Skip to content

Wayfinder: additive HTTP API + native Swift iOS client #190

Description

@lj-n

Destination

An additive HTTP API grown out of the existing TypeScript user-context, so a native Swift iOS app (separate repo) can talk to a self-hosted genug-da instance — without touching the web app that ships publicly first. One domain implementation (createUserCtx), several transports. The project is not "split into three"; nothing is rewritten.

The map is done when: the OpenAPI contract for the iOS MVP is published and generates a Swift client, PAT + QR auth works end-to-end, the ~7 MVP endpoints are live over user-context, the separate iOS repo exists and carries Phase-2 work, and Phase-3 offline-capture groundwork (outbox + sync columns) has landed. This map carries execution (see Notes) — it produces the working API, not just a spec.

Notes

  • Every decision in the register below was settled in a grilling session on 2026-07-18. They are decided, not open — do not re-litigate. The register lives directly in this body; there are no closed decision tickets behind it.
  • Execution override: unlike the wayfinder default (plan-only), this effort carries implementation in its tickets — the deliverable is the working API plus a bootstrapped iOS repo.
  • CLAUDE.md override — do not "fix" this back: the cross-client API deliberately uses +server.ts endpoints, breaking the repo's "remote-functions-first" rule. Remote functions remain the rule for web-only surfaces. An ADR will follow later; until then this note is the record.
  • Phase 0 is the existing Public Release milestone. API work must never block it.
  • Related: PWA installability #188 covers the Android/installable-web story — complementary to native iOS, shipped alongside it, don't recreate.
  • Charting choices (delegated to the wayfinder session, decided 2026-07-18): ticket granularity is coarse — one deliverable per ticket, since per-endpoint slicing would pre-empt the contract keystone; the iOS repo gets a tracking ticket in this repo (participates in the native blocking graph) until the separate repo exists.
  • Grounding, verified 2026-07-18 — trust these pointers, don't re-derive:
    • Envelope math: src/lib/server/db/user-context/envelope.ts. categoryBalances() is SQL, but unassigned() is an imperative TS reach-back min-scan over months — an algorithm, not just SQL. This is why offline Level 2 would mean a Swift port.
    • Domain layer / single source of truth: src/lib/server/db/user-context/ (createUserCtx(userId)).
    • Remote-function adapter layer: src/lib/server/utils/remote-guard.ts (guarded{Query,Command,Form,BatchQuery}).
    • Auth today: src/lib/server/db/auth/ — opaque sessions, 20-day sliding expiry; refreshSession extends without rotating the token.
    • Input schemas: hand-written valibot in src/lib/schemas/ (valibot@1); no OpenAPI tooling exists yet.
    • Tables: src/lib/server/db/tables/; only 2 migrations exist; money = integer minor units.
    • Migration generation quirk (hit 2026-07-19): npm run db:migrations fails because drizzle-kit's bundled jiti resolves the tsconfig paths inherited from .svelte-kit/tsconfig.json against the repo root. Workaround: temporarily add correct paths to the root tsconfig.json, generate, revert (details in the PAT auth ticket resolution).
  • Skills sessions should consult: /grilling, /prototype, /research; context7 for swift-openapi-generator / @valibot/to-json-schema facts.

