fastify-auto-sqlapi 0.2.0 → 0.2.1

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.
Files changed (40) hide show
  1. package/AGENTS_BACKEND.md +48 -3
  2. package/BREAKING_CHANGES.md +81 -0
  3. package/CHANGELOG.md +57 -0
  4. package/README.md +109 -2
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/lib/db.d.ts +6 -1
  8. package/dist/lib/db.d.ts.map +1 -1
  9. package/dist/lib/db.js +8 -2
  10. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  11. package/dist/lib/engine/bulk/bulk-upsert.js +4 -3
  12. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  13. package/dist/lib/engine/rest/get.js +5 -4
  14. package/dist/lib/engine/rest/update.js +1 -1
  15. package/dist/lib/engine/search/search.d.ts.map +1 -1
  16. package/dist/lib/engine/search/search.js +5 -4
  17. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  18. package/dist/lib/engine/write-helpers.js +2 -2
  19. package/dist/lib/errors.d.ts +24 -0
  20. package/dist/lib/errors.d.ts.map +1 -1
  21. package/dist/lib/errors.js +59 -0
  22. package/dist/lib/table-helpers.d.ts.map +1 -1
  23. package/dist/lib/table-helpers.js +48 -0
  24. package/dist/lib/tenant.d.ts +41 -6
  25. package/dist/lib/tenant.d.ts.map +1 -1
  26. package/dist/lib/tenant.js +169 -54
  27. package/dist/routes/auto/plugin.d.ts.map +1 -1
  28. package/dist/routes/auto/plugin.js +6 -0
  29. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  30. package/dist/routes/auto/route-helpers.js +12 -3
  31. package/dist/types/plugin.d.ts +19 -0
  32. package/dist/types/plugin.d.ts.map +1 -1
  33. package/dist/types/tenant.d.ts +26 -2
  34. package/dist/types/tenant.d.ts.map +1 -1
  35. package/docs/GLOSSARY.md +3 -1
  36. package/docs/adr/0006-raw-db-errors.md +2 -1
  37. package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
  38. package/docs/adr/0013-sanitized-db-errors.md +114 -0
  39. package/docs/adr/README.md +1 -0
  40. package/package.json +1 -1
@@ -10,3 +10,62 @@ export function httpError(statusCode, message) {
10
10
  err.statusCode = statusCode;
11
11
  return err;
12
12
  }
