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:
2026-05-17 19:17:22 -07:00
parent 955aeed6ce
commit 6b8862ef2b
86 changed files with 6772 additions and 2817 deletions

1
deploy_error.txt Normal file

File diff suppressed because one or more lines are too long

1
deploy_logs.json Normal file

File diff suppressed because one or more lines are too long

127
docs/API_QA_CHECKLIST.md Normal file
View File

@@ -0,0 +1,127 @@
# API QA Checklist
> Comprehensive enhancement list for `vibn-frontend/app/api/` derived from the
> 2026-05-17 QA pass. Anchored to `BETA_LAUNCH_PLAN.md`.
>
> **Convention:** each item has an ID like `S-01` (Security), `A-01` (Auth/Arch),
> `B-01` (Beta blocker), `C-01` (Chat/AI pipeline), `R-01` (Reliability),
> `D-01` (Deletion/cleanup), `O-01` (Code Org). Tick the box as you ship.
---
## Phase 1 — Foundations (`lib/server/*`)
- [x] **F-01** `lib/server/api-handler.ts``withAuth`, `withTenantProject`, `withWorkspace`, `withAdminSecret` route wrappers. Every new route uses these instead of reimplementing the auth dance.
- [x] **F-02** `lib/server/logger.ts` — structured logger that takes `{turnId, projectId, route, userId}` and routes to `console.*` in dev, Sentry breadcrumb in prod.
- [x] **F-03** `lib/server/audit-log.ts``writeAuditLog({workspace, user, action, resourceType, resourceId, params, ok})` helper + migration for `audit_log` table.
- [x] **F-04** `lib/server/rate-limit.ts` — Postgres-backed sliding window. Default: 60 req/min per user per route. Per-route override via opts.
- [x] **F-05** `lib/server/coolify-webhook.ts` — verifyCoolifySignature(body, signature, secret). Mirrors `verifyWebhookSignature` from `lib/gitea.ts`.
- [x] **F-06** `lib/server/timing-safe.ts``timingSafeStringEq(a, b)` helper wrapping `crypto.timingSafeEqual` for every admin-secret bearer check.
---
## Phase 2 — Deletions (security cleanup)
These are unauthenticated routes that read/write tenant data using only a URL `projectId`. Delete them now; if anything legitimate calls one, we'll find out fast and reintroduce it under `withTenantProject`.
- [x] **D-01** `app/api/debug/cursor-analysis` — Firestore dump
- [x] **D-02** `app/api/debug/cursor-content-sample`
- [x] **D-03** `app/api/debug/cursor-conversations`
- [x] **D-04** `app/api/debug/cursor-relevant`
- [x] **D-05** `app/api/debug/cursor-sample-dates`
- [x] **D-06** `app/api/debug/cursor-session-summary`
- [x] **D-07** `app/api/debug/cursor-sessions`
- [x] **D-08** `app/api/debug/cursor-stats`
- [x] **D-09** `app/api/debug/cursor-unknown-sessions`
- [x] **D-10** `app/api/debug/cursor-workspaces`
- [x] **D-11** `app/api/debug/append-conversation`
- [x] **D-12** `app/api/debug/check-links`
- [x] **D-13** `app/api/debug/check-project`
- [x] **D-14** `app/api/debug/context-sources`
- [x] **D-15** `app/api/debug/env` — leaks env-var presence
- [x] **D-16** `app/api/debug/first-project`
- [x] **D-17** `app/api/debug/knowledge`
- [x] **D-18** `app/api/debug/knowledge-items`
- [x] **D-19** `app/api/debug/prisma`
- [x] **D-20** `app/api/cursor/backfill` — comment says "TEMPORARY: no auth required"
- [x] **D-21** `app/api/cursor/clear-imports` — same
- [x] **D-22** `app/api/cursor/tag-sessions` — same
- [x] **D-23** `app/api/firebase/test` — writes/deletes Firestore on every call, no auth
- [x] **D-24** `app/api/sentry-example-api` — always throws; dev-only fixture
- [x] **D-25** `app/api/test-token` — server-side `auth.currentUser` (broken pattern)
- [x] **D-26** `app/api/diagnose` — info-discloses env vars + verifies arbitrary tokens
- [x] **D-27** `app/api/admin/check-sessions` — no auth, named `/admin/`
- [x] **D-28** `app/api/admin/fix-project-workspace` — no auth, accepts any project
---
## Phase 3 — Auth gates + hardening on the remaining unauthenticated routes
- [x] **S-01** `app/api/ai/chat` — wrap in `withTenantProject('projectId')`. Currently anyone can chat as any project.
- [x] **S-02** `app/api/ai/conversation` (GET, DELETE) — same.
- [x] **S-03** `app/api/ai/conversation/reset` — same.
- [x] **S-04** `app/api/context/summarize` — wrap in `withAuth`. No tenant scope needed; just stop unauth Gemini quota burn.
- [x] **S-05** `app/api/work-completed` — wrap in `withTenantProject('projectId')` and remove the literal-`1` fallback.
- [x] **S-06** `app/api/webhooks/coolify` — verify signature against `COOLIFY_WEBHOOK_SECRET` using `verifyCoolifySignature`. Reject on mismatch.
- [x] **S-07** `app/api/admin/migrate` — switch `secret !== incoming` to `timingSafeStringEq(secret, incoming)`.
- [x] **S-08** `app/api/admin/path-b/{disable,enable,idle-sweep,autosave}` — same.
- [x] **S-09** `app/api/admin/path-b/route.ts` — same.
- [x] **S-10** `app/api/internal/infra-health` — same.
---
## Phase 4 — Chat / AI pipeline hardening
`app/api/chat/route.ts` and `lib/ai/*` enhancements.
- [x] **C-01** Lower `MAX_TOOL_ROUNDS` from 15 to 8.
- [x] **C-02** Tighten loop detection: hard-break at 3 identical fingerprints (was 5); add an absolute cap of 6 consecutive tool calls with no intervening assistant text.
- [x] **C-03** Add "Mode: respond first, act second" block at the top of `buildSystemPrompt` (above the existing Identity section).
- [x] **C-04** SSE heartbeat: emit `{type:"ping"}` every 25s while the loop is running (cleared on `safeClose` / `cancel`).
- [x] **C-05** `executeMcpTool` timeout: wrap each tool invocation in `Promise.race([exec, timeout(45_000)])`; surface as `tool_timeout` SSE event.
- [x] **C-06** `turnId`: generate a `crypto.randomUUID()` per chat turn; include in every log line and the first SSE chunk so we can correlate prod issues.
- [x] **C-07** Recovery-summary trigger expansion: also fire when the AI emitted no text for ≥4 rounds (not just on tool failure / round cap / loop break).
- [ ] **C-08** Deprecate `app/api/ai/chat`. Add `Deprecation: true` header + log line; redirect callers to `/api/chat` over 30 days, then delete. *(skipped this pass — needs migration tracking)*
---
## Phase 5 — Beta gaps from `BETA_LAUNCH_PLAN.md`
Each maps to a checked task in the plan that's not yet implemented in the API surface.
- [x] **B-01 (P4.7)** `audit_log` table + writes from every mutating MCP tool in `app/api/mcp/route.ts` (`apps_create`, `apps_delete`, `apps_deploy`, `databases_create`, `databases_delete`, `domains_register`, `secrets_set`, `ship`).
- [x] **B-02 (P4.8)** Invite/waitlist endpoints: `POST /api/invites` (admin-only, creates token), `GET /api/invites/[token]` (validates), `POST /api/invites/[token]/redeem` (consumes on signup).
- [x] **B-03 (P6.B1)** `fs_project_dev_servers` table migration + `dev_server_start` MCP tool hook to upsert on success.
- [ ] **B-04 (P6.B2)** Auto-resume hook on project page mount. *(scaffolded; full wiring deferred since it touches the project layout page, which is outside `/api`)*
- [x] **B-05 (P6.C1)** SSE `plan` event protocol in `app/api/chat/route.ts` — emit `{type:"plan", taskId, text, status}` whenever `plan_task_add` / `plan_task_edit` fires within a turn.
- [x] **B-06 (P6.D2)** `fs_project_secrets` table + `POST /api/projects/[id]/secrets`, `GET /api/projects/[id]/secrets` (keys-only), `DELETE /api/projects/[id]/secrets/[key]`. Encrypted via existing `lib/crypto.ts` pattern.
- [x] **B-07 (P3.7)** `project_brief` MCP tool stub + extraction scaffold in `lib/integrations/brief-extract.ts`. Wired into `buildSystemPrompt` as `[PROJECT BRIEF]` block when `fs_projects.data.plan.brief` is non-empty.
- [ ] **B-08 (P2.5)** Per-request Sentry span+release annotation in every handler. *(deferred — needs Sentry SDK pattern audit across the codebase)*
---
## Phase 6 — Reliability & observability
- [x] **R-01** Adopt `lib/server/logger.ts` in `app/api/chat/route.ts` (highest-traffic route).
- [x] **R-02** Rate-limit `/api/chat`, `/api/context/summarize`, `/api/extension/link-project`, `/api/admin/migrate`.
- [ ] **R-03** Idempotency keys on webhook receivers (`(event_id, project_id)` unique constraint). *(deferred — Coolify event payload schema needs research)*
- [ ] **R-04** Per-tool cost/token accounting table `chat_costs`. *(deferred — needs pricing strategy)*
---
## Phase 7 — Code organization
- [ ] **O-01** Refactor the 8 highest-traffic routes onto `withAuth` / `withTenantProject` / `withWorkspace`. *(seeded with examples; bulk refactor deferred)*
- [ ] **O-02** Decompose `app/api/chat/route.ts` (1088 lines) into `lib/server/chat-{prompt,tool-loop,recovery,sse}.ts`. *(deferred — non-blocking refactor)*
- [x] **O-03** `app/api/ROUTES.md` — enumerate every route with `auth`, `tenant`, purpose.
- [ ] **O-04** Continue extracting MCP `toolXxx()` into `lib/mcp/tools/*.ts`. *(deferred — non-blocking)*
---
## How to use this doc
- Tick a box only when the change is committed AND the unit/smoke test passes.
- Items marked `(deferred — …)` are intentional cuts so this lands as one
reviewable batch. Re-open them in `AI_CAPABILITIES.md` after beta.
- Each phase commit message should reference the IDs it closes, e.g.
`feat(api): F-01..F-06 lib/server foundations`.

6
docs/for-entrepenuers.md Normal file
View File

@@ -0,0 +1,6 @@
For Entrepreneurs Who Build for Small BusinessesYou can build a business without ever picking up the phone.Most entrepreneurship advice is written by extroverts, for extroverts.Get out there. Network. Cold-call. Pitch in person. Build your personal brand. Post on LinkedIn every day. Hand out business cards. Show up at events. Hustle.If any of that drains you on contact, congratulations — every entrepreneurship podcast, book, and course has spent the last decade quietly telling you that you don't have what it takes.They were wrong.There's another way to build a real business. Quiet. Behind the curtain. Powered by the parts of you that already work — your taste, your craft, your patience, your ability to disappear into a problem for eight hours and emerge with something useful.That way is finally viable. It wasn't before. It is now.The shiftFor most of business history, you needed people skills to find customers. Cold outreach. Networking. Conferences. Sales calls. The work of getting in front of buyers required showing up as yourself, repeatedly, in rooms full of strangers. If that drained you, you had a real disadvantage.AI changes the math. The work of finding customers — the market research, the targeting, the outreach, the content, the social presence — can now be handled by software. Not faked. Not spammed. Done well, at scale, without you ever having to be the loud person in the room.That doesn't make introverts viable for the first time. It makes them advantaged for the first time. Because while the extroverts are spending their day in meetings and on networking calls, you can spend yours actually building the thing.The patient, careful, behind-the-curtain builder has always been undervalued. AI just made them powerful.What you can buildSmall business is the right place to start. It's underserved, it's enormous, and it doesn't require you to be a household name. You don't need to "go viral." You don't need a personal brand. You don't need an audience.You need a small business that has a problem, a tool that solves it, and a way for them to find you. Vibn handles two of those.You build the tool. Vibn finds the customers.A few things people are building:
A vertical SaaS for one type of small business — a booking and customer-management tool built specifically for tattoo studios, dog groomers, or small accounting practices
A custom-build practice where you build one-off systems for local small businesses, hand them over, and get paid once per project
A productized service — pick a single specific problem ("I'll build your shop a custom booking system in a week, $X flat") and run it as a quiet, profitable little machine
A tool for a niche you already know — if you used to work in restaurants, build for restaurants; if you used to do the books for trades, build for trades. Your prior life is your market research.
The unifying idea: small, specific, useful tools for small, specific businesses. Not the next billion-dollar SaaS. A real business that pays you well and that you actually enjoy running.How Vibn does the parts you don't want to doYou don't have to be the salesperson for your own business. You don't have to be the marketer, the social media manager, or the person dialing for dollars.Market research. Vibn helps you find what small businesses in a given niche actually need — the problems they're posting about, the tools they're complaining about, the gaps in their stack. You don't have to go to industry conferences to find this out. The signal is already online; Vibn surfaces it.Customer discovery. Vibn helps you find your first 100 customers through our Google partnership. Real businesses with real problems, identified for you. You decide who to reach out to (if you want to) or let the system do the outreach (if you don't).Content and social, on autopilot. Vibn writes, schedules, and posts your marketing across whatever channels matter for your niche. You don't have to be a "thought leader." You don't have to film yourself. Your business has a presence; you don't.You stay in the build. While the system does the parts that drain you, you do the parts that energize you — building the tool, refining it, talking to the small number of customers you actually want to talk to. The work matches the wiring.What this looks like in practicePick a niche. Maybe one you already know, or one you've quietly observed from a distance. Open Vibn. Describe what you want to build — say, a custom client portal for independent therapy practices, or a job-tracking tool for solo electricians, or a reservations system for small wineries.Vibn helps you research the niche, build the tool, host it, set up logins, and find your first customers. You spend your days in the part you love — shaping the product, talking (in writing, mostly) to the few customers who matter most, making it better.You don't need a co-founder. You don't need a team. You don't need an office, a network, a personal brand, or a podcast.You need a quiet room, a problem worth solving, and Vibn.Why introverts are about to have a momentFor a long time, "build a business" has meant "build a public version of yourself."It doesn't have to anymore.The new generation of small, profitable, sustainable businesses won't be built by founders posting on Twitter all day. They'll be built by quiet operators who pick a niche, build the right tool, let the system handle the noisy parts, and serve their customers carefully for years.You'll never see them on a "30 under 30" list. They'll be doing better than the people on the list.You can be one of them.Your role in the missionSmall business needs a generation of new builders — people who will quietly, carefully build the custom software that small businesses have needed for two decades and never gotten. Not in San Francisco. Not at scale. Not for a Series A. In every town, by every kind of builder, including the kind who'd rather not be the face of anything.You're who we built Vibn for.It's okay to be the person behind the curtain. The work still counts. The business is still real. The impact is still yours.Let's go build it. Quietly.[ Start building → ]Free to start · No credit card · Built in Canada

58
docs/for-freelancers.md Normal file
View File

@@ -0,0 +1,58 @@
For Freelancers (rewritten)
You're the craftsman of the AI economy.
Every small business in your town is running on a stack of eight to fifteen tools that don't fit, don't talk, and don't work the way the business actually runs.
The owner is gluing it together with spreadsheets and their own time. They're paying every month — forever — for software that was built for somebody else.
They don't need another integration. They don't need another dashboard. They need one tool, built for their business.
You're who builds it.
The opportunity nobody is serving
For twenty years, custom software has been out of reach for small business. Building the right tool meant hiring a developer, paying $50,000, and waiting six months — for a business doing $400k a year. Nobody could afford it. So small businesses got herded into off-the-shelf SaaS that almost-but-not-quite fit, and you watched it happen.
That's over.
With AI doing the heavy lifting, a single freelancer can deliver in a week what used to take a dev team months. The full system that runs a small business — purpose-built, custom-fit, owned by the client. Not a plugin. Not a dashboard. The actual software the business runs on.
The work is real. The market is enormous. And almost nobody is doing it yet.
Why this is the work to be doing
You can keep grinding for SaaS companies. You can build features nobody asked for, write marketing landing pages on contract, and wait for the next round of cuts.
Or you can walk into the bakery on your block, the dentist's office across the street, the bookkeeper one neighborhood over, and offer them the thing they've quietly wanted for years: the software their business has been trying to be built around.
You'd be the most valuable person they know.
Small business owners are not picky. They are hungry. They have been paying for software that doesn't fit for so long they've stopped imagining it could be different. They have been waiting — without knowing they were waiting — for someone like you to show up.
You're who shows up.
What the work actually looks like
You meet a local business. You learn how they run — what tools they use, what those tools don't do, what they're working around with spreadsheets. You describe what they need to Vibn. The AI builds it. You shape it, refine it, polish it, hand it over.
The most common projects:
A complete front-of-house system for a salon, med-spa, or studio — bookings, customer notes, packages, schedules, payments, marketing — replacing four or five subscriptions with one custom build
A custom shop management system for a trade business — jobs, crew, scheduling, quotes, invoices, customer history — built for how that specific business runs
A unified client portal for a service business — bookings, invoices, communications, document sharing, reviews — branded to the client, owned by the client
A full studio management system — classes, members, packages, attendance, marketing — purpose-built for one studio's exact model
A back-office operating system for a small operation that's outgrown spreadsheets but is never going to be big enough for "real" enterprise software
Custom-built replacements for the SaaS subscriptions that almost work — rebuilt to fit perfectly, no monthly rent, owned forever
You're not building features. You're building the system that runs the business.
A new kind of business
This is not contract dev work. It's not agency work. It's something else.
You're not selling time. You're selling outcomes. You're not building specs. You're sitting with a small business owner, hearing how they actually run things, and shaping software that fits them like a tailored suit. You hand over the keys when you're done. The client owns it forever. You get paid once, well, and move on.
Vibn does the work of an engineering team. You do the work of understanding the customer, finding the shape of their business, and building the tool that fits it. That's the part the AI can't do — yet, maybe ever. It's the part where craft lives.
The skill that matters now is taste. Listening to an owner describe their day, hearing what they don't say out loud, and recognizing the exact shape of the tool they've been needing. That's a craft. That's something to build a career on.
How you make this a business
A few things freelancers are doing well:
Pick a niche. Trades. Med-spas. Restaurants. Service businesses. Each niche has a recognizable shape — once you've built three systems for barbershops, the fourth takes a week.
Look for the spreadsheet. Every small business has the spreadsheet — the one they use because their real software can't do what they need. That spreadsheet is the brief. Whatever it does is the system you're building.
Charge for the outcome, not the hours. A custom system that replaces four SaaS subscriptions and fits the business perfectly is worth $5,000$15,000 to the owner, regardless of how long it takes you. Don't price the work — price the result.
Build local, build a portfolio. Three businesses in your town becomes a case study. A case study becomes referrals. Referrals become a full pipeline. You don't need to scale beyond your community to make a great living.
Your role in the mission
Small business has been underserved by software for two decades — not because nobody could build the right tools, but because the math never worked. AI changes the math.
The fix isn't another SaaS company. The fix is a new generation of local builders who can deliver the actual software a small business should be running on. Custom-fit. Hand-delivered. Owned by the business.
That's you.
You're not just building tools. You're rebuilding the economics of small business software, one business at a time, in your community.
That's a real career. A real craft. A real way to spend the next ten years.
The work is here. The businesses are waiting. Let's go build.
[ Start your first project → ]
Free to start · No credit card · Built in Canada

57
docs/for-smbs.md Normal file
View File

@@ -0,0 +1,57 @@
For Small Business Owners (rewritten)
This is your golden age.
Look at how your business runs right now.
A booking tool over here. An invoicing tool over there. A separate CRM. A point-of-sale system that doesn't quite know about either of them. An accounting add-on. A scheduling app. A customer feedback tool. A loyalty platform. A marketing thing your last consultant set up that you can't remember the login for.
And underneath all of it — the spreadsheet. The one you actually trust. The one you've been using for years to keep track of what your "real" software can't.
Eight tools, none of them built for you, none of them talking to each other. You're the one holding it all together.
It was never supposed to work this way.
What changed
For twenty years, the only choice small businesses had was to rent software built for somebody else. Each tool covered a slice of the business. None of them covered your business. You stitched them together because there was no other option.
There's another option now.
You can replace your entire stack — every tool that doesn't fit, every subscription that doesn't earn its keep, every spreadsheet you use to glue them together — with one tool, built for your business. Not configured for it. Not customized for it. Built for it.
Booking, scheduling, invoicing, customers, inventory, reporting — all in one place, designed around how you actually run things. Your terminology. Your workflow. Your rules.
This used to require an engineering team and six figures. Now it requires you, an idea, and a few afternoons. Or a local builder who can do it for you.
You own it. Forever. No subscription. No vendor lock-in. No price hikes next year.
Start where the pain is
Pull up your bank statement and find the monthly subscription line.
For every tool you pay for, ask one question:
"Is this actually doing the job for my business today?"
The booking tool that almost works the way your shop runs. The CRM that doesn't quite handle your kind of customer. The invoicing software that fits a Shopify store but not your store. The inventory tool that's too complicated for what you actually need. The reporting dashboard that gives you everything except the number you actually want to see.
Each one of those is a tool you're renting that doesn't fit.
Now imagine all of them gone, replaced by one system that does fit — built around the way you actually work, owned by your business, no monthly rent.
That's what Vibn is for.
What you can build
You don't need to be technical. You describe what your business does and what you need it to do. The AI builds it. It puts it online, sets up logins for your team and your customers, and helps you actually get people using it.
A few of the things small business owners are building:
A single tool that runs the whole front-of-house — bookings, customer notes, scheduling, payments, follow-ups — replacing four or five subscriptions with one custom system
A custom shop management tool built around your trade, your jobs, your crew — replacing the generic field-service software that never quite worked
A complete client portal where customers book, pay, see their history, leave reviews, and refer friends — in one place, branded to your business, not a generic SaaS
An end-to-end studio system — classes, members, schedules, packages, attendance, marketing — purpose-built for your studio, not the average one
A unified back-office system that handles invoicing, expenses, payroll prep, and reporting in one place, the way your bookkeeper actually thinks about it
A small-format ERP — yes, really — for a business that's outgrown spreadsheets but never going to be big enough for SAP
These are not integrations. These are not dashboards. These are the actual tools that run the business, built once, owned forever.
You own it. Forever.
This is the part the SaaS industry doesn't want you to think about.
Every month you pay for software, you're renting. You're not building anything that's yours. When they raise the price, you pay. When they change the features, you adapt. When they get acquired or shut down, you lose your workflow and your data.
The tool you build with Vibn is yours. Your business owns it. Your data lives in it. You don't pay rent on it every month. It works the way you work, because you built it the way you work.
This is what software should have always been for small business.
What if you don't want to build it yourself?
You don't have to.
A new kind of professional is emerging — local builders who specialize in building custom Vibn systems for the small businesses in their community. They speak your language. They understand small business. They hand you the keys when they're done.
You hire them once. You own the tool forever. No subscription. No vendor lock-in.
[ Find a builder in your area ]
You are why this exists
Small business is the backbone of every neighborhood, every economy, every community worth being part of. You deserve software that fits your business — not a generic version of somebody else's. You deserve to own the tools that run your livelihood.
That's what Vibn is here for.
This is your golden age. Let's build it.
[ Start building free → ]
No credit card · Free to start · Built in Canada

43
docs/mission.md Normal file
View File

@@ -0,0 +1,43 @@
Our Mission
"Look at your subscription costs, ask if they're doing the job, if not, get building."
A letter from the founder
I've spent the last ten years trying to help small businesses grow.
Startups. Clubs. Plumbers. Bookkeepers. Family restaurants. Single-location retail. The businesses that make neighborhoods what they are. I've watched they struggle and stagnate.
Here's what I learned: the biggest thing holding small businesses back isn't the economy, or competition, or marketing. It's the owner. Their hesitations. Their (very reasonable) skepticism of change. Most small business owners are run off their feet — they don't have the time or appetite to adopt new tools, new systems, new anything. And when someone tries to sell them on change, they bristle. They've been burned too many times.
But I've also learned something else. When an owner makes an the idea theirs, they adopt it instantly.
That's what AI changes. For the first time, software can have a real conversation with a small business owner. It can listen to their problem in their words, propose a solution that feels obvious to them, and build it on the spot. The owner isn't being sold to. They're being heard. And when an idea is theirs, the resistance disappears.
That's the unlock. That's why this moment matters.
Why small business never got the software it deserved
People love to blame SaaS for squeezing small business. I don't think that's quite right.
The real story is structural. For the last two decades, the math of venture capital pushed every promising software company toward enterprise. It wasn't a conspiracy — it was incentives. Small business has high churn. Enterprise has multi-year contracts. LPs expect returns on a fund timeline that small business revenue can't deliver. So founders with great ideas for small business software kept getting nudged upmarket — by their investors, their boards, their boards' investors — until eventually the SMB version of every product was an afterthought, and the real product was built for a 500-person finance team.
Small business got the leftovers. Monthly subscriptions for software that almost-but-not-quite solves the problem.
Nobody set out to underserve small business. The system just didn't reward serving them well. That gap — the gap between what small businesses actually needed and what got built — is the gap Vibn fills.
Why now
There's a wave coming. AI is going to displace a lot of people — software engineers especially, but knowledge workers across the board. The doom narrative says this is the end of opportunity. I think it's the opposite.
Small business is where the next generation of careers gets built.
Not as a consolation prize. As an upgrade. Owning the bakery instead of writing code for a company that sells software to bakeries. Building custom tools for the plumber down the street instead of building dashboards for a Series C SaaS. Working at a thriving local business that owns its own software, instead of grinding through layoffs at companies that don't know what they want to be.
For laid-off engineers, this is a place to land — and not a small one. The same skills that built SaaS for the enterprise can build extraordinary things for small businesses now that the tools exist. For young entrepreneurs, this is the cheapest, fastest, most legitimate path to running a real business that has ever existed. For everyone who wants to help small businesses thrive, this is the moment to do it.
What Vibn is, really
Vibn is a vibe coding platform. That's the surface.
Underneath, it's a wager: that if you make it possible for a small business to have custom software — built by the owner, or by a local freelancer who hands it over — without subscriptions, without endless tool sprawl, without code, without engineering teams — you start to fix something that's been broken for twenty years.
Owners build tools and own them outright. Freelancers build custom solutions for their community and get paid like the craftsmen they are. Subscriptions get cancelled. Margins go back to the businesses earning them. Software stops being something small businesses rent forever and starts being something they own.
That's the golden age. Not abstract. Concrete. One business at a time. One tool at a time.
What you can do right now
If you own a small business: pull up your bank statement and look at the subscription line. Look at every tool you pay for every month. Ask one question — is this actually doing the job for my business today? If the answer is no, even partially, you should be building.
If you're an engineer who got laid off, or never got the job: there is real work here. Real businesses that need real tools. You don't need a startup, a co-founder, or a Series A. You need one local small business and a willingness to build them exactly what they need.
If you're an entrepreneur looking for a wave to ride: this is it. The tools are here. The customers are here. The moment is here.
We built Vibn for all of you.
Let's build the golden age.
— Mark Henderson
Founder, Vibn

1
env_data.json Normal file

File diff suppressed because one or more lines are too long

28
grep_out.txt Normal file

File diff suppressed because one or more lines are too long

1
latest_deploy.txt Normal file
View File

@@ -0,0 +1 @@
[{"command":null,"output":"Docker 27.0.3 with BuildKit and Buildx detected on deployment server (localhost).","type":"stdout","timestamp":"2026-05-17T20:48:41.186732Z","hidden":false,"batch":1},{"command":null,"output":"Starting deployment of https:\/\/git.vibnai.com\/mark\/vibn-frontend.git:main to localhost.","type":"stdout","timestamp":"2026-05-17T20:48:41.236729Z","hidden":false,"batch":1,"order":2}]

View File

@@ -0,0 +1,45 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Onboarding</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="assets/logo-black.png" />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="onboarding.css" />
<template id="__bundler_thumbnail" data-bg-color="#27201d">
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#27201d"/>
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#e9835d"/>
<stop offset="1" stop-color="#b53a25"/>
</linearGradient>
</defs>
<circle cx="100" cy="100" r="58" fill="url(#g)"/>
<g fill="#1a0f0a" stroke="#1a0f0a" stroke-width="2" stroke-linejoin="round">
<path d="M82 78 L94 78 L98 110 L102 78 L114 78 L102 130 Z"/>
<rect x="118" y="120" width="14" height="5" rx="1"/>
</g>
</svg>
</template>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="primitives.jsx"></script>
<script type="text/babel" src="onboarding-primitives.jsx"></script>
<script type="text/babel" src="onboarding-fork.jsx"></script>
<script type="text/babel" src="onboarding-entrepreneur.jsx"></script>
<script type="text/babel" src="onboarding-owner.jsx"></script>
<script type="text/babel" src="onboarding-consultant.jsx"></script>
<script type="text/babel" src="onboarding-build.jsx"></script>
<script type="text/babel" src="onboarding-app.jsx"></script>
</body>
</html>

28
new-site/Onboarding.html Normal file
View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vibn — Onboarding</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="assets/logo-black.png" />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="onboarding.css" />
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="primitives.jsx"></script>
<script type="text/babel" src="onboarding-primitives.jsx"></script>
<script type="text/babel" src="onboarding-fork.jsx"></script>
<script type="text/babel" src="onboarding-entrepreneur.jsx"></script>
<script type="text/babel" src="onboarding-owner.jsx"></script>
<script type="text/babel" src="onboarding-consultant.jsx"></script>
<script type="text/babel" src="onboarding-build.jsx"></script>
<script type="text/babel" src="onboarding-app.jsx"></script>
</body>
</html>

181
new-site/onboarding-app.jsx Normal file
View File

@@ -0,0 +1,181 @@
// Root onboarding app — owns the route state and the answers dict.
// Routes: fork → <path> → build → ready. A floating debug navigator (toggle
// in the lower-right) lets reviewers jump between any screen without
// filling out the form.
function OnboardingApp() {
const initialName = React.useMemo(() => {
try { return localStorage.getItem("vibn:firstName") || ""; } catch { return ""; }
}, []);
const [stage, setStage] = React.useState("fork"); // fork | path | build | ready
const [path, setPath] = React.useState(null); // entrepreneur | owner | consultant
const [forkChoice, setForkChoice] = React.useState(null);
const [step, setStep] = React.useState(0);
const [data, setData] = React.useState({});
const [debugOpen, setDebugOpen] = React.useState(false);
const update = (patch) => setData((d) => ({ ...d, ...patch }));
// ── transitions ──────────────────────────────────────────────────────
const confirmFork = () => {
if (!forkChoice) return;
setPath(forkChoice);
setStep(0);
setStage("path");
};
const backToFork = () => { setStage("fork"); setStep(0); };
const completePath = () => setStage("build");
const openWorkspace = () => setStage("ready");
const close = () => { window.location.href = "index.html"; };
const openChat = () => { window.location.href = "index.html"; };
// ⌘↵ advances on whatever the current primary action is
React.useEffect(() => {
const handler = (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
const btn = document.querySelector(".btn-primary:not([disabled])");
if (btn) btn.click();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
// ── render ───────────────────────────────────────────────────────────
let body;
if (stage === "fork") {
body = (
<ForkScreen
name={initialName}
value={forkChoice}
onChange={setForkChoice}
onClose={close}
onNext={confirmFork}
/>
);
} else if (stage === "path") {
const props = {
data, onUpdate: update,
onBack: backToFork,
onClose: close,
onComplete: completePath,
onJumpToStep: setStep,
step,
};
if (path === "entrepreneur") body = <EntrepreneurPath {...props} />;
else if (path === "owner") body = <OwnerPath {...props} />;
else body = <ConsultantPath {...props} />;
} else if (stage === "build") {
body = (
<BuildScreen
path={path} data={data}
onBack={() => setStage("path")}
onClose={close}
onOpen={openWorkspace}
/>
);
} else {
body = <ReadyScreen path={path} data={data} onClose={close} onOpenChat={openChat} />;
}
return (
<div className="app">
{body}
<DebugNav
open={debugOpen} setOpen={setDebugOpen}
stage={stage} path={path} step={step}
onJump={(s, p, idx) => {
if (s === "fork") setStage("fork");
else if (s === "build") { setPath(p); setStage("build"); }
else if (s === "ready") { setPath(p); setStage("ready"); }
else { setPath(p); setStep(idx); setStage("path"); }
}}
/>
</div>
);
}
// ── Debug navigator ──────────────────────────────────────────────────────
function DebugNav({ open, setOpen, stage, path, step, onJump }) {
const groups = [
{ title: "Start", rows: [
{ label: "01 · Fork", active: stage === "fork", go: () => onJump("fork") },
]},
{ title: "Entrepreneur", rows: [
{ label: "02 · Idea", active: stage === "path" && path === "entrepreneur" && step === 0, go: () => onJump("path", "entrepreneur", 0) },
{ label: "03 · Audience", active: stage === "path" && path === "entrepreneur" && step === 1, go: () => onJump("path", "entrepreneur", 1) },
{ label: "04 · Goal", active: stage === "path" && path === "entrepreneur" && step === 2, go: () => onJump("path", "entrepreneur", 2) },
{ label: "05 · Vibe", active: stage === "path" && path === "entrepreneur" && step === 3, go: () => onJump("path", "entrepreneur", 3) },
]},
{ title: "Owner", rows: [
{ label: "02 · Business", active: stage === "path" && path === "owner" && step === 0, go: () => onJump("path", "owner", 0) },
{ label: "03 · Stack", active: stage === "path" && path === "owner" && step === 1, go: () => onJump("path", "owner", 1) },
{ label: "04 · First fix", active: stage === "path" && path === "owner" && step === 2, go: () => onJump("path", "owner", 2) },
{ label: "05 · Scale", active: stage === "path" && path === "owner" && step === 3, go: () => onJump("path", "owner", 3) },
]},
{ title: "Consultant", rows: [
{ label: "02 · Client", active: stage === "path" && path === "consultant" && step === 0, go: () => onJump("path", "consultant", 0) },
{ label: "03 · Brief", active: stage === "path" && path === "consultant" && step === 1, go: () => onJump("path", "consultant", 1) },
{ label: "04 · Scope", active: stage === "path" && path === "consultant" && step === 2, go: () => onJump("path", "consultant", 2) },
{ label: "05 · Handoff", active: stage === "path" && path === "consultant" && step === 3, go: () => onJump("path", "consultant", 3) },
]},
{ title: "Finish", rows: [
{ label: "Build · entrepreneur", active: stage === "build" && path === "entrepreneur", go: () => onJump("build", "entrepreneur") },
{ label: "Build · owner", active: stage === "build" && path === "owner", go: () => onJump("build", "owner") },
{ label: "Build · consultant", active: stage === "build" && path === "consultant", go: () => onJump("build", "consultant") },
{ label: "Ready", active: stage === "ready", go: () => onJump("ready", path || "entrepreneur") },
]},
];
return (
<div className="debug">
{open && (
<div className="debug-panel">
{groups.map((g) => (
<React.Fragment key={g.title}>
<div style={{
fontFamily: "var(--font-mono)",
fontSize: 9.5,
color: "var(--fg-faint)",
letterSpacing: "0.14em",
textTransform: "uppercase",
padding: "8px 8px 4px",
}}>{g.title}</div>
{g.rows.map((r) => (
<button
key={r.label}
type="button"
className={"debug-row" + (r.active ? " active" : "")}
onClick={r.go}
>
{r.active && <b> </b>}{r.label}
</button>
))}
</React.Fragment>
))}
<button
type="button"
className="debug-row"
onClick={() => setOpen(false)}
style={{ marginTop: 8, justifyContent: "center", color: "var(--fg-mute)" }}
>
Close
</button>
</div>
)}
<button
type="button"
className="debug-toggle"
onClick={() => setOpen((o) => !o)}
title="Designer navigator"
>
<span style={{ color: "var(--accent)", marginRight: 6 }}></span>
{stage === "path" ? `${path} · step ${step + 1}` : stage}
</button>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<OnboardingApp />);

View File

@@ -0,0 +1,445 @@
// Build + Ready screens. The build screen shows the terminal stream + a live
// preview stencil; ready is a quiet confirmation page with the workspace URL.
// ── Per-path build plans ───────────────────────────────────────────────────
function buildPlanFor(path, data) {
const common = [
{ line: "vibn init — reading brief", ms: 600 },
{ line: "↳ provisioning workspace", ms: 700 },
{ line: "↳ wiring auth (email + Google)", ms: 800 },
{ line: "↳ minting database & seed schema", ms: 700 },
];
if (path === "entrepreneur") {
return [
...common,
{ line: `↳ generating landing page · vibe "${data.vibe || "warm"}"`, ms: 900 },
{ line: `↳ writing copy aimed at "${(data.audience || "your audience").slice(0, 40)}"`, ms: 800 },
{ line: "↳ wiring email capture + Stripe payment link", ms: 700 },
{ line: "↳ scaffolding admin: subscribers, sales, comments", ms: 800 },
{ line: `↳ tuning launch plan for goal: ${data.goal || "first_customer"}`, ms: 700 },
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
{ line: "ready.", ms: 400, ok: true },
];
}
if (path === "owner") {
return [
...common,
{ line: `↳ modelling ${data.biz || "small business"} for "${data.bizName || "your business"}"`, ms: 800 },
{ line: `↳ importing your stack (${(data.tools || []).length} tools)`, ms: 800 },
{ line: `↳ building module: ${labelFor(data.firstThing)}`, ms: 1000 },
{ line: "↳ generating customer + job records (10 sample)", ms: 700 },
{ line: "↳ scheduling daily ops view + weekly report", ms: 700 },
{ line: `↳ wiring savings tracker · est. $${(data.spend || 0)}/mo replaced`, ms: 800 },
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
{ line: "ready.", ms: 400, ok: true },
];
}
return [
...common,
{ line: `↳ branding workspace for "${data.clientName || "client"}"`, ms: 800 },
{ line: `↳ scaffolding scope (${(data.scope || []).length} modules)`, ms: 1000 },
...(data.scope || []).slice(0, 4).map((s) => ({ line: `${s}`, ms: 350 })),
{ line: "↳ generating handoff document + invoice template", ms: 700 },
{ line: `↳ setting deploy target: ${data.handoff || "subdomain"}`, ms: 700 },
{ line: "↳ publishing preview → " + workspaceUrlFor(path, data), ms: 900 },
{ line: "ready.", ms: 400, ok: true },
];
}
function labelFor(id) {
const map = {
booking: "Bookings & scheduling",
invoicing: "Quotes & invoices",
customers: "Customer portal",
inventory: "Inventory & orders",
team: "Team & dispatch",
marketing: "Marketing site",
};
return map[id] || "your first workflow";
}
function workspaceUrlFor(path, data) {
const seed =
(path === "owner" && data.bizName) ||
(path === "consultant" && data.clientName) ||
(path === "entrepreneur" && (data.audience || data.idea)) ||
"your-workspace";
const slug = String(seed).toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.split(/\s+/)
.slice(0, 3)
.join("-")
.slice(0, 28) || "your-workspace";
return `${slug}.vibn.app`;
}
const BUILD_BIZ_LABEL = {
service: "Trades / services",
retail: "Retail",
food: "Food & drink",
appointments: "Appointments",
events: "Events / hospitality",
other: "Small business",
};
// ── Build screen ───────────────────────────────────────────────────────────
function BuildScreen({ path, data, onBack, onClose, onOpen }) {
const plan = React.useMemo(() => buildPlanFor(path, data), [path, data]);
const [lineIdx, setLineIdx] = React.useState(0);
const [done, setDone] = React.useState(false);
const logRef = React.useRef(null);
React.useEffect(() => {
if (lineIdx >= plan.length) { setDone(true); return undefined; }
const t = setTimeout(() => setLineIdx(lineIdx + 1), plan[lineIdx].ms);
return () => clearTimeout(t);
}, [lineIdx, plan]);
React.useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [lineIdx]);
const url = workspaceUrlFor(path, data);
const lane = LANE_LABELS[path];
const previewTitle =
path === "owner" ? (data.bizName || "Your business") :
path === "consultant" ? (data.clientName || "Your client") :
"Your launch page";
const previewSub =
path === "owner" ? `${BUILD_BIZ_LABEL[data.biz] || "Small business"} · ${labelFor(data.firstThing)}` :
path === "consultant" ? (data.industry || "Project") :
(data.audience || "An idea worth building").slice(0, 64);
const pct = done ? 1 : lineIdx / plan.length;
return (
<>
<WizardTop
onBack={onBack}
onClose={onClose}
lane={lane}
stepText={done ? "Done" : "Building"}
progress={pct}
/>
<main className="wiz-body" style={{ paddingTop: "clamp(28px, 5vh, 56px)" }}>
<div className="wiz-card xwide" style={{ gap: 18 }}>
<style>{`
.b-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
gap: 18px;
}
@media (max-width: 920px) { .b-grid { grid-template-columns: 1fr; } }
.b-pane {
border-radius: 12px;
border: 1px solid var(--hairline);
background: linear-gradient(180deg, oklch(0.16 0.008 60 / 0.92), oklch(0.14 0.008 60 / 0.92));
min-height: 420px;
display: flex; flex-direction: column;
overflow: hidden;
}
.b-pane-bar {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--hairline);
background: oklch(0.16 0.008 60 / 0.6);
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-mute);
letter-spacing: 0.04em;
}
.b-pane-bar .live {
margin-left: auto;
display: inline-flex; align-items: center; gap: 6px;
color: oklch(0.85 0.16 155);
font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase;
}
.b-pane-bar .live::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: oklch(0.78 0.16 155);
box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6);
animation: pulse 2s ease-out infinite;
}
.b-log {
flex: 1;
padding: 14px 16px;
font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.7;
color: var(--fg-dim);
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.b-log .l-ok { color: oklch(0.85 0.16 155); font-weight: 500; }
.b-log .l-current { color: var(--fg); }
.b-log .l-done { color: var(--fg-mute); }
.b-log .l-done::before { content: "✓ "; color: var(--ok); margin-right: 2px; }
.b-log .l-current::before { content: "● "; color: var(--accent); margin-right: 2px;
animation: blink 1s steps(2) infinite; }
.b-cursor {
display: inline-block;
width: 7px; height: 13px; vertical-align: -2px;
background: var(--accent);
margin-left: 2px;
animation: blink 1s steps(2) infinite;
box-shadow: 0 0 12px var(--accent-glow);
}
.b-prev-bar {
padding: 10px 14px;
display: flex; gap: 10px; align-items: center;
border-bottom: 1px solid var(--hairline);
background: oklch(0.16 0.008 60 / 0.5);
}
.b-prev-url {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--fg-mute);
padding: 4px 12px;
background: oklch(0.13 0.008 60);
border: 1px solid var(--hairline);
border-radius: 999px;
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.b-prev-stage {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 22px;
}
.b-stencil {
width: 100%;
display: flex; flex-direction: column; gap: 12px;
opacity: 0;
animation: stencil-in 0.7s ease-out forwards;
}
@keyframes stencil-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.b-stencil-block {
border-radius: 9px;
background: linear-gradient(135deg, oklch(0.22 0.011 60), oklch(0.18 0.009 60));
border: 1px solid var(--hairline);
position: relative; overflow: hidden;
}
.b-stencil-block::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.06), transparent);
transform: translateX(-100%);
animation: shimmer 2s ease-in-out infinite;
}
@keyframes shimmer { to { transform: translateX(100%); } }
@keyframes blink { 50% { opacity: 0.25; } }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0.6); }
70% { box-shadow: 0 0 0 8px oklch(0.78 0.16 155 / 0); }
100% { box-shadow: 0 0 0 0 oklch(0.78 0.16 155 / 0); }
}
.b-foot {
display: flex; align-items: center; justify-content: space-between;
gap: 14px;
padding-top: 6px;
}
.b-foot-status {
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.06em;
color: var(--fg-faint);
}
.b-foot-status b { color: var(--accent); font-weight: 500; }
`}</style>
<WizardQ
title={done ? "Your workspace is live." : "Building your workspace…"}
sub={done
? "Open it to see what Vibn made. Every change from here happens in the chat."
: "Nothing for you to do. Vibn is scaffolding everything from your answers."}
/>
<div className="b-grid">
{/* terminal */}
<div className="b-pane">
<div className="b-pane-bar">
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.65 0.18 25 / 0.7)" }} />
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.78 0.13 80 / 0.7)" }} />
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.72 0.16 145 / 0.7)" }} />
<span style={{ marginLeft: 6 }}>vibn build {url}</span>
{!done && <span className="live">live</span>}
</div>
<div className="b-log" ref={logRef}>
{plan.slice(0, lineIdx + 1).map((p, i) => {
const isCurrent = i === lineIdx && !done;
const isOk = p.ok && i <= lineIdx;
const cls = isOk ? "l-ok" : isCurrent ? "l-current" : "l-done";
return <div key={i} className={cls}>{p.line}</div>;
})}
{!done && <span className="b-cursor" aria-hidden="true" />}
</div>
</div>
{/* preview */}
<div className="b-pane">
<div className="b-prev-bar">
<span style={{ display: "flex", gap: 5 }}>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: "oklch(0.30 0.010 60)" }}/>
</span>
<span className="b-prev-url">https://{url}</span>
</div>
<div className="b-prev-stage">
<div className="b-stencil">
<div style={{ fontSize: 20, fontWeight: 500, letterSpacing: "-0.02em", color: "var(--fg)", lineHeight: 1.15 }}>
{previewTitle}
</div>
<div style={{ fontSize: 13, color: "var(--fg-mute)", lineHeight: 1.45 }}>
{previewSub}
</div>
<div style={{ height: 4 }} />
<div className="b-stencil-block" style={{ height: 80 }} />
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<div className="b-stencil-block" style={{ height: 60, animationDelay: "0.4s" }} />
<div className="b-stencil-block" style={{ height: 60, animationDelay: "0.8s" }} />
</div>
</div>
</div>
</div>
</div>
<div className="b-foot">
<span className="b-foot-status">
{done ? "build complete" : <>Building <b>{Math.min(lineIdx, plan.length)}/{plan.length}</b></>}
</span>
{done && (
<button type="button" className="btn btn-primary btn-wiz" onClick={onOpen}>
Open my workspace <Arrow size={13} />
</button>
)}
</div>
</div>
</main>
</>
);
}
// ── Ready screen ───────────────────────────────────────────────────────────
function ReadyScreen({ path, data, onClose, onOpenChat }) {
const url = workspaceUrlFor(path, data);
const summary = summaryFor(path, data);
return (
<>
<WizardTop
onClose={onClose}
lane={LANE_LABELS[path]}
stepText="Ready"
progress={1}
/>
<WizardBody>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 8 }}>
<span
style={{
width: 32, height: 32, borderRadius: 8,
background: "linear-gradient(135deg, var(--accent), oklch(0.65 0.20 18))",
boxShadow: "0 0 18px var(--accent-glow)",
display: "grid", placeItems: "center",
color: "var(--accent-fg)", flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
</span>
</div>
<WizardQ
title="You're in."
sub="Workspace provisioned, first build is online. Every change from here happens in the chat."
/>
<div
style={{
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
overflow: "hidden",
}}
>
<div
style={{
padding: "14px 16px",
borderBottom: "1px solid var(--hairline)",
display: "flex", alignItems: "center", gap: 10,
}}
>
<span className="mono" style={{ fontSize: 10.5, color: "var(--fg-faint)", letterSpacing: "0.12em", textTransform: "uppercase" }}>
Workspace URL
</span>
<span className="mono" style={{ fontSize: 14, color: "var(--fg)", marginLeft: "auto" }}>
{url}
</span>
</div>
<div style={{ padding: "12px 16px 14px", display: "flex", flexDirection: "column", gap: 8 }}>
{summary.map((row, i) => (
<div key={i} style={{ display: "flex", gap: 14, alignItems: "baseline", fontSize: 13.5 }}>
<span
className="mono"
style={{
width: 86, flexShrink: 0,
color: "var(--fg-faint)",
fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase",
}}
>
{row.label}
</span>
<span style={{ color: "var(--fg-dim)" }}>{row.value}</span>
</div>
))}
</div>
</div>
<div className="wiz-foot">
<a href="index.html" className="wiz-skip">Back to home</a>
<button type="button" className="btn btn-primary btn-wiz" onClick={onOpenChat}>
Open the build chat <Arrow size={13} />
</button>
</div>
</WizardBody>
</>
);
}
function summaryFor(path, data) {
if (path === "owner") {
return [
{ label: "Business", value: `${data.bizName || "Untitled"} · ${BUILD_BIZ_LABEL[data.biz] || "Small business"}` },
{ label: "Replacing", value: `${(data.tools || []).length} tools · ~$${data.spend || 0}/mo` },
{ label: "First fix", value: labelFor(data.firstThing) },
{ label: "Team", value: `${data.team || 1} · ${(data.customers || 0).toLocaleString()} cust/mo` },
];
}
if (path === "consultant") {
return [
{ label: "Client", value: `${data.clientName || "Untitled"} · ${data.industry || ""}` },
{ label: "Scope", value: `${(data.scope || []).length} modules` },
{ label: "Brief", value: (data.brief || "").slice(0, 60) + ((data.brief || "").length > 60 ? "…" : "") },
{ label: "Handoff", value: data.handoff || "subdomain" },
];
}
return [
{ label: "Building", value: (data.idea || "").slice(0, 64) + ((data.idea || "").length > 64 ? "…" : "") },
{ label: "Audience", value: (data.audience || "").slice(0, 64) },
{ label: "Goal", value: data.goal || "first_customer" },
{ label: "Vibe", value: data.vibe || "warm" },
];
}
Object.assign(window, { BuildScreen, ReadyScreen });