Decisions so far

  1. Optimize for: ship web public + learn Swift via a real native iOS client. Go dropped — "really not a need." Every Go-era risk (envelope port, double maintenance, second source of truth) died with it.

  2. "Public" = open-source, self-hosted. One instance per person/household (matches the single-admin constraint and usersToBudgets roles). Not SaaS.

  3. Web app untouched. Keeps remote functions. The iOS API is additive over the same user-context. No BFF/SPA demotion.

  4. Offline ceiling = Level 1 (offline capture). v1 is online-only. Level 2 (offline-first) is off the table — it would force reimplementing the envelope min-scan in Swift.

  5. Optimistic-UI boundary: optimistic on direct entities; derived envelope totals are server-authoritative, returned in the write's own response. No envelope math on the client.

  6. Contract = OpenAPI/REST + swift-openapi-generator. Connect-RPC/buf dropped. Inputs derive from existing valibot schemas via @valibot/to-json-schema. Envelope-view response schemas must be authored by hand — the biggest drift risk.

  7. Drizzle is the sole schema/migration owner. One process, one SQLite file, migrations at startup as today.

  8. iOS auth = PAT + QR "scan to connect," primary and sole. Separate hashed apiTokens table (not overloading sessions), web issuance/revocation UI, Bearer path added to hooks.server.ts. QR encodes {serverUrl, token}. Plaintext shown once; hashed at rest; optional expiry, defaulted off.

  9. Native Swift for iOS (craft/learning genuinely wanted — asymmetric to Go). PWA (PWA installability: manifest, placeholder icons, and iOS meta tags #188) also ships, for Android — not either/or.

  10. Repo layout: web + API live in this repo; iOS is a separate repo (doesn't exist yet), consuming the generated OpenAPI client.

  11. Sequencing, 4 phases: Phase 0 ship web public (existing milestone, blocked by nothing here) → Phase 1 API foundation + auth (testable with zero Swift) → Phase 2 iOS v1 online-only → Phase 3 offline capture (add sync columns then).

  12. iOS MVP surface (~7 endpoints, not "every remote function"): PAT auth · GET envelope view (month) · GET accounts + categories · POST/PATCH/DELETE transaction · POST assignment with a fat response (recomputed UnassignedBreakdown + affected category balance). Everything else stays web-only. — amended by 15: the surface is now 12 operations.

  13. No speculative offline schema now. IDs are already client-generatable (createId()); migrations are automated; the user controls all clients. Tombstones/updatedAt arrive in Phase 3.

  14. Cross-cutting principle: additive-only API changes plus a client-version header (App Store lag means the server is sometimes newer than the app).

  15. API contract for the iOS MVP (~7 endpoints, fat POST assignment) — Contract settled as a lint-clean OpenAPI 3.1 document (full YAML in the resolution comment). Surface amended to 12 operations: + budget list (GET /budgets — QR stays {serverUrl, token}), + transaction register, + transfers, + reassignment. Fat writes everywhere (EnvelopeDelta for a client-supplied ?month= + affected account balances; transfers carry balances only — budget-neutral). X-Genug-Client: <platform>/<version> header, optional in spec / middleware-injected, enforced with 426. Error model { code, message }. Derivation confirmed: valibot → JSON Schema via typeMode: 'input' + errorMode: 'ignore'; two new API-side valibot schemas needed (transfer from/to shape, nullable targetCategoryId).

  16. PAT auth: apiTokens table + Bearer path in hooks.server.ts — Implemented per decision 8; lands via PR #206, which auto-closes the ticket on merge (dependents unblock only once the code is on main). Hashed api_tokens table (sha256 at rest, owner-scoped revocation, optional expiry defaulted off, lastUsedAt stamping); createApiToken returns the plaintext exactly once — the input for the issuance UI + QR payload. Bearer branch populates locals.user with locals.session = null: API clients pass locals.user gates (createUserCtx unchanged) but never get web-session semantics. Verified with unit tests + curl against a live guarded remote query. CHANGELOG entry deferred to the issuance-UI ticket.

  17. Web UI: PAT issuance, QR scan-to-connect, revocation — Implemented per decision 8; lands via PR #207, which auto-closes the ticket on merge. Settings page gains an "API Tokens" section: issuance (name + optional expiry, date-only → valid through the chosen day UTC), one-time reveal dialog with copyable plaintext + QR, and a revocable token list. QR payload now concrete (input for the iOS bootstrap): the JSON string {"serverUrl": <instance origin>, "token": <plaintext>}, rendered client-side via uqr. Carries the CHANGELOG entry deferred from the PAT foundation ticket.

  18. OpenAPI pipeline: valibot-derived inputs, hand-authored envelope responses, decode-contract test — Implemented per decision 6; landed via PR #208 (merged, ticket auto-closed — Implement the iOS MVP endpoints over user-context #195 now unblocked). The keystone contract (API contract for the iOS MVP (~7 endpoints, fat POST assignment) #191) is now a real artifact: docs/api/openapi.yaml, regenerated by npm run api:generate and lint-clean under @redocly/cli. Two owners, one document — the six request bodies are DERIVED from the valibot schemas via @valibot/to-json-schema (typeMode: 'input', errorMode: 'ignore'), so inputs can't drift from server validation; the envelope-view response schemas are hand-authored in src/lib/server/api/contract.ts. Adds the two API-side valibot schemas API contract for the iOS MVP (~7 endpoints, fat POST assignment) #191 specified (src/lib/schemas/api.ts: from/to transfers, nullable-target reassignment). The decode-contract test (in the unit run) drives the real user-context envelope queries, serialises as JSON, and asserts the output decodes against the published schemas — the only guard against a hand-authored response schema drifting from envelope.ts. Generator runs through Vite's ssrLoadModule for alias/Paraglide resolution. Derivation nuance for Implement the iOS MVP endpoints over user-context #195: mechanical derivation drops format/Money/Month $ref decoration on request bodies and uses anyOf for nullables (valibot still enforces the dropped constraints at runtime).

  19. Implement the iOS MVP endpoints over user-context — Implemented per decision 12/15; lands via PR #209, which auto-closes the ticket on merge (dependents unblock once on main). All 12 operations live as thin +server.ts routes under /api/v1, a second transport over the same createUserCtx(userId) — web app and remote functions untouched. Routes parse + validate against the existing valibot schemas, then delegate to a ctx-injected assembly layer (src/lib/server/api/endpoints.ts); a withApi wrapper (guard.ts/respond.ts) owns PAT Bearer auth (401), the optional X-Genug-Client gate (426 seam, no minimums yet), and the { code, message } error envelope. Fat writes (decision 5): every write returns the recomputed EnvelopeDelta for ?month= + affected account balances; transfers carry balances only (ADR-0015). Responses are decode-tested against the published contract schemas. For Bootstrap the separate iOS repo (Phase 2 tracking) #196: the Swift client generates from docs/api/openapi.yaml, base URL = QR serverUrl + /api/v1, Authorization: Bearer <token>, branch on error code.

Not yet specified

  • ADRs — the remote-functions-first override and the keystone decisions (additive API, PAT auth, offline ceiling, OpenAPI tooling, and now the settled contract conventions from decision 15) are ADR candidates. The user wants issues only for now; ADR authoring waits for an explicit go.
  • Phase-2 iOS scope detail (screens, app architecture, Swift stack) — will live in the future iOS repo; only its bootstrap is a ticket here.

Out of scope

  • Go backend (genug-core) and any cross-language domain port — killed in the 2026-07-18 grilling ("really not a need").
  • Offline Level 2 (offline-first) — would force reimplementing the envelope reach-back min-scan in Swift; the ceiling is Level-1 capture.
  • SaaS / hosted multi-tenant — "public" means open-source, self-hosted, one instance per household.
  • Connect-RPC / buf / protobuf — the contract is OpenAPI/REST with swift-openapi-generator.
  • Web-app demotion (BFF/SPA) — only ever needed to avoid cross-language domain duplication; moot in all-TS.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions