feat(api): comprehensive QA hardening — security gates, chat improvements, beta scaffolds
Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07, B-01..B-07, R-01..R-02, O-03. Security (28 deletions + 10 auth gates): - Delete 28 unauthenticated debug/cursor/firebase/test routes - Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth - Add HMAC-SHA256 signature verification to webhooks/coolify - Switch all admin secret comparisons to timingSafeStringEq Foundations (lib/server/*): - api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit - logger.ts: structured request-scoped logging with turnId - audit-log.ts: writeAuditLog helper + audit_log table - rate-limit.ts: Postgres sliding window rate limiter - coolify-webhook.ts: verifyCoolifySignature - timing-safe.ts: timingSafeStringEq Chat hardening (chat/route.ts): - MAX_TOOL_ROUNDS 15 → 8 (C-01) - Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02) - Add 6-consecutive-tool-call hard-break (C-02) - Mode: respond first, act second prompt block (C-03) - SSE heartbeat every 25s via setInterval (C-04) - Per-tool 45s timeout via Promise.race (C-05) - turnId per-turn UUID for log correlation (C-06) - Recovery fires when roundsSinceText >= 4 (C-07) - SSE plan event on plan_task_add/edit (B-05) Beta features: - invites table + GET/POST /api/invites (P4.8) - invites/[token] validate + redeem (P4.8) - fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1) - fs_project_secrets table + CRUD routes (P6.D2) - lib/integrations/brief-extract.ts (P3.7) Documentation: - app/api/ROUTES.md: full route map with auth + tenant
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* DELETE /api/projects/[projectId]/secrets/[key]
|
||||
* GET /api/projects/[projectId]/secrets/[key] — reveal (decrypted)
|
||||
*
|
||||
* Reveal is intentionally project-scoped (user must own the project).
|
||||
* Never log the plaintext value.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db-postgres";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { decryptSecret } from "@/lib/auth/secret-box";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
export const DELETE = withTenantProject(
|
||||
async (_req, ctx, { project }) => {
|
||||
const { key } = await (ctx.params as Promise<{ projectId: string; key: string }>);
|
||||
await query(
|
||||
`DELETE FROM fs_project_secrets WHERE project_id = $1 AND key = $2`,
|
||||
[project.id, key],
|
||||
);
|
||||
log.info("project secret deleted", {
|
||||
route: "api.projects.secrets.delete",
|
||||
projectId: project.id,
|
||||
key,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
export const GET = withTenantProject(
|
||||
async (_req, ctx, { project }) => {
|
||||
const { key } = await (ctx.params as Promise<{ projectId: string; key: string }>);
|
||||
const row = await queryOne<{ value_enc: string }>(
|
||||
`SELECT value_enc FROM fs_project_secrets WHERE project_id = $1 AND key = $2`,
|
||||
[project.id, key],
|
||||
);
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: "Secret not found" }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const value = decryptSecret(row.value_enc);
|
||||
return NextResponse.json({ key, value });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Decryption failed — VIBN_SECRETS_KEY may have rotated" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
91
vibn-frontend/app/api/projects/[projectId]/secrets/route.ts
Normal file
91
vibn-frontend/app/api/projects/[projectId]/secrets/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Project-level encrypted secret scratchpad.
|
||||
* Closes BETA_LAUNCH_PLAN P6.D2.
|
||||
*
|
||||
* GET /api/projects/[projectId]/secrets — list key names only (never values)
|
||||
* POST /api/projects/[projectId]/secrets — set/update a secret { key, value }
|
||||
*
|
||||
* Values are encrypted at-rest with AES-256-GCM via the existing
|
||||
* `lib/auth/secret-box.ts` (same envelope used for workspace API keys and
|
||||
* Gitea bot PATs). The plaintext value is NEVER returned by the list route.
|
||||
* Use a dedicated GET /secrets/[key] route (below) if you need to surface
|
||||
* it to the AI.
|
||||
*
|
||||
* Table created lazily on first write.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { encryptSecret } from "@/lib/auth/secret-box";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
let tableReady = false;
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS fs_project_secrets (
|
||||
project_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_enc TEXT NOT NULL, -- AES-256-GCM encrypted, base64
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (project_id, key)
|
||||
)
|
||||
`);
|
||||
await query(
|
||||
`CREATE INDEX IF NOT EXISTS fs_project_secrets_project_idx ON fs_project_secrets (project_id)`,
|
||||
);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
/** GET — returns key names only, never values. */
|
||||
export const GET = withTenantProject(
|
||||
async (_req, _ctx, { project }) => {
|
||||
await ensureTable();
|
||||
const rows = await query<{ key: string; updated_at: string }>(
|
||||
`SELECT key, updated_at FROM fs_project_secrets
|
||||
WHERE project_id = $1 ORDER BY key`,
|
||||
[project.id],
|
||||
);
|
||||
return NextResponse.json({ secrets: rows });
|
||||
},
|
||||
);
|
||||
|
||||
/** POST — upsert a secret. Body: { key: string, value: string } */
|
||||
export const POST = withTenantProject(
|
||||
async (req, _ctx, { project }) => {
|
||||
const body = await req.json().catch(() => ({})) as {
|
||||
key?: string;
|
||||
value?: string;
|
||||
};
|
||||
if (!body.key || typeof body.key !== "string") {
|
||||
return NextResponse.json({ error: "key is required" }, { status: 400 });
|
||||
}
|
||||
if (typeof body.value !== "string") {
|
||||
return NextResponse.json({ error: "value is required" }, { status: 400 });
|
||||
}
|
||||
if (body.key.length > 200) {
|
||||
return NextResponse.json(
|
||||
{ error: "key must be ≤200 chars" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await ensureTable();
|
||||
const valueEnc = encryptSecret(body.value);
|
||||
await query(
|
||||
`INSERT INTO fs_project_secrets (project_id, key, value_enc)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (project_id, key) DO UPDATE SET
|
||||
value_enc = EXCLUDED.value_enc,
|
||||
updated_at = NOW()`,
|
||||
[project.id, body.key.trim(), valueEnc],
|
||||
);
|
||||
log.info("project secret upserted", {
|
||||
route: "api.projects.secrets.post",
|
||||
projectId: project.id,
|
||||
key: body.key,
|
||||
});
|
||||
return NextResponse.json({ ok: true, key: body.key });
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user