@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.
- package/README.md +466 -0
- package/package.json +48 -0
- package/src/ai/client.ts +1059 -0
- package/src/ai/errors.ts +114 -0
- package/src/ai/index.ts +27 -0
- package/src/ai/model-policy.ts +27 -0
- package/src/ai/pricing.ts +158 -0
- package/src/ai/providers/deepseek.ts +61 -0
- package/src/ai/providers/gemini.ts +919 -0
- package/src/ai/providers/openai-compatible.ts +731 -0
- package/src/ai/providers/sse.ts +163 -0
- package/src/ai/registry.ts +65 -0
- package/src/ai/schema.ts +382 -0
- package/src/ai/types.ts +282 -0
- package/src/auth/browser-exchange.ts +316 -0
- package/src/auth/errors.ts +57 -0
- package/src/auth/index.ts +29 -0
- package/src/auth/login-policy.ts +95 -0
- package/src/auth/oauth-state.ts +332 -0
- package/src/auth/passkey.ts +760 -0
- package/src/auth/redaction.ts +142 -0
- package/src/auth/session.ts +106 -0
- package/src/auth/types.ts +72 -0
- package/src/changelog/catalogue.ts +140 -0
- package/src/changelog/index.ts +5 -0
- package/src/changelog/locale.ts +122 -0
- package/src/changelog/service.ts +148 -0
- package/src/changelog/types.ts +122 -0
- package/src/db/bypass-tenant.ts +15 -0
- package/src/db/plugins/tenant-filter.ts +518 -0
- package/src/db/tenant-context.ts +51 -0
- package/src/guide/index.ts +5 -0
- package/src/guide/markdown.ts +108 -0
- package/src/guide/service.ts +345 -0
- package/src/guide/source.ts +116 -0
- package/src/guide/types.ts +177 -0
- package/src/index.ts +1 -0
- package/src/tenant.ts +14 -0
|
@@ -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
|
+
}
|