View File

@@ -0,0 +1,294 @@
// Consultant path — 4 steps for freelancers building for a client.
const CONS_TOTAL = 4;
const CONS_STEP_NAMES = ["Client", "Brief", "Scope", "Handoff"];
function ConsClient({ clientName, industry, contact, onChange }) {
return (
<>
<WizardQ
title="Who are you building for?"
sub="Used to brand the preview and the handoff doc."
/>
<Field label="Client / company">
<input
className="wiz-input"
placeholder="Rivera & Co Roofing"
value={clientName}
onChange={(e) => onChange({ clientName: e.target.value })}
autoFocus
/>
</Field>
<Field label="What they do">
<input
className="wiz-input"
placeholder="Residential roofing, mostly insurance jobs"
value={industry}
onChange={(e) => onChange({ industry: e.target.value })}
/>
</Field>
<Field label="Your point of contact" optional hint="So Vibn can address them in the handoff.">
<input
className="wiz-input"
placeholder="Marisol Rivera, Owner"
value={contact}
onChange={(e) => onChange({ contact: e.target.value })}
/>
</Field>
</>
);
}
const BRIEF_TEMPLATES = [
{ id: "quote_tool", label: "Quote tool",
body: "Customers request a quote with a few photos and a project description. The team reviews, sends a polished PDF, customer signs and pays a deposit online." },
{ id: "booking", label: "Booking system",
body: "Customers see real availability, book a service window, and get reminders. The team has a daily view of jobs with addresses and contact info." },
{ id: "portal", label: "Customer portal",
body: "Logged-in customers see past jobs, invoices, documents, and can message the business. The business sees a single page per customer." },
{ id: "internal", label: "Internal ops",
body: "Replace the spreadsheets the team is currently using. CRUD on jobs/customers, simple reports, role-based access, export to accounting." },
];
function ConsBrief({ brief, onChange }) {
return (
<>
<WizardQ
title="What did they ask for?"
sub="Paste the brief, or start from a template and edit."
/>
<Field label="Start from a template" optional>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{BRIEF_TEMPLATES.map((t) => (
<button
key={t.id}
type="button"
className="preset-chip"
style={{ padding: "8px 14px", fontSize: 13 }}
onClick={() => onChange(t.body)}
>
{t.label}
</button>
))}
</div>
</Field>
<Field label="Brief">
<textarea
className="wiz-input"
style={{ minHeight: 160 }}
placeholder="The client wants…"
value={brief}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
</>
);
}
const SCOPE_GROUPS = [
{ label: "Foundations", items: ["Auth & accounts", "Roles & permissions", "Database & admin", "Hosting & domain"] },
{ label: "Customer-facing", items: ["Marketing site", "Booking / scheduling", "Quote requests", "Customer portal", "Self-serve forms"] },
{ label: "Money", items: ["Stripe / payments", "Invoices & receipts", "Subscriptions", "Refunds & disputes"] },
{ label: "Ops", items: ["Dashboards & reports", "CSV import / export", "Email + SMS notifs", "Audit log"] },
];
function ConsScope({ scope, onChange }) {
const toggle = (item) => {
if (scope.includes(item)) onChange(scope.filter((x) => x !== item));
else onChange([...scope, item]);
};
return (
<>
<WizardQ
title="What's in scope?"
sub="Tick what you've signed up to deliver. The rest you can add later — billable."
/>
<div
style={{
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(2, 1fr)",
}}
>
{SCOPE_GROUPS.map((g) => (
<div
key={g.label}
style={{
padding: "14px 14px 10px",
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
}}
>
<div
className="mono"
style={{
fontSize: 10.5, letterSpacing: "0.12em", textTransform: "uppercase",
color: "var(--fg-mute)", marginBottom: 10,
}}
>
{g.label}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{g.items.map((it) => {
const active = scope.includes(it);
return (
<button
key={it}
type="button"
onClick={() => toggle(it)}
style={{
display: "flex", alignItems: "center", gap: 10,
textAlign: "left",
padding: "6px 4px",
color: active ? "var(--fg)" : "var(--fg-dim)",
borderRadius: 6,
fontSize: 13.5,
}}
>
<span
style={{
width: 16, height: 16,
flexShrink: 0,
borderRadius: 4,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
background: active ? "var(--accent)" : "transparent",
color: "var(--accent-fg)",
display: "grid", placeItems: "center",
}}
>
{active && (
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
)}
</span>
{it}
</button>
);
})}
</div>
</div>
))}
</div>
</>
);
}
function ConsHandoff({ data, onChange }) {
return (
<>
<WizardQ
title="And finally — delivery."
sub="Where it lives, how you bill. Change later from settings."
/>
<Field label="Brand colors" optional hint="Hex, or just describe it. Paste a link, drop a screenshot — Vibn will figure it out.">
<input
className="wiz-input"
placeholder="#1B4D3E and #F2E2C4 — matches their truck wraps"
value={data.brand || ""}
onChange={(e) => onChange({ brand: e.target.value })}
/>
</Field>
<Field label="Where should it live?">
<PresetGroup
options={[
{ id: "subdomain", label: "Vibn subdomain", desc: "client-name.vibn.app — fastest." },
{ id: "custom", label: "Their custom domain", desc: "We'll walk you through DNS." },
{ id: "transfer", label: "Transfer ownership", desc: "They own it. You stay billable as editor." },
]}
value={data.handoff}
onChange={(v) => onChange({ handoff: v })}
columns={1}
/>
</Field>
<Field label="Your hourly rate" optional hint="Used on the handoff doc & invoice template.">
<div style={{ position: "relative" }}>
<span
className="mono"
style={{
position: "absolute", left: 14, top: "50%", transform: "translateY(-50%)",
color: "var(--fg-faint)", fontSize: 14.5, pointerEvents: "none",
}}
>$</span>
<input
className="wiz-input"
type="number"
min="0"
placeholder="120"
value={data.rate || ""}
onChange={(e) => onChange({ rate: e.target.value })}
style={{ paddingLeft: 26, paddingRight: 58 }}
/>
<span
className="mono"
style={{
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
color: "var(--fg-faint)", fontSize: 11.5, letterSpacing: "0.04em",
pointerEvents: "none",
}}
>/ hour</span>
</div>
</Field>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
function ConsultantPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < CONS_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext;
if (step === 0) {
body = (
<ConsClient
clientName={data.clientName || ""}
industry={data.industry || ""}
contact={data.contact || ""}
onChange={onUpdate}
/>
);
canNext = (data.clientName || "").trim().length >= 2 && (data.industry || "").trim().length >= 3;
} else if (step === 1) {
body = <ConsBrief brief={data.brief || ""} onChange={(v) => onUpdate({ brief: v })} />;
canNext = (data.brief || "").trim().length >= 30;
} else if (step === 2) {
body = <ConsScope scope={data.scope || []} onChange={(v) => onUpdate({ scope: v })} />;
canNext = (data.scope || []).length >= 2;
} else {
body = <ConsHandoff data={data} onChange={onUpdate} />;
canNext = !!data.handoff;
}
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.consultant}
stepText={CONS_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 2 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === CONS_TOTAL - 1 ? "Spin up project →" : "Continue"}
hint={canNext ? "⌘↵" : null}
/>
</WizardBody>
</>
);
}
Object.assign(window, { ConsultantPath, CONS_TOTAL });

View File

@@ -0,0 +1,274 @@
// Entrepreneur path — 4 steps. Each step is a focused question.
const ENTREP_TOTAL = 4;
const ENTREP_STEP_NAMES = ["Idea", "Audience", "Goal", "Look"];
const IDEA_PROMPTS = [
"A community for indie game devs to swap playtesters, with weekly demo nights",
"An AI tool that turns my handwritten recipe notes into a clean cookbook for my family",
"A waitlist + scheduler for my pottery studio — small classes, six people max",
"A subscription box service for cold-brew enthusiasts, with monthly tasting cards",
"A simple tool that turns my Strava data into framed art prints I can sell",
];
function EntrepIdea({ value, onChange }) {
const [phIdx, setPhIdx] = React.useState(0);
const [phChars, setPhChars] = React.useState(0);
const [deleting, setDeleting] = React.useState(false);
React.useEffect(() => {
if (value.length > 0) return undefined;
const full = IDEA_PROMPTS[phIdx];
const speed = deleting ? 18 : 38;
const t = setTimeout(() => {
if (!deleting) {
if (phChars < full.length) setPhChars(phChars + 1);
else setTimeout(() => setDeleting(true), 1500);
} else {
if (phChars > 0) setPhChars(phChars - 1);
else { setDeleting(false); setPhIdx((phIdx + 1) % IDEA_PROMPTS.length); }
}
}, speed);
return () => clearTimeout(t);
}, [value, phIdx, phChars, deleting]);
return (
<>
<WizardQ
title="What are you building?"
sub="One paragraph is enough. Talk like you would to a friend."
/>
<div style={{ position: "relative" }}>
<textarea
className="wiz-input"
style={{ minHeight: 140, fontSize: 15 }}
value={value}
onChange={(e) => onChange(e.target.value)}
autoFocus
aria-label="Describe your idea"
/>
{value.length === 0 && (
<div
style={{
position: "absolute", top: 12, left: 14, right: 14,
pointerEvents: "none",
color: "var(--fg-faint)",
font: "14.5px/1.5 var(--font-sans)",
}}
>
{IDEA_PROMPTS[phIdx].slice(0, phChars)}
<span
style={{
display: "inline-block",
width: 7, height: 14, verticalAlign: "-2px",
background: "var(--accent)", marginLeft: 1,
animation: "blink 1s steps(2) infinite",
boxShadow: "0 0 10px var(--accent-glow)",
}}
/>
</div>
)}
</div>
<div
className="mono"
style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.06em", marginTop: -16 }}
>
{value.length} chars · be specific where it matters
</div>
</>
);
}
const AUDIENCE_PRESETS = [
"Me and people like me",
"A small community I'm part of",
"Local people in my city",
"Anyone searching for this",
"Other small businesses",
"Hobbyists in a niche I love",
];
function EntrepAudience({ value, onChange }) {
const isPreset = AUDIENCE_PRESETS.includes(value);
return (
<>
<WizardQ
title="Who is it for?"
sub="The clearer your audience, the better the copy Vibn writes for it."
/>
<ChipGroup
options={AUDIENCE_PRESETS}
values={value ? [value] : []}
onChange={(arr) => onChange(arr[arr.length - 1] || "")}
/>
<Field label="Or describe them in your own words" optional>
<input
className="wiz-input"
placeholder="e.g. dog owners in Brooklyn who walk before work"
value={!isPreset ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
</>
);
}
const GOALS = [
{ id: "first_customer", icon: "🎯", label: "First real customer",
desc: "Someone I don't know pays me. Even once." },
{ id: "ten_users", icon: "👥", label: "Ten weekly users",
desc: "A signal the thing actually does something useful." },
{ id: "mrr_1k", icon: "📈", label: "$1k MRR",
desc: "Enough to take it seriously." },
{ id: "side_quit", icon: "🚪", label: "Replace my day job",
desc: "The long road. Make this the main thing." },
{ id: "audience", icon: "📣", label: "Build a tiny audience",
desc: "200 emails, a community, something I can talk to." },
{ id: "ship_it", icon: "🚀", label: "Just ship it",
desc: "I want the thing to exist." },
];
function EntrepGoal({ value, onChange }) {
return (
<>
<WizardQ
title="What does “working” look like?"
sub="Helps Vibn decide what to build first — a landing page that converts, or a tool that retains."
/>
<PresetGroup
options={GOALS.map((g) => ({
id: g.id, label: g.label, desc: g.desc,
icon: <span style={{ fontSize: 14 }}>{g.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
</>
);
}
const VIBES = [
{ id: "warm", name: "Warm coral", swatch: "linear-gradient(135deg, #E27855, #B33B2A)",
desc: "Confident, hand-built, warm." },
{ id: "ink", name: "Ink & paper", swatch: "linear-gradient(135deg, #1d1d1d, #4a4a4a)",
desc: "Editorial, serif, quiet." },
{ id: "sage", name: "Sage matte", swatch: "linear-gradient(135deg, #7BA890, #3F6B57)",
desc: "Calm, modern, slightly herbal." },
{ id: "neon", name: "Neon arcade", swatch: "linear-gradient(135deg, #5B6CFF, #FF3DDB)",
desc: "Loud, fun, late-night." },
{ id: "cream", name: "Cream linen", swatch: "linear-gradient(135deg, #F2E7D5, #C9A977)",
desc: "Cozy and beige." },
{ id: "later", name: "Decide later", swatch: "repeating-linear-gradient(45deg, oklch(0.30 0.010 60), oklch(0.30 0.010 60) 6px, oklch(0.22 0.010 60) 6px, oklch(0.22 0.010 60) 12px)",
desc: "Vibn picks one that fits." },
];
function EntrepVibe({ value, onChange }) {
return (
<>
<WizardQ
title="Pick a starting vibe."
sub="Every color and font is a tweak away once the site is live."
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 10,
}}
>
{VIBES.map((v) => {
const active = value === v.id;
return (
<button
key={v.id}
type="button"
onClick={() => onChange(v.id)}
style={{
padding: "10px 10px 10px",
borderRadius: 11,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
textAlign: "left",
color: "var(--fg)",
display: "flex", flexDirection: "column", gap: 8,
transition: "border-color .15s, background .15s",
}}
>
<span
style={{
height: 52, borderRadius: 7,
background: v.swatch,
border: "1px solid oklch(1 0 0 / 0.08)",
boxShadow: "inset 0 1px 0 oklch(1 0 0 / 0.18)",
}}
/>
<span style={{ fontSize: 13, fontWeight: 500, letterSpacing: "-0.005em" }}>
{v.name}
</span>
<span style={{ fontSize: 11.5, color: "var(--fg-mute)", lineHeight: 1.4 }}>
{v.desc}
</span>
</button>
);
})}
</div>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
function EntrepreneurPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < ENTREP_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext, onSkip = null;
if (step === 0) {
body = <EntrepIdea value={data.idea || ""} onChange={(v) => onUpdate({ idea: v })} />;
canNext = (data.idea || "").trim().length >= 8;
} else if (step === 1) {
body = <EntrepAudience value={data.audience || ""} onChange={(v) => onUpdate({ audience: v })} />;
canNext = (data.audience || "").trim().length >= 3;
} else if (step === 2) {
body = <EntrepGoal value={data.goal} onChange={(v) => onUpdate({ goal: v })} />;
canNext = !!data.goal;
} else {
body = <EntrepVibe value={data.vibe} onChange={(v) => onUpdate({ vibe: v })} />;
canNext = !!data.vibe;
onSkip = () => { onUpdate({ vibe: "later" }); next(); };
}
// 5 total: fork(1) + 4 path steps
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.entrepreneur}
stepText={ENTREP_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 2 || step === 3 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === ENTREP_TOTAL - 1 ? "Build →" : "Continue"}
hint={canNext ? "⌘↵" : null}
onSkip={onSkip}
skipLabel="Pick for me"
/>
</WizardBody>
</>
);
}
Object.assign(window, { EntrepreneurPath, ENTREP_TOTAL });

View File

@@ -0,0 +1,134 @@
// Step 1: the only branching question — "which describes you?"
// Quiet radio-style cards. No quotes, no marketing, no glow theatrics.
const FORKS = [
{
id: "entrepreneur",
label: "I'm building my own thing",
hint: "Idea → live → first customer. You're the founder.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="9" cy="9" r="3"/>
<path d="M9 2.5v2M9 13.5v2M2.5 9h2M13.5 9h2"/>
</svg>
),
},
{
id: "owner",
label: "I run a business",
hint: "Replace the stack of tools you currently rent.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 6h12l-1 9H4L3 6Z"/>
<path d="M6 6V4.5a3 3 0 0 1 6 0V6"/>
</svg>
),
},
{
id: "consultant",
label: "I build for clients",
hint: "A workspace per client. Bill for the system, not the hours.",
icon: (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2.5 15 9 3l6.5 12"/>
<path d="M5.5 12h7"/>
</svg>
),
},
];
function ForkScreen({ name, value, onChange, onClose, onNext }) {
return (
<>
<WizardTop
onBack={null}
onClose={onClose}
stepText="Pick your lane"
current={1}
total={5}
/>
<WizardBody>
<WizardQ
title={name ? `Welcome, ${name}. Which sounds like you?` : "Which one sounds like you?"}
sub="Vibn asks different questions on the next screens depending on the answer. You can change this later."
/>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{FORKS.map((f) => {
const active = value === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => onChange(f.id)}
onDoubleClick={() => { onChange(f.id); onNext(); }}
style={{
display: "flex", alignItems: "center", gap: 14,
padding: "14px 16px",
borderRadius: 12,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
textAlign: "left",
color: "var(--fg)",
transition: "border-color .15s, background .15s",
cursor: "pointer",
}}
>
<span style={{
width: 36, height: 36, flexShrink: 0,
borderRadius: 9,
background: active ? "oklch(0.74 0.175 35 / 0.18)" : "oklch(0.22 0.011 60)",
border: "1px solid var(--hairline)",
color: active ? "var(--accent)" : "var(--fg-mute)",
display: "grid", placeItems: "center",
}}>
{f.icon}
</span>
<span style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1 }}>
<span style={{ fontSize: 15, fontWeight: 500, letterSpacing: "-0.008em" }}>
{f.label}
</span>
<span style={{ fontSize: 13, color: "var(--fg-mute)", lineHeight: 1.4 }}>
{f.hint}
</span>
</span>
<span
style={{
width: 18, height: 18, flexShrink: 0,
borderRadius: "50%",
border: `1.5px solid ${active ? "var(--accent)" : "var(--hairline-2)"}`,
background: active ? "var(--accent)" : "transparent",
display: "grid", placeItems: "center",
color: "var(--accent-fg)",
transition: "border-color .15s, background .15s",
}}
>
{active && (
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
)}
</span>
</button>
);
})}
</div>
<WizardFooter
canNext={!!value}
onNext={onNext}
nextLabel="Continue"
hint={value ? "Press ⌘↵" : null}
/>
</WizardBody>
</>
);
}
Object.assign(window, { ForkScreen });

View File

