cvx-kit 0.1.15 → 0.1.17
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/README.md +67 -40
- package/dist/querying/filters.d.mts +10 -1
- package/dist/querying/filters.mjs +1 -1
- package/dist/querying/filters.mjs.map +1 -1
- package/dist/querying/planner.d.mts +1 -0
- package/dist/querying/planner.mjs +1 -1
- package/dist/querying/planner.mjs.map +1 -1
- package/dist/querying/relationships.d.mts +1 -1
- package/dist/querying/relationships.mjs +1 -1
- package/dist/querying/relationships.mjs.map +1 -1
- package/dist/querying.d.mts +9 -8
- package/dist/querying.mjs.map +1 -1
- package/dist/relations-public.d.mts +2 -2
- package/dist/relations.d.mts +74 -25
- package/dist/relations.mjs +1 -1
- package/dist/relations.mjs.map +1 -1
- package/dist/zod-table.d.mts +59 -2
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/package.json +12 -3
- package/AGENTS.md +0 -47
- package/CHANGELOG.md +0 -337
- package/ROADMAP.md +0 -126
- package/docs/agent-tools.md +0 -41
- package/docs/approvals.md +0 -201
- package/docs/architecture.md +0 -124
- package/docs/auth.md +0 -443
- package/docs/command-context.md +0 -150
- package/docs/commands-legacy.md +0 -513
- package/docs/commands.md +0 -173
- package/docs/conventions.md +0 -345
- package/docs/crud.md +0 -66
- package/docs/domain-contracts.md +0 -148
- package/docs/domain-operations.md +0 -615
- package/docs/effect.md +0 -403
- package/docs/llms.md +0 -324
- package/docs/maintainability.md +0 -124
- package/docs/migration.md +0 -351
- package/docs/oxlint.md +0 -260
- package/docs/querying.md +0 -329
- package/docs/tenancy.md +0 -176
- package/docs/triggers.md +0 -77
- package/docs/upgrading.md +0 -287
- package/docs/webhooks.md +0 -58
- package/docs/zod-table.md +0 -278
- package/scripts/convex-config-bridge.mjs +0 -44
- package/scripts/convex-smoke-env.mjs +0 -33
- package/scripts/extract-changelog.mjs +0 -29
- package/scripts/owned-process.mjs +0 -140
- package/scripts/owned-process.test.mjs +0 -107
- package/scripts/smoke-packed-components.mjs +0 -341
- package/scripts/smoke-packed-effect.mjs +0 -439
- package/scripts/smoke-packed-querying.mjs +0 -298
- package/scripts/test-packed-auth.mjs +0 -72
- package/scripts/test-packed-effect.mjs +0 -482
- package/scripts/test-packed-ids.mjs +0 -150
- package/scripts/test-packed-oxlint.mjs +0 -167
- package/scripts/test-packed-querying.mjs +0 -185
package/docs/auth.md
DELETED
|
@@ -1,443 +0,0 @@
|
|
|
1
|
-
# `cvx-kit/auth` — auth-aware function constructors from injected policy
|
|
2
|
-
|
|
3
|
-
`createAuthFunctions` builds the function constructors your whole backend uses
|
|
4
|
-
in place of raw `query`/`mutation`/`action`. Nothing in the kit imports your
|
|
5
|
-
app's singletons — identity resolution, role vocabulary, membership
|
|
6
|
-
verification, trigger wrapping, and error policy are all **injected** by the
|
|
7
|
-
host. The kit provides the structure; the app provides the policy.
|
|
8
|
-
|
|
9
|
-
## Setup — declared once per app
|
|
10
|
-
|
|
11
|
-
```ts
|
|
12
|
-
// convex/functions.ts (the single place constructors are built)
|
|
13
|
-
import { createAuthFunctions, defaultRoleMap } from 'cvx-kit/auth'
|
|
14
|
-
import {
|
|
15
|
-
action,
|
|
16
|
-
internalAction,
|
|
17
|
-
internalMutation,
|
|
18
|
-
internalQuery,
|
|
19
|
-
mutation,
|
|
20
|
-
query,
|
|
21
|
-
} from './_generated/server'
|
|
22
|
-
import type { DataModel } from './_generated/dataModel'
|
|
23
|
-
import { triggers } from './triggers'
|
|
24
|
-
import { authKit } from './auth'
|
|
25
|
-
|
|
26
|
-
export const {
|
|
27
|
-
authQuery,
|
|
28
|
-
authMutation,
|
|
29
|
-
authAction,
|
|
30
|
-
roleQuery,
|
|
31
|
-
roleMutation,
|
|
32
|
-
roleAction,
|
|
33
|
-
adminQuery,
|
|
34
|
-
adminMutation,
|
|
35
|
-
adminAction,
|
|
36
|
-
systemQuery,
|
|
37
|
-
systemMutation,
|
|
38
|
-
systemAction,
|
|
39
|
-
include,
|
|
40
|
-
authenticatedUser,
|
|
41
|
-
} = createAuthFunctions<DataModel>({
|
|
42
|
-
query,
|
|
43
|
-
mutation,
|
|
44
|
-
action,
|
|
45
|
-
internalQuery,
|
|
46
|
-
internalMutation,
|
|
47
|
-
internalAction,
|
|
48
|
-
getAuthUser: (ctx) => authKit.getAuthUser(ctx),
|
|
49
|
-
mapRole: defaultRoleMap, // or your own vocabulary (see below)
|
|
50
|
-
adminRoles: ['admin'],
|
|
51
|
-
triggers, // every mutation write runs through wrapDB
|
|
52
|
-
verifyMembership: async ({ userId, organizationId }) => {
|
|
53
|
-
const memberships = await authKit.workos.userManagement.listOrganizationMemberships({
|
|
54
|
-
organizationId,
|
|
55
|
-
userId,
|
|
56
|
-
statuses: ['active'],
|
|
57
|
-
})
|
|
58
|
-
const m = memberships.data.find((c) => c.userId === userId)
|
|
59
|
-
return m ? { organizationId: m.organizationId, roleSlug: m.role.slug } : null
|
|
60
|
-
},
|
|
61
|
-
})
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Every function in the app is then built from these — raw `query`/`mutation`
|
|
65
|
-
imports from `_generated/server` appear **only** in this file.
|
|
66
|
-
|
|
67
|
-
## One shared Effect function foundation
|
|
68
|
-
|
|
69
|
-
Use `createEffectAuthFunctions` from `cvx-kit/effect` in `convex/functions.ts`
|
|
70
|
-
to configure all twelve constructors once. Its first argument is the same
|
|
71
|
-
`AuthFunctionsConfig` used by `createAuthFunctions`; its second argument has
|
|
72
|
-
six explicit policies: `query`, `mutation`, `action`, `systemQuery`,
|
|
73
|
-
`systemMutation`, and `systemAction`. Each accepts `services`, `context`, and
|
|
74
|
-
`mapError`, like `effectZodApiBuilder`. Pass `{}` when no additions are needed.
|
|
75
|
-
|
|
76
|
-
Type the auth config with your generated `DataModel` and role vocabulary,
|
|
77
|
-
then let the factory infer its generics from both arguments. Do not supply
|
|
78
|
-
partial generic arguments, which would fix the remaining inference defaults.
|
|
79
|
-
The following public-package example defines a reusable app factory; the
|
|
80
|
-
host passes its auth, membership, trigger, and RLS policy as `config`.
|
|
81
|
-
|
|
82
|
-
<!-- packed-effect-example -->
|
|
83
|
-
|
|
84
|
-
```ts
|
|
85
|
-
import {
|
|
86
|
-
defineSchema,
|
|
87
|
-
defineTable,
|
|
88
|
-
makeFunctionReference,
|
|
89
|
-
type DataModelFromSchemaDefinition,
|
|
90
|
-
type GenericQueryCtx,
|
|
91
|
-
type GenericMutationCtx,
|
|
92
|
-
} from 'convex/server'
|
|
93
|
-
import { v } from 'convex/values'
|
|
94
|
-
import { Effect } from 'effect'
|
|
95
|
-
import { z } from 'zod'
|
|
96
|
-
import type { AuthFunctionsConfig, AuthBundle } from 'cvx-kit/auth'
|
|
97
|
-
import { createEffectAuthFunctions, createEffectFoundation } from 'cvx-kit/effect'
|
|
98
|
-
|
|
99
|
-
const schema = defineSchema({ notes: defineTable({ tenant: v.string(), title: v.string() }) })
|
|
100
|
-
type Model = DataModelFromSchemaDefinition<typeof schema>
|
|
101
|
-
type Role = 'owner' | 'viewer'
|
|
102
|
-
const input = z.object({ title: z.string().trim().min(1) })
|
|
103
|
-
const saveRef = makeFunctionReference<'mutation', z.input<typeof input>, null>('notes:save')
|
|
104
|
-
|
|
105
|
-
export function createApplicationFunctions(config: AuthFunctionsConfig<Model, Role>) {
|
|
106
|
-
const foundation = createEffectFoundation({
|
|
107
|
-
observability: {
|
|
108
|
-
enabled: false,
|
|
109
|
-
classifyError: () => ({ outcome: 'failed', errorCode: 'FAILED' }),
|
|
110
|
-
},
|
|
111
|
-
writeAudit: () => undefined, // supply the app's transactional audit writer in production
|
|
112
|
-
})
|
|
113
|
-
const queries = foundation.Query({
|
|
114
|
-
context: (ctx: (GenericQueryCtx<Model> | GenericMutationCtx<Model>) & AuthBundle<Role>) => ctx,
|
|
115
|
-
operations: ({ query }) => ({
|
|
116
|
-
actor: query({
|
|
117
|
-
input: z.object({}),
|
|
118
|
-
result: z.string(),
|
|
119
|
-
handler: (_input, ctx) => ctx.actor.userId,
|
|
120
|
-
}),
|
|
121
|
-
}),
|
|
122
|
-
})
|
|
123
|
-
const commands = foundation.Command({
|
|
124
|
-
context: (ctx: GenericMutationCtx<Model> & AuthBundle<Role>) => ctx,
|
|
125
|
-
operations: ({ command }) => ({
|
|
126
|
-
save: command({
|
|
127
|
-
input,
|
|
128
|
-
result: z.null(),
|
|
129
|
-
classification: 'business',
|
|
130
|
-
audit: () => null,
|
|
131
|
-
handler: (args, ctx) =>
|
|
132
|
-
Effect.promise(async () => {
|
|
133
|
-
await ctx.db.insert('notes', { title: args.title, tenant: ctx.tenant })
|
|
134
|
-
return null
|
|
135
|
-
}),
|
|
136
|
-
}),
|
|
137
|
-
}),
|
|
138
|
-
})
|
|
139
|
-
const functions = createEffectAuthFunctions(config, {
|
|
140
|
-
query: { context: (ctx) => ({ queries: queries.withContext(ctx) }) },
|
|
141
|
-
mutation: {
|
|
142
|
-
context: (ctx) => ({
|
|
143
|
-
queries: queries.withContext(ctx),
|
|
144
|
-
commands: commands.withContext(ctx),
|
|
145
|
-
}),
|
|
146
|
-
},
|
|
147
|
-
action: {
|
|
148
|
-
context: (ctx) => ({
|
|
149
|
-
commands: {
|
|
150
|
-
save: (args: z.input<typeof input>) =>
|
|
151
|
-
Effect.promise(() => ctx.runMutation(saveRef, args)),
|
|
152
|
-
},
|
|
153
|
-
}),
|
|
154
|
-
},
|
|
155
|
-
systemQuery: {},
|
|
156
|
-
systemMutation: {},
|
|
157
|
-
systemAction: {},
|
|
158
|
-
})
|
|
159
|
-
const save = functions.roleMutation('owner')({
|
|
160
|
-
args: input.shape,
|
|
161
|
-
returns: z.null(),
|
|
162
|
-
handler: (ctx, args) => ctx.commands.exec('save', args),
|
|
163
|
-
})
|
|
164
|
-
return { ...functions, save }
|
|
165
|
-
}
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
Export the returned constructors once from `convex/functions.ts`, and export
|
|
169
|
-
`save` at the registered `notes:save` mutation path. Endpoint modules can then
|
|
170
|
-
use `ctx.commands` or `ctx.queries` directly. The callbacks run per invocation
|
|
171
|
-
after auth, role checks, triggers/RLS wrapping, and live action membership
|
|
172
|
-
refresh. Binding is lazy; do not cache an authenticated `withContext` handle
|
|
173
|
-
at module scope. Denied requests never run the callbacks.
|
|
174
|
-
|
|
175
|
-
| Policy | Available authority and capabilities |
|
|
176
|
-
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
177
|
-
| `query` | Trusted auth bundle, secured DB reader, `include`; bind read registries only. |
|
|
178
|
-
| `mutation` | Trusted auth bundle, secured triggered DB writer, `include`; bind queries and mutation commands. |
|
|
179
|
-
| `action` | Live-verified auth bundle, `runQuery`/`runMutation`; use action-compatible registries or forward to registered functions. No DB or `include`. |
|
|
180
|
-
| `systemQuery` / `systemMutation` / `systemAction` | Existing internal authority, configured separately. No fabricated actor or inherited public handles; system mutations retain triggers and bypass public RLS as before. |
|
|
181
|
-
|
|
182
|
-
Every auth/role/admin constructor of a kind shares that kind's policy. System
|
|
183
|
-
policies are deliberately explicit; choose their trusted capabilities rather
|
|
184
|
-
than synthesizing public credentials. An action forwarding to `runMutation`
|
|
185
|
-
creates a separate registered mutation transaction; it does not turn the
|
|
186
|
-
whole action into a transaction or carry its live role into that mutation's
|
|
187
|
-
JWT-based auth policy.
|
|
188
|
-
|
|
189
|
-
Additions cannot replace native or auth context keys: static checks reject
|
|
190
|
-
known collisions and runtime checks also reject inherited keys before the
|
|
191
|
-
handler executes. Put class-based handles under named properties in the
|
|
192
|
-
returned plain record. Scoped services, required Effect services, Zod raw and
|
|
193
|
-
parsed argument/result types, function references, and error projection retain
|
|
194
|
-
the existing adapter's behavior. Let failures escape registered mutations so
|
|
195
|
-
nested command writes, triggers, and audit writes roll back together.
|
|
196
|
-
|
|
197
|
-
## Shared Effect execution after authentication
|
|
198
|
-
|
|
199
|
-
For an individual constructor, wrap the builder returned by `createAuthFunctions` with
|
|
200
|
-
`effectZodApiBuilder` from `cvx-kit/effect`. The original constructor resolves
|
|
201
|
-
authentication, authorization, tenant policy, and the wrapped database before
|
|
202
|
-
the service provider runs. Keep that ordering when adding Effect; use the Zod
|
|
203
|
-
adapter for these custom constructors.
|
|
204
|
-
|
|
205
|
-
This factory accepts the configured `authMutation` from the setup above. The
|
|
206
|
-
actor service comes from trusted `ctx.actor`, with no actor or organization
|
|
207
|
-
argument from callers:
|
|
208
|
-
|
|
209
|
-
<!-- packed-effect-example -->
|
|
210
|
-
|
|
211
|
-
```ts
|
|
212
|
-
import type { GenericDataModel } from 'convex/server'
|
|
213
|
-
import { createAuthFunctions } from 'cvx-kit/auth'
|
|
214
|
-
import { effectZodApiBuilder } from 'cvx-kit/effect'
|
|
215
|
-
import { Context, Effect } from 'effect'
|
|
216
|
-
import { z } from 'zod'
|
|
217
|
-
|
|
218
|
-
class Actor extends Context.Service<
|
|
219
|
-
Actor,
|
|
220
|
-
{ userId: string; organizationId: string; role: string }
|
|
221
|
-
>()('app/Actor') {}
|
|
222
|
-
|
|
223
|
-
export function createEffectAccountApi<DataModel extends GenericDataModel>(
|
|
224
|
-
baseAuthMutation: ReturnType<typeof createAuthFunctions<DataModel>>['authMutation'],
|
|
225
|
-
) {
|
|
226
|
-
const authMutation = effectZodApiBuilder(baseAuthMutation, {
|
|
227
|
-
services: (ctx) => Context.make(Actor, ctx.actor),
|
|
228
|
-
})
|
|
229
|
-
const currentActor = authMutation({
|
|
230
|
-
args: {},
|
|
231
|
-
returns: z.object({ userId: z.string(), organizationId: z.string() }),
|
|
232
|
-
handler: () =>
|
|
233
|
-
Effect.gen(function* () {
|
|
234
|
-
const actor = yield* Actor
|
|
235
|
-
return { userId: actor.userId, organizationId: actor.organizationId }
|
|
236
|
-
}),
|
|
237
|
-
})
|
|
238
|
-
return { authMutation, currentActor }
|
|
239
|
-
}
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
Call `createEffectAccountApi(authMutation)` with the constructor exported by
|
|
243
|
-
your `convex/functions.ts`, then export `currentActor` from the account API
|
|
244
|
-
module. The factory preserves the configured auth policy; it does not choose
|
|
245
|
-
an identity provider or membership authority.
|
|
246
|
-
|
|
247
|
-
Declare the provider once per constructor, then reuse the builder across
|
|
248
|
-
endpoints. Database services should capture the provider's `ctx.db`, so
|
|
249
|
-
trigger and RLS enforcement also applies to Effect-driven writes. Services
|
|
250
|
-
are created per invocation; do not cache an authenticated context in a module
|
|
251
|
-
singleton. Query providers receive readers; HTTP integrations belong in
|
|
252
|
-
actions.
|
|
253
|
-
|
|
254
|
-
The adapter runs the composed Effect within an invocation scope and awaits
|
|
255
|
-
finalizers before returning. Domain errors can be mapped with handler pipes
|
|
256
|
-
or shared `mapError`; they remain thrown failures at the native boundary so
|
|
257
|
-
mutation writes roll back. Native argument/result validators remain in place.
|
|
258
|
-
Effect's inferred server-side error and service channels are not a typed
|
|
259
|
-
transport error contract. See [the Effect guide](./effect.md) for complete
|
|
260
|
-
domain registries, error mapping, scoped services, and tracing.
|
|
261
|
-
|
|
262
|
-
## The constructor families
|
|
263
|
-
|
|
264
|
-
| Constructor | Visibility | Auth | Extra |
|
|
265
|
-
| --------------------------------------------------------------- | ---------- | ---------------------------- | -------------------------------- |
|
|
266
|
-
| `authQuery` / `authMutation` / `authAction` | public | any authenticated org member | actions live-verify membership |
|
|
267
|
-
| `roleQuery(...roles)` / `roleMutation(...)` / `roleAction(...)` | public | listed roles only | factory — call with your roles |
|
|
268
|
-
| `adminQuery` / `adminMutation` / `adminAction` | public | `config.adminRoles` | pre-built `role*(...adminRoles)` |
|
|
269
|
-
| `systemQuery` | internal | none (trusted caller) | include-equipped, RLS-unwrapped |
|
|
270
|
-
| `systemMutation` | internal | none (trusted caller) | still trigger-wrapped |
|
|
271
|
-
| `systemAction` | internal | none | plain internal action |
|
|
272
|
-
|
|
273
|
-
All are `zCustom*` constructors from convex-helpers, so `args` and `returns`
|
|
274
|
-
take zod schemas directly and compose with `zodTable` masks.
|
|
275
|
-
|
|
276
|
-
## What handlers receive on `ctx`
|
|
277
|
-
|
|
278
|
-
Authenticated constructors extend the ctx with a frozen auth bundle:
|
|
279
|
-
|
|
280
|
-
- `ctx.identity` — the raw `UserIdentity` (JWT claims)
|
|
281
|
-
- `ctx.user.id` — the synchronized principal id (from `getAuthUser`)
|
|
282
|
-
- `ctx.org` — `{ organizationId, role }`
|
|
283
|
-
- `ctx.role` — the mapped role
|
|
284
|
-
- `ctx.actor` — `{ userId, organizationId, role }` — pass this to audits,
|
|
285
|
-
approvals, and commands as the canonical actor reference
|
|
286
|
-
- `ctx.include` — the bounded query builder (below)
|
|
287
|
-
|
|
288
|
-
`systemQuery` and `systemMutation` get `include` (plus trigger wrapping for the
|
|
289
|
-
mutation) but no auth bundle.
|
|
290
|
-
|
|
291
|
-
## Role vocabulary is per-app
|
|
292
|
-
|
|
293
|
-
`mapRole` maps the identity's role slug onto **your** role union; returning
|
|
294
|
-
`null` rejects with `FORBIDDEN`, so unknown roles never pass. The default
|
|
295
|
-
(`defaultRoleMap`) is WorkOS-flavored: `member → writer`, and
|
|
296
|
-
`reader`/`writer`/`admin` pass through.
|
|
297
|
-
|
|
298
|
-
Supply your own vocabulary via the generic:
|
|
299
|
-
|
|
300
|
-
```ts
|
|
301
|
-
createAuthFunctions<DataModel, 'viewer' | 'editor' | 'owner'>({
|
|
302
|
-
mapRole: (slug) => (slug === 'owner' ? 'owner' : slug === 'editor' ? 'editor' : slug ? 'viewer' : null),
|
|
303
|
-
adminRoles: ['owner'],
|
|
304
|
-
...
|
|
305
|
-
})
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
## Queries/mutations trust the JWT; actions re-verify
|
|
309
|
-
|
|
310
|
-
Queries and mutations read org and role from the identity token. Actions —
|
|
311
|
-
which can run long and call third parties — additionally call
|
|
312
|
-
`verifyMembership` **live** before the handler runs, and the fresher
|
|
313
|
-
role/organization from that check replaces the JWT's. Verification **fails
|
|
314
|
-
closed**: any error or org mismatch throws `FORBIDDEN`. Omit
|
|
315
|
-
`verifyMembership` to let actions trust the JWT like queries do.
|
|
316
|
-
|
|
317
|
-
## Resolving org/role from the database (`resolveOrganization`)
|
|
318
|
-
|
|
319
|
-
Some identity providers issue tokens that carry **no org claims** — custom
|
|
320
|
-
credentials, magic codes, or apps that keep memberships in their own tables.
|
|
321
|
-
For those, configure `resolveOrganization` to derive organization authority
|
|
322
|
-
from the app's own data instead of the JWT.
|
|
323
|
-
|
|
324
|
-
When configured, the hook is the organization authority — claim parsing is
|
|
325
|
-
skipped entirely. Returning `null` **or throwing** rejects with `FORBIDDEN`
|
|
326
|
-
(fail closed); a missing identity or user is still `UNAUTHENTICATED`; the
|
|
327
|
-
returned `roleSlug` still passes through `mapRole` like a claim would.
|
|
328
|
-
|
|
329
|
-
The handler must be **action-safe**: in actions the ctx has no `db`, so branch
|
|
330
|
-
on its presence and fall back to an internal query:
|
|
331
|
-
|
|
332
|
-
```ts
|
|
333
|
-
import { internal } from './_generated/api'
|
|
334
|
-
|
|
335
|
-
resolveOrganization: async ({ ctx, user }) => {
|
|
336
|
-
const membership =
|
|
337
|
-
'db' in ctx
|
|
338
|
-
? await ctx.db
|
|
339
|
-
.query('companyUsers')
|
|
340
|
-
.withIndex('by_user', (q) => q.eq('userId', user.id))
|
|
341
|
-
.first()
|
|
342
|
-
: await ctx.runQuery(internal.memberships.byUser, { userId: user.id })
|
|
343
|
-
if (!membership || !membership.active) return null // revoked ⇒ FORBIDDEN
|
|
344
|
-
return { organizationId: membership.orgId, roleSlug: membership.roleSlug }
|
|
345
|
-
},
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
Resolver obligations:
|
|
349
|
-
|
|
350
|
-
- Bind the lookup to the **verified** user — `user.id` (or
|
|
351
|
-
`identity.subject`), never a caller-influenced or non-unique attribute.
|
|
352
|
-
- Resolve revoked/inactive/expired memberships to `null`, so revocation
|
|
353
|
-
actually takes effect fail-closed.
|
|
354
|
-
|
|
355
|
-
Composition: `mapRole` still applies to the returned slug; with tenancy
|
|
356
|
-
configured, `ctx.tenant` derives from the hook's org
|
|
357
|
-
(`security.tenancy.resolve` is unchanged); `verifyMembership` for actions is
|
|
358
|
-
unchanged and receives the hook-resolved `organizationId` — in actions its
|
|
359
|
-
live result supersedes the hook's role/org, exactly as it supersedes claims.
|
|
360
|
-
|
|
361
|
-
Cost: one DB read per authenticated call (claims are free); actions with
|
|
362
|
-
`verifyMembership` configured also pay the live check. When debugging, note
|
|
363
|
-
that a misconfigured hook surfaces as blanket `FORBIDDEN` — errors are
|
|
364
|
-
swallowed fail-closed, never rethrown.
|
|
365
|
-
|
|
366
|
-
## Mutations are structurally trigger-wrapped
|
|
367
|
-
|
|
368
|
-
When `triggers` (or `wrapDB`) is configured, every `authMutation`,
|
|
369
|
-
`roleMutation`, and `systemMutation` runs its writes through
|
|
370
|
-
`triggers.wrapDB(ctx)`. Trigger enforcement (timestamps, append-only,
|
|
371
|
-
no-delete, denormalization) is therefore **structural** — a handler cannot
|
|
372
|
-
forget it, because it never sees an unwrapped `ctx.db`. This is the reason raw
|
|
373
|
-
`mutation` must not be used outside `functions.ts`.
|
|
374
|
-
|
|
375
|
-
## `include()` — every read is bounded
|
|
376
|
-
|
|
377
|
-
`include` wraps a table query in a fluent selector that (a) picks the first
|
|
378
|
-
matching indexed query and (b) refuses unbounded reads. `execute(limit)`
|
|
379
|
-
rejects limits outside `1..maxRows` (default 100, configurable via
|
|
380
|
-
`maxIncludedQueryRows`).
|
|
381
|
-
|
|
382
|
-
```ts
|
|
383
|
-
export const list = authQuery({
|
|
384
|
-
args: { ownerId: zid('users').optional(), limit: z.number() },
|
|
385
|
-
returns: z.array(documents.publicDto),
|
|
386
|
-
handler: (ctx, args) =>
|
|
387
|
-
ctx
|
|
388
|
-
.include(ctx.db.query('documents'))
|
|
389
|
-
.when(args.ownerId, (q, ownerId) =>
|
|
390
|
-
q.withIndex('by_owner', (ix) => ix.eq('ownerId', ownerId)),
|
|
391
|
-
)
|
|
392
|
-
.otherwise((q) => q.withIndex('by_owner'))
|
|
393
|
-
.execute(args.limit, (rows) => rows.map(documents.toPublicDto)),
|
|
394
|
-
})
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
Selector methods:
|
|
398
|
-
|
|
399
|
-
- `.when(value, select)` — if `value` is non-null and nothing selected yet,
|
|
400
|
-
use `select(query, value)`.
|
|
401
|
-
- `.matching(indexName, range?, shouldMatch?)` — index selection with an
|
|
402
|
-
optional guard boolean.
|
|
403
|
-
- `.otherwise(select)` — fallback, returns the raw `Query`.
|
|
404
|
-
- `.resolve()` — selected query or full table scan (escape hatch).
|
|
405
|
-
- `.execute(limit, transform?)` — bounded `take` + optional projection. This
|
|
406
|
-
is the normal terminal.
|
|
407
|
-
|
|
408
|
-
## Row-level security and tenancy (optional)
|
|
409
|
-
|
|
410
|
-
The `security` config adds structural RLS to every `auth*`/`role*`/`admin*`
|
|
411
|
-
constructor — role-level rules (`security.rules`, standalone), tenant
|
|
412
|
-
isolation from a table registry (`security.tenancy`, opt-in), or both
|
|
413
|
-
AND-composed. Queries get a wrapped reader; mutations wrap triggers first,
|
|
414
|
-
then the RLS writer; `system*` stays unwrapped. With tenancy configured, ctx
|
|
415
|
-
gains `ctx.tenant` (actions re-derive it from live verification). Full
|
|
416
|
-
treatment: `tenancy.md`.
|
|
417
|
-
|
|
418
|
-
## Errors
|
|
419
|
-
|
|
420
|
-
All rejections go through the injected `ErrorFactory` (default: throws
|
|
421
|
-
`KitError` with codes `UNAUTHENTICATED`, `FORBIDDEN`,
|
|
422
|
-
`INVALID_REQUEST_BOUNDARY`). Hosts with their own error taxonomy adapt it:
|
|
423
|
-
|
|
424
|
-
```ts
|
|
425
|
-
errors: { throw: (input) => { throw App.errors.from(input) } }
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
## Rules of thumb
|
|
429
|
-
|
|
430
|
-
1. Build constructors once in `convex/functions.ts`; import them everywhere
|
|
431
|
-
else. Raw `_generated/server` builders appear nowhere else.
|
|
432
|
-
2. Public functions: `auth*`/`role*`/`admin*`. Scheduled/internal work:
|
|
433
|
-
`system*`. Never expose an unauthenticated public function unless it is
|
|
434
|
-
deliberately anonymous (and then document why).
|
|
435
|
-
3. Reads go through `ctx.include(...)` with an explicit limit.
|
|
436
|
-
4. Use `ctx.actor` as the actor reference in audits and approvals — never
|
|
437
|
-
re-derive identity in a handler.
|
|
438
|
-
|
|
439
|
-
Optional request context can be added through the existing Effect API adapter's
|
|
440
|
-
`context` callback. Bind selected registries with `commands.withContext(value)`
|
|
441
|
-
and consume `ctx.commands.exec(name, input)` through the application's existing
|
|
442
|
-
function helper names. Keep dependencies local to each feature; explicit execution
|
|
443
|
-
and input-only command handlers remain supported. See [optional context injection](./command-context.md).
|
package/docs/command-context.md
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
# Optional command context injection
|
|
2
|
-
|
|
3
|
-
Bind context once at a function boundary when it makes endpoint code clearer.
|
|
4
|
-
`withContext(value)` accepts whatever the registry's resolver accepts: a number,
|
|
5
|
-
a service bundle, a request context, or another bound command group. It returns
|
|
6
|
-
a small handle whose `exec(name, input)` still produces a lazy Effect. Binding
|
|
7
|
-
neither resolves context nor executes a command. Explicit
|
|
8
|
-
`commands.exec(name, input, context)` remains supported.
|
|
9
|
-
|
|
10
|
-
## Keep existing function names
|
|
11
|
-
|
|
12
|
-
Add context through `effectZodApiBuilder` around the existing custom constructor.
|
|
13
|
-
Export it under your application's ordinary `authMutation`, `adminQuery`,
|
|
14
|
-
`organizationMutation`, or other function name. These are application choices;
|
|
15
|
-
the library does not require a new helper family. The same option works on
|
|
16
|
-
`effectApiBuilder` for native builders. `services` is optional when your Effects
|
|
17
|
-
need no services.
|
|
18
|
-
|
|
19
|
-
The complete example below uses a custom authenticated mutation to make the
|
|
20
|
-
context flow standalone. A kit application can supply
|
|
21
|
-
`createAuthFunctions(...).authMutation` instead. Its authenticated, secured
|
|
22
|
-
context is constructed before the additions callback runs.
|
|
23
|
-
|
|
24
|
-
<!-- packed-effect-example -->
|
|
25
|
-
|
|
26
|
-
```ts
|
|
27
|
-
import { Effect } from 'effect'
|
|
28
|
-
import { mutationGeneric } from 'convex/server'
|
|
29
|
-
import { zCustomMutation } from 'convex-helpers/server/zod4'
|
|
30
|
-
import { createEffectFoundation, effectZodApiBuilder } from 'cvx-kit/effect'
|
|
31
|
-
import { z } from 'zod'
|
|
32
|
-
|
|
33
|
-
const foundation = createEffectFoundation({
|
|
34
|
-
observability: {
|
|
35
|
-
enabled: false,
|
|
36
|
-
classifyError: () => ({ outcome: 'failed', errorCode: 'COMMAND_FAILED' }),
|
|
37
|
-
},
|
|
38
|
-
// Standalone demonstration only: production applications provide their audit writer.
|
|
39
|
-
writeAudit: () => undefined,
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
const notifications = foundation.Command({
|
|
43
|
-
context: (dependencies: { format: (message: string) => string }) => dependencies,
|
|
44
|
-
operations: ({ command }) => ({
|
|
45
|
-
format: command({
|
|
46
|
-
input: z.string(), result: z.string(), classification: 'business',
|
|
47
|
-
handler: (message, context) => context.format(message),
|
|
48
|
-
audit: () => null,
|
|
49
|
-
}),
|
|
50
|
-
}),
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
const tasks = foundation.Command({
|
|
54
|
-
context: (dependencies: {
|
|
55
|
-
nextNumber: () => number,
|
|
56
|
-
notifications: ReturnType<typeof notifications.withContext>,
|
|
57
|
-
}) => dependencies,
|
|
58
|
-
operations: ({ command }) => ({
|
|
59
|
-
create: command({
|
|
60
|
-
input: z.string(), result: z.string(), classification: 'business',
|
|
61
|
-
handler: (title, context) => context.notifications.exec(
|
|
62
|
-
'format', `${context.nextNumber()}:${title}`,
|
|
63
|
-
),
|
|
64
|
-
audit: () => null,
|
|
65
|
-
}),
|
|
66
|
-
}),
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
const baseAuthMutation = zCustomMutation(mutationGeneric, {
|
|
70
|
-
args: {},
|
|
71
|
-
input: async (ctx) => {
|
|
72
|
-
const identity = await ctx.auth.getUserIdentity()
|
|
73
|
-
if (!identity) throw Error('UNAUTHORIZED')
|
|
74
|
-
return { ctx: { actor: identity.subject }, args: {} }
|
|
75
|
-
},
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
// In a real app, keep this composition in the tasks feature's functions module.
|
|
79
|
-
export const authMutation = effectZodApiBuilder(baseAuthMutation, {
|
|
80
|
-
context: (ctx) => {
|
|
81
|
-
const messages = notifications.withContext({
|
|
82
|
-
format: (message) => `${ctx.actor}:${message}`,
|
|
83
|
-
})
|
|
84
|
-
return {
|
|
85
|
-
tasks: tasks.withContext({ nextNumber: () => 42, notifications: messages }),
|
|
86
|
-
}
|
|
87
|
-
},
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
export const createTask = authMutation({
|
|
91
|
-
args: { title: z.string() },
|
|
92
|
-
returns: z.string(),
|
|
93
|
-
handler: (ctx, input) => ctx.tasks.exec('create', input.title),
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
// An input-only command needs no domain dependencies.
|
|
97
|
-
const echo = foundation.Command({
|
|
98
|
-
context: () => undefined,
|
|
99
|
-
operations: ({ command }) => ({
|
|
100
|
-
echo: command({
|
|
101
|
-
input: z.string(), result: z.string(), classification: 'business',
|
|
102
|
-
handler: (input) => input, audit: () => null,
|
|
103
|
-
}),
|
|
104
|
-
}),
|
|
105
|
-
})
|
|
106
|
-
export const echoEffect = echo.exec('echo', 'hello', undefined)
|
|
107
|
-
export const boundEchoEffect = echo.withContext(undefined).exec('echo', 'hello')
|
|
108
|
-
// A program can compose further work; the API adapter owns execution.
|
|
109
|
-
export const composedEcho = Effect.map(boundEchoEffect, (value) => value.length)
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
Context additions are optional and cannot replace existing fields, including
|
|
113
|
-
`db`, `auth`, or a custom constructor's authenticated actor. Type checking rejects
|
|
114
|
-
known collisions with declared fields. Runtime checking also protects inherited
|
|
115
|
-
properties (such as `toString`) and keys from broadly typed records or erased
|
|
116
|
-
types; those collisions cannot always be determined from the declared context
|
|
117
|
-
type. Additions can
|
|
118
|
-
return a plain record of own properties, or a Promise or Effect of that record.
|
|
119
|
-
Wrap class instances and services in named properties, such as `{ pricingService }`,
|
|
120
|
-
rather than returning an instance as the additions object. The adapter provides services first, then
|
|
121
|
-
runs initialization and the handler in the same invocation scope. Missing
|
|
122
|
-
initializer services are rejected when registering the endpoint, just like
|
|
123
|
-
missing handler services. Initialization failures use the configured error
|
|
124
|
-
projection and release acquired resources.
|
|
125
|
-
|
|
126
|
-
## Applications with 25–50 entities
|
|
127
|
-
|
|
128
|
-
Organize by feature or workflow rather than making every entity a property of a
|
|
129
|
-
single global context. A tasks helper can bind tasks and notifications; a billing
|
|
130
|
-
helper can bind billing and pricing. Each registry declares its own narrow
|
|
131
|
-
resolver input. Share stable service implementations at module scope, and build
|
|
132
|
-
request-dependent handles within the context callback.
|
|
133
|
-
|
|
134
|
-
Keep shared identity or tenant information small. Inject a business function or
|
|
135
|
-
service only into the features that use it. Pass bound commands only where a
|
|
136
|
-
workflow calls them; avoid mutual command dependencies. Bindings hold references
|
|
137
|
-
and do not initialize the referenced registry. Large dependency graphs are
|
|
138
|
-
application architecture choices, not a required library container.
|
|
139
|
-
|
|
140
|
-
If a feature needs no injected commands, omit `context` additions. If an endpoint
|
|
141
|
-
benefits from explicit execution, pass its context directly. An input-only
|
|
142
|
-
handler can ignore domain context, but permission and audit policies may still
|
|
143
|
-
need the original trusted invocation context. Preserve those policies when
|
|
144
|
-
choosing the registry resolver and binding input.
|
|
145
|
-
|
|
146
|
-
Nested calls return Effects and compose in the current program. Do not call
|
|
147
|
-
`Effect.runPromise` inside command handlers; the function adapter runs the
|
|
148
|
-
complete program once. Command schema checks, permissions, audit, and completion
|
|
149
|
-
remain active for each nested command. Keep database mutation work in a mutation
|
|
150
|
-
boundary so Convex can roll it back if execution or result validation fails.
|