@exvio/os-backend-core 0.4.0

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,518 @@
1
+ /**
2
+ * Shared Kysely plugin that auto-injects tenant_id filters on tagged tables,
3
+ * reading the active tenantId from AsyncLocalStorage (../tenant-context).
4
+ *
5
+ * Behaviour:
6
+ * - SELECT / UPDATE / DELETE: walk From/Join nodes, add WHERE on
7
+ * tenant-aware tables.
8
+ * - INNER/LEFT joins: put joined-side predicates in ON so LEFT JOIN keeps
9
+ * unmatched rows. RIGHT/FULL/CROSS/APPLY variants fail when their joined
10
+ * side is tenant-aware because the same rewrite is not semantics-safe.
11
+ * - UPDATE: tenant_id itself is immutable in a tenant-scoped query;
12
+ * multi-table targets and dynamic/raw column names fail loudly.
13
+ * - INSERT (VALUES): inject (or overwrite) tenant_id in the row(s).
14
+ * - UPSERT / REPLACE into tenant-aware tables: throws fail-loud until the
15
+ * conflict target can be proven to include tenant_id.
16
+ * - INSERT … SELECT / INSERT … DEFAULT VALUES into a tenant-aware
17
+ * table: throws fail-loud. The inner SELECT does still get its own
18
+ * tenant_id filter via OperationNodeTransformer recursion, but the
19
+ * INSERT projection itself can't be safely stamped (we'd be guessing
20
+ * where in the column list to splice tenant_id), so we'd be relying
21
+ * on the DB's NOT NULL constraint to catch the missing column at
22
+ * execute time. Better to refuse at the call site — caller must use
23
+ * bypassTenant() and explicitly guarantee the projected tenant_id.
24
+ * - bypass=true: pass through unmodified.
25
+ * - no context: configurable; v0.1 defaults to compatibility passthrough.
26
+ * - MERGE: throws fail-loud (not yet supported; wrap call in
27
+ * bypassTenant() if intentional).
28
+ *
29
+ * Recursion model
30
+ * ───────────────
31
+ * The plugin extends Kysely's `OperationNodeTransformer`, which already
32
+ * walks the entire AST depth-first. Our overrides:
33
+ *
34
+ * 1. Inject the tenant predicate into the *current* SELECT/UPDATE/
35
+ * DELETE/INSERT node.
36
+ * 2. Then call `super.transform*` on the rewritten node — which
37
+ * recurses into every child (FROM/JOIN/WHERE/HAVING/SET/CTE
38
+ * body/scalar subquery/EXISTS/IN-subquery/etc.). Any nested
39
+ * SelectQueryNode hits our `transformSelectQuery` override again
40
+ * and gets its own predicate.
41
+ *
42
+ * This is the only correct way to handle subqueries built via
43
+ * `eb.selectFrom(...)`: a single root-level inspection misses every
44
+ * nested SELECT (cross-tenant data leak). See test cases tagged
45
+ * "nested-AST coverage" for what this fix prevents.
46
+ *
47
+ * Known limitations
48
+ * ─────────────────
49
+ * - **Raw `sql\`...\`` template strings bypass the plugin entirely.**
50
+ * They are compiled directly to SQL fragments without an AST node
51
+ * for tables/joins, so there is nothing structural to walk. Code
52
+ * that assembles tenant-aware queries via `sql\`...\`` MUST add
53
+ * the `tenant_id = ?` predicate by hand. (We considered string-
54
+ * scanning RawNode.sql but it's brittle — quoted identifiers,
55
+ * comments, alias collisions all turn it into a parser project.)
56
+ * - **MERGE statements throw fail-loud.** The Kysely query builder
57
+ * doesn't currently expose a "from" we can rewrite uniformly, and
58
+ * we'd rather block at the call site than silently leak.
59
+ * - **Unsupported joins fail when the joined table is tenant-aware.**
60
+ * RIGHT/FULL joins preserve the joined side, so adding its predicate to ON
61
+ * does not remove cross-tenant rows. CROSS/APPLY variants have different
62
+ * or absent ON semantics. Callers must rewrite or explicitly bypass with
63
+ * their own tenant predicate.
64
+ *
65
+ * AST notes (Kysely 0.28.x)
66
+ * ─────────────────────────
67
+ * - TableNode wraps a SchemableIdentifierNode (which wraps an
68
+ * IdentifierNode .name); table name is at .table.identifier.name.
69
+ * - AliasNode.alias for table aliases is itself an IdentifierNode
70
+ * (so its name is at .alias.name).
71
+ * - UpdateQueryNode.table is a TableNode/AliasNode for the supported
72
+ * single-table form. Kysely represents multi-table targets as ListNode;
73
+ * unresolved target shapes fail loudly instead of bypassing isolation.
74
+ * - InsertQueryNode.values is an OperationNode (declared as such),
75
+ * practically a ValuesNode whose .values is an array of
76
+ * PrimitiveValueListNode (raw primitives) or ValueListNode (wrapped
77
+ * ValueNodes). We handle both — Kysely uses Primitive when all row
78
+ * values are plain primitives, otherwise ValueList.
79
+ * - All AST nodes are frozen / readonly; we always return new objects
80
+ * via spread, never mutate.
81
+ */
82
+ import {
83
+ type KyselyPlugin,
84
+ type PluginTransformQueryArgs,
85
+ type PluginTransformResultArgs,
86
+ type RootOperationNode,
87
+ type OperationNode,
88
+ type QueryResult,
89
+ type UnknownRow,
90
+ OperationNodeTransformer,
91
+ SelectQueryNode,
92
+ UpdateQueryNode,
93
+ DeleteQueryNode,
94
+ InsertQueryNode,
95
+ MergeQueryNode,
96
+ WhereNode,
97
+ AndNode,
98
+ BinaryOperationNode,
99
+ ColumnNode,
100
+ ReferenceNode,
101
+ TableNode,
102
+ AliasNode,
103
+ ValueNode,
104
+ OperatorNode,
105
+ ValuesNode,
106
+ PrimitiveValueListNode,
107
+ ValueListNode,
108
+ IdentifierNode,
109
+ JoinNode,
110
+ type ColumnUpdateNode,
111
+ } from 'kysely'
112
+ import { tenantContext, type TenantStore } from '../tenant-context.ts'
113
+
114
+ const TENANT_ID_COLUMN = 'tenant_id'
115
+ const SAFE_ON_CLAUSE_JOIN_TYPES = new Set([
116
+ 'InnerJoin',
117
+ 'LeftJoin',
118
+ 'LateralInnerJoin',
119
+ 'LateralLeftJoin',
120
+ ])
121
+
122
+ export type MissingTenantContextPolicy = 'passthrough' | 'throw'
123
+
124
+ /** The narrow context surface required by TenantFilterPlugin. */
125
+ export interface TenantContextReader {
126
+ getStore(): TenantStore | undefined
127
+ }
128
+
129
+ export interface TenantFilterPluginOptions {
130
+ /**
131
+ * Explicit context injection prevents a linked or duplicated package from
132
+ * silently giving the plugin a different AsyncLocalStorage instance.
133
+ */
134
+ context?: TenantContextReader
135
+ /**
136
+ * `passthrough` preserves the pre-package fleet behaviour. Consumers should
137
+ * migrate non-HTTP entry points and then opt into `throw` fail-closed mode.
138
+ */
139
+ onMissingContext?: MissingTenantContextPolicy
140
+ }
141
+
142
+ /**
143
+ * Identifies a table-reference as it appears in FROM/JOIN/UPDATE: both
144
+ * the underlying table name (used to look up tenant-awareness) and the
145
+ * alias to qualify the injected WHERE column with (so a JOIN's two
146
+ * filters don't collide on the same `tenant_id` reference).
147
+ */
148
+ interface TableRef {
149
+ /** Underlying table name (used for tenant-awareness lookup). */
150
+ tableName: string
151
+ /** Alias if present, otherwise the table name (used to qualify the WHERE). */
152
+ qualifier: string
153
+ }
154
+
155
+ /**
156
+ * AST transformer that walks the full operation-node tree and injects a
157
+ * `tenant_id = $tid` predicate on every SELECT/UPDATE/DELETE that
158
+ * targets a tenant-aware table, plus stamps `tenant_id = $tid` into
159
+ * every INSERT row. Recursion is delegated to the base class — see the
160
+ * "Recursion model" docblock at the top of the file.
161
+ */
162
+ class TenantFilterTransformer extends OperationNodeTransformer {
163
+ constructor(
164
+ private readonly tables: ReadonlySet<string>,
165
+ private readonly tid: number,
166
+ ) {
167
+ super()
168
+ }
169
+
170
+ protected override transformSelectQuery(node: SelectQueryNode): SelectQueryNode {
171
+ // 1. Inject WHERE for tenant-aware tables in *this* SELECT's FROM/JOIN.
172
+ const filtered = this.injectIntoSelect(node)
173
+ // 2. Recurse: every nested SelectQueryNode (subquery, CTE body,
174
+ // EXISTS, IN, scalar) hits transformSelectQuery again.
175
+ return super.transformSelectQuery(filtered)
176
+ }
177
+
178
+ protected override transformUpdateQuery(node: UpdateQueryNode): UpdateQueryNode {
179
+ const filtered = this.injectIntoUpdate(node)
180
+ return super.transformUpdateQuery(filtered)
181
+ }
182
+
183
+ protected override transformDeleteQuery(node: DeleteQueryNode): DeleteQueryNode {
184
+ const filtered = this.injectIntoDelete(node)
185
+ return super.transformDeleteQuery(filtered)
186
+ }
187
+
188
+ protected override transformInsertQuery(node: InsertQueryNode): InsertQueryNode {
189
+ const filtered = this.injectIntoInsert(node)
190
+ return super.transformInsertQuery(filtered)
191
+ }
192
+
193
+ protected override transformMergeQuery(_node: MergeQueryNode): MergeQueryNode {
194
+ throw new Error(
195
+ 'TenantFilterPlugin: MERGE statements are not supported — wrap the call ' +
196
+ 'in bypassTenant() if intentional, or rewrite as INSERT/UPDATE/DELETE.',
197
+ )
198
+ }
199
+
200
+ // ── helpers ─────────────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Resolves a from/join/update target node to its underlying table name
204
+ * + the qualifier (alias if any, else table name) used to scope the
205
+ * injected WHERE. Returns null for things we cannot interpret as a
206
+ * single tenant-aware table (subqueries, raw expressions, etc.).
207
+ */
208
+ private resolveTableRef(node: OperationNode): TableRef | null {
209
+ if (AliasNode.is(node)) {
210
+ const inner = node.node
211
+ if (!TableNode.is(inner)) return null
212
+ const tableName = inner.table.identifier.name
213
+ const aliasName = IdentifierNode.is(node.alias) ? node.alias.name : tableName
214
+ return { tableName, qualifier: aliasName }
215
+ }
216
+ if (TableNode.is(node)) {
217
+ const tableName = node.table.identifier.name
218
+ return { tableName, qualifier: tableName }
219
+ }
220
+ return null
221
+ }
222
+
223
+ /** Build `<qualifier>.tenant_id = $tid` BinaryOperationNode. */
224
+ private buildTenantPredicate(qualifier: string): BinaryOperationNode {
225
+ return BinaryOperationNode.create(
226
+ ReferenceNode.create(ColumnNode.create(TENANT_ID_COLUMN), TableNode.create(qualifier)),
227
+ OperatorNode.create('='),
228
+ ValueNode.create(this.tid),
229
+ )
230
+ }
231
+
232
+ /**
233
+ * Append `predicate` to an existing WhereNode (AND-combined) or wrap
234
+ * it in a new WhereNode if there isn't one yet.
235
+ */
236
+ private appendWhere(existing: WhereNode | undefined, predicate: OperationNode): WhereNode {
237
+ if (!existing) return WhereNode.create(predicate)
238
+ return WhereNode.create(AndNode.create(existing.where, predicate))
239
+ }
240
+
241
+ /**
242
+ * Collect tenant predicates for every tenant-aware FROM/USING item.
243
+ * These go into the outer WHERE clause (FROM tables are not inside any
244
+ * JOIN's ON, so WHERE is the correct landing spot).
245
+ */
246
+ private collectFromPredicates(
247
+ fromItems: ReadonlyArray<OperationNode> | undefined,
248
+ ): OperationNode[] {
249
+ const preds: OperationNode[] = []
250
+ if (!fromItems) return preds
251
+ for (const item of fromItems) {
252
+ const ref = this.resolveTableRef(item)
253
+ if (ref && this.tables.has(ref.tableName)) {
254
+ preds.push(this.buildTenantPredicate(ref.qualifier))
255
+ }
256
+ }
257
+ return preds
258
+ }
259
+
260
+ /**
261
+ * For each tenant-aware JOIN, append the tenant predicate to the JOIN's
262
+ * own ON clause (NOT the outer WHERE). This is correctness-critical for
263
+ * LEFT JOIN: putting the joined-side `tenant_id = ?` filter in the outer
264
+ * WHERE turns a LEFT JOIN into an effective INNER JOIN, because rows
265
+ * where the right-hand side is unmatched have NULL for every joined
266
+ * column — including tenant_id — and `NULL = ?` is falsy. Symptom: a
267
+ * user with a NULL FK to a tenant-aware table silently disappears from
268
+ * results. INNER JOIN behaviour is identical either way; placing in ON
269
+ * is uniform.
270
+ */
271
+ private rewriteJoins(
272
+ joins: ReadonlyArray<JoinNode> | undefined,
273
+ ): ReadonlyArray<JoinNode> | undefined {
274
+ if (!joins?.length) return joins
275
+ return joins.map((join) => {
276
+ // JoinNode.table is the joined-table OperationNode (TableNode or
277
+ // AliasNode-wrapped TableNode).
278
+ const ref = this.resolveTableRef(join.table)
279
+ if (!ref || !this.tables.has(ref.tableName)) return join
280
+ if (!SAFE_ON_CLAUSE_JOIN_TYPES.has(join.joinType)) {
281
+ throw new Error(
282
+ `TenantFilterPlugin: ${join.joinType} against tenant-aware table ` +
283
+ `${ref.tableName} is not supported. Rewrite the join as INNER/LEFT, ` +
284
+ `or use bypassTenant() with an explicit tenant predicate.`,
285
+ )
286
+ }
287
+ // JoinNode.cloneWithOn AND-combines into the existing ON, or
288
+ // creates a fresh ON if none was present.
289
+ return JoinNode.cloneWithOn(join, this.buildTenantPredicate(ref.qualifier))
290
+ })
291
+ }
292
+
293
+ // ── inject methods ──────────────────────────────────────────────────────
294
+
295
+ private injectIntoSelect(node: SelectQueryNode): SelectQueryNode {
296
+ const fromPreds = this.collectFromPredicates(node.from?.froms)
297
+ const newJoins = this.rewriteJoins(node.joins)
298
+ const joinsChanged = newJoins !== node.joins
299
+ if (fromPreds.length === 0 && !joinsChanged) return node
300
+
301
+ let where = node.where
302
+ for (const p of fromPreds) where = this.appendWhere(where, p)
303
+ return { ...node, where, joins: newJoins }
304
+ }
305
+
306
+ private injectIntoUpdate(node: UpdateQueryNode): UpdateQueryNode {
307
+ if (!node.table) return node
308
+ // Only a statically resolvable single-table target is safe to rewrite.
309
+ // Kysely emits ListNode for multi-table UPDATE and may accept raw target
310
+ // expressions; neither form has unambiguous ownership/filter semantics.
311
+ // The target table's tenant filter goes into the outer WHERE — UPDATE
312
+ // has no "ON clause" for its target, and there's no LEFT-JOIN-style
313
+ // NULL-row hazard for the target itself.
314
+ const targetRef = this.resolveTableRef(node.table)
315
+ if (!targetRef) {
316
+ throw new Error(
317
+ `TenantFilterPlugin: UPDATE target shape ${node.table.kind} is not supported. ` +
318
+ `Use a single statically named table, or bypassTenant() only for a ` +
319
+ `reviewed operation with explicit tenant predicates.`,
320
+ )
321
+ }
322
+ const fromPreds: OperationNode[] = []
323
+ if (this.tables.has(targetRef.tableName)) {
324
+ this.assertTenantOwnershipIsNotUpdated(node.updates, targetRef.tableName)
325
+ fromPreds.push(this.buildTenantPredicate(targetRef.qualifier))
326
+ }
327
+ // UPDATE … FROM list (auxiliary tables): same FROM-clause logic as SELECT.
328
+ fromPreds.push(...this.collectFromPredicates(node.from?.froms))
329
+ // UPDATE … JOIN: filter inside each JOIN's ON, same reasoning as SELECT.
330
+ const newJoins = this.rewriteJoins(node.joins)
331
+ const joinsChanged = newJoins !== node.joins
332
+ if (fromPreds.length === 0 && !joinsChanged) return node
333
+
334
+ let where = node.where
335
+ for (const p of fromPreds) where = this.appendWhere(where, p)
336
+ return { ...node, where, joins: newJoins }
337
+ }
338
+
339
+ private injectIntoDelete(node: DeleteQueryNode): DeleteQueryNode {
340
+ const fromPreds = [
341
+ ...this.collectFromPredicates(node.from?.froms),
342
+ ...this.collectFromPredicates(node.using?.tables),
343
+ ]
344
+ // DELETE … USING / JOIN: same handling — joined-side filter into ON.
345
+ const newJoins = this.rewriteJoins(node.joins)
346
+ const joinsChanged = newJoins !== node.joins
347
+ if (fromPreds.length === 0 && !joinsChanged) return node
348
+
349
+ let where = node.where
350
+ for (const p of fromPreds) where = this.appendWhere(where, p)
351
+ return { ...node, where, joins: newJoins }
352
+ }
353
+
354
+ private injectIntoInsert(node: InsertQueryNode): InsertQueryNode {
355
+ if (!node.into) return node
356
+ const tableName = node.into.table.identifier.name
357
+ if (!this.tables.has(tableName)) return node
358
+
359
+ if (
360
+ node.replace ||
361
+ node.onConflict ||
362
+ node.onDuplicateKey ||
363
+ node.orAction?.action.toLowerCase() === 'replace'
364
+ ) {
365
+ throw new Error(
366
+ `TenantFilterPlugin: upsert/replace is not supported for tenant-aware ` +
367
+ `table ${tableName}. Use a tenant-scoped UPDATE followed by INSERT, or ` +
368
+ `bypassTenant() only after proving the conflict key includes tenant_id.`,
369
+ )
370
+ }
371
+
372
+ const columns = node.columns ?? []
373
+ const tenantColIdx = columns.findIndex(
374
+ (c) => ColumnNode.is(c) && c.column.name === TENANT_ID_COLUMN,
375
+ )
376
+
377
+ const valuesNode = node.values
378
+ if (!valuesNode || !ValuesNode.is(valuesNode)) {
379
+ // INSERT … SELECT or INSERT … DEFAULT VALUES into a tenant-aware
380
+ // table. The inner SELECT (if any) still gets filtered by the
381
+ // OperationNodeTransformer recursion in transformInsertQuery, so
382
+ // the *rows* selected match the current tenant — but the INSERT
383
+ // projection itself doesn't get tenant_id stamped, so the column
384
+ // either isn't in the target list at all or carries through
385
+ // whatever the source SELECT projected (potentially the wrong
386
+ // tenant). Relying on DB NOT NULL to catch this is a runtime
387
+ // failure mode we'd rather refuse explicitly. Mirror the MERGE
388
+ // policy: fail loud at the call site.
389
+ throw new Error(
390
+ `TenantFilterPlugin: INSERT INTO ${tableName} ... SELECT (or DEFAULT VALUES) ` +
391
+ `is not supported for tenant-aware tables. Wrap the operation in ` +
392
+ `bypassTenant() and explicitly guarantee tenant_id if it is intentional.`,
393
+ )
394
+ }
395
+
396
+ if (tenantColIdx >= 0) {
397
+ // Already in column list — overwrite the per-row value at that index.
398
+ const newRows = valuesNode.values.map((row) => {
399
+ if (PrimitiveValueListNode.is(row)) {
400
+ const newVals = row.values.slice()
401
+ newVals[tenantColIdx] = this.tid
402
+ return PrimitiveValueListNode.create(newVals)
403
+ }
404
+ if (ValueListNode.is(row)) {
405
+ const newVals = row.values.slice()
406
+ newVals[tenantColIdx] = ValueNode.create(this.tid)
407
+ return ValueListNode.create(newVals)
408
+ }
409
+ return row
410
+ })
411
+ return {
412
+ ...node,
413
+ values: ValuesNode.create(newRows as ReadonlyArray<typeof valuesNode.values[number]>),
414
+ }
415
+ }
416
+
417
+ // tenant_id not in column list — append column + per-row value.
418
+ const newColumns = [...columns, ColumnNode.create(TENANT_ID_COLUMN)]
419
+ const newRows = valuesNode.values.map((row) => {
420
+ if (PrimitiveValueListNode.is(row)) {
421
+ return PrimitiveValueListNode.create([...row.values, this.tid])
422
+ }
423
+ if (ValueListNode.is(row)) {
424
+ return ValueListNode.create([...row.values, ValueNode.create(this.tid)])
425
+ }
426
+ return row
427
+ })
428
+ return {
429
+ ...node,
430
+ columns: newColumns,
431
+ values: ValuesNode.create(newRows as ReadonlyArray<typeof valuesNode.values[number]>),
432
+ }
433
+ }
434
+
435
+ private assertTenantOwnershipIsNotUpdated(
436
+ updates: ReadonlyArray<ColumnUpdateNode> | undefined,
437
+ tableName: string,
438
+ ): void {
439
+ if (!updates) return
440
+ for (const update of updates) {
441
+ const columnName = this.resolveColumnName(update.column)
442
+ if (columnName === null) {
443
+ throw new Error(
444
+ `TenantFilterPlugin: UPDATE uses a dynamic/raw column name on ` +
445
+ `tenant-aware table ${tableName}. Use statically named columns so ` +
446
+ `tenant_id ownership can be verified.`,
447
+ )
448
+ }
449
+ if (columnName === TENANT_ID_COLUMN) {
450
+ throw new Error(
451
+ `TenantFilterPlugin: UPDATE cannot change tenant_id on tenant-aware ` +
452
+ `table ${tableName}. Move ownership through a reviewed cross-tenant ` +
453
+ `operation, not a tenant-scoped UPDATE.`,
454
+ )
455
+ }
456
+ }
457
+ }
458
+
459
+ private resolveColumnName(node: OperationNode): string | null {
460
+ if (ColumnNode.is(node)) return node.column.name
461
+ if (ReferenceNode.is(node) && ColumnNode.is(node.column)) {
462
+ return node.column.column.name
463
+ }
464
+ return null
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Kysely plugin entry point. Builds a fresh transformer per query so
470
+ * the captured tenant id can't leak across requests.
471
+ */
472
+ export class TenantFilterPlugin implements KyselyPlugin {
473
+ private readonly tables: ReadonlySet<string>
474
+ private readonly context: TenantContextReader
475
+ private readonly onMissingContext: MissingTenantContextPolicy
476
+
477
+ constructor(
478
+ tables: ReadonlySet<string>,
479
+ options: TenantFilterPluginOptions = {},
480
+ ) {
481
+ // The catalogue is a security boundary. Snapshot it so later consumer
482
+ // mutation cannot silently change which tables receive tenant predicates.
483
+ this.tables = new Set(tables)
484
+ this.context = options.context ?? tenantContext
485
+ this.onMissingContext = options.onMissingContext ?? 'passthrough'
486
+ }
487
+
488
+ transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
489
+ const ctx = this.context.getStore()
490
+ if (!ctx) return this.handleMissingContext(args.node)
491
+ if (ctx.bypass) return args.node
492
+ if (!Number.isSafeInteger(ctx.tenantId) || ctx.tenantId == null || ctx.tenantId <= 0) {
493
+ throw new Error(
494
+ 'TenantFilterPlugin: non-bypass tenant context must contain a positive ' +
495
+ 'safe-integer tenantId. Use withTenant() to establish tenant work, or ' +
496
+ 'bypassTenant() for a reviewed cross-tenant operation.',
497
+ )
498
+ }
499
+
500
+ const transformer = new TenantFilterTransformer(this.tables, ctx.tenantId)
501
+ return transformer.transformNode(args.node) as RootOperationNode
502
+ }
503
+
504
+ private handleMissingContext(node: RootOperationNode): RootOperationNode {
505
+ if (this.onMissingContext === 'throw') {
506
+ throw new Error(
507
+ 'TenantFilterPlugin: query executed without a valid tenant context. ' +
508
+ 'Wrap tenant work in withTenant()/tenantContext.run(), or use ' +
509
+ 'bypassTenant() for a reviewed cross-tenant operation.',
510
+ )
511
+ }
512
+ return node
513
+ }
514
+
515
+ async transformResult(args: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
516
+ return args.result
517
+ }
518
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Package-owned tenant context, propagated through async calls via
3
+ * AsyncLocalStorage. Set by tenantMiddleware; read by the Kysely
4
+ * tenant-filter plugin when transforming queries.
5
+ *
6
+ * `bypass: true` tells the plugin to leave queries unmodified — used by
7
+ * cross-tenant system queries (OAuth callback before tenant resolution,
8
+ * Hub admin tenant CRUD, etc.). See `bypass-tenant.ts` for the
9
+ * caller-facing helper.
10
+ */
11
+ import { AsyncLocalStorage } from 'node:async_hooks'
12
+
13
+ export interface TenantStore {
14
+ /** null = no tenant resolved (only valid when bypass=true). */
15
+ readonly tenantId: number | null
16
+ /** When true, the Kysely plugin leaves queries unmodified. */
17
+ readonly bypass: boolean
18
+ }
19
+
20
+ export const tenantContext = new AsyncLocalStorage<TenantStore>()
21
+
22
+ /** Returns the current tenant store, or null if no run() is on the call stack. */
23
+ export function getCurrentTenant(): TenantStore | null {
24
+ return tenantContext.getStore() ?? null
25
+ }
26
+
27
+ /**
28
+ * Run `fn` with bypass=true. Preserves the existing tenantId so logs
29
+ * that read tenant context still work; only the auto-injection is
30
+ * suppressed. Outside an existing tenantContext.run, treats as
31
+ * { tenantId: null, bypass: true }.
32
+ */
33
+ export async function withBypass<T>(fn: () => T | Promise<T>): Promise<T> {
34
+ const current = tenantContext.getStore()
35
+ const next: TenantStore = current
36
+ ? { tenantId: current.tenantId, bypass: true }
37
+ : { tenantId: null, bypass: true }
38
+ return tenantContext.run(next, fn)
39
+ }
40
+
41
+ /**
42
+ * Run `fn` scoped to one tenant. Background jobs run outside the HTTP
43
+ * middleware lifecycle and must use this helper before touching tenant-aware
44
+ * tables; otherwise there is no context for the Kysely plugin to read.
45
+ */
46
+ export async function withTenant<T>(tenantId: number, fn: () => T | Promise<T>): Promise<T> {
47
+ if (!Number.isSafeInteger(tenantId) || tenantId <= 0) {
48
+ throw new RangeError('withTenant: tenantId must be a positive safe integer')
49
+ }
50
+ return tenantContext.run({ tenantId, bypass: false }, fn)
51
+ }
@@ -0,0 +1,5 @@
1
+ export { parseGuideMarkdown, stripGuideAiContext } from './markdown.ts'
2
+ export { createGuideService } from './service.ts'
3
+ export { createFileGuideContentSource, isGuideIdentifier } from './source.ts'
4
+ export { GuideError } from './types.ts'
5
+ export type * from './types.ts'
@@ -0,0 +1,108 @@
1
+ import { GuideError } from './types.ts'
2
+ import type { GuideFrontmatter, ParsedGuideMarkdown } from './types.ts'
3
+
4
+ const AI_CONTEXT_START = /<!--\s*ai-context\b/gi
5
+
6
+ /**
7
+ * Removes private authoring context from user-facing markdown. An unclosed
8
+ * marker removes the remainder of the document (fail-closed).
9
+ */
10
+ export function stripGuideAiContext(markdown: string): string {
11
+ if (typeof markdown !== 'string') malformed()
12
+
13
+ let output = ''
14
+ let cursor = 0
15
+ AI_CONTEXT_START.lastIndex = 0
16
+ for (;;) {
17
+ const match = AI_CONTEXT_START.exec(markdown)
18
+ if (!match) break
19
+ output += markdown.slice(cursor, match.index)
20
+ const end = markdown.indexOf('-->', AI_CONTEXT_START.lastIndex)
21
+ if (end < 0) return output
22
+ cursor = end + 3
23
+ AI_CONTEXT_START.lastIndex = cursor
24
+ }
25
+ return output + markdown.slice(cursor)
26
+ }
27
+
28
+ /** Tiny, deliberately limited frontmatter parser for title and description. */
29
+ export function parseGuideMarkdown(raw: string): ParsedGuideMarkdown {
30
+ if (typeof raw !== 'string') malformed()
31
+ const document = raw.startsWith('\uFEFF') ? raw.slice(1) : raw
32
+ const first = readLine(document, 0)
33
+ if (first.value !== '---') {
34
+ return freezeParsed({ title: '' }, stripGuideAiContext(document))
35
+ }
36
+
37
+ const values: Record<string, string> = Object.create(null) as Record<string, string>
38
+ let cursor = first.next
39
+ let closed = false
40
+ while (cursor <= document.length) {
41
+ const line = readLine(document, cursor)
42
+ if (line.value === '---') {
43
+ cursor = line.next
44
+ closed = true
45
+ break
46
+ }
47
+ parseFrontmatterLine(line.value, values)
48
+ if (line.next === cursor) break
49
+ cursor = line.next
50
+ }
51
+ if (!closed) malformed()
52
+
53
+ const title = values['title'] ?? ''
54
+ const description = values['description']
55
+ const frontmatter: GuideFrontmatter = Object.freeze({
56
+ title,
57
+ ...(description === undefined ? {} : { description }),
58
+ })
59
+ return Object.freeze({
60
+ frontmatter,
61
+ body: stripGuideAiContext(document.slice(cursor)),
62
+ })
63
+ }
64
+
65
+ function parseFrontmatterLine(line: string, values: Record<string, string>): void {
66
+ const trimmed = line.trim()
67
+ if (!trimmed || trimmed.startsWith('#')) return
68
+ const match = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line)
69
+ if (!match) malformed()
70
+ const key = match[1]!
71
+ if (key !== 'title' && key !== 'description') malformed()
72
+ if (Object.hasOwn(values, key)) malformed()
73
+ values[key] = parseScalar(match[2]!)
74
+ }
75
+
76
+ function parseScalar(raw: string): string {
77
+ const value = raw.trim()
78
+ if (!value) return ''
79
+ const quote = value[0]
80
+ if (quote !== '"' && quote !== "'") return value
81
+ if (value.length < 2 || value.at(-1) !== quote) malformed()
82
+ const inner = value.slice(1, -1)
83
+ if (quote === "'") return inner.replace(/''/g, "'")
84
+ try {
85
+ return JSON.parse(value) as string
86
+ } catch {
87
+ malformed()
88
+ }
89
+ }
90
+
91
+ function readLine(value: string, start: number): { value: string; next: number } {
92
+ const lf = value.indexOf('\n', start)
93
+ if (lf < 0) {
94
+ return { value: value.slice(start).replace(/\r$/, ''), next: value.length }
95
+ }
96
+ return { value: value.slice(start, lf).replace(/\r$/, ''), next: lf + 1 }
97
+ }
98
+
99
+ function freezeParsed(frontmatter: GuideFrontmatter, body: string): ParsedGuideMarkdown {
100
+ return Object.freeze({ frontmatter: Object.freeze(frontmatter), body })
101
+ }
102
+
103
+ function malformed(): never {
104
+ throw new GuideError({
105
+ code: 'malformed_document',
106
+ message: 'Guide document is malformed',
107
+ })
108
+ }