cvx-kit 0.0.2 → 0.0.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.
@@ -0,0 +1,179 @@
1
+ # `cvx-kit/tenancy` — row-level security, with tenancy as an opt-in layer
2
+
3
+ Row-level security and tenancy are **separate, composable features** of the
4
+ same `security` config on `createAuthFunctions`:
5
+
6
+ - **`security.rules` alone** — role-level / module-level RLS with no tenancy
7
+ at all: gate writes by role, freeze evidence tables, ownership-gate reads.
8
+ `defaultPolicy` stays `'allow'`; unlisted tables behave normally.
9
+ - **`security.tenancy` alone** — pure tenant isolation from a table registry.
10
+ - **Both** — rules are AND-composed with tenant isolation.
11
+
12
+ A single-org app that only wants role-gated writes configures `rules` and
13
+ never touches tenancy. Nothing below is required unless you opt into it.
14
+
15
+ Multi-tenancy itself is a structural machine, not a per-handler
16
+ convention. The model: **tenant = the identity provider's organization id**,
17
+ stamped as a server-owned `tenant` string field on every tenant-scoped row,
18
+ always derived from the verified identity — never accepted as an argument.
19
+ Isolation is defense-in-depth; each layer covers a hole in the previous one:
20
+
21
+ 1. **Schema** — `tenantTable` injects the field and strips it from every
22
+ write boundary.
23
+ 2. **Row-level security** — deny-default rules generated mechanically from
24
+ one table registry, wrapped into the auth constructors.
25
+ 3. **Triggers** — a post-image invariant forbids tenant reassignment (RLS
26
+ authorizes against the pre-image only).
27
+ 4. **Reference guards** — client-supplied ids re-verify ownership without
28
+ disclosing existence.
29
+
30
+ Single-org apps skip all of this: every piece is opt-in, and
31
+ `createAuthFunctions` without a `security` config behaves exactly as before.
32
+
33
+ ## The registry is the design
34
+
35
+ One list of table names drives everything. Adding a table to the registry is
36
+ the entire onboarding — there is no second per-table step to forget:
37
+
38
+ ```ts
39
+ // convex/tenancy.ts (or alongside triggers.ts)
40
+ export const TENANT_TABLES = ['<entities>', '<others>'] as const
41
+ ```
42
+
43
+ - `security.tenancy.tables: TENANT_TABLES` → RLS rules generated per table
44
+ - `tenantOwnership(triggers, ...TENANT_TABLES)` → reassignment guard per table
45
+ - each listed table is declared with `tenantTable(...)` in its module
46
+
47
+ Recommended architecture test: assert every `tenantTable` in the codebase
48
+ appears in `TENANT_TABLES`, and vice versa.
49
+
50
+ ## Wiring
51
+
52
+ ```ts
53
+ // domain/<module>/schema.ts — tenant injected at the schema boundary
54
+ export const <entities> = tenantTable('<entities>', (id) => ({
55
+ name: z.string(), ownerId: id('users'),
56
+ }), { publicFields: ['name'] })
57
+ // tenant: excluded from insertSchema/updateSchema/commandInput,
58
+ // in publicDto only if explicitly allowlisted
59
+
60
+ // domain/<module>/table.ts — every index tenant-prefixed
61
+ <entities>: <entities>.table.index('by_tenant', ['tenant'])
62
+ .index('by_tenant_owner', ['tenant', 'ownerId'])
63
+
64
+ // convex/triggers.ts
65
+ tenantOwnership(triggers, ...TENANT_TABLES)
66
+
67
+ // convex/functions.ts
68
+ createAuthFunctions<DataModel>({
69
+ ...,
70
+ security: {
71
+ tenancy: {
72
+ tables: TENANT_TABLES,
73
+ // optional; defaults to bundle.org.organizationId
74
+ resolve: (bundle) => bundle.org.organizationId,
75
+ },
76
+ // role-level security, AND-composed with tenant isolation
77
+ rules: (bundle) => ({
78
+ '<entities>': { modify: async () => bundle.role === '<privileged>' },
79
+ }),
80
+ // defaults to 'deny' when tenancy is configured
81
+ defaultPolicy: 'deny',
82
+ },
83
+ })
84
+ ```
85
+
86
+ ## What the constructors do with it
87
+
88
+ - Every authenticated ctx gains **`ctx.tenant`** (also on the frozen bundle) —
89
+ resolved via `security.tenancy.resolve`, defaulting to the organization id.
90
+ Actions re-derive it from the live-verified membership.
91
+ - **Queries** get a wrapped reader: rows failing `read` are invisible —
92
+ filtered from every `get`/`query`, even a full table scan.
93
+ - **Mutations** wrap triggers first, then the RLS writer — `insert`/`modify`
94
+ failing a rule throws. Order matters and is handled for you.
95
+ - **`system*` constructors stay unwrapped** — trusted internal paths reach
96
+ every table. This is deliberate: schedulers and callbacks receive the
97
+ server-derived tenant as data, never as authority.
98
+ - With tenancy configured, `defaultPolicy` defaults to **deny**: tables
99
+ outside the registry are unreachable from `auth*`/`role*`/`admin*`
100
+ functions. Global/platform tables are reached from `system*` paths, or by
101
+ explicitly granting rules for them.
102
+
103
+ Handlers stamp inserts from the ctx — the schema forbids clients supplying
104
+ it, RLS rejects a wrong value, and the trigger forbids changing it later:
105
+
106
+ ```ts
107
+ await ctx.db.insert('<entities>', { ...args, tenant: ctx.tenant })
108
+ ```
109
+
110
+ ## Role-level security — with or without tenancy
111
+
112
+ `security.rules` receives the full auth bundle and returns convex-helpers
113
+ `Rules`. It works standalone (no tenancy configured) or AND-composed with
114
+ tenant isolation (`composeRules`) — a row must pass **every** rule set that
115
+ defines the operation. Use it for role-gated writes, ownership-gated reads,
116
+ or state-machine write locks:
117
+
118
+ ```ts
119
+ // rules-only, single-org app: no tenancy anywhere
120
+ security: {
121
+ rules: (bundle) => ({
122
+ '<entities>': { modify: async () => bundle.role !== '<readonly-role>' },
123
+ }),
124
+ }
125
+ ```
126
+
127
+ ```ts
128
+ rules: (bundle) => ({
129
+ '<entities>': {
130
+ modify: async (_ctx, doc) =>
131
+ bundle.role === 'admin' || doc.ownerId === bundle.actor.userId,
132
+ },
133
+ '<evidence>': { modify: async () => false }, // frozen via RLS as well as trigger
134
+ })
135
+ ```
136
+
137
+ ## Client-supplied ids
138
+
139
+ RLS makes foreign rows invisible to reads, but any id a client sends should
140
+ still be re-verified — and without disclosing existence:
141
+
142
+ ```ts
143
+ import { requireTenantReference } from 'cvx-kit/tenancy'
144
+
145
+ const row = await requireTenantReference(ctx.tenant, () => ctx.db.get(args.id))
146
+ // missing row and foreign row fail identically: REFERENCE_NOT_FOUND
147
+ ```
148
+
149
+ `assertTenantOwned(tenant, doc)` is the internal-path variant
150
+ (CROSS_TENANT_REFERENCE, for rows you already loaded on trusted paths).
151
+
152
+ ## Footguns
153
+
154
+ 1. **Actions have no db** — an action passing work to internal functions must
155
+ pass `ctx.tenant` (server-derived) as data; internal functions must never
156
+ accept a caller-chosen tenant on a public path.
157
+ 2. **Provider ids are global** (memberships, invitations in WorkOS-style
158
+ providers): org-scoped mutations against the provider must re-check the
159
+ id belongs to the active org, or you ship a cross-tenant IDOR.
160
+ 3. **Tables outside the registry get none of this.** Keep the global-table
161
+ list short and named; every one is a per-handler-discipline zone.
162
+ 4. **`t.run(...)` in convex-test bypasses RLS and triggers** — same rule as
163
+ always: seeding only.
164
+ 5. **Return "not found", never "forbidden"** for cross-tenant references —
165
+ existence is also tenant data.
166
+
167
+ ## Module maps
168
+
169
+ `createModule` (from `cvx-kit/zod-table`) merges per-module table maps into
170
+ the schema and rejects a table declared by two modules:
171
+
172
+ ```ts
173
+ // convex/domain/table.ts
174
+ export const domainTables = createModule(
175
+ <moduleA>Tables, <moduleB>Tables, <moduleC>Tables,
176
+ )
177
+ // convex/schema.ts
178
+ export default defineSchema(domainTables)
179
+ ```
package/docs/triggers.md CHANGED
@@ -48,6 +48,11 @@ createAuthFunctions<DataModel>({ ..., triggers })
48
48
  votes/history/audit-style evidence tables.
