cvx-kit 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,461 +0,0 @@
1
- ---
2
- title: "feat: v0.1.0 feature set — the six roadmap helpers + release discipline"
3
- type: feat
4
- date: 2026-08-14
5
- artifact_contract: ce-unified-plan/v1
6
- artifact_readiness: implementation-ready
7
- execution: code
8
- product_contract_source: ce-plan-bootstrap
9
- origin: ROADMAP.md
10
- ---
11
-
12
- # feat: v0.1.0 feature set — the six roadmap helpers + release discipline
13
-
14
- ## Summary
15
-
16
- Implement cvx-kit `v0.1.0` as defined in `ROADMAP.md`: the six planned
17
- features (pagination bridge, CRUD command factory, state-machine helper,
18
- rate-limit middleware factory, webhook boundary helper, agent tool
19
- generator), the typed-middleware-context tightening they build on, and the
20
- release discipline that starts at 0.1.0 — `CHANGELOG.md`, GitHub Releases on
21
- every tag, and a shipped upgrade guide (the "migration file") documenting the
22
- old way → new way for consumers on 0.0.x.
23
-
24
- **Product Contract preservation:** n/a — solo plan; scope confirmed in-session.
25
-
26
- ---
27
-
28
- ## Problem Frame
29
-
30
- cvx-kit 0.0.8 has the structural kernel (boundaries, auth, tenancy, commands,
31
- middleware) but consumers still hand-write the same per-entity code: CRUD
32
- operations, transition guards, pagination plumbing, rate limits, webhook
33
- dedup, and agent tool definitions. 0.1.0 is "the big one" (see
34
- `docs/explain/roadmap-to-1.0.0.html`): ship the full helper surface in one
35
- release so consumers adopt a complete kit once, and switch on the semver
36
- discipline (breaking = minor only, CHANGELOG, GitHub Releases) at the same
37
- moment.
38
-
39
- ## Requirements
40
-
41
- - R1. `paginated(dto)` helpers + a `paginate` terminal on `include` give
42
- cursor pagination with zod-typed args/returns.
43
- - R2. `createCrudCommands` generates create/update/archive operations from a
44
- `zodTable`, fully inside the audited command pipeline.
45
- - R3. `createStateMachine` produces typed transition predicates/assertions
46
- from a constants tuple, usable as command guards.
47
- - R4. A packaged rate-limit middleware factory works on both Command and
48
- Query middleware seams, keyed by ctx (default `ctx.tenant`).
49
- - R5. A webhook boundary helper provides signature verification + natural-key
50
- dedup + delegation to internal functions.
51
- - R6. `createAgentTools` emits agent tool definitions from the existing
52
- `tools.*` masks whose handlers route through command executors —
53
- shape-compatible with `@convex-dev/agent`, no dependency on it.
54
- - R7. Middleware context enrichment is typed: `next({ context })` widens the
55
- downstream ctx type; the existing loose form keeps compiling.
56
- - R8. `CHANGELOG.md` exists; every release tag gets a GitHub Release whose
57
- notes come from the CHANGELOG (user-directed: changelog also on GitHub
58
- versioning).
59
- - R9. A shipped upgrade guide documents 0.0.x → 0.1.0 per feature (old way →
60
- new way), and every new feature has a docs page/section + `llms.md` entry
61
- + tests.
62
- - R10. All existing invariants hold: facade-only foundation runtime exports,
63
- middleware inside pipeline invariants, strict boundary schemas, tarball =
64
- dist + docs + `src/test.ts` only.
65
-
66
- ## Key Technical Decisions
67
-
68
- - KTD1. **One new runtime dependency only: `@convex-dev/rate-limiter`**
69
- (session-settled: user-approved — chosen over also depending on
70
- `@convex-dev/agent`: the rate-limit factory calls the limiter at runtime,
71
- while agent tools can be emitted shape-compatible). The host mounts the
72
- rate-limiter component; the kit factory receives the constructed
73
- `RateLimiter` instance (injection, per the kit's no-singletons rule).
74
- - KTD2. **Agent tool generator is dependency-free** (session-settled:
75
- user-approved). It returns `{ name, description, args, handler }` records
76
- where `args` is a zod schema (using `jsonSafeZid` internally) — directly
77
- consumable by `@convex-dev/agent`'s `createTool` and adaptable to ai-sdk
78
- `tool()`. Verified against `@convex-dev/agent@0.6.4`'s contract.
79
- - KTD3. **Typed middleware context lands first** (session-settled:
80
- user-directed — pulled from the consolidation list because the rate-limit
81
- factory should land on final typing, not the loose v1). Backward
82
- compatible: an untyped `next({ context })` still compiles.
83
- - KTD4. **New helpers ship as subpath exports** matching the existing
84
- pattern (`cvx-kit/pagination` style is rejected — see below): `crud`,
85
- `state-machine`, `middleware`, `webhooks`, `agent-tools` each get a
86
- subpath + index re-export; pagination joins existing homes (`zod-table`
87
- for schema helpers, `auth` for the include terminal) rather than a new
88
- module, since it derives from those seams.
89
- - KTD5a. convex-helpers' same-named `crud` helper was considered and
90
- rejected: it bypasses the command pipeline (no audits, no guards, hard
91
- deletes) — `src/docs/crud.md` states the distinction.
92
- - KTD5. **CRUD factory takes the Foundation-bound `Command` class as input**
93
- — `createCrudCommands({ Command, table, ... })` — never imports it. Keeps
94
- the facade-only rule intact: the factory is a *user* of the destructured
95
- facade, exactly like a domain module.
96
- - KTD6. **Webhook dedup storage is host-owned**: the helper exports a
97
- `webhookEventsTable()` (a plain `zodTable`) the host registers in its
98
- schema, and the boundary helper receives the table name + internal
99
- function references. The kit gains no tables (components own zero
100
- application capability).
101
- - KTD7. **GitHub Releases are created by the existing release workflow**
102
- (user-directed: "the changelog also should be on the github versioning") —
103
- a step after successful publish extracts the current version's CHANGELOG
104
- section and runs `gh release create` with it. The deprecated action pins
105
- (`actions/checkout`, `actions/setup-node`) get bumped in the same touch.
106
- - KTD8. **Upgrade guide ships in package docs** (session-settled:
107
- user-approved — chosen over root-only `MIGRATING.md`): authored at
108
- `src/docs/upgrading.md`, synced to `docs/` like the rest, linked from
109
- README and `llms.md`.
110
-
111
- ## High-Level Technical Design
112
-
113
- How the six features attach to existing seams (nothing new is load-bearing —
114
- every feature is a consumer of an existing extension point):
115
-
116
- ```mermaid
117
- flowchart LR
118
- subgraph existing [Existing seams — unchanged]
119
- ZT[zodTable masks]
120
- INC[include builder]
121
- CMD[Foundation Command\npipeline + guards]
122
- MW[middleware chain]
123
- SYS[system* constructors]
124
- end
125
- P[pagination bridge] --> ZT
126
- P --> INC
127
- CRUD[createCrudCommands] --> CMD
128
- SM[createStateMachine] -->|guard| CMD
129
- RL[rateLimit factory] --> MW
130
- WH[webhook boundary] --> SYS
131
- AT[createAgentTools] -->|handlers| CMD
132
- AT --> ZT
133
- ```
134
-
135
- Directional guidance, not implementation specification.
136
-
137
- ---
138
-
139
- ## Implementation Units
140
-
141
- ### U1. Typed middleware context accumulation
142
-
143
- **Goal:** `next({ context })` widens the downstream ctx type; guards and
144
- handlers after an enriching middleware see the enrichment in types.
145
- **Requirements:** R7, R10. **Dependencies:** none.
146
- **Files:** `src/components/foundation/client.ts`,
147
- `src/components/foundation/modules/query/query.ts`,
148
- `test/command.test.ts`, `test/query-middleware.test.ts`.
149
- **Approach:** Generic parameter on the middleware helper
150
- (`Command.middleware<Ctx, Extension>`) so the middleware's declared extension
151
- threads into the chain's context type; runtime unchanged (merge already
152
- works). Keep `CommandMiddleware`/`QueryMiddleware` assignable from the loose
153
- form. TanStack-style full inference across an array is explicitly NOT the
154
- goal for 0.1.0 — per-middleware declared extensions are enough.
155
- **Test scenarios:**
156
- - A middleware declaring `{ vendor: string }` lets the guard/handler use
157
- `ctx.vendor` without casts (type-level test via `tsc` on a fixture).
158
- - Existing untyped middleware in `test/command.test.ts` compiles unchanged.
159
- - Runtime ordering tests keep passing (no behavior change).
160
- **Verification:** typecheck + full suite green; no `as never` needed in the
161
- middleware tests that previously required it.
162
-
163
- ### U2. Pagination bridge
164
-
165
- **Goal:** cursor pagination with zod-typed boundaries.
166
- **Requirements:** R1, R10. **Dependencies:** none.
167
- **Files:** `src/zod-table.ts` (arg/return schema helpers), `src/auth.ts`
168
- (`paginate` terminal on `IncludedQuery`), `test/pagination.test.ts`,
169
- `test/fixture/functions.ts` (one paginated query),
170
- `src/docs/zod-table.md`, `src/docs/auth.md`, `src/docs/llms.md`.
171
- **Approach:**
172
- 1. `paginated(dto)` → `{ args: convexToZod(paginationOptsValidator)`-based
173
- opts schema, `returns:` a zod page result (`page: dto[]`, `isDone`,
174
- `continueCursor`) via convex-helpers bridges.
175
- 2. `include(...).paginate(opts, transform?)` terminal calling Convex's
176
- `query.paginate(opts)` on the selected indexed query; page size bounded by
177
- the same `maxRows` policy as `execute`.
178
- **Execution note:** verify early that a paginated read works through the
179
- RLS-wrapped reader (tenancy fixture) — if the wrapped reader doesn't support
180
- `paginate`, that's a planning-assumption failure to surface, not to patch
181
- around silently.
182
- **Test scenarios:**
183
- - Happy path: two pages traverse a seeded fixture table via an index;
184
- cursors chain; `isDone` terminates.
185
- - DTO projection: page items pass through `toPublicDto` transform.
186
- - Bound: page size above `maxRows` rejects with `INVALID_REQUEST_BOUNDARY`.
187
- - Tenancy: paginated scan in the tenant fixture returns only the caller
188
- tenant's rows.
189
- - Short pages: with foreign-tenant rows interleaved, pages may be shorter
190
- than `numItems` but cursors remain correct and traversal completes.
191
- **Verification:** suite green including the tenancy pagination case.
192
-
193
- ### U3. State-machine helper
194
-
195
- **Goal:** typed transition legality from constants tuples.
196
- **Requirements:** R3. **Dependencies:** none.
197
- **Files:** `src/state-machine.ts` (new), `src/index.ts`, `package.json`
198
- (exports subpath `./state-machine`), `test/state-machine.test.ts`,
199
- `src/docs/commands.md` (guard example), `src/docs/llms.md`.
200
- **Approach:** `createStateMachine(STATES, transitions)` where `transitions`
201
- is `Partial<Record<State, readonly State[]>>` typed against the tuple.
202
- Returns `{ states, can(from, to), assert(from, to, errors?) }`; `assert`
203
- throws via the injected `ErrorFactory` (default `KitError`,
204
- `INVALID_TRANSITION`) so it drops into command guards.
205
- **Patterns to follow:** vocabulary-tuple convention from `conventions.md`;
206
- error injection from `src/errors.ts`.
207
- **Test scenarios:**
208
- - Legal transition passes; illegal throws `INVALID_TRANSITION`.
209
- - A state with no outgoing transitions rejects everything.
210
- - Type-level: a literal not in the tuple fails to compile in `transitions`.
211
- - Custom `ErrorFactory` receives the throw.
212
- **Verification:** suite green; the commands.md guard example compiles as
213
- written in a fixture.
214
-
215
- ### U4. CRUD command factory
216
-
217
- **Goal:** standard create/update/archive operations from one declaration.
218
- **Requirements:** R2, R10. **Dependencies:** U1 (types). (U3 is referenced
219
- only by the archive-guard docs example, not by the implementation — U3 and U4
220
- can proceed in parallel.)
221
- **Files:** `src/crud.ts` (new), `src/index.ts`, `package.json` (subpath
222
- `./crud`), `test/crud.test.ts` (real-runtime via a fixture),
223
- `test/fixture/` additions, `src/docs/crud.md` (new), `src/docs/llms.md`,
224
- README modules section.
225
- **Approach:** `createCrudCommands({ Command, table, aggregateType,
226
- classification?, actor, archive?, guards?, middleware? })` returning
227
- `{ operations, executeCreate, executeUpdate, executeArchive }`:
228
- 1. Operation keys derived `<tableName>.create|update|archive`.
229
- 2. `create` input = `table.commandInput` (+ injected ids the host declares);
230
- handler inserts with `tenant`/server fields stamped via a host-provided
231
- `enrich(ctx, command)` callback (keeps the factory tenancy-agnostic).
232
- 3. `update` input = commandInput partial + id; `archive` sets `archivedAt`
233
- (soft delete per kit doctrine — no hard-delete operation is generated).
234
- 4. Audit derivation built from `aggregateType` + actor mapping; `aggregates:
235
- [aggregateType]` declared so the allowlist enforces it.
236
- **Test scenarios:**
237
- - Create/update/archive execute on the real runtime through authMutation
238
- adapters in the fixture; rows reflect changes; audits written with the
239
- declared aggregate type.
240
- - Update with a field outside `commandFields` rejects (strict schema).
241
- - Archive sets `archivedAt`, leaves the row (no delete).
242
- - Guards passed per-operation run before handlers (reuse guard-order test
243
- approach from `test/command.test.ts`).
244
- - Generated operations respect `classification` default `'business'`.
245
- - Tenancy: in the tenant fixture (factory registered behind tenant RLS rules),
246
- an update/archive targeting another tenant's document id rejects, and a
247
- create whose enrich fails to stamp `tenant` is rejected by RLS — mirrors
248
- U2's tenancy case. `enrich` is REQUIRED when the table is a tenantTable
249
- (compile-level where feasible).
250
- **Verification:** suite green; `docs/crud.md` example is the fixture code.
251
-
252
- ### U5. Rate-limit middleware factory
253
-
254
- **Goal:** first packaged middleware.
255
- **Requirements:** R4, R10. **Dependencies:** U1.
256
- **Files:** `src/middleware.ts` (new; home for packaged middleware),
257
- `src/index.ts`, `package.json` (dependency `@convex-dev/rate-limiter@^0.3`,
258
- subpath `./middleware`), `test/middleware.test.ts`,
259
- `src/docs/commands.md` (middleware section), `src/docs/tenancy.md` (keying
260
- note), `src/docs/llms.md`.
261
- **Approach:** `rateLimit({ limiter, name, key?, onLimit? })` returning a
262
- `CommandMiddleware` (and a query variant): before `next()`, call
263
- `limiter.limit(ctx, name, { key: key?.(ctx) ?? ctx.tenant })`; on rejection
264
- throw via `ErrorFactory` (`RATE_LIMITED`, carrying `retryAfter` in the
265
- message/metadata) or call `onLimit`. The limiter instance is injected —
266
- checked against registry snapshot of `@convex-dev/rate-limiter@0.3.2` (re-verify at install) (`new
267
- RateLimiter(components.rateLimiter, { limits })`, `limit(ctx, name, { key
268
- })` returning status). The kit never mounts the component; docs show host
269
- mounting.
270
- **Test scenarios:**
271
- - Fake limiter injected: under-limit passes through and handler runs.
272
- - Over-limit: handler never runs, `RATE_LIMITED` thrown, audit skipped.
273
- - Default key is `ctx.tenant`; custom `key` fn receives the ctx.
274
- - No `key` fn and `ctx.tenant` undefined: configuration error, handler never
275
- runs.
276
- - Works in both registry-wide and per-operation middleware slots (order
277
- test).
278
- **Verification:** suite green; publint clean with the new dependency external
279
- in the build (attw stays deferred per Scope Boundaries).
280
-
281
- ### U6. Webhook boundary helper
282
-
283
- **Goal:** the sanctioned webhook pattern as code.
284
- **Requirements:** R5, R10. **Dependencies:** none.
285
- **Files:** `src/webhooks.ts` (new), `src/index.ts`, `package.json` (subpath
286
- `./webhooks`), `test/webhooks.test.ts` (real runtime),
287
- `test/fixture-webhooks/` (schema + functions + `_generated` stub),
288
- `src/docs/webhooks.md` (new), `src/docs/llms.md`.
289
- **Approach:**
290
- 1. `webhookEventsTable()` — a plain `zodTable` (`eventKey`, `receivedAt`,
291
- `source`) the host registers; `by_eventKey` index documented.
292
- 2. `createWebhookBoundary({ verify, eventKey, table?, errors? })` returning
293
- `handle(ctx, request, target)` for use inside the host's `httpAction`:
294
- verify signature over the RAW request body (`request.text()` handed to
295
- `verify` before any parsing; docs example uses constant-time comparison —
296
- `crypto.subtle` HMAC verify — with the secret read from a Convex env var;
297
- throw on failure), compute the natural key, `runMutation(target, payload)` where `target` is a host internal
298
- mutation that calls the kit's `recordWebhookEvent(ctx, key)` guard —
299
- insert-if-absent, return `duplicate: true` when seen (dedup is
300
- transactional inside the mutation, not in the action).
301
- **Execution note:** dedup must live in the mutation (transactional), not the
302
- action — design the API so the helper makes this the easy path.
303
- **Test scenarios:**
304
- - First delivery processes; identical redelivery (same natural key) is a
305
- no-op reporting duplicate.
306
- - Failed signature verification throws before any write.
307
- - Verification runs against the raw body bytes, not a re-serialized parse.
308
- - Two different events with different keys both process.
309
- - Key derivation follows `${event}:${id}:${updatedAt}` in the docs example.
310
- **Verification:** suite green on the real runtime fixture.
311
-
312
- ### U7. Agent tool generator
313
-
314
- **Goal:** LLM tools that are audited commands.
315
- **Requirements:** R6, R10. **Dependencies:** U2 (list args derive from
316
- pagination opts), U4 (mutation executors are the natural handlers).
317
- **Files:** `src/agent-tools.ts` (new), `src/zod-table.ts` (switch `tools.update`/`tools.id`
318
- masks from `zid` to `jsonSafeZid` so the masks are JSON-schema-safe as KTD2
319
- assumes; compile-time Id types preserved), `src/index.ts`,
320
- `package.json` (subpath `./agent-tools`), `test/agent-tools.test.ts`,
321
- `src/docs/agent-tools.md` (new), `src/docs/llms.md`.
322
- **Approach:** `createAgentTools(table, { create?, update?, get?, list? })`
323
- mapping the existing `table.tools.*` masks to tool records `{ name,
324
- description, args, handler }`: `args` are the masks (already `jsonSafeZid`
325
- -safe), `description` derived (`Create a <tableName> record`, overridable),
326
- `handler: (ctx, input) => executor(ctx, input)` wiring the caller-supplied
327
- executors. Read contract: `get` uses the `tools.id`
328
- mask with a caller-supplied read handler; `list` args derive from U2's
329
- pagination opts schema with a caller-supplied query handler (reads do not
330
- route through commands). Shape-compatible with `@convex-dev/agent`'s
331
- `createTool` (checked against registry snapshot 2026-08-14; re-verify at
332
- install time) — the host spreads the record into `createTool` or
333
- ai-sdk `tool()`; the kit imports neither.
334
- **Test scenarios:**
335
- - Generated `args` for create equals `table.tools.insert` (mask reuse, no
336
- drift).
337
- - Handler invocation routes to the supplied executor with parsed input.
338
- - Ids present as plain strings in JSON schema (jsonSafeZid behavior holds
339
- through the mask).
340
- - Only requested tool kinds are emitted; names are `<tableName>_<verb>`.
341
- **Verification:** suite green; docs example type-checks against a stub
342
- `createTool` signature in tests.
343
-
344
- ### U8. CHANGELOG + GitHub Releases + workflow hardening
345
-
346
- **Goal:** release discipline switched on.
347
- **Requirements:** R8. **Dependencies:** none (can land first).
348
- **Files:** `CHANGELOG.md` (new; backfill 0.0.1–0.0.8 briefly, then 0.1.0),
349
- `.github/workflows/release.yml` (release-notes step + updated action pins),
350
- `README.md` (Releasing section), `ROADMAP.md` (check boxes as they complete).
351
- **Approach:** Keep-a-Changelog format. After the publish step succeeds, a
352
- step extracts the current tag's section from `CHANGELOG.md` and creates the
353
- GitHub Release (`gh release create "$TAG" --notes-file`), using the
354
- workflow's `GITHUB_TOKEN` (needs `contents: write` on the job — scope it to
355
- the release job only). Bump `actions/checkout` and `actions/setup-node` pins
356
- to current majors to clear the Node-20 deprecation annotation.
357
- **Test scenarios:** `Test expectation: none — CI configuration; verified by
358
- the 0.1.0 release run producing a GitHub Release with the changelog section
359
- as notes.`
360
- **Verification:** (1) the CHANGELOG-section extraction runs locally against
361
- the backfilled file and yields the 0.1.0 section verbatim BEFORE tagging;
362
- (2) the v0.1.0 tag run publishes to npm AND creates a GitHub Release with
363
- correct notes. Backfill source of truth: `git log` + release history, not
364
- memory.
365
-
366
- ### U9. Upgrade guide (the migration file) + docs pass
367
-
368
- **Goal:** consumers on 0.0.x know the new way.
369
- **Requirements:** R9. **Dependencies:** U1–U7 (documents them).
370
- **Files:** `src/docs/upgrading.md` (new), `src/docs/llms.md` (exports map +
371
- rules updates for all new modules), `README.md` (docs list + modules),
372
- `src/docs/conventions.md` (where the new helpers fit the file anatomy).
373
- **Approach:** `upgrading.md` is organized old way → new way per feature:
374
- hand-written CRUD operations → `createCrudCommands`; hand-rolled transition
375
- `if`s → `createStateMachine`; `.take()` loops → `paginate`; hand-rolled
376
- limiter checks → `rateLimit` middleware; ad-hoc webhook dedup →
377
- `createWebhookBoundary`; hand-written agent tools → `createAgentTools`;
378
- untyped middleware ctx → typed extensions. Each entry: before-snippet,
379
- after-snippet, and what stays unchanged. Also records the 0.1.0 semver
380
- contract switch WITH the caveat (per review): the six new export surfaces
381
- get final-name review during implementation, and the breaking window
382
- formally closes at the follow-up consolidation release (name decision + API
383
- audit) — CHANGELOG and upgrading.md both state this.
384
- **Test scenarios:** `Test expectation: none — documentation; correctness is
385
- enforced by U1–U7 examples being lifted from fixture code.`
386
- **Verification:** every new export appears in `llms.md`'s tables; docs sync
387
- ships the new pages in the tarball (dry-run check).
388
-
389
- ### U10. Release v0.1.0
390
-
391
- **Goal:** ship it. **Requirements:** all. **Dependencies:** U1–U9.
392
- **Files:** `package.json` (version via `npm version minor`).
393
- **Approach:** full local verification (build → typecheck → test → tarball
394
- dry-run), commit, `npm version minor`, push tag; watch the workflow publish
395
- npm + GitHub Release.
396
- **Test scenarios:** `Test expectation: none — release mechanics; gates are
397
- the Verification Contract.`
398
- **Verification:** registry shows 0.1.0 with provenance; GitHub Release
399
- exists with changelog notes; a consumer install shows the new subpaths.
400
-
401
- ---
402
-
403
- ## Scope Boundaries
404
-
405
- **In scope:** the ten units above.
406
-
407
- ### Deferred to Follow-Up Work
408
- - Package-name decision + full export-name API audit (user decision;
409
- remaining 0.1.0 roadmap items — do before or immediately after this ships,
410
- since the upgrade guide will need a rename note if the name changes).
411
- - `attw` + pack dry-run in CI (roadmap 0.1.0 CI-hardening item not blocking
412
- the features; the pin bumps land in U8).
413
- - Full TanStack-style middleware chain inference (per-middleware declared
414
- extensions are the 0.1.0 contract; array-level inference is future work).
415
-
416
- ### Outside this product's identity
417
- - Idempotency store, frontend bindings, multi-provider auth abstractions
418
- (per `ROADMAP.md` out-of-scope section).
419
-
420
- ## Open Questions (deferred to implementation)
421
- - ~~RLS-wrapped `paginate` support~~ RESOLVED during review: the wrapper
422
- supports `paginate` (convex-helpers filter WrapQuery). Real caveat:
423
- filtered pagination can return pages SHORTER than `numItems` when RLS
424
- rejects rows — U2 tests cursor correctness under mid-page filtering and
425
- the docs state the short-page contract.
426
- - Exact `RateLimiter` status shape on rejection (`retryAfter` field name) —
427
- read from the installed package at implementation time.
428
-
429
- ## Risks
430
- - **Dependency drift:** `@convex-dev/rate-limiter` peer-depends on
431
- `convex ^1.24.8` (compatible) but pulls a React peer — verify it stays a
432
- server-only import path so consumers without React aren't warned. If
433
- noisy, document or mark peer-optional in kit docs.
434
- - **U4 API surface:** the CRUD factory is the most opinion-dense new API;
435
- wrong defaults here are expensive post-0.1.0. Mitigation: fixture-first
436
- design (write the consuming fixture before the factory), and the archive
437
- operation stays soft-delete-only.
438
- - **Release workflow token:** `gh release create` needs `contents: write`;
439
- the job currently has `contents: read`. Scope the elevation to the release
440
- job only.
441
-
442
- ## Verification Contract
443
- - `bun run build` (includes bridge + publint), `bun run typecheck`,
444
- `bun run test` all green.
445
- - Tarball dry-run: dist + docs + `src/test.ts` only; all new docs pages
446
- present; new subpaths resolve (publint).
447
- - Tenancy suite still proves isolation with pagination added.
448
- - Every new runtime export has ≥1 test and an `llms.md` row.
449
-
450
- ## Definition of Done
451
- - All ten units complete; `cvx-kit@0.1.0` live on npm with provenance.
452
- - GitHub Release v0.1.0 exists with CHANGELOG-derived notes.
453
- - `ROADMAP.md` 0.1.0 feature checkboxes checked (consolidation items that
454
- remain open — name decision, API audit — explicitly unchecked with a note).
455
-
456
- ## Sources & Research
457
- - `ROADMAP.md` (origin), `docs/explain/roadmap-to-1.0.0.html`.
458
- - Session context: this repo's full source (authored this session).
459
- - Registry: `@convex-dev/rate-limiter@0.3.2` API (constructor, named limits,
460
- `limit(ctx, name, { key })`); `@convex-dev/agent@0.6.4` peers/contract.
461
- - convex-helpers: `convexToZod` (zod4), `paginationOptsValidator` (convex).
@@ -1,178 +0,0 @@
1
- ---
2
- title: resolveOrganization Auth Hook - Plan
3
- type: feat
4
- date: 2026-08-21
5
- artifact_contract: ce-unified-plan/v1
6
- artifact_readiness: implementation-ready
7
- product_contract_source: ce-plan-bootstrap
8
- execution: code
9
- ---
10
-
11
- # resolveOrganization Auth Hook - Plan
12
-
13
- ## Goal Capsule
14
-
15
- - **Objective:** Apps whose identity tokens carry no organization/role claims can use every cvx-kit auth constructor by resolving membership from their own database, and the capability ships documented in the next release.
16
- - **Means:** An optional `resolveOrganization` hook on `AuthFunctionsConfig`, per the API shape in issue #8 (KTD1).
17
- - **Authority:** This plan; issue #8 (`ImRLopezAG/cvx-kit#8`) for the API contract; existing fail-closed semantics in `src/auth.ts` for behavior precedent.
18
- - **Stop conditions:** Stop if implementing the hook requires changing the tenancy/RLS engine (`src/tenancy.ts`) or breaking any existing config field — that contradicts R5 and needs a user decision.
19
- - **Tail ownership:** Ends when the Definition of Done holds; releasing the version itself (`release:minor`) is the maintainer's call.
20
-
21
- ---
22
-
23
- ## Product Contract
24
-
25
- ### Summary
26
-
27
- Add an optional `resolveOrganization` hook to `createAuthFunctions` so identity providers without org JWT claims (custom credentials, magic codes, app-table memberships) can resolve organization and role from the database. Claim-based resolution stays the default; behavior is fully backwards compatible. Ships with tests, docs, and a changelog entry targeting the next minor version.
28
-
29
- ### Problem Frame
30
-
31
- `authenticatedUser` in `src/auth.ts` resolves `organizationId` and `roleSlug` exclusively from JWT claims (`identity.organization.*`, `identity.org_id`, `identity.role`). Providers that keep membership in app tables cannot use the kit's auth constructors without stuffing claims at sign-in or bypassing the kit entirely — defeating centralized tenant resolution. Issue #8 reports this from a real migration and proposes the hook; the reporter offered a PR, so landing the agreed shape upstream also closes the issue.
32
-
33
- ### Requirements
34
-
35
- - R1. `AuthFunctionsConfig` accepts an optional `resolveOrganization` hook called with `{ ctx, identity, user }` after the user is resolved, returning `Promise<{ organizationId: string; roleSlug: string } | null>`.
36
- - R2. When the hook is configured, its result overrides claim-based resolution for `organizationId` and role; claim parsing is not consulted.
37
- - R3. When the hook returns `null`, authentication fails with `FORBIDDEN`.
38
- - R4. The hook's `roleSlug` passes through `mapRole`; unknown roles reject with `FORBIDDEN`, exactly as claim-derived roles do today.
39
- - R5. When the hook is not configured, observable behavior is unchanged for hook-less configurations (backwards compatible), proven by the existing claim-based regression suite.
40
- - R6. The hook composes with existing machinery: `tenant` derives from the hook-resolved organization (via `security.tenancy.resolve` when set), and `verifyMembership` for actions continues to run against the hook-resolved `organizationId`.
41
- - R7. The capability is documented (`src/docs/auth.md`, `src/docs/llms.md`, the `README.md` example) and recorded in `CHANGELOG.md` under Unreleased for the next minor release.
42
- - R8. The documented hook contract carries two resolver obligations, each covered by a test: the lookup must bind its result to the verified user (never a caller-influenced or non-unique attribute), and revoked, inactive, or expired memberships must resolve to `null` so fail-closed revocation holds on queries and mutations.
43
- - R9. The hook is usable from all three function kinds: in action invocations the hook's `ctx` is an action context with no `ctx.db`, so the documented pattern (and the docs example) must branch to `ctx.runQuery` of an internal query there.
44
-
45
- ### Key Decisions
46
-
47
- - **Fail closed on `null`** — a configured hook is the source of truth; falling back to claims would let a revoked membership pass on stale claims. (session-settled: user-directed — chosen over claims-fallback: confirmed at scoping) Governs R3.
48
- - **Hook composes with, not replaces, `verifyMembership`** — actions keep their live re-verification pipeline unchanged. (session-settled: user-directed — chosen over hook-as-live-check: confirmed at scoping) Governs R6.
49
-
50
- ### Scope Boundaries
51
-
52
- - No changes to the tenancy/RLS engine (`src/tenancy.ts`) or trigger machinery.
53
- - No new auth providers or provider adapters.
54
-
55
- #### Deferred to Follow-Up Work
56
-
57
- - A convenience helper that builds `resolveOrganization` from a membership table + index name (only if consumer demand appears after release).
58
-
59
- ---
60
-
61
- ## Planning Contract
62
-
63
- ### Key Technical Decisions
64
-
65
- - KTD1. **API shape follows issue #8 verbatim** — `resolveOrganization?: (input: { ctx, identity, user }) => Promise<{ organizationId, roleSlug } | null>`, typed against the existing `AnyAuthContext<DataModel>` and `UserIdentity`. Rationale: the shape already composes with `mapRole` and `verifyMembership`, and matching it lets the issue close without API renegotiation. (session-settled: user-approved — chosen over redesigning the signature: confirmed at scoping)
66
- - KTD2. **Resolution happens inside `authenticatedUser`** so queries, mutations, AND actions all inherit it from one place; `actionInput`'s `verifyMembership` re-resolution runs afterward, unchanged. Rationale: `actionInput` calls `authenticatedUser` first, so no second integration point is needed. Constraint (R9): in actions the hook receives a `GenericActionCtx`, which has no `db` — a hook body doing `ctx.db.query(...)` will not typecheck against `AnyAuthContext` and would throw in actions, which KTD3's fail-closed catch masks as `FORBIDDEN`. The docs example must therefore branch (`'db' in ctx ? ctx.db... : ctx.runQuery(internal...)`) or use a `runQuery`-based lookup throughout.
67
- - KTD3. **Hook errors fail closed with `FORBIDDEN`**, mirroring the existing `verifiedMembership` catch-and-reject pattern (`src/auth.ts`, `verifiedMembership`). Rationale: an erroring membership lookup must not authenticate anyone; consistency with the kit's only other injected-lookup precedent.
68
- - KTD4. **The `UNAUTHENTICATED`-vs-`FORBIDDEN` boundary is preserved**: missing identity/user stays `UNAUTHENTICATED`; a resolved user without a usable org/role (hook `null`, unknown role) is `FORBIDDEN`. The existing missing-claim `UNAUTHENTICATED` path is untouched for hook-less configs.
69
-
70
- ### Assumptions
71
-
72
- - Hook return of a non-null result with an empty-string `organizationId` is treated as a hook-author bug, not specially validated (same trust level as `verifyMembership`'s return today).
73
-
74
- ### High-Level Technical Design
75
-
76
- Resolution order inside `authenticatedUser` after the hook lands:
77
-
78
- ```mermaid
79
- flowchart TB
80
- A[ctx.auth.getUserIdentity] -->|null| U1[UNAUTHENTICATED]
81
- A --> B[getAuthUser]
82
- B -->|null| U1
83
- B --> C{resolveOrganization configured?}
84
- C -->|yes| D[await hook with ctx, identity, user]
85
- D -->|throws or null| F1[FORBIDDEN]
86
- D -->|result| E[organizationId + roleSlug from hook]
87
- C -->|no| G[claim parsing: identity.organization / org_id / role]
88
- G -->|no org claim| U1
89
- G --> E2[organizationId + roleSlug from claims]
90
- E --> H[mapRole]
91
- E2 --> H
92
- H -->|null| F1
93
- H --> I[freeze bundle; tenant = tenancy.resolve or organizationId]
94
- ```
95
-
96
- Actions then proceed through `actionInput` → `verifiedMembership` exactly as today, using the bundle's `organizationId` (now possibly hook-resolved).
97
-
98
- ---
99
-
100
- ## Implementation Units
101
-
102
- ### U1. Add the `resolveOrganization` hook to `createAuthFunctions`
103
-
104
- - **Goal:** Hook-configured apps resolve org/role from the database; unconfigured apps are unchanged.
105
- - **Requirements:** R1, R2, R3, R4, R5, R6
106
- - **Dependencies:** none
107
- - **Files:** `src/auth.ts`
108
- - **Approach:**
109
- - Add the `resolveOrganization` field to `AuthFunctionsConfig` with the KTD1 signature and a doc comment matching the style of `verifyMembership`'s.
110
- - In `authenticatedUser`, after the identity/user guard: when the hook is configured, call it in a try/catch; a throw or `null` result rejects with `FORBIDDEN` (KTD3); otherwise use its `organizationId`/`roleSlug` and skip claim parsing (KTD2, KTD4). The rest of the function (`mapRole`, freeze, tenant derivation) is shared by both branches.
111
- - **Patterns to follow:** `verifiedMembership` in `src/auth.ts` for fail-closed error handling; existing doc-comment voice on `AuthFunctionsConfig` fields.
112
- - **Test scenarios:** covered in U2 (same behavior surface).
113
- - **Verification:** `bun run typecheck` passes; no existing test changes needed for hook-less configs.
114
-
115
- ### U2. Real-runtime tests for hook-based resolution
116
-
117
- - **Goal:** The hook's contract (override, fail-closed, mapRole, composition) is proven on the real Convex runtime.
118
- - **Requirements:** R2, R3, R4, R5, R6, R8, R9
119
- - **Dependencies:** U1
120
- - **Files:** `test/convex-auth.test.ts` (or a sibling `test/convex-auth-resolve-org.test.ts` if the fixture diverges), new or extended fixture under `test/fixture/` with a membership table and a `createAuthFunctions` config using `resolveOrganization`. Base identities in these tests carry **no** `org_id`/`role` claims; scenarios that test claim-override behavior create additional identities **with** claims as needed.
121
- - **Approach:** Mirror the existing `convexTest(schema, modules)` harness and `withIdentity` style. Seed membership rows via a system/internal function or `t.run`.
122
- - **Test scenarios:**
123
- - Happy path: identity without org claims + membership row → query and mutation succeed; `ctx.actor.organizationId` matches the membership row.
124
- - Hook returns `null` (no membership row) → `FORBIDDEN`.
125
- - Hook returns an unknown `roleSlug` → `FORBIDDEN` (mapRole rejection).
126
- - Hook configured but identity missing entirely → `UNAUTHENTICATED` (boundary preserved, KTD4).
127
- - Hook throws → `FORBIDDEN` (fail closed, KTD3).
128
- - Hook result overrides present-but-different claims: identity carries `org_id: 'org_claims'`, membership row says `org_db` → actor org is `org_db`.
129
- - Claim parsing is fully bypassed (R2): identity carries malformed/unknown org and role claims plus a valid membership row → authentication succeeds on the hook-derived org and mapped role.
130
- - Action path (R9): an `authAction` with the hook configured (a `runQuery`-based hook body) authenticates a claims-less identity successfully.
131
- - Composition with `verifyMembership` (R6): with both configured, an action asserts `verifyMembership` receives the hook-resolved `organizationId` (not a claim value); a `verifyMembership` null/mismatch result rejects with `FORBIDDEN`.
132
- - Identity binding (R8): a negative cross-user test — user A's identity can never receive user B's membership row or organization.
133
- - Revocation (R8): a revoked/inactive membership row resolves to `null` in the example predicate → `FORBIDDEN` on queries and mutations.
134
- - Composition with tenancy (mandatory, end-to-end): with `security.tenancy` configured, rows written under two different hook-resolved tenants are mutually invisible through the auth constructors' RLS-wrapped database (a direct `ctx.tenant` assertion is not an acceptable substitute).
135
- - Hook-less missing-claim regression (R5): a valid identity with a resolved user but no org/role claims, and no hook configured → `UNAUTHENTICATED`, exactly as today.
136
- - Regression: existing claim-based suite passes unmodified (R5).
137
- - **Verification:** `bun run test` green.
138
-
139
- ### U3. Documentation
140
-
141
- - **Goal:** A consumer with a claims-less provider can adopt the hook from the docs alone.
142
- - **Requirements:** R7, R8, R9
143
- - **Dependencies:** U1
144
- - **Files:** `src/docs/auth.md`, `src/docs/llms.md` (edit sources — `docs/` is generated by `docs:sync`, never edit it directly), `README.md` (extend its existing `createAuthFunctions` example with the new field).
145
- - **Approach:** In `auth.md`, add a section alongside "Queries/mutations trust the JWT; actions re-verify": when to use the hook (no org claims in tokens), resolution order (hook overrides claims; `null`/error → `FORBIDDEN`), a `companyUsers`-style example handler that is action-safe per R9 (branching to `ctx.runQuery` where `ctx.db` is absent), the resolver obligations per R8 (bind the lookup to the verified user; resolve revoked/inactive/expired memberships to `null`), the composition notes (mapRole still applies; tenant derives from the hook's org; `verifyMembership` unchanged), and the cost note (one DB read per authed call vs. free claims). Add the field to `llms.md`'s config surface.
146
- - **Test scenarios:** Test expectation: none — documentation-only unit; U4's verification covers `docs:sync` freshness.
147
- - **Verification:** Rendered docs read coherently; example compiles conceptually against the U1 signature.
148
-
149
- ### U4. Changelog and release readiness
150
-
151
- - **Goal:** The feature is recorded for the next minor release and issue #8 can be closed by the shipping PR.
152
- - **Requirements:** R7
153
- - **Dependencies:** U1, U2, U3
154
- - **Files:** `CHANGELOG.md`
155
- - **Approach:** Add an `### Added` entry under `## [Unreleased]` describing the hook and its fail-closed semantics, in the file's existing voice. The PR description references "Closes #8". Actual version bump stays with the maintainer via `release:minor`.
156
- - **Test scenarios:** Test expectation: none — release metadata only.
157
- - **Verification:** `bun run prepublishOnly` (build + typecheck + test + docs:sync) succeeds locally.
158
-
159
- ---
160
-
161
- ## Verification Contract
162
-
163
- | Gate | Command | Applies to |
164
- |---|---|---|
165
- | Types | `bun run typecheck` | U1 |
166
- | Runtime test suite | `bun run test` | U1, U2 |
167
- | Full release gate | `bun run prepublishOnly` | U4 (final) |
168
-
169
- All new tests run on the real Convex runtime via `convex-test`, matching the existing suite; no mocked auth paths.
170
-
171
- ## Definition of Done
172
-
173
- - All four units complete; R1–R9 each traceable to landed code, tests, or docs.
174
- - Existing test suite passes unmodified (R5 proof).
175
- - `bun run prepublishOnly` green.
176
- - `CHANGELOG.md` Unreleased section carries the entry; docs sources updated (not the generated `docs/` copies).
177
- - No dead-end or experimental code left in the diff.
178
- - Follow-up: shipping PR references issue #8 so it closes on merge.