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.
package/src/tenancy.ts ADDED
@@ -0,0 +1,110 @@
1
+ import {
2
+ wrapDatabaseReader,
3
+ wrapDatabaseWriter,
4
+ type RLSConfig,
5
+ type Rules,
6
+ } from 'convex-helpers/server/rowLevelSecurity'
7
+ import type {
8
+ GenericDataModel,
9
+ TableNamesInDataModel,
10
+ } from 'convex/server'
11
+ import { defaultErrors, type ErrorFactory } from './errors'
12
+
13
+ export { wrapDatabaseReader, wrapDatabaseWriter }
14
+ export type { RLSConfig, Rules }
15
+
16
+ /** The server-owned tenancy field stamped on every tenant table row. */
17
+ export const TENANT_FIELD = 'tenant' as const
18
+
19
+ type TenantRow = { [TENANT_FIELD]?: unknown }
20
+
21
+ /**
22
+ * Deny-oriented row-level security rules generated mechanically from a table
23
+ * registry: read/insert/modify pass only when the row's tenant matches.
24
+ * Registering a table here is the entire onboarding — there is no second
25
+ * per-table step to forget.
26
+ */
27
+ export function createTenantRules<DataModel extends GenericDataModel>(
28
+ tenant: string,
29
+ tables: readonly TableNamesInDataModel<DataModel>[],
30
+ ): Rules<unknown, DataModel> {
31
+ const owned = async (_ctx: unknown, doc: TenantRow) =>
32
+ doc[TENANT_FIELD] === tenant
33
+ return Object.fromEntries(
34
+ tables.map((table) => [
35
+ table,
36
+ { read: owned, insert: owned, modify: owned },
37
+ ]),
38
+ ) as unknown as Rules<unknown, DataModel>
39
+ }
40
+
41
+ /**
42
+ * AND-composes rule sets: a row passes an operation only when every rule set
43
+ * that defines a rule for it passes. Lets tenant isolation and role-level
44
+ * rules coexist on the same wrapped database.
45
+ */
46
+ export function composeRules<Ctx, DataModel extends GenericDataModel>(
47
+ ...sets: readonly Rules<Ctx, DataModel>[]
48
+ ): Rules<Ctx, DataModel> {
49
+ const tables = new Set(sets.flatMap((set) => Object.keys(set)))
50
+ const composed: Record<string, Record<string, unknown>> = {}
51
+ for (const table of tables) {
52
+ const operations: Record<string, unknown> = {}
53
+ for (const operation of ['read', 'insert', 'modify'] as const) {
54
+ const rules = sets
55
+ .map((set) => (set as Record<string, Record<string, unknown>>)[table])
56
+ .map((tableRules) => tableRules?.[operation])
57
+ .filter((rule): rule is (ctx: Ctx, doc: never) => Promise<boolean> =>
58
+ typeof rule === 'function',
59
+ )
60
+ if (rules.length === 0) continue
61
+ operations[operation] = async (ctx: Ctx, doc: never) => {
62
+ for (const rule of rules) {
63
+ if (!(await rule(ctx, doc))) return false
64
+ }
65
+ return true
66
+ }
67
+ }
68
+ composed[table] = operations
69
+ }
70
+ return composed as Rules<Ctx, DataModel>
71
+ }
72
+
73
+ /**
74
+ * Loads a client-referenced document and verifies tenant ownership without
75
+ * disclosing existence: a missing row and a foreign row fail identically
76
+ * with REFERENCE_NOT_FOUND. Use for every id argument a client supplies.
77
+ */
78
+ export async function requireTenantReference<
79
+ Document extends { [TENANT_FIELD]?: unknown },
80
+ >(
81
+ tenant: string,
82
+ load: () => Promise<Document | null>,
83
+ errors: ErrorFactory = defaultErrors,
84
+ ): Promise<Document> {
85
+ const document = await load()
86
+ if (!document || document[TENANT_FIELD] !== tenant) {
87
+ return errors.throw({
88
+ code: 'REFERENCE_NOT_FOUND',
89
+ message: 'The referenced row is unavailable',
90
+ })
91
+ }
92
+ return document
93
+ }
94
+
95
+ /** Asserts a loaded document belongs to the tenant; use on internal paths. */
96
+ export function assertTenantOwned<
97
+ Document extends { [TENANT_FIELD]?: unknown },
98
+ >(
99
+ tenant: string,
100
+ document: Document,
101
+ errors: ErrorFactory = defaultErrors,
102
+ ): Document {
103
+ if (document[TENANT_FIELD] !== tenant) {
104
+ return errors.throw({
105
+ code: 'CROSS_TENANT_REFERENCE',
106
+ message: 'The document belongs to another tenant',
107
+ })
108
+ }
109
+ return document
110
+ }
package/src/triggers.ts CHANGED
@@ -54,6 +54,40 @@ export function noDelete<DataModel extends GenericDataModel>(
54
54
  }
55
55
  }
56
56
 
