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
91 lines
2.7 KiB
TypeScript
91 lines
2.7 KiB
TypeScript
/**
|
|
* POST /api/invites
|
|
*
|
|
* Admin-only. Creates an invite token and returns the invite URL.
|
|
* Closes BETA_LAUNCH_PLAN P4.8.
|
|
*
|
|
* Body: { email?: string, note?: string, maxUses?: number }
|
|
* Auth: Bearer ADMIN_MIGRATE_SECRET
|
|
*
|
|
* curl -X POST https://vibnai.com/api/invites \
|
|
* -H "x-admin-secret: $ADMIN_MIGRATE_SECRET" \
|
|
* -d '{"email":"friend@example.com","note":"beta tester"}'
|
|
*/
|
|
import { NextResponse } from "next/server";
|
|
import { query } from "@/lib/db-postgres";
|
|
import { withAdminSecret } from "@/lib/server/api-handler";
|
|
import { randomBytes } from "crypto";
|
|
|
|
let tableReady = false;
|
|
async function ensureTable() {
|
|
if (tableReady) return;
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS invites (
|
|
token TEXT PRIMARY KEY,
|
|
email TEXT,
|
|
note TEXT,
|
|
max_uses INT NOT NULL DEFAULT 1,
|
|
use_count INT NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ,
|
|
redeemed_by TEXT[] NOT NULL DEFAULT '{}'
|
|
)
|
|
`);
|
|
await query(`CREATE INDEX IF NOT EXISTS invites_email_idx ON invites (email) WHERE email IS NOT NULL`);
|
|
tableReady = true;
|
|
}
|
|
|
|
export const POST = withAdminSecret(
|
|
async (request) => {
|
|
await ensureTable();
|
|
const body = await request.json().catch(() => ({})) as {
|
|
email?: string;
|
|
note?: string;
|
|
maxUses?: number;
|
|
expiresInDays?: number;
|
|
};
|
|
|
|
const token = randomBytes(20).toString("hex");
|
|
const maxUses = Math.max(1, Math.min(100, body.maxUses ?? 1));
|
|
const expiresAt = body.expiresInDays
|
|
? new Date(Date.now() + body.expiresInDays * 86_400_000).toISOString()
|
|
: null;
|
|
|
|
await query(
|
|
`INSERT INTO invites (token, email, note, max_uses, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[token, body.email ?? null, body.note ?? null, maxUses, expiresAt],
|
|
);
|
|
|
|
const baseUrl =
|
|
process.env.NEXT_PUBLIC_APP_URL ||
|
|
process.env.NEXTAUTH_URL ||
|
|
"https://vibnai.com";
|
|
|
|
return NextResponse.json({
|
|
ok: true,
|
|
token,
|
|
inviteUrl: `${baseUrl}/auth?invite=${token}`,
|
|
email: body.email ?? null,
|
|
maxUses,
|
|
expiresAt,
|
|
});
|
|
},
|
|
{ secretEnvVar: "ADMIN_MIGRATE_SECRET", altHeader: "x-admin-secret" },
|
|
);
|
|
|
|
export const GET = withAdminSecret(
|
|
async (request) => {
|
|
await ensureTable();
|
|
const { searchParams } = new URL(request.url);
|
|
const limit = Math.min(200, parseInt(searchParams.get("limit") ?? "50", 10));
|
|
const rows = await query(
|
|
`SELECT token, email, note, max_uses, use_count, created_at, expires_at, redeemed_by
|
|
FROM invites ORDER BY created_at DESC LIMIT $1`,
|
|
[limit],
|
|
);
|
|
return NextResponse.json({ invites: rows });
|
|
},
|
|
{ secretEnvVar: "ADMIN_MIGRATE_SECRET", altHeader: "x-admin-secret" },
|
|
);
|