49
49
  - **`noDelete(triggers, ...tables)`** — forbids hard deletes only; updates
50
50
  pass. Pair with `archivedAt` for soft deletion.
51
+ - **`tenantOwnership(triggers, ...tables)`** — the tenancy post-image
52
+ invariant: inserts must carry a non-empty `tenant`, updates may never
53
+ reassign it. Register for every table in the tenancy registry (row-level
54
+ security checks the pre-image only — this closes that hole). See
55
+ `tenancy.md`.
51
56
 
52
57
  `Triggers`, `Change`, and `Trigger` are re-exported for custom registrations.
53
58
 
package/docs/zod-table.md CHANGED
@@ -93,6 +93,29 @@ Think of the options as three independent allowlists over the same shape:
93
93
  All derived objects are `.strict()` — unknown keys are rejected, in both
94
94
  directions.
95
95
 
96
+ ## `tenantTable` — tenant-owned rows
97
+
98
+ `tenantTable` is `zodTable` plus a server-owned `tenant` string field —
99
+ injected exactly like timestamps: present in storage, excluded from
100
+ `insertSchema`/`updateSchema`/`commandInput`, in `publicDto` only by explicit
101
+ allowlist. Handlers stamp it from `ctx.tenant`; row-level security matches on
102
+ it; the `tenantOwnership` trigger forbids reassigning it. See `tenancy.md`.
103
+
104
+ ```ts
105
+ export const <entities> = tenantTable('<entities>', (id) => ({ ... }), {
106
+ commandFields: [...], publicFields: [...],
107
+ })
108
+ ```
109
+
110
+ ## `createModule` — module table maps
111
+
112
+ Merges per-module table maps into the application schema, throwing when two
113
+ modules declare the same table — the combinator for `domain/table.ts`:
114
+
115
+ ```ts
116
+ export default defineSchema(createModule(<moduleA>Tables, <moduleB>Tables))
117
+ ```
118
+
96
119
  ## `zodVariantTable` — discriminated unions
