phase-0: foundation, both apps boot end to end

Monorepo (pnpm workspaces) with two deployable apps and three pure packages.

apps/api (Hono on Node): Zod validated env that fails fast and names the problem,
Kysely factories for SQLite and Postgres chosen by DATABASE_URL scheme, portable
migrations covering the whole SPEC section 5 schema, Better Auth with the four
roles and seeded demo accounts, localized error envelope, /healthz and /readyz,
graceful SIGTERM drain. Dialect specific SQL is confined to the two factories.

apps/web (Next.js App Router): locale routed shell in es and en with a language
switcher, sign in screen, and a runtime /api proxy so the browser only ever sees
one origin and cookies stay first party.

packages/i18n ships both catalogs complete; es is generated from COPY.md and a
test re-derives it from the document on every run so it cannot drift.
packages/contracts holds the Zod schemas and the typed client the web app uses.

Verified: 43 vitest tests, 14 Playwright tests on mobile and desktop, typecheck
and lint clean, migrate and seed from a clean database, sign in through the proxy
with CSRF rejection of foreign origins.

Not verified here: docker compose. This user has no access to the docker socket.

RULES.md is absent from docs/, so packages/rules exports only RULES_VERSION and
no tax rule, check digit or deadline was invented. See DECISIONS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-03 21:46:35 +00:00
co-authored by Claude Opus 5
commit ae2ea20b7e
106 changed files with 11541 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
# DECISIONS
One entry per decision that is not already obvious from the specs. Newest phase last.
Markers used in the code:
- `// SPEC-GAP:` the specs did not settle this and a choice was made here.
- `// TODO-TAX-VERIFY:` a tax rule that RULES.md does not state. Never invented, always flagged.
---
## Blocking gaps in the source material
### RULES.md is missing
`docs/` ships SPEC.md, FLOWS.md, COPY.md and CONTRACTS.md. RULES.md, which the prompt
names as authoritative for every tax rule, is not present. Phase 1 is entirely RULES.md
and phases 4 and 5 depend on it.
Consequence for phase 0: `packages/rules` exists and exports only `RULES_VERSION`.
Nothing in this phase computes a tax number, a check digit or a deadline, so nothing was
invented. The seed deliberately stops short of profiles for the same reason: CONTRACTS.md
section 4 asks for RUC base `4123456` "with computed DV" and a `deadlineDigit`, both of
which are RULES.md algorithms. Seeding accounts only keeps phase 0 honest.
**Needed before phase 1 starts.**
### `boneyard` and `canvas-ui` are not the packages the prompt means
Both names resolve on npm to unrelated projects: `boneyard@0.1.4` is a 2015 Backbone
"architectural toolkit", `canvas-ui@0.2.3` is a Mesosphere Bootstrap theme. Neither does
skeleton loading or canvas effects. Neither is needed before phase 7.
Plan unless corrected: keep the *behaviour* the prompt specifies (skeletons on every
content load and never a spinner; canvas effects in exactly two places, degrading
gracefully) behind a single `<Skeleton name>` component and a single effect component, so
swapping in the real library later is a one file change.
**Please confirm the intended packages before phase 7.**
### Neither named skill is installed
`ponytail` and `ui-ux-pro-max-skill` are not available in this environment. Their stated
intent was applied by hand: nothing speculative, no unused configuration, and FLOWS.md
section 1 as the design constraint.
---
## Phase 0
### Migrations: better-auth generates its own four tables, we own the rest
`src/db/migrator.ts` runs two ordered steps: better-auth's `getMigrations()` creates and
updates `user`, `session`, `account` and `verification`, then the Kysely migrator applies
`src/db/migrations`. Delegating the auth tables keeps them in step with the installed
better-auth version and emits correct DDL for both dialects, so no hand written dialect
SQL was needed for them. Upgrading better-auth in a way that adds a column means adding a
migration that calls the same generator again.
### The whole schema ships in migration `001_core`, not phase by phase
Every table in SPEC.md section 5 is created now. The schema is fully specified and stable;
splitting it across phases would produce a pile of migration files and no benefit before
release. Later phases add modules on top, not tables.
### Timestamps and money are portable by construction
Timestamps are ISO-8601 text and dates are `YYYY-MM-DD` text in both dialects: they sort
chronologically as strings, so no dialect specific date type or comparison is needed
anywhere. Money is `bigint`, because guaranies pass int4 at about Gs. 2.100.000.000, and
`apps/api/src/db/postgres.ts` registers an int8 parser that returns a number and throws
outside the safe integer range.
### `/api` is proxied by a route handler, not a Next rewrite
SPEC-GAP against SPEC.md section 2, which specifies Next rewrites. Next bakes rewrite
destinations into the build manifest, so `API_INTERNAL_URL` would become a build time
value and one image could not serve both compose and k8s. `apps/web/app/api/[...path]/route.ts`
forwards at request time instead. The single origin model is unchanged: the browser only
ever sees the web origin, cookies stay first party, and there is still no CORS anywhere.
### Workspace packages ship TypeScript source with no build step
`packages/*` have no `dist`. The web app lists them in `transpilePackages` and tsup bundles
them into the API. Their relative imports are extensionless, because Turbopack does not
rewrite a `.js` specifier onto a `.ts` source file.
### The es catalog is split from the strings COPY.md does not define
`catalogs/es.ts` is generated from COPY.md and is verbatim; `copy-parity.test.ts` re-derives
it from `docs/COPY.md` on every run and fails on any drift, in either direction. Strings the
product needs that COPY.md does not list (the seven error envelope messages, three sign in
labels, the language switcher) live in `catalogs/es.extra.ts` and follow the tone rules in
COPY.md section 0. `es` is the merge of the two.
### `decl.approve` is both a message and a namespace
COPY.md defines `decl.approve` (the button) alongside `decl.approve.confirmTitle`. A nested
message tree cannot hold both, and next-intl walks a nested tree. `unflatten` moves such a
message to a reserved `_` child and `resolveKey` maps the key for callers, so components
still address messages by their COPY.md key. `apps/web/src/i18n/t.ts` is the wrapper; it is
computed from the catalog, so a future collision is handled without another change.
### Locale negotiation on `/`
next-intl's default detection is left on: a browser asking for English lands on `/en`,
anything else falls back to `es`. The en catalog exists for expats and international users
(COPY.md section 0-EN), which is exactly the population whose browser is in English. An
explicit choice through the switcher always wins and is in the URL.
### SQLite refuses `JOBS_INLINE=false`
Implemented literally as SPEC.md section 15 instructs, which is narrower than the mode
matrix in the same section: that table allows "SQLite, 1 dedicated worker" under Split
small. Two pollers cannot be made safe against a single writer, so the boot check wins and
SQLite stays single process. Worth reconciling in SPEC.md.
### Deferred to the phase that needs them, deliberately
- `RateLimiter`: SPEC.md section 6 specifies a token bucket, but the first endpoint with a
stated limit is `GET /lookup/ruc/:number` in phase 2. better-auth's own rate limiting
covers the auth routes until then.
- DTO schemas in `packages/contracts`: enums, the error envelope, the client and
`ProfileDto` exist because phase 0 uses them. The rest arrive with their endpoints.
- Storage in `/readyz`: the check covers the database and pending migrations. The storage
driver probe is added in phase 3 with the driver.
### Smaller choices
- TypeScript 5.9, not 7.x: `typescript-eslint@8` declares `typescript <6.1.0`.
- `better-sqlite3` is kept out of `onlyBuiltDependencies`: it ships prebuilt binaries, so
letting pnpm run the implicit `node-gyp rebuild` would compile it for nothing and force a
toolchain into the image.
- The language switcher is a native `<select>`: one dependency fewer than a popover, and
the better mobile and keyboard experience for a two item choice.
- `apps/web/proxy.ts`, not `middleware.ts`: Next 16 deprecates the middleware convention.
- The dark mode palette and the `dark` variant are wired now; the toggle itself is phase 7
polish. System preference works today.
- `audit_log` is append only by construction: the module exposes no update or delete. A
database trigger was written and then removed, because it would have been the only piece
of dialect specific SQL outside the two files SPEC.md section 5 allows.
### Legal pages
`/legal/privacidad` and `/legal/terminos` do not exist yet (they belong to phase 2's
marketing routes). Per COPY.md section 13 they will ship with clearly marked placeholder
content and no generated legal text. **TODO: human written before launch.**