57
+ /**
58
+ * Post-image tenant invariant for tenantTable rows. Row-level security
59
+ * authorizes a modification against the PRE-image, so ownership must also be
60
+ * enforced against the post-image: inserts must carry a tenant, and updates
61
+ * may never reassign it. Register for every tenant table (same registry that
62
+ * drives createTenantRules).
63
+ */
64
+ export function tenantOwnership<DataModel extends GenericDataModel>(
65
+ triggers: Triggers<DataModel>,
66
+ ...tables: readonly TableNamesInDataModel<DataModel>[]
67
+ ) {
68
+ for (const table of tables) {
69
+ triggers.register(table, async (_ctx, change) => {
70
+ if (change.operation === 'insert') {
71
+ const document = change.newDoc as { tenant?: unknown }
72
+ if (
73
+ typeof document.tenant !== 'string' ||
74
+ document.tenant.length === 0
75
+ ) {
76
+ throw new Error(`${table} rows require a tenant`)
77
+ }
78
+ return
79
+ }
80
+ if (change.operation === 'update') {
81
+ const previous = change.oldDoc as { tenant?: unknown }
82
+ const current = change.newDoc as { tenant?: unknown }
83
+ if (previous.tenant !== current.tenant) {
84
+ throw new Error(`${table} tenant ownership cannot be reassigned`)
85
+ }
86
+ }
87
+ })
88
+ }
89
+ }
90
+
57
91
  /**
58
92
  * Maintains zod-table's opinionated lifecycle timestamps: createdAt and
59
93
  * updatedAt on insert, updatedAt on every update. archivedAt stays under
package/src/zod-table.ts CHANGED
@@ -138,6 +138,92 @@ export function zodTable<
138
138
  }
139
139
  }
140
140
 
141
+ const tenantShape = {
142
+ tenant: z.string().min(1),
143
+ } satisfies Shape
144
+
145
+ type TenantShape = typeof tenantShape
146
+
147
+ /**
148
+ * A zodTable whose rows are tenant-owned: injects the server-owned `tenant`
149
+ * field (the tenancy boundary — see cvx-kit/tenancy). Like timestamps, it is
150
+ * excluded from insert/update/command boundaries; handlers stamp it from
151
+ * ctx.tenant, row-level security matches on it, and the tenantOwnership
152
+ * trigger forbids reassigning it. Expose it via publicFields only when a DTO
153
+ * genuinely needs it.
154
+ */
155
+ export function tenantTable<
156
+ Table extends string,
157
+ Fields extends Shape,
158
+ const ServerFields extends readonly ShapeKey<Fields>[] = readonly [],
159
+ const CommandFields extends readonly ShapeKey<Fields>[] = readonly [],
160
+ const PublicFields extends readonly ShapeKey<
161
+ Fields & TenantShape & TimestampShape
162
+ >[] = readonly [],
163
+ >(
164
+ tableName: Table,
165
+ fields: (id: typeof zid) => Fields,
166
+ options: TableBoundaryOptions<
167
+ Fields & TenantShape,
168
+ readonly (ServerFields[number] | 'tenant')[],
169
+ CommandFields,
170
+ PublicFields
171
+ > = {},
172
+ ) {
173
+ return zodTable(
174
+ tableName,
175
+ (id) => ({ ...fields(id), ...tenantShape }),
176
+ {
177
+ ...options,
178
+ serverFields: [
179
+ ...((options.serverFields ?? []) as readonly (
180
+ | ServerFields[number]
181
+ | 'tenant'
182
+ )[]),
183
+ 'tenant',
184
+ ] as never,
185
+ } as never,
186
+ ) as ReturnType<
187
+ typeof zodTable<
188
+ Table,
189
+ Fields & TenantShape,
190
+ readonly (ServerFields[number] | 'tenant')[],
191
+ CommandFields,
192
+ PublicFields
193
+ >
194
+ >
195
+ }
196
+
197
+ /**
198
+ * Merges per-module table maps into the application schema map, rejecting
199
+ * duplicate table names across modules — the module-registry combinator for
200
+ * domain/table.ts: `defineSchema(createModule(catalogTables, salesTables))`.
201
+ */
202
+ export function createModule<
203
+ const Maps extends readonly Record<string, TableDefinition<any>>[],
204
+ >(...maps: Maps): UnionToIntersection<Maps[number]> {
205
+ const combined: Record<string, TableDefinition<any>> = {}
206
+ for (const map of maps) {
207
+ for (const [tableName, definition] of Object.entries(map)) {
208
+ if (tableName in combined) {
209
+ throw new Error(
210
+ `Table "${tableName}" is declared by more than one module`,
211
+ )
212
+ }
213
+ combined[tableName] = definition
214
+ }
215
+ }
216
+ return combined as UnionToIntersection<Maps[number]>
217
+ }
218
+
219
+ type UnionToIntersection<Union> = (
220
+ Union extends unknown
221
+ ? (member: Union) => void
222
+ : never
223
+ ) extends (member: infer Intersection) => void
224
+ ? Intersection
225
+ : never
226
+
141
227
  /** Preserves discriminated-union storage while retaining the same table owner. */
142
228
  export function zodVariantTable<Table extends string, Schema extends z.ZodType>(
143
229
  tableName: Table,