cvx-kit 0.1.0 → 0.1.2
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.
- package/CHANGELOG.md +41 -0
- package/README.md +6 -0
- package/dist/auth.d.mts +16 -0
- package/dist/auth.mjs +1 -1
- package/dist/auth.mjs.map +1 -1
- package/dist/components/approvals/_generated/api.d.mts +2 -0
- package/dist/components/approvals/_generated/api.mjs.map +1 -1
- package/dist/components/approvals/_generated/component.d.mts +7 -0
- package/dist/components/approvals/_generated/dataModel.d.mts +1 -1
- package/dist/components/approvals/audit.mjs +1 -1
- package/dist/components/approvals/client.d.mts +4 -4
- package/dist/components/approvals/client.mjs +1 -1
- package/dist/components/approvals/client.mjs.map +1 -1
- package/dist/components/approvals/constants.d.mts +8 -2
- package/dist/components/approvals/constants.mjs +1 -1
- package/dist/components/approvals/constants.mjs.map +1 -1
- package/dist/components/approvals/decisions.mjs +1 -1
- package/dist/components/approvals/decisions.mjs.map +1 -1
- package/dist/components/approvals/health.d.mts +15 -0
- package/dist/components/approvals/health.mjs +2 -0
- package/dist/components/approvals/health.mjs.map +1 -0
- package/dist/components/approvals/requests.mjs +1 -1
- package/dist/components/approvals/requests.mjs.map +1 -1
- package/dist/components/approvals/schema.js +1 -0
- package/dist/components/approvals/validators.d.mts +119 -2
- package/dist/components/approvals/validators.mjs +1 -1
- package/dist/components/approvals/validators.mjs.map +1 -1
- package/dist/components/approvals/workflow_steps.d.mts +2 -25
- package/dist/components/approvals/workflow_steps.mjs.map +1 -1
- package/dist/components/foundation/_generated/dataModel.d.mts +1 -1
- package/dist/components/foundation/{schema.mjs → schema.js} +0 -1
- package/docs/approvals.md +50 -2
- package/docs/auth.md +49 -0
- package/docs/llms.md +1 -0
- package/docs/plans/2026-08-21-0930-feat-resolve-organization-hook-plan.md +178 -0
- package/docs/upgrading.md +23 -0
- package/package.json +5 -2
- package/scripts/convex-config-bridge.mjs +41 -3
- package/scripts/smoke-packed-components.mjs +374 -0
- package/src/test.ts +2 -2
- package/dist/components/approvals/schema.mjs +0 -2
- package/dist/components/approvals/schema.mjs.map +0 -1
- package/dist/components/foundation/schema.mjs.map +0 -1
- /package/dist/components/approvals/{schema.d.mts → schema.d.ts} +0 -0
- /package/dist/components/foundation/{schema.d.mts → schema.d.ts} +0 -0
package/docs/approvals.md
CHANGED
|
@@ -16,6 +16,20 @@ import approvals from 'cvx-kit/components/approvals/convex.config'
|
|
|
16
16
|
app.use(approvals)
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
After installation or upgrade, deploy once and run the component health query
|
|
20
|
+
from a host wrapper:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
return ctx.runQuery(components.approvals.health.check, {})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
It returns the installed approvals schema version and required index names. It
|
|
27
|
+
also executes reads through
|
|
28
|
+
`approvalDecisions.by_runId_and_decidedAt` and
|
|
29
|
+
`approvalDecisions.by_runId_and_stepKey_and_actor_actorRef`, failing with an
|
|
30
|
+
installation-specific diagnostic if the packaged component schema was not
|
|
31
|
+
deployed.
|
|
32
|
+
|
|
19
33
|
The component mounts its own private `workflow` and `auditLog` children. Do
|
|
20
34
|
**not** re-register those children at the app root under the same identity,
|
|
21
35
|
and never reach into `components.approvals.workflow` / `.auditLog` from host
|
|
@@ -99,10 +113,14 @@ const { runId } = await publishApproval.start(ctx, {
|
|
|
99
113
|
// decide — a manager approves or rejects
|
|
100
114
|
await publishApproval.decide(ctx, { runId, decision: 'approved', reason }, ctx.actor)
|
|
101
115
|
|
|
102
|
-
// observe
|
|
116
|
+
// observe from a host query
|
|
103
117
|
await publishApproval.status(ctx, runId) // pending | approved | rejected | expired | canceled
|
|
104
118
|
await publishApproval.evidence(ctx, runId) // per-step decision evidence
|
|
105
|
-
await publishApproval.list(ctx, {
|
|
119
|
+
await publishApproval.list(ctx, {
|
|
120
|
+
scopeRef,
|
|
121
|
+
state: 'pending',
|
|
122
|
+
paginationOpts: { cursor: null, numItems: 20 },
|
|
123
|
+
})
|
|
106
124
|
|
|
107
125
|
// manage
|
|
108
126
|
await publishApproval.cancel(ctx, runId, ctx.actor)
|
|
@@ -119,6 +137,8 @@ Because the component cannot know your tables, the handler must re-establish
|
|
|
119
137
|
trust:
|
|
120
138
|
|
|
121
139
|
```ts
|
|
140
|
+
import { approvalCallbackArgs } from 'cvx-kit/components/approvals'
|
|
141
|
+
|
|
122
142
|
export const applyDecision = systemMutation({
|
|
123
143
|
args: approvalCallbackArgs,
|
|
124
144
|
handler: async (ctx, input) => {
|
|
@@ -136,6 +156,34 @@ export const applyDecision = systemMutation({
|
|
|
136
156
|
That `approvalRunId` check is the idempotency/staleness guard from the origin
|
|
137
157
|
app — copy it.
|
|
138
158
|
|
|
159
|
+
For branch callbacks, `decision.evidence` contains
|
|
160
|
+
`{ stepKey, actorRef, decision, reason?, decidedAt }` items. Use the exported
|
|
161
|
+
`approvalCallbackArgs` validator instead of duplicating that protocol boundary.
|
|
162
|
+
|
|
163
|
+
### Paginated inboxes
|
|
164
|
+
|
|
165
|
+
Expose the configured workflow through a host query; component pagination uses
|
|
166
|
+
`convex-helpers` internally because Convex's built-in `.paginate()` is not
|
|
167
|
+
available inside components:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
export const approvalInbox = authQuery({
|
|
171
|
+
args: {
|
|
172
|
+
state: z.enum(['pending', 'approved', 'rejected', 'expired', 'canceled']).optional(),
|
|
173
|
+
paginationOpts: convexToZod(paginationOptsValidator),
|
|
174
|
+
},
|
|
175
|
+
handler: (ctx, args) => publishApproval.list(ctx, {
|
|
176
|
+
scopeRef: ctx.org.organizationId,
|
|
177
|
+
...args,
|
|
178
|
+
}),
|
|
179
|
+
})
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The result keeps Convex's standard pagination shape, so React consumers can
|
|
183
|
+
call `usePaginatedQuery(api.approvals.approvalInbox, { state: 'pending' },
|
|
184
|
+
{ initialNumItems: 20 })`. Pass each returned cursor only back to the same
|
|
185
|
+
scope/state query.
|
|
186
|
+
|
|
139
187
|
## Rules and footguns
|
|
140
188
|
|
|
141
189
|
1. Root facade (`convex/approvals.ts`) instantiates the client; workflow
|
package/docs/auth.md
CHANGED
|
@@ -97,6 +97,55 @@ role/organization from that check replaces the JWT's. Verification **fails
|
|
|
97
97
|
closed**: any error or org mismatch throws `FORBIDDEN`. Omit
|
|
98
98
|
`verifyMembership` to let actions trust the JWT like queries do.
|
|
99
99
|
|
|
100
|
+
## Resolving org/role from the database (`resolveOrganization`)
|
|
101
|
+
|
|
102
|
+
Some identity providers issue tokens that carry **no org claims** — custom
|
|
103
|
+
credentials, magic codes, or apps that keep memberships in their own tables.
|
|
104
|
+
For those, configure `resolveOrganization` to derive organization authority
|
|
105
|
+
from the app's own data instead of the JWT.
|
|
106
|
+
|
|
107
|
+
When configured, the hook is the organization authority — claim parsing is
|
|
108
|
+
skipped entirely. Returning `null` **or throwing** rejects with `FORBIDDEN`
|
|
109
|
+
(fail closed); a missing identity or user is still `UNAUTHENTICATED`; the
|
|
110
|
+
returned `roleSlug` still passes through `mapRole` like a claim would.
|
|
111
|
+
|
|
112
|
+
The handler must be **action-safe**: in actions the ctx has no `db`, so branch
|
|
113
|
+
on its presence and fall back to an internal query:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { internal } from './_generated/api'
|
|
117
|
+
|
|
118
|
+
resolveOrganization: async ({ ctx, user }) => {
|
|
119
|
+
const membership =
|
|
120
|
+
'db' in ctx
|
|
121
|
+
? await ctx.db
|
|
122
|
+
.query('companyUsers')
|
|
123
|
+
.withIndex('by_user', (q) => q.eq('userId', user.id))
|
|
124
|
+
.first()
|
|
125
|
+
: await ctx.runQuery(internal.memberships.byUser, { userId: user.id })
|
|
126
|
+
if (!membership || !membership.active) return null // revoked ⇒ FORBIDDEN
|
|
127
|
+
return { organizationId: membership.orgId, roleSlug: membership.roleSlug }
|
|
128
|
+
},
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Resolver obligations:
|
|
132
|
+
|
|
133
|
+
- Bind the lookup to the **verified** user — `user.id` (or
|
|
134
|
+
`identity.subject`), never a caller-influenced or non-unique attribute.
|
|
135
|
+
- Resolve revoked/inactive/expired memberships to `null`, so revocation
|
|
136
|
+
actually takes effect fail-closed.
|
|
137
|
+
|
|
138
|
+
Composition: `mapRole` still applies to the returned slug; with tenancy
|
|
139
|
+
configured, `ctx.tenant` derives from the hook's org
|
|
140
|
+
(`security.tenancy.resolve` is unchanged); `verifyMembership` for actions is
|
|
141
|
+
unchanged and receives the hook-resolved `organizationId` — in actions its
|
|
142
|
+
live result supersedes the hook's role/org, exactly as it supersedes claims.
|
|
143
|
+
|
|
144
|
+
Cost: one DB read per authenticated call (claims are free); actions with
|
|
145
|
+
`verifyMembership` configured also pay the live check. When debugging, note
|
|
146
|
+
that a misconfigured hook surfaces as blanket `FORBIDDEN` — errors are
|
|
147
|
+
swallowed fail-closed, never rethrown.
|
|
148
|
+
|
|
100
149
|
## Mutations are structurally trigger-wrapped
|
|
101
150
|
|
|
102
151
|
When `triggers` (or `wrapDB`) is configured, every `authMutation`,
|
package/docs/llms.md
CHANGED
|
@@ -114,6 +114,7 @@ export const { authQuery, authMutation, authAction, adminQuery, adminMutation,
|
|
|
114
114
|
adminRoles: ['admin'],
|
|
115
115
|
triggers,
|
|
116
116
|
verifyMembership: async ({ userId, organizationId }) => { /* live check; actions only */ },
|
|
117
|
+
resolveOrganization: async ({ ctx, identity, user }) => { /* org+role from app tables; overrides claims; null/throw ⇒ FORBIDDEN */ },
|
|
117
118
|
})
|
|
118
119
|
|
|
119
120
|
// convex/foundation.ts
|
|
@@ -0,0 +1,178 @@
|
|
|
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.
|
package/docs/upgrading.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Upgrading to 0.1.0 — the new way of things
|
|
2
2
|
|
|
3
|
+
## Approvals packaging repair (0.1.x patch)
|
|
4
|
+
|
|
5
|
+
`cvx-kit@0.1.0` published component schemas as `schema.mjs`, which Convex does
|
|
6
|
+
not discover for packaged components. Upgrade to the patch containing this
|
|
7
|
+
repair, remove any `patchedDependencies` entry that copied or renamed the
|
|
8
|
+
approvals schema, reinstall from a clean lockfile state, and run one normal
|
|
9
|
+
deployment:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun install
|
|
13
|
+
bunx convex dev --once
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The repaired package publishes only `schema.js` and `schema.d.ts` for each
|
|
17
|
+
component. Do not retain both `.mjs` and `.js`; Convex canonicalizes them to the
|
|
18
|
+
same module and rejects the deployment with `CanonicalizationConflict`.
|
|
19
|
+
|
|
20
|
+
This upgrade does not change table fields or index definitions, so a second
|
|
21
|
+
deployment is not required for staged index backfills. Existing deployments
|
|
22
|
+
that never received the component schema will create the tables and indexes on
|
|
23
|
+
the first repaired deployment. Confirm readiness through
|
|
24
|
+
`components.approvals.health.check` before resuming approval traffic.
|
|
25
|
+
|
|
3
26
|
The migration file for consumers on 0.0.x. Each section: the old way you were
|
|
4
27
|
hand-writing, the 0.1.0 way, and what stays unchanged. Nothing existing
|
|
5
28
|
breaks in this release except one boundary-mask improvement (see the last
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cvx-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Reusable Convex application kit: foundation kernels, approvals component, zod table boundaries, WorkOS-backed auth function constructors, and audited command protocol.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -90,7 +90,10 @@
|
|
|
90
90
|
"scripts": {
|
|
91
91
|
"build": "vp pack && node scripts/convex-config-bridge.mjs && publint",
|
|
92
92
|
"typecheck": "tsc --noEmit",
|
|
93
|
-
"test": "vp test run",
|
|
93
|
+
"test": "vp test run --exclude test/package-contents.test.mjs",
|
|
94
|
+
"test:package": "bun run build && vp test run test/package-contents.test.mjs",
|
|
95
|
+
"test:package:smoke:bun": "bun scripts/smoke-packed-components.mjs bun",
|
|
96
|
+
"test:package:smoke:npm": "node scripts/smoke-packed-components.mjs npm",
|
|
94
97
|
"docs:sync": "rm -f docs/*.md && cp src/docs/*.md docs/",
|
|
95
98
|
"prepublishOnly": "bun run build && bun run typecheck && bun run test && bun run docs:sync",
|
|
96
99
|
"build:codegen": "convex codegen --component-dir ./src/components/foundation && convex codegen --component-dir ./src/components/approvals && vp pack",
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
// is literally named `convex.config.js` (see DEFINITION_FILENAME_JS in the
|
|
3
3
|
// convex CLI). vp pack emits .mjs, so this postbuild step materializes the
|
|
4
4
|
// compiled definition as convex.config.js in each component's dist directory.
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs'
|
|
6
12
|
import { dirname, join } from 'node:path'
|
|
7
13
|
import { fileURLToPath } from 'node:url'
|
|
8
14
|
|
|
@@ -10,8 +16,40 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
|
10
16
|
|
|
11
17
|
for (const component of ['foundation', 'approvals']) {
|
|
12
18
|
const directory = join(root, 'dist', 'components', component)
|
|
13
|
-
const
|
|
19
|
+
const config = readFileSync(join(directory, 'convex.config.mjs'), 'utf8')
|
|
14
20
|
.replace(/\/\/# sourceMappingURL=.*\n?/, '')
|
|
15
|
-
writeFileSync(join(directory, 'convex.config.js'),
|
|
21
|
+
writeFileSync(join(directory, 'convex.config.js'), config)
|
|
22
|
+
|
|
23
|
+
// Component implementations are discovered from schema.ts/schema.js only.
|
|
24
|
+
// Keeping schema.mjs beside schema.js is also invalid because Convex
|
|
25
|
+
// canonicalizes both extensions to the same module path.
|
|
26
|
+
const schema = readFileSync(join(directory, 'schema.mjs'), 'utf8').replace(
|
|
27
|
+
/\/\/# sourceMappingURL=.*\n?/,
|
|
28
|
+
'',
|
|
29
|
+
)
|
|
30
|
+
writeFileSync(join(directory, 'schema.js'), schema)
|
|
31
|
+
for (const filename of readdirSync(directory)) {
|
|
32
|
+
if (!filename.endsWith('.mjs')) continue
|
|
33
|
+
const path = join(directory, filename)
|
|
34
|
+
const source = readFileSync(path, 'utf8')
|
|
35
|
+
const bridged = source.replaceAll('./schema.mjs', './schema.js')
|
|
36
|
+
if (bridged !== source) writeFileSync(path, bridged)
|
|
37
|
+
}
|
|
38
|
+
renameSync(join(directory, 'schema.d.mts'), join(directory, 'schema.d.ts'))
|
|
39
|
+
const dataModelDeclaration = join(
|
|
40
|
+
directory,
|
|
41
|
+
'_generated',
|
|
42
|
+
'dataModel.d.mts',
|
|
43
|
+
)
|
|
44
|
+
writeFileSync(
|
|
45
|
+
dataModelDeclaration,
|
|
46
|
+
readFileSync(dataModelDeclaration, 'utf8').replace(
|
|
47
|
+
'../schema.mjs',
|
|
48
|
+
'../schema.js',
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
rmSync(join(directory, 'schema.mjs'))
|
|
52
|
+
rmSync(join(directory, 'schema.mjs.map'))
|
|
16
53
|
console.log(`bridged dist/components/${component}/convex.config.js`)
|
|
54
|
+
console.log(`bridged dist/components/${component}/schema.js`)
|
|
17
55
|
}
|