cvx-kit 0.0.2 → 0.0.4
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 +1 -0
- package/dist/auth.d.mts +52 -5
- package/dist/auth.mjs +1 -1
- package/dist/auth.mjs.map +1 -1
- package/dist/components/foundation/client.d.mts +40 -3
- package/dist/components/foundation/client.mjs +1 -1
- package/dist/components/foundation/client.mjs.map +1 -1
- package/dist/index.d.mts +6 -5
- package/dist/index.mjs +1 -1
- package/dist/tenancy.d.mts +34 -0
- package/dist/tenancy.mjs +2 -0
- package/dist/tenancy.mjs.map +1 -0
- package/dist/triggers.d.mts +9 -1
- package/dist/triggers.mjs +1 -1
- package/dist/triggers.mjs.map +1 -1
- package/dist/zod-table.d.mts +21 -1
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/docs/auth.md +10 -0
- package/docs/commands.md +56 -4
- package/docs/conventions.md +21 -0
- package/docs/explain/modules-and-multitenancy.html +469 -0
- package/docs/llms.md +16 -4
- package/docs/tenancy.md +179 -0
- package/docs/triggers.md +5 -0
- package/docs/zod-table.md +23 -0
- package/package.json +6 -8
- package/src/test.ts +7 -5
- package/src/auth.ts +0 -360
- package/src/components/approvals/_generated/api.ts +0 -69
- package/src/components/approvals/_generated/component.ts +0 -335
- package/src/components/approvals/_generated/dataModel.ts +0 -60
- package/src/components/approvals/_generated/server.ts +0 -156
- package/src/components/approvals/audit.ts +0 -148
- package/src/components/approvals/client.ts +0 -321
- package/src/components/approvals/constants.ts +0 -51
- package/src/components/approvals/convex.config.ts +0 -10
- package/src/components/approvals/decisions.ts +0 -376
- package/src/components/approvals/functions.ts +0 -27
- package/src/components/approvals/requests.ts +0 -240
- package/src/components/approvals/schema.ts +0 -23
- package/src/components/approvals/validators.ts +0 -272
- package/src/components/approvals/workflow.ts +0 -204
- package/src/components/approvals/workflow_steps.ts +0 -189
- package/src/components/foundation/_generated/api.ts +0 -62
- package/src/components/foundation/_generated/component.ts +0 -29
- package/src/components/foundation/_generated/dataModel.ts +0 -60
- package/src/components/foundation/_generated/server.ts +0 -156
- package/src/components/foundation/client.ts +0 -190
- package/src/components/foundation/convex.config.ts +0 -3
- package/src/components/foundation/functions.ts +0 -10
- package/src/components/foundation/modules/command/__tests__/command.test.ts +0 -106
- package/src/components/foundation/modules/command/command.ts +0 -154
- package/src/components/foundation/modules/observability/__tests__/observability.test.ts +0 -79
- package/src/components/foundation/modules/observability/observability.ts +0 -95
- package/src/components/foundation/modules/query/__tests__/query.test.ts +0 -25
- package/src/components/foundation/modules/query/query.ts +0 -47
- package/src/components/foundation/result.ts +0 -77
- package/src/components/foundation/schema.ts +0 -4
- package/src/components/foundation/telemetry.ts +0 -23
- package/src/errors.ts +0 -25
- package/src/index.ts +0 -41
- package/src/triggers.ts +0 -90
- package/src/zod-table.ts +0 -197
- /package/docs/{convex-architecture-explainer.html → explain/convex-architecture-explainer.html} +0 -0
package/docs/tenancy.md
ADDED
|
@@ -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,16 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cvx-kit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
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",
|
|
7
7
|
"sideEffects": false,
|
|
8
|
-
"files": [
|
|
9
|
-
"dist",
|
|
10
|
-
"src",
|
|
11
|
-
"!src/docs",
|
|
12
|
-
"docs"
|
|
13
|
-
],
|
|
14
8
|
"exports": {
|
|
15
9
|
"./package.json": "./package.json",
|
|
16
10
|
".": {
|
|
@@ -25,6 +19,10 @@
|
|
|
25
19
|
"types": "./dist/auth.d.mts",
|
|
26
20
|
"import": "./dist/auth.mjs"
|
|
27
21
|
},
|
|
22
|
+
"./tenancy": {
|
|
23
|
+
"types": "./dist/tenancy.d.mts",
|
|
24
|
+
"import": "./dist/tenancy.mjs"
|
|
25
|
+
},
|
|
28
26
|
"./triggers": {
|
|
29
27
|
"types": "./dist/triggers.d.mts",
|
|
30
28
|
"import": "./dist/triggers.mjs"
|
|
@@ -70,7 +68,7 @@
|
|
|
70
68
|
"typecheck": "tsc --noEmit",
|
|
71
69
|
"test": "vp test run",
|
|
72
70
|
"docs:sync": "rm -f docs/*.md && cp src/docs/*.md docs/",
|
|
73
|
-
"prepublishOnly": "bun run
|
|
71
|
+
"prepublishOnly": "bun run build && bun run typecheck && bun run test && bun run docs:sync",
|
|
74
72
|
"build:codegen": "convex codegen --component-dir ./src/components/foundation && convex codegen --component-dir ./src/components/approvals && vp pack",
|
|
75
73
|
"release": "npm version patch && git push --follow-tags",
|
|
76
74
|
"release:minor": "npm version minor && git push --follow-tags"
|
package/src/test.ts
CHANGED
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
* Test helpers for consumers, following the official component template:
|
|
3
3
|
* register the kit's components on a convex-test instance so host-app tests
|
|
4
4
|
* run against them in-memory. Requires a vitest-compatible runner
|
|
5
|
-
* (import.meta.glob), which is why this
|
|
5
|
+
* (import.meta.glob), which is why this file ships as source — but it
|
|
6
|
+
* registers the COMPILED component modules from dist, so dist is the only
|
|
7
|
+
* code the package ships.
|
|
6
8
|
*/
|
|
7
|
-
import approvalsSchema from '
|
|
8
|
-
import foundationSchema from '
|
|
9
|
+
import approvalsSchema from '../dist/components/approvals/schema.mjs'
|
|
10
|
+
import foundationSchema from '../dist/components/foundation/schema.mjs'
|
|
9
11
|
|
|
10
12
|
const foundationModules = import.meta.glob(
|
|
11
|
-
'
|
|
13
|
+
'../dist/components/foundation/**/*.mjs',
|
|
12
14
|
)
|
|
13
15
|
const approvalsModules = import.meta.glob(
|
|
14
|
-
'
|
|
16
|
+
'../dist/components/approvals/**/*.mjs',
|
|
15
17
|
)
|
|
16
18
|
|
|
17
19
|
type RegistersComponents = {
|
package/src/auth.ts
DELETED
|
@@ -1,360 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
zCustomAction,
|
|
3
|
-
zCustomMutation,
|
|
4
|
-
zCustomQuery,
|
|
5
|
-
} from 'convex-helpers/server/zod4'
|
|
6
|
-
import type {
|
|
7
|
-
ActionBuilder,
|
|
8
|
-
DocumentByInfo,
|
|
9
|
-
GenericActionCtx,
|
|
10
|
-
GenericDataModel,
|
|
11
|
-
GenericMutationCtx,
|
|
12
|
-
GenericQueryCtx,
|
|
13
|
-
GenericTableInfo,
|
|
14
|
-
IndexNames,
|
|
15
|
-
IndexRange,
|
|
16
|
-
IndexRangeBuilder,
|
|
17
|
-
MutationBuilder,
|
|
18
|
-
NamedIndex,
|
|
19
|
-
Query,
|
|
20
|
-
QueryBuilder,
|
|
21
|
-
QueryInitializer,
|
|
22
|
-
UserIdentity,
|
|
23
|
-
} from 'convex/server'
|
|
24
|
-
import type { Triggers } from 'convex-helpers/server/triggers'
|
|
25
|
-
import { defaultErrors, type ErrorFactory } from './errors'
|
|
26
|
-
|
|
27
|
-
/** The default vocabulary; every app can supply its own via the Role generic. */
|
|
28
|
-
export type DefaultRole = 'reader' | 'writer' | 'admin'
|
|
29
|
-
|
|
30
|
-
export type Actor<Role extends string> = Readonly<{
|
|
31
|
-
userId: string
|
|
32
|
-
organizationId: string
|
|
33
|
-
role: Role
|
|
34
|
-
}>
|
|
35
|
-
|
|
36
|
-
export type AuthBundle<Role extends string> = Readonly<{
|
|
37
|
-
identity: UserIdentity
|
|
38
|
-
user: Readonly<{ id: string }>
|
|
39
|
-
org: Readonly<{ organizationId: string; role: Role }>
|
|
40
|
-
role: Role
|
|
41
|
-
actor: Actor<Role>
|
|
42
|
-
}>
|
|
43
|
-
|
|
44
|
-
type AnyAuthContext<DataModel extends GenericDataModel> =
|
|
45
|
-
| GenericQueryCtx<DataModel>
|
|
46
|
-
| GenericMutationCtx<DataModel>
|
|
47
|
-
| GenericActionCtx<DataModel>
|
|
48
|
-
|
|
49
|
-
export type AuthFunctionsConfig<
|
|
50
|
-
DataModel extends GenericDataModel,
|
|
51
|
-
Role extends string,
|
|
52
|
-
> = {
|
|
53
|
-
/** The consumer's generated function builders (from ./_generated/server). */
|
|
54
|
-
query: QueryBuilder<DataModel, 'public'>
|
|
55
|
-
mutation: MutationBuilder<DataModel, 'public'>
|
|
56
|
-
action: ActionBuilder<DataModel, 'public'>
|
|
57
|
-
internalMutation: MutationBuilder<DataModel, 'internal'>
|
|
58
|
-
internalAction: ActionBuilder<DataModel, 'internal'>
|
|
59
|
-
/** Resolves the synchronized principal, e.g. (ctx) => authKit.getAuthUser(ctx). */
|
|
60
|
-
getAuthUser: (
|
|
61
|
-
ctx: AnyAuthContext<DataModel>,
|
|
62
|
-
) => Promise<{ id: string } | null>
|
|
63
|
-
/**
|
|
64
|
-
* Maps an identity role slug onto the app's role vocabulary. Returning
|
|
65
|
-
* null rejects the caller with FORBIDDEN — unknown roles never pass.
|
|
66
|
-
*/
|
|
67
|
-
mapRole: (roleSlug?: string) => Role | null
|
|
68
|
-
/** Roles allowed through the admin* constructors. */
|
|
69
|
-
adminRoles: readonly NoInfer<Role>[]
|
|
70
|
-
/**
|
|
71
|
-
* Live membership check for actions (queries/mutations trust the JWT).
|
|
72
|
-
* Authentication fails closed with FORBIDDEN when verification errors.
|
|
73
|
-
* Omit to let actions trust the JWT like queries do.
|
|
74
|
-
*/
|
|
75
|
-
verifyMembership?: (input: {
|
|
76
|
-
userId: string
|
|
77
|
-
organizationId: string
|
|
78
|
-
}) => Promise<{ organizationId: string; roleSlug: string } | null>
|
|
79
|
-
/** Host error policy; defaults to throwing KitError. */
|
|
80
|
-
errors?: ErrorFactory
|
|
81
|
-
/**
|
|
82
|
-
* Application trigger registry. When set, every authMutation,
|
|
83
|
-
* roleMutation, and systemMutation write runs through triggers.wrapDB —
|
|
84
|
-
* evaluations registered on tables fire structurally.
|
|
85
|
-
*/
|
|
86
|
-
triggers?: Triggers<DataModel>
|
|
87
|
-
/** Lower-level alternative to `triggers`: a custom ctx wrapper. */
|
|
88
|
-
wrapDB?: (
|
|
89
|
-
ctx: GenericMutationCtx<DataModel>,
|
|
90
|
-
) => GenericMutationCtx<DataModel>
|
|
91
|
-
/** Upper bound for include() reads. */
|
|
92
|
-
maxIncludedQueryRows?: number
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** WorkOS-style default: `member` writes; reader/writer/admin pass through. */
|
|
96
|
-
export function defaultRoleMap(roleSlug?: string): DefaultRole | null {
|
|
97
|
-
const normalized = roleSlug?.trim().toLowerCase()
|
|
98
|
-
if (normalized === 'member') return 'writer'
|
|
99
|
-
return normalized === 'reader' ||
|
|
100
|
-
normalized === 'writer' ||
|
|
101
|
-
normalized === 'admin'
|
|
102
|
-
? normalized
|
|
103
|
-
: null
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export type IncludedQuery<Table extends GenericTableInfo> = {
|
|
107
|
-
when<Value>(
|
|
108
|
-
value: Value | null | undefined,
|
|
109
|
-
select: (query: QueryInitializer<Table>, value: Value) => Query<Table>,
|
|
110
|
-
): IncludedQuery<Table>
|
|
111
|
-
matching<IndexName extends IndexNames<Table>>(
|
|
112
|
-
indexName: IndexName,
|
|
113
|
-
indexRange?: (
|
|
114
|
-
query: IndexRangeBuilder<
|
|
115
|
-
DocumentByInfo<Table>,
|
|
116
|
-
NamedIndex<Table, IndexName>
|
|
117
|
-
>,
|
|
118
|
-
) => IndexRange,
|
|
119
|
-
shouldMatch?: boolean,
|
|
120
|
-
): IncludedQuery<Table>
|
|
121
|
-
otherwise(
|
|
122
|
-
select: (query: QueryInitializer<Table>) => Query<Table>,
|
|
123
|
-
): Query<Table>
|
|
124
|
-
resolve(): Query<Table>
|
|
125
|
-
execute<Result = Awaited<ReturnType<Query<Table>['take']>>>(
|
|
126
|
-
limit: number,
|
|
127
|
-
transform?: (
|
|
128
|
-
data: Awaited<ReturnType<Query<Table>['take']>>,
|
|
129
|
-
) => Result | Promise<Result>,
|
|
130
|
-
): Promise<Result>
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export type Include = <Table extends GenericTableInfo>(
|
|
134
|
-
query: QueryInitializer<Table>,
|
|
135
|
-
) => IncludedQuery<Table>
|
|
136
|
-
|
|
137
|
-
/** Builds an include() that selects the first matching indexed query and bounds the read. */
|
|
138
|
-
export function createInclude(options?: {
|
|
139
|
-
errors?: ErrorFactory
|
|
140
|
-
maxRows?: number
|
|
141
|
-
}): Include {
|
|
142
|
-
const errors = options?.errors ?? defaultErrors
|
|
143
|
-
const maxRows = options?.maxRows ?? 100
|
|
144
|
-
|
|
145
|
-
function attach<Table extends GenericTableInfo>(
|
|
146
|
-
query: QueryInitializer<Table>,
|
|
147
|
-
selected?: Query<Table>,
|
|
148
|
-
): IncludedQuery<Table> {
|
|
149
|
-
return {
|
|
150
|
-
when: (value, select) =>
|
|
151
|
-
selected || value === undefined || value === null
|
|
152
|
-
? attach(query, selected)
|
|
153
|
-
: attach(query, select(query, value)),
|
|
154
|
-
matching: (indexName, indexRange, shouldMatch = true) =>
|
|
155
|
-
selected || !shouldMatch
|
|
156
|
-
? attach(query, selected)
|
|
157
|
-
: attach(query, query.withIndex(indexName, indexRange)),
|
|
158
|
-
otherwise: (select) => selected ?? select(query),
|
|
159
|
-
resolve: () => selected ?? query.fullTableScan(),
|
|
160
|
-
execute: async (limit, transform) => {
|
|
161
|
-
if (!Number.isSafeInteger(limit) || limit < 1 || limit > maxRows) {
|
|
162
|
-
return errors.throw({
|
|
163
|
-
code: 'INVALID_REQUEST_BOUNDARY',
|
|
164
|
-
message: `A bounded query limit must be from 1 to ${maxRows}`,
|
|
165
|
-
})
|
|
166
|
-
}
|
|
167
|
-
const data = await (selected ?? query.fullTableScan()).take(limit)
|
|
168
|
-
return transform ? transform(data) : (data as never)
|
|
169
|
-
},
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return (query) => attach(query)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Builds the auth-aware function constructors from the host's injected
|
|
178
|
-
* policy: identity resolution, the app's own role vocabulary, live
|
|
179
|
-
* membership verification for actions, trigger wrapping for mutations,
|
|
180
|
-
* and errors. Role-based access is first-class — every constructor family
|
|
181
|
-
* has a role-gated variant (roleQuery/roleMutation/roleAction), and the
|
|
182
|
-
* admin* constructors are just that gate applied to config.adminRoles.
|
|
183
|
-
*/
|
|
184
|
-
export function createAuthFunctions<
|
|
185
|
-
DataModel extends GenericDataModel,
|
|
186
|
-
const Role extends string = DefaultRole,
|
|
187
|
-
>(config: AuthFunctionsConfig<DataModel, Role>) {
|
|
188
|
-
const errors = config.errors ?? defaultErrors
|
|
189
|
-
const wrapDB = config.triggers
|
|
190
|
-
? (ctx: GenericMutationCtx<DataModel>) =>
|
|
191
|
-
(config.triggers as Triggers<DataModel>).wrapDB(ctx)
|
|
192
|
-
: (config.wrapDB ?? ((ctx) => ctx))
|
|
193
|
-
const include = createInclude({
|
|
194
|
-
errors,
|
|
195
|
-
maxRows: config.maxIncludedQueryRows,
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
async function authenticatedUser(
|
|
199
|
-
ctx: AnyAuthContext<DataModel>,
|
|
200
|
-
): Promise<AuthBundle<Role>> {
|
|
201
|
-
const identity = await ctx.auth.getUserIdentity()
|
|
202
|
-
const user = identity ? await config.getAuthUser(ctx) : null
|
|
203
|
-
if (!identity || !user) return errors.throw({ code: 'UNAUTHENTICATED' })
|
|
204
|
-
const organization = identity.organization
|
|
205
|
-
const organizationId =
|
|
206
|
-
organization &&
|
|
207
|
-
typeof organization === 'object' &&
|
|
208
|
-
'organizationId' in organization &&
|
|
209
|
-
typeof organization.organizationId === 'string'
|
|
210
|
-
? organization.organizationId
|
|
211
|
-
: typeof identity.org_id === 'string'
|
|
212
|
-
? identity.org_id
|
|
213
|
-
: null
|
|
214
|
-
if (!organizationId) return errors.throw({ code: 'UNAUTHENTICATED' })
|
|
215
|
-
const organizationRole =
|
|
216
|
-
organization &&
|
|
217
|
-
typeof organization === 'object' &&
|
|
218
|
-
'role' in organization &&
|
|
219
|
-
typeof organization.role === 'string'
|
|
220
|
-
? organization.role
|
|
221
|
-
: typeof identity.role === 'string'
|
|
222
|
-
? identity.role
|
|
223
|
-
: undefined
|
|
224
|
-
const role = config.mapRole(organizationRole)
|
|
225
|
-
if (!role) return errors.throw({ code: 'FORBIDDEN' })
|
|
226
|
-
return Object.freeze({
|
|
227
|
-
identity,
|
|
228
|
-
user: Object.freeze({ id: user.id }),
|
|
229
|
-
org: Object.freeze({ organizationId, role }),
|
|
230
|
-
role,
|
|
231
|
-
actor: Object.freeze({ userId: user.id, organizationId, role }),
|
|
232
|
-
})
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
async function verifiedMembership(userId: string, organizationId: string) {
|
|
236
|
-
if (!config.verifyMembership) return null
|
|
237
|
-
try {
|
|
238
|
-
const membership = await config.verifyMembership({
|
|
239
|
-
userId,
|
|
240
|
-
organizationId,
|
|
241
|
-
})
|
|
242
|
-
if (membership && membership.organizationId === organizationId) {
|
|
243
|
-
return membership
|
|
244
|
-
}
|
|
245
|
-
} catch {
|
|
246
|
-
// Authentication fails closed when membership cannot be verified.
|
|
247
|
-
}
|
|
248
|
-
return errors.throw({ code: 'FORBIDDEN' })
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function requireRole(role: Role, allowed: readonly Role[]) {
|
|
252
|
-
if (!allowed.includes(role)) return errors.throw({ code: 'FORBIDDEN' })
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
async function queryInput(ctx: GenericQueryCtx<DataModel>) {
|
|
256
|
-
return {
|
|
257
|
-
ctx: { ...ctx, include, ...(await authenticatedUser(ctx)) },
|
|
258
|
-
args: {},
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
async function mutationInput(ctx: GenericMutationCtx<DataModel>) {
|
|
263
|
-
return {
|
|
264
|
-
ctx: { ...wrapDB(ctx), include, ...(await authenticatedUser(ctx)) },
|
|
265
|
-
args: {},
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async function actionInput(ctx: GenericActionCtx<DataModel>) {
|
|
270
|
-
const authenticated = await authenticatedUser(ctx)
|
|
271
|
-
const membership = await verifiedMembership(
|
|
272
|
-
authenticated.user.id,
|
|
273
|
-
authenticated.org.organizationId,
|
|
274
|
-
)
|
|
275
|
-
if (!membership) return { ctx: { ...ctx, ...authenticated }, args: {} }
|
|
276
|
-
const role = config.mapRole(membership.roleSlug)
|
|
277
|
-
if (!role) return errors.throw({ code: 'FORBIDDEN' })
|
|
278
|
-
return {
|
|
279
|
-
ctx: {
|
|
280
|
-
...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
|
-
}),
|
|
292
|
-
},
|
|
293
|
-
args: {},
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** Query constructor allowing only the given roles. */
|
|
298
|
-
function roleQuery(...allowed: readonly Role[]) {
|
|
299
|
-
return zCustomQuery(config.query, {
|
|
300
|
-
args: {},
|
|
301
|
-
input: async (ctx) => {
|
|
302
|
-
const authenticated = await queryInput(ctx)
|
|
303
|
-
requireRole(authenticated.ctx.actor.role, allowed)
|
|
304
|
-
return authenticated
|
|
305
|
-
},
|
|
306
|
-
})
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/** Mutation constructor allowing only the given roles. */
|
|
310
|
-
function roleMutation(...allowed: readonly Role[]) {
|
|
311
|
-
return zCustomMutation(config.mutation, {
|
|
312
|
-
args: {},
|
|
313
|
-
input: async (ctx) => {
|
|
314
|
-
const authenticated = await mutationInput(ctx)
|
|
315
|
-
requireRole(authenticated.ctx.actor.role, allowed)
|
|
316
|
-
return authenticated
|
|
317
|
-
},
|
|
318
|
-
})
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/** Action constructor allowing only the given roles (post live verification). */
|
|
322
|
-
function roleAction(...allowed: readonly Role[]) {
|
|
323
|
-
return zCustomAction(config.action, {
|
|
324
|
-
args: {},
|
|
325
|
-
input: async (ctx) => {
|
|
326
|
-
const authenticated = await actionInput(ctx)
|
|
327
|
-
requireRole(authenticated.ctx.actor.role, allowed)
|
|
328
|
-
return authenticated
|
|
329
|
-
},
|
|
330
|
-
})
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
return {
|
|
334
|
-
include,
|
|
335
|
-
authenticatedUser,
|
|
336
|
-
roleQuery,
|
|
337
|
-
roleMutation,
|
|
338
|
-
roleAction,
|
|
339
|
-
authQuery: zCustomQuery(config.query, { args: {}, input: queryInput }),
|
|
340
|
-
authMutation: zCustomMutation(config.mutation, {
|
|
341
|
-
args: {},
|
|
342
|
-
input: mutationInput,
|
|
343
|
-
}),
|
|
344
|
-
authAction: zCustomAction(config.action, {
|
|
345
|
-
args: {},
|
|
346
|
-
input: actionInput,
|
|
347
|
-
}),
|
|
348
|
-
adminQuery: roleQuery(...config.adminRoles),
|
|
349
|
-
adminMutation: roleMutation(...config.adminRoles),
|
|
350
|
-
adminAction: roleAction(...config.adminRoles),
|
|
351
|
-
systemMutation: zCustomMutation(config.internalMutation, {
|
|
352
|
-
args: {},
|
|
353
|
-
input: async (ctx) => ({ ctx: { ...wrapDB(ctx), include }, args: {} }),
|
|
354
|
-
}),
|
|
355
|
-
systemAction: zCustomAction(config.internalAction, {
|
|
356
|
-
args: {},
|
|
357
|
-
input: async (ctx) => ({ ctx, args: {} }),
|
|
358
|
-
}),
|
|
359
|
-
}
|
|
360
|
-
}
|