Javis OS
Javis OS는 기업가를 돕는 에이전시 AI로, 업무를 지원하고 함께 성장합니다
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기A multi tenant portal where agencies run their clients, projects, deliverables and invoices in one place, and each of their clients gets a read only window onto their own work.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
client-hq-ebrar.vercel.app is a live deployment of this repository. It signs you in through Clerk rather than a shared password, so there is no evaluation login to publish here: use Sign up on the deployment itself to create your own account and agency.
A fresh agency starts unsubscribed, the same as in section 3, so /clients, /projects, /invoices and /team redirect to /billing until a subscription exists on that deployment's own Stripe account. That gate is real product behaviour, not a demo limitation, and the two screenshots below are what the gated pages look like once it is cleared.README 전문 보기 ↗🚀 Live Demo
client-hq-ebrar.vercel.app is a live deployment of this repository. It signs you in through Clerk rather than a shared password, so there is no evaluation login to publish here: use
Sign upon the deployment itself to create your own account and agency.A fresh agency starts
unsubscribed, the same as in section 3, so/clients,/projects,/invoicesand/teamredirect to/billinguntil a subscription exists on that deployment's own Stripe account. That gate is real product behaviour, not a demo limitation, and the two screenshots below are what the gated pages look like once it is cleared.
A multi tenant portal where agencies run their clients, projects, deliverables and invoices in one place, and each of their clients gets a read only window onto their own work.
A small agency usually juggles client records in one tool, files in another, invoices in a third, and status updates in email. Their clients, in turn, keep asking "where is the file?" and "has this been invoiced?".
ClientHQ puts that in one product with two audiences:
Agencies pay ClientHQ a monthly subscription through Stripe, with a trial. That is the only money the platform touches.
In scope: sign in and agency organizations, client records, contacts and portal invitations, projects, deliverable upload and download, invoice authoring and lifecycle, invoice PDF, the client portal, team members and roles, provider webhook sync, daily maintenance sweeps, rate limits on costly actions, and error tracking with privacy respecting product analytics.
Out of scope, on purpose: paying an invoice through the platform, agency branding in the portal, and a public marketing site. See section 8 and the roadmap for the full list.
These three rules shape every design decision below. If a change would weaken one, it needs its own written design first.
Zero money transfer. No client money moves through the platform. Stripe is used for exactly one thing, the agency's own subscription. An invoice is a record the agency sends to its client, and marking it paid is a status change made by staff, not a payment. There is no Stripe Connect, no card form for clients, and nothing to hold in escrow.
Data isolation. One agency must never be able to read or write another agency's rows, and a client contact must never see beyond their own client. Every tenant scoped table carries a NOT NULL org_id, every query goes through one scoping layer that applies it, and a lint rule stops code from reaching around that layer.
Fail closed security. When something is unknown, missing or unresolved, the answer is refusal:
Host header, so a forged header cannot send a signed in visitor to an attacker's origin.One honest limit: the application layer fails closed, but the database layer does not yet. Row level security is deferred (see section 8), so isolation today is enforced by construction in application code rather than by PostgreSQL itself.
Every feature on the roadmap now has code on main. Features 1, 2 and 5 (stack, tooling, design system) are marked done. The rest are still marked in progress in docs/scope/scope.md, which is the live source of truth, because each has a closing step open: an independent review, a verification pass, or written documentation.
Feature 20 (product analytics, Sentry error tracking and privacy consent) is the one whose earlier status was wrong in this document, so here is exactly where it stands, checked against the code, the tests and the verification record on main:
| Aspect | Status |
|---|---|
| Implemented | Sentry error tracking in all three runtimes (server, browser, edge proxy). PostHog product analytics on PostHog's EU cloud, with business events fired from the server. A consent cookie and banner, a public /privacy page, and erasure of a person's analytics data when they are deleted. See section 4 for how it works |
| Automated tests | Unit suites for the Sentry logic (src/observability/), the analytics client, event catalogue, consent and provider (src/analytics/), the action wrapper's event slot, the erasure sweep and the error state component. Database tests for the analytics reads and the privacy of cron output. A browser spec, e2e/analytics-consent.spec.ts, proves what does and does not leave the browser. No test ever contacts a provider |
| Formal verification | Written record in the verification file. All automated commands passed on 2026-09-18, including a production build without a Sentry token that emits no source maps. The PostHog and Sentry pipelines were proven end to end on a live deployment. The rest of the manual walk was accepted with gaps, listed in the next row |
| Not yet proven | Live confirmation of the other four Sentry throw types, source map stack traces, scrubbing on a live event, the trace sampler, masked replay, the three engineered alert scenarios, the full PostHog event walk, erasure on a real Clerk deletion, and the screen reader pass on /privacy and the banner. The one Sentry alert rule (email on each new production issue) is not created yet |
| Independent review | Done. It found one blocker: invitation tokens in portal URLs would have reached Sentry. That is fixed (query strings and hashes are stripped from request and breadcrumb URLs) along with three lesser findings, in a follow up commit |
| Roadmap row | Still "in progress": the dedicated test pass and the written documentation are unticked, even though test files exist |
Rate limiting (feature 19) is also built, verified and reviewed: see the action wrapper below. Only its documentation step is open.
One Next.js App Router application serves both audiences. Route groups (the folders in parentheses) organize the code without changing URLs.
| Audience | Where it lives | URLs | Who gets in |
|---|---|---|---|
| Agency staff | src/app/(agency)/ | /dashboard, /clients, /projects, /invoices, /team, /billing, /settings | A signed in user whose session names an active organization |
| Agency, subscription gated | src/app/(agency)/(gated)/ | everything above except /billing and /settings | Also needs a paid up subscription |
| Client contact | src/app/portal/ | /portal, /portal/projects, /portal/invoices, /portal/files | A signed in user with an accepted contact row |
| Signed in, no destination yet | src/app/(auth)/ | /sign-in, /sign-up, /onboarding | Everyone; /onboarding routes a person to their agency, an agency picker, the portal, or a create an agency form |
| Public | src/app/privacy/, src/app/page.tsx | /privacy, / | Everyone, with no session |
| Machines | src/app/api/ | /api/webhooks/stripe, /api/webhooks/clerk, /api/cron/daily, /api/health/db | Callers that prove themselves with a signature or a secret instead of a session |
Three details worth knowing:
(gated) is a nested group inside (agency), so URLs stay the same. /billing and /settings sit outside it on purpose: a lapsed agency must always be able to reach the page that lets it pay, and the redirect to /billing can never loop./deliverables/[id]/download, /invoices/[id]/pdf, /portal/invoices/[id]/pdf) live outside both groups, because both audiences use them. Each handler resolves the caller's context itself and applies the matching access rules./portal or /onboarding: a client contact never carries an organization claim, and checking there would bounce them forever.Every request is resolved to a tenant context once, from a verified session, before any data is touched. The proxy decides from session claims alone (no database call). The layouts and actions then resolve the context against the local mirror rows.
flowchart TD
R[Request] --> P{Proxy: public route?}
P -- yes --> PUB[Sign in, sign up, privacy, webhooks, cron]
P -- no --> S{Session?}
S -- no --> SI[Redirect to sign in]
S -- yes --> A{Agency path?}
A -- yes --> O{Active organization claim?}
O -- no --> ONB[Redirect to onboarding]
O -- yes --> SC[staffContext: Clerk org id and user id resolve to local org and user rows]
A -- no, portal --> CC[contactContext: Clerk user id resolves to an accepted client contact row]
SC --> G{Subscription verdict}
G -- unsubscribed or locked --> BILL[Redirect to billing]
G -- grace --> RO[Reads render, writes refused]
G -- full --> DAL
RO --> DAL
CC --> PG{Agency verdict}
PG -- unsubscribed or locked --> UNA[Portal unavailable page]
PG -- full or grace --> DAL
DAL["tenantDb(ctx): org_id added to every query. Contacts are also narrowed to their own client_id"] --> DB[(PostgreSQL)]
Where the pieces live:
| Step | File |
|---|---|
| Public list and agency path check | src/proxy.ts |
| Staff and contact context resolution | src/db/tenant/context.ts |
| Subscription verdict (pure function) | src/access/level.ts |
| Portal gate | src/portal/gate.ts |
| The scoped accessor | src/db/tenant/accessor.ts |
Each decision below is written to stand on its own: the problem, the decision, and what it costs. Every one also has a full design document with the options that were rejected, linked as a deep dive at the end. You do not need to open them to follow the reasoning.
The problem. This product stores many agencies' data in the same tables, separated only by an org_id column. If application code enforces that separation, one query that forgets its org_id predicate quietly hands one agency's rows to another, and nothing in the database notices. Discipline alone does not scale past a few developers or a few months.
The decision. Make forgetting impossible instead of unlikely. All reads and writes go through a single layer in src/db/tenant/ that applies the tenant filter for you. The raw database handle is unreachable from anywhere else, enforced by a lint rule that fails the build.
The layer exposes a small surface:
| Name | Role |
|---|---|
tenantContext() | Works out who is asking, once per request, from the session (or the contact row for the portal) |
tenantDb(ctx) | The only way to reach a tenant scoped table. Adds org_id to every read, update and delete, and stamps it on every insert |
withTenantAction() | The only shape a Server Action takes |
unsafeTenantQuery() | A conspicuous, logged exit for a query the accessor cannot express. Still scoped, with the predicate written by hand |
withSystemAccess() | The unscoped door, for callers with no tenant to resolve. Fenced to three files |
withTenantAction() wraps every write in the same steps, so no action can skip one:
org:admin or org:member).subscription_inactive.Result with a closed set of error codes. Only after success, revalidate declared paths and tags and fire any declared analytics event, after the transaction has committed.The trade off.
org_id column, so a new table is protected without anyone remembering to protect it, and a table without one cannot be passed to the accessor at all (a compile error).src/db/tenant/** plus three exact paths may), and one blocks the unscoped door outside the two webhook routes and the cron route. A test fails if either allowed list changes without the documented design changing, so widening one is a pull request conversation, not a quiet edit.memberships.role column, which is a display mirror that can be briefly stale.Deep dive: tenant layer design, rate limiting design, and the area guide src/db/AGENTS.md.
The problem. Agencies upload files of up to 100 MB. Streaming those bytes through a serverless function costs memory and execution time, hits request size limits, and makes the application a bottleneck for something object storage does natively. But letting the browser talk to storage directly means the server no longer sees the bytes, so it cannot trust what arrives.
The decision. Files go straight from the browser to Cloudflare R2. The application only signs short lived URLs and records what happened, and it verifies the stored object afterwards instead of trusting the browser's claims.
sequenceDiagram
participant B as Browser
participant S as Server Action
participant D as PostgreSQL
participant R as Cloudflare R2
B->>S: requestUpload(project, name, type, size)
S->>S: Check allowlist, 100 MB cap, project not archived
S->>D: Insert deliverable, status pending
S->>D: Set key org/orgId/project/projectId/id
S-->>B: Presigned PUT URL, valid 15 minutes
B->>R: PUT file directly
B->>S: confirmUpload(id)
S->>R: HEAD object (what is really there?)
S->>D: Set status ready, real size and type
Note over B,R: Later, on download
B->>S: GET /deliverables/id/download
S->>S: Context, access gate, row, visibility rules
S-->>B: Redirect to presigned GET URL, valid 2 minutes
How each risk is handled:
HEAD on confirm. A row only becomes ready if what landed passes the same rules.org/{orgId}/project/{projectId}/{id}. The row's id is minted by the accessor, so the key is set in a second step after the insert.visible_to_client defaults to false. Downloads redirect to a 2 minute signed URL after the same context and gate checks as any page.pending rows, deleting the object first and the row second.The trade off. The server never sees the bytes, so it cannot scan or transform them, and the size and type rules are only as good as the metadata R2 reports on HEAD. The design also creates a window where an object exists in R2 with no confirmed row, which is why the abandoned upload sweep exists. Finally, R2's CORS rule is per bucket and per origin, so each preview deployment that needs real uploads needs its own bucket, configured with pnpm r2:setup, or has to run with storage unconfigured.
Deep dive: deliverables design and src/storage/AGENTS.md.
The problem. The local database mirrors subscription state from Stripe and identity state from Clerk. Both providers deliver events at least once, out of order, and sometimes twice at the same instant. A naive handler double applies events, lets an old event overwrite a newer state, or marks an event handled while its effect was lost, and the mirror silently drifts from the truth. In this product a stale subscription row becomes a lockout for an agency that is paying.
The decision. Every event is recorded in a ledger table, processed_webhook_events, inside the same transaction as its effect. The table has a unique constraint on (source, event_id) and no payload (events carry personal data, and both providers keep the originals). It is pruned after 90 days by the daily cron.
The Stripe handler follows a fixed six step order (src/payments/webhook.ts):
ON CONFLICT DO NOTHING. If nothing was inserted, the event was already handled: answer 200 and apply nothing.FOR UPDATE, then apply the retrieved state. This stops two concurrent deliveries from committing in the wrong order.The Clerk webhook follows the same shape for eight organization, membership and user events, with soft deletes and personal data scrubbing. Both handlers are two of the only three callers allowed to use the unscoped system access door.
A daily backstop catches what a broken webhook would miss: reconcile sweeps list Stripe subscriptions and Clerk objects and repair any local drift through the same shared apply function.
The trade off. Correctness is bought with an extra network round trip to Stripe per event and a row lock per agency, and the handler has an unusual failure policy. Stripe disables an endpoint that keeps failing, which would silently freeze the mirror, so almost nothing answers 500. A 500 means only "this might work on retry". An event that can never succeed (naming no agency, or an agency that does not exist) is logged and answered 200. The one exception is a payload that does not parse, which means this code is wrong about Stripe's shapes and deserves the retries and the loud failure. Exceptions are reported to Sentry before the handler rethrows, so the provider still retries and the operator still hears about it.
Deep dive: Stripe webhook design, Clerk webhook design, daily sweeps design, src/payments/AGENTS.md.
The problem. An agency's right to use the product depends on its subscription, and that changes with the clock: a failed payment starts a grace period, and the period ends on its own. Storing a "locked" flag means a scheduled job has to flip it, the job can fail or run late, and the flag can disagree with what Stripe says.
The decision. The access level is never stored and never cached. It is computed on every read from two columns of the subscription row and the current time, by a pure function (src/access/level.ts).
| Level | Subscription state | Agency experience |
|---|---|---|
full | trialing or active | Everything works |
grace | past_due, less than 7 days since the first failed payment | Reads work under a banner naming the date changes resume; every write is refused |
locked | past_due beyond 7 days, unpaid, canceled, incomplete_expired, paused, or any status this build does not know | Sent to /billing |
unsubscribed | No subscription row, or a Checkout that never finished | Sent to /billing |
Why this shape holds up:
cancel_at_period_end is deliberately not an input. An agency that has cancelled keeps full access until Stripe actually ends the subscription and the webhook records it.past_due row with no start date, which the webhook should never produce, is treated as locked and reported rather than becoming a grace window with no end.(gated) layout redirects pages, and withTenantAction() refuses writes, so a stale page on screen cannot write past the gate.full and grace, and a "portal unavailable" page when the agency is locked or unsubscribed.The trade off. Computing on every read means every gated request depends on a fresh subscription row, and the rule is a product decision baked into a constant (7 days), not a setting. Also, a Next.js layout does not re render on a client side navigation, so an agency whose grace window lapses mid session keeps reading until its next full page load. Writes are refused immediately, so the gap is read only and bounded.
Deep dive: checkout and webhook design and access gate design.
The stack was settled once, in the foundational design, and the summary below is enough to work from.
| Choice | Why |
|---|---|
| Next.js 16 App Router, React 19 | Server Components for reads and Server Actions for writes keep data access on the server, next to the tenant layer. One deployable serves both audiences. src/proxy.ts is Next 16's name for what used to be middleware |
| TypeScript 5, strict, on Node 22 LTS | No any, no unchecked casts, and exhaustive switches over the invoice status and subscription status enums, so adding a state is a compile error until every consumer handles it |
| Tailwind CSS v4 and shadcn/ui | Accessible primitives built on Radix, themed through design tokens in light and dark. The visual system is written down in design.md |
Next.js 16 has breaking changes from earlier versions. Read the guide in node_modules/next/dist/docs/ before writing framework code.
| Choice | Why |
|---|---|
| PostgreSQL on Supabase | Real constraints, transactions and row locks, which the invoice numbering and webhook designs rely on |
| Drizzle ORM | The schema is TypeScript, migrations are generated SQL committed under drizzle/ and reviewed in pull requests |
| drizzle-zod | Insert and select schemas are derived from the tables, so input parsing and the schema cannot drift. Zod parses every value crossing into the server: Server Action arguments, route bodies, webhook payloads, URL params |
Database rules worth knowing:
DATABASE_URL is the Supabase transaction mode pooler (port 6543) for every runtime query, with prepared statements off because PgBouncer hands each transaction a different backend. DIRECT_URL (port 5432) is for migrations only.char(3) currency on every invoice. Quantities are numeric(12,3) and travel as strings. Rounding is half away from zero, and CHECK constraints recompute totals in the database so wrong arithmetic cannot be stored.Clerk provides sign in, sessions and agency organizations. Its roles map to org:admin and org:member. The application keeps local mirror rows for organizations, users and memberships, synced by the Clerk webhook and repaired on demand if a row is missing, so a person is never stranded between sign up and the webhook arriving.
Client contacts are not Clerk organization members. They are invited by email (through Resend, with React Email templates), accept a single use invitation, and are then resolved from their accepted client_contacts row.
Invoice PDFs are rendered on the server with @react-pdf/renderer, from the same presentation module the invoice screen uses, so the page and the PDF cannot show different numbers. The agency's own name comes from the database, fonts are bundled, and the same handler serves staff and contacts under their own access rules. Only src/invoices/pdf/ may import the renderer.
Known limits: A4 only, dates in UTC, money in the en-US locale, and no tagged (screen reader navigable) PDF structure. All are tracked on the roadmap.
This is feature 20, implemented as described here. Its status against tests and verification is in section 1.
Problem. The operator needs production errors with readable stack traces and the agency they hit, and needs to know where signups drop out of the funnel. But the product holds an agency's client relationships, and its users may be in the EU, so an analytics tool that swallows names, emails or invoice amounts would be a liability.
Decision. Two providers, one rule: only ids, roles, dates, counts and fixed labels ever leave the application. Never an email, a name, a money amount or a file name.
@sentry/nextjs) reports errors from the Node server, the browser and the edge proxy. It sends only from Vercel production and preview, never from development, tests or CI. Source maps upload when the build has a Sentry token and are removed from the deployed output. A scrub step strips headers, cookies, request bodies, query strings and hashes, and every user field except the id. Events are tagged with the agency's org_id. Traces are sampled at 10 percent and never for cron or webhook traffic. Session replay is fully masked and recorded only when an error occurs. Error screens show a Reference id that matches the Sentry event.agency.created, invoice.issued and subscription.started fire from the server, after the database transaction commits, through an after() callback so a response never waits on the provider. The catalogue is typed: a property may only be an id, a declared label, a boolean, a count or a date, so a free text string is a compile error, and a test fails on any property key that looks like an email, name, amount or title./ingest path./portal. Portal events carry only agency and client ids.clienthq_consent, set by a public Server Action. The banner shows Accept and Decline with equal weight, is not a dialog and never traps focus, and comes back from "Cookie settings" on /privacy or in the user menu. /privacy states in plain words what is collected and what never is.Trade off. Server side events fire after commit on a best effort basis, so an event can be lost if the function freezes first, which is why the retry sweep exists only for erasure and not for the funnel. Cookieless page views cannot join a signed out visitor across visits. There is no in product analytics view: dashboards are the providers' own, and the application never reads back from them. Masked replay and error tracking rest on legitimate interest rather than consent, and the privacy page says so.
Deep dive: observability design and src/analytics/AGENTS.md.
| Provider | Role | State |
|---|---|---|
| Resend | Invitation and invoice emails. Without a key in development, emails print to the server console | In use |
| Vercel | Hosting and the daily cron trigger (03:00 UTC) | In use |
| PostgreSQL counter table | Rate limits. It replaced the Upstash Redis originally planned, so there is no extra provider | In use |
| Sentry, PostHog | See the previous subsection | In use |
Vitest runs everything under src/, scripts/ and tools/ (over 200 test files), and tests sit beside the source they cover.
| Layer | What it covers | Examples |
|---|---|---|
| Pure modules | Money, permission and tenancy logic, with the clock as a parameter | level.test.ts stands at the grace boundary; money.test.ts checks rounding edges; status.test.ts covers the invoice lifecycle; window.test.ts covers rate limit windows; sentry-scrub.test.ts covers what is removed from an error event |
| Components | Rendered in jsdom with Testing Library, with axe run on the result | grace-banner.test.tsx, cookie-banner.test.tsx, the primitives and patterns under src/ui/ |
| Database integration | Claims about PostgreSQL rather than TypeScript, in *.db.test.ts files | See the next subsection |
| Browser | Real pages in a real browser | See the browser subsection |
Failures are returned as values (a Result shape), and the tests assert on those values rather than on thrown errors. Providers are always replaced with fakes: a fake object store, a recording analytics sink, stubbed Stripe and Clerk gateways. NODE_ENV=test makes the analytics client a no op regardless of keys.
Each feature also has acceptance criteria validation: its design lists numbered acceptance criteria (AC-1, AC-2, and so on), and each test file opens with a covers: header naming the criteria it proves, so any criterion can be traced to a test.
The claims that matter most are about SQL, so they are proven against a real PostgreSQL rather than mocks. In CI, a second job starts a throwaway postgres:17 container (matching the Supabase project's version), applies every migration from empty, applies them again expecting nothing to happen, asserts the live schema against the design, and then runs these automated boundary tests against it:
CHECK constraints agree at the rounding boundaries.Locally, these files skip themselves when DIRECT_URL is not set, so pnpm test works without a database. Point DIRECT_URL at a disposable PostgreSQL 17 or a development Supabase project to run them.
Playwright specs in e2e/ run against the real application on port 3100: scaffold and shell, sign in, clients, contacts, projects, deliverables, billing, invoice PDF, and analytics consent. The consent spec asserts that no request leaves for /ingest, sentry.io or posthog.com from public, agency or portal pages when no provider is configured, and that Accept, Decline and Cookie settings behave as designed. The client portal journey needs a second, real signed in session, so it runs its own dev server on port 3101 and only when the E2E_CLERK_CONTACT_* variables are present. See e2e/CLERK.md for how the browser suite gets past sign in with Clerk testing tokens.
Every UI surface, including empty and error states, must meet WCAG 2.2 AA. It is checked in three ways:
wcag2a, wcag2aa, wcag21a, wcag21aa and wcag22aa rule sets.@axe-core/playwright in both light and dark themes, which is where contrast as actually painted and target size as actually laid out can be seen.Automated tools do not replace a person with a keyboard and a screen reader, so a manual pass is part of formal verification.
Formal verification here means a written, criterion by criterion verification record kept beside each feature's design, not a mathematical proof. It lists the commands to run and a manual walk against the real application, and marks each step as done or open. The /design route shows every component in every state in both themes, which supports that walk. Where a walk was accepted with gaps, the gaps are named in the record. Feature 20's are summarized in section 1.
clienthq/no-raw-db-import blocks importing src/db/client.ts outside the data access layer, including through a dynamic import() written as a template literal, which the rule was hardened to catch after a security review found it slipped through as a computed specifier.clienthq/no-system-access-import blocks the unscoped door outside the two webhook routes and the cron route.tenant-isolation-config.test.mts fails if either allowed list stops matching the documented design.src/invoices/pdf/. The PostHog and Sentry SDK boundaries are documented conventions checked in code review..githooks/pre-commit (format and lint on staged files, typecheck of the whole project). CI runs format, lint, typecheck, unit tests and a migration drift check on every push and pull request. .github/workflows/migrate.yml migrates the deployed database on merge to main and only then triggers the deploy, so a failed migration stops the release.This is the one section that names the internal workflow commands. Everywhere else the README uses plain engineering terms.
The build proves the whole pipe before building any part of it fully. Slice 1 is a walking skeleton: real auth, real database, real tenancy, real UI, really deployed, and narrow rather than fake. It ends with the first tenant scoped write and read (client records). Later slices widen the pipe one feature at a time: subscription, contacts, projects, deliverables, invoices, portal, team, maintenance, then rate limits and observability.
Each feature moves through the same slash commands, and each step leaves an artifact behind.
| Step | Command | Leaves behind |
|---|---|---|
| Plan the roadmap | /scope | docs/scope/scope.md, the living feature list with status |
| Decide and design | /architect | docs/specs/NNNN-title/index.md with numbered acceptance criteria and rationale.md with the options considered |
| Build | /develop | Code in small vertical threads, each mapped to acceptance criteria in the design's build plan |
| Prove behaviour | /check verify | verify.md beside the design: the formal verification record, from driving the real app against every acceptance criterion |
| Write tests | /test | *.test.ts files beside the source |
| Independent review | /check review | A file in docs/reviews/, written by a model that did not write the code |
| Write it up | /document | PR text, changelog entries, release notes, and this README |
| Keep context current | /sync | Updated AGENTS.md files and roadmap reconciliation |
/audit bootstraps the AGENTS.md files every later step reads, and /debug finds root causes without adding features. Each major area also has its own short guide, such as src/db/AGENTS.md, src/payments/AGENTS.md, src/analytics/AGENTS.md and src/rate-limit/AGENTS.md, linked from the root AGENTS.md.
Conventions: functional and immutable code, pure functions by default with side effects pushed to the edges, named exports, every environment variable declared in the Zod schema in src/lib/env.ts and read through env(), snake_case in SQL and camelCase in TypeScript, and Conventional Commits.
| You need | Notes |
|---|---|
| Node 22 or newer | The engines field enforces it |
| pnpm 11 | If pnpm is not on your path, run corepack enable pnpm once so the plain pnpm command works |
| A PostgreSQL 17 database | A Supabase project matches production. You need both the pooler and the direct connection strings |
| A Clerk application | A development instance, with organizations enabled |
| A Stripe account in test mode | A product with a monthly recurring Price and a 14 day trial set on it. The Stripe CLI for local webhooks |
| Optional: Cloudflare R2, Resend, Sentry, PostHog | Without them, uploads show a notice, emails print to the console, and observability calls do nothing |
Stripe dashboard prerequisites (Billing Portal setup and the six webhook events) are listed in the comments of .env.example. The Clerk webhook needs eight events, listed there too.
pnpm install
cp .env.example .env.local # then fill in the values
.env also works. .env.local takes precedence, and the migration tooling and scripts follow the same order as Next. Every variable is validated in src/lib/env.ts, and a missing one fails with a named message.
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL | Yes | Supabase transaction mode pooler, port 6543. Every runtime query |
DIRECT_URL | Yes | Direct connection, port 5432. Migrations and the database tests only |
NEXT_PUBLIC_APP_URL | Defaults to http://localhost:3000 | Base URL for Stripe redirects and email links |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY | Yes | Clerk keys |
NEXT_PUBLIC_CLERK_SIGN_IN_URL, NEXT_PUBLIC_CLERK_SIGN_UP_URL | Yes | Must agree with the routes under src/app/(auth) (/sign-in, /sign-up) |
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL, NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL | Yes | Both point at /onboarding |
CLERK_WEBHOOK_SIGNING_SECRET | Yes | Verifies the Clerk webhook |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, STRIPE_SECRET_KEY | Yes | Stripe keys |
STRIPE_WEBHOOK_SECRET | Yes | Locally, use the whsec_ that stripe listen prints |
STRIPE_PRICE_ID | Yes | The monthly Price. The trial length is set on the Price in Stripe |
EMAIL_FROM | Yes | Bare sending address on a domain verified in Resend |
CRON_SECRET | Yes | Bearer token for /api/cron/daily, at least 16 characters. Generate with openssl rand -hex 32 |
RESEND_API_KEY | Production | Without it in development, emails print to the console |
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET | Production | Without them, uploads show a "storage not configured" notice |
| Command | What it does |
|---|---|
pnpm db:check | One round trip to Postgres through Drizzle. Writes nothing. The running app has the same check at /api/health/db |
pnpm db:generate | Generate a migration from src/db/schema/. Never hand edit generated SQL |
pnpm db:migrate | Apply migrations over DIRECT_URL |
pnpm db:migrate:check | Fail if the schema and the committed migrations have drifted apart |
pnpm db:schema:assert | Read the live PostgreSQL catalogue and compare tables, constraints, indexes and delete actions with the documented schema |
pnpm db:seed | A repeatable development seed: a working agency, Apex Interactive Studio, with staff, clients, contacts, projects, deliverables and invoices in every status, backdated across the last 60 days, plus two smaller agencies in a grace window and locked. Running it again updates rather than duplicates. Set SEED_CLERK_ORG_ID first to build Apex inside a real Clerk organization you can sign in to |
pnpm db:studio | Drizzle Studio |
pnpm r2:setup | Configure the R2 bucket's CORS rule |
The seed refuses to open a connection unless the DIRECT_URL host is localhost or the host named in SEED_ALLOW_HOST, so a production string in the wrong terminal changes nothing.
Then start the app:
pnpm db:migrate
pnpm dev
It serves on http://localhost:3000. For Stripe and Clerk events locally, forward them to the app with stripe listen --forward-to localhost:3000/api/webhooks/stripe and the Clerk CLI relay described in .env.example. To trigger the daily sweeps by hand:
curl -i -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/daily
The Supabase free tier pauses a project after about a week without traffic. The daily cron route's own queries keep it awake.
pnpm format:check # Prettier
pnpm lint # ESLint, including the two tenant isolation rules
pnpm typecheck # next typegen, then tsc
pnpm test # Vitest; database suites skip without DIRECT_URL
pnpm db:migrate:check # schema and migrations still agree
pnpm test:e2e # Playwright, against the dev server on port 3100
To include the database boundary tests, set DIRECT_URL to a migrated PostgreSQL and run them by file, for example:
pnpm vitest run src/db/tenant/tenancy.db.test.ts
pnpm install points core.hooksPath at .githooks/, so the pre commit checks run on their own. git commit --no-verify skips them, and CI runs the same checks either way.
These are decisions made on purpose and deferred, each with a reason and a trigger. They are recorded here so the plan stays honest.
What is deferred. Today, tenant isolation is enforced in application code, and the database will hand over any row it is asked for. Row level security would fail closed at the database instead, and it is the single biggest security upgrade available to this design.
Why it waits. Not because it is unwanted, but because of serverless connection pooling. The transaction mode pooler gives every transaction a different backend connection, so a per request setting has to be applied inside each transaction rather than once per connection. The design is already unblocked: org_id is NOT NULL on every tenant table, and all access goes through one choke point.
org_id = current_setting('app.current_org_id')::uuid), and changing the layer's single choke point to open a transaction and set the setting.What is deferred. No timezone is modelled anywhere. issue_date and the overdue sweep use the UTC calendar day, and billing, grace and invitation dates are rendered in UTC with an explicit label. The effect: an invoice issued at 23:00 in Los Angeles gets tomorrow's date.
Why it is cheap to fix later. Dates are date columns supplied by the application rather than by SQL current_date, and the UTC day comes from one helper, so no timezone arithmetic is scattered through the code.
organizations.timezone and a helper that returns the agency's calendar day, then use it for issue dates, the overdue sweep, and the project overdue badge (which already takes "today" as a parameter).en-US money formatting because organizations has no address, tax id, page size or locale columns.What is deferred. The invoice unit amount input and the money formatter assume two minor unit digits, which is right for USD, EUR and GBP. Every invoice carries its own char(3) currency, and the database constrains its format (three uppercase letters) rather than the ISO 4217 list, which would go stale inside a migration. A currency with zero or three minor units, such as JPY or KWD, would be entered and displayed inconsistently.
/privacy page is a plain language notice, not legal advice).pnpm db:seed already produces the data (a full agency, Apex Interactive Studio, backdated across 60 days); pointing SEED_CLERK_ORG_ID at a real Clerk organization is what would make it sign in able, which is the one piece a hosted demo still needs./settings now shows the agency's name, default currency, description, tax id and address read only (see src/settings/AGENTS.md); a form and a Server Action to change them are a feature of their own.Each is tracked with its origin in the Deferred section of docs/scope/scope.md.
R2_ADMIN_ACCESS_KEY_ID, R2_ADMIN_SECRET_ACCESS_KEY |
Only for pnpm r2:setup |
| An admin token used to configure the bucket. Never set on Vercel |
NEXT_PUBLIC_SENTRY_DSN | Optional | Turns on Sentry error tracking. It sends only from Vercel production and preview |
SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN | Optional, build only | Source map upload. Without the token the build succeeds and uploads nothing. Set the token on the build environment only |
NEXT_PUBLIC_POSTHOG_KEY | Optional | Turns on PostHog analytics |
NEXT_PUBLIC_POSTHOG_HOST | Defaults to https://eu.i.posthog.com | PostHog ingestion host |
POSTHOG_PERSONAL_API_KEY, POSTHOG_PROJECT_ID | Optional | Used only to delete a person's analytics data on erasure. Without them the erasure sweep reports itself skipped |
VERCEL_ENV, NEXT_PUBLIC_VERCEL_ENV, VERCEL_GIT_COMMIT_SHA | Optional, supplied by Vercel | Decide whether observability is enabled and tag releases. Enable "automatically expose system environment variables" in Vercel |
SEED_ALLOW_HOST | Development only | A non local database host that pnpm db:seed may write to |
SEED_CLERK_ORG_ID | Development only | A real Clerk organization id. Set, pnpm db:seed builds the demo agency (Apex Interactive Studio) inside it, so you can sign in and see it; anything already there is backed up to .seed-backups/ first. Unset, the seed builds a separate agency nobody can sign in to |
E2E_CLERK_USER_*, E2E_CLERK_CONTACT_* | Optional | Dedicated Clerk development users for the browser suite. Tests that need them skip when unset |