@@ -0,0 +1,262 @@
// Owner path — 4 steps for small-business owners replacing their stack.
const OWNER_TOTAL = 4;
const OWNER_STEP_NAMES = ["Business", "Stack", "First fix", "Scale"];
const BIZ_KINDS = [
{ id: "service", icon: "🛠", label: "Trades / home services", desc: "Plumbing, HVAC, landscaping, cleaning" },
{ id: "retail", icon: "🛍", label: "Retail / shop", desc: "Vintage, boutique, market, online" },
{ id: "food", icon: "🥐", label: "Food & drink", desc: "Café, bakery, food truck, catering" },
{ id: "appointments", icon: "💈", label: "Appointment-based", desc: "Salon, studio, clinic, tutoring" },
{ id: "events", icon: "🎟", label: "Events / hospitality", desc: "Venue, rental, planning" },
{ id: "other", icon: "✦", label: "Something else", desc: "We'll learn from your answers" },
];
function OwnerBiz({ value, name, onChange, onNameChange }) {
return (
<>
<WizardQ
title="What does your business do?"
sub="Roughly. We tailor the next screens to match."
/>
<PresetGroup
options={BIZ_KINDS.map((b) => ({
id: b.id, label: b.label, desc: b.desc,
icon: <span style={{ fontSize: 14 }}>{b.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
<Field label="Business name">
<input
className="wiz-input"
placeholder="Sunrise Plumbing, Pearl Lane Bakery…"
value={name}
onChange={(e) => onNameChange(e.target.value)}
/>
</Field>
</>
);
}
const STACK_TOOLS = [
"Square / POS",
"Stripe",
"Calendly",
"Acuity",
"Shopify",
"QuickBooks",
"Mailchimp",
"Instagram",
"Google Sheets",
"Notion / Airtable",
"Wix / Squarespace",
"WhatsApp / Slack",
"A printed binder",
"Head + notepad",
];
function OwnerStack({ tools, spend, onToolsChange, onSpendChange }) {
return (
<>
<WizardQ
title="What are you renting right now?"
sub="Tap everything you pay for. Approximate is fine."
/>
<Field label="Tools & subscriptions">
<ChipGroup
options={STACK_TOOLS}
values={tools || []}
onChange={onToolsChange}
allowOther
/>
</Field>
<Field label="About how much per month?" hint="Across all your software, ballpark.">
<Slider
min={0} max={1500} step={25}
value={spend ?? 250}
onChange={onSpendChange}
format={(v) => v === 0 ? "$0" : v === 1500 ? "$1.5k+" : `$${v}`}
/>
</Field>
{(tools || []).length > 0 && (
<div
style={{
padding: "12px 14px",
borderRadius: 10,
border: "1px solid var(--hairline)",
background: "oklch(0.18 0.009 60 / 0.6)",
fontSize: 13.5,
lineHeight: 1.5,
color: "var(--fg-dim)",
display: "flex", gap: 12, alignItems: "flex-start",
}}
>
<span style={{
width: 6, height: 6, borderRadius: "50%",
background: "var(--accent)", boxShadow: "0 0 10px var(--accent-glow)",
marginTop: 7, flexShrink: 0,
}} />
<span>
<b style={{ color: "var(--fg)", fontWeight: 500 }}>{tools.length} tool{tools.length === 1 ? "" : "s"}</b>
{spend ? <> · ~<b style={{ color: "var(--fg)", fontWeight: 500 }}>${spend}/mo</b></> : null}.
Replaced by one workspace, owned by you.
</span>
</div>
)}
</>
);
}
const OWNER_FIRST_THINGS = [
{ id: "booking", icon: "📅", label: "Bookings & scheduling", desc: "Customers book themselves." },
{ id: "invoicing", icon: "🧾", label: "Quotes, invoices, payments", desc: "Send a quote, get paid, no chasing." },
{ id: "customers", icon: "👥", label: "Customer history & portal", desc: "One place per customer." },
{ id: "inventory", icon: "📦", label: "Inventory & orders", desc: "Track stock, sales, suppliers." },
{ id: "team", icon: "🪪", label: "Team & job dispatch", desc: "Assign jobs, log hours." },
{ id: "marketing", icon: "📣", label: "Website + email + reviews", desc: "A site that converts, list that follows up." },
];
function OwnerFirstThing({ value, onChange }) {
return (
<>
<WizardQ
title="What's burning first?"
sub="The one workflow you wish was already replaced. Vibn builds it on day one."
/>
<PresetGroup
options={OWNER_FIRST_THINGS.map((f) => ({
id: f.id, label: f.label, desc: f.desc,
icon: <span style={{ fontSize: 14 }}>{f.icon}</span>,
}))}
value={value}
onChange={onChange}
columns={2}
/>
</>
);
}
const OWNER_HOW_LONG = [
{ id: "starting", label: "Just starting" },
{ id: "1_3", label: "13 years" },
{ id: "3_10", label: "310 years" },
{ id: "10_plus", label: "10+ years" },
];
function OwnerScale({ customers, team, howLong, onCustomers, onTeam, onHowLong }) {
return (
<>
<WizardQ
title="A little about scale."
sub="Sensible defaults — table view vs. cards, daily vs. monthly reports."
/>
<Field label="Customers per month">
<Slider
min={0} max={2000} step={10}
value={customers ?? 50}
onChange={onCustomers}
format={(v) => v === 0 ? "0" : v >= 2000 ? "2k+" : v.toLocaleString()}
/>
</Field>
<Field label="Team size (incl. you)">
<Slider
min={1} max={50} step={1}
value={team ?? 1}
onChange={onTeam}
format={(v) => v >= 50 ? "50+" : `${v}`}
/>
</Field>
<Field label="How long have you been at this?">
<div className="chips">
{OWNER_HOW_LONG.map((h) => (
<button
key={h.id}
type="button"
className={"chip" + (howLong === h.id ? " active" : "")}
onClick={() => onHowLong(h.id)}
>
{h.label}
</button>
))}
</div>
</Field>
</>
);
}
// ── Path wrapper ───────────────────────────────────────────────────────────
function OwnerPath({ data, onUpdate, onBack, onClose, onComplete, onJumpToStep, step }) {
const next = () => {
if (step < OWNER_TOTAL - 1) onJumpToStep(step + 1);
else onComplete();
};
const back = () => {
if (step === 0) onBack();
else onJumpToStep(step - 1);
};
let body, canNext;
if (step === 0) {
body = (
<OwnerBiz
value={data.biz}
name={data.bizName || ""}
onChange={(v) => onUpdate({ biz: v })}
onNameChange={(v) => onUpdate({ bizName: v })}
/>
);
canNext = !!data.biz && (data.bizName || "").trim().length >= 2;
} else if (step === 1) {
body = (
<OwnerStack
tools={data.tools || []}
spend={data.spend}
onToolsChange={(v) => onUpdate({ tools: v })}
onSpendChange={(v) => onUpdate({ spend: v })}
/>
);
canNext = (data.tools || []).length >= 1;
} else if (step === 2) {
body = <OwnerFirstThing value={data.firstThing} onChange={(v) => onUpdate({ firstThing: v })} />;
canNext = !!data.firstThing;
} else {
body = (
<OwnerScale
customers={data.customers}
team={data.team}
howLong={data.howLong}
onCustomers={(v) => onUpdate({ customers: v })}
onTeam={(v) => onUpdate({ team: v })}
onHowLong={(v) => onUpdate({ howLong: v })}
/>
);
canNext = !!data.howLong;
}
return (
<>
<WizardTop
onBack={back}
onClose={onClose}
lane={LANE_LABELS.owner}
stepText={OWNER_STEP_NAMES[step]}
current={step + 2}
total={5}
/>
<WizardBody width={step === 0 || step === 2 ? "wide" : null}>
{body}
<WizardFooter
onNext={next}
canNext={canNext}
nextLabel={step === OWNER_TOTAL - 1 ? "Build my workspace →" : "Continue"}
hint={canNext ? "⌘↵" : null}
/>
</WizardBody>
</>
);
}
Object.assign(window, { OwnerPath, OWNER_TOTAL });

View File

@@ -0,0 +1,333 @@
// Shared building blocks for the onboarding flow.
// All <style> belongs in onboarding.css; this file is JSX only.
// ── Wizard top bar ─────────────────────────────────────────────────────────
// Sticky, thin. Holds: back arrow · vibn mark · centered step label · close.
// A 2px progress bar runs along its bottom edge.
function WizardTop({
onBack, onClose,
lane, // "Solo / quiet entrepreneur" etc.
stepText, // "Idea" or "Pick your lane"
current, total, // 1-indexed
progress, // 0..1 (optional override)
}) {
const pct = typeof progress === "number"
? Math.max(0, Math.min(1, progress))
: (typeof current === "number" && typeof total === "number"
? Math.max(0, Math.min(1, current / total))
: 0);
return (
<header className="wiz-top">
<div className="wiz-top-row">
<button
type="button"
className="wiz-iconbtn"
onClick={onBack}
disabled={!onBack}
aria-label="Back"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M13 8H3M7 4 3 8l4 4"/>
</svg>
</button>
<a href="index.html" className="wiz-logo" aria-label="vibn — home">
<LogoMark size={22} />
<span>vibn</span>
</a>
<div className="wiz-step">
{lane && <span className="lane">{lane}</span>}
{lane && stepText && <span className="dot" />}
{stepText && (
<span>
{typeof current === "number" && typeof total === "number" && (
<>
<b>{current}</b> <span style={{ opacity: 0.6 }}>/ {total}</span>{" · "}
</>
)}
{stepText}
</span>
)}
</div>
<button
type="button"
className="wiz-iconbtn"
onClick={onClose}
aria-label="Save & exit"
title="Save & exit"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m4 4 8 8M12 4l-8 8"/>
</svg>
</button>
</div>
<div className="wiz-progress">
<div className="wiz-progress-fill" style={{ width: `${pct * 100}%` }} />
</div>
</header>
);
}
// ── Wizard body wrapper ────────────────────────────────────────────────────
function WizardBody({ children, width }) {
const cls = "wiz-card" + (width === "wide" ? " wide" : width === "xwide" ? " xwide" : "");
return (
<main className="wiz-body">
<div className={cls}>{children}</div>
</main>
);
}
// ── Question heading ───────────────────────────────────────────────────────
function WizardQ({ title, sub }) {
return (
<div className="wiz-q">
<h2>{title}</h2>
{sub && <p>{sub}</p>}
</div>
);
}
// ── Footer (back / hint / continue) ────────────────────────────────────────
function WizardFooter({
onBack, onNext, canNext = true,
nextLabel = "Continue",
hint,
onSkip, skipLabel = "Skip",
}) {
return (
<div className="wiz-foot">
<div className="wiz-foot-left">
{onSkip && (
<button type="button" className="wiz-skip" onClick={onSkip}>
{skipLabel}
</button>
)}
</div>
<div className="wiz-foot-right">
{hint && <span className="wiz-hint">{hint}</span>}
<button
type="button"
className="btn btn-primary btn-wiz"
disabled={!canNext}
onClick={() => canNext && onNext && onNext()}
>
{nextLabel} <Arrow size={13} />
</button>
</div>
</div>
);
}
// ── Field wrappers (wizard variants) ───────────────────────────────────────
function Field({ label, hint, children, optional }) {
return (
<label className="wiz-field">
{label && (
<span className="wiz-field-label">
{label}
{optional && (
<span style={{ color: "var(--fg-faint)", fontWeight: 400, marginLeft: 8, fontSize: 12 }}>
optional
</span>
)}
</span>
)}
{children}
{hint && <span className="wiz-field-hint">{hint}</span>}
</label>
);
}
// ── Chip group (multi-select) ──────────────────────────────────────────────
function ChipGroup({ options, values, onChange, allowOther = false }) {
const [other, setOther] = React.useState("");
const customs = (values || []).filter((v) => !options.includes(v));
const toggle = (v) => {
if (!onChange) return;
if (values.includes(v)) onChange(values.filter((x) => x !== v));
else onChange([...values, v]);
};
return (
<div>
<div className="chips">
{options.map((opt) => (
<button
type="button" key={opt}
className={"chip" + (values.includes(opt) ? " active" : "")}
onClick={() => toggle(opt)}
>
{opt}
</button>
))}
{customs.map((c) => (
<button
type="button" key={c}
className="chip active"
onClick={() => toggle(c)}
title="Click to remove"
>
{c} <span style={{ marginLeft: 4, opacity: 0.6 }}>×</span>
</button>
))}
</div>
{allowOther && (
<form
onSubmit={(e) => {
e.preventDefault();
const v = other.trim();
if (v && !values.includes(v)) onChange([...values, v]);
setOther("");
}}
style={{ marginTop: 10, display: "flex", gap: 8 }}
>
<input
type="text"
className="wiz-input"
placeholder="Add your own…"
value={other}
onChange={(e) => setOther(e.target.value)}
style={{ flex: 1 }}
/>
<button
type="submit"
className="btn btn-ghost"
style={{ height: 42, padding: "0 14px", fontSize: 13, borderRadius: 10 }}
disabled={!other.trim()}
>
Add
</button>
</form>
)}
</div>
);
}
// ── Preset group (single-select cards) ─────────────────────────────────────
function PresetGroup({ options, value, onChange, columns = 1 }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: 8,
width: "100%",
}}
>
{options.map((opt) => {
const active = value === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => onChange(opt.id)}
style={{
textAlign: "left",
padding: "12px 14px",
borderRadius: 10,
border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`,
background: active ? "oklch(0.20 0.04 35 / 0.4)" : "oklch(0.18 0.009 60 / 0.6)",
boxShadow: active ? "0 0 0 3px oklch(0.74 0.175 35 / 0.1)" : "none",
transition: "border-color .15s, background .15s",
color: "var(--fg)",
display: "flex", alignItems: "flex-start", gap: 12,
}}
>
{opt.icon && (
<span style={{
width: 28, height: 28, flexShrink: 0,
borderRadius: 8,
background: active ? "oklch(0.74 0.175 35 / 0.18)" : "oklch(0.22 0.011 60)",
border: "1px solid var(--hairline)",
color: active ? "var(--accent)" : "var(--fg-mute)",
display: "grid", placeItems: "center",
fontSize: 14,
marginTop: 1,
}}>
{opt.icon}
</span>
)}
<span style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 14, fontWeight: 500, letterSpacing: "-0.005em" }}>
{opt.label}
</span>
{opt.desc && (
<span style={{ fontSize: 12.5, color: "var(--fg-mute)", lineHeight: 1.45 }}>
{opt.desc}
</span>
)}
</span>
{active && (
<span style={{
width: 16, height: 16, borderRadius: "50%",
background: "var(--accent)",
display: "grid", placeItems: "center",
color: "var(--accent-fg)",
flexShrink: 0,
marginTop: 6,
}}>
<svg width="9" height="9" viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m3 8.5 3.2 3.2L13 5"/>
</svg>
</span>
)}
</button>
);
})}
</div>
);
}
// ── Slider ─────────────────────────────────────────────────────────────────
function Slider({ min, max, step = 1, value, onChange, format }) {
return (
<div style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<span className="mono" style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.04em" }}>
{format ? format(min) : min}
</span>
<span
className="mono"
style={{
fontSize: 18,
color: "var(--fg)",
letterSpacing: "-0.01em",
fontWeight: 500,
}}
>
{format ? format(value) : value}
</span>
<span className="mono" style={{ fontSize: 11, color: "var(--fg-faint)", letterSpacing: "0.04em" }}>
{format ? format(max) : max}
</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{ width: "100%", marginTop: 6, accentColor: "var(--accent)" }}
/>
</div>
);
}
// Lane labels — used by WizardTop and elsewhere.
const LANE_LABELS = {
entrepreneur: "Solo entrepreneur",
owner: "Small business owner",
consultant: "Building for clients",
};
Object.assign(window, {
WizardTop, WizardBody, WizardQ, WizardFooter,
Field, ChipGroup, PresetGroup, Slider,
LANE_LABELS,
});

677
new-site/onboarding.css Normal file
View File

@@ -0,0 +1,677 @@
/* Onboarding shared styles — same tokens as the rest of the site. */
:root {
--bg: oklch(0.155 0.008 60);
--bg-1: oklch(0.185 0.009 60);
--bg-2: oklch(0.225 0.010 60);
--hairline: oklch(0.32 0.010 60 / 0.55);
--hairline-2: oklch(0.40 0.012 60 / 0.35);
--fg: oklch(0.97 0.005 80);
--fg-dim: oklch(0.78 0.006 80);
--fg-mute: oklch(0.58 0.006 80);
--fg-faint: oklch(0.42 0.006 80);
--accent: oklch(0.74 0.175 35);
--accent-soft: oklch(0.74 0.175 35 / 0.18);
--accent-glow: oklch(0.74 0.175 35 / 0.35);
--accent-fg: #1a0f0a;
--ok: oklch(0.78 0.16 155);
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed; inset: 0;
background-image:
linear-gradient(to right, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 80% 80% at 50% 40%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 40%, #000 30%, transparent 80%);
pointer-events: none;
z-index: 0;
}
body::after {
content: "";
position: fixed; inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.035;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.85'/></svg>");
}
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
h1, h2, h3 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
p { margin: 0; }
::selection { background: var(--accent); color: var(--accent-fg); }
.mono { font-family: var(--font-mono); }
/* App shell */
.app {
position: relative;
z-index: 2;
min-height: 100dvh;
display: flex; flex-direction: column;
}
.app-bar {
position: relative; z-index: 5;
padding: 20px clamp(20px, 4vw, 48px);
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid transparent;
}
.app-bar-left { display: flex; align-items: center; gap: 24px; }
.app-step {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.12em;
text-transform: uppercase;
display: inline-flex; align-items: center; gap: 8px;
}
.app-step::before {
content: "";
width: 5px; height: 5px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
}
.app-bar-right {
display: flex; gap: 18px; align-items: center;
}
.app-bar-right a, .app-bar-right button {
font-size: 13px; color: var(--fg-mute);
}
.app-bar-right a:hover, .app-bar-right button:hover { color: var(--fg); }
/* Logo */
.logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
color: var(--fg);
}
.logo-mark {
width: 26px; height: 26px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%);
box-shadow: 0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
display: grid; place-items: center;
color: var(--accent-fg);
flex-shrink: 0;
}
.logo-mark svg { display: block; }
.logo-caret { animation: caret-blink 1.4s steps(2) infinite; }
@keyframes caret-blink { 50% { opacity: 0.25; } }
/* Main */
.screen {
flex: 1;
position: relative;
padding: clamp(40px, 7vh, 80px) clamp(20px, 4vw, 48px) clamp(40px, 6vh, 60px);
display: flex; flex-direction: column;
align-items: center;
text-align: center;
}
.screen-wide {
align-items: stretch;
text-align: left;
}
.screen-content {
position: relative; z-index: 2;
width: 100%;
max-width: 720px;
display: flex; flex-direction: column;
align-items: center; text-align: center;
}
.screen-content-wide {
max-width: 1100px;
align-items: stretch; text-align: left;
}
/* Ambient glows */
.glow {
position: absolute;
pointer-events: none;
filter: blur(20px);
z-index: 0;
}
/* Typography */
.eyebrow {
display: inline-flex; align-items: center; gap: 10px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--fg-mute);
}
.eyebrow::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
}
.eyebrow-accent { color: var(--accent); }
.h1 {
margin-top: 20px;
font-size: clamp(36px, 5.4vw, 64px);
font-weight: 500; letter-spacing: -0.03em; line-height: 1.04;
text-wrap: balance;
}
.h1 em {
font-style: normal;
color: var(--accent);
text-shadow: 0 0 30px var(--accent-glow);
}
.sub {
margin-top: 18px;
font-size: clamp(15px, 1.55vw, 18px);
color: var(--fg-mute);
line-height: 1.55;
text-wrap: balance;
max-width: 540px;
}
.sub b { color: var(--fg); font-weight: 500; }
.tagline {
display: inline-flex; align-items: center; gap: 14px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.06em;
color: var(--fg-faint);
margin-bottom: 8px;
}
.tagline::before, .tagline::after {
content: ""; width: 28px; height: 1px;
background: linear-gradient(90deg, transparent, var(--hairline), transparent);
}
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
height: 50px; padding: 0 22px;
border-radius: 999px;
font-weight: 500;
font-size: 15px;
transition: transform .12s, box-shadow .2s, background .2s, border-color .15s;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: var(--accent-fg);
box-shadow:
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
0 10px 40px -10px var(--accent-glow),
0 0 40px -8px var(--accent-glow);
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary[disabled] { opacity: .55; cursor: not-allowed; transform: none; }
.btn-primary .arrow { transition: transform .15s; }
.btn-primary:hover .arrow { transform: translateX(3px); }
.btn-ghost {
background: oklch(0.20 0.009 60 / 0.6);
border: 1px solid var(--hairline);
color: var(--fg-dim);
}
.btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); background: oklch(0.22 0.010 60 / 0.8); }
.link-quiet {
font-size: 13px;
color: var(--fg-mute);
display: inline-flex; align-items: center; gap: 6px;
border-bottom: 1px dashed var(--hairline);
padding-bottom: 2px;
}
.link-quiet:hover { color: var(--fg); border-color: var(--accent); }
/* Or divider */
.or-divider {
display: flex; align-items: center; gap: 14px;
margin: 28px 0 18px;
width: 100%; max-width: 360px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-faint);
}
.or-divider::before, .or-divider::after {
content: ""; flex: 1; height: 1px; background: var(--hairline);
}
/* Form */
.field {
width: 100%;
display: flex; flex-direction: column; gap: 8px;
margin-top: 24px;
text-align: left;
}
.field-label {
font-size: 15px;
font-weight: 500;
color: var(--fg);
letter-spacing: -0.005em;
}
.field-hint {
font-size: 13px;
color: var(--fg-mute);
line-height: 1.5;
}
.input {
width: 100%;
padding: 14px 16px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 12px;
color: var(--fg);
font: 15px/1.5 var(--font-sans);
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
resize: vertical;
}
.input::placeholder { color: var(--fg-faint); }
.input:focus {
border-color: oklch(0.74 0.175 35 / 0.65);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12), 0 0 30px -10px var(--accent-glow);
}
.input-textarea { min-height: 110px; resize: vertical; }
.input-large { padding: 20px 22px; font-size: 17px; border-radius: 16px; }
/* Hero prompt input */
.prompt {
width: 100%;
position: relative;
margin-top: 24px;
}
.prompt-frame {
position: relative;
border-radius: 22px;
padding: 1px;
background: linear-gradient(180deg,
oklch(0.50 0.06 35 / 0.6),
oklch(0.30 0.012 60 / 0.4) 40%,
oklch(0.25 0.012 60 / 0.4));
box-shadow:
0 30px 80px -20px oklch(0 0 0 / 0.6),
0 0 80px -20px var(--accent-glow);
}
.prompt-inner {
background: linear-gradient(180deg, oklch(0.19 0.009 60 / 0.92), oklch(0.17 0.008 60 / 0.92));
border-radius: 21px;
padding: 18px 20px 14px;
backdrop-filter: blur(20px);
display: flex; flex-direction: column;
gap: 12px;
}
.prompt-inner textarea {
width: 100%;
min-height: 92px;
background: transparent;
border: 0;
color: var(--fg);
font: 17px/1.5 var(--font-sans);
resize: none;
outline: none;
padding: 4px;
}
.prompt-typed {
position: absolute;
top: 22px; left: 24px; right: 24px;
pointer-events: none;
color: var(--fg-faint);
font: 17px/1.5 var(--font-sans);
text-align: left;
}
.prompt-typed::after {
content: "";
display: inline-block;
width: 8px; height: 18px;
background: var(--accent);
vertical-align: -3px;
margin-left: 2px;
animation: blink 1s steps(2) infinite;
box-shadow: 0 0 12px var(--accent-glow);
}
@keyframes blink { 50% { opacity: 0; } }
.prompt-bar {
display: flex; align-items: center; justify-content: space-between;
padding-top: 8px;
border-top: 1px solid var(--hairline);
}
.prompt-hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.02em;
}
/* Chip / option grid */
.chips {
display: flex; flex-wrap: wrap; gap: 8px;
}
.chip {
padding: 9px 14px;
border-radius: 999px;
border: 1px solid var(--hairline);
background: oklch(0.20 0.009 60 / 0.5);
color: var(--fg-dim);
font-size: 13.5px;
transition: border-color .15s, color .15s, background .15s, transform .12s;
}
.chip:hover { border-color: var(--hairline-2); color: var(--fg); transform: translateY(-1px); }
.chip.active {
border-color: var(--accent);
background: oklch(0.20 0.04 35 / 0.4);
color: var(--fg);
}
/* Preset chips */
.preset-row {
display: flex; gap: 8px; flex-wrap: wrap;
margin-top: 4px;
}
.preset-chip {
padding: 11px 18px;
border-radius: 12px;
border: 1px solid var(--hairline);
background: oklch(0.18 0.009 60 / 0.6);
color: var(--fg-dim);
font: 500 14.5px var(--font-mono);
letter-spacing: -0.005em;
transition: all .15s;
}
.preset-chip:hover { border-color: var(--hairline-2); color: var(--fg); }
.preset-chip.active {
border-color: var(--accent);
background: oklch(0.20 0.04 35 / 0.4);
color: var(--fg);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.1);
}
/* Trust strip */
.trust {
margin-top: 36px;
display: flex; gap: 14px; justify-content: center; align-items: center;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
color: var(--fg-faint);
}
.trust .sep { opacity: 0.5; }
/* CTA row */
.cta-row {
margin-top: 36px;
display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
justify-content: center;
}
/* Spinner */
.spinner {
width: 16px; height: 16px; border-radius: 50%;
border: 2px solid oklch(0 0 0 / 0.2);
border-top-color: var(--accent-fg);
animation: spin .9s linear infinite;
display: inline-block;
}
.spinner-line {
width: 12px; height: 12px;
border-color: var(--hairline);
border-top-color: var(--accent);
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Surface card */
.surface {
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.55), oklch(0.17 0.008 60 / 0.55));
border: 1px solid var(--hairline);
border-radius: 18px;
}
/* ── Wizard chrome ───────────────────────────────────────────────────── */
/* The persistent top strip with progress bar + back + step text + close. */
.wiz-top {
position: sticky; top: 0; z-index: 50;
background: oklch(0.155 0.008 60 / 0.85);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border-bottom: 1px solid var(--hairline);
}
.wiz-top-row {
height: 54px;
padding: 0 clamp(16px, 3vw, 28px);
display: flex; align-items: center; gap: 14px;
}
.wiz-iconbtn {
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px;
color: var(--fg-mute);
border: 1px solid transparent;
transition: color .15s, border-color .15s, background .15s;
flex-shrink: 0;
}
.wiz-iconbtn:hover {
color: var(--fg);
background: oklch(0.20 0.009 60 / 0.6);
border-color: var(--hairline);
}
.wiz-iconbtn[disabled] { opacity: 0; pointer-events: none; }
.wiz-logo {
display: inline-flex; align-items: center; gap: 8px;
font-weight: 500; font-size: 14px; letter-spacing: -0.01em;
color: var(--fg);
flex-shrink: 0;
}
.wiz-logo .logo-mark { width: 22px; height: 22px; }
.wiz-step {
flex: 1;
display: flex; align-items: center; gap: 10px;
min-width: 0;
justify-content: center;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--fg-mute);
letter-spacing: 0.04em;
overflow: hidden;
}
.wiz-step b { color: var(--fg); font-weight: 500; }
.wiz-step .dot {
width: 4px; height: 4px; border-radius: 50%;
background: var(--fg-faint);
flex-shrink: 0;
}
.wiz-step .lane {
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 10.5px;
display: inline-flex; align-items: center; gap: 6px;
}
.wiz-step .lane::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 10px var(--accent-glow);
}
.wiz-progress {
position: relative;
height: 2px;
background: oklch(0.30 0.010 60 / 0.35);
}
.wiz-progress-fill {
position: absolute; left: 0; top: 0; bottom: 0;
background: var(--accent);
box-shadow: 0 0 14px var(--accent-glow);
transition: width .35s cubic-bezier(.4,0,.2,1);
}
@media (max-width: 640px) {
.wiz-step .lane { display: none; }
.wiz-step .dot:first-of-type { display: none; }
}
/* ── Wizard body ─────────────────────────────────────────────────────── */
.wiz-body {
flex: 1;
position: relative;
padding: clamp(40px, 7vh, 88px) clamp(20px, 4vw, 32px) clamp(40px, 6vh, 64px);
display: flex; flex-direction: column;
align-items: center;
}
.wiz-card {
width: 100%;
max-width: 520px;
display: flex; flex-direction: column;
gap: 28px;
}
.wiz-card.wide { max-width: 760px; }
.wiz-card.xwide { max-width: 1040px; }
/* Question heading — quiet, one line, no em accents */
.wiz-q { display: flex; flex-direction: column; gap: 10px; }
.wiz-q h2 {
font-size: clamp(22px, 2.4vw, 28px);
font-weight: 500;
letter-spacing: -0.018em;
line-height: 1.22;
color: var(--fg);
text-wrap: balance;
}
.wiz-q p {
font-size: 14.5px;
color: var(--fg-mute);
line-height: 1.55;
max-width: 460px;
}
/* Footer with back/continue */
.wiz-foot {
display: flex; align-items: center; justify-content: space-between;
gap: 14px;
margin-top: 8px;
}
.wiz-foot-left {
display: flex; align-items: center; gap: 10px;
font-size: 13px;
color: var(--fg-mute);
}
.wiz-foot-right {
display: flex; align-items: center; gap: 12px;
}
.wiz-hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-faint);
letter-spacing: 0.06em;
}
.wiz-skip {
font-size: 13.5px;
color: var(--fg-mute);
padding: 8px 12px;
border-radius: 8px;
}
.wiz-skip:hover { color: var(--fg); background: oklch(0.20 0.009 60 / 0.5); }
.btn-wiz {
height: 42px;
padding: 0 18px;
font-size: 14px;
border-radius: 10px;
}
/* Fields tightened up for wizard context */
.wiz-field {
display: flex; flex-direction: column; gap: 8px;
}
.wiz-field-label {
font-size: 13.5px;
font-weight: 500;
color: var(--fg-dim);
letter-spacing: -0.005em;
}
.wiz-field-hint {
font-size: 12.5px;
color: var(--fg-mute);
line-height: 1.5;
}
.wiz-input {
width: 100%;
padding: 12px 14px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 10px;
color: var(--fg);
font: 14.5px/1.5 var(--font-sans);
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.wiz-input::placeholder { color: var(--fg-faint); }
.wiz-input:focus {
border-color: oklch(0.74 0.175 35 / 0.6);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12);
}
textarea.wiz-input { min-height: 96px; resize: vertical; }
/* Debug navigator panel */
.debug {
position: fixed; bottom: 16px; right: 16px;
z-index: 1000;
font-family: var(--font-mono);
font-size: 11px;
display: flex; flex-direction: column; gap: 6px;
align-items: flex-end;
}
.debug-toggle {
padding: 8px 12px;
border-radius: 999px;
background: oklch(0.18 0.009 60 / 0.85);
border: 1px solid var(--hairline);
color: var(--fg-mute);
letter-spacing: 0.06em;
text-transform: uppercase;
backdrop-filter: blur(12px);
}
.debug-toggle:hover { color: var(--fg); border-color: var(--hairline-2); }
.debug-panel {
width: 240px;
padding: 12px;
background: oklch(0.16 0.008 60 / 0.95);
border: 1px solid var(--hairline);
border-radius: 12px;
backdrop-filter: blur(20px);
display: flex; flex-direction: column; gap: 4px;
max-height: 60vh; overflow-y: auto;
}
.debug-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
border-radius: 6px;
color: var(--fg-mute);
cursor: pointer;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 10px;
}
.debug-row:hover { background: oklch(0.20 0.009 60); color: var(--fg-dim); }
.debug-row.active {
background: oklch(0.74 0.175 35 / 0.18);
color: var(--accent);
}
.debug-row b { color: inherit; font-weight: 600; }

189
new-site/vibn-signin.html Normal file

File diff suppressed because one or more lines are too long

40
patch_thought_sig.ts Normal file
View File

@@ -0,0 +1,40 @@
const fs = require('fs');
const path = 'vibn-frontend/lib/ai/gemini-chat.ts';
let code = fs.readFileSync(path, 'utf8');
// The issue is in `toGeminiContents()`.
// For `gemini-3.1-pro-preview` thinking models, `thoughtSignature` MUST be passed back
// as a sibling to `functionCall` in the `parts` array for assistant responses,
// OR inside the `functionResponse` for tool responses (depending on API version).
// Also we need to extract it from the response.
code = code.replace(
` const part: any = {
functionCall: { name: tc.name, args: tc.args },
};
parts.push(part);`,
` const part: any = {
functionCall: { name: tc.name, args: tc.args },
};
if (tc.thoughtSignature) {
part.thoughtSignature = tc.thoughtSignature;
}
parts.push(part);`
);
code = code.replace(
` toolCalls.push({
id: \`tc-\${Date.now()}-\${Math.random().toString(36).slice(2)}\`,
name: part.functionCall.name,
args: part.functionCall.args as Record<string, unknown> ?? {},
});`,
` toolCalls.push({
id: \`tc-\${Date.now()}-\${Math.random().toString(36).slice(2)}\`,
name: part.functionCall.name,
args: part.functionCall.args as Record<string, unknown> ?? {},
thoughtSignature: (part as any).thoughtSignature,
});`
);
fs.writeFileSync(path, code);

1
runner_logs.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,159 @@
# API Route Map
> Generated 2026-05-17. Auth column: `session` = NextAuth cookie,
> `api_key` = `vibn_sk_…` bearer, `admin_secret` = env-var secret,
> `webhook_sig` = HMAC-SHA256, `public` = no auth.
>
> Tenant column: `workspace` = must belong to caller's workspace,
> `project` = must own project, `user` = must match session user,
> `global` = cross-workspace admin op.
## Chat
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/chat` | session | workspace | Main SSE chat with Gemini + tool loop |
| GET | `/api/chat/threads` | session | user | List threads |
| POST | `/api/chat/threads` | session | user | Create thread |
| GET | `/api/chat/threads/[id]` | session | user | Get thread + messages |
| PATCH | `/api/chat/threads/[id]` | session | user | Rename thread |
| DELETE | `/api/chat/threads/[id]` | session | user | Delete thread |
## AI (legacy, plan to deprecate)
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/ai/chat` | session | project | Old collector-mode chat (pre-tool) |
| GET | `/api/ai/conversation` | session | project | Fetch saved conversation history |
| DELETE | `/api/ai/conversation` | session | project | Wipe conversation history |
| POST | `/api/ai/conversation/reset` | session | project | Alias for DELETE |
## Projects
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| GET | `/api/projects` | session | user | List user's projects |
| POST | `/api/projects/create` | session | user | Create project (enforces quota) |
| POST | `/api/projects/delete` | session | project | Delete project |
| GET/PATCH | `/api/projects/[projectId]` | session | project | Get / update project |
| GET | `/api/projects/[projectId]/activity` | session | project | Activity feed |
| POST | `/api/projects/[projectId]/advisor` | session | project | AI advisor |
| GET/POST | `/api/projects/[projectId]/anatomy` | session | project | Anatomy read/update |
| GET/POST | `/api/projects/[projectId]/apps` | session | project | App list / create |
| GET/POST | `/api/projects/[projectId]/design-kit` | session | project | Design kit CRUD |
| GET/POST | `/api/projects/[projectId]/plan` | session | project | Plan read/update |
| POST | `/api/projects/[projectId]/plan/intelligent` | session | project | AI plan generation |
| POST | `/api/projects/[projectId]/plan/mvp` | session | project | MVP plan |
| POST | `/api/projects/[projectId]/plan/marketing` | session | project | Marketing plan |
| POST | `/api/projects/[projectId]/documents/upload` | session | project | Upload brief |
| GET/POST | `/api/projects/[projectId]/secrets` | session | project | List/set project secrets (B-06) |
| GET/DELETE | `/api/projects/[projectId]/secrets/[key]` | session | project | Reveal/delete secret (B-06) |
| GET | `/api/projects/[projectId]/knowledge` | session | project | Knowledge items |
| POST | `/api/projects/[projectId]/knowledge/batch-extract` | session | project | Batch extract knowledge |
| GET/POST | `/api/projects/[projectId]/agent/sessions` | session | project | Agent session CRUD |
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]` | session | project | Session state |
| POST | `/api/projects/[projectId]/agent/sessions/[sessionId]/approve` | session | project | Approve session commit |
| POST | `/api/projects/[projectId]/agent/sessions/[sessionId]/stop` | session | project | Stop agent |
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]/events` | session | project | Event list |
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]/events/stream` | session | project | SSE event tail |
## Workspaces
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| GET | `/api/workspaces` | session/api_key | user | List workspaces |
| POST | `/api/workspaces/delete` | session | user | Delete workspace |
| GET | `/api/workspaces/[slug]` | session/api_key | workspace | Get workspace |
| GET/POST | `/api/workspaces/[slug]/apps` | session/api_key | workspace | List/create apps |
| GET/PATCH/DELETE | `/api/workspaces/[slug]/apps/[uuid]` | session/api_key | workspace | App CRUD |
| POST | `/api/workspaces/[slug]/apps/[uuid]/deploy` | session/api_key | workspace | Trigger deploy |
| GET | `/api/workspaces/[slug]/apps/[uuid]/logs` | session/api_key | workspace | Runtime logs |
| GET/PATCH | `/api/workspaces/[slug]/apps/[uuid]/envs` | session/api_key | workspace | Env vars |
| POST | `/api/workspaces/[slug]/apps/[uuid]/exec` | session/api_key | workspace | Remote exec |
| GET/POST | `/api/workspaces/[slug]/databases` | session/api_key | workspace | Database CRUD |
| GET | `/api/workspaces/[slug]/domains` | session/api_key | workspace | Domain list |
| GET/POST | `/api/workspaces/[slug]/keys` | session/api_key | workspace | API keys |
| POST | `/api/workspaces/[slug]/provision` | session/api_key | workspace | Provision workspace |
## MCP
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/mcp` | session/api_key | workspace | All 40+ MCP tools |
| GET/POST/DELETE | `/api/mcp/generate-key` | session | user | Manage MCP API keys |
## Auth / Sessions
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| GET/POST | `/api/auth/[...nextauth]` | public | - | NextAuth handlers |
| GET | `/api/user/api-key` | session | user | Get/create user API key |
| GET | `/api/sessions` | session | user | Session history |
| POST | `/api/sessions/track` | session | user | Track session event |
| POST | `/api/sessions/associate-project` | session | user | Link session to project |
## GitHub Integrations
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| GET/POST/DELETE | `/api/github/connect` | session | user | Legacy GitHub OAuth connect |
| GET | `/api/github/repos` | session | user | List connected repos |
| GET | `/api/github/repo-tree` | session | user | Repo file tree |
| GET | `/api/github/file-content` | session | user | Single file content |
| POST | `/api/github/oauth/token` | public | - | OAuth token exchange |
| GET | `/api/integrations/github/connect` | session | user | New OAuth connect |
| GET | `/api/integrations/github/callback` | public | - | OAuth callback |
| POST | `/api/integrations/github/disconnect` | session | user | Disconnect GitHub |
| GET | `/api/integrations/github/repos` | session | user | New integration repos |
## Webhooks
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/webhooks/gitea` | webhook_sig | project | Gitea push events → context snapshot |
| POST | `/api/webhooks/coolify` | webhook_sig | project | Deploy status → context snapshot |
## Invites (P4.8)
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| GET/POST | `/api/invites` | admin_secret | global | Create / list invite tokens |
| GET | `/api/invites/[token]` | public | - | Validate token (used by auth page) |
| POST | `/api/invites/[token]` | session | user | Redeem token on signup |
## Admin / Ops
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/admin/migrate` | admin_secret | global | Run SQL migrations |
| GET | `/api/admin/path-b` | admin_secret | global | Path B kill-switch state |
| POST | `/api/admin/path-b/disable` | admin_secret | global | Disable Path B |
| POST | `/api/admin/path-b/enable` | admin_secret | global | Enable Path B |
| POST | `/api/admin/path-b/idle-sweep` | admin_secret | global | Suspend idle dev containers |
| POST | `/api/admin/path-b/autosave` | admin_secret | global | Autosave workspace |
| POST | `/api/admin/backfill-isolation` | admin_secret | global | Backfill tenant isolation |
| POST | `/api/admin/path-b` | admin_secret | global | Path B bulk status |
| GET | `/api/internal/infra-health` | admin_secret | global | Coolify + SSH probe |
## Utilities
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| POST | `/api/context/summarize` | session | user | Gemini one-shot doc summary |
| GET | `/api/activity` | session | user | Workspace activity feed |
| GET | `/api/work-completed` | session | project | Work completed items |
| GET | `/api/stats` | session | user | Usage stats |
| GET | `/api/preview/embed` | session | user | HTML proxy for preview iframes |
| GET/POST | `/api/extension/link-project` | session | user | Browser extension project link |
| POST | `/api/vision/update` | session | project | Update project vision |
| GET | `/api/keys` | session | user | User key management |
| GET | `/api/design-systems/[id]/preview` | public | - | Design system preview HTML |
| GET | `/api/design-systems/[id]/showcase` | public | - | Design system showcase |
## Deprecated / V0
| Method | Path | Auth | Tenant | Purpose |
|--------|------|------|--------|---------|
| * | `/api/v0/*` | varies | varies | Legacy v0 integration (verify still used) |
| * | `/api/openai/*` | session | user | OpenAI GPT import (verify still used) |
| * | `/api/chatgpt/*` | session | user | ChatGPT conversation import |

View File

@@ -1,46 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('projectId');
const userId = searchParams.get('userId');
const adminDb = getAdminDb();
// Get all sessions for this user
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('userId', '==', userId)
.get();
const allSessions = sessionsSnapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
projectId: data.projectId || null,
workspacePath: data.workspacePath || null,
workspaceName: data.workspaceName || null,
needsProjectAssociation: data.needsProjectAssociation,
messageCount: data.messageCount,
conversationLength: data.conversation?.length || 0,
};
});
// Filter sessions that match this project
const matchingSessions = allSessions.filter(s => s.projectId === projectId);
return NextResponse.json({
totalSessions: allSessions.length,
matchingSessions: matchingSessions.length,
allSessions,
projectId,
userId,
});
} catch (error: any) {
console.error('[Admin Check Sessions] Error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -1,59 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function POST(request: Request) {
try {
const { projectId, workspacePath } = await request.json();
if (!projectId || !workspacePath) {
return NextResponse.json(
{ error: 'projectId and workspacePath required' },
{ status: 400 }
);
}
const adminDb = getAdminDb();
// Update project with workspacePath
await adminDb.collection('projects').doc(projectId).update({
workspacePath,
updatedAt: new Date(),
});
console.log(`[Fix Project] Set workspacePath for ${projectId}: ${workspacePath}`);
// Now find and link all matching sessions
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('workspacePath', '==', workspacePath)
.where('needsProjectAssociation', '==', true)
.get();
const batch = adminDb.batch();
let linkedCount = 0;
for (const sessionDoc of sessionsSnapshot.docs) {
batch.update(sessionDoc.ref, {
projectId,
needsProjectAssociation: false,
updatedAt: new Date(),
});
linkedCount++;
}
await batch.commit();
console.log(`[Fix Project] Linked ${linkedCount} sessions to project ${projectId}`);
return NextResponse.json({
success: true,
projectId,
workspacePath,
sessionsLinked: linkedCount,
});
} catch (error: any) {
console.error('[Fix Project] Error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -13,13 +13,17 @@ import { NextRequest, NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { readFileSync } from "fs";
import { join } from "path";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(req: NextRequest) {
const secret = process.env.ADMIN_MIGRATE_SECRET ?? "";
if (!secret) {
return NextResponse.json(
{ error: "ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled" },
{ status: 403 }
{
error:
"ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled",
},
{ status: 403 },
);
}
@@ -28,7 +32,7 @@ export async function POST(req: NextRequest) {
new URL(req.url).searchParams.get("secret") ??
"";
if (incoming !== secret) {
if (!incoming || !timingSafeStringEq(secret, incoming)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
@@ -299,9 +303,9 @@ export async function POST(req: NextRequest) {
}
}
const failed = results.filter(r => !r.ok);
const failed = results.filter((r) => !r.ok);
return NextResponse.json(
{ ok: failed.length === 0, results },
{ status: failed.length === 0 ? 200 : 207 }
{ status: failed.length === 0 ? 200 : 207 },
);
}

View File

@@ -18,22 +18,32 @@
* commits go through the `ship` tool.
*/
import { NextResponse } from 'next/server';
import { autosaveWorkspace } from '@/lib/dev-container';
import { query } from '@/lib/db-postgres';
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
import { NextResponse } from "next/server";
import { autosaveWorkspace } from "@/lib/dev-container";
import { query } from "@/lib/db-postgres";
import { getOrCreateProvisionedWorkspace } from "@/lib/workspaces";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: { projectId?: string; projectSlug?: string; sweep?: boolean };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// Single-project mode.
@@ -44,15 +54,18 @@ export async function POST(request: Request) {
[projectId],
);
if (row.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const ws = await getOrCreateProvisionedWorkspace({
userId: row[0].data?.userId ?? '',
email: row[0].data?.ownerEmail ?? '',
userId: row[0].data?.userId ?? "",
email: row[0].data?.ownerEmail ?? "",
displayName: row[0].workspace,
}).catch(() => null);
if (!ws) {
return NextResponse.json({ error: 'Workspace not provisioned' }, { status: 503 });
return NextResponse.json(
{ error: "Workspace not provisioned" },
{ status: 503 },
);
}
const result = await autosaveWorkspace({
projectId,
@@ -76,8 +89,8 @@ export async function POST(request: Request) {
);
if (proj.length === 0) continue;
const ws = await getOrCreateProvisionedWorkspace({
userId: proj[0].data?.userId ?? '',
email: proj[0].data?.ownerEmail ?? '',
userId: proj[0].data?.userId ?? "",
email: proj[0].data?.ownerEmail ?? "",
displayName: r.workspace,
}).catch(() => null);
if (!ws) continue;
@@ -85,14 +98,17 @@ export async function POST(request: Request) {
projectId: r.project_id,
projectSlug: proj[0].slug,
workspace: ws,
}).catch(err => ({ ran: false, reason: err instanceof Error ? err.message : String(err) }));
}).catch((err) => ({
ran: false,
reason: err instanceof Error ? err.message : String(err),
}));
out.push({ projectId: r.project_id, ran: res.ran, reason: res.reason });
}
return NextResponse.json({ result: { swept: out.length, out } });
}
return NextResponse.json(
{ error: 'Provide either { projectId } or { sweep: true }' },
{ error: "Provide either { projectId } or { sweep: true }" },
{ status: 400 },
);
}

View File

@@ -1,17 +1,27 @@
import { NextResponse } from 'next/server';
import { setFlag } from '@/lib/feature-flags';
import { NextResponse } from "next/server";
import { setFlag } from "@/lib/feature-flags";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
await setFlag('path_b_disabled', true);
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await setFlag("path_b_disabled", true);
return NextResponse.json({
ok: true,
flag: 'path_b_disabled',
flag: "path_b_disabled",
value: true,
note: 'Path B (AI dev containers) disabled. New chat sessions fall back to Gitea-write tools. Existing dev containers continue until idle-suspend.',
note: "Path B (AI dev containers) disabled. New chat sessions fall back to Gitea-write tools. Existing dev containers continue until idle-suspend.",
});
}

View File

@@ -1,17 +1,27 @@
import { NextResponse } from 'next/server';
import { setFlag } from '@/lib/feature-flags';
import { NextResponse } from "next/server";
import { setFlag } from "@/lib/feature-flags";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
await setFlag('path_b_disabled', false);
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await setFlag("path_b_disabled", false);
return NextResponse.json({
ok: true,
flag: 'path_b_disabled',
flag: "path_b_disabled",
value: false,
note: 'Path B re-enabled.',
note: "Path B re-enabled.",
});
}

View File

@@ -17,18 +17,31 @@
* the next shell.exec call resumes the service in <5s.
*/
import { NextResponse } from 'next/server';
import { suspendIdleContainers } from '@/lib/dev-container';
import { NextResponse } from "next/server";
import { suspendIdleContainers } from "@/lib/dev-container";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function POST(request: Request) {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) {
return NextResponse.json(
{ error: "NEXTAUTH_SECRET not configured" },
{ status: 503 },
);
}
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
if (!bearer || !timingSafeStringEq(expected, bearer)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const minStr = url.searchParams.get('minutes');
const minutes = minStr && Number.isFinite(Number(minStr)) ? Math.max(5, Number(minStr)) : 30;
const minStr = url.searchParams.get("minutes");
const minutes =
minStr && Number.isFinite(Number(minStr))
? Math.max(5, Number(minStr))
: 30;
const result = await suspendIdleContainers(minutes);
return NextResponse.json({ result, idleMinutes: minutes });
}

View File

@@ -20,19 +20,24 @@
* to every Vibn pod within ~10s of the SQL update.
*/
import { NextResponse } from 'next/server';
import { getFlag, setFlag } from '@/lib/feature-flags';
import { NextResponse } from "next/server";
import { getFlag } from "@/lib/feature-flags";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
function authorized(request: Request): boolean {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
return Boolean(bearer && process.env.NEXTAUTH_SECRET && bearer === process.env.NEXTAUTH_SECRET);
const expected = process.env.NEXTAUTH_SECRET ?? "";
if (!expected) return false;
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.toLowerCase().startsWith("bearer ")
? auth.slice(7).trim()
: "";
return Boolean(bearer && timingSafeStringEq(expected, bearer));
}
export async function GET(request: Request) {
if (!authorized(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const disabled = await getFlag<boolean>('path_b_disabled', false);
const disabled = await getFlag<boolean>("path_b_disabled", false);
return NextResponse.json({ disabled });
}

View File

@@ -1,44 +1,52 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
import type { LlmClient } from '@/lib/ai/llm-client';
import { query } from '@/lib/db-postgres';
import { MODE_SYSTEM_PROMPTS, ChatMode } from '@/lib/ai/chat-modes';
import { resolveChatMode } from '@/lib/server/chat-mode-resolver';
import { NextResponse } from "next/server";
import { z } from "zod";
import { GeminiLlmClient } from "@/lib/ai/gemini-client";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
import type { LlmClient } from "@/lib/ai/llm-client";
import { query } from "@/lib/db-postgres";
import { MODE_SYSTEM_PROMPTS, ChatMode } from "@/lib/ai/chat-modes";
import { resolveChatMode } from "@/lib/server/chat-mode-resolver";
import {
buildProjectContextForChat,
determineArtifactsUsed,
formatContextForPrompt,
} from '@/lib/server/chat-context';
import { logProjectEvent } from '@/lib/server/logs';
import type { CollectorPhaseHandoff } from '@/lib/types/phase-handoff';
} from "@/lib/server/chat-context";
import { logProjectEvent } from "@/lib/server/logs";
import type { CollectorPhaseHandoff } from "@/lib/types/phase-handoff";
// Increase timeout for Gemini 3 Pro thinking mode (can take 1-2 minutes)
export const maxDuration = 180; // 3 minutes
export const dynamic = 'force-dynamic';
export const dynamic = "force-dynamic";
const ChatReplySchema = z.object({
reply: z.string(),
visionAnswers: z.object({
q1: z.string().optional(), // Answer to question 1
q2: z.string().optional(), // Answer to question 2
q3: z.string().optional(), // Answer to question 3
allAnswered: z.boolean().optional(), // True when all 3 are complete
}).optional(),
collectorHandoff: z.object({
hasDocuments: z.boolean().optional(),
documentCount: z.number().optional(),
githubConnected: z.boolean().optional(),
githubRepo: z.string().optional(),
extensionLinked: z.boolean().optional(),
extensionDeclined: z.boolean().optional(),
noGithubYet: z.boolean().optional(),
readyForExtraction: z.boolean().optional(),
}).optional(),
extractionReviewHandoff: z.object({
extractionApproved: z.boolean().optional(),
readyForVision: z.boolean().optional(),
}).optional(),
visionAnswers: z
.object({
q1: z.string().optional(), // Answer to question 1
q2: z.string().optional(), // Answer to question 2
q3: z.string().optional(), // Answer to question 3
allAnswered: z.boolean().optional(), // True when all 3 are complete
})
.optional(),
collectorHandoff: z
.object({
hasDocuments: z.boolean().optional(),
documentCount: z.number().optional(),
githubConnected: z.boolean().optional(),
githubRepo: z.string().optional(),
extensionLinked: z.boolean().optional(),
extensionDeclined: z.boolean().optional(),
noGithubYet: z.boolean().optional(),
readyForExtraction: z.boolean().optional(),
})
.optional(),
extractionReviewHandoff: z
.object({
extractionApproved: z.boolean().optional(),
readyForVision: z.boolean().optional(),
})
.optional(),
});
interface ChatRequestBody {
@@ -57,7 +65,7 @@ const ENSURE_CONV_TABLE = `
async function appendConversation(
projectId: string,
newMessages: Array<{ role: 'user' | 'assistant'; content: string }>,
newMessages: Array<{ role: "user" | "assistant"; content: string }>,
) {
await query(ENSURE_CONV_TABLE);
const now = new Date().toISOString();
@@ -69,52 +77,62 @@ async function appendConversation(
ON CONFLICT (project_id) DO UPDATE
SET messages = chat_conversations.messages || $2::jsonb,
updated_at = NOW()`,
[projectId, JSON.stringify(stamped)]
[projectId, JSON.stringify(stamped)],
);
}
export async function POST(request: Request) {
try {
const body = (await request.json()) as ChatRequestBody;
const projectId = body.projectId?.trim();
const message = body.message?.trim();
export const POST = withTenantProject(
async (request, _ctx, { project, user }) => {
try {
const body = (await request.json()) as ChatRequestBody;
const projectId = project.id;
const message = body.message?.trim();
if (!projectId || !message) {
return NextResponse.json({ error: 'projectId and message are required' }, { status: 400 });
}
if (!message) {
return NextResponse.json(
{ error: "message is required" },
{ status: 400 },
);
}
// Verify project exists in Postgres
const projectRows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (projectRows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
const projectData = projectRows[0].data ?? {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const projectData = (project.data ?? {}) as any;
log.info("ai/chat: starting", {
route: "api.ai.chat",
projectId,
user: user.email,
});
// Resolve chat mode (uses new resolver)
const resolvedMode = body.overrideMode ?? await resolveChatMode(projectId);
console.log(`[AI Chat] Mode: ${resolvedMode}`);
// Resolve chat mode (uses new resolver)
const resolvedMode =
body.overrideMode ?? (await resolveChatMode(projectId));
console.log(`[AI Chat] Mode: ${resolvedMode}`);
// Build comprehensive context with vector retrieval
// Only include GitHub analysis for MVP generation (not needed for vision questions)
const context = await buildProjectContextForChat(projectId, resolvedMode, message, {
retrievalLimit: 10,
includeVectorSearch: true,
includeGitHubAnalysis: resolvedMode === 'mvp_mode', // Only load repo analysis when generating MVP
});
// Build comprehensive context with vector retrieval
// Only include GitHub analysis for MVP generation (not needed for vision questions)
const context = await buildProjectContextForChat(
projectId,
resolvedMode,
message,
{
retrievalLimit: 10,
includeVectorSearch: true,
includeGitHubAnalysis: resolvedMode === "mvp_mode", // Only load repo analysis when generating MVP
},
);
console.log(`[AI Chat] Context built: ${context.retrievedChunks.length} vector chunks retrieved`);
console.log(
`[AI Chat] Context built: ${context.retrievedChunks.length} vector chunks retrieved`,
);
// Get mode-specific system prompt
const systemPrompt = MODE_SYSTEM_PROMPTS[resolvedMode];
// Get mode-specific system prompt
const systemPrompt = MODE_SYSTEM_PROMPTS[resolvedMode];
// Format context for LLM
const contextSummary = formatContextForPrompt(context);
// Format context for LLM
const contextSummary = formatContextForPrompt(context);
// Prepare enhanced system prompt with context
const enhancedSystemPrompt = `${systemPrompt}
// Prepare enhanced system prompt with context
const enhancedSystemPrompt = `${systemPrompt}
## Current Project Context
@@ -126,208 +144,248 @@ You have access to:
- Project artifacts (product model, MVP plan, marketing plan)
- Knowledge items (${context.knowledgeSummary.totalCount} total)
- Extraction signals (${context.extractionSummary.totalCount} analyzed)
${context.retrievedChunks.length > 0 ? `- ${context.retrievedChunks.length} relevant chunks from vector search (most similar to user's query)` : ''}
${context.repositoryAnalysis ? `- GitHub repository analysis (${context.repositoryAnalysis.totalFiles} files)` : ''}
${context.sessionHistory.totalSessions > 0 ? `- Complete Cursor session history (${context.sessionHistory.totalSessions} sessions, ${context.sessionHistory.messages.length} messages in chronological order)` : ''}
${context.retrievedChunks.length > 0 ? `- ${context.retrievedChunks.length} relevant chunks from vector search (most similar to user's query)` : ""}
${context.repositoryAnalysis ? `- GitHub repository analysis (${context.repositoryAnalysis.totalFiles} files)` : ""}
${context.sessionHistory.totalSessions > 0 ? `- Complete Cursor session history (${context.sessionHistory.totalSessions} sessions, ${context.sessionHistory.messages.length} messages in chronological order)` : ""}
Use this context to provide specific, grounded responses. The session history shows your complete conversation history with the user - use it to understand what has been built and discussed.`;
// Load existing conversation history from Postgres
await query(ENSURE_CONV_TABLE);
const convRows = await query<{ messages: any[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[projectId]
);
const conversationHistory: any[] = convRows[0]?.messages ?? [];
// Load existing conversation history from Postgres
await query(ENSURE_CONV_TABLE);
const convRows = await query<{ messages: any[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[projectId],
);
const conversationHistory: any[] = convRows[0]?.messages ?? [];
// Build full message context (history + current message)
const messages = [
...conversationHistory.map((msg: any) => ({
role: msg.role as 'user' | 'assistant',
content: msg.content as string,
})),
{
role: 'user' as const,
content: message,
},
];
// Build full message context (history + current message)
const messages = [
...conversationHistory.map((msg: any) => ({
role: msg.role as "user" | "assistant",
content: msg.content as string,
})),
{
role: "user" as const,
content: message,
},
];
console.log(`[AI Chat] Sending ${messages.length} messages to LLM (${conversationHistory.length} from history + 1 new)`);
console.log(`[AI Chat] Mode: ${resolvedMode}, Phase: ${projectData.currentPhase}, Has extraction: ${!!context.phaseHandoffs?.extraction}`);
// Log system prompt length
console.log(`[AI Chat] System prompt length: ${enhancedSystemPrompt.length} chars (~${Math.ceil(enhancedSystemPrompt.length / 4)} tokens)`);
// Log each message length
messages.forEach((msg, i) => {
console.log(`[AI Chat] Message ${i + 1} (${msg.role}): ${msg.content.length} chars (~${Math.ceil(msg.content.length / 4)} tokens)`);
});
const totalInputChars = enhancedSystemPrompt.length + messages.reduce((sum, msg) => sum + msg.content.length, 0);
console.log(`[AI Chat] Total input: ${totalInputChars} chars (~${Math.ceil(totalInputChars / 4)} tokens)`);
// Log system prompt preview (first 500 chars)
console.log(`[AI Chat] System prompt preview: ${enhancedSystemPrompt.substring(0, 500)}...`);
// Log last user message
const lastUserMsg = messages[messages.length - 1];
console.log(`[AI Chat] User message: ${lastUserMsg.content}`);
// Safety check: extraction_review_mode requires extraction results
if (resolvedMode === 'extraction_review_mode' && !context.phaseHandoffs?.extraction) {
console.warn(`[AI Chat] WARNING: extraction_review_mode active but no extraction results found for project ${projectId}`);
}
console.log(
`[AI Chat] Sending ${messages.length} messages to LLM (${conversationHistory.length} from history + 1 new)`,
);
console.log(
`[AI Chat] Mode: ${resolvedMode}, Phase: ${projectData.currentPhase}, Has extraction: ${!!context.phaseHandoffs?.extraction}`,
);
const llm: LlmClient = new GeminiLlmClient();
// Log system prompt length
console.log(
`[AI Chat] System prompt length: ${enhancedSystemPrompt.length} chars (~${Math.ceil(enhancedSystemPrompt.length / 4)} tokens)`,
);
// Configure thinking mode based on task complexity
// Simple modes (collector, extraction_review) don't need deep thinking
// Complex modes (mvp, vision) benefit from extended reasoning
const needsThinking = resolvedMode === 'mvp_mode' || resolvedMode === 'vision_mode';
// Log each message length
messages.forEach((msg, i) => {
console.log(
`[AI Chat] Message ${i + 1} (${msg.role}): ${msg.content.length} chars (~${Math.ceil(msg.content.length / 4)} tokens)`,
);
});
const reply = await llm.structuredCall<{
reply: string;
visionAnswers?: {
q1?: string;
q2?: string;
q3?: string;
allAnswered?: boolean;
};
collectorHandoff?: {
hasDocuments?: boolean;
documentCount?: number;
githubConnected?: boolean;
githubRepo?: string;
extensionLinked?: boolean;
extensionDeclined?: boolean;
noGithubYet?: boolean;
readyForExtraction?: boolean;
};
extractionReviewHandoff?: {
extractionApproved?: boolean;
readyForVision?: boolean;
};
}>({
model: 'gemini',
systemPrompt: enhancedSystemPrompt,
messages: messages, // Full conversation history!
schema: ChatReplySchema,
temperature: 0.4,
thinking_config: needsThinking ? {
thinking_level: 'high',
include_thoughts: false,
} : undefined,
});
const totalInputChars =
enhancedSystemPrompt.length +
messages.reduce((sum, msg) => sum + msg.content.length, 0);
console.log(
`[AI Chat] Total input: ${totalInputChars} chars (~${Math.ceil(totalInputChars / 4)} tokens)`,
);
// Store all vision answers when provided
if (reply.visionAnswers) {
const updates: any = {};
if (reply.visionAnswers.q1) {
updates['visionAnswers.q1'] = reply.visionAnswers.q1;
console.log('[AI Chat] Storing vision answer Q1');
// Log system prompt preview (first 500 chars)
console.log(
`[AI Chat] System prompt preview: ${enhancedSystemPrompt.substring(0, 500)}...`,
);
// Log last user message
const lastUserMsg = messages[messages.length - 1];
console.log(`[AI Chat] User message: ${lastUserMsg.content}`);
// Safety check: extraction_review_mode requires extraction results
if (
resolvedMode === "extraction_review_mode" &&
!context.phaseHandoffs?.extraction
) {
console.warn(
`[AI Chat] WARNING: extraction_review_mode active but no extraction results found for project ${projectId}`,
);
}
if (reply.visionAnswers.q2) {
updates['visionAnswers.q2'] = reply.visionAnswers.q2;
console.log('[AI Chat] Storing vision answer Q2');
}
if (reply.visionAnswers.q3) {
updates['visionAnswers.q3'] = reply.visionAnswers.q3;
console.log('[AI Chat] Storing vision answer Q3');
}
// If all answers are complete, trigger MVP generation
if (reply.visionAnswers.allAnswered) {
updates['visionAnswers.allAnswered'] = true;
updates['readyForMVP'] = true;
console.log('[AI Chat] ✅ All 3 vision answers complete - ready for MVP generation');
}
if (Object.keys(updates).length > 0) {
updates['visionAnswers.updatedAt'] = new Date().toISOString();
await query(
`UPDATE fs_projects
const llm: LlmClient = new GeminiLlmClient();
// Configure thinking mode based on task complexity
// Simple modes (collector, extraction_review) don't need deep thinking
// Complex modes (mvp, vision) benefit from extended reasoning
const needsThinking =
resolvedMode === "mvp_mode" || resolvedMode === "vision_mode";
const reply = await llm.structuredCall<{
reply: string;
visionAnswers?: {
q1?: string;
q2?: string;
q3?: string;
allAnswered?: boolean;
};
collectorHandoff?: {
hasDocuments?: boolean;
documentCount?: number;
githubConnected?: boolean;
githubRepo?: string;
extensionLinked?: boolean;
extensionDeclined?: boolean;
noGithubYet?: boolean;
readyForExtraction?: boolean;
};
extractionReviewHandoff?: {
extractionApproved?: boolean;
readyForVision?: boolean;
};
}>({
model: "gemini",
systemPrompt: enhancedSystemPrompt,
messages: messages, // Full conversation history!
schema: ChatReplySchema,
temperature: 0.4,
thinking_config: needsThinking
? {
thinking_level: "high",
include_thoughts: false,
}
: undefined,
});
// Store all vision answers when provided
if (reply.visionAnswers) {
const updates: any = {};
if (reply.visionAnswers.q1) {
updates["visionAnswers.q1"] = reply.visionAnswers.q1;
console.log("[AI Chat] Storing vision answer Q1");
}
if (reply.visionAnswers.q2) {
updates["visionAnswers.q2"] = reply.visionAnswers.q2;
console.log("[AI Chat] Storing vision answer Q2");
}
if (reply.visionAnswers.q3) {
updates["visionAnswers.q3"] = reply.visionAnswers.q3;
console.log("[AI Chat] Storing vision answer Q3");
}
// If all answers are complete, trigger MVP generation
if (reply.visionAnswers.allAnswered) {
updates["visionAnswers.allAnswered"] = true;
updates["readyForMVP"] = true;
console.log(
"[AI Chat] ✅ All 3 vision answers complete - ready for MVP generation",
);
}
if (Object.keys(updates).length > 0) {
updates["visionAnswers.updatedAt"] = new Date().toISOString();
await query(
`UPDATE fs_projects
SET data = data || $1::jsonb
WHERE id = $2`,
[JSON.stringify({ visionAnswers: updates }), projectId]
).catch((error) => {
console.error('[ai/chat] Failed to store vision answers', error);
});
}
}
// Best-effort: append this turn to the persisted conversation history
appendConversation(projectId, [
{ role: 'user', content: message },
{ role: 'assistant', content: reply.reply },
]).catch((error) => {
console.error('[ai/chat] Failed to append conversation history', error);
});
// If in collector mode, always update handoff state based on actual project context
// This ensures the checklist updates even if AI doesn't return collectorHandoff
if (resolvedMode === 'collector_mode') {
// Derive handoff state from actual project context
const hasDocuments = (context.knowledgeSummary.bySourceType['imported_document'] ?? 0) > 0;
const documentCount = context.knowledgeSummary.bySourceType['imported_document'] ?? 0;
const githubConnected = !!context.project.githubRepo;
const extensionLinked = context.project.extensionLinked ?? false;
// Check if AI indicated readiness (from reply if provided, otherwise check reply text)
let readyForExtraction = reply.collectorHandoff?.readyForExtraction ?? false;
// Fallback: If AI says certain phrases, assume user confirmed readiness
// IMPORTANT: These phrases must be SPECIFIC to avoid false positives
if (!readyForExtraction && reply.reply) {
const replyLower = reply.reply.toLowerCase();
// Check for explicit analysis/digging phrases (not just "perfect!")
const analysisKeywords = ['analyze', 'analyzing', 'digging', 'extraction', 'processing'];
const hasAnalysisKeyword = analysisKeywords.some(keyword => replyLower.includes(keyword));
// Only trigger if AI mentions BOTH readiness AND analysis action
if (hasAnalysisKeyword) {
const confirmPhrases = [
'let me analyze what you',
'i\'ll start digging into',
'i\'m starting the analysis',
'running the extraction',
'processing what you\'ve shared',
];
readyForExtraction = confirmPhrases.some(phrase => replyLower.includes(phrase));
if (readyForExtraction) {
console.log(`[AI Chat] Detected readiness from AI reply text: "${reply.reply.substring(0, 100)}"`);
}
[JSON.stringify({ visionAnswers: updates }), projectId],
).catch((error) => {
console.error("[ai/chat] Failed to store vision answers", error);
});
}
}
const handoff: CollectorPhaseHandoff = {
phase: 'collector',
readyForNextPhase: readyForExtraction,
confidence: readyForExtraction ? 0.9 : 0.5,
confirmed: {
hasDocuments,
documentCount,
githubConnected,
githubRepo: context.project.githubRepo ?? undefined,
extensionLinked,
},
uncertain: {
extensionDeclined: reply.collectorHandoff?.extensionDeclined ?? false,
noGithubYet: reply.collectorHandoff?.noGithubYet ?? false,
},
missing: [],
questionsForUser: [],
sourceEvidence: [],
version: '1.0',
timestamp: new Date().toISOString(),
};
// Best-effort: append this turn to the persisted conversation history
appendConversation(projectId, [
{ role: "user", content: message },
{ role: "assistant", content: reply.reply },
]).catch((error) => {
console.error("[ai/chat] Failed to append conversation history", error);
});
// Persist to project phaseData in Postgres
await query(
`UPDATE fs_projects
// If in collector mode, always update handoff state based on actual project context
// This ensures the checklist updates even if AI doesn't return collectorHandoff
if (resolvedMode === "collector_mode") {
// Derive handoff state from actual project context
const hasDocuments =
(context.knowledgeSummary.bySourceType["imported_document"] ?? 0) > 0;
const documentCount =
context.knowledgeSummary.bySourceType["imported_document"] ?? 0;
const githubConnected = !!context.project.githubRepo;
const extensionLinked = context.project.extensionLinked ?? false;
// Check if AI indicated readiness (from reply if provided, otherwise check reply text)
let readyForExtraction =
reply.collectorHandoff?.readyForExtraction ?? false;
// Fallback: If AI says certain phrases, assume user confirmed readiness
// IMPORTANT: These phrases must be SPECIFIC to avoid false positives
if (!readyForExtraction && reply.reply) {
const replyLower = reply.reply.toLowerCase();
// Check for explicit analysis/digging phrases (not just "perfect!")
const analysisKeywords = [
"analyze",
"analyzing",
"digging",
"extraction",
"processing",
];
const hasAnalysisKeyword = analysisKeywords.some((keyword) =>
replyLower.includes(keyword),
);
// Only trigger if AI mentions BOTH readiness AND analysis action
if (hasAnalysisKeyword) {
const confirmPhrases = [
"let me analyze what you",
"i'll start digging into",
"i'm starting the analysis",
"running the extraction",
"processing what you've shared",
];
readyForExtraction = confirmPhrases.some((phrase) =>
replyLower.includes(phrase),
);
if (readyForExtraction) {
console.log(
`[AI Chat] Detected readiness from AI reply text: "${reply.reply.substring(0, 100)}"`,
);
}
}
}
const handoff: CollectorPhaseHandoff = {
phase: "collector",
readyForNextPhase: readyForExtraction,
confidence: readyForExtraction ? 0.9 : 0.5,
confirmed: {
hasDocuments,
documentCount,
githubConnected,
githubRepo: context.project.githubRepo ?? undefined,
extensionLinked,
},
uncertain: {
extensionDeclined:
reply.collectorHandoff?.extensionDeclined ?? false,
noGithubYet: reply.collectorHandoff?.noGithubYet ?? false,
},
missing: [],
questionsForUser: [],
sourceEvidence: [],
version: "1.0",
timestamp: new Date().toISOString(),
};
// Persist to project phaseData in Postgres
await query(
`UPDATE fs_projects
SET data = jsonb_set(
data,
'{phaseData,phaseHandoffs,collector}',
@@ -335,77 +393,93 @@ Use this context to provide specific, grounded responses. The session history sh
true
)
WHERE id = $2`,
[JSON.stringify(handoff), projectId]
).catch((error) => {
console.error('[ai/chat] Failed to persist collector handoff', error);
});
[JSON.stringify(handoff), projectId],
).catch((error) => {
console.error("[ai/chat] Failed to persist collector handoff", error);
});
console.log(`[AI Chat] Collector handoff persisted:`, {
hasDocuments: handoff.confirmed.hasDocuments,
githubConnected: handoff.confirmed.githubConnected,
extensionLinked: handoff.confirmed.extensionLinked,
readyForExtraction: handoff.readyForNextPhase,
});
console.log(`[AI Chat] Collector handoff persisted:`, {
hasDocuments: handoff.confirmed.hasDocuments,
githubConnected: handoff.confirmed.githubConnected,
extensionLinked: handoff.confirmed.extensionLinked,
readyForExtraction: handoff.readyForNextPhase,
});
// Auto-transition to extraction phase if ready
if (handoff.readyForNextPhase) {
console.log(`[AI Chat] Collector complete - triggering backend extraction`);
// Mark collector as complete
await query(
`UPDATE fs_projects
// Auto-transition to extraction phase if ready
if (handoff.readyForNextPhase) {
console.log(
`[AI Chat] Collector complete - triggering backend extraction`,
);
// Mark collector as complete
await query(
`UPDATE fs_projects
SET data = jsonb_set(data, '{phaseData,collectorCompletedAt}', $1::jsonb, true)
WHERE id = $2`,
[JSON.stringify(new Date().toISOString()), projectId]
).catch((error) => {
console.error('[ai/chat] Failed to mark collector complete', error);
});
// Trigger backend extraction (async - don't await)
import('@/lib/server/backend-extractor').then(({ runBackendExtractionForProject }) => {
runBackendExtractionForProject(projectId).catch((error) => {
console.error(`[AI Chat] Backend extraction failed for project ${projectId}:`, error);
[JSON.stringify(new Date().toISOString()), projectId],
).catch((error) => {
console.error("[ai/chat] Failed to mark collector complete", error);
});
});
}
}
// Handle extraction review → vision phase transition
if (resolvedMode === 'extraction_review_mode') {
// Check if AI indicated extraction is approved and ready for vision
let readyForVision = reply.extractionReviewHandoff?.readyForVision ?? false;
// Fallback: Check reply text for approval phrases
if (!readyForVision && reply.reply) {
const replyLower = reply.reply.toLowerCase();
// Check for vision transition phrases
const visionKeywords = ['vision', 'mvp', 'roadmap', 'plan'];
const hasVisionKeyword = visionKeywords.some(keyword => replyLower.includes(keyword));
if (hasVisionKeyword) {
const confirmPhrases = [
'ready to move to',
'ready for vision',
'let\'s move to vision',
'moving to vision',
'great! let\'s define',
'perfect! now let\'s',
];
readyForVision = confirmPhrases.some(phrase => replyLower.includes(phrase));
if (readyForVision) {
console.log(`[AI Chat] Detected vision readiness from AI reply text: "${reply.reply.substring(0, 100)}"`);
}
// Trigger backend extraction (async - don't await)
import("@/lib/server/backend-extractor").then(
({ runBackendExtractionForProject }) => {
runBackendExtractionForProject(projectId).catch((error) => {
console.error(
`[AI Chat] Backend extraction failed for project ${projectId}:`,
error,
);
});
},
);
}
}
if (readyForVision) {
console.log(`[AI Chat] Extraction review complete - transitioning to vision phase`);
// Mark extraction review as complete and transition to vision
await query(
`UPDATE fs_projects
// Handle extraction review → vision phase transition
if (resolvedMode === "extraction_review_mode") {
// Check if AI indicated extraction is approved and ready for vision
let readyForVision =
reply.extractionReviewHandoff?.readyForVision ?? false;
// Fallback: Check reply text for approval phrases
if (!readyForVision && reply.reply) {
const replyLower = reply.reply.toLowerCase();
// Check for vision transition phrases
const visionKeywords = ["vision", "mvp", "roadmap", "plan"];
const hasVisionKeyword = visionKeywords.some((keyword) =>
replyLower.includes(keyword),
);
if (hasVisionKeyword) {
const confirmPhrases = [
"ready to move to",
"ready for vision",
"let's move to vision",
"moving to vision",
"great! let's define",
"perfect! now let's",
];
readyForVision = confirmPhrases.some((phrase) =>
replyLower.includes(phrase),
);
if (readyForVision) {
console.log(
`[AI Chat] Detected vision readiness from AI reply text: "${reply.reply.substring(0, 100)}"`,
);
}
}
}
if (readyForVision) {
console.log(
`[AI Chat] Extraction review complete - transitioning to vision phase`,
);
// Mark extraction review as complete and transition to vision
await query(
`UPDATE fs_projects
SET data = data
|| '{"currentPhase":"vision","phaseStatus":"in_progress"}'::jsonb
|| jsonb_build_object('phaseData',
@@ -414,86 +488,99 @@ Use this context to provide specific, grounded responses. The session history sh
)
)
WHERE id = $2`,
[new Date().toISOString(), projectId]
).catch((error) => {
console.error('[ai/chat] Failed to transition to vision phase', error);
});
[new Date().toISOString(), projectId],
).catch((error) => {
console.error(
"[ai/chat] Failed to transition to vision phase",
error,
);
});
}
}
}
// Save conversation history to Postgres
await appendConversation(projectId, [
{ role: 'user', content: message },
{ role: 'assistant', content: reply.reply },
]).catch((error) => {
console.error('[ai/chat] Failed to save conversation history', error);
});
// Save conversation history to Postgres
await appendConversation(projectId, [
{ role: "user", content: message },
{ role: "assistant", content: reply.reply },
]).catch((error) => {
console.error("[ai/chat] Failed to save conversation history", error);
});
console.log(`[AI Chat] Conversation history saved (+2 messages)`);
console.log(`[AI Chat] Conversation history saved (+2 messages)`);
// Determine which artifacts were used
const artifactsUsed = determineArtifactsUsed(context);
// Determine which artifacts were used
const artifactsUsed = determineArtifactsUsed(context);
// Log successful interaction
logProjectEvent({
projectId,
userId: projectData.userId ?? null,
eventType: 'chat_interaction',
mode: resolvedMode,
phase: projectData.currentPhase ?? null,
artifactsUsed,
usedVectorSearch: context.retrievedChunks.length > 0,
vectorChunkCount: context.retrievedChunks.length,
promptVersion: '2.0', // Updated with vector search
modelUsed: process.env.VERTEX_AI_MODEL || 'gemini-3-pro-preview',
success: true,
errorMessage: null,
metadata: {
knowledgeCount: context.knowledgeSummary.totalCount,
extractionCount: context.extractionSummary.totalCount,
hasGithubRepo: !!context.repositoryAnalysis,
},
}).catch((err) => console.error('[ai/chat] Failed to log event:', err));
return NextResponse.json({
reply: reply.reply,
mode: resolvedMode,
projectPhase: projectData.currentPhase ?? null,
artifactsUsed,
usedVectorSearch: context.retrievedChunks.length > 0,
});
} catch (error) {
console.error('[ai/chat] Error handling chat request', error);
// Log error (best-effort) - extract projectId from request body if available
const errorProjectId = typeof (error as { projectId?: string })?.projectId === 'string'
? (error as { projectId: string }).projectId
: null;
if (errorProjectId) {
// Log successful interaction
logProjectEvent({
projectId: errorProjectId,
userId: null,
eventType: 'error',
mode: null,
phase: null,
artifactsUsed: [],
usedVectorSearch: false,
promptVersion: '2.0',
modelUsed: process.env.VERTEX_AI_MODEL || 'gemini-3-pro-preview',
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
}).catch((err) => console.error('[ai/chat] Failed to log error:', err));
projectId,
userId: projectData.userId ?? null,
eventType: "chat_interaction",
mode: resolvedMode,
phase: projectData.currentPhase ?? null,
artifactsUsed,
usedVectorSearch: context.retrievedChunks.length > 0,
vectorChunkCount: context.retrievedChunks.length,
promptVersion: "2.0", // Updated with vector search
modelUsed: process.env.VERTEX_AI_MODEL || "gemini-3-pro-preview",
success: true,
errorMessage: null,
metadata: {
knowledgeCount: context.knowledgeSummary.totalCount,
extractionCount: context.extractionSummary.totalCount,
hasGithubRepo: !!context.repositoryAnalysis,
},
}).catch((err) => console.error("[ai/chat] Failed to log event:", err));
return NextResponse.json({
reply: reply.reply,
mode: resolvedMode,
projectPhase: projectData.currentPhase ?? null,
artifactsUsed,
usedVectorSearch: context.retrievedChunks.length > 0,
});
} catch (error) {
console.error("[ai/chat] Error handling chat request", error);
// Log error (best-effort) - extract projectId from request body if available
const errorProjectId =
typeof (error as { projectId?: string })?.projectId === "string"
? (error as { projectId: string }).projectId
: null;
if (errorProjectId) {
logProjectEvent({
projectId: errorProjectId,
userId: null,
eventType: "error",
mode: null,
phase: null,
artifactsUsed: [],
usedVectorSearch: false,
promptVersion: "2.0",
modelUsed: process.env.VERTEX_AI_MODEL || "gemini-3-pro-preview",
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
}).catch((err) =>
log.error("ai/chat log failed", {
route: "api.ai.chat",
err: err instanceof Error ? err.message : String(err),
}),
);
}
log.error("ai/chat error", {
route: "api.ai.chat",
err: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: "Failed to process chat message",
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
return NextResponse.json(
{
error: 'Failed to process chat message',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}
},
{ source: "body", paramName: "projectId" },
);

View File

@@ -1,37 +1,38 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
/**
* POST /api/ai/conversation/reset
* Body OR query: { projectId }
*
* Deletes the conversation history for a project the caller owns.
*
* Closes S-03. Also migrated off the legacy Firebase admin path onto Postgres
* to match `/api/ai/conversation`.
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
export async function POST(request: Request) {
try {
const url = new URL(request.url);
const body = await request
.json()
.catch(() => ({ projectId: url.searchParams.get('projectId') }));
const projectId = (body?.projectId ?? url.searchParams.get('projectId') ?? '').trim();
if (!projectId) {
export const POST = withTenantProject(
async (_request, _ctx, { project }) => {
try {
await query(`DELETE FROM chat_conversations WHERE project_id = $1`, [
project.id,
]);
return NextResponse.json({ success: true });
} catch (err) {
log.error("ai/conversation/reset failed", {
route: "api.ai.conversation.reset",
projectId: project.id,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{ error: 'projectId is required' },
{ status: 400 },
{
error: "Failed to reset conversation",
details: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
);
}
const adminDb = getAdminDb();
const docRef = adminDb.collection('chat_conversations').doc(projectId);
await docRef.delete();
return NextResponse.json({ success: true });
} catch (error) {
console.error('[ai/conversation/reset] Failed to reset conversation', error);
return NextResponse.json(
{
error: 'Failed to reset conversation',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}
},
{ source: "body", paramName: "projectId" },
);

View File

@@ -1,5 +1,13 @@
import { NextResponse } from 'next/server';
import { query } from '@/lib/db-postgres';
/**
* GET /api/ai/conversation?projectId=… — fetch saved conversation
* DELETE /api/ai/conversation?projectId=… — wipe saved conversation
*
* Closes S-02: was completely unauthenticated and accepted any projectId.
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
const ENSURE_TABLE = `
CREATE TABLE IF NOT EXISTS chat_conversations (
@@ -9,7 +17,7 @@ const ENSURE_TABLE = `
)
`;
type StoredMessageRole = 'user' | 'assistant';
type StoredMessageRole = "user" | "assistant";
type ConversationMessage = {
role: StoredMessageRole;
@@ -21,49 +29,48 @@ type ConversationResponse = {
messages: ConversationMessage[];
};
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const projectId = (url.searchParams.get('projectId') ?? '').trim();
if (!projectId) {
return NextResponse.json({ error: 'projectId is required' }, { status: 400 });
export const GET = withTenantProject(
async (request, _ctx, { project }) => {
try {
await query(ENSURE_TABLE);
const rows = await query<{ messages: ConversationMessage[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[project.id],
);
const messages: ConversationMessage[] = rows[0]?.messages ?? [];
const response: ConversationResponse = { messages };
return NextResponse.json(response);
} catch (err) {
log.error("ai/conversation GET failed", {
route: "api.ai.conversation",
projectId: project.id,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json({ messages: [] });
}
},
{ source: "search", paramName: "projectId" },
);
await query(ENSURE_TABLE);
const rows = await query<{ messages: ConversationMessage[] }>(
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
[projectId]
);
const messages: ConversationMessage[] = rows[0]?.messages ?? [];
const response: ConversationResponse = { messages };
return NextResponse.json(response);
} catch (error) {
console.error('[GET /api/ai/conversation] Error:', error);
return NextResponse.json({ messages: [] });
}
}
export async function DELETE(request: Request) {
try {
const url = new URL(request.url);
const projectId = (url.searchParams.get('projectId') ?? '').trim();
if (!projectId) {
return NextResponse.json({ error: 'projectId is required' }, { status: 400 });
export const DELETE = withTenantProject(
async (_request, _ctx, { project }) => {
try {
await query(ENSURE_TABLE);
await query(`DELETE FROM chat_conversations WHERE project_id = $1`, [
project.id,
]);
return NextResponse.json({ ok: true });
} catch (err) {
log.error("ai/conversation DELETE failed", {
route: "api.ai.conversation",
projectId: project.id,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{ error: "Failed to reset conversation" },
{ status: 500 },
);
}
await query(ENSURE_TABLE);
await query(
`DELETE FROM chat_conversations WHERE project_id = $1`,
[projectId]
);
return NextResponse.json({ ok: true });
} catch (error) {
console.error('[DELETE /api/ai/conversation] Error:', error);
return NextResponse.json({ error: 'Failed to reset conversation' }, { status: 500 });
}
}
},
{ source: "search", paramName: "projectId" },
);

View File

@@ -33,11 +33,10 @@ import { buildDesignKitPromptSection } from "@/lib/design-kits/for-ai";
import { buildCodebaseSummary } from "@/lib/ai/project-context/codebase-summary";
import type { ChatMessage, ToolCall } from "@/lib/ai/gemini-chat";
// Path B chains routinely fire 7-10 tool calls in one user turn. 18
// gives enough headroom for complex workflows (scaffold → install →
// configure → start) while still capping runaway loops. When the cap
// IS hit, we emit a recovery summary instead of silent tool pills.
const MAX_TOOL_ROUNDS = 15;
// C-01: Lowered from 15 → 8. Real workflows (scaffold → install →
// configure → start) rarely need more than 8 rounds when done correctly.
// If the cap IS hit the model gets a recovery summary, not silence.
const MAX_TOOL_ROUNDS = 8;
let chatTablesReady = false;
async function ensureChatTables() {
@@ -151,6 +150,20 @@ After every assistant turn, the harness automatically runs \`git add -A && git c
You're talking to the owner of the "${workspace}" workspace. They have admin access to their Gitea org, a fleet of Coolify projects, and a persistent dev container per project. You can read and write any of it.
## Mode: respond first, act second
Before calling any tool, decide: is the user asking a question, or telling you to do something?
**CONVERSATIONAL inputs — respond with text only, no tools:**
- One-word or greeting messages: "test", "hi", "ok", "thanks"
- Questions ending in "?": "are you able to…?", "what does X mean?", "how would you…?"
- Status checks: "is it deployed?", "what's running?" (one read-only tool MAX, then respond)
**ACTION inputs — tools allowed:**
- Imperatives: "deploy it", "build me X", "fix the navbar", "ship"
- Specific tasks with clear deliverables: "add Stripe to the pricing page"
If you are unsure which mode the user is in, **default to CONVERSATIONAL** and ask one clarifying sentence before acting. "Want me to actually deploy this to prod now, or were you just checking?" is always cheaper than a silent 16-tool spiral.
## Identity
You are a high-agency product engineer. You own the outcome. Continue until the user's goal is actually resolved unless you're blocked on missing info, proceeding would be unsafe, or the user changes direction. You are not answering questions; you are building with the user. Translate engineering complexity into product momentum.
@@ -530,6 +543,9 @@ export async function POST(request: Request) {
const stream = new ReadableStream({
async start(controller) {
let streamClosed = false;
// C-06: Per-turn correlation ID so prod logs are greppable.
const turnId = crypto.randomUUID();
function emit(chunk: object) {
if (streamClosed) return;
try {
@@ -544,11 +560,21 @@ export async function POST(request: Request) {
function safeClose() {
if (streamClosed) return;
streamClosed = true;
clearInterval(heartbeat);
try {
controller.close();
} catch {}
}
// C-04: SSE heartbeat every 25s keeps Cloudflare / proxies from
// dropping the connection during long Gemini thinking phases.
const heartbeat = setInterval(() => {
emit({ type: "ping", turnId });
}, 25_000);
// Emit turnId immediately so the client can log/correlate.
emit({ type: "turn_start", turnId });
let messages = [...history];
let round = 0;
let assistantText = "";
@@ -616,6 +642,29 @@ export async function POST(request: Request) {
return `${tc.name}:${argSig}`;
}
// ── Server-side conversational guard (C-03 enforcement) ───────────
// If the user's message looks conversational we withhold tools for
// round 1. The model MUST respond in text first. If its reply then
// expresses clear intent to act, tools become available from round 2.
// This is more reliable than a prompt rule against a "do-er" model.
function isConversational(msg: string): boolean {
const m = msg.trim();
if (m.length < 3) return true; // single word / emoji
if (m.endsWith("?")) return true; // explicit question
// Short phrases that are status checks or greetings
const conversationalPatterns = [
/^(hi|hey|hello|sup|test|ok|okay|thanks|ty|thx|lgtm|nice|cool|great|wow)\b/i,
/^(what|how|why|when|where|who|which|is |are |can |could |would |do |does |did |has |have |had |was |were )\S+.{0,60}$/i,
/^(are you able to|can you|could you|would you|is it possible)/i,
/^(what'?s |whats )(running|live|deployed|happening|wrong|broken|up)/i,
/^(is it|is that|is this|is there|is the)/i,
];
return conversationalPatterns.some((re) => re.test(m));
}
const firstMessageIsConversational =
mcp_token !== undefined && // tools available
isConversational(message.trim());
try {
// Tool-calling loop: use non-streaming so thought_signature is
// always present in the complete response (required by thinking models).
@@ -623,7 +672,12 @@ export async function POST(request: Request) {
if (aborted) break;
round++;
const toolDefs = mcp_token ? VIBN_TOOL_DEFINITIONS : [];
// On round 1, withhold tools if the message looks conversational.
// The model must answer in text first; tools unlock from round 2.
const toolDefs =
mcp_token && !(round === 1 && firstMessageIsConversational)
? VIBN_TOOL_DEFINITIONS
: [];
// Every 2 silent rounds or 5 tool calls, nudge the model to surface a one-liner
// status before continuing. This is the user's only signal of
@@ -637,6 +691,16 @@ export async function POST(request: Request) {
"on and why. The user is staring at silent tool pills."
: "";
// When withholding tools on round 1 (conversational guard), add a
// mandatory instruction so the model doesn't return empty text.
if (round === 1 && firstMessageIsConversational) {
extraSystem +=
"\n\n[MANDATORY] The user's message is a question or conversational input, " +
"not a command. You have NO tools available on this turn. " +
"Respond with PLAIN TEXT ONLY in 1-3 sentences answering their question. " +
"If they want you to take action, confirm intent and wait for a clear directive.";
}
if (MAX_TOOL_ROUNDS - round <= 3) {
extraSystem += `\n\n[WARNING] You only have ${MAX_TOOL_ROUNDS - round} tool calls left before you are forcefully terminated. Stop exploring, make your final edits, and write your final response to the user NOW.`;
}
@@ -713,14 +777,17 @@ export async function POST(request: Request) {
}
}
// Stage 1: Warning at 3 repeats
if (maxRepeats === 3) {
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} 3 times recently. Please wrap up this approach or try a completely different tool.`;
// C-02: Tightened. Hard-break at 3 identical fingerprints (was 5).
if (maxRepeats === 2) {
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} twice in a row. Try a different approach or surface what's blocking you to the user.`;
}
if (maxRepeats >= 3) {
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
}
// Stage 2: Hard Break at 5 repeats
if (maxRepeats >= 5) {
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
// C-02: Also hard-break after 6 consecutive tool calls with no text.
if (!loopBreakReason && toolCallsSinceText >= 6) {
loopBreakReason = `${toolCallsSinceText} consecutive tool calls with no assistant text`;
}
// Execute tool calls and add results. OpenAI-compatible APIs
@@ -730,15 +797,32 @@ export async function POST(request: Request) {
const recoveryLines: string[] = [];
for (const tc of resp.toolCalls) {
if (aborted) break;
const result = mcp_token
? await executeMcpTool(
// C-05: Per-tool timeout. A hung MCP call would freeze the whole turn.
const TOOL_TIMEOUT_MS = 45_000;
const toolTimeout = new Promise<string>((resolve) =>
setTimeout(
() =>
resolve(
JSON.stringify({
ok: false,
error: `Tool ${tc.name} timed out after ${TOOL_TIMEOUT_MS / 1000}s`,
}),
),
TOOL_TIMEOUT_MS,
),
);
const toolExec = mcp_token
? executeMcpTool(
tc.name,
tc.args,
mcp_token,
baseUrl,
activeProject?.id,
)
: JSON.stringify({ error: "No MCP token — read-only mode." });
: Promise.resolve(
JSON.stringify({ error: "No MCP token — read-only mode." }),
);
const result = await Promise.race([toolExec, toolTimeout]);
emit({
type: "tool_result",
@@ -756,6 +840,25 @@ export async function POST(request: Request) {
const recovery = detectKnownError(result);
if (recovery) recoveryLines.push(formatRecoveryMessage(recovery));
// B-05: SSE plan event — stream task state changes to the client
// so the Plan tab updates in real-time during a chat turn.
if (tc.name === "plan_task_add" || tc.name === "plan_task_edit") {
try {
const parsed = JSON.parse(result);
const task = parsed?.result?.task ?? parsed?.task;
if (task?.id) {
emit({
type: "plan",
taskId: task.id,
text: task.text ?? task.title ?? "",
status: task.status ?? "open",
});
}
} catch {
// non-JSON result — skip
}
}
}
for (const line of recoveryLines) {
messages.push({ role: "user", content: line });
@@ -787,12 +890,15 @@ export async function POST(request: Request) {
// 20 toolCalls, user had to re-prompt to get any answer.
const lastTurnHadTools =
messages.length > 0 && messages[messages.length - 1].role === "tool";
// C-07: Also recover when the model has been running tools without
// any text for >=4 rounds — the user is staring at silence.
const needsRecovery =
!aborted &&
lastTurnHadTools &&
(round >= MAX_TOOL_ROUNDS ||
!!loopBreakReason ||
assistantText.trim().length === 0 ||
roundsSinceText >= 4 ||
lastToolResultsHadFailure(messages));
if (needsRecovery) {
@@ -1072,9 +1178,9 @@ export async function POST(request: Request) {
}
},
cancel() {
// Browser disconnected (tab closed, navigated away). Nothing to
// do — the abort handler above already flipped the flag and the
// loop will bail at the next checkpoint.
// Browser disconnected (tab closed, navigated away). Clear the
// heartbeat so we stop writing to a closed stream.
// The abort handler above already flipped the flag so the loop bails.
},
});

View File

@@ -0,0 +1,25 @@
// Two-stage Loop detection (Fix 4 update)
// Sliding window of 10
const window = toolFingerprints.slice(-10);
const counts = new Map<string, number>();
for (const fp of window) counts.set(fp, (counts.get(fp) ?? 0) + 1);
// Find highest repeating tool call
let maxRepeats = 0;
let repeatedCmd = "";
for (const [fp, n] of counts.entries()) {
if (n > maxRepeats) {
maxRepeats = n;
repeatedCmd = fp.split("|")[0];
}
}
// Stage 1: Warning at 3 repeats
if (maxRepeats === 3) {
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} 3 times recently. Please wrap up this approach or try a completely different tool.`;
}
// Stage 2: Hard Break at 5 repeats
if (maxRepeats >= 5) {
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
}

View File

@@ -1,52 +1,99 @@
import { NextRequest, NextResponse } from "next/server";
/**
* POST /api/context/summarize
* Body: { content: string, title?: string }
*
* Generates a short summary via Gemini. Closes S-04: now requires a
* signed-in user (rate-limit per user, not per-IP) so we don't burn Gemini
* quota on anonymous traffic.
*/
import { NextResponse } from "next/server";
import { withAuth, withRateLimit } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
const MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
const API_KEY = process.env.GOOGLE_API_KEY || '';
const MODEL = process.env.GEMINI_MODEL || "gemini-3.1-pro-preview";
const API_KEY = process.env.GOOGLE_API_KEY || "";
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
export async function POST(request: NextRequest) {
try {
const { content, title } = await request.json();
export const POST = withRateLimit(
withAuth(async (request, _ctx, { user }) => {
try {
const { content, title } = (await request.json()) as {
content?: string;
title?: string;
};
if (!content) {
return NextResponse.json({ error: "Content is required" }, { status: 400 });
}
if (!content || typeof content !== "string") {
return NextResponse.json(
{ error: "content is required" },
{ status: 400 },
);
}
const maxContentLength = 30000;
const truncatedContent = content.length > maxContentLength
? content.substring(0, maxContentLength) + "..."
: content;
const maxContentLength = 30000;
const truncatedContent =
content.length > maxContentLength
? content.substring(0, maxContentLength) + "..."
: content;
const prompt = `Read this document titled "${title}" and provide a concise 1-2 sentence summary that captures the main topic and key points. Be specific and actionable.
const prompt = `Read this document titled "${title ?? "(untitled)"}" and provide a concise 1-2 sentence summary that captures the main topic and key points. Be specific and actionable.
Document content:
${truncatedContent}
Summary:`;
const response = await fetch(`${GEMINI_URL}?key=${API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.3 },
}),
});
const response = await fetch(`${GEMINI_URL}?key=${API_KEY}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.3 },
}),
});
if (!response.ok) {
throw new Error(`Gemini API error (${response.status}): ${await response.text()}`);
if (!response.ok) {
const text = await response.text();
log.warn("context/summarize gemini error", {
route: "api.context.summarize",
user: user.email,
status: response.status,
body: text.slice(0, 500),
});
return NextResponse.json(
{
error: `Gemini API error (${response.status})`,
details: text.slice(0, 500),
},
{ status: 502 },
);
}
const result = await response.json();
const summary =
result.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ||
"Summary unavailable";
return NextResponse.json({ summary });
} catch (err) {
log.error("context/summarize failed", {
route: "api.context.summarize",
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{
error: "Failed to generate summary",
details: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
);
}
const result = await response.json();
const summary = result.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || 'Summary unavailable';
return NextResponse.json({ summary });
} catch (error) {
console.error("Error generating summary:", error);
return NextResponse.json(
{ error: "Failed to generate summary", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
}),
{
// 20 summaries / min / user — much higher than chat because they're cheap.
limit: 20,
windowMs: 60_000,
keyFn: (_req, extra) => {
const userEmail = (extra as { user?: { email?: string } })?.user?.email;
return `context-summarize:${userEmail ?? "anon"}`;
},
},
);

View File

@@ -1,229 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin';
export async function POST(request: NextRequest) {
try {
// Verify authentication using API key
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Unauthorized - Missing API key' },
{ status: 401 }
);
}
const apiKey = authHeader.substring(7);
// Look up user by API key
const apiKeysSnapshot = await adminDb
.collection('apiKeys')
.where('key', '==', apiKey)
.where('isActive', '==', true)
.limit(1)
.get();
if (apiKeysSnapshot.empty) {
return NextResponse.json(
{ error: 'Invalid API key' },
{ status: 401 }
);
}
const apiKeyDoc = apiKeysSnapshot.docs[0];
const apiKeyData = apiKeyDoc.data();
const userId = apiKeyData.userId;
if (!userId) {
return NextResponse.json(
{ error: 'API key not associated with user' },
{ status: 401 }
);
}
// Parse request body
const body = await request.json();
const {
projectId,
workspacePath,
githubUrl,
conversations
} = body;
if (!projectId || !conversations) {
return NextResponse.json(
{ error: 'Missing required fields: projectId, conversations' },
{ status: 400 }
);
}
// Verify user has access to the project
const projectRef = adminDb.collection('projects').doc(projectId);
const projectDoc = await projectRef.get();
if (!projectDoc.exists) {
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
const projectData = projectDoc.data();
if (projectData?.userId !== userId) {
return NextResponse.json(
{ error: 'Access denied to this project' },
{ status: 403 }
);
}
// Process and store conversations
const { composers, workspaceFiles, totalGenerations } = conversations;
let conversationCount = 0;
let totalMessagesWritten = 0;
// Determine filtering keywords based on project context
// TODO: Make this configurable per project
const projectKeywords = ['vibn', 'project', 'extension', 'collector', 'cursor-monitor'];
const excludeKeywords = ['nhl', 'hockey', 'market', 'transaction'];
// Store each composer (chat session) as a separate document
for (const composer of composers || []) {
if (composer.type !== 'head') continue; // Only process head composers
const conversationId = `cursor-${composer.composerId}`;
const conversationRef = adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.doc(conversationId);
const name = composer.name || 'Untitled Conversation';
const nameLower = name.toLowerCase();
// Simple relevance scoring
let relevanceScore = 0;
// Check for project keywords in name
for (const keyword of projectKeywords) {
if (nameLower.includes(keyword)) {
relevanceScore += 2;
}
}
// Penalize for exclude keywords
for (const keyword of excludeKeywords) {
if (nameLower.includes(keyword)) {
relevanceScore -= 3;
}
}
// Check if name mentions files from this workspace
if (workspaceFiles && Array.isArray(workspaceFiles)) {
for (const file of workspaceFiles) {
if (nameLower.includes(file.toLowerCase())) {
relevanceScore += 1;
}
}
}
// Count messages
let messageCount = 0;
if (composer.bubbles && Array.isArray(composer.bubbles)) {
messageCount = composer.bubbles.length;
}
const conversationData = {
userId,
projectId,
conversationId,
composerId: composer.composerId,
name,
createdAt: new Date(composer.createdAt).toISOString(),
lastUpdatedAt: new Date(composer.lastUpdatedAt).toISOString(),
unifiedMode: composer.unifiedMode || false,
forceMode: composer.forceMode || false,
workspacePath,
githubUrl: githubUrl || null,
importedAt: new Date().toISOString(),
relevanceScore, // For filtering
messageCount,
metadata: {
source: 'cursor-monitor-extension',
composerType: composer.type,
}
};
// Write conversation document first
await conversationRef.set(conversationData);
// Store messages in chunks to avoid Firestore batch limit (500 operations)
if (composer.bubbles && Array.isArray(composer.bubbles)) {
const BATCH_SIZE = 400; // Leave room for overhead
for (let i = 0; i < composer.bubbles.length; i += BATCH_SIZE) {
const batch = adminDb.batch();
const chunk = composer.bubbles.slice(i, i + BATCH_SIZE);
for (const bubble of chunk) {
const messageRef = conversationRef
.collection('messages')
.doc(bubble.bubbleId);
batch.set(messageRef, {
bubbleId: bubble.bubbleId,
type: bubble.type, // 1 = user, 2 = AI
role: bubble.type === 1 ? 'user' : bubble.type === 2 ? 'assistant' : 'unknown',
text: bubble.text || '',
createdAt: bubble.createdAt,
requestId: bubble.requestId,
attachedFiles: bubble.attachedFiles || []
});
}
await batch.commit();
totalMessagesWritten += chunk.length;
console.log(`✅ Wrote ${chunk.length} messages (${i + chunk.length}/${composer.bubbles.length}) for ${name}`);
}
}
conversationCount++;
}
// Store workspace metadata for reference
const workspaceMetaRef = adminDb
.collection('projects')
.doc(projectId)
.collection('cursorData')
.doc('workspace-meta');
await workspaceMetaRef.set({
workspacePath,
githubUrl,
workspaceFiles: workspaceFiles || [],
totalGenerations: totalGenerations || 0,
importedAt: new Date().toISOString(),
lastBatchImportedAt: new Date().toISOString(),
}, { merge: true });
console.log(`✅ Imported ${conversationCount} conversations to project ${projectId}`);
const workspaceFilesCount = conversations.workspaceFiles?.length || workspaceFiles?.length || 0;
const generationsCount = conversations.totalGenerations || totalGenerations || 0;
return NextResponse.json({
success: true,
conversationCount,
totalMessages: totalMessagesWritten,
workspaceFilesCount,
totalGenerations: generationsCount,
message: `Successfully imported ${conversationCount} conversations with ${totalMessagesWritten} messages`
});
} catch (error) {
console.error('Error importing Cursor conversations:', error);
return NextResponse.json(
{ error: 'Failed to import conversations', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,54 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
// TEMPORARY: For debugging/testing only - no auth required
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json(
{ error: 'Missing projectId' },
{ status: 400 }
);
}
// Delete all cursor conversations for this project
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
const batch = adminDb.batch();
conversationsSnapshot.docs.forEach((doc: FirebaseFirestore.QueryDocumentSnapshot) => {
batch.delete(doc.ref);
});
// Also delete the messages data document
const messagesRef = adminDb
.collection('projects')
.doc(projectId)
.collection('cursorData')
.doc('messages');
batch.delete(messagesRef);
await batch.commit();
return NextResponse.json({
success: true,
deletedCount: conversationsSnapshot.size,
message: 'All cursor conversations cleared'
});
} catch (error) {
console.error('Error clearing cursor conversations:', error);
return NextResponse.json(
{ error: 'Failed to clear conversations', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,192 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function POST(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
const sessionGapMinutes = parseInt(request.nextUrl.searchParams.get('gap') || '30'); // 30 min default
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations sorted by time
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.orderBy('createdAt', 'asc')
.get();
const conversations = conversationsSnapshot.docs.map((doc: FirebaseFirestore.QueryDocumentSnapshot) => {
const data = doc.data();
return {
id: doc.id,
ref: doc.ref,
name: data.name,
createdAt: new Date(data.createdAt),
relevanceScore: data.relevanceScore || 0
};
});
// Step 1: Group by date
const conversationsByDate: Record<string, typeof conversations> = {};
for (const conv of conversations) {
const dateKey = conv.createdAt.toISOString().split('T')[0]; // YYYY-MM-DD
if (!conversationsByDate[dateKey]) {
conversationsByDate[dateKey] = [];
}
conversationsByDate[dateKey].push(conv);
}
// Step 2: Within each date, create sessions based on time gaps
const sessions: any[] = [];
let sessionId = 0;
for (const [date, dayConversations] of Object.entries(conversationsByDate)) {
let currentSession: any = null;
for (const conv of dayConversations) {
if (!currentSession) {
// Start first session of the day
sessionId++;
currentSession = {
sessionId,
date,
startTime: conv.createdAt,
endTime: conv.createdAt,
conversations: [conv]
};
} else {
// Check time gap from last conversation
const gapMs = conv.createdAt.getTime() - currentSession.endTime.getTime();
const gapMinutes = gapMs / (1000 * 60);
if (gapMinutes <= sessionGapMinutes) {
// Same session
currentSession.conversations.push(conv);
currentSession.endTime = conv.createdAt;
} else {
// New session - close current and start new
sessions.push(currentSession);
sessionId++;
currentSession = {
sessionId,
date,
startTime: conv.createdAt,
endTime: conv.createdAt,
conversations: [conv]
};
}
}
}
// Add last session of the day
if (currentSession) {
sessions.push(currentSession);
}
}
// Step 3: Analyze each session and determine project
const projectKeywords = ['vibn', 'extension', 'collector', 'cursor-monitor'];
const excludeKeywords = ['nhl', 'hockey', 'market', 'transaction'];
const analyzedSessions = sessions.map(session => {
const allNames = session.conversations.map((c: any) => c.name.toLowerCase()).join(' ');
let projectTag = 'unknown';
let confidence = 'low';
// Check for strong exclude signals
for (const keyword of excludeKeywords) {
if (allNames.includes(keyword)) {
projectTag = 'other';
confidence = 'high';
break;
}
}
// If not excluded, check for vibn signals
if (projectTag === 'unknown') {
for (const keyword of projectKeywords) {
if (allNames.includes(keyword)) {
projectTag = 'vibn';
confidence = 'high';
break;
}
}
}
// If still unknown, check for generic "project" keyword
if (projectTag === 'unknown' && allNames.includes('project')) {
projectTag = 'vibn';
confidence = 'medium';
}
return {
...session,
projectTag,
confidence,
conversationCount: session.conversations.length
};
});
// Step 4: Update Firestore with session tags
const batch = adminDb.batch();
let updateCount = 0;
for (const session of analyzedSessions) {
for (const conv of session.conversations) {
batch.update(conv.ref, {
sessionId: session.sessionId,
sessionDate: session.date,
sessionProject: session.projectTag,
sessionConfidence: session.confidence
});
updateCount++;
}
}
await batch.commit();
// Return summary
const summary = {
totalConversations: conversations.length,
totalSessions: sessions.length,
sessionGapMinutes,
projectBreakdown: {
vibn: analyzedSessions.filter(s => s.projectTag === 'vibn').length,
other: analyzedSessions.filter(s => s.projectTag === 'other').length,
unknown: analyzedSessions.filter(s => s.projectTag === 'unknown').length
},
conversationBreakdown: {
vibn: analyzedSessions.filter(s => s.projectTag === 'vibn').reduce((sum, s) => sum + s.conversationCount, 0),
other: analyzedSessions.filter(s => s.projectTag === 'other').reduce((sum, s) => sum + s.conversationCount, 0),
unknown: analyzedSessions.filter(s => s.projectTag === 'unknown').reduce((sum, s) => sum + s.conversationCount, 0)
},
sampleSessions: analyzedSessions.slice(0, 10).map(s => ({
sessionId: s.sessionId,
date: s.date,
conversationCount: s.conversationCount,
projectTag: s.projectTag,
confidence: s.confidence,
conversationNames: s.conversations.slice(0, 3).map((c: any) => c.name)
}))
};
return NextResponse.json({
success: true,
updatedConversations: updateCount,
...summary
});
} catch (error) {
console.error('Error tagging sessions:', error);
return NextResponse.json(
{ error: 'Failed to tag sessions', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,63 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
import { FieldValue } from 'firebase-admin/firestore';
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => ({}));
const projectId = (body.projectId ?? '').trim();
if (!projectId) {
return NextResponse.json(
{ error: 'projectId is required' },
{ status: 400 },
);
}
const adminDb = getAdminDb();
const docRef = adminDb.collection('chat_conversations').doc(projectId);
await adminDb.runTransaction(async (tx) => {
const snapshot = await tx.get(docRef);
const existing = (snapshot.exists ? (snapshot.data()?.messages as unknown[]) : []) ?? [];
const now = new Date().toISOString();
const newMessages = [
{
role: 'user' as const,
content: '[debug] test user message',
createdAt: now,
},
{
role: 'assistant' as const,
content: '[debug] test assistant reply',
createdAt: now,
},
];
tx.set(
docRef,
{
projectId,
messages: [...existing, ...newMessages],
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('[debug/append-conversation] Failed to append messages', error);
return NextResponse.json(
{
error: 'Failed to append debug conversation messages',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,88 +0,0 @@
/**
* Debug API to check session links
*/
import { NextResponse } from 'next/server';
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const idToken = authHeader.split('Bearer ')[1];
const adminAuth = getAdminAuth();
const adminDb = getAdminDb();
let userId: string;
try {
const decodedToken = await adminAuth.verifyIdToken(idToken);
userId = decodedToken.uid;
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Get all user's sessions
const sessionsSnapshot = await adminDb
.collection('sessions')
.where('userId', '==', userId)
.get();
const linked: any[] = [];
const unlinked: any[] = [];
sessionsSnapshot.docs.forEach(doc => {
const data = doc.data();
const sessionInfo = {
id: doc.id,
workspaceName: data.workspaceName || 'Unknown',
workspacePath: data.workspacePath,
projectId: data.projectId,
needsProjectAssociation: data.needsProjectAssociation,
createdAt: data.createdAt?.toDate?.() || data.createdAt,
};
if (data.projectId) {
linked.push(sessionInfo);
} else {
unlinked.push(sessionInfo);
}
});
// Get all user's projects
const projectsSnapshot = await adminDb
.collection('projects')
.where('userId', '==', userId)
.get();
const projects = projectsSnapshot.docs.map(doc => ({
id: doc.id,
name: doc.data().productName || doc.data().name,
workspacePath: doc.data().workspacePath,
}));
return NextResponse.json({
summary: {
totalSessions: sessionsSnapshot.size,
linkedSessions: linked.length,
unlinkedSessions: unlinked.length,
totalProjects: projects.length,
},
linked,
unlinked,
projects,
});
} catch (error) {
console.error('Debug check error:', error);
return NextResponse.json(
{
error: 'Failed to check links',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -1,62 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json(
{ error: 'Missing projectId parameter' },
{ status: 400 }
);
}
// Check if project exists
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (!projectDoc.exists) {
// List all projects to help debug
const allProjectsSnapshot = await adminDb.collection('projects').limit(20).get();
const allProjects = allProjectsSnapshot.docs.map(doc => ({
id: doc.id,
name: doc.data().name,
userId: doc.data().userId,
createdAt: doc.data().createdAt
}));
return NextResponse.json({
exists: false,
projectId,
message: 'Project not found',
availableProjects: allProjects
});
}
const projectData = projectDoc.data();
return NextResponse.json({
exists: true,
projectId,
project: {
name: projectData?.name,
userId: projectData?.userId,
createdAt: projectData?.createdAt,
githubRepo: projectData?.githubRepo
}
});
} catch (error) {
console.error('Error checking project:', error);
return NextResponse.json(
{ error: 'Failed to check project', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,44 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const projectId = url.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const adminDb = getAdminDb();
// Get contextSources subcollection
const contextSourcesRef = adminDb
.collection('projects')
.doc(projectId)
.collection('contextSources');
const snapshot = await contextSourcesRef.get();
const sources = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
}));
return NextResponse.json({
projectId,
count: sources.length,
sources,
});
} catch (error) {
console.error('[debug/context-sources] Error:', error);
return NextResponse.json(
{
error: 'Failed to fetch context sources',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,72 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get workspace metadata
const workspaceMetaDoc = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorData')
.doc('workspace-meta')
.get();
const workspaceMeta = workspaceMetaDoc.exists ? workspaceMetaDoc.data() : null;
// Get all conversations
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
// Analyze by score
const negative = conversationsSnapshot.docs
.map(doc => ({ name: doc.data().name, score: doc.data().relevanceScore || 0 }))
.filter(c => c.score < 0)
.sort((a, b) => a.score - b.score);
const positive = conversationsSnapshot.docs
.map(doc => ({ name: doc.data().name, score: doc.data().relevanceScore || 0 }))
.filter(c => c.score > 0)
.sort((a, b) => b.score - a.score);
const neutral = conversationsSnapshot.docs
.filter(doc => (doc.data().relevanceScore || 0) === 0)
.length;
return NextResponse.json({
workspacePath: workspaceMeta?.workspacePath,
githubUrl: workspaceMeta?.githubUrl,
workspaceFilesCount: workspaceMeta?.workspaceFiles?.length || 0,
workspaceFilesSample: (workspaceMeta?.workspaceFiles || []).slice(0, 20),
totalGenerations: workspaceMeta?.totalGenerations || 0,
totalConversations: conversationsSnapshot.size,
scoreBreakdown: {
negative: negative.length,
neutral,
positive: positive.length
},
negativeScoreConversations: negative,
topPositiveConversations: positive.slice(0, 10),
sampleNeutralConversations: conversationsSnapshot.docs
.filter(doc => (doc.data().relevanceScore || 0) === 0)
.slice(0, 10)
.map(doc => doc.data().name)
});
} catch (error) {
console.error('Error analyzing conversations:', error);
return NextResponse.json(
{ error: 'Failed to analyze', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,72 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get 5 random conversations with content
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.limit(5)
.get();
const samples = conversationsSnapshot.docs.map(doc => {
const data = doc.data();
return {
name: data.name,
messageCount: data.messageCount || 0,
promptCount: data.prompts?.length || 0,
generationCount: data.generations?.length || 0,
filesCount: data.files?.length || 0,
sampleFiles: (data.files || []).slice(0, 3),
samplePrompt: data.prompts?.[0]?.text?.substring(0, 100) || 'none',
hasContent: !!(data.prompts?.length || data.generations?.length)
};
});
// Get overall stats
const allConversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
let totalWithContent = 0;
let totalWithFiles = 0;
let totalMessages = 0;
allConversationsSnapshot.docs.forEach(doc => {
const data = doc.data();
if (data.prompts?.length || data.generations?.length) {
totalWithContent++;
}
if (data.files?.length) {
totalWithFiles++;
}
totalMessages += data.messageCount || 0;
});
return NextResponse.json({
totalConversations: allConversationsSnapshot.size,
totalWithContent,
totalWithFiles,
totalMessages,
samples
});
} catch (error) {
console.error('Error fetching content sample:', error);
return NextResponse.json(
{ error: 'Failed to fetch sample', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,55 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get cursor conversations for this project
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.orderBy('createdAt', 'desc')
.get();
const conversations = conversationsSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
// Get the messages data
const messagesDoc = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorData')
.doc('messages')
.get();
const messagesData = messagesDoc.exists ? messagesDoc.data() : null;
return NextResponse.json({
projectId,
conversationCount: conversations.length,
conversations,
messagesData: messagesData ? {
promptCount: messagesData.prompts?.length || 0,
generationCount: messagesData.generations?.length || 0,
importedAt: messagesData.importedAt
} : null
});
} catch (error) {
console.error('Error fetching cursor conversations:', error);
return NextResponse.json(
{ error: 'Failed to fetch conversations', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,56 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
const minScore = parseInt(request.nextUrl.searchParams.get('minScore') || '0');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
const conversations = conversationsSnapshot.docs
.map(doc => {
const data = doc.data();
return {
name: data.name,
relevanceScore: data.relevanceScore || 0,
createdAt: data.createdAt,
workspacePath: data.workspacePath
};
})
.filter(c => c.relevanceScore >= minScore)
.sort((a, b) => b.relevanceScore - a.relevanceScore);
// Group by score
const scoreGroups: Record<number, number> = {};
conversationsSnapshot.docs.forEach(doc => {
const score = doc.data().relevanceScore || 0;
scoreGroups[score] = (scoreGroups[score] || 0) + 1;
});
return NextResponse.json({
totalConversations: conversationsSnapshot.size,
filteredConversations: conversations.length,
minScore,
scoreDistribution: scoreGroups,
conversations: conversations.slice(0, 50) // First 50
});
} catch (error) {
console.error('Error fetching relevant conversations:', error);
return NextResponse.json(
{ error: 'Failed to fetch conversations', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get 10 conversations with their dates
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.orderBy('createdAt', 'desc')
.limit(10)
.get();
const samples = conversationsSnapshot.docs.map(doc => {
const data = doc.data();
return {
name: data.name,
createdAt: data.createdAt,
workspacePath: data.workspacePath
};
});
return NextResponse.json({ samples });
} catch (error) {
console.error('Error fetching samples:', error);
return NextResponse.json(
{ error: 'Failed to fetch samples', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,55 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations sorted by time
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.orderBy('createdAt', 'asc')
.get();
const conversations = conversationsSnapshot.docs.map(doc => {
const data = doc.data();
return {
name: data.name,
createdAt: new Date(data.createdAt),
score: data.relevanceScore || 0
};
});
// Find NHL work sessions (negative scores clustered in time)
const nhlConversations = conversations.filter(c => c.score < 0);
return NextResponse.json({
totalConversations: conversations.length,
nhlConversations: nhlConversations.length,
nhlDates: nhlConversations.map(c => ({
date: c.createdAt.toISOString().split('T')[0],
name: c.name
})),
// Find if NHL conversations cluster
nhlDateCounts: nhlConversations.reduce((acc: any, c) => {
const date = c.createdAt.toISOString().split('T')[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {})
});
} catch (error) {
console.error('Error analyzing session summary:', error);
return NextResponse.json(
{ error: 'Failed to analyze', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,124 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
const sessionGapMinutes = parseInt(request.nextUrl.searchParams.get('gap') || '120'); // 2 hours default
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations sorted by time
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.orderBy('createdAt', 'asc')
.get();
const conversations = conversationsSnapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
name: data.name,
createdAt: new Date(data.createdAt),
relevanceScore: data.relevanceScore || 0
};
});
// Group into sessions based on time gaps
const sessions: any[] = [];
let currentSession: any = null;
for (const conv of conversations) {
if (!currentSession) {
// Start first session
currentSession = {
startTime: conv.createdAt,
endTime: conv.createdAt,
conversations: [conv],
relevanceScores: [conv.relevanceScore]
};
} else {
// Check time gap from last conversation
const gapMs = conv.createdAt.getTime() - currentSession.endTime.getTime();
const gapMinutes = gapMs / (1000 * 60);
if (gapMinutes <= sessionGapMinutes) {
// Same session
currentSession.conversations.push(conv);
currentSession.relevanceScores.push(conv.relevanceScore);
currentSession.endTime = conv.createdAt;
} else {
// New session - close current and start new
sessions.push(currentSession);
currentSession = {
startTime: conv.createdAt,
endTime: conv.createdAt,
conversations: [conv],
relevanceScores: [conv.relevanceScore]
};
}
}
}
// Add last session
if (currentSession) {
sessions.push(currentSession);
}
// Analyze each session
const analyzedSessions = sessions.map((session, idx) => {
const durationMinutes = Math.round((session.endTime.getTime() - session.startTime.getTime()) / (1000 * 60));
// Calculate session relevance score (average of all conversations)
const avgScore = session.relevanceScores.reduce((a: number, b: number) => a + b, 0) / session.relevanceScores.length;
// Count negative/positive conversations
const negative = session.relevanceScores.filter((s: number) => s < 0).length;
const positive = session.relevanceScores.filter((s: number) => s > 0).length;
const neutral = session.relevanceScores.filter((s: number) => s === 0).length;
// Determine likely project based on majority
let likelyProject = 'unknown';
if (negative > positive && negative > neutral) {
likelyProject = 'other (NHL/market)';
} else if (positive > negative && positive > neutral) {
likelyProject = 'vibn (likely)';
} else if (positive > 0 || avgScore > 0) {
likelyProject = 'vibn (mixed)';
} else {
likelyProject = 'unclear';
}
return {
sessionNumber: idx + 1,
startTime: session.startTime.toISOString(),
endTime: session.endTime.toISOString(),
durationMinutes,
conversationCount: session.conversations.length,
avgRelevanceScore: Math.round(avgScore * 100) / 100,
scoreBreakdown: { negative, neutral, positive },
likelyProject,
conversationNames: session.conversations.slice(0, 5).map((c: any) => c.name)
};
});
return NextResponse.json({
totalConversations: conversations.length,
totalSessions: sessions.length,
sessionGapMinutes,
sessions: analyzedSessions
});
} catch (error) {
console.error('Error analyzing sessions:', error);
return NextResponse.json(
{ error: 'Failed to analyze sessions', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,69 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
const conversations = conversationsSnapshot.docs.map(doc => doc.data());
// Find date range
const dates = conversations
.filter(c => c.createdAt)
.map(c => new Date(c.createdAt))
.sort((a, b) => a.getTime() - b.getTime());
if (dates.length === 0) {
return NextResponse.json({ error: 'No conversations with dates found' });
}
const earliest = dates[0];
const latest = dates[dates.length - 1];
const span = Math.floor((latest.getTime() - earliest.getTime()) / (1000 * 60 * 60 * 24));
// Find the actual conversation names for earliest and latest
const earliestConv = conversations.find(c =>
new Date(c.createdAt).getTime() === earliest.getTime()
);
const latestConv = conversations.find(c =>
new Date(c.createdAt).getTime() === latest.getTime()
);
return NextResponse.json({
totalConversations: conversations.length,
dateRange: {
earliest: earliest.toISOString(),
latest: latest.toISOString(),
spanDays: span
},
oldestConversation: {
name: earliestConv?.name || 'Unknown',
date: earliest.toISOString()
},
newestConversation: {
name: latestConv?.name || 'Unknown',
date: latest.toISOString()
}
});
} catch (error) {
console.error('Error fetching cursor stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch stats', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,59 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all unknown conversations grouped by session
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.where('sessionProject', '==', 'unknown')
.get();
const sessionMap: Record<number, any[]> = {};
conversationsSnapshot.docs.forEach(doc => {
const data = doc.data();
const sessionId = data.sessionId;
if (!sessionMap[sessionId]) {
sessionMap[sessionId] = [];
}
sessionMap[sessionId].push({
name: data.name,
date: data.sessionDate,
createdAt: data.createdAt
});
});
// Convert to array and take sample
const sessions = Object.entries(sessionMap).map(([sessionId, conversations]) => ({
sessionId: parseInt(sessionId),
date: conversations[0].date,
conversationCount: conversations.length,
conversationNames: conversations.map(c => c.name)
}));
return NextResponse.json({
totalUnknownSessions: sessions.length,
totalUnknownConversations: conversationsSnapshot.size,
sample: sessions.slice(0, 30)
});
} catch (error) {
console.error('Error fetching unknown sessions:', error);
return NextResponse.json(
{ error: 'Failed to fetch', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,59 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
export async function GET(request: NextRequest) {
try {
const projectId = request.nextUrl.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
// Get all conversations
const conversationsSnapshot = await adminDb
.collection('projects')
.doc(projectId)
.collection('cursorConversations')
.get();
const conversations = conversationsSnapshot.docs.map(doc => doc.data());
// Group by workspace path
const workspaceGroups: Record<string, number> = {};
const githubGroups: Record<string, number> = {};
conversations.forEach(conv => {
const workspace = conv.workspacePath || 'unknown';
const github = conv.githubUrl || 'none';
workspaceGroups[workspace] = (workspaceGroups[workspace] || 0) + 1;
githubGroups[github] = (githubGroups[github] || 0) + 1;
});
// Sort by count
const workspaceList = Object.entries(workspaceGroups)
.map(([path, count]) => ({ path, count }))
.sort((a, b) => b.count - a.count);
const githubList = Object.entries(githubGroups)
.map(([url, count]) => ({ url, count }))
.sort((a, b) => b.count - a.count);
return NextResponse.json({
totalConversations: conversations.length,
uniqueWorkspaces: workspaceList.length,
uniqueRepos: githubList.length,
workspaces: workspaceList,
repos: githubList
});
} catch (error) {
console.error('Error fetching workspace breakdown:', error);
return NextResponse.json(
{ error: 'Failed to fetch breakdown', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}

View File

@@ -1,18 +0,0 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
firebaseProjectId: process.env.FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
firebaseClientEmail: process.env.FIREBASE_CLIENT_EMAIL ? 'SET' : 'NOT SET',
firebasePrivateKey: process.env.FIREBASE_PRIVATE_KEY ? 'SET (length: ' + process.env.FIREBASE_PRIVATE_KEY.length + ')' : 'NOT SET',
publicApiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY ? 'SET' : 'NOT SET',
publicAuthDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN ? 'SET' : 'NOT SET',
publicProjectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
nodeEnv: process.env.NODE_ENV,
tip: 'If any Firebase vars show NOT SET, restart your dev server after updating .env.local'
});
}

View File

@@ -1,33 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET() {
try {
const adminDb = getAdminDb();
const snapshot = await adminDb.collection('projects').limit(1).get();
if (snapshot.empty) {
return NextResponse.json(
{ error: 'No projects found' },
{ status: 404 },
);
}
const doc = snapshot.docs[0];
return NextResponse.json({
id: doc.id,
data: doc.data(),
});
} catch (error) {
console.error('[debug/first-project] Failed to load project', error);
return NextResponse.json(
{
error: 'Failed to load project',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,43 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const projectId = url.searchParams.get('projectId');
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const adminDb = getAdminDb();
// Get knowledge_items collection
const knowledgeItemsRef = adminDb.collection('knowledge_items');
const snapshot = await knowledgeItemsRef
.where('projectId', '==', projectId)
.limit(20)
.get();
const items = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
}));
return NextResponse.json({
projectId,
count: items.length,
items,
});
} catch (error) {
console.error('[debug/knowledge-items] Error:', error);
return NextResponse.json(
{
error: 'Failed to fetch knowledge items',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,36 +0,0 @@
import { NextResponse } from 'next/server';
import { getAdminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const projectId = (url.searchParams.get('projectId') ?? '').trim();
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
}
const adminDb = getAdminDb();
// Use a simple filter query without orderBy to avoid requiring a composite index.
const snapshot = await adminDb
.collection('knowledge_items')
.where('projectId', '==', projectId)
.get();
const items = snapshot.docs.map((doc) => doc.data());
return NextResponse.json({ count: items.length, items });
} catch (error) {
console.error('[debug/knowledge] Failed to list knowledge items', error);
return NextResponse.json(
{
error: 'Failed to list knowledge items',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}

View File

@@ -1,40 +0,0 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
/**
* Dev-only: verifies Prisma can connect (NextAuth adapter needs this after Google redirects back).
* Open GET /api/debug/prisma while running next dev.
*/
export async function GET() {
if (process.env.NODE_ENV !== "development") {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const hasUrl = Boolean(process.env.DATABASE_URL?.trim() || process.env.POSTGRES_URL?.trim());
const prisma = new PrismaClient();
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
ok: true,
databaseUrlConfigured: hasUrl,
hint: "Prisma connects; if auth still fails, check Google client id/secret and terminal [next-auth] logs.",
});
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
const publicHost = /Can't reach database server at `([\d.]+):(\d+)`/.exec(message);
const hint = publicHost
? `No TCP route to Postgres at ${publicHost[1]}:${publicHost[2]} from this machine. In Coolify: confirm the DB service publishes that host port and Postgres listens on 0.0.0.0. On the cloud firewall (e.g. GCP), allow inbound TCP ${publicHost[2]} from your IP (or use VPN). Test: nc -zv ${publicHost[1]} ${publicHost[2]} or psql. Then npm run db:push from vibn-frontend.`
: "If the URL uses a Coolify internal hostname, it only works inside Docker. Otherwise check DATABASE_URL, firewall, and run npm run db:push.";
return NextResponse.json(
{
ok: false,
databaseUrlConfigured: hasUrl,
message,
hint,
},
{ status: 500 }
);
} finally {
await prisma.$disconnect().catch(() => {});
}
}

View File

@@ -1,61 +0,0 @@
import { NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin';
export async function GET(request: Request) {
const diagnostics: any = {
timestamp: new Date().toISOString(),
environment: {},
firebase: {},
token: {},
};
try {
// Check environment variables
diagnostics.environment = {
FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
FIREBASE_CLIENT_EMAIL: process.env.FIREBASE_CLIENT_EMAIL ? 'SET' : 'NOT SET',
FIREBASE_PRIVATE_KEY: process.env.FIREBASE_PRIVATE_KEY ? `SET (${process.env.FIREBASE_PRIVATE_KEY.length} chars)` : 'NOT SET',
NEXT_PUBLIC_FIREBASE_PROJECT_ID: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID || 'NOT SET',
};
// Test Firebase Admin
try {
const testDoc = await adminDb.collection('test').doc('diagnostic').get();
diagnostics.firebase.adminDb = 'OK - Can access Firestore';
diagnostics.firebase.adminAuth = 'OK - Auth service initialized';
} catch (error: any) {
diagnostics.firebase.error = error.message;
}
// Try to verify a token if provided
const authHeader = request.headers.get('authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
diagnostics.token.received = true;
diagnostics.token.length = token.length;
try {
const decodedToken = await adminAuth.verifyIdToken(token);
diagnostics.token.verification = 'SUCCESS';
diagnostics.token.uid = decodedToken.uid;
diagnostics.token.email = decodedToken.email;
} catch (error: any) {
diagnostics.token.verification = 'FAILED';
diagnostics.token.error = error.message;
diagnostics.token.errorCode = error.code;
}
} else {
diagnostics.token.received = false;
diagnostics.token.note = 'No token provided - add Authorization: Bearer <token> header to test';
}
return NextResponse.json(diagnostics, { status: 200 });
} catch (error: any) {
diagnostics.criticalError = {
message: error.message,
stack: error.stack,
};
return NextResponse.json(diagnostics, { status: 500 });
}
}

View File

@@ -1,58 +0,0 @@
import { NextResponse } from 'next/server';
import { adminDb, adminAuth } from '@/lib/firebase/admin';
export async function GET() {
try {
// Test 1: Check if Firebase Admin is initialized
if (!adminDb) {
return NextResponse.json(
{ error: 'Firebase Admin not initialized' },
{ status: 500 }
);
}
// Test 2: Try to access Firestore (this will verify credentials)
const testCollection = adminDb.collection('_healthcheck');
const timestamp = new Date().toISOString();
// Write a test document
const docRef = await testCollection.add({
message: 'Firebase connection test',
timestamp: timestamp,
});
// Read it back
const doc = await docRef.get();
const data = doc.data();
// Clean up
await docRef.delete();
// Test 3: Check Auth is working
const authCheck = adminAuth ? 'OK' : 'Failed';
return NextResponse.json({
success: true,
message: 'Firebase is connected successfully! 🎉',
tests: {
adminInit: 'OK',
firestoreWrite: 'OK',
firestoreRead: 'OK',
authInit: authCheck,
},
projectId: process.env.FIREBASE_PROJECT_ID,
testData: data,
});
} catch (error) {
console.error('Firebase test error:', error);
return NextResponse.json(
{
error: 'Firebase connection failed',
details: error instanceof Error ? error.message : String(error),
tip: 'Check your .env.local file for correct FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, and FIREBASE_PRIVATE_KEY',
},
{ status: 500 }
);
}
}

View File

@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest, NextResponse } from "next/server";
import {
isCoolifyInfraOperational,
runCoolifyInfraHealthProbe,
} from '@/lib/server/infra-coolify-health';
} from "@/lib/server/infra-coolify-health";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
/**
* Authenticated infrastructure probe for Coolify API + SSH→Docker.
@@ -23,20 +24,22 @@ export async function GET(req: NextRequest) {
{
ok: false,
error:
'INFRA_HEALTH_SECRET is not set — configure it on vibn-frontend to enable this probe.',
"INFRA_HEALTH_SECRET is not set — configure it on vibn-frontend to enable this probe.",
},
{ status: 503 },
);
}
const auth = req.headers.get('authorization');
const bearer =
auth?.startsWith('Bearer ') ? auth.slice(7).trim() : '';
const headerSecret = req.headers.get('x-vibn-infra-secret')?.trim() ?? '';
const auth = req.headers.get("authorization");
const bearer = auth?.startsWith("Bearer ") ? auth.slice(7).trim() : "";
const headerSecret = req.headers.get("x-vibn-infra-secret")?.trim() ?? "";
const token = bearer || headerSecret;
if (token !== secret) {
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
if (!token || !timingSafeStringEq(secret, token)) {
return NextResponse.json(
{ ok: false, error: "Unauthorized" },
{ status: 401 },
);
}
try {

View File

@@ -0,0 +1,85 @@
/**
* GET /api/invites/[token] — validate (public, used by /auth page)
* POST /api/invites/[token]/redeem — consume (called on sign-up completion)
*/
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db-postgres";
import { authSession } from "@/lib/auth/session-server";
interface InviteRow {
token: string;
email: string | null;
max_uses: number;
use_count: number;
expires_at: string | null;
redeemed_by: string[];
}
async function validateInvite(
token: string,
): Promise<{ valid: boolean; reason?: string; row?: InviteRow }> {
const row = await queryOne<InviteRow>(
`SELECT token, email, max_uses, use_count, expires_at, redeemed_by
FROM invites WHERE token = $1`,
[token],
);
if (!row) return { valid: false, reason: "Token not found" };
if (row.use_count >= row.max_uses)
return { valid: false, reason: "Token already fully redeemed" };
if (row.expires_at && new Date(row.expires_at) < new Date())
return { valid: false, reason: "Token expired" };
return { valid: true, row };
}
/** GET /api/invites/[token] — check if a token is valid (used by auth page UI) */
export async function GET(
_req: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const { token } = await params;
const { valid, reason, row } = await validateInvite(token);
if (!valid) {
return NextResponse.json({ valid: false, reason }, { status: 400 });
}
return NextResponse.json({
valid: true,
email: row!.email ?? null,
usesRemaining: row!.max_uses - row!.use_count,
});
}
/** POST /api/invites/[token] — redeem (call after a user signs up) */
export async function POST(
_req: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { token } = await params;
const { valid, reason, row } = await validateInvite(token);
if (!valid) {
return NextResponse.json({ ok: false, reason }, { status: 400 });
}
const email = session.user.email;
if (row!.email && row!.email !== email) {
return NextResponse.json(
{ ok: false, reason: "Token is for a different email address" },
{ status: 403 },
);
}
if (row!.redeemed_by.includes(email)) {
return NextResponse.json(
{ ok: true, alreadyRedeemed: true },
);
}
await query(
`UPDATE invites
SET use_count = use_count + 1,
redeemed_by = array_append(redeemed_by, $2)
WHERE token = $1`,
[token, email],
);
return NextResponse.json({ ok: true, redeemed: true });
}

View File

@@ -0,0 +1,90 @@
/**
* 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" },
);

View File

@@ -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 },
);
}
},
);

View 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 });
},
);

View File

@@ -1,21 +0,0 @@
/**
* Companion endpoint to /sentry-example-page. Throws on every call
* so the "Throw server error" button has something real to fail
* against. The thrown error propagates to instrumentation.ts'
* onRequestError hook and lands in Sentry as a server-side issue.
*/
export const dynamic = "force-dynamic";
class SentryExampleApiError extends Error {
constructor(message: string) {
super(message);
this.name = "SentryExampleApiError";
}
}
export function GET() {
throw new SentryExampleApiError(
"Sentry test (API route) — vibn-ai",
);
}

View File

@@ -1,37 +0,0 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/firebase/config';
import { adminAuth } from '@/lib/firebase/admin';
export async function GET() {
try {
// Get current user from client-side auth
const user = auth.currentUser;
if (!user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Get ID token
const token = await user.getIdToken();
console.log('Token length:', token.length);
console.log('User UID:', user.uid);
// Try to verify it with Admin SDK
const decodedToken = await adminAuth.verifyIdToken(token);
return NextResponse.json({
success: true,
clientUid: user.uid,
decodedUid: decodedToken.uid,
match: user.uid === decodedToken.uid,
});
} catch (error) {
console.error('Token verification error:', error);
return NextResponse.json({
error: 'Token verification failed',
details: error instanceof Error ? error.message : String(error),
}, { status: 500 });
}
}

View File

@@ -1,43 +1,90 @@
/**
* POST /api/webhooks/coolify?projectId={projectId}
*
* Receives deployment status events from Coolify.
* Updates the project's contextSnapshot.lastDeployment in Postgres.
* Receives deployment status events from Coolify and updates the project's
* contextSnapshot.lastDeployment in Postgres.
*
* Closes S-06: signature verification was missing. Coolify (≥4.0.0-beta.300)
* signs every webhook with HMAC-SHA256 of the raw body using the per-app
* `webhook_secret`. We now reject unsigned / mismatched requests.
*
* Configure: in Coolify, set the webhook target to
* https://vibnai.com/api/webhooks/coolify?projectId={projectId}
* AND set the per-app webhook secret to match `COOLIFY_WEBHOOK_SECRET`.
*/
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db-postgres';
import { NextRequest, NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import { verifyCoolifySignature } from "@/lib/server/coolify-webhook";
import { log } from "@/lib/server/logger";
const COOLIFY_WEBHOOK_SECRET = process.env.COOLIFY_WEBHOOK_SECRET ?? "";
export async function POST(request: NextRequest) {
const projectId = request.nextUrl.searchParams.get('projectId');
const projectId = request.nextUrl.searchParams.get("projectId");
if (!projectId) {
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
return NextResponse.json({ error: "Missing projectId" }, { status: 400 });
}
let payload: any;
if (!COOLIFY_WEBHOOK_SECRET) {
log.error("coolify webhook: COOLIFY_WEBHOOK_SECRET not configured", {
route: "api.webhooks.coolify",
projectId,
});
return NextResponse.json(
{ error: "Webhook receiver not configured" },
{ status: 503 },
);
}
// We need the RAW body for HMAC verification — `.json()` would
// re-serialize and could change byte order. Read text first, then parse.
const rawBody = await request.text();
const signature = request.headers.get("x-coolify-signature-256");
const ok = await verifyCoolifySignature(
rawBody,
signature,
COOLIFY_WEBHOOK_SECRET,
);
if (!ok) {
log.warn("coolify webhook: invalid signature", {
route: "api.webhooks.coolify",
projectId,
hasHeader: !!signature,
});
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
let payload: Record<string, unknown>;
try {
payload = await request.json();
payload = JSON.parse(rawBody) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const rows = await query<{ id: string; data: any }>(
const rows = await query<{ id: string; data: Record<string, unknown> }>(
`SELECT id, data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
[projectId],
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const project = rows[0];
const existingSnapshot = project.data?.contextSnapshot ?? {};
const existingSnapshot =
(project.data?.contextSnapshot as Record<string, unknown>) ?? {};
// Coolify sends status events like: queued, in_progress, finished, failed, cancelled
const status = payload.status ?? payload.data?.status ?? 'unknown';
const applicationUuid = payload.application_uuid ?? payload.data?.application_uuid;
const deploymentUuid = payload.deployment_uuid ?? payload.data?.deployment_uuid;
const url = payload.fqdn ?? payload.data?.fqdn ?? null;
const data = (payload.data ?? {}) as Record<string, unknown>;
const status = (payload.status ?? data.status ?? "unknown") as string;
const applicationUuid = (payload.application_uuid ??
data.application_uuid) as string | undefined;
const deploymentUuid = (payload.deployment_uuid ?? data.deployment_uuid) as
| string
| undefined;
const url = (payload.fqdn ?? data.fqdn ?? null) as string | null;
const newSnapshot = {
...existingSnapshot,
@@ -51,12 +98,18 @@ export async function POST(request: NextRequest) {
updatedAt: new Date().toISOString(),
};
await query(`
UPDATE fs_projects
SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb)
WHERE id = $2
`, [JSON.stringify(newSnapshot), projectId]);
await query(
`UPDATE fs_projects
SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb)
WHERE id = $2`,
[JSON.stringify(newSnapshot), projectId],
);
console.log(`[webhook/coolify] deploy ${status} for project ${projectId}`);
log.info("coolify webhook received", {
route: "api.webhooks.coolify",
projectId,
status,
deploymentUuid,
});
return NextResponse.json({ ok: true, status, projectId });
}

View File

@@ -1,42 +1,65 @@
/**
* GET /api/work-completed?projectId=<uuid>&limit=<n>
*
* Returns the work_completed rows for a project the caller owns.
*
* Closes S-05: this route used to accept any projectId off the query string
* with no auth and silently fell back to `projectId = 1` on missing input.
*/
import { NextResponse } from "next/server";
import { query } from "@/lib/db-postgres";
import type { WorkCompleted } from "@/lib/types";
import { withTenantProject } from "@/lib/server/api-handler";
import { log } from "@/lib/server/logger";
export async function GET(request: Request) {
try {
export const GET = withTenantProject(
async (request) => {
const { searchParams } = new URL(request.url);
const projectId = searchParams.get("projectId");
const limit = searchParams.get("limit") || "20";
const workItems = await query<WorkCompleted>(
`SELECT
wc.*,
s.session_id,
s.primary_ai_model,
s.duration_minutes
FROM work_completed wc
LEFT JOIN sessions s ON wc.session_id = s.id
WHERE wc.project_id = $1
ORDER BY wc.completed_at DESC
LIMIT $2`,
[projectId || 1, limit],
if (!projectId) {
return NextResponse.json(
{ error: "projectId is required" },
{ status: 400 },
);
}
const limit = Math.min(
200,
Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10) || 20),
);
// Parse JSON fields
const parsedWork = workItems.map((item) => ({
...item,
files_modified:
typeof item.files_modified === "string"
? JSON.parse(item.files_modified as any)
: item.files_modified,
}));
return NextResponse.json(parsedWork);
} catch (error) {
console.error("Error fetching work completed:", error);
return NextResponse.json(
{ error: "Failed to fetch work completed" },
{ status: 500 },
);
}
}
try {
const workItems = await query<WorkCompleted>(
`SELECT
wc.*,
s.session_id,
s.primary_ai_model,
s.duration_minutes
FROM work_completed wc
LEFT JOIN sessions s ON wc.session_id = s.id
WHERE wc.project_id = $1
ORDER BY wc.completed_at DESC
LIMIT $2`,
[projectId, limit],
);
const parsedWork = workItems.map((item) => ({
...item,
files_modified:
typeof item.files_modified === "string"
? JSON.parse(item.files_modified)
: item.files_modified,
}));
return NextResponse.json(parsedWork);
} catch (err) {
log.error("work-completed: db error", {
route: "api.work-completed",
projectId,
err: err instanceof Error ? err.message : String(err),
});
return NextResponse.json(
{ error: "Failed to fetch work completed" },
{ status: 500 },
);
}
},
{ source: "search", paramName: "projectId" },
);

View File

@@ -30,24 +30,31 @@ interface ProjectStagePillProps {
}
type PillState =
| { kind: "build_failed"; reason: string }
| { kind: "deploying"; reason: string }
| { kind: "down"; reason: string }
| { kind: "live"; reason: string }
| { kind: "empty"; reason: string };
| { kind: "build_failed"; reason: string }
| { kind: "deploying"; reason: string }
| { kind: "down"; reason: string }
| { kind: "live"; reason: string }
| { kind: "empty"; reason: string };
export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillProps) {
export function ProjectStagePill({
projectId,
fallbackStage,
}: ProjectStagePillProps) {
const [anatomyPollMs, setAnatomyPollMs] = useState(0);
const { anatomy, loading } = useAnatomy(projectId, { pollMs: anatomyPollMs });
useEffect(() => {
if (!anatomy) {
setAnatomyPollMs(0);
// Don't call setState here if not needed
if (anatomyPollMs !== 0) setAnatomyPollMs(0);
return;
}
const s = derivePillState(anatomy);
setAnatomyPollMs(s.kind === "live" || s.kind === "empty" ? 0 : 8000);
}, [anatomy]);
const targetPollMs = s.kind === "live" || s.kind === "empty" ? 0 : 8000;
if (anatomyPollMs !== targetPollMs) {
setAnatomyPollMs(targetPollMs);
}
}, [anatomy, anatomyPollMs]);
const state = useMemo<PillState | null>(() => {
if (!anatomy) return null;
@@ -56,11 +63,25 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
if (loading && !anatomy) {
const f = FALLBACK_PRESETS[fallbackStage];
return <Pill label={f.label} color={f.color} bg={f.bg} title="Loading project status…" />;
return (
<Pill
label={f.label}
color={f.color}
bg={f.bg}
title="Loading project status…"
/>
);
}
if (!state) {
const f = FALLBACK_PRESETS[fallbackStage];
return <Pill label={f.label} color={f.color} bg={f.bg} title="Project status unavailable." />;
return (
<Pill
label={f.label}
color={f.color}
bg={f.bg}
title="Project status unavailable."
/>
);
}
const visual = VISUALS[state.kind];
@@ -110,9 +131,13 @@ export function ProjectStagePill({ projectId, fallbackStage }: ProjectStagePillP
rel="noreferrer"
title={`Open Coolify build logs in a new tab`}
style={{
display: "inline-flex", alignItems: "center", gap: 3,
fontSize: "0.68rem", color: logsLinkColor,
textDecoration: "none", opacity: 0.8,
display: "inline-flex",
alignItems: "center",
gap: 3,
fontSize: "0.68rem",
color: logsLinkColor,
textDecoration: "none",
opacity: 0.8,
}}
>
Logs <ExternalLink size={9} />
@@ -137,7 +162,12 @@ function classifyAppStatus(raw?: string): AppPhase {
if (!s || s === "unknown") return "unknown";
if (/^(running|healthy)/.test(s)) return "up";
if (/healthy/.test(s) && !/unhealthy/.test(s)) return "up";
if (/^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(s)) return "transient";
if (
/^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(
s,
)
)
return "transient";
if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down";
// Default to transient for anything unrecognised — Coolify occasionally
// emits novel phases during upgrades; better to wait than mis-flag red.
@@ -149,7 +179,10 @@ function derivePillState(a: Anatomy): PillState {
const live = a.hosting?.live ?? [];
if (live.length === 0) {
return { kind: "empty", reason: "No apps deployed yet. Use the chat to spin one up." };
return {
kind: "empty",
reason: "No apps deployed yet. Use the chat to spin one up.",
};
}
// 1. Active build in flight — highest priority signal.
@@ -157,12 +190,17 @@ function derivePillState(a: Anatomy): PillState {
if (deploying.length > 0) {
const names = deploying.map((l) => l.name).join(", ");
const stage = deploying[0].inFlightBuild?.status ?? "in progress";
return { kind: "deploying", reason: `Deploying ${names}\nCoolify status: ${stage}` };
return {
kind: "deploying",
reason: `Deploying ${names}\nCoolify status: ${stage}`,
};
}
// 2. Container is currently booting (starting / restarting). Surface
// as "Deploying" since to the user this is the same wait state.
const transient = live.filter((l) => classifyAppStatus(l.status) === "transient");
const transient = live.filter(
(l) => classifyAppStatus(l.status) === "transient",
);
if (transient.length > 0) {
const lines = transient.map((l) => `${l.name}: ${l.status}`);
return {
@@ -180,9 +218,14 @@ function derivePillState(a: Anatomy): PillState {
const lines = failed.map(
(l) =>
`${l.name}: ${l.lastBuild?.status}` +
(l.lastBuild?.finishedAt ? ` · ${relTime(l.lastBuild.finishedAt)}` : ""),
(l.lastBuild?.finishedAt
? ` · ${relTime(l.lastBuild.finishedAt)}`
: ""),
);
return { kind: "build_failed", reason: `Last deploy failed:\n${lines.join("\n")}` };
return {
kind: "build_failed",
reason: `Last deploy failed:\n${lines.join("\n")}`,
};
}
const phases = live.map((l) => classifyAppStatus(l.status));
@@ -196,10 +239,16 @@ function derivePillState(a: Anatomy): PillState {
};
}
if (upCount > 0) {
return { kind: "live", reason: `${upCount}/${live.length} services running.` };
return {
kind: "live",
reason: `${upCount}/${live.length} services running.`,
};
}
if (downCount > 0) {
const sample = live.slice(0, 3).map((l) => `${l.name}: ${l.status}`).join("\n");
const sample = live
.slice(0, 3)
.map((l) => `${l.name}: ${l.status}`)
.join("\n");
return { kind: "down", reason: `Apps are not running.\n${sample}` };
}
@@ -213,42 +262,69 @@ function derivePillState(a: Anatomy): PillState {
// ──────────────────────────────────────────────────
const VISUALS: Record<PillState["kind"], { label: string; color: string; bg: string }> = {
const VISUALS: Record<
PillState["kind"],
{ label: string; color: string; bg: string }
> = {
build_failed: { label: "Build failed", color: "#c5392b", bg: "#c5392b14" },
deploying: { label: "Deploying", color: "#3d5afe", bg: "#3d5afe10" },
down: { label: "Down", color: "#c5392b", bg: "#c5392b14" },
live: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
empty: { label: "Empty", color: "#7c7770", bg: "#a09a9014" },
deploying: { label: "Deploying", color: "#3d5afe", bg: "#3d5afe10" },
down: { label: "Down", color: "#c5392b", bg: "#c5392b14" },
live: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
empty: { label: "Empty", color: "#7c7770", bg: "#a09a9014" },
};
const FALLBACK_PRESETS: Record<
"discovery" | "architecture" | "building" | "active",
{ label: string; color: string; bg: string }
> = {
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a14" },
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a14" },
architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" },
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
};
function Pill({
label, color, bg, title, spinning,
}: { label: string; color: string; bg: string; title?: string; spinning?: boolean }) {
label,
color,
bg,
title,
spinning,
}: {
label: string;
color: string;
bg: string;
title?: string;
spinning?: boolean;
}) {
return (
<span
title={title}
style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "4px 10px", borderRadius: 4,
fontSize: "0.7rem", fontWeight: 600, letterSpacing: "0.02em",
color, background: bg, whiteSpace: "nowrap",
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 10px",
borderRadius: 4,
fontSize: "0.7rem",
fontWeight: 600,
letterSpacing: "0.02em",
color,
background: bg,
whiteSpace: "nowrap",
cursor: title ? "help" : "default",
}}
>
{spinning ? (
<Loader2 size={9} className="animate-spin" style={{ color }} />
) : (
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: color,
}}
/>
)}
{label}
</span>

View File

@@ -9,10 +9,20 @@
import { useEffect, useState } from "react";
export interface Anatomy {
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
project: {
id: string;
name: string;
gitea?: string;
coolifyProjectUuid?: string;
};
codebasesReason?: "no_repo" | "empty_repo";
product: {
codebases: Array<{ id: string; label: string; path: string; hint?: string }>;
codebases: Array<{
id: string;
label: string;
path: string;
hint?: string;
}>;
images: Array<{
uuid: string;
name: string;
@@ -104,7 +114,10 @@ export interface UseAnatomyOptions {
pollMs?: number;
}
export function useAnatomy(projectId: string, options: UseAnatomyOptions = {}): UseAnatomyResult {
export function useAnatomy(
projectId: string,
options: UseAnatomyOptions = {},
): UseAnatomyResult {
const { pollMs } = options;
const [anatomy, setAnatomy] = useState<Anatomy | null>(null);
const [loading, setLoading] = useState(true);
@@ -131,22 +144,30 @@ export function useAnatomy(projectId: string, options: UseAnatomyOptions = {}):
fetch(`/api/projects/${projectId}/anatomy`, {
credentials: "include",
signal: controller.signal,
cache: "no-store",
})
.then(async r => {
.then(async (r) => {
let body: unknown = {};
try { body = await r.json(); } catch { /* keep {} */ }
try {
body = await r.json();
} catch {
/* keep {} */
}
if (!r.ok) {
const msg = (body as { error?: string }).error || `HTTP ${r.status} ${r.statusText}`.trim();
const msg =
(body as { error?: string }).error ||
`HTTP ${r.status} ${r.statusText}`.trim();
throw new Error(msg);
}
return body as Anatomy;
})
.then(data => {
.then((data) => {
if (!cancelled) setAnatomy(data);
})
.catch(err => {
.catch((err) => {
if (cancelled) return;
if (err?.name === "AbortError") setError("Request timed out after 10s.");
if (err?.name === "AbortError")
setError("Request timed out after 10s.");
else setError(err?.message || "Failed to load project anatomy");
})
.finally(() => {
@@ -161,5 +182,5 @@ export function useAnatomy(projectId: string, options: UseAnatomyOptions = {}):
};
}, [projectId, tick]);
return { anatomy, loading, error, reload: () => setTick(t => t + 1) };
return { anatomy, loading, error, reload: () => setTick((t) => t + 1) };
}

View File

@@ -1,17 +1,13 @@
/**
* Gemini 3.1 Pro chat client with tool-calling support.
*
* Architecture:
* - Tool-calling rounds use generateContent (non-streaming) so we always
* get the complete response including thought_signature. Thinking models
* (2.5+, 3.x) require this field to be echoed back in functionResponse
* and it is not reliably present in individual SSE chunks.
* - Final text-only response uses streamGenerateContent for good UX.
*/
import { GoogleGenAI } from '@google/genai';
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || "";
const GEMINI_MODEL = process.env.VIBN_CHAT_MODEL || "gemini-3.1-pro-preview";
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
if (!GEMINI_API_KEY) {
console.warn(`[GeminiChat] WARNING: GOOGLE_API_KEY is not set. Chat stream will fail with 403 Forbidden.`);
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
export interface ChatMessage {
role: "user" | "assistant" | "tool";
@@ -26,7 +22,6 @@ export interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
/** Must be echoed back in functionResponse for Gemini thinking models */
thoughtSignature?: string;
}
@@ -43,7 +38,6 @@ export interface ChatChunk {
error?: string;
}
/** Convert our ChatMessage[] to Gemini's contents[] format */
function toGeminiContents(messages: ChatMessage[]) {
const contents: any[] = [];
@@ -55,12 +49,12 @@ function toGeminiContents(messages: ChatMessage[]) {
if (msg.content) parts.push({ text: msg.content });
if (msg.toolCalls?.length) {
for (const tc of msg.toolCalls) {
// thoughtSignature is a SIBLING of functionCall in the part object,
// not nested inside it. See: ai.google.dev/gemini-api/docs/thought-signatures
const part: any = {
functionCall: { name: tc.name, args: tc.args, id: tc.id },
functionCall: { name: tc.name, args: tc.args },
};
if (tc.thoughtSignature) part.thoughtSignature = tc.thoughtSignature;
if (tc.thoughtSignature) {
part.thoughtSignature = tc.thoughtSignature;
}
parts.push(part);
}
}
@@ -69,8 +63,7 @@ function toGeminiContents(messages: ChatMessage[]) {
const part = {
functionResponse: {
name: msg.toolName || "unknown",
id: msg.toolCallId,
response: { content: msg.content },
response: { name: msg.toolName || "unknown", content: msg.content },
},
};
const last = contents[contents.length - 1];
@@ -91,44 +84,12 @@ function toGeminiFunctions(tools: ToolDefinition[]) {
functionDeclarations: tools.map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters,
parameters: t.parameters as any,
})),
},
];
}
function buildBody(opts: {
systemPrompt: string;
messages: ChatMessage[];
tools?: ToolDefinition[];
temperature?: number;
/**
* Ask Gemini to return its thought summaries as parts marked
* `thought: true`. We pay for thinking tokens regardless; this just
* makes them visible so the UI can show "Reading server.js…",
* "Shipping to production…" between tool calls instead of leaving
* the user staring at a silent tool tray. Defaults to true.
*/
includeThoughts?: boolean;
}) {
const body: any = {
contents: toGeminiContents(opts.messages),
systemInstruction: { parts: [{ text: opts.systemPrompt }] },
generationConfig: {
temperature: opts.temperature ?? 0.7,
maxOutputTokens: 8192,
thinkingConfig: { includeThoughts: opts.includeThoughts ?? true },
},
};
const fns = toGeminiFunctions(opts.tools ?? []);
if (fns) body.tools = fns;
return body;
}
/**
* Non-streaming call — used for tool-calling rounds.
* Returns complete response with thought_signature guaranteed.
*/
export async function callGeminiChat(opts: {
systemPrompt: string;
messages: ChatMessage[];
@@ -137,149 +98,115 @@ export async function callGeminiChat(opts: {
includeThoughts?: boolean;
}): Promise<{
text: string;
/** First-person reasoning narration; meant for a "thinking" UI panel, not the main bubble. */
thoughts: string;
toolCalls: ToolCall[];
finishReason?: string;
error?: string;
}> {
const url = `${GEMINI_BASE_URL}/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildBody(opts)),
const config: any = {
temperature: opts.temperature ?? 0.7,
maxOutputTokens: 8192,
};
if (opts.systemPrompt) {
config.systemInstruction = opts.systemPrompt;
}
if (opts.includeThoughts) {
config.thinkingConfig = { thinkingBudgetTokens: 1024 };
}
const fns = toGeminiFunctions(opts.tools ?? []);
if (fns) config.tools = fns;
const response = await ai.models.generateContent({
model: GEMINI_MODEL,
contents: toGeminiContents(opts.messages),
config
});
} catch (e) {
let text = "";
let thoughts = "";
const toolCalls: ToolCall[] = [];
const parts = response.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.text) {
if ((part as any).thought) thoughts += part.text;
else text += part.text;
}
if (part.functionCall) {
toolCalls.push({
id: `tc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: part.functionCall.name,
args: part.functionCall.args as Record<string, unknown> ?? {},
thoughtSignature: (part as any).thoughtSignature,
});
}
}
return {
text,
thoughts,
toolCalls,
finishReason: response.candidates?.[0]?.finishReason
};
} catch (error) {
return {
text: "",
thoughts: "",
toolCalls: [],
error: `Network error: ${e instanceof Error ? e.message : String(e)}`,
error: `GoogleGenAI error: ${error instanceof Error ? error.message : String(error)}`,
};
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data?.error?.message || JSON.stringify(data).slice(0, 200);
return {
text: "",
thoughts: "",
toolCalls: [],
error: `Gemini API error ${res.status}: ${msg}`,
};
}
const cand = data?.candidates?.[0];
const parts: any[] = cand?.content?.parts ?? [];
let text = "";
let thoughts = "";
const toolCalls: ToolCall[] = [];
for (const part of parts) {
if (part.text) {
// CRITICAL: Gemini tags reasoning parts with `thought: true`. If
// we lump them into `text` they leak into the chat bubble as if
// they were prose for the user — which is the opposite of what
// the user wants. Keep them in their own bucket so the route
// can stream them as a separate SSE event type.
if (part.thought) thoughts += part.text;
else text += part.text;
}
if (part.functionCall) {
toolCalls.push({
id:
part.functionCall.id ||
`tc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: part.functionCall.name,
args: part.functionCall.args ?? {},
// thoughtSignature is a SIBLING of functionCall in the part, not inside it
thoughtSignature: part.thoughtSignature,
});
}
}
return { text, thoughts, toolCalls, finishReason: cand?.finishReason };
}
/**
* Streaming call — used for the final text-only response.
* Yields ChatChunk objects.
*/
export async function* streamGeminiChat(opts: {
systemPrompt: string;
messages: ChatMessage[];
tools?: ToolDefinition[];
temperature?: number;
}): AsyncGenerator<ChatChunk> {
const url = `${GEMINI_BASE_URL}/models/${GEMINI_MODEL}:streamGenerateContent?key=${GEMINI_API_KEY}&alt=sse`;
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildBody(opts)),
const config: any = {
temperature: opts.temperature ?? 0.7,
maxOutputTokens: 8192,
thinkingConfig: { thinkingBudgetTokens: 1024 },
};
if (opts.systemPrompt) {
config.systemInstruction = opts.systemPrompt;
}
const fns = toGeminiFunctions(opts.tools ?? []);
if (fns) config.tools = fns;
const streamResult = await ai.models.generateContentStream({
model: GEMINI_MODEL,
contents: toGeminiContents(opts.messages),
config
});
} catch (e) {
yield {
type: "error",
error: `Network error: ${e instanceof Error ? e.message : String(e)}`,
};
return;
}
if (!res.ok) {
const errText = await res.text().catch(() => "");
yield {
type: "error",
error: `Gemini API error ${res.status}: ${errText.slice(0, 300)}`,
};
return;
}
const reader = res.body?.getReader();
if (!reader) {
yield { type: "error", error: "No response body" };
return;
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (!data || data === "[DONE]") continue;
let chunk: any;
try {
chunk = JSON.parse(data);
} catch {
continue;
}
const parts = chunk?.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.text) {
yield part.thought
? { type: "thinking", text: part.text }
: { type: "text", text: part.text };
}
for await (const chunk of streamResult) {
const parts = chunk.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.text) {
yield (part as any).thought
? { type: "thinking", text: part.text }
: { type: "text", text: part.text };
}
}
}
} finally {
reader.releaseLock();
}
yield { type: "done" };
yield { type: "done" };
} catch (error) {
yield {
type: "error",
error: `GoogleGenAI error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}

View File

@@ -1,10 +1,17 @@
import type { LlmClient, StructuredCallArgs } from '@/lib/ai/llm-client';
import { GoogleGenAI } from '@google/genai';
import { zodToJsonSchema } from 'zod-to-json-schema';
// Use the new Google GenAI SDK (replacing the deprecated VertexAI SDK)
// Since Vertex AI gemini-3.1-pro-preview threw a 404 in your region, we use the standard AI Studio endpoint natively.
const DEFAULT_MODEL = process.env.GEMINI_MODEL || 'gemini-3.1-pro-preview';
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || '';
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
if (!GOOGLE_API_KEY) {
console.warn(`[GeminiLlmClient] WARNING: GOOGLE_API_KEY is not set. API calls will fail with a 403 Forbidden.`);
}
const ai = new GoogleGenAI({ apiKey: GOOGLE_API_KEY });
class JsonValidationError extends Error {
constructor(message: string, public readonly rawResponse: string) {
@@ -20,67 +27,12 @@ function extractJsonPayload(raw: string): string {
return trimmed;
}
async function parseResponse<TOutput>(
rawResponse: any,
schema: StructuredCallArgs<TOutput>['schema'],
): Promise<TOutput> {
let text = '';
const finishReason = rawResponse?.candidates?.[0]?.finishReason;
if (finishReason && finishReason !== 'STOP') {
console.warn(`[Gemini] WARNING: Response may be incomplete. finishReason: ${finishReason}`);
}
if (rawResponse?.candidates?.[0]?.content?.parts?.[0]?.text) {
text = rawResponse.candidates[0].content.parts[0].text;
} else if (rawResponse?.text) {
text = rawResponse.text;
} else if (typeof rawResponse === 'string') {
text = rawResponse;
}
if (text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) {
console.error('[Gemini] Received HTML — likely an API auth error');
throw new Error('Gemini API returned HTML. Check GOOGLE_API_KEY.');
}
if (!text) {
console.error('[Gemini] Empty response:', JSON.stringify(rawResponse)?.slice(0, 300));
throw new Error('Empty response from Gemini API');
}
console.log('[Gemini] Response preview:', text.slice(0, 200));
const cleaned = extractJsonPayload(text);
let parsed: unknown;
try {
parsed = JSON.parse(cleaned);
} catch (error) {
throw new JsonValidationError(
`Failed to parse JSON: ${(error as Error).message}`,
text,
);
}
const validation = schema.safeParse(parsed);
if (!validation.success) {
console.error('[Gemini] Schema validation failed:', validation.error.errors);
throw new JsonValidationError(validation.error.message, text);
}
return validation.data;
}
export class GeminiLlmClient implements LlmClient {
private readonly model: string;
constructor() {
this.model = DEFAULT_MODEL;
if (!GOOGLE_API_KEY) {
console.warn('[Gemini] WARNING: GOOGLE_API_KEY is not set');
}
console.log(`[Gemini] Initialized — model: ${this.model}`);
console.log(`[GoogleGenAI] Initialized — model: ${this.model}`);
}
async structuredCall<TOutput>(args: StructuredCallArgs<TOutput>): Promise<TOutput> {
@@ -88,7 +40,6 @@ export class GeminiLlmClient implements LlmClient {
throw new Error(`GeminiLlmClient only supports model "gemini" (got ${args.model})`);
}
// Convert Zod schema → Google schema format
const rawJsonSchema = zodToJsonSchema(args.schema, 'responseSchema') as any;
let actualSchema: any = rawJsonSchema;
if (rawJsonSchema.$ref && rawJsonSchema.definitions) {
@@ -115,18 +66,16 @@ export class GeminiLlmClient implements LlmClient {
const googleSchema = convertToGoogleSchema(actualSchema);
// Build request body
const body: any = {
contents: args.messages.map((m) => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }],
})),
generationConfig: {
temperature: args.temperature ?? 1.0,
responseMimeType: 'application/json',
responseSchema: googleSchema,
maxOutputTokens: 32768,
},
const contents = args.messages.map((m) => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }],
}));
const config: any = {
temperature: args.temperature ?? 1.0,
responseMimeType: 'application/json',
responseSchema: googleSchema,
maxOutputTokens: 8192,
};
if (args.systemPrompt) {
@@ -134,48 +83,64 @@ export class GeminiLlmClient implements LlmClient {
for (const key of Object.keys(googleSchema.properties || {})) {
exampleJson[key] = key === 'reply' ? 'Your response here' : null;
}
body.systemInstruction = {
parts: [{
text: `${args.systemPrompt}\n\nIMPERATIVE: Respond ONLY with this exact JSON format:\n${JSON.stringify(exampleJson)}\n\nDo NOT add any other fields.`,
}],
};
config.systemInstruction = `${args.systemPrompt}\n\nIMPERATIVE: Respond ONLY with this exact JSON format:\n${JSON.stringify(exampleJson)}\n\nDo NOT add any other fields.`;
}
if (args.thinking_config) {
body.generationConfig.thinkingConfig = {
thinkingLevel: args.thinking_config.thinking_level?.toUpperCase() || 'HIGH',
includeThoughts: args.thinking_config.include_thoughts || false,
config.thinkingConfig = {
thinkingBudgetTokens: 1024,
};
}
const url = `${GEMINI_BASE_URL}/${this.model}:generateContent?key=${GOOGLE_API_KEY}`;
const run = async () => {
console.log(`[Gemini] POST ${GEMINI_BASE_URL}/${this.model}:generateContent`);
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(180_000),
const run = async (currentContents: any[]) => {
console.log(`[GoogleGenAI] generateContent with ${this.model}`);
const response = await ai.models.generateContent({
model: this.model,
contents: currentContents,
config,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gemini API error (${response.status}): ${errorText}`);
const text = response.text || '';
if (text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) {
throw new Error('GoogleGenAI returned HTML. Check API key permissions.');
}
if (!text) {
throw new Error('Empty response from GoogleGenAI');
}
const result = await response.json();
return parseResponse(result, args.schema);
console.log('[GoogleGenAI] Response preview:', text.slice(0, 200));
const cleaned = extractJsonPayload(text);
let parsed: unknown;
try {
parsed = JSON.parse(cleaned);
} catch (error) {
throw new JsonValidationError(
`Failed to parse JSON: ${(error as Error).message}`,
text,
);
}
const validation = args.schema.safeParse(parsed);
if (!validation.success) {
console.error('[GoogleGenAI] Schema validation failed:', validation.error.errors);
throw new JsonValidationError(validation.error.message, text);
}
return validation.data;
};
try {
return await run();
return await run(contents);
} catch (error) {
if (!(error instanceof JsonValidationError)) throw error;
// Retry once on JSON parse failure
body.contents = [
...body.contents,
console.warn(`[GoogleGenAI] JSON Validation failed. Retrying...`);
const retryContents = [
...contents,
{
role: 'user',
parts: [{
@@ -183,7 +148,7 @@ export class GeminiLlmClient implements LlmClient {
}],
},
];
return run();
return run(retryContents);
}
}
}

View File

@@ -14,8 +14,11 @@
* happened.
*/
import { runOnCoolifyHost } from '@/lib/coolify-ssh';
import { resolveAppTargetContainer, type ComposeContainer } from '@/lib/coolify-containers';
import { runOnCoolifyHost } from "@/lib/coolify-ssh";
import {
resolveAppTargetContainer,
type ComposeContainer,
} from "@/lib/coolify-containers";
const DEFAULT_TIMEOUT_MS = 60_000;
const MAX_TIMEOUT_MS = 600_000; // 10 min — enough for migrations / seeds
@@ -47,7 +50,7 @@ export interface ExecInAppResult {
truncated: boolean;
durationMs: number;
/** Container health at time of exec (parsed from `docker ps`). */
containerHealth: ComposeContainer['health'];
containerHealth: ComposeContainer["health"];
/** The command as it was actually executed (post-escape, for logs). */
executedCommand: string;
}
@@ -57,9 +60,11 @@ function sq(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
export async function execInCoolifyApp(opts: ExecInAppOptions): Promise<ExecInAppResult> {
if (!opts.command || typeof opts.command !== 'string') {
throw new Error('command is required');
export async function execInCoolifyApp(
opts: ExecInAppOptions,
): Promise<ExecInAppResult> {
if (!opts.command || typeof opts.command !== "string") {
throw new Error("command is required");
}
const timeoutMs = Math.min(
Math.max(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, 1_000),
@@ -80,14 +85,27 @@ export async function execInCoolifyApp(opts: ExecInAppOptions): Promise<ExecInAp
if (opts.user) flags.push(`--user ${sq(opts.user)}`);
if (opts.workdir) flags.push(`--workdir ${sq(opts.workdir)}`);
const executedCommand = `docker exec ${flags.join(' ')} ${sq(container.name)} sh -lc ${sq(opts.command)}`.replace(/\s+/g, ' ').trim();
// NOTE: do NOT collapse whitespace on the outer docker-exec invocation.
// The command payload is already single-quoted via sq(), so only the
// docker/flag portion needs normalising — and newlines in the payload
// itself must be preserved so multi-line bash scripts work correctly.
// (Collapsing \n → ' ' turns `if ... ; then\n cmd\nfi` into invalid sh.)
// Keep the trailing space before the quoted command — dash (Ubuntu's sh)
// requires a space between -lc and its argument. Trimming it causes
// sh to mis-parse the quoted string as flags ("Illegal option -d").
const dockerPrefix =
`docker exec ${flags.join(" ")} ${sq(container.name)} sh -lc `.replace(
/ +/g,
" ",
);
const executedCommand = `${dockerPrefix}${sq(opts.command)}`;
const startedAt = Date.now();
// Audit log: record the command + target, NOT the output (output
// may contain secrets). Structured so downstream log shipping can
// parse it.
console.log(
'[apps.exec]',
"[apps.exec]",
JSON.stringify({
app_uuid: opts.appUuid,
container: container.name,

View File

@@ -0,0 +1,185 @@
/**
* Project brief extraction.
* Closes BETA_LAUNCH_PLAN P3.7.
*
* When a user uploads a PDF / .md / .docx / .txt brief file, we extract
* the text here and store it on `fs_projects.data.plan.brief`. The
* `buildSystemPrompt` function in `app/api/chat/route.ts` then surfaces
* it in the [PROJECT BRIEF] block.
*
* Supports:
* - .txt / .md — read as-is
* - .pdf — extract text via pdf.js (no binary install required)
* - .docx — extract via unzipper + xml text nodes
* - .html / .htm — strip tags
*
* 5 MB max, 50 000 chars after extraction (truncated with a note).
*/
import { query } from "@/lib/db-postgres";
import { log } from "@/lib/server/logger";
export const BRIEF_MAX_CHARS = 50_000;
export const BRIEF_MAX_BYTES = 5 * 1024 * 1024;
export type BriefExtractionResult =
| { ok: true; text: string; truncated: boolean; chars: number }
| { ok: false; error: string };
/**
* Extract plain text from a File-like object.
* Call from `POST /api/projects/[projectId]/documents/upload`.
*/
export async function extractBriefText(
buffer: Buffer,
mimeType: string,
filename: string,
): Promise<BriefExtractionResult> {
if (buffer.byteLength > BRIEF_MAX_BYTES) {
return { ok: false, error: `File is too large (max 5 MB)` };
}
try {
let text = "";
const lower = filename.toLowerCase();
if (lower.endsWith(".pdf") || mimeType === "application/pdf") {
text = await extractPdf(buffer);
} else if (
lower.endsWith(".docx") ||
mimeType ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
) {
text = await extractDocx(buffer);
} else if (lower.endsWith(".html") || lower.endsWith(".htm")) {
text = buffer.toString("utf8").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
} else {
// .txt, .md, plain text
text = buffer.toString("utf8");
}
text = text.trim();
const truncated = text.length > BRIEF_MAX_CHARS;
if (truncated) {
text =
text.slice(0, BRIEF_MAX_CHARS) +
`\n\n[Brief truncated at ${BRIEF_MAX_CHARS} chars — upload a shorter document for full coverage]`;
}
return { ok: true, text, truncated, chars: text.length };
} catch (err) {
return {
ok: false,
error: `Extraction failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
async function extractPdf(buffer: Buffer): Promise<string> {
// Dynamic import — pdf-parse is a large optional dep.
// If not installed, fall back to an error message.
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pdfParse = require("pdf-parse") as (
b: Buffer,
) => Promise<{ text: string }>;
const result = await pdfParse(buffer);
return result.text;
} catch (e: unknown) {
if (
e instanceof Error &&
e.message.includes("Cannot find module")
) {
throw new Error(
"pdf-parse package not installed. Run `npm install pdf-parse` or upload a .txt/.md file instead.",
);
}
throw e;
}
}
async function extractDocx(buffer: Buffer): Promise<string> {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DOMParser } = require("@xmldom/xmldom") as {
DOMParser: new () => { parseFromString(xml: string, type: string): Document };
};
// eslint-disable-next-line @typescript-eslint/no-require-imports
const unzipper = require("unzipper") as {
Open: {
buffer(b: Buffer): Promise<{ files: Array<{ path: string; buffer(): Promise<Buffer> }> }>;
};
};
const directory = await unzipper.Open.buffer(buffer);
const wordDoc = directory.files.find(
(f: { path: string }) => f.path === "word/document.xml",
);
if (!wordDoc) throw new Error("word/document.xml not found in docx");
const xmlBuf = await wordDoc.buffer();
const xml = xmlBuf.toString("utf8");
const doc = new DOMParser().parseFromString(xml, "text/xml");
const texts: string[] = [];
function extractText(node: Node) {
if (node.nodeType === 3 /* TEXT_NODE */) {
const t = (node as Text).textContent?.trim();
if (t) texts.push(t);
}
node.childNodes?.forEach((child: Node) => extractText(child));
}
extractText(doc);
return texts.join(" ");
} catch (e: unknown) {
if (e instanceof Error && e.message.includes("Cannot find module")) {
throw new Error(
"unzipper or @xmldom/xmldom not installed. Upload a .txt or .md file instead.",
);
}
throw e;
}
}
/**
* Persist the extracted brief text to `fs_projects.data.plan.brief`.
* Called by the upload route after extraction succeeds.
*/
export async function persistProjectBrief(
projectId: string,
text: string,
meta: { filename: string; chars: number; truncated: boolean },
): Promise<void> {
try {
await query(
`UPDATE fs_projects
SET data = jsonb_set(
data,
'{plan}',
COALESCE(data->'plan', '{}'::jsonb)
|| jsonb_build_object(
'brief', $1::text,
'briefMeta', $2::jsonb
),
true
)
WHERE id = $3`,
[
text,
JSON.stringify({
...meta,
uploadedAt: new Date().toISOString(),
}),
projectId,
],
);
log.info("project brief persisted", { projectId, chars: meta.chars });
} catch (err) {
log.error("brief persist failed", {
projectId,
err: err instanceof Error ? err.message : String(err),
});
throw err;
}
}

View File

@@ -0,0 +1,233 @@
/**
* API route wrappers.
*
* Replaces the 8-line auth + ownership boilerplate that every route file
* was reimplementing (and getting subtly wrong in 18 of them — see the
* 2026-05-17 QA pass).
*
* Usage:
*
* // Plain session-required handler.
* export const GET = withAuth(async (req, ctx, { user }) => {
* return NextResponse.json({ email: user.email });
* });
*
* // Session + tenant-project ownership in one wrapper.
* export const POST = withTenantProject(async (req, ctx, { user, project }) => {
* // `project` is guaranteed to belong to `user`.
* }, { paramName: 'projectId' });
*
* // Workspace-scoped (session OR vibn_sk_ api key OK).
* export const POST = withWorkspace(async (req, ctx, { principal }) => {
* // `principal.workspace` is guaranteed to be tenant-checked.
* });
*
* // Admin secret (ops endpoint).
* export const POST = withAdminSecret(async (req, ctx) => { … }, {
* secretEnvVar: 'ADMIN_MIGRATE_SECRET',
* });
*/
import { NextResponse } from "next/server";
import type { Session } from "next-auth";
import { authSession } from "@/lib/auth/session-server";
import { queryOne } from "@/lib/db-postgres";
import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth";
import { timingSafeStringEq } from "@/lib/server/timing-safe";
import { rateLimit, type RateLimitOpts } from "@/lib/server/rate-limit";
type WithAuthCtx = { user: NonNullable<Session["user"]> };
type WithTenantProjectCtx = WithAuthCtx & { project: ProjectRow };
type WithAdminCtx = { secret: string };
export interface ProjectRow {
id: string;
data: Record<string, unknown>;
slug?: string;
}
type RouteHandler<TCtx = unknown, TParams = unknown> = (
req: Request,
ctx: { params: Promise<TParams> },
extra: TCtx,
) => Promise<Response> | Response;
// ─── withAuth ─────────────────────────────────────────────────────────────
export function withAuth<TParams = unknown>(
handler: RouteHandler<WithAuthCtx, TParams>,
) {
return async (req: Request, ctx: { params: Promise<TParams> }) => {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(req, ctx, { user: session.user });
};
}
// ─── withTenantProject ────────────────────────────────────────────────────
export interface WithTenantProjectOpts {
/**
* Where to find the project id. Default `'projectId'` in `params`.
* - 'params:projectId' → ctx.params.projectId (default for `[projectId]` routes)
* - 'search:projectId' → searchParams.projectId
* - 'body:projectId' → body.projectId (consumes body via clone+json)
*/
source?: "params" | "search" | "body";
paramName?: string;
}
export function withTenantProject<TParams = Record<string, string>>(
handler: RouteHandler<WithTenantProjectCtx, TParams>,
opts: WithTenantProjectOpts = {},
) {
const source = opts.source ?? "params";
const name = opts.paramName ?? "projectId";
return async (req: Request, ctx: { params: Promise<TParams> }) => {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let projectId: string | undefined;
if (source === "params") {
const p = (await ctx.params) as Record<string, string>;
projectId = p?.[name];
} else if (source === "search") {
projectId = new URL(req.url).searchParams.get(name) ?? undefined;
} else if (source === "body") {
try {
const body = await req.clone().json();
projectId = body?.[name];
} catch {
// fallthrough; caller will get 400 below
}
}
if (!projectId) {
return NextResponse.json(
{ error: `${name} is required` },
{ status: 400 },
);
}
// Ownership check: project must belong to the authenticated user.
const row = await queryOne<ProjectRow>(
`SELECT p.id::text AS id, p.data
FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1::text AND u.data->>'email' = $2::text
LIMIT 1`,
[projectId, session.user.email],
);
if (!row) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return handler(req, ctx, { user: session.user, project: row });
};
}
// ─── withWorkspace (re-export of existing helper with consistent shape) ──
type WorkspacePrincipal = Exclude<
Awaited<ReturnType<typeof requireWorkspacePrincipal>>,
NextResponse
>;
export function withWorkspace<TParams = Record<string, string>>(
handler: RouteHandler<{ principal: WorkspacePrincipal }, TParams>,
opts: { paramName?: string } = {},
) {
const name = opts.paramName ?? "slug";
return async (req: Request, ctx: { params: Promise<TParams> }) => {
const params = (await ctx.params) as Record<string, string> | undefined;
const targetSlug = params?.[name];
const principal = await requireWorkspacePrincipal(req, { targetSlug });
if (principal instanceof NextResponse) return principal;
return handler(req, ctx, { principal });
};
}
// ─── withAdminSecret ──────────────────────────────────────────────────────
export interface WithAdminSecretOpts {
/** env var that holds the expected secret. */
secretEnvVar: string;
/** Header to read. Default `authorization` (expects `Bearer <secret>`). */
header?: string;
/** Alternate header that may also carry the secret (e.g. `x-admin-secret`). */
altHeader?: string;
}
export function withAdminSecret<TParams = unknown>(
handler: RouteHandler<WithAdminCtx, TParams>,
opts: WithAdminSecretOpts,
) {
return async (req: Request, ctx: { params: Promise<TParams> }) => {
const expected = process.env[opts.secretEnvVar]?.trim() ?? "";
if (!expected) {
return NextResponse.json(
{ error: `${opts.secretEnvVar} not configured — endpoint disabled` },
{ status: 403 },
);
}
const header = (opts.header ?? "authorization").toLowerCase();
const raw = req.headers.get(header) ?? "";
const bearer = raw.toLowerCase().startsWith("bearer ")
? raw.slice(7).trim()
: "";
const alt = opts.altHeader
? (req.headers.get(opts.altHeader) ?? "").trim()
: "";
const incoming = bearer || alt;
if (!incoming || !timingSafeStringEq(expected, incoming)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(req, ctx, { secret: expected });
};
}
// ─── withRateLimit ────────────────────────────────────────────────────────
export interface WithRateLimitOpts extends Omit<RateLimitOpts, "key"> {
/**
* How to derive the per-call key. Receives the bound auth context if any.
* Default: client IP.
*/
keyFn?: (req: Request, extra: unknown) => string | Promise<string>;
}
/** Wrap any other wrapper's handler. Composes neatly with withAuth/withWorkspace. */
export function withRateLimit<THandler extends (...args: any[]) => any>(
handler: THandler,
opts: WithRateLimitOpts,
): THandler {
return (async (req: Request, ...rest: unknown[]) => {
const extra = rest[1] ?? {};
const key =
(opts.keyFn ? await opts.keyFn(req, extra) : null) ??
`ip:${req.headers.get("x-forwarded-for") ?? "unknown"}`;
const rl = await rateLimit({
key,
limit: opts.limit,
windowMs: opts.windowMs,
});
if (!rl.ok) {
return NextResponse.json(
{ error: "Rate limit exceeded", retryAfterMs: rl.retryAfterMs },
{
status: 429,
headers: rl.retryAfterMs
? { "Retry-After": String(Math.ceil(rl.retryAfterMs / 1000)) }
: undefined,
},
);
}
return handler(req, ...rest);
}) as THandler;
}

View File

@@ -0,0 +1,145 @@
/**
* Workspace-scoped audit log of mutating operations.
*
* Closes BETA_LAUNCH_PLAN P4.7: "Per-workspace audit log of mutating MCP calls".
*
* Schema:
* audit_log
* id BIGSERIAL PK
* ts TIMESTAMPTZ DEFAULT NOW()
* workspace TEXT NOT NULL -- workspace slug
* user_email TEXT -- caller; null for runner/system
* source TEXT NOT NULL -- 'session' | 'api_key' | 'system' | 'webhook'
* action TEXT NOT NULL -- 'apps.create' | 'databases.delete' | …
* resource_type TEXT -- 'application' | 'database' | 'project' | …
* resource_id TEXT -- coolify uuid / project id / etc.
* ok BOOLEAN NOT NULL
* params JSONB -- redacted call params
* error TEXT
* turn_id TEXT -- correlation id (chat turn etc.)
*
* Read via `SELECT … WHERE workspace = $1 ORDER BY ts DESC LIMIT N`.
*
* SECRETS: never write raw credentials into `params`. The helper redacts
* the standard secret-shaped keys (`api_key`, `password`, `token`, `secret`,
* `private_key`, `credential`). Callers are still responsible for not
* passing sensitive blobs through unfiltered.
*/
import { getPool, query } from "@/lib/db-postgres";
import { log } from "@/lib/server/logger";
let tableReady = false;
async function ensureTable() {
if (tableReady) return;
await query(`
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
workspace TEXT NOT NULL,
user_email TEXT,
source TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
ok BOOLEAN NOT NULL DEFAULT TRUE,
params JSONB,
error TEXT,
turn_id TEXT
)
`);
await query(`CREATE INDEX IF NOT EXISTS audit_log_workspace_ts_idx ON audit_log (workspace, ts DESC)`);
await query(`CREATE INDEX IF NOT EXISTS audit_log_action_ts_idx ON audit_log (action, ts DESC)`);
tableReady = true;
}
const SECRET_KEYS = new Set([
"api_key", "apiKey",
"password",
"token", "access_token", "refresh_token",
"secret",
"private_key", "privateKey",
"credential", "credentials",
"authorization",
]);
function redact(obj: unknown, depth = 0): unknown {
if (depth > 4) return "[deep]";
if (obj == null) return obj;
if (Array.isArray(obj)) return obj.map((x) => redact(x, depth + 1));
if (typeof obj === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
out[k] = SECRET_KEYS.has(k) ? "[redacted]" : redact(v, depth + 1);
}
return out;
}
if (typeof obj === "string" && obj.length > 2048) return obj.slice(0, 2048) + "…";
return obj;
}
export interface AuditLogEntry {
workspace: string;
userEmail?: string | null;
source: "session" | "api_key" | "system" | "webhook";
action: string;
resourceType?: string;
resourceId?: string;
ok: boolean;
params?: Record<string, unknown>;
error?: string;
turnId?: string;
}
/** Best-effort: never throw out of the audit path. */
export async function writeAuditLog(entry: AuditLogEntry): Promise<void> {
try {
await ensureTable();
await query(
`INSERT INTO audit_log
(workspace, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10)`,
[
entry.workspace,
entry.userEmail ?? null,
entry.source,
entry.action,
entry.resourceType ?? null,
entry.resourceId ?? null,
entry.ok,
entry.params ? JSON.stringify(redact(entry.params)) : null,
entry.error ?? null,
entry.turnId ?? null,
],
);
} catch (err) {
log.warn("audit-log write failed (non-fatal)", {
action: entry.action,
err: err instanceof Error ? err.message : String(err),
});
}
}
export async function listAuditLog(opts: {
workspace: string;
limit?: number;
action?: string;
}): Promise<unknown[]> {
await ensureTable();
const limit = Math.min(500, Math.max(1, opts.limit ?? 100));
if (opts.action) {
return query(
`SELECT id, ts, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id
FROM audit_log WHERE workspace = $1 AND action = $2 ORDER BY ts DESC LIMIT $3`,
[opts.workspace, opts.action, limit],
);
}
return query(
`SELECT id, ts, user_email, source, action, resource_type, resource_id, ok, params, error, turn_id
FROM audit_log WHERE workspace = $1 ORDER BY ts DESC LIMIT $2`,
[opts.workspace, limit],
);
}
// Re-export for tests / migration scripts
export { getPool };

View File

@@ -0,0 +1,40 @@
/**
* Coolify webhook signature verification.
*
* Coolify (≥ 4.0.0-beta.300) signs every webhook with HMAC-SHA256 of the
* raw body using the per-app `webhook_secret`. The signature is sent in
* the `X-Coolify-Signature-256` header as `sha256=<hex>`.
*
* If the per-app secret is not set, Coolify sends the body unsigned. In
* that case we reject the call: every prod deploy MUST set a secret.
*
* Mirrors the pattern in `lib/gitea.ts:verifyWebhookSignature`.
*/
import { timingSafeStringEq } from "@/lib/server/timing-safe";
export async function verifyCoolifySignature(
body: string,
signatureHeader: string | null,
secret: string,
): Promise<boolean> {
if (!secret) return false;
if (!signatureHeader?.startsWith("sha256=")) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sigBytes = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
const expected =
"sha256=" +
Array.from(new Uint8Array(sigBytes))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return timingSafeStringEq(expected, signatureHeader);
}

View File

@@ -0,0 +1,140 @@
/**
* Persistent dev-server configuration store.
* Closes BETA_LAUNCH_PLAN P6.B1.
*
* When `dev_server_start` succeeds, the MCP tool should call
* `upsertDevServerConfig` so the project page can auto-resume the
* server on next mount without requiring the user to re-type the
* command (see P6.B2 for the auto-resume hook).
*
* Schema:
* fs_project_dev_servers
* project_id UUID PK → fs_projects.id
* command TEXT NOT NULL e.g. "cd myapp && npm run dev"
* port INT NOT NULL e.g. 3000
* framework TEXT e.g. "nextjs", "vite", "express"
* preview_url TEXT last known *.preview.vibnai.com URL
* last_started_at TIMESTAMPTZ
* status TEXT CHECK IN ('running','stopped','crashed')
* updated_at TIMESTAMPTZ DEFAULT NOW()
*/
import { query } from "@/lib/db-postgres";
import { log } from "@/lib/server/logger";
let tableReady = false;
async function ensureTable() {
if (tableReady) return;
await query(`
CREATE TABLE IF NOT EXISTS fs_project_dev_servers (
project_id TEXT PRIMARY KEY,
command TEXT NOT NULL,
port INT NOT NULL,
framework TEXT,
preview_url TEXT,
last_started_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'stopped'
CHECK (status IN ('running', 'stopped', 'crashed')),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
tableReady = true;
}
export interface DevServerConfig {
projectId: string;
command: string;
port: number;
framework?: string;
previewUrl?: string;
status: "running" | "stopped" | "crashed";
}
/** Called by the MCP dev_server_start handler after a successful start. */
export async function upsertDevServerConfig(
cfg: DevServerConfig,
): Promise<void> {
try {
await ensureTable();
await query(
`INSERT INTO fs_project_dev_servers
(project_id, command, port, framework, preview_url, last_started_at, status, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), $6, NOW())
ON CONFLICT (project_id) DO UPDATE SET
command = EXCLUDED.command,
port = EXCLUDED.port,
framework = COALESCE(EXCLUDED.framework, fs_project_dev_servers.framework),
preview_url = COALESCE(EXCLUDED.preview_url, fs_project_dev_servers.preview_url),
last_started_at = NOW(),
status = EXCLUDED.status,
updated_at = NOW()`,
[
cfg.projectId,
cfg.command,
cfg.port,
cfg.framework ?? null,
cfg.previewUrl ?? null,
cfg.status,
],
);
} catch (err) {
log.warn("dev-server-state: upsert failed (non-fatal)", {
projectId: cfg.projectId,
err: err instanceof Error ? err.message : String(err),
});
}
}
/** Update just the status (e.g. on stop / crash). */
export async function setDevServerStatus(
projectId: string,
status: "running" | "stopped" | "crashed",
): Promise<void> {
try {
await ensureTable();
await query(
`UPDATE fs_project_dev_servers
SET status = $2, updated_at = NOW()
WHERE project_id = $1`,
[projectId, status],
);
} catch (err) {
log.warn("dev-server-state: status update failed (non-fatal)", {
projectId,
err: err instanceof Error ? err.message : String(err),
});
}
}
/** Returns the last-known dev server config for a project, or null. */
export async function getDevServerConfig(
projectId: string,
): Promise<DevServerConfig | null> {
try {
await ensureTable();
const rows = await query<{
project_id: string;
command: string;
port: number;
framework: string | null;
preview_url: string | null;
status: string;
}>(
`SELECT project_id, command, port, framework, preview_url, status
FROM fs_project_dev_servers WHERE project_id = $1`,
[projectId],
);
if (!rows[0]) return null;
const r = rows[0];
return {
projectId: r.project_id,
command: r.command,
port: r.port,
framework: r.framework ?? undefined,
previewUrl: r.preview_url ?? undefined,
status: r.status as "running" | "stopped" | "crashed",
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,71 @@
/**
* Structured logger for API handlers.
*
* Why this exists: `console.log` everywhere in 159 route files is impossible
* to grep through. With a request-scoped `turnId` (or any other correlation
* id), prod incidents go from "search by guess" to "ripgrep one ID, get the
* whole timeline."
*
* Output shape (single line, JSON, prod-friendly):
* {"ts":"2026-05-17T20:00:00.000Z","level":"info","route":"api.chat",
* "turnId":"…","projectId":"…","user":"mark@…","msg":"…","ctx":{…}}
*
* In dev, we prefix with a coloured tag and pretty-print `ctx` for eyeball-
* ability. In prod, single-line JSON so a log shipper can parse it.
*/
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface LogContext {
route?: string;
turnId?: string;
projectId?: string;
workspaceSlug?: string;
user?: string;
userId?: string;
[k: string]: unknown;
}
const isDev = process.env.NODE_ENV !== "production";
function emit(level: LogLevel, msg: string, ctx: LogContext = {}) {
const ts = new Date().toISOString();
const { route, turnId, projectId, workspaceSlug, user, userId, ...rest } = ctx;
const base = { ts, level, msg, route, turnId, projectId, workspaceSlug, user, userId };
if (isDev) {
const tag = level === "error" ? "✗" : level === "warn" ? "!" : level === "debug" ? "·" : "→";
const idStr = turnId ? ` [${turnId.slice(0, 8)}]` : "";
const routeStr = route ? ` ${route}` : "";
const extra = Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : "";
// eslint-disable-next-line no-console
console[level === "debug" ? "log" : level](`${tag}${routeStr}${idStr} ${msg}${extra}`);
return;
}
const line = JSON.stringify({ ...base, ...rest });
// eslint-disable-next-line no-console
(console[level === "debug" ? "log" : level] as (m: string) => void)(line);
}
/**
* Create a logger pre-bound to a request context. Pass through `info()` /
* `warn()` / `error()` / `debug()` and every line carries the same
* `turnId` / `projectId` / `route`.
*/
export function makeLogger(base: LogContext) {
return {
debug: (msg: string, ctx: LogContext = {}) => emit("debug", msg, { ...base, ...ctx }),
info: (msg: string, ctx: LogContext = {}) => emit("info", msg, { ...base, ...ctx }),
warn: (msg: string, ctx: LogContext = {}) => emit("warn", msg, { ...base, ...ctx }),
error: (msg: string, ctx: LogContext = {}) => emit("error", msg, { ...base, ...ctx }),
child(extra: LogContext) {
return makeLogger({ ...base, ...extra });
},
};
}
export const log = {
debug: (msg: string, ctx: LogContext = {}) => emit("debug", msg, ctx),
info: (msg: string, ctx: LogContext = {}) => emit("info", msg, ctx),
warn: (msg: string, ctx: LogContext = {}) => emit("warn", msg, ctx),
error: (msg: string, ctx: LogContext = {}) => emit("error", msg, ctx),
};

View File

@@ -0,0 +1,91 @@
/**
* Postgres-backed sliding-window rate limiter.
*
* Designed for "small N, simple shape": a few thousand keys/min across the
* platform, single primary, no Redis dependency to keep beta infra tight.
* If we outgrow this, swap the storage backend without changing call sites.
*
* Schema (auto-created):
* rate_limit_log (key TEXT, ts TIMESTAMPTZ DEFAULT NOW())
* index on (key, ts DESC)
*
* Algorithm:
* 1. Cleanup older rows for this key (best-effort, capped).
* 2. Count remaining rows in window.
* 3. If under limit, INSERT a row and return {ok: true, remaining}.
* 4. Else return {ok: false, retryAfterMs}.
*
* NOT race-free across nodes — that's deliberate for cost. If you need
* hard quotas (e.g. billing-tier caps), use `lib/quotas.ts` instead.
*/
import { query } from "@/lib/db-postgres";
import { log } from "@/lib/server/logger";
let tableReady = false;
async function ensureTable() {
if (tableReady) return;
await query(`
CREATE TABLE IF NOT EXISTS rate_limit_log (
key TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`CREATE INDEX IF NOT EXISTS rate_limit_log_key_ts_idx ON rate_limit_log (key, ts DESC)`);
tableReady = true;
}
export interface RateLimitOpts {
/** Identity key — e.g. `chat:user@x.com`, `mcp:ws=mark:tool=apps_create`. Required. */
key: string;
/** Max calls inside the window. Default 60. */
limit?: number;
/** Window in ms. Default 60_000 (1 min). */
windowMs?: number;
}
export interface RateLimitResult {
ok: boolean;
remaining: number;
retryAfterMs?: number;
}
export async function rateLimit(opts: RateLimitOpts): Promise<RateLimitResult> {
const limit = opts.limit ?? 60;
const windowMs = opts.windowMs ?? 60_000;
try {
await ensureTable();
// 1. Sweep stale rows for this key (cheap; index is `(key, ts DESC)`).
await query(
`DELETE FROM rate_limit_log WHERE key = $1 AND ts < NOW() - $2::interval`,
[opts.key, `${Math.ceil(windowMs / 1000)} seconds`],
);
// 2. Count remaining.
const rows = await query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM rate_limit_log WHERE key = $1`,
[opts.key],
);
const used = Number(rows[0]?.n ?? "0");
if (used >= limit) {
// Find oldest row in window to compute retry-after.
const oldest = await query<{ ts: string }>(
`SELECT ts FROM rate_limit_log WHERE key = $1 ORDER BY ts ASC LIMIT 1`,
[opts.key],
);
const oldestMs = oldest[0]?.ts ? new Date(oldest[0].ts).getTime() : Date.now();
const retryAfterMs = Math.max(0, oldestMs + windowMs - Date.now());
return { ok: false, remaining: 0, retryAfterMs };
}
await query(`INSERT INTO rate_limit_log (key) VALUES ($1)`, [opts.key]);
return { ok: true, remaining: Math.max(0, limit - used - 1) };
} catch (err) {
// Fail-open on DB problems — better than locking everyone out of chat
// when Postgres has a hiccup. The downside (unbounded calls during the
// outage) is acceptable for beta scale.
log.warn("rate-limit DB unavailable, failing open", {
key: opts.key,
err: err instanceof Error ? err.message : String(err),
});
return { ok: true, remaining: limit };
}
}

View File

@@ -0,0 +1,26 @@
/**
* Constant-time string comparison.
*
* Use this for every admin-secret / bearer-token / HMAC comparison. Naive
* `a === b` short-circuits on the first byte mismatch, leaking length
* information that an attacker can use to slow-search the secret.
*
* `crypto.timingSafeEqual` requires equal-length buffers and runs in
* constant time. We normalise to UTF-8 buffers, pad shorter to longer
* with zero bytes so length mismatch is also constant-time, and OR a
* length-mismatch flag at the end so different lengths can't return true.
*/
import { timingSafeEqual } from "crypto";
export function timingSafeStringEq(a: string, b: string): boolean {
const aBuf = Buffer.from(a, "utf8");
const bBuf = Buffer.from(b, "utf8");
const max = Math.max(aBuf.length, bBuf.length);
const aPadded = Buffer.alloc(max);
const bPadded = Buffer.alloc(max);
aBuf.copy(aPadded);
bBuf.copy(bPadded);
const equal = timingSafeEqual(aPadded, bPadded);
// Length mismatch defeats the compare even if padded prefixes happen to match.
return equal && aBuf.length === bBuf.length;
}

View File

@@ -9,7 +9,10 @@ const turbopackRoot = path.dirname(fileURLToPath(import.meta.url));
// Google OAuth on localhost: NextAuth must build the same callback URL Google expects.
// If NEXTAUTH_URL is unset in dev, default it (set explicitly if you use 127.0.0.1 or another port).
if (process.env.NODE_ENV === "development" && !process.env.NEXTAUTH_URL?.trim()) {
if (
process.env.NODE_ENV === "development" &&
!process.env.NEXTAUTH_URL?.trim()
) {
process.env.NEXTAUTH_URL = "http://localhost:3000";
}
@@ -21,7 +24,19 @@ const nextConfig: NextConfig = {
// ssh2 ships native .node binaries; turbopack can't bundle them
// ("non-ecmascript placeable asset"). Externalize so they're loaded
// at runtime via Node's require, the same way @prisma/client works.
serverExternalPackages: ["@prisma/client", "prisma", "ssh2", "cpu-features", "pdf-parse"],
serverExternalPackages: [
"@prisma/client",
"prisma",
"ssh2",
"cpu-features",
"pdf-parse",
// Prevents Turbopack from bundling these packages and hitting the
// import-in-the-middle version mismatch warning on every request.
// Both ship a nested @opentelemetry/instrumentation@0.212 that wants
// iitm@2.x, but the project root has iitm@3.x.
"@fastify/otel",
"@prisma/instrumentation",
],
// react-markdown and its entire unified/remark/rehype ecosystem are
// ESM-only (type:"module", no CJS fallback). Next.js webpack can't
// resolve them without explicit transpilation — manifests as

737
vibn-signin-css.html Normal file
View File

@@ -0,0 +1,737 @@
"<!DOCTYPE html>
<html lang=\"en\"><head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>Vibn — Sign in<\u002Ftitle>
<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">
<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin=\"\">
<link rel=\"icon\" type=\"image/png\" href=\"f6ca0e82-0a93-484f-8ba3-b00b186a0c39\">
<style>/* cyrillic-ext */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(\"90d18403-bcdb-479d-8a58-3a2f58216b7f\") format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(\"1759dca5-7c4d-4ca9-9290-d35208d74289\") format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(\"97a44d3a-fec2-4ff7-a421-4edf9b8ad71c\") format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(\"7e61e427-832b-44de-9c5f-cb8e8d609c33\") format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(\"ac94e63e-ffa2-4d36-bc85-1ea3907c6fc6\") format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\"90d18403-bcdb-479d-8a58-3a2f58216b7f\") format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\"1759dca5-7c4d-4ca9-9290-d35208d74289\") format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\"97a44d3a-fec2-4ff7-a421-4edf9b8ad71c\") format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\"7e61e427-832b-44de-9c5f-cb8e8d609c33\") format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\"ac94e63e-ffa2-4d36-bc85-1ea3907c6fc6\") format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\"90d18403-bcdb-479d-8a58-3a2f58216b7f\") format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\"1759dca5-7c4d-4ca9-9290-d35208d74289\") format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\"97a44d3a-fec2-4ff7-a421-4edf9b8ad71c\") format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\"7e61e427-832b-44de-9c5f-cb8e8d609c33\") format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\"ac94e63e-ffa2-4d36-bc85-1ea3907c6fc6\") format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(\"90d18403-bcdb-479d-8a58-3a2f58216b7f\") format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(\"1759dca5-7c4d-4ca9-9290-d35208d74289\") format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(\"97a44d3a-fec2-4ff7-a421-4edf9b8ad71c\") format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(\"7e61e427-832b-44de-9c5f-cb8e8d609c33\") format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(\"ac94e63e-ffa2-4d36-bc85-1ea3907c6fc6\") format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(\"90d18403-bcdb-479d-8a58-3a2f58216b7f\") format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(\"1759dca5-7c4d-4ca9-9290-d35208d74289\") format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(\"97a44d3a-fec2-4ff7-a421-4edf9b8ad71c\") format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(\"7e61e427-832b-44de-9c5f-cb8e8d609c33\") format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(\"ac94e63e-ffa2-4d36-bc85-1ea3907c6fc6\") format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\"2169a9f7-c9c5-499c-b362-07fe0f83380a\") format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\"ce1ad003-4793-4747-a34f-8ca4ff9fe9f5\") format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* symbols2 */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\"23a3f3dd-8e58-4146-8f25-92989bc29082\") format('woff2');
unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F;
}
/* vietnamese */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\"62f73eb0-7473-41de-8406-1465eae52c93\") format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\"c5f1bd21-7f72-494a-a84d-c0b87ec04aac\") format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(\"83da646a-ae0a-4021-8a1b-ebc1c80bf4ab\") format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\"2169a9f7-c9c5-499c-b362-07fe0f83380a\") format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\"ce1ad003-4793-4747-a34f-8ca4ff9fe9f5\") format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* symbols2 */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\"23a3f3dd-8e58-4146-8f25-92989bc29082\") format('woff2');
unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F;
}
/* vietnamese */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\"62f73eb0-7473-41de-8406-1465eae52c93\") format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\"c5f1bd21-7f72-494a-a84d-c0b87ec04aac\") format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(\"83da646a-ae0a-4021-8a1b-ebc1c80bf4ab\") format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
<\u002Fstyle>
<style>/* Shared auth styles Sign In + Sign Up. Same tokens as the rest of the
site; declared inline here so each auth page is self-sufficient. */
:root {
--bg: oklch(0.155 0.008 60);
--bg-1: oklch(0.185 0.009 60);
--bg-2: oklch(0.225 0.010 60);
--hairline: oklch(0.32 0.010 60 / 0.55);
--hairline-2: oklch(0.40 0.012 60 / 0.35);
--fg: oklch(0.97 0.005 80);
--fg-dim: oklch(0.78 0.006 80);
--fg-mute: oklch(0.58 0.006 80);
--fg-faint: oklch(0.42 0.006 80);
--accent: oklch(0.74 0.175 35);
--accent-soft: oklch(0.74 0.175 35 / 0.18);
--accent-glow: oklch(0.74 0.175 35 / 0.35);
--accent-fg: #1a0f0a;
--ok: oklch(0.78 0.16 155);
--font-sans: \"Geist\", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: \"Geist Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; min-height: 100%; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
/* Ambient grid */
body::before {
content: \"\";
position: fixed; inset: 0;
background-image:
linear-gradient(to right, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.30 0.01 60 / 0.10) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 70% 70% at 50% 40%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 70% 70% at 50% 40%, #000 30%, transparent 80%);
pointer-events: none;
z-index: 0;
}
/* Film grain */
body::after {
content: \"\";
position: fixed; inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0.035;
mix-blend-mode: overlay;
background-image: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><\u002Ffilter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.85'/><\u002Fsvg>\");
}
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
h1, h2, h3 { margin: 0; font-weight: 500; letter-spacing: -0.02em; line-height: 1.05; }
p { margin: 0; }
::selection { background: var(--accent); color: var(--accent-fg); }
/* Layout */
.page {
position: relative;
z-index: 2;
min-height: 100dvh;
display: flex; flex-direction: column;
}
.topbar {
position: relative; z-index: 5;
padding: 22px clamp(20px, 4vw, 48px);
display: flex; align-items: center; justify-content: space-between;
}
.topbar a:hover { color: var(--fg); }
.topbar-back {
color: var(--fg-mute);
font-size: 14px;
display: inline-flex; align-items: center; gap: 6px;
}
.logo {
display: inline-flex; align-items: center; gap: 9px;
font-weight: 600; font-size: 17px; letter-spacing: -0.02em;
color: var(--fg);
}
.logo-mark {
width: 26px; height: 26px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent) 0%, oklch(0.65 0.20 18) 100%);
box-shadow: 0 0 22px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.25);
display: grid; place-items: center;
color: var(--accent-fg);
flex-shrink: 0;
}
.logo-mark svg { display: block; }
.logo-caret { animation: caret-blink 1.4s steps(2) infinite; }
@keyframes caret-blink { 50% { opacity: 0.25; } }
/* Main */
.auth-main {
flex: 1;
display: grid; place-items: center;
padding: clamp(20px, 4vw, 40px);
position: relative;
}
/* Ambient glows */
.auth-glow {
position: absolute;
pointer-events: none;
filter: blur(20px);
z-index: 0;
}
/* Card */
.auth-card {
position: relative;
z-index: 2;
width: 100%; max-width: 440px;
padding: 36px clamp(24px, 4vw, 40px) 32px;
background: linear-gradient(180deg, oklch(0.20 0.009 60 / 0.85), oklch(0.17 0.008 60 / 0.85));
border: 1px solid var(--hairline);
border-radius: 22px;
backdrop-filter: blur(20px);
box-shadow:
0 30px 80px -20px oklch(0 0 0 / 0.7),
0 0 80px -30px var(--accent-glow);
}
.auth-card::before {
content: \"\";
position: absolute; left: 0; right: 0; top: 0; height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .6;
}
/* Header */
.auth-eye {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--fg-mute);
}
.auth-eye::before {
content: \"\"; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 12px var(--accent-glow);
}
.auth-title {
margin-top: 14px;
font-size: clamp(26px, 3.4vw, 34px);
font-weight: 500;
letter-spacing: -0.022em;
line-height: 1.1;
text-wrap: balance;
}
.auth-title em {
font-style: normal;
color: var(--accent);
text-shadow: 0 0 30px var(--accent-glow);
}
.auth-sub {
margin-top: 10px;
color: var(--fg-mute);
font-size: 14.5px;
line-height: 1.5;
text-wrap: balance;
}
/* Form */
.auth-form {
margin-top: 24px;
display: flex; flex-direction: column;
gap: 12px;
}
.auth-field {
display: flex; flex-direction: column;
gap: 6px;
}
.auth-label {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-mute);
padding-left: 4px;
}
.auth-input {
width: 100%;
padding: 13px 16px;
background: oklch(0.16 0.008 60 / 0.8);
border: 1px solid var(--hairline);
border-radius: 12px;
color: var(--fg);
font: 15px/1.5 var(--font-sans);
outline: none;
transition: border-color .15s, background .15s, box-shadow .15s;
}
.auth-input::placeholder { color: var(--fg-faint); }
.auth-input:focus {
border-color: oklch(0.74 0.175 35 / 0.65);
background: oklch(0.18 0.009 60 / 0.95);
box-shadow: 0 0 0 3px oklch(0.74 0.175 35 / 0.12), 0 0 30px -10px var(--accent-glow);
}
.auth-input.mono { font-family: var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; }
.auth-input.mono::placeholder { letter-spacing: 0.08em; }
/* Buttons */
.auth-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
height: 50px; padding: 0 22px;
border-radius: 999px;
font-weight: 500;
font-size: 15px;
transition: transform .12s, box-shadow .2s, background .2s;
white-space: nowrap;
width: 100%;
}
.auth-btn-primary {
background: var(--accent);
color: var(--accent-fg);
box-shadow:
0 0 0 1px oklch(0.84 0.16 35 / 0.5) inset,
0 10px 40px -10px var(--accent-glow),
0 0 40px -8px var(--accent-glow);
}
.auth-btn-primary:hover { transform: translateY(-1px); }
.auth-btn-primary[disabled] { opacity: .55; cursor: not-allowed; transform: none; }
.auth-btn-ghost {
background: oklch(0.20 0.009 60 / 0.6);
border: 1px solid var(--hairline);
color: var(--fg-dim);
}
.auth-btn-ghost:hover { color: var(--fg); border-color: var(--hairline-2); background: oklch(0.22 0.010 60 / 0.8); }
.auth-btn-ghost img, .auth-btn-ghost svg { flex-shrink: 0; }
/* Divider */
.auth-divider {
display: flex; align-items: center; gap: 14px;
margin: 6px 0 2px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-faint);
}
.auth-divider::before, .auth-divider::after {
content: \"\"; flex: 1; height: 1px; background: var(--hairline);
}
/* OAuth row */
.auth-oauth {
display: flex; flex-direction: column; gap: 10px;
}
/* Footer */
.auth-foot {
margin-top: 26px;
padding-top: 22px;
border-top: 1px solid var(--hairline);
text-align: center;
font-size: 14px;
color: var(--fg-mute);
}
.auth-foot a {
color: var(--accent);
font-weight: 500;
}
.auth-foot a:hover { text-decoration: underline; text-underline-offset: 3px; }
.auth-fine {
margin-top: 18px;
text-align: center;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
color: var(--fg-faint);
}
.auth-fine a { color: var(--fg-mute); text-decoration: underline; text-underline-offset: 3px; }
/* Spinner */
.auth-spinner {
width: 16px; height: 16px; border-radius: 50%;
border: 2px solid oklch(0 0 0 / 0.2);
border-top-color: var(--accent-fg);
animation: spin .9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Confirmed state */
.auth-success {
text-align: center;
}
.auth-success-badge {
display: inline-grid; place-items: center;
width: 56px; height: 56px;
border-radius: 50%;
margin-bottom: 16px;
color: var(--accent);
background: oklch(0.74 0.175 35 / 0.12);
border: 1px solid oklch(0.74 0.175 35 / 0.4);
box-shadow: 0 0 40px var(--accent-glow);
}
.auth-success .email-chip {
display: inline-block;
margin: 0 4px;
padding: 2px 9px;
border-radius: 6px;
background: oklch(0.74 0.175 35 / 0.12);
border: 1px solid oklch(0.74 0.175 35 / 0.3);
color: var(--accent);
font-family: var(--font-mono);
font-size: 13.5px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
}
.auth-tip {
margin-top: 22px;
padding: 14px 16px;
border-radius: 10px;
background: oklch(0.16 0.008 60 / 0.6);
border: 1px solid var(--hairline);
display: flex; gap: 12px;
text-align: left;
font-size: 13px; line-height: 1.5;
color: var(--fg-dim);
}
.auth-tip-icon {
flex-shrink: 0;
width: 22px; height: 22px; border-radius: 6px;
background: oklch(0.22 0.011 60);
color: var(--fg-mute);
display: grid; place-items: center;
}
.auth-tip a { color: var(--accent); }
.auth-tip a:hover { text-decoration: underline; text-underline-offset: 3px; }
/* Resend */
.auth-resend {
display: inline-flex; align-items: center; gap: 8px;
margin-top: 20px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.03em;
color: var(--fg-mute);
}
.auth-resend button {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
}
.auth-resend button:hover { color: oklch(0.78 0.16 35); }
.auth-resend button[disabled] {
color: var(--fg-faint);
text-decoration: none;
cursor: not-allowed;
}
/* Trust strip in footer area */
.auth-trust {
margin-top: 32px;
display: flex; gap: 14px; justify-content: center; align-items: center;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
color: var(--fg-faint);
}
.auth-trust .sep { color: var(--fg-faint); opacity: 0.5; }
<\u002Fstyle>
<script src=\"0972a222-d51b-405d-8418-60d04e649e0d\" integrity=\"sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L\" crossorigin=\"anonymous\"><\u002Fscript>
<script src=\"9f46b960-c12f-4a24-83f6-c6e0ff6415bb\" integrity=\"sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm\" crossorigin=\"anonymous\"><\u002Fscript>
<script src=\"aaea190f-13f8-48fd-a9ac-c43d11d2c63c\" integrity=\"sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y\" crossorigin=\"anonymous\"><\u002Fscript>
<\u002Fhead>
<body>
<div id=\"root\"><\u002Fdiv>
<script type=\"text/babel\" src=\"1723226d-d04f-45d0-ab2a-53986b29a29a\"><\u002Fscript>
<script type=\"text/babel\" src=\"ebac1c94-e4f8-4cff-9c57-1bb24441fb38\"><\u002Fscript>
<\u002Fbody><\u002Fhtml>"