97
120
 
98
121
  For tables whose rows are a discriminated union (events, polymorphic
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cvx-kit",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
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",
@@ -25,6 +25,10 @@
25
25
  "types": "./dist/auth.d.mts",
26
26
  "import": "./dist/auth.mjs"
27
27
  },
28
+ "./tenancy": {
29
+ "types": "./dist/tenancy.d.mts",
30
+ "import": "./dist/tenancy.mjs"
31
+ },
28
32
  "./triggers": {
29
33
  "types": "./dist/triggers.d.mts",
30
34
  "import": "./dist/triggers.mjs"
package/src/auth.ts CHANGED
@@ -22,7 +22,16 @@ import type {
22
22
  UserIdentity,
23
23
  } from 'convex/server'
24
24
  import type { Triggers } from 'convex-helpers/server/triggers'
25
+ import type { TableNamesInDataModel } from 'convex/server'
25
26
  import { defaultErrors, type ErrorFactory } from './errors'
27
+ import {
28
+ composeRules,
29
+ createTenantRules,
30
+ wrapDatabaseReader,
31
+ wrapDatabaseWriter,
32
+ type RLSConfig,
33
+ type Rules,
34
+ } from './tenancy'
26
35
 
27
36
  /** The default vocabulary; every app can supply its own via the Role generic. */
28
37
  export type DefaultRole = 'reader' | 'writer' | 'admin'
@@ -39,8 +48,43 @@ export type AuthBundle<Role extends string> = Readonly<{
39
48
  org: Readonly<{ organizationId: string; role: Role }>
40
49
  role: Role
41
50
  actor: Actor<Role>
51
+ /** The resolved tenant — security.tenancy.resolve, or the organization id. */
52
+ tenant: string
42
53
  }>
43
54
 
55
+ /**
56
+ * Row-level security configuration. Tenant isolation and role-level rules
57
+ * compose (AND) onto the same wrapped database: queries get a wrapped
58
+ * reader, mutations get triggers first, then a wrapped writer.
59
+ */
60
+ export type SecurityConfig<
61
+ DataModel extends GenericDataModel,
62
+ Role extends string,
63
+ > = {
64
+ /**
65
+ * Tenant isolation. `tables` is THE registry: every listed table gets
66
+ * read/insert/modify gated on row.tenant === ctx.tenant. Pair with
67
+ * tenantTable (schema) and tenantOwnership (triggers) on the same list.
68
+ */
69
+ tenancy?: {
70
+ tables: readonly TableNamesInDataModel<DataModel>[]
71
+ /** Maps the auth bundle to the tenant; defaults to org.organizationId. */
72
+ resolve?: (bundle: Omit<AuthBundle<Role>, 'tenant'>) => string
73
+ }
74
+ /**
75
+ * Role-level (or any policy) rules derived from the authenticated bundle,
76
+ * composed AND-wise with tenant isolation. E.g. gate `modify` on a table
77
+ * to admin roles.
78
+ */
79
+ rules?: (bundle: AuthBundle<Role>) => Rules<unknown, DataModel>
80
+ /**
81
+ * Policy for tables with no rule. Defaults to 'deny' when tenancy is
82
+ * configured (unlisted tables are unreachable — fail closed), 'allow'
83
+ * otherwise.
84
+ */
85
+ defaultPolicy?: RLSConfig['defaultPolicy']
86
+ }
87
+
44
88
  type AnyAuthContext<DataModel extends GenericDataModel> =
45
89
  | GenericQueryCtx<DataModel>
46
90
  | GenericMutationCtx<DataModel>
@@ -90,6 +134,12 @@ export type AuthFunctionsConfig<
90
134
  ) => GenericMutationCtx<DataModel>
91
135
  /** Upper bound for include() reads. */
92
136
  maxIncludedQueryRows?: number
137
+ /**
138
+ * Row-level security: tenant isolation (from a table registry) and
139
+ * role-level rules, applied structurally inside every auth*, role*, and
140
+ * admin* constructor. system* constructors stay unwrapped (trusted).
141
+ */
142
+ security?: SecurityConfig<DataModel, Role>
93
143
  }
94
144
 
95
145
  /** WorkOS-style default: `member` writes; reader/writer/admin pass through. */
@@ -223,15 +273,68 @@ export function createAuthFunctions<
223
273
  : undefined
224
274
  const role = config.mapRole(organizationRole)
225
275
  if (!role) return errors.throw({ code: 'FORBIDDEN' })
226
- return Object.freeze({
276
+ const base = {
227
277
  identity,
228
278
  user: Object.freeze({ id: user.id }),
229
279
  org: Object.freeze({ organizationId, role }),
230
280
  role,
231
281
  actor: Object.freeze({ userId: user.id, organizationId, role }),
282
+ }
283
+ return Object.freeze({
284
+ ...base,
285
+ tenant: config.security?.tenancy?.resolve?.(base) ?? organizationId,
232
286
  })
233
287
  }
234
288
 
289
+ const securityConfig: RLSConfig | undefined = config.security
290
+ ? {
291
+ defaultPolicy:
292
+ config.security.defaultPolicy ??
293
+ (config.security.tenancy ? 'deny' : 'allow'),
294
+ }
295
+ : undefined
296
+
297
+ function securityRules(
298
+ bundle: AuthBundle<Role>,
299
+ ): Rules<unknown, DataModel> | undefined {
300
+ const security = config.security
301
+ if (!security) return undefined
302
+ const sets: Rules<unknown, DataModel>[] = []
303
+ if (security.tenancy) {
304
+ sets.push(
305
+ createTenantRules<DataModel>(bundle.tenant, security.tenancy.tables),
306
+ )
307
+ }
308
+ if (security.rules) sets.push(security.rules(bundle))
309
+ return sets.length === 1 ? sets[0] : composeRules(...sets)
310
+ }
311
+
312
+ function secureReader(
313
+ ctx: GenericQueryCtx<DataModel>,
314
+ bundle: AuthBundle<Role>,
315
+ ): GenericQueryCtx<DataModel> {
316
+ const rules = securityRules(bundle)
317
+ if (!rules) return ctx
318
+ return {
319
+ ...ctx,
320
+ db: wrapDatabaseReader({}, ctx.db, rules as never, securityConfig),
321
+ }
322
+ }
323
+
324
+ function secureWriter(
325
+ ctx: GenericMutationCtx<DataModel>,
326
+ bundle: AuthBundle<Role>,
327
+ ): GenericMutationCtx<DataModel> {
328
+ const rules = securityRules(bundle)
329
+ if (!rules) return ctx
330
+ // Triggers wrap first (ctx already trigger-wrapped by the caller),
331
+ // then row-level security wraps the triggered db — order matters.
332
+ return {
333
+ ...ctx,
334
+ db: wrapDatabaseWriter({}, ctx.db, rules as never, securityConfig),
335
+ }
336
+ }
337
+
235
338
  async function verifiedMembership(userId: string, organizationId: string) {
236
339
  if (!config.verifyMembership) return null
237
340
  try {
@@ -253,15 +356,17 @@ export function createAuthFunctions<
253
356
  }
254
357
 
255
358
  async function queryInput(ctx: GenericQueryCtx<DataModel>) {
359
+ const bundle = await authenticatedUser(ctx)
256
360
  return {
257
- ctx: { ...ctx, include, ...(await authenticatedUser(ctx)) },
361
+ ctx: { ...secureReader(ctx, bundle), include, ...bundle },
258
362
  args: {},
259
363
  }
260
364
  }
261
365
 
262
366
  async function mutationInput(ctx: GenericMutationCtx<DataModel>) {
367
+ const bundle = await authenticatedUser(ctx)
263
368
  return {
264
- ctx: { ...wrapDB(ctx), include, ...(await authenticatedUser(ctx)) },
369
+ ctx: { ...secureWriter(wrapDB(ctx), bundle), include, ...bundle },
265
370
  args: {},
266
371
  }
267
372
  }
@@ -275,20 +380,28 @@ export function createAuthFunctions<
275
380
  if (!membership) return { ctx: { ...ctx, ...authenticated }, args: {} }
276
381
  const role = config.mapRole(membership.roleSlug)
277
382
  if (!role) return errors.throw({ code: 'FORBIDDEN' })
383
+ const verified = {
384
+ identity: authenticated.identity,
385
+ user: authenticated.user,
386
+ org: Object.freeze({
387
+ organizationId: membership.organizationId,
388
+ role,
389
+ }),
390
+ role,
391
+ actor: Object.freeze({
392
+ userId: authenticated.user.id,
393
+ organizationId: membership.organizationId,
394
+ role,
395
+ }),
396
+ }
278
397
  return {
279
398
  ctx: {
280
399
  ...ctx,
281
- ...authenticated,
282
- org: Object.freeze({
283
- organizationId: membership.organizationId,
284
- role,
285
- }),
286
- role,
287
- actor: Object.freeze({
288
- userId: authenticated.user.id,
289
- organizationId: membership.organizationId,
290
- role,
291
- }),
400
+ ...verified,
401
+ // Tenant re-derives from the live-verified organization.
402
+ tenant:
403
+ config.security?.tenancy?.resolve?.(verified) ??
404
+ membership.organizationId,
292
405
  },
293
406
  args: {},
294
407
  }
@@ -35,12 +35,41 @@ export type AuditedOperation = Readonly<{
35
35
  command: Readonly<{ parse: (value: unknown) => unknown }>
36
36
  result: Readonly<{ parse: (value: unknown) => unknown }>
37
37
  classification: string
38
+ /**
39
+ * Permission slug required to execute this operation. Checked through the
40
+ * Foundation's injected checkPermission BEFORE guards and the handler;
41
+ * declaring a permission without injecting a checker fails closed.
42
+ */
43
+ permission?: string
44
+ /**
45
+ * Per-operation precondition, after the permission check and the registry
46
+ * default guard, before the handler. Throw to deny — nothing has run yet.
47
+ */
48
+ guard?: (context: never, command: never) => MaybePromise<void>
49
+ /**
50
+ * Allowlist of aggregate types this operation's audit may reference. When
51
+ * declared, an audit entry whose aggregate.type is not listed throws —
52
+ * the audit vocabulary is enforced, not advisory.
53
+ */
54
+ aggregates?: readonly string[]
38
55
  audit: (
39
56
  resolution: Readonly<{ command: never; result: never }>,
40
57
  context: never,
41
58
  ) => MaybePromise<Omit<AuditEntryInput, 'classification'> | null>
42
59
  }>
43
60
 
61
+ /** Registry-wide defaults applied to every operation of one Command. */
62
+ export type CommandDefaults = Readonly<{
63
+ /** Runs before every operation's own guard. Throw to deny. */
64
+ guard?: (context: never) => MaybePromise<void>
65
+ }>
66
+
67
+ /** Injected permission policy: throw to deny. Host semantics, kit ordering. */
68
+ export type PermissionChecker = (
69
+ context: never,
70
+ input: Readonly<{ permission: string; operation: string }>,
71
+ ) => MaybePromise<void>
72
+
44
73
  export type AuditedRegistry = Readonly<Record<string, AuditedOperation>>
45
74
 
46
75
  export type FoundationOptions = Readonly<{
@@ -52,6 +81,12 @@ export type FoundationOptions = Readonly<{
52
81
  */
53
82
  writeAudit?: AuditWriter
54
83
  }>
84
+ /**
85
+ * Permission policy for operations that declare a `permission`. Runs
86
+ * before guards and the handler; throw to deny. An operation with a
87
+ * permission but no injected checker fails closed.
88
+ */
89
+ checkPermission?: PermissionChecker
55
90
  }>
56
91
 
57
92
  type FoundationComponentApi = Readonly<{
@@ -68,6 +103,13 @@ class BoundCommand<Context, const Operations extends AuditedRegistry> {
68
103
  readonly #kernel: CommandKernelClass<Context, Operations>
69
104
  readonly #observability: Observability
70
105
  readonly #writeAudit: AuditWriter
106
+ readonly #checkPermission: PermissionChecker | undefined
107
+ readonly #defaults: CommandDefaults
108
+
109
+ /** Declared aggregate types per operation — introspectable for tests. */
110
+ readonly aggregates: Readonly<{
111
+ [Key in OperationKey<Operations>]: Operations[Key]['aggregates']
112
+ }>
71
113
 
72
114
  static operation<const Definition extends AuditedOperation>(
73
115
  definition: Definition,
@@ -77,10 +119,25 @@ class BoundCommand<Context, const Operations extends AuditedRegistry> {
77
119
 
78
120
  constructor(
79
121
  operations: Operations,
80
- deps: { observability: Observability; writeAudit: AuditWriter },
122
+ deps: {
123
+ observability: Observability
124
+ writeAudit: AuditWriter
125
+ checkPermission?: PermissionChecker
126
+ defaults?: CommandDefaults
127
+ },
81
128
  ) {
82
129
  this.#observability = deps.observability
83
130
  this.#writeAudit = deps.writeAudit
131
+ this.#checkPermission = deps.checkPermission
132
+ this.#defaults = deps.defaults ?? {}
133
+ this.aggregates = Object.freeze(
134
+ Object.fromEntries(
135
+ Object.entries(operations).map(([operation, definition]) => [
136
+ operation,
137
+ definition.aggregates,
138
+ ]),
139
+ ),
140
+ ) as this['aggregates']
84
141
  this.#kernel = new CommandKernelClass<Context, Operations>({
85
142
  operations,
86
143
  execute: (execution) => this.#execute(execution),
@@ -106,12 +163,35 @@ class BoundCommand<Context, const Operations extends AuditedRegistry> {
106
163
  classification: execution.definition.classification,
107
164
  },
108
165
  async () => {
166
+ // Guards run before the handler: permission → default → operation.
167
+ const permission = execution.definition.permission
168
+ if (permission !== undefined) {
169
+ if (!this.#checkPermission) {
170
+ throw new CommandPermissionError(execution.operation)
171
+ }
172
+ await this.#checkPermission(execution.context as never, {
173
+ permission,
174
+ operation: execution.operation,
175
+ })
176
+ }
177
+ await this.#defaults.guard?.(execution.context as never)
178
+ await execution.definition.guard?.(
179
+ execution.context as never,
180
+ execution.command as never,
181
+ )
109
182
  const result = await execution.run()
110
183
  const audit = await execution.definition.audit(
111
184
  { command: execution.command, result } as never,
112
185
  execution.context as never,
113
186
  )
114
187
  if (audit) {
188
+ const allowed = execution.definition.aggregates
189
+ if (allowed && !allowed.includes(audit.aggregate.type)) {
190
+ throw new CommandAggregateError(
191
+ execution.operation,
192
+ audit.aggregate.type,
193
+ )
194
+ }
115
195
  await this.#writeAudit(execution.context as never, {
116
196
  ...audit,
117
197
  classification: execution.definition.classification,
@@ -123,6 +203,30 @@ class BoundCommand<Context, const Operations extends AuditedRegistry> {
123
203
  }
124
204
  }
125
205
 
206
+ /** The audit referenced an aggregate type outside the operation's allowlist. */
207
+ class CommandAggregateError extends Error {
208
+ readonly code = 'COMMAND_AGGREGATE_NOT_DECLARED'
209
+ readonly name = 'CommandAggregateError'
210
+
211
+ constructor(operation: string, aggregateType: string) {
212
+ super(
213
+ `Operation "${operation}" audited aggregate type "${aggregateType}" outside its declared aggregates`,
214
+ )
215
+ }
216
+ }
217
+
218
+ /** Fails closed: an operation declared a permission but no checker exists. */
219
+ class CommandPermissionError extends Error {
220
+ readonly code = 'COMMAND_PERMISSION_NOT_CONFIGURED'
221
+ readonly name = 'CommandPermissionError'
222
+
223
+ constructor(operation: string) {
224
+ super(
225
+ `Operation "${operation}" declares a permission but the Foundation has no checkPermission`,
226
+ )
227
+ }
228
+ }
229
+
126
230
  export type ApplicationCommand<
127
231
  Context,
128
232
  Operations extends AuditedRegistry,
@@ -131,6 +235,7 @@ export type ApplicationCommand<
131
235
  type CommandConstructor = {
132
236
  new <Context, const Operations extends AuditedRegistry>(
133
237
  operations: Operations,
238
+ defaults?: CommandDefaults,
134
239
  ): BoundCommand<Context, Operations>
135
240
  operation: (typeof BoundCommand)['operation']
136
241
  }
@@ -153,12 +258,18 @@ export class Foundation<
153
258
  const observability = new Observability(options.observability)
154
259
  this.observability = observability
155
260
  const writeAudit = options.observability.writeAudit ?? (() => undefined)
261
+ const checkPermission = options.checkPermission
156
262
  this.Command = class <
157
263
  Context,
158
264
  const Operations extends AuditedRegistry,
159
265
  > extends BoundCommand<Context, Operations> {
160
- constructor(operations: Operations) {
161
- super(operations, { observability, writeAudit })
266
+ constructor(operations: Operations, defaults?: CommandDefaults) {
267
+ super(operations, {
268
+ observability,
269
+ writeAudit,
270
+ checkPermission,
271
+ defaults,
272
+ })
162
273
  }
163
274
  }
164
275
  }
package/src/index.ts CHANGED
@@ -4,12 +4,23 @@ export {
4
4
  type ErrorFactory,
5
5
  } from './errors'
6
6
  export {
7
+ createModule,
7
8
  jsonSafeZid,
9
+ tenantTable,
8
10
  TIMESTAMP_FIELDS,
9
11
  zodTable,
10
12
  zodVariantTable,
11
13
  type TableBoundaryOptions,
12
14
  } from './zod-table'
15
+ export {
16
+ assertTenantOwned,
17
+ composeRules,
18
+ createTenantRules,
19
+ requireTenantReference,
20
+ TENANT_FIELD,
21
+ type RLSConfig,
22
+ type Rules,
23
+ } from './tenancy'
13
24
  export {
14
25
  createAuthFunctions,
15
26
  createInclude,
@@ -20,9 +31,11 @@ export {
20
31
  type DefaultRole,
21
32
  type Include,
22
33
  type IncludedQuery,
34
+ type SecurityConfig,
23
35
  } from './auth'
24
36
  export {
25
37
  appendOnly,
38
+ tenantOwnership,
26
39
  createTriggers,
27
40
  noDelete,
28
41
  timestamps,
@@ -37,5 +50,7 @@ export {
37
50
  type AuditedRegistry,
38
51
  type AuditEntryInput,
39
52
  type AuditWriter,
53
+ type CommandDefaults,
40
54
  type FoundationOptions,
55
+ type PermissionChecker,
41
56
  } from './components/foundation/client'