13
+ /**
14
+ * Replace an error escaping a route handler with one that carries no database detail.
15
+ *
16
+ * Driver messages name tables, columns and constraints, and the route error handler sends
17
+ * `error.message` verbatim — so a unique violation describes the schema to whoever
18
+ * triggered it. Errors the plugin raises itself are deliberate `4xx` with messages written
19
+ * for clients (`httpError`, `validationErrors`) and pass through untouched; everything
20
+ * else becomes a bare `500`.
21
+ *
22
+ * The original is attached as `cause`, so a consumer `setErrorHandler` can still apply its
23
+ * own policy — mapping SQLSTATE to a status code stays the consumer's decision (ADR 0006).
24
+ * No status code is remapped here: a `500` stays a `500`.
25
+ *
26
+ * `requestId` is carried on the sanitized error and rendered in the response body: it is the
27
+ * same value Fastify logs as `reqId` on every line, so the log entry holding the real error
28
+ * is one grep away from the client's `500`.
29
+ *
30
+ * With `exposeDebugInfo`, the original is also described in a `debugInfo` payload rendered
31
+ * beside those fields — additive, so the response a client codes against is the same one it
32
+ * gets in production. It is off by default and derived from nothing: `debug` belongs to the
33
+ * consumer, who may wire it to an environment variable, and no option may open an external
34
+ * channel as a side effect of meaning something internal. See ADR 0013.
35
+ */
36
+ export function sanitizeRouteError(err, requestId, exposeDebugInfo = false) {
37
+ const statusCode = err?.statusCode;
38
+ if (typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500)
39
+ return err;
40
+ const safe = httpError(500, 'Internal Server Error');
41
+ safe.name = 'Internal Server Error';
42
+ safe.cause = err;
43
+ if (requestId)
44
+ safe.requestId = requestId;
45
+ if (exposeDebugInfo)
46
+ safe.debugInfo = describeError(err);
47
+ return safe;
48
+ }
49
+ /**
50
+ * Driver properties worth handing a developer, across both supported drivers: `pg` puts the
51
+ * schema detail in `constraint`/`detail`/`table`/`column`, `mysql2` in `errno`/`sqlState`/
52
+ * `sqlMessage`. Copied by allowlist rather than spread, so a driver that starts attaching
53
+ * the connection config (password included) to its errors cannot ship it to a client.
54
+ */
55
+ const DEBUG_ERROR_PROPS = [
56
+ 'code', 'errno', 'sqlState', 'sqlMessage',
57
+ 'constraint', 'detail', 'hint', 'table', 'column', 'schema', 'routine', 'position',
58
+ ];
59
+ function describeError(err) {
60
+ if (!(err instanceof Error))
61
+ return { value: String(err) };
62
+ const info = { name: err.name, message: err.message };
63
+ for (const prop of DEBUG_ERROR_PROPS) {
64
+ const value = err[prop];
65
+ if (value !== undefined)
66
+ info[prop] = value;
67
+ }
68
+ if (err.stack)
69
+ info.stack = err.stack;
70
+ return info;
71
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EACP,MAAM,aAAa,CAAC;AAarB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAOX;AAqFD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
1
+ {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EACP,MAAM,aAAa,CAAC;AAarB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAQX;AAoJD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
@@ -50,8 +50,56 @@ export function defineTable(config) {
50
50
  validateWriteJoinsUnrestricted(config.allowedWriteJoins);
51
51
  validateComputedFields(config);
52
52
  validateReadExclude(config);
53
+ validateTenantScope(config);
53
54
  return config;
54
55
  }
56
+ /**
57
+ * `tenantScope` has three forms and they do not mix: `{ column }`, `{ column, through }`, and
58
+ * `{ anyOf }`. A config naming more than one form is ambiguous rather than additive — an `anyOf`
59
+ * entry reached through a parent FK would need its own join per entry — and an empty `anyOf`
60
+ * would fail closed on every row, which looks like a broken deployment rather than a rule.
61
+ *
62
+ * Column *names* are not checked against the schema: a tenant column may legitimately be kept
63
+ * out of the generated schema (see ADR 0009) while still scoping the table.
64
+ */
65
+ function validateTenantScope(config) {
66
+ const scope = config.tenantScope;
67
+ if (!scope)
68
+ return;
69
+ const table = config.Schema.tableName;
70
+ const hasAnyOf = 'anyOf' in scope;
71
+ const hasColumn = 'column' in scope;
72
+ if (hasAnyOf && hasColumn) {
73
+ throw new Error(`defineTable: tenantScope on table '${table}' declares both 'anyOf' and 'column'. ` +
74
+ `Use one form: 'column' (owner column on this table), 'column' + 'through' (owner ` +
75
+ `reached via a FK), or 'anyOf' (several owner columns, visible to any of them).`);
76
+ }
77
+ if (hasAnyOf && 'through' in scope) {
78
+ throw new Error(`defineTable: tenantScope on table '${table}' combines 'anyOf' with 'through'. ` +
79
+ `An 'anyOf' entry reached through a parent is not supported — declare the scope on ` +
80
+ `the parent table instead.`);
81
+ }
82
+ if (hasAnyOf) {
83
+ const { anyOf } = scope;
84
+ if (!Array.isArray(anyOf) || !anyOf.length) {
85
+ throw new Error(`defineTable: tenantScope 'anyOf' on table '${table}' must be a non-empty array of ` +
86
+ `column names. An empty list would hide every row of the table.`);
87
+ }
88
+ if (anyOf.some((c) => typeof c !== 'string' || !c)) {
89
+ throw new Error(`defineTable: tenantScope 'anyOf' on table '${table}' must contain column names as ` +
90
+ `non-empty strings.`);
91
+ }
92
+ if (new Set(anyOf).size !== anyOf.length) {
93
+ throw new Error(`defineTable: tenantScope 'anyOf' on table '${table}' repeats a column. ` +
94
+ `Each owner column must appear once.`);
95
+ }
96
+ return;
97
+ }
98
+ if (!hasColumn || typeof scope.column !== 'string') {
99
+ throw new Error(`defineTable: tenantScope on table '${table}' declares neither 'column' nor 'anyOf'. ` +
100
+ `A scope with no owner column would filter nothing.`);
101
+ }
102
+ }
55
103
  /**
56
104
  * `fields` narrows a relation for reading; it has no meaning on a write join and would be
57
105
  * actively harmful there. Write paths resolve the secondary's upsert rule by looking the
@@ -1,8 +1,15 @@
1
1
  import type { FastifyRequest } from 'fastify';
2
2
  import type { QueryClient } from './db.js';
3
3
  import { ConditionBuilder, type ConditionValue, type ConditionValueOrUndefined } from 'node-condition-builder';
4
- import type { ITable, SqlApiPluginOptions, TenantId, TenantScope, TenantScopeIndirect, TenantContext } from '../types.js';
4
+ import type { ITable, SqlApiPluginOptions, TenantId, TenantScope, TenantScopeDirect, TenantScopeIndirect, TenantScopeAnyOf, TenantContext } from '../types.js';
5
5
  export declare function resolveTenant(options: SqlApiPluginOptions, tableConf: ITable, request: FastifyRequest): Promise<TenantContext | undefined>;
6
+ /**
7
+ * The read predicate for a scope, as a fragment every caller appends to its own WHERE.
8
+ *
9
+ * An `anyOf` scope returns an OR-mode builder — the row is visible when any listed column holds
10
+ * a tenant id — and every caller appends it, so the group keeps its own parentheses. A NULL
11
+ * column simply fails to match, which is the wanted behaviour: the other party still decides.
12
+ */
6
13
  export declare function buildTenantCondition(db: QueryClient, scope: TenantScope, tenantIds: TenantId[], ownTable: string): ConditionBuilder;
7
14
  /**
8
15
  * Re-target a tenant context onto a related table: the caller's tenant ids paired with that
@@ -21,21 +28,35 @@ export declare function tenantForTable(tenant: TenantContext | undefined, tableC
21
28
  *
22
29
  * `tableRef` is the name the scoped rows go by in that statement — the table name in a DELETE,
23
30
  * the join alias in an ON clause, the subquery alias inside a correlated SELECT. Returns
24
- * undefined without a tenant context (admin).
31
+ * undefined without a tenant context (admin) or when the table carries no scope.
25
32
  */
26
33
  export declare function buildTenantRowGuard(db: QueryClient, tenant: TenantContext | undefined, tableRef: string): ConditionBuilder | undefined;
27
34
  export declare function buildTenantJoin(db: QueryClient, scope: TenantScopeIndirect, mainTableName: string): string;
35
+ /**
36
+ * The insert-side rule for an `anyOf` scope: the payload must anchor the row itself.
37
+ *
38
+ * Nothing is auto-injected — with several owner columns the plugin cannot know which party the
39
+ * caller is meant to be, and guessing would either invent a relationship or refuse a legitimate
40
+ * one. So the record must name at least one listed column, and at least one of the named ones
41
+ * must hold a tenant id. The *other* parties are deliberately left alone: a swap request points
42
+ * at a colleague by definition, and requiring every listed column to match would make the shape
43
+ * unusable.
44
+ *
45
+ * @testonly Exported only so unit tests can exercise it directly.
46
+ */
47
+ export declare function assertTenantAnchor(record: Record<string, unknown>, scope: TenantScopeAnyOf, tenantIds: TenantId[]): void;
28
48
  /** Writes the tenant value into a record according to its scope.
29
49
  * @testonly Exported only so unit tests can exercise it directly.
30
50
  */
31
- export declare function injectTenantValue(record: Record<string, unknown>, scope: TenantScope, tenantIds: TenantId[]): void;
51
+ export declare function injectTenantValue(record: Record<string, unknown>, scope: TenantScopeDirect | TenantScopeIndirect, tenantIds: TenantId[]): void;
32
52
  /** Checks that an indirect-scope FK really belongs to the tenant.
33
53
  * @testonly Exported only so unit tests can exercise it directly.
34
54
  */
35
55
  export declare function validateTenantFK(db: QueryClient, scope: TenantScopeIndirect, tenantIds: TenantId[], fkValues: unknown[]): Promise<void>;
36
56
  /**
37
57
  * Build WHERE clause for DELETE with tenant filtering.
38
- * Handles both direct (simple AND) and indirect (subquery via JOIN) scopes.
58
+ * Handles both direct (simple AND) and indirect (subquery via JOIN) scopes; a tenant whose
59
+ * addressed table declares no scope yields the plain primary-key clause.
39
60
  */
40
61
  export declare function buildTenantDeleteWhere(db: QueryClient, tableName: string, pkCol: string, pkValue: ConditionValueOrUndefined | ConditionValue[], tenant: TenantContext): {
41
62
  where: string;
@@ -64,8 +85,9 @@ export declare function assertTenantOwnsConflicts(db: QueryClient, tenant: Tenan
64
85
  */
65
86
  export declare function enforceTenantFKOnUpdate(db: QueryClient, tenant: TenantContext | undefined, updateFields: Record<string, unknown>): Promise<void>;
66
87
  /**
67
- * Mutates `fields` in-place: removes the tenant column from a SET payload (direct scopes only).
68
- * No-op for indirect or no scope.
88
+ * Mutates `fields` in-place: removes the tenant columns from a SET payload (scopes whose columns
89
+ * live on the table itself). An `anyOf` scope drops *every* listed column — neither party can be
90
+ * reassigned, the same spirit as "a tenant cannot move its own row". No-op for indirect scopes.
69
91
  */
70
92
  export declare function stripTenantColumn(fields: Record<string, unknown>, scope: TenantScope): void;
71
93
  /**
@@ -75,6 +97,19 @@ export declare function stripTenantColumn(fields: Record<string, unknown>, scope
75
97
  * Throws 400 (ambiguous tenant) or 403 (mismatch) on violation. No-op when `tenant` is undefined.
76
98
  */
77
99
  export declare function enforceTenantOnWrites(db: QueryClient, tenant: TenantContext | undefined, records: Record<string, unknown>[]): Promise<void>;
100
+ /**
101
+ * Columns an UPSERT must leave alone when it lands on an existing row.
102
+ *
103
+ * `stripTenantColumn` does this for a plain UPDATE, but an upsert's payload doubles as the
104
+ * INSERT branch, where the owner columns are exactly what anchors a new row — they cannot simply
105
+ * be dropped. So they are excluded from the `DO UPDATE SET` instead: state which party you are
106
+ * to create the row, and that statement is ignored when the row already exists.
107
+ *
108
+ * Only `anyOf` scopes need this. A direct scope injects the caller's own tenant, so writing it
109
+ * back onto a row the conflict guard already proved theirs is a no-op; an indirect one keeps its
110
+ * owner on another table entirely.
111
+ */
112
+ export declare function tenantImmutableColumns(tenant: TenantContext | undefined): string[];
78
113
  /**
79
114
  * For update: builds the optional `extraCondition` ConditionBuilder for direct tenants.
80
115
  * Returns undefined for indirect or no-tenant scenarios — those are handled separately
@@ -1 +1 @@
1
- {"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../../src/lib/tenant.ts"],"names":[],"mappings":"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"}
@@ -3,31 +3,72 @@ import { httpError } from './errors.js';
3
3
  function isIndirect(scope) {
4
4
  return 'through' in scope;
5
5
  }
6
+ function isAnyOf(scope) {
7
+ return 'anyOf' in scope;
8
+ }
9
+ /**
10
+ * The tenant columns living on the scoped table itself, in declaration order: one for a direct
11
+ * scope, several for an `anyOf` one. Never called for an indirect scope, whose column belongs to
12
+ * the through table.
13
+ */
14
+ function ownColumns(scope) {
15
+ return isAnyOf(scope) ? scope.anyOf : [scope.column];
16
+ }
17
+ /**
18
+ * Whether this request could touch a scoped row at all: the addressed table is scoped, or one of
19
+ * the tables it can reach through a declared relation is.
20
+ *
21
+ * Relations are what makes the second case necessary — a relation is a read grant on its target
22
+ * and a write join a write grant, so a deliberately public host must still carry its neighbours'
23
+ * scopes into the joins and secondaries it drives. One level is the whole reach: joins resolve
24
+ * from the addressed table's own `allowedReadJoins`/`allowedWriteJoins` and never chain further.
25
+ *
26
+ * When nothing scoped is in reach, `getTenantId` is not called at all — a request that cannot
27
+ * read or write a scoped row should not pay for resolving a tenant, and consumer code that
28
+ * assumes an authenticated request should not run on routes that never needed one.
29
+ */
30
+ function scopeInReach(tableConf, dbTables) {
31
+ if (tableConf.tenantScope)
32
+ return true;
33
+ if (!dbTables)
34
+ return false;
35
+ const relations = [...(tableConf.allowedReadJoins ?? []), ...(tableConf.allowedWriteJoins ?? [])];
36
+ return relations.some((join) => dbTables[join.joinSchema.tableName]?.tenantScope !== undefined);
37
+ }
6
38
  export async function resolveTenant(options, tableConf, request) {
7
39
  if (!options.getTenantId)
8
40
  return undefined;
9
- if (!tableConf.tenantScope)
41
+ if (!scopeInReach(tableConf, options.DbTables))
10
42
  return undefined;
11
43
  const raw = await options.getTenantId(request);
12
44
  if (raw == null)
13
45
  return undefined; // admin
14
46
  const ids = Array.isArray(raw) ? raw : [raw];
47
+ // `scope` stays undefined for an unscoped host: the ids still travel, so the tables it reaches
48
+ // enforce their own scopes, while the host itself keeps no filter.
15
49
  return { ids, scope: tableConf.tenantScope };
16
50
  }
51
+ /**
52
+ * The read predicate for a scope, as a fragment every caller appends to its own WHERE.
53
+ *
54
+ * An `anyOf` scope returns an OR-mode builder — the row is visible when any listed column holds
55
+ * a tenant id — and every caller appends it, so the group keeps its own parentheses. A NULL
56
+ * column simply fails to match, which is the wanted behaviour: the other party still decides.
57
+ */
17
58
  export function buildTenantCondition(db, scope, tenantIds, ownTable) {
18
- const col = scope.column;
19
- let qualifier;
20
59
  if (isIndirect(scope)) {
21
60
  const throughTable = scope.through.schema.tableName;
22
- qualifier = `${db.qi(throughTable)}.${db.qi(col)}`;
61
+ const cb = new ConditionBuilder('AND', db.cbDialect);
62
+ cb.isIn(`${db.qi(throughTable)}.${db.qi(scope.column)}`, tenantIds);
63
+ return cb;
23
64
  }
24
- else {
25
- // Qualify with the table the scope column lives on: the statement may carry
26
- // joins, and a bare column shared with a joined table would be ambiguous.
27
- qualifier = `${db.qi(ownTable)}.${db.qi(col)}`;
65
+ // Qualify with the table the scope columns live on: the statement may carry
66
+ // joins, and a bare column shared with a joined table would be ambiguous.
67
+ const cols = ownColumns(scope);
68
+ const cb = new ConditionBuilder(cols.length > 1 ? 'OR' : 'AND', db.cbDialect);
69
+ for (const col of cols) {
70
+ cb.isIn(`${db.qi(ownTable)}.${db.qi(col)}`, tenantIds);
28
71
  }
29
- const cb = new ConditionBuilder('AND', db.cbDialect);
30
- cb.isIn(qualifier, tenantIds);
31
72
  return cb;
32
73
  }
33
74
  /**
@@ -52,17 +93,18 @@ export function tenantForTable(tenant, tableConf) {
52
93
  *
53
94
  * `tableRef` is the name the scoped rows go by in that statement — the table name in a DELETE,
54
95
  * the join alias in an ON clause, the subquery alias inside a correlated SELECT. Returns
55
- * undefined without a tenant context (admin).
96
+ * undefined without a tenant context (admin) or when the table carries no scope.
56
97
  */
57
98
  export function buildTenantRowGuard(db, tenant, tableRef) {
58
- if (!tenant)
99
+ const scope = tenant?.scope;
100
+ if (!tenant || !scope)
59
101
  return undefined;
60
102
  const cb = new ConditionBuilder('AND', db.cbDialect);
61
- if (!isIndirect(tenant.scope)) {
62
- cb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableRef));
103
+ if (!isIndirect(scope)) {
104
+ cb.append(buildTenantCondition(db, scope, tenant.ids, tableRef));
63
105
  return cb;
64
106
  }
65
- const { through, column } = tenant.scope;
107
+ const { through, column } = scope;
66
108
  const throughRef = db.qi(through.schema.tableName);
67
109
  const markers = tenant.ids.map(() => '?').join(', ');
68
110
  cb.raw(`EXISTS (SELECT 1 FROM ${throughRef} WHERE ` +
@@ -76,6 +118,27 @@ export function buildTenantJoin(db, scope, mainTableName) {
76
118
  const foreignField = scope.through.foreignField;
77
119
  return `INNER JOIN ${db.qi(throughTable)} ON ${db.qi(mainTableName)}.${db.qi(localField)} = ${db.qi(throughTable)}.${db.qi(foreignField)}`;
78
120
  }
121
+ /**
122
+ * The insert-side rule for an `anyOf` scope: the payload must anchor the row itself.
123
+ *
124
+ * Nothing is auto-injected — with several owner columns the plugin cannot know which party the
125
+ * caller is meant to be, and guessing would either invent a relationship or refuse a legitimate
126
+ * one. So the record must name at least one listed column, and at least one of the named ones
127
+ * must hold a tenant id. The *other* parties are deliberately left alone: a swap request points
128
+ * at a colleague by definition, and requiring every listed column to match would make the shape
129
+ * unusable.
130
+ *
131
+ * @testonly Exported only so unit tests can exercise it directly.
132
+ */
133
+ export function assertTenantAnchor(record, scope, tenantIds) {
134
+ const present = scope.anyOf.filter((col) => record[col] != null);
135
+ if (!present.length) {
136
+ throw httpError(400, `Missing tenant anchor: one of ${scope.anyOf.join(', ')} must be set`);
137
+ }
138
+ if (!present.some((col) => tenantIds.includes(record[col]))) {
139
+ throw httpError(403, 'Access denied: tenant value does not match');
140
+ }
141
+ }
79
142
  /** Writes the tenant value into a record according to its scope.
80
143
  * @testonly Exported only so unit tests can exercise it directly.
81
144
  */
@@ -121,16 +184,18 @@ export async function validateTenantFK(db, scope, tenantIds, fkValues) {
121
184
  }
122
185
  /**
123
186
  * Build WHERE clause for DELETE with tenant filtering.
124
- * Handles both direct (simple AND) and indirect (subquery via JOIN) scopes.
187
+ * Handles both direct (simple AND) and indirect (subquery via JOIN) scopes; a tenant whose
188
+ * addressed table declares no scope yields the plain primary-key clause.
125
189
  */
126
190
  export function buildTenantDeleteWhere(db, tableName, pkCol, pkValue, tenant) {
127
- if (isIndirect(tenant.scope)) {
191
+ const scope = tenant.scope;
192
+ if (scope && isIndirect(scope)) {
128
193
  const innerCb = new ConditionBuilder('AND', db.cbDialect);
129
194
  innerCb.isIn(`${db.qi(tableName)}.${db.qi(pkCol)}`, (Array.isArray(pkValue) ? pkValue : [pkValue]));
130
- innerCb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableName));
195
+ innerCb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
131
196
  const innerWhere = innerCb.build(1, db.ph);
132
197
  const values = innerCb.getValues();
133
- const joinSql = buildTenantJoin(db, tenant.scope, tableName);
198
+ const joinSql = buildTenantJoin(db, scope, tableName);
134
199
  const where = `${db.qi(pkCol)} IN (SELECT ${db.qi(tableName)}.${db.qi(pkCol)} FROM ${db.qi(tableName)} ${joinSql} WHERE ${innerWhere})`;
135
200
  return { where, values };
136
201
  }
@@ -141,7 +206,8 @@ export function buildTenantDeleteWhere(db, tableName, pkCol, pkValue, tenant) {
141
206
  else {
142
207
  cb.isEqual(db.qi(pkCol), pkValue);
143
208
  }
144
- cb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableName));
209
+ if (scope)
210
+ cb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
145
211
  const where = cb.build(1, db.ph);
146
212
  const values = cb.getValues();
147
213
  return { where, values };
@@ -158,7 +224,8 @@ export function buildTenantDeleteWhere(db, tableName, pkCol, pkValue, tenant) {
158
224
  * any conflict column are skipped (they can only INSERT, never hit a conflict).
159
225
  */
160
226
  export async function assertTenantOwnsConflicts(db, tenant, tableName, conflictCols, records) {
161
- if (!tenant || !conflictCols.length)
227
+ const scope = tenant?.scope;
228
+ if (!tenant || !scope || !conflictCols.length)
162
229
  return;
163
230
  // Collect distinct, fully-specified conflict-key tuples.
164
231
  const tuples = [];
@@ -199,19 +266,35 @@ export async function assertTenantOwnsConflicts(db, tenant, tableName, conflictC
199
266
  }
200
267
  // Tenant-mismatch clause: rows whose tenant is NOT one the caller owns.
201
268
  let joinSql = '';
202
- let tenantCol;
203
- if (isIndirect(tenant.scope)) {
204
- joinSql = ` ${buildTenantJoin(db, tenant.scope, tableName)}`;
205
- tenantCol = `${db.qi(tenant.scope.through.schema.tableName)}.${db.qi(tenant.scope.column)}`;
269
+ let tenantCols;
270
+ if (isIndirect(scope)) {
271
+ joinSql = ` ${buildTenantJoin(db, scope, tableName)}`;
272
+ tenantCols = [`${db.qi(scope.through.schema.tableName)}.${db.qi(scope.column)}`];
206
273
  }
207
274
  else {
208
- tenantCol = `${qTable}.${db.qi(tenant.scope.column)}`;
275
+ tenantCols = ownColumns(scope).map((c) => `${qTable}.${db.qi(c)}`);
276
+ }
277
+ const inClause = (col) => {
278
+ const phs = tenant.ids.map((id) => {
279
+ values.push(id);
280
+ return db.ph(values.length);
281
+ });
282
+ return `${col} IN (${phs.join(', ')})`;
283
+ };
284
+ let mismatchSql;
285
+ if (tenantCols.length === 1) {
286
+ mismatchSql = `${tenantCols[0]} NOT IN (${tenant.ids.map((id) => {
287
+ values.push(id);
288
+ return db.ph(values.length);
289
+ }).join(', ')})`;
290
+ }
291
+ else {
292
+ // A NULL party makes `IN` return NULL, and `NOT NULL` is not TRUE — a plain negation would
293
+ // let a row with one party NULL and the other owned by a stranger pass the probe unflagged.
294
+ // COALESCE turns "unknown" into "not visible to me", which is what the read side already
295
+ // does implicitly by never matching a NULL.
296
+ mismatchSql = `NOT COALESCE(${tenantCols.map(inClause).join(' OR ')}, FALSE)`;
209
297
  }
210
- const tenantPhs = tenant.ids.map((id) => {
211
- values.push(id);
212
- return db.ph(values.length);
213
- });
214
- const mismatchSql = `${tenantCol} NOT IN (${tenantPhs.join(', ')})`;
215
298
  const sql = `SELECT 1 FROM ${qTable}${joinSql} WHERE ${matchSql} AND ${mismatchSql} LIMIT 1`;
216
299
  const r = await db.query(sql, values);
217
300
  if (r.rows.length > 0) {
@@ -228,24 +311,27 @@ export async function assertTenantOwnsConflicts(db, tenant, tableName, conflictC
228
311
  * `updateFields` keys are DB column names (snake_case), matching `through.localField`.
229
312
  */
230
313
  export async function enforceTenantFKOnUpdate(db, tenant, updateFields) {
231
- if (!tenant || !isIndirect(tenant.scope))
314
+ if (!tenant?.scope || !isIndirect(tenant.scope))
232
315
  return;
233
- const localCol = tenant.scope.through.localField;
316
+ const scope = tenant.scope;
317
+ const localCol = scope.through.localField;
234
318
  if (!(localCol in updateFields))
235
319
  return;
236
320
  const newValue = updateFields[localCol];
237
321
  if (newValue == null)
238
322
  return;
239
- await validateTenantFK(db, tenant.scope, tenant.ids, [newValue]);
323
+ await validateTenantFK(db, scope, tenant.ids, [newValue]);
240
324
  }
241
325
  /**
242
- * Mutates `fields` in-place: removes the tenant column from a SET payload (direct scopes only).
243
- * No-op for indirect or no scope.
326
+ * Mutates `fields` in-place: removes the tenant columns from a SET payload (scopes whose columns
327
+ * live on the table itself). An `anyOf` scope drops *every* listed column — neither party can be
328
+ * reassigned, the same spirit as "a tenant cannot move its own row". No-op for indirect scopes.
244
329
  */
245
330
  export function stripTenantColumn(fields, scope) {
246
331
  if (isIndirect(scope))
247
332
  return;
248
- delete fields[scope.column];
333
+ for (const col of ownColumns(scope))
334
+ delete fields[col];
249
335
  }
250
336
  /**
251
337
  * For insert/bulk-upsert: enforce tenant on records that are about to be written.
@@ -254,15 +340,38 @@ export function stripTenantColumn(fields, scope) {
254
340
  * Throws 400 (ambiguous tenant) or 403 (mismatch) on violation. No-op when `tenant` is undefined.
255
341
  */
256
342
  export async function enforceTenantOnWrites(db, tenant, records) {
257
- if (!tenant)
343
+ const scope = tenant?.scope;
344
+ if (!tenant || !scope)
258
345
  return;
259
- if (isIndirect(tenant.scope)) {
260
- const fkCol = tenant.scope.through.localField;
261
- await validateTenantFK(db, tenant.scope, tenant.ids, records.map((r) => r[fkCol]));
346
+ if (isIndirect(scope)) {
347
+ const fkCol = scope.through.localField;
348
+ await validateTenantFK(db, scope, tenant.ids, records.map((r) => r[fkCol]));
349
+ return;
350
+ }
351
+ if (isAnyOf(scope)) {
352
+ for (const r of records)
353
+ assertTenantAnchor(r, scope, tenant.ids);
262
354
  return;
263
355
  }
264
356
  for (const r of records)
265
- injectTenantValue(r, tenant.scope, tenant.ids);
357
+ injectTenantValue(r, scope, tenant.ids);
358
+ }
359
+ /**
360
+ * Columns an UPSERT must leave alone when it lands on an existing row.
361
+ *
362
+ * `stripTenantColumn` does this for a plain UPDATE, but an upsert's payload doubles as the
363
+ * INSERT branch, where the owner columns are exactly what anchors a new row — they cannot simply
364
+ * be dropped. So they are excluded from the `DO UPDATE SET` instead: state which party you are
365
+ * to create the row, and that statement is ignored when the row already exists.
366
+ *
367
+ * Only `anyOf` scopes need this. A direct scope injects the caller's own tenant, so writing it
368
+ * back onto a row the conflict guard already proved theirs is a no-op; an indirect one keeps its
369
+ * owner on another table entirely.
370
+ */
371
+ export function tenantImmutableColumns(tenant) {
372
+ if (!tenant?.scope || !isAnyOf(tenant.scope))
373
+ return [];
374
+ return tenant.scope.anyOf;
266
375
  }
267
376
  /**
268
377
  * For update: builds the optional `extraCondition` ConditionBuilder for direct tenants.
@@ -270,13 +379,17 @@ export async function enforceTenantOnWrites(db, tenant, records) {
270
379
  * (indirect uses pre-check via `assertTenantOwnership`).
271
380
  */
272
381
  export function buildTenantUpdateExtra(db, tenant) {
273
- if (!tenant || isIndirect(tenant.scope))
382
+ const scope = tenant?.scope;
383
+ if (!scope || isIndirect(scope))
274
384
  return undefined;
275
- const cb = new ConditionBuilder('AND', db.cbDialect);
276
- if (tenant.ids.length === 1)
277
- cb.isEqual(tenant.scope.column, tenant.ids[0]);
278
- else
279
- cb.isIn(tenant.scope.column, tenant.ids);
385
+ const cols = ownColumns(scope);
386
+ const cb = new ConditionBuilder(cols.length > 1 ? 'OR' : 'AND', db.cbDialect);
387
+ for (const col of cols) {
388
+ if (tenant.ids.length === 1)
389
+ cb.isEqual(col, tenant.ids[0]);
390
+ else
391
+ cb.isIn(col, tenant.ids);
392
+ }
280
393
  return cb;
281
394
  }
282
395
  /**
@@ -285,14 +398,15 @@ export function buildTenantUpdateExtra(db, tenant) {
285
398
  * No-op for direct or no-tenant — those are enforced via `extraCondition` in the UPDATE itself.
286
399
  */
287
400
  export async function assertTenantOwnership(db, tenant, tableName, pkCol, pkValue) {
288
- if (!tenant || !isIndirect(tenant.scope))
401
+ if (!tenant?.scope || !isIndirect(tenant.scope))
289
402
  return;
403
+ const scope = tenant.scope;
290
404
  const cb = new ConditionBuilder('AND', db.cbDialect);
291
405
  const cols = Array.isArray(pkCol) ? pkCol : [pkCol];
292
406
  const vals = Array.isArray(pkValue) ? pkValue : [pkValue];
293
407
  cols.forEach((c, i) => cb.isEqual(`${db.qi(tableName)}.${db.qi(c)}`, vals[i]));
294
- cb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableName));
295
- const sql = `SELECT 1 FROM ${db.qi(tableName)} ${buildTenantJoin(db, tenant.scope, tableName)} WHERE ${cb.build(1, db.ph)} LIMIT 1`;
408
+ cb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
409
+ const sql = `SELECT 1 FROM ${db.qi(tableName)} ${buildTenantJoin(db, scope, tableName)} WHERE ${cb.build(1, db.ph)} LIMIT 1`;
296
410
  const r = await db.query(sql, cb.getValues());
297
411
  if (r.rows.length === 0) {
298
412
  throw httpError(404, 'Record not found');
@@ -305,13 +419,14 @@ export async function assertTenantOwnership(db, tenant, tableName, pkCol, pkValu
305
419
  * `beforeBulkDelete` hook, so user code never runs against rows the caller cannot access.
306
420
  */
307
421
  export async function assertTenantOwnsAll(db, tenant, tableName, pkCol, ids) {
308
- if (!tenant || !ids.length)
422
+ const scope = tenant?.scope;
423
+ if (!tenant || !scope || !ids.length)
309
424
  return;
310
425
  const qualifiedPk = `${db.qi(tableName)}.${db.qi(pkCol)}`;
311
426
  const cb = new ConditionBuilder('AND', db.cbDialect);
312
427
  cb.isIn(qualifiedPk, ids);
313
- cb.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableName));
314
- const join = isIndirect(tenant.scope) ? ` ${buildTenantJoin(db, tenant.scope, tableName)}` : '';
428
+ cb.append(buildTenantCondition(db, scope, tenant.ids, tableName));
429
+ const join = isIndirect(scope) ? ` ${buildTenantJoin(db, scope, tableName)}` : '';
315
430
  const sql = `SELECT DISTINCT ${qualifiedPk} AS pk FROM ${db.qi(tableName)}${join} WHERE ${cb.build(1, db.ph)}`;
316
431
  const r = await db.query(sql, cb.getValues());
317
432
  const uniqueRequested = new Set(ids.map((v) => String(v))).size;
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAgB,MAAM,SAAS,CAAC;AAc7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;kCAuD/C,eAAe,WACf,mBAAmB,KAC3B,OAAO,CAAC,IAAI,CAAC;AAHhB,wBA6DoC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAgB,MAAM,SAAS,CAAC;AAc7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;kCAuD/C,eAAe,WACf,mBAAmB,KAC3B,OAAO,CAAC,IAAI,CAAC;AAHhB,wBAsEoC"}
@@ -82,10 +82,16 @@ export default fp(async function fastifyAutoSqlApi(fastify, options) {
82
82
  });
83
83
  }
84
84
  const statusCode = error.statusCode || 500;
85
+ // A sanitized error carries the request id so the client can point at the log line
86
+ // holding the real cause, and — under `exposeDebugInfo` — the description of that
87
+ // cause (ADR 0013). Both are additive: the fields above never change shape.
88
+ const sanitized = error;
85
89
  reply.status(statusCode).send({
86
90
  statusCode,
87
91
  error: error.name || 'Error',
88
92
  message: error.message,
93
+ ...(sanitized.requestId ? { requestId: sanitized.requestId } : {}),
94
+ ...(sanitized.debugInfo ? { debugInfo: sanitized.debugInfo } : {}),
89
95
  });
90
96
  });
91
97
  if (options.swagger) {
@@ -1 +1 @@
1
- {"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQ5F,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,gFAAgF;IAChF,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnC,aAAa,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,wIAAwI;IACxI,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IACpF;2EACuE;IACvE,MAAM,EAAE,CACN,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AA6DD,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CAuBf"}
1
+ {"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAI7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQ5F,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,gFAAgF;IAChF,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnC,aAAa,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,wIAAwI;IACxI,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IACpF;2EACuE;IACvE,MAAM,EAAE,CACN,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AA6DD,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CA6Bf"}