@happyvertical/smrt-core 0.37.7 → 0.37.8

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 (41) hide show
  1. package/AGENTS.md +15 -6
  2. package/dist/dispatch/index.d.ts +1 -1
  3. package/dist/dispatch/index.d.ts.map +1 -1
  4. package/dist/dispatch/index.js +2 -2
  5. package/dist/dispatch/tenant-resolver.d.ts +23 -0
  6. package/dist/dispatch/tenant-resolver.d.ts.map +1 -1
  7. package/dist/dispatch/tenant-resolver.js +33 -1
  8. package/dist/dispatch/tenant-resolver.js.map +1 -1
  9. package/dist/generators/conditional-get.d.ts +12 -0
  10. package/dist/generators/conditional-get.d.ts.map +1 -1
  11. package/dist/generators/conditional-get.js +30 -2
  12. package/dist/generators/conditional-get.js.map +1 -1
  13. package/dist/generators/rest.d.ts +30 -0
  14. package/dist/generators/rest.d.ts.map +1 -1
  15. package/dist/generators/rest.js +62 -7
  16. package/dist/generators/rest.js.map +1 -1
  17. package/dist/index.js +2 -2
  18. package/dist/manifest/manifest-loader.d.ts +10 -1
  19. package/dist/manifest/manifest-loader.d.ts.map +1 -1
  20. package/dist/manifest/manifest-loader.js +18 -5
  21. package/dist/manifest/manifest-loader.js.map +1 -1
  22. package/dist/manifest/static-manifest.js +2 -2
  23. package/dist/manifest/static-manifest.js.map +1 -1
  24. package/dist/manifest/store.js +1 -1
  25. package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
  26. package/dist/manifest/test-manifest-stub.js +277 -2
  27. package/dist/manifest/test-manifest-stub.js.map +1 -1
  28. package/dist/manifest.json +2 -2
  29. package/dist/registry/shared-state.d.ts +8 -1
  30. package/dist/registry/shared-state.d.ts.map +1 -1
  31. package/dist/registry/shared-state.js +11 -3
  32. package/dist/registry/shared-state.js.map +1 -1
  33. package/dist/smrt-knowledge.json +6 -6
  34. package/dist/utils/stack-frames.d.ts +50 -0
  35. package/dist/utils/stack-frames.d.ts.map +1 -0
  36. package/dist/utils/stack-frames.js +64 -0
  37. package/dist/utils/stack-frames.js.map +1 -0
  38. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  39. package/dist/vite-plugin/sveltekit-generator.js +41 -17
  40. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  41. package/package.json +4 -4
package/AGENTS.md CHANGED
@@ -103,16 +103,25 @@ When the target declares multiple FKs back to the parent, annotate `@oneToMany(T
103
103
  ## Vite Plugin
104
104
 
105
105
  ```typescript
106
- // vite.config.ts — required for @smrt() decorators
106
+ // vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)
107
107
  export default defineConfig({
108
- esbuild: {
109
- tsconfigRaw: {
110
- compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true }
111
- }
112
- }
108
+ oxc: {
109
+ decorator: {
110
+ legacy: true,
111
+ emitDecoratorMetadata: true,
112
+ },
113
+ },
113
114
  });
114
115
  ```
115
116
 
117
+ Under Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`
118
+ recipe (or tsconfig `experimentalDecorators` reached through SvelteKit's
119
+ `extends "./.svelte-kit/tsconfig.json"` chain), so that recipe throws
120
+ `SyntaxError: Invalid or unexpected token` on the first SSR request. Configure
121
+ decorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need
122
+ the legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,
123
+ emitDecoratorMetadata: true`.
124
+
116
125
  ## Gotchas
117
126
 
118
127
  - **Never override toJSON()** — handles STI discriminator + meta field extraction. Use `transformJSON()`
@@ -42,6 +42,6 @@ export { DispatchCollection } from './collections/Dispatches.js';
42
42
  export { DispatchSubscriptionCollection } from './collections/DispatchSubscriptions.js';
43
43
  export { Dispatch, type DispatchData } from './models/Dispatch.js';
44
44
  export { DispatchSubscription, type DispatchSubscriptionData, } from './models/DispatchSubscription.js';
45
- export { type DispatchTenantResolver, type DispatchTenantScope, resolveDispatchTenantId, resolveDispatchTenantScope, setDispatchTenantResolver, } from './tenant-resolver.js';
45
+ export { type DispatchTenantResolver, type DispatchTenantScope, isTenantScopedClassResolved, resolveDispatchTenantId, resolveDispatchTenantScope, setDispatchTenantResolver, setTenantScopedClassResolver, } from './tenant-resolver.js';
46
46
  export type { DispatchBusOptions, DispatchCleanupOptions, DispatchCleanupResult, DispatchEmitOptions, DispatchHandler, DispatchListOptions, DispatchMetadata, DispatchProcessOptions, DispatchRetryOptions, DispatchStatus, DispatchSubscribeOptions, } from './types.js';
47
47
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/dispatch/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAGH,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,EAAE,8BAA8B,EAAE,MAAM,wCAAwC,CAAC;AAExF,OAAO,EAAE,QAAQ,EAAE,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACnE,OAAO,EACL,oBAAoB,EACpB,KAAK,wBAAwB,GAC9B,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,uBAAuB,EACvB,0BAA0B,EAC1B,yBAAyB,GAC1B,MAAM,sBAAsB,CAAC;AAG9B,YAAY,EACV,kBAAkB,EAClB,sBAAsB,EACtB,qBAAqB,EACrB,mBAAmB,EACnB,eAAe,EACf,mBAAmB,EACnB,gBAAgB,EAChB,sBAAsB,EACtB,oBAAoB,EACpB,cAAc,EACd,wBAAwB,GACzB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/dispatch/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAGH,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AACjE,OAAO,EAAE,8BAA8B,EAAE,MAAM,wCAAwC,CAAC;AAExF,OAAO,EAAE,QAAQ,EAAE,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACnE,OAAO,EACL,oBAAoB,EACpB,KAAK,wBAAwB,GAC9B,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,2BAA2B,EAC3B,uBAAuB,EACvB,0BAA0B,EAC1B,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,sBAAsB,CAAC;AAG9B,YAAY,EACV,kBAAkB,EAClB,sBAAsB,EACtB,qBAAqB,EACrB,mBAAmB,EACnB,eAAe,EACf,mBAAmB,EACnB,gBAAgB,EAChB,sBAAsB,EACtB,oBAAoB,EACpB,cAAc,EACd,wBAAwB,GACzB,MAAM,YAAY,CAAC"}
@@ -2,6 +2,6 @@ import { Dispatch } from "./models/Dispatch.js";
2
2
  import { DispatchCollection } from "./collections/Dispatches.js";
3
3
  import { DispatchSubscription } from "./models/DispatchSubscription.js";
4
4
  import { DispatchSubscriptionCollection } from "./collections/DispatchSubscriptions.js";
5
- import { resolveDispatchTenantId, resolveDispatchTenantScope, setDispatchTenantResolver } from "./tenant-resolver.js";
5
+ import { isTenantScopedClassResolved, resolveDispatchTenantId, resolveDispatchTenantScope, setDispatchTenantResolver, setTenantScopedClassResolver } from "./tenant-resolver.js";
6
6
  import { DispatchBus, createDispatchBus } from "./bus.js";
7
- export { Dispatch, DispatchBus, DispatchCollection, DispatchSubscription, DispatchSubscriptionCollection, createDispatchBus, resolveDispatchTenantId, resolveDispatchTenantScope, setDispatchTenantResolver };
7
+ export { Dispatch, DispatchBus, DispatchCollection, DispatchSubscription, DispatchSubscriptionCollection, createDispatchBus, isTenantScopedClassResolved, resolveDispatchTenantId, resolveDispatchTenantScope, setDispatchTenantResolver, setTenantScopedClassResolver };
@@ -25,6 +25,7 @@
25
25
  export type DispatchTenantResolver = () => string | null | undefined;
26
26
  declare global {
27
27
  var __smrtDispatchTenantResolver: DispatchTenantResolver | undefined;
28
+ var __smrtTenantScopedClassResolver: ((className: string) => boolean) | undefined;
28
29
  }
29
30
  /**
30
31
  * Register the tenant resolver the DispatchBus uses to derive the active
@@ -95,4 +96,26 @@ export declare function resolveDispatchTenantScope(): DispatchTenantScope;
95
96
  * scope.
96
97
  */
97
98
  export declare function resolveDispatchTenantId(): string | null | undefined;
99
+ /**
100
+ * Register the resolver that reports whether a class is tenant-scoped, covering
101
+ * BOTH registration forms (S #1782). Core's `ObjectRegistry.isTenantScoped`
102
+ * recognizes `@smrt({ tenantScoped })` and the manifest-merged `@TenantScoped()`
103
+ * config, but the standalone `@TenantScoped()` decorator (smrt-tenancy) records
104
+ * its config only in the tenancy registry at decoration time — invisible to core
105
+ * until/unless a manifest carries it. Tenancy fills this slot at
106
+ * `enableTenancy()` so core-side fail-closed guards (the generated REST read
107
+ * scope) recognize tenant-scoped classes regardless of registration form or
108
+ * manifest timing. Mirrors {@link setDispatchTenantResolver}.
109
+ *
110
+ * @param resolver - Predicate returning `true` for tenant-scoped class names, or
111
+ * `undefined` to clear (which `disableTenancy()` does).
112
+ */
113
+ export declare function setTenantScopedClassResolver(resolver: ((className: string) => boolean) | undefined): void;
114
+ /**
115
+ * Whether the tenancy layer reports `className` as tenant-scoped. Returns
116
+ * `false` when tenancy is disabled (no resolver) or the resolver throws.
117
+ *
118
+ * @param className - Class name (simple or qualified) to check.
119
+ */
120
+ export declare function isTenantScopedClassResolved(className: string): boolean;
98
121
  //# sourceMappingURL=tenant-resolver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tenant-resolver.d.ts","sourceRoot":"","sources":["../../src/dispatch/tenant-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAAG,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;AAErE,OAAO,CAAC,MAAM,CAAC;IAEb,IAAI,4BAA4B,EAAE,sBAAsB,GAAG,SAAS,CAAC;CACtE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,sBAAsB,GAAG,SAAS,GAC3C,IAAI,CAEN;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,IAAI,mBAAmB,CAehE;AAED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,GAAG,IAAI,GAAG,SAAS,CAYnE"}
1
+ {"version":3,"file":"tenant-resolver.d.ts","sourceRoot":"","sources":["../../src/dispatch/tenant-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAAG,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;AAErE,OAAO,CAAC,MAAM,CAAC;IAEb,IAAI,4BAA4B,EAAE,sBAAsB,GAAG,SAAS,CAAC;IAErE,IAAI,+BAA+B,EAC/B,CAAC,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,GAChC,SAAS,CAAC;CACf;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,sBAAsB,GAAG,SAAS,GAC3C,IAAI,CAEN;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,IAAI,mBAAmB,CAehE;AAED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,GAAG,IAAI,GAAG,SAAS,CAYnE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,CAAC,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,SAAS,GACrD,IAAI,CAEN;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAUtE"}
@@ -60,7 +60,39 @@ function resolveDispatchTenantId() {
60
60
  return;
61
61
  }
62
62
  }
63
+ /**
64
+ * Register the resolver that reports whether a class is tenant-scoped, covering
65
+ * BOTH registration forms (S #1782). Core's `ObjectRegistry.isTenantScoped`
66
+ * recognizes `@smrt({ tenantScoped })` and the manifest-merged `@TenantScoped()`
67
+ * config, but the standalone `@TenantScoped()` decorator (smrt-tenancy) records
68
+ * its config only in the tenancy registry at decoration time — invisible to core
69
+ * until/unless a manifest carries it. Tenancy fills this slot at
70
+ * `enableTenancy()` so core-side fail-closed guards (the generated REST read
71
+ * scope) recognize tenant-scoped classes regardless of registration form or
72
+ * manifest timing. Mirrors {@link setDispatchTenantResolver}.
73
+ *
74
+ * @param resolver - Predicate returning `true` for tenant-scoped class names, or
75
+ * `undefined` to clear (which `disableTenancy()` does).
76
+ */
77
+ function setTenantScopedClassResolver(resolver) {
78
+ globalThis.__smrtTenantScopedClassResolver = resolver;
79
+ }
80
+ /**
81
+ * Whether the tenancy layer reports `className` as tenant-scoped. Returns
82
+ * `false` when tenancy is disabled (no resolver) or the resolver throws.
83
+ *
84
+ * @param className - Class name (simple or qualified) to check.
85
+ */
86
+ function isTenantScopedClassResolved(className) {
87
+ const resolver = globalThis.__smrtTenantScopedClassResolver;
88
+ if (!resolver) return false;
89
+ try {
90
+ return resolver(className) === true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
63
95
  //#endregion
64
- export { resolveDispatchTenantId, resolveDispatchTenantScope, setDispatchTenantResolver };
96
+ export { isTenantScopedClassResolved, resolveDispatchTenantId, resolveDispatchTenantScope, setDispatchTenantResolver, setTenantScopedClassResolver };
65
97
 
66
98
  //# sourceMappingURL=tenant-resolver.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"tenant-resolver.js","names":[],"sources":["../../src/dispatch/tenant-resolver.ts"],"sourcesContent":["/**\n * DispatchBus tenant resolver — dependency-inversion hook\n *\n * `@happyvertical/smrt-core` cannot depend on `@happyvertical/smrt-tenancy`\n * (tenancy depends on core, not the other way around). To let the DispatchBus\n * stamp and filter dispatches by the active tenant without creating a circular\n * dependency, core exposes an injectable resolver slot that tenancy fills at\n * `enableTenancy()` time — the same inversion pattern used by\n * {@link GlobalInterceptors}.\n *\n * When tenancy is not enabled (non-tenant deployments, existing tests), the\n * resolver defaults to a no-op that returns `undefined`, so the DispatchBus\n * behaves exactly as before: no tenant column is stamped and no tenant filter\n * is applied.\n *\n * Stored on `globalThis` so all module instances share one resolver, which is\n * critical in the monorepo where the same package can be loaded from multiple\n * paths (mirrors {@link ObjectRegistry} / {@link GlobalInterceptors}).\n */\n\n/**\n * Resolver function that returns the active tenant id for the current async\n * execution scope, or `undefined`/`null` when there is no tenant context\n * (system/global scope).\n */\nexport type DispatchTenantResolver = () => string | null | undefined;\n\ndeclare global {\n // eslint-disable-next-line no-var\n var __smrtDispatchTenantResolver: DispatchTenantResolver | undefined;\n}\n\n/**\n * Register the tenant resolver the DispatchBus uses to derive the active\n * tenant id on emit/subscribe/process.\n *\n * Called by `@happyvertical/smrt-tenancy`'s `enableTenancy()`; application\n * code never needs to call this directly. Passing `undefined` clears the\n * resolver (restoring the no-op default), which `disableTenancy()` does.\n *\n * @param resolver - Function returning the active tenant id, or `undefined` to\n * clear and fall back to the no-op default.\n */\nexport function setDispatchTenantResolver(\n resolver: DispatchTenantResolver | undefined,\n): void {\n globalThis.__smrtDispatchTenantResolver = resolver;\n}\n\n/**\n * Resolved tenant scope for a DispatchBus operation.\n *\n * The DispatchBus must distinguish three states, because they have different\n * read/write semantics (S5 #1398):\n *\n * - **Tenancy disabled** (`enforced: false`): no resolver is registered. The bus\n * applies no tenant filter and stamps no tenant id — identical to pre-tenancy\n * behavior. This is the backward-compatibility path for non-tenant\n * deployments and existing tests.\n * - **Tenancy enabled, active tenant** (`enforced: true`, `tenantId: T`): reads\n * are restricted to `(tenant_id = T OR tenant_id IS NULL)` and emits stamp\n * `tenant_id = T`.\n * - **Tenancy enabled, no active tenant** (`enforced: true`, `tenantId: null`):\n * reads are restricted to `tenant_id IS NULL` only (global rows). This is a\n * *fail-closed* state — when tenancy is on but no tenant context is active\n * (e.g. processing outside `withTenant()`), reads MUST NOT leak other\n * tenants' rows. Emits stamp `tenant_id = NULL` (global).\n *\n * Critically, `enforced: true` + `tenantId: null` is NOT collapsed into the\n * disabled state: when tenancy is enabled, a missing tenant context restricts\n * reads to global rows rather than opening up all tenants.\n */\nexport interface DispatchTenantScope {\n /**\n * Whether tenant enforcement is active (a resolver is registered). When\n * `false`, the bus applies no tenant filter at all (pre-tenancy behavior).\n */\n enforced: boolean;\n /**\n * The active tenant id, or `null` when there is no active tenant context.\n * Only meaningful when `enforced` is `true`.\n */\n tenantId: string | null;\n}\n\n/**\n * Resolve the active tenant scope for the current async execution scope.\n *\n * This is the trust anchor for DispatchBus tenant isolation. It distinguishes\n * \"tenancy disabled\" (no resolver registered → no filtering) from \"tenancy\n * enabled but no active tenant\" (resolver registered, returns null/undefined →\n * fail-closed to global-only reads). See {@link DispatchTenantScope}.\n *\n * @returns The resolved tenant scope.\n */\nexport function resolveDispatchTenantScope(): DispatchTenantScope {\n const resolver = globalThis.__smrtDispatchTenantResolver;\n if (!resolver) {\n // No resolver registered → tenancy is off. No filtering, no stamping.\n return { enforced: false, tenantId: null };\n }\n try {\n const resolved = resolver();\n return { enforced: true, tenantId: resolved ?? null };\n } catch {\n // A misbehaving resolver must never break dispatch processing. Tenancy is\n // still considered enforced (a resolver IS registered), so fail closed to\n // global-only reads rather than leaking all tenants.\n return { enforced: true, tenantId: null };\n }\n}\n\n/**\n * Resolve only the active tenant id for the current async execution scope.\n *\n * Returns `undefined` when no resolver is registered (tenancy disabled) or when\n * the resolver reports no active tenant. Prefer {@link resolveDispatchTenantScope}\n * for read-filtering decisions, which need to distinguish those two cases.\n *\n * @returns The active tenant id, or `undefined`/`null` when there is no tenant\n * scope.\n */\nexport function resolveDispatchTenantId(): string | null | undefined {\n const resolver = globalThis.__smrtDispatchTenantResolver;\n if (!resolver) {\n return undefined;\n }\n try {\n return resolver();\n } catch {\n // A misbehaving resolver must never break dispatch emission; treat a\n // throwing resolver as \"no tenant context\".\n return undefined;\n }\n}\n"],"mappings":";;;;;;;;;;;;AA2CA,SAAgB,0BACd,UACM;CACN,WAAW,+BAA+B;AAC5C;;;;;;;;;;;AAgDA,SAAgB,6BAAkD;CAChE,MAAM,WAAW,WAAW;CAC5B,IAAI,CAAC,UAEH,OAAO;EAAE,UAAU;EAAO,UAAU;CAAK;CAE3C,IAAI;EAEF,OAAO;GAAE,UAAU;GAAM,UADR,SACkB,KAAY;EAAK;CACtD,QAAQ;EAIN,OAAO;GAAE,UAAU;GAAM,UAAU;EAAK;CAC1C;AACF;;;;;;;;;;;AAYA,SAAgB,0BAAqD;CACnE,MAAM,WAAW,WAAW;CAC5B,IAAI,CAAC,UACH;CAEF,IAAI;EACF,OAAO,SAAS;CAClB,QAAQ;EAGN;CACF;AACF"}
1
+ {"version":3,"file":"tenant-resolver.js","names":[],"sources":["../../src/dispatch/tenant-resolver.ts"],"sourcesContent":["/**\n * DispatchBus tenant resolver — dependency-inversion hook\n *\n * `@happyvertical/smrt-core` cannot depend on `@happyvertical/smrt-tenancy`\n * (tenancy depends on core, not the other way around). To let the DispatchBus\n * stamp and filter dispatches by the active tenant without creating a circular\n * dependency, core exposes an injectable resolver slot that tenancy fills at\n * `enableTenancy()` time — the same inversion pattern used by\n * {@link GlobalInterceptors}.\n *\n * When tenancy is not enabled (non-tenant deployments, existing tests), the\n * resolver defaults to a no-op that returns `undefined`, so the DispatchBus\n * behaves exactly as before: no tenant column is stamped and no tenant filter\n * is applied.\n *\n * Stored on `globalThis` so all module instances share one resolver, which is\n * critical in the monorepo where the same package can be loaded from multiple\n * paths (mirrors {@link ObjectRegistry} / {@link GlobalInterceptors}).\n */\n\n/**\n * Resolver function that returns the active tenant id for the current async\n * execution scope, or `undefined`/`null` when there is no tenant context\n * (system/global scope).\n */\nexport type DispatchTenantResolver = () => string | null | undefined;\n\ndeclare global {\n // eslint-disable-next-line no-var\n var __smrtDispatchTenantResolver: DispatchTenantResolver | undefined;\n // eslint-disable-next-line no-var\n var __smrtTenantScopedClassResolver:\n | ((className: string) => boolean)\n | undefined;\n}\n\n/**\n * Register the tenant resolver the DispatchBus uses to derive the active\n * tenant id on emit/subscribe/process.\n *\n * Called by `@happyvertical/smrt-tenancy`'s `enableTenancy()`; application\n * code never needs to call this directly. Passing `undefined` clears the\n * resolver (restoring the no-op default), which `disableTenancy()` does.\n *\n * @param resolver - Function returning the active tenant id, or `undefined` to\n * clear and fall back to the no-op default.\n */\nexport function setDispatchTenantResolver(\n resolver: DispatchTenantResolver | undefined,\n): void {\n globalThis.__smrtDispatchTenantResolver = resolver;\n}\n\n/**\n * Resolved tenant scope for a DispatchBus operation.\n *\n * The DispatchBus must distinguish three states, because they have different\n * read/write semantics (S5 #1398):\n *\n * - **Tenancy disabled** (`enforced: false`): no resolver is registered. The bus\n * applies no tenant filter and stamps no tenant id — identical to pre-tenancy\n * behavior. This is the backward-compatibility path for non-tenant\n * deployments and existing tests.\n * - **Tenancy enabled, active tenant** (`enforced: true`, `tenantId: T`): reads\n * are restricted to `(tenant_id = T OR tenant_id IS NULL)` and emits stamp\n * `tenant_id = T`.\n * - **Tenancy enabled, no active tenant** (`enforced: true`, `tenantId: null`):\n * reads are restricted to `tenant_id IS NULL` only (global rows). This is a\n * *fail-closed* state — when tenancy is on but no tenant context is active\n * (e.g. processing outside `withTenant()`), reads MUST NOT leak other\n * tenants' rows. Emits stamp `tenant_id = NULL` (global).\n *\n * Critically, `enforced: true` + `tenantId: null` is NOT collapsed into the\n * disabled state: when tenancy is enabled, a missing tenant context restricts\n * reads to global rows rather than opening up all tenants.\n */\nexport interface DispatchTenantScope {\n /**\n * Whether tenant enforcement is active (a resolver is registered). When\n * `false`, the bus applies no tenant filter at all (pre-tenancy behavior).\n */\n enforced: boolean;\n /**\n * The active tenant id, or `null` when there is no active tenant context.\n * Only meaningful when `enforced` is `true`.\n */\n tenantId: string | null;\n}\n\n/**\n * Resolve the active tenant scope for the current async execution scope.\n *\n * This is the trust anchor for DispatchBus tenant isolation. It distinguishes\n * \"tenancy disabled\" (no resolver registered → no filtering) from \"tenancy\n * enabled but no active tenant\" (resolver registered, returns null/undefined →\n * fail-closed to global-only reads). See {@link DispatchTenantScope}.\n *\n * @returns The resolved tenant scope.\n */\nexport function resolveDispatchTenantScope(): DispatchTenantScope {\n const resolver = globalThis.__smrtDispatchTenantResolver;\n if (!resolver) {\n // No resolver registered → tenancy is off. No filtering, no stamping.\n return { enforced: false, tenantId: null };\n }\n try {\n const resolved = resolver();\n return { enforced: true, tenantId: resolved ?? null };\n } catch {\n // A misbehaving resolver must never break dispatch processing. Tenancy is\n // still considered enforced (a resolver IS registered), so fail closed to\n // global-only reads rather than leaking all tenants.\n return { enforced: true, tenantId: null };\n }\n}\n\n/**\n * Resolve only the active tenant id for the current async execution scope.\n *\n * Returns `undefined` when no resolver is registered (tenancy disabled) or when\n * the resolver reports no active tenant. Prefer {@link resolveDispatchTenantScope}\n * for read-filtering decisions, which need to distinguish those two cases.\n *\n * @returns The active tenant id, or `undefined`/`null` when there is no tenant\n * scope.\n */\nexport function resolveDispatchTenantId(): string | null | undefined {\n const resolver = globalThis.__smrtDispatchTenantResolver;\n if (!resolver) {\n return undefined;\n }\n try {\n return resolver();\n } catch {\n // A misbehaving resolver must never break dispatch emission; treat a\n // throwing resolver as \"no tenant context\".\n return undefined;\n }\n}\n\n/**\n * Register the resolver that reports whether a class is tenant-scoped, covering\n * BOTH registration forms (S #1782). Core's `ObjectRegistry.isTenantScoped`\n * recognizes `@smrt({ tenantScoped })` and the manifest-merged `@TenantScoped()`\n * config, but the standalone `@TenantScoped()` decorator (smrt-tenancy) records\n * its config only in the tenancy registry at decoration time — invisible to core\n * until/unless a manifest carries it. Tenancy fills this slot at\n * `enableTenancy()` so core-side fail-closed guards (the generated REST read\n * scope) recognize tenant-scoped classes regardless of registration form or\n * manifest timing. Mirrors {@link setDispatchTenantResolver}.\n *\n * @param resolver - Predicate returning `true` for tenant-scoped class names, or\n * `undefined` to clear (which `disableTenancy()` does).\n */\nexport function setTenantScopedClassResolver(\n resolver: ((className: string) => boolean) | undefined,\n): void {\n globalThis.__smrtTenantScopedClassResolver = resolver;\n}\n\n/**\n * Whether the tenancy layer reports `className` as tenant-scoped. Returns\n * `false` when tenancy is disabled (no resolver) or the resolver throws.\n *\n * @param className - Class name (simple or qualified) to check.\n */\nexport function isTenantScopedClassResolved(className: string): boolean {\n const resolver = globalThis.__smrtTenantScopedClassResolver;\n if (!resolver) {\n return false;\n }\n try {\n return resolver(className) === true;\n } catch {\n return false;\n }\n}\n"],"mappings":";;;;;;;;;;;;AA+CA,SAAgB,0BACd,UACM;CACN,WAAW,+BAA+B;AAC5C;;;;;;;;;;;AAgDA,SAAgB,6BAAkD;CAChE,MAAM,WAAW,WAAW;CAC5B,IAAI,CAAC,UAEH,OAAO;EAAE,UAAU;EAAO,UAAU;CAAK;CAE3C,IAAI;EAEF,OAAO;GAAE,UAAU;GAAM,UADR,SACkB,KAAY;EAAK;CACtD,QAAQ;EAIN,OAAO;GAAE,UAAU;GAAM,UAAU;EAAK;CAC1C;AACF;;;;;;;;;;;AAYA,SAAgB,0BAAqD;CACnE,MAAM,WAAW,WAAW;CAC5B,IAAI,CAAC,UACH;CAEF,IAAI;EACF,OAAO,SAAS;CAClB,QAAQ;EAGN;CACF;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,6BACd,UACM;CACN,WAAW,kCAAkC;AAC/C;;;;;;;AAQA,SAAgB,4BAA4B,WAA4B;CACtE,MAAM,WAAW,WAAW;CAC5B,IAAI,CAAC,UACH,OAAO;CAET,IAAI;EACF,OAAO,SAAS,SAAS,MAAM;CACjC,QAAQ;EACN,OAAO;CACT;AACF"}
@@ -72,6 +72,18 @@ export interface ReadCacheControlOptions {
72
72
  * leak one tenant's rows to other tenants or anonymous visitors.
73
73
  */
74
74
  export declare function resolveReadCacheControl(apiConfig: unknown, options?: ReadCacheControlOptions): string;
75
+ /**
76
+ * Warn (once per model) when a tenant-scoped model is also marked publicly
77
+ * readable (`@smrt({ api: { public: true | 'read' } })`).
78
+ *
79
+ * Anonymous / no-tenant-context reads on such a model fail closed to NULL-tenant
80
+ * (global) rows only (#1782): they never expose any tenant's rows. That is the
81
+ * intended, safe behavior, but silently it reads as "the public endpoint returns
82
+ * nothing" — so surface the combination and its consequence at generation /
83
+ * serve time. Called from both the REST runtime and the SvelteKit route
84
+ * generator so the message appears wherever the model is exposed.
85
+ */
86
+ export declare function warnIfTenantScopedPublicRead(modelName: string, apiConfig: unknown, tenantScoped: boolean): void;
75
87
  /**
76
88
  * Warn (once per model) when a tenant-scoped model configures
77
89
  * `api.cache.sMaxage`: the knob is deliberately neutralized to private
@@ -1 +1 @@
1
- {"version":3,"file":"conditional-get.d.ts","sourceRoot":"","sources":["../../src/generators/conditional-get.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH,mFAAmF;AACnF,eAAO,MAAM,0BAA0B,sBAAsB,CAAC;AAE9D;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GACX,OAAO,CAQT;AAOD,gFAAgF;AAChF,MAAM,WAAW,uBAAuB;IACtC;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AA8BD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,OAAO,EAClB,OAAO,GAAE,uBAA4B,GACpC,MAAM,CAMR;AAMD;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,OAAO,EAClB,YAAY,EAAE,OAAO,GACpB,IAAI,CAUN;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,MAAM,GACnB,QAAQ,CAsBV;AAED,sEAAsE;AACtE,MAAM,WAAW,gCACf,SAAQ,uBAAuB;IAC/B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,OAAO,EAClB,OAAO,GAAE,gCAAqC,GAC7C,MAAM,CAsDR"}
1
+ {"version":3,"file":"conditional-get.d.ts","sourceRoot":"","sources":["../../src/generators/conditional-get.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH,mFAAmF;AACnF,eAAO,MAAM,0BAA0B,sBAAsB,CAAC;AAE9D;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GACX,OAAO,CAQT;AAOD,gFAAgF;AAChF,MAAM,WAAW,uBAAuB;IACtC;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AA8BD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,OAAO,EAClB,OAAO,GAAE,uBAA4B,GACpC,MAAM,CAMR;AAkBD;;;;;;;;;;GAUG;AACH,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,OAAO,EAClB,YAAY,EAAE,OAAO,GACpB,IAAI,CAYN;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,OAAO,EAClB,YAAY,EAAE,OAAO,GACpB,IAAI,CAUN;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,MAAM,GACnB,QAAQ,CAsBV;AAED,sEAAsE;AACtE,MAAM,WAAW,gCACf,SAAQ,uBAAuB;IAC/B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,OAAO,EAClB,OAAO,GAAE,gCAAqC,GAC7C,MAAM,CA2DR"}
@@ -86,7 +86,32 @@ function resolveReadCacheControl(apiConfig, options = {}) {
86
86
  if (options.tenantScoped) return PRIVATE_READ_CACHE_CONTROL;
87
87
  return requestedSharedCacheControl(apiConfig) ?? "private, no-cache";
88
88
  }
89
+ /** Whether an `api` config opts reads out of auth (`public: true | 'read'`). */
90
+ function isPublicRead(apiConfig) {
91
+ if (!apiConfig || typeof apiConfig !== "object") return false;
92
+ const value = apiConfig.public;
93
+ return value === true || value === "read";
94
+ }
89
95
  var sharedCacheNeutralizedWarned = /* @__PURE__ */ new Set();
96
+ var tenantScopedPublicReadWarned = /* @__PURE__ */ new Set();
97
+ /**
98
+ * Warn (once per model) when a tenant-scoped model is also marked publicly
99
+ * readable (`@smrt({ api: { public: true | 'read' } })`).
100
+ *
101
+ * Anonymous / no-tenant-context reads on such a model fail closed to NULL-tenant
102
+ * (global) rows only (#1782): they never expose any tenant's rows. That is the
103
+ * intended, safe behavior, but silently it reads as "the public endpoint returns
104
+ * nothing" — so surface the combination and its consequence at generation /
105
+ * serve time. Called from both the REST runtime and the SvelteKit route
106
+ * generator so the message appears wherever the model is exposed.
107
+ */
108
+ function warnIfTenantScopedPublicRead(modelName, apiConfig, tenantScoped) {
109
+ if (!tenantScoped) return;
110
+ if (!isPublicRead(apiConfig)) return;
111
+ if (tenantScopedPublicReadWarned.has(modelName)) return;
112
+ tenantScopedPublicReadWarned.add(modelName);
113
+ console.warn(`[smrt] tenant-scoped model ${modelName} is marked api.public — anonymous reads with no tenant context return NULL-tenant (global) rows ONLY, never any tenant’s rows (fail-closed, #1782). Resolve a tenant from the request (host/subdomain/session) if per-tenant public reads are intended.`);
114
+ }
90
115
  /**
91
116
  * Warn (once per model) when a tenant-scoped model configures
92
117
  * `api.cache.sMaxage`: the knob is deliberately neutralized to private
@@ -140,7 +165,10 @@ function conditionalJsonResponse(request, payload, cacheControl) {
140
165
  */
141
166
  function generateConditionalGetRouteHelper(apiConfig, options = {}) {
142
167
  const cacheControl = resolveReadCacheControl(apiConfig, options);
143
- if (options.modelName) warnIfSharedCacheNeutralized(options.modelName, apiConfig, options.tenantScoped === true);
168
+ if (options.modelName) {
169
+ warnIfSharedCacheNeutralized(options.modelName, apiConfig, options.tenantScoped === true);
170
+ warnIfTenantScopedPublicRead(options.modelName, apiConfig, options.tenantScoped === true);
171
+ }
144
172
  return `
145
173
  // Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an
146
174
  // empty body. Reads stay private unless the model is public AND opts into
@@ -184,6 +212,6 @@ function conditionalJson(request: Request, payload: unknown): Response {
184
212
  `;
185
213
  }
186
214
  //#endregion
187
- export { PRIVATE_READ_CACHE_CONTROL, computeBodyEtag, conditionalJsonResponse, generateConditionalGetRouteHelper, ifNoneMatchSatisfied, resolveReadCacheControl, warnIfSharedCacheNeutralized };
215
+ export { PRIVATE_READ_CACHE_CONTROL, computeBodyEtag, conditionalJsonResponse, generateConditionalGetRouteHelper, ifNoneMatchSatisfied, resolveReadCacheControl, warnIfSharedCacheNeutralized, warnIfTenantScopedPublicRead };
188
216
 
189
217
  //# sourceMappingURL=conditional-get.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"conditional-get.js","names":[],"sources":["../../src/generators/conditional-get.ts"],"sourcesContent":["/**\n * Conditional GET v1 for generated read routes (#1757).\n *\n * Generated `list`/`get` responses carry a strong ETag computed from the\n * serialized JSON body, and a matching `If-None-Match` answers\n * `304 Not Modified` with an empty body. v1 deliberately still runs the query\n * — the win is transfer, parse, and re-render, not the database round trip\n * (a later slice upgrades the ETag source to the change-feed table version).\n *\n * Cache-Control policy (fail-private, mirroring the #1540 posture):\n * - Default reads: `private, no-cache` — responses may be stored by the\n * browser but MUST be revalidated before reuse, and shared caches never\n * store them.\n * - `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` reads:\n * `public, max-age=0, s-maxage=<n>` — CDNs/shared caches may serve the\n * response for `n` seconds while browsers still revalidate (cheap 304s).\n * Models without the public flag NEVER emit shared-cache headers, even when\n * `cache.sMaxage` is configured.\n * - Tenant-scoped models (`@smrt({ tenantScoped })` / `@TenantScoped()`, any\n * mode) NEVER emit shared-cache headers: their bodies vary with the tenant\n * context, which URL-keyed shared caches cannot see. `sMaxage` is ignored\n * with a one-time warning.\n *\n * Consumed by both the runtime REST generator (`./rest.ts`) and — as an\n * emitted code snippet — the SvelteKit route generator\n * (`../vite-plugin/sveltekit-generator.ts`). Keeping every piece here keeps\n * the two generators' diffs minimal and the policy in one place.\n */\n\nimport { createHash } from 'node:crypto';\n\n/** Default Cache-Control for generated reads: private conditional revalidation. */\nexport const PRIVATE_READ_CACHE_CONTROL = 'private, no-cache';\n\n/**\n * Compute the strong ETag for a serialized response body.\n *\n * SHA-256 of the exact JSON text, base64url-encoded and quoted per RFC 9110.\n * Deterministic for a given body, so any change to the underlying data (which\n * changes the serialized JSON) changes the ETag.\n */\nexport function computeBodyEtag(body: string): string {\n return `\"${createHash('sha256').update(body).digest('base64url')}\"`;\n}\n\n/**\n * Whether an `If-None-Match` request header matches the response ETag.\n *\n * Implements RFC 9110 §13.1.2 weak comparison: `*` matches anything, the\n * header may carry a comma-separated list, and a `W/` prefix is ignored.\n */\nexport function ifNoneMatchSatisfied(\n header: string | null | undefined,\n etag: string,\n): boolean {\n if (!header) return false;\n if (header.trim() === '*') return true;\n return header.split(',').some((candidate) => {\n const tag = candidate.trim();\n const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;\n return opaque === etag;\n });\n}\n\ninterface ApiCacheShape {\n cache?: { sMaxage?: unknown };\n public?: unknown;\n}\n\n/** Model-level context that constrains the cache policy beyond `api` config. */\nexport interface ReadCacheControlOptions {\n /**\n * Whether the model is tenant-scoped (`@smrt({ tenantScoped })` or the\n * `@TenantScoped()` decorator, ANY mode including `'optional'`). Tenant\n * scoping keys the response body on request identity (session cookie), which\n * shared caches cannot see — they key on the URL alone — so honoring\n * `sMaxage` would serve one tenant's rows to other tenants or to anonymous\n * visitors. Fail-closed: tenant-scoped models NEVER emit shared-cache\n * headers (#1757 review finding).\n */\n tenantScoped?: boolean;\n}\n\n/**\n * The shared Cache-Control string the `api` config asks for, or null when the\n * config does not (validly) opt into shared caching. Config-only — the\n * tenant-scoped restriction is applied by `resolveReadCacheControl`.\n */\nfunction requestedSharedCacheControl(apiConfig: unknown): string | null {\n if (!apiConfig || typeof apiConfig !== 'object') {\n return null;\n }\n\n const config = apiConfig as ApiCacheShape;\n const publicRead = config.public === true || config.public === 'read';\n const sMaxage = config.cache?.sMaxage;\n\n if (\n publicRead &&\n typeof sMaxage === 'number' &&\n Number.isFinite(sMaxage) &&\n sMaxage > 0\n ) {\n // Shared caches serve for sMaxage seconds; browsers (max-age=0) always\n // revalidate, so end users see edits immediately via cheap 304s.\n return `public, max-age=0, s-maxage=${Math.floor(sMaxage)}`;\n }\n\n return null;\n}\n\n/**\n * Resolve the Cache-Control header for a generated read response from a\n * model's `@smrt({ api })` config (defensively typed — the config arrives as\n * `unknown` from the registry at runtime and from the manifest at build time).\n *\n * Only models that opted out of auth via `public: true` (or `'read'`, which\n * makes reads public) may emit shared-cache headers, and only when they also\n * configure a positive `cache.sMaxage`. Everything else — including a\n * non-public model that configures `sMaxage` — stays `private, no-cache`.\n *\n * Tenant-scoped models are ALWAYS `private, no-cache` regardless of config:\n * their response bodies vary with the tenant context (resolved from session\n * cookies, invisible to URL-keyed shared caches), so shared caching would\n * leak one tenant's rows to other tenants or anonymous visitors.\n */\nexport function resolveReadCacheControl(\n apiConfig: unknown,\n options: ReadCacheControlOptions = {},\n): string {\n if (options.tenantScoped) {\n return PRIVATE_READ_CACHE_CONTROL;\n }\n\n return requestedSharedCacheControl(apiConfig) ?? PRIVATE_READ_CACHE_CONTROL;\n}\n\n// One warning per model — both transports resolve the same model repeatedly\n// (per route template at generation time, per request at runtime).\nconst sharedCacheNeutralizedWarned = new Set<string>();\n\n/**\n * Warn (once per model) when a tenant-scoped model configures\n * `api.cache.sMaxage`: the knob is deliberately neutralized to private\n * caching, and silently ignoring it would leave developers wondering why no\n * CDN caching happens. Called from both the REST runtime and the SvelteKit\n * route generator so the message surfaces wherever the model is served.\n */\nexport function warnIfSharedCacheNeutralized(\n modelName: string,\n apiConfig: unknown,\n tenantScoped: boolean,\n): void {\n if (!tenantScoped) return;\n if (requestedSharedCacheControl(apiConfig) === null) return;\n if (sharedCacheNeutralizedWarned.has(modelName)) return;\n sharedCacheNeutralizedWarned.add(modelName);\n console.warn(\n `[smrt] api.cache.sMaxage ignored for tenant-scoped model ${modelName}: ` +\n 'shared caches cannot key on tenant context — serving ' +\n `'${PRIVATE_READ_CACHE_CONTROL}' instead (#1757).`,\n );\n}\n\n/**\n * Build the JSON response for a generated read, honoring `If-None-Match`.\n *\n * Returns `304 Not Modified` with an EMPTY body when the request's\n * `If-None-Match` matches the body ETag; otherwise a 200 with the serialized\n * payload. Both carry the ETag and the resolved Cache-Control so clients can\n * revalidate the representation they hold.\n */\nexport function conditionalJsonResponse(\n request: Request,\n payload: unknown,\n cacheControl: string,\n): Response {\n const body = JSON.stringify(payload);\n const etag = computeBodyEtag(body);\n\n if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) {\n return new Response(null, {\n status: 304,\n headers: {\n 'Cache-Control': cacheControl,\n ETag: etag,\n },\n });\n }\n\n return new Response(body, {\n status: 200,\n headers: {\n 'Cache-Control': cacheControl,\n 'Content-Type': 'application/json',\n ETag: etag,\n },\n });\n}\n\n/** Generation-time context for the emitted SvelteKit route helper. */\nexport interface ConditionalGetRouteHelperOptions\n extends ReadCacheControlOptions {\n /** Model name used for the one-time sMaxage-neutralized warning. */\n modelName?: string;\n}\n\n/**\n * Emit the conditional-GET helper inlined into generated SvelteKit route\n * files, following the generator's existing inline-helper convention\n * (auth guard, tenant context, writable policy). The Cache-Control policy is\n * resolved at generation time from the object's `@smrt({ api })` config plus\n * the model's tenant scoping, and baked in as a constant.\n *\n * Kept textually in lockstep with the runtime helpers above — the `.spec`\n * suite drives both through the same HTTP semantics.\n */\nexport function generateConditionalGetRouteHelper(\n apiConfig: unknown,\n options: ConditionalGetRouteHelperOptions = {},\n): string {\n // All branches of resolveReadCacheControl return fixed framework-owned\n // strings (no user text), so interpolating into a single-quoted literal is\n // safe and matches the generated-code quoting style.\n const cacheControl = resolveReadCacheControl(apiConfig, options);\n if (options.modelName) {\n warnIfSharedCacheNeutralized(\n options.modelName,\n apiConfig,\n options.tenantScoped === true,\n );\n }\n\n return `\n// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an\n// empty body. Reads stay private unless the model is public AND opts into\n// shared caching via @smrt({ api: { cache: { sMaxage } } }).\nimport { createHash } from 'node:crypto';\n\nconst READ_CACHE_CONTROL = '${cacheControl}';\n\nfunction bodyEtag(body: string): string {\n return \\`\"\\${createHash('sha256').update(body).digest('base64url')}\"\\`;\n}\n\nfunction ifNoneMatchSatisfied(header: string | null, etag: string): boolean {\n if (!header) return false;\n if (header.trim() === '*') return true;\n return header.split(',').some((candidate) => {\n const tag = candidate.trim();\n const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;\n return opaque === etag;\n });\n}\n\nfunction conditionalJson(request: Request, payload: unknown): Response {\n const body = JSON.stringify(payload);\n const etag = bodyEtag(body);\n if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) {\n return new Response(null, {\n status: 304,\n headers: { 'cache-control': READ_CACHE_CONTROL, etag },\n });\n }\n return new Response(body, {\n status: 200,\n headers: {\n 'cache-control': READ_CACHE_CONTROL,\n 'content-type': 'application/json',\n etag,\n },\n });\n}\n`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,IAAa,6BAA6B;;;;;;;;AAS1C,SAAgB,gBAAgB,MAAsB;CACpD,OAAO,IAAI,WAAW,QAAQ,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,OAAO,WAAW,EAAE;AACnE;;;;;;;AAQA,SAAgB,qBACd,QACA,MACS;CACT,IAAI,CAAC,QAAQ,OAAO;CACpB,IAAI,OAAO,KAAK,MAAM,KAAK,OAAO;CAClC,OAAO,OAAO,MAAM,GAAG,CAAC,CAAC,MAAM,cAAc;EAC3C,MAAM,MAAM,UAAU,KAAK;EAE3B,QADe,IAAI,WAAW,IAAI,IAAI,IAAI,MAAM,CAAC,IAAI,SACnC;CACpB,CAAC;AACH;;;;;;AA0BA,SAAS,4BAA4B,WAAmC;CACtE,IAAI,CAAC,aAAa,OAAO,cAAc,UACrC,OAAO;CAGT,MAAM,SAAS;CACf,MAAM,aAAa,OAAO,WAAW,QAAQ,OAAO,WAAW;CAC/D,MAAM,UAAU,OAAO,OAAO;CAE9B,IACE,cACA,OAAO,YAAY,YACnB,OAAO,SAAS,OAAO,KACvB,UAAU,GAIV,OAAO,+BAA+B,KAAK,MAAM,OAAO;CAG1D,OAAO;AACT;;;;;;;;;;;;;;;;AAiBA,SAAgB,wBACd,WACA,UAAmC,CAAC,GAC5B;CACR,IAAI,QAAQ,cACV,OAAO;CAGT,OAAO,4BAA4B,SAAS,KAAA;AAC9C;AAIA,IAAM,+CAA+B,IAAI,IAAY;;;;;;;;AASrD,SAAgB,6BACd,WACA,WACA,cACM;CACN,IAAI,CAAC,cAAc;CACnB,IAAI,4BAA4B,SAAS,MAAM,MAAM;CACrD,IAAI,6BAA6B,IAAI,SAAS,GAAG;CACjD,6BAA6B,IAAI,SAAS;CAC1C,QAAQ,KACN,4DAA4D,UAAU,0DAEhE,2BAA2B,mBACnC;AACF;;;;;;;;;AAUA,SAAgB,wBACd,SACA,SACA,cACU;CACV,MAAM,OAAO,KAAK,UAAU,OAAO;CACnC,MAAM,OAAO,gBAAgB,IAAI;CAEjC,IAAI,qBAAqB,QAAQ,QAAQ,IAAI,eAAe,GAAG,IAAI,GACjE,OAAO,IAAI,SAAS,MAAM;EACxB,QAAQ;EACR,SAAS;GACP,iBAAiB;GACjB,MAAM;EACR;CACF,CAAC;CAGH,OAAO,IAAI,SAAS,MAAM;EACxB,QAAQ;EACR,SAAS;GACP,iBAAiB;GACjB,gBAAgB;GAChB,MAAM;EACR;CACF,CAAC;AACH;;;;;;;;;;;AAmBA,SAAgB,kCACd,WACA,UAA4C,CAAC,GACrC;CAIR,MAAM,eAAe,wBAAwB,WAAW,OAAO;CAC/D,IAAI,QAAQ,WACV,6BACE,QAAQ,WACR,WACA,QAAQ,iBAAiB,IAC3B;CAGF,OAAO;;;;;;8BAMqB,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmC3C"}
1
+ {"version":3,"file":"conditional-get.js","names":[],"sources":["../../src/generators/conditional-get.ts"],"sourcesContent":["/**\n * Conditional GET v1 for generated read routes (#1757).\n *\n * Generated `list`/`get` responses carry a strong ETag computed from the\n * serialized JSON body, and a matching `If-None-Match` answers\n * `304 Not Modified` with an empty body. v1 deliberately still runs the query\n * — the win is transfer, parse, and re-render, not the database round trip\n * (a later slice upgrades the ETag source to the change-feed table version).\n *\n * Cache-Control policy (fail-private, mirroring the #1540 posture):\n * - Default reads: `private, no-cache` — responses may be stored by the\n * browser but MUST be revalidated before reuse, and shared caches never\n * store them.\n * - `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` reads:\n * `public, max-age=0, s-maxage=<n>` — CDNs/shared caches may serve the\n * response for `n` seconds while browsers still revalidate (cheap 304s).\n * Models without the public flag NEVER emit shared-cache headers, even when\n * `cache.sMaxage` is configured.\n * - Tenant-scoped models (`@smrt({ tenantScoped })` / `@TenantScoped()`, any\n * mode) NEVER emit shared-cache headers: their bodies vary with the tenant\n * context, which URL-keyed shared caches cannot see. `sMaxage` is ignored\n * with a one-time warning.\n *\n * Consumed by both the runtime REST generator (`./rest.ts`) and — as an\n * emitted code snippet — the SvelteKit route generator\n * (`../vite-plugin/sveltekit-generator.ts`). Keeping every piece here keeps\n * the two generators' diffs minimal and the policy in one place.\n */\n\nimport { createHash } from 'node:crypto';\n\n/** Default Cache-Control for generated reads: private conditional revalidation. */\nexport const PRIVATE_READ_CACHE_CONTROL = 'private, no-cache';\n\n/**\n * Compute the strong ETag for a serialized response body.\n *\n * SHA-256 of the exact JSON text, base64url-encoded and quoted per RFC 9110.\n * Deterministic for a given body, so any change to the underlying data (which\n * changes the serialized JSON) changes the ETag.\n */\nexport function computeBodyEtag(body: string): string {\n return `\"${createHash('sha256').update(body).digest('base64url')}\"`;\n}\n\n/**\n * Whether an `If-None-Match` request header matches the response ETag.\n *\n * Implements RFC 9110 §13.1.2 weak comparison: `*` matches anything, the\n * header may carry a comma-separated list, and a `W/` prefix is ignored.\n */\nexport function ifNoneMatchSatisfied(\n header: string | null | undefined,\n etag: string,\n): boolean {\n if (!header) return false;\n if (header.trim() === '*') return true;\n return header.split(',').some((candidate) => {\n const tag = candidate.trim();\n const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;\n return opaque === etag;\n });\n}\n\ninterface ApiCacheShape {\n cache?: { sMaxage?: unknown };\n public?: unknown;\n}\n\n/** Model-level context that constrains the cache policy beyond `api` config. */\nexport interface ReadCacheControlOptions {\n /**\n * Whether the model is tenant-scoped (`@smrt({ tenantScoped })` or the\n * `@TenantScoped()` decorator, ANY mode including `'optional'`). Tenant\n * scoping keys the response body on request identity (session cookie), which\n * shared caches cannot see — they key on the URL alone — so honoring\n * `sMaxage` would serve one tenant's rows to other tenants or to anonymous\n * visitors. Fail-closed: tenant-scoped models NEVER emit shared-cache\n * headers (#1757 review finding).\n */\n tenantScoped?: boolean;\n}\n\n/**\n * The shared Cache-Control string the `api` config asks for, or null when the\n * config does not (validly) opt into shared caching. Config-only — the\n * tenant-scoped restriction is applied by `resolveReadCacheControl`.\n */\nfunction requestedSharedCacheControl(apiConfig: unknown): string | null {\n if (!apiConfig || typeof apiConfig !== 'object') {\n return null;\n }\n\n const config = apiConfig as ApiCacheShape;\n const publicRead = config.public === true || config.public === 'read';\n const sMaxage = config.cache?.sMaxage;\n\n if (\n publicRead &&\n typeof sMaxage === 'number' &&\n Number.isFinite(sMaxage) &&\n sMaxage > 0\n ) {\n // Shared caches serve for sMaxage seconds; browsers (max-age=0) always\n // revalidate, so end users see edits immediately via cheap 304s.\n return `public, max-age=0, s-maxage=${Math.floor(sMaxage)}`;\n }\n\n return null;\n}\n\n/**\n * Resolve the Cache-Control header for a generated read response from a\n * model's `@smrt({ api })` config (defensively typed — the config arrives as\n * `unknown` from the registry at runtime and from the manifest at build time).\n *\n * Only models that opted out of auth via `public: true` (or `'read'`, which\n * makes reads public) may emit shared-cache headers, and only when they also\n * configure a positive `cache.sMaxage`. Everything else — including a\n * non-public model that configures `sMaxage` — stays `private, no-cache`.\n *\n * Tenant-scoped models are ALWAYS `private, no-cache` regardless of config:\n * their response bodies vary with the tenant context (resolved from session\n * cookies, invisible to URL-keyed shared caches), so shared caching would\n * leak one tenant's rows to other tenants or anonymous visitors.\n */\nexport function resolveReadCacheControl(\n apiConfig: unknown,\n options: ReadCacheControlOptions = {},\n): string {\n if (options.tenantScoped) {\n return PRIVATE_READ_CACHE_CONTROL;\n }\n\n return requestedSharedCacheControl(apiConfig) ?? PRIVATE_READ_CACHE_CONTROL;\n}\n\n/** Whether an `api` config opts reads out of auth (`public: true | 'read'`). */\nfunction isPublicRead(apiConfig: unknown): boolean {\n if (!apiConfig || typeof apiConfig !== 'object') {\n return false;\n }\n const value = (apiConfig as ApiCacheShape).public;\n return value === true || value === 'read';\n}\n\n// One warning per model — both transports resolve the same model repeatedly\n// (per route template at generation time, per request at runtime).\nconst sharedCacheNeutralizedWarned = new Set<string>();\n\n// One warning per model for the tenant-scoped + public-read combination (#1782).\nconst tenantScopedPublicReadWarned = new Set<string>();\n\n/**\n * Warn (once per model) when a tenant-scoped model is also marked publicly\n * readable (`@smrt({ api: { public: true | 'read' } })`).\n *\n * Anonymous / no-tenant-context reads on such a model fail closed to NULL-tenant\n * (global) rows only (#1782): they never expose any tenant's rows. That is the\n * intended, safe behavior, but silently it reads as \"the public endpoint returns\n * nothing\" — so surface the combination and its consequence at generation /\n * serve time. Called from both the REST runtime and the SvelteKit route\n * generator so the message appears wherever the model is exposed.\n */\nexport function warnIfTenantScopedPublicRead(\n modelName: string,\n apiConfig: unknown,\n tenantScoped: boolean,\n): void {\n if (!tenantScoped) return;\n if (!isPublicRead(apiConfig)) return;\n if (tenantScopedPublicReadWarned.has(modelName)) return;\n tenantScopedPublicReadWarned.add(modelName);\n console.warn(\n `[smrt] tenant-scoped model ${modelName} is marked api.public — ` +\n 'anonymous reads with no tenant context return NULL-tenant (global) ' +\n 'rows ONLY, never any tenant’s rows (fail-closed, #1782). Resolve a ' +\n 'tenant from the request (host/subdomain/session) if per-tenant public ' +\n 'reads are intended.',\n );\n}\n\n/**\n * Warn (once per model) when a tenant-scoped model configures\n * `api.cache.sMaxage`: the knob is deliberately neutralized to private\n * caching, and silently ignoring it would leave developers wondering why no\n * CDN caching happens. Called from both the REST runtime and the SvelteKit\n * route generator so the message surfaces wherever the model is served.\n */\nexport function warnIfSharedCacheNeutralized(\n modelName: string,\n apiConfig: unknown,\n tenantScoped: boolean,\n): void {\n if (!tenantScoped) return;\n if (requestedSharedCacheControl(apiConfig) === null) return;\n if (sharedCacheNeutralizedWarned.has(modelName)) return;\n sharedCacheNeutralizedWarned.add(modelName);\n console.warn(\n `[smrt] api.cache.sMaxage ignored for tenant-scoped model ${modelName}: ` +\n 'shared caches cannot key on tenant context — serving ' +\n `'${PRIVATE_READ_CACHE_CONTROL}' instead (#1757).`,\n );\n}\n\n/**\n * Build the JSON response for a generated read, honoring `If-None-Match`.\n *\n * Returns `304 Not Modified` with an EMPTY body when the request's\n * `If-None-Match` matches the body ETag; otherwise a 200 with the serialized\n * payload. Both carry the ETag and the resolved Cache-Control so clients can\n * revalidate the representation they hold.\n */\nexport function conditionalJsonResponse(\n request: Request,\n payload: unknown,\n cacheControl: string,\n): Response {\n const body = JSON.stringify(payload);\n const etag = computeBodyEtag(body);\n\n if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) {\n return new Response(null, {\n status: 304,\n headers: {\n 'Cache-Control': cacheControl,\n ETag: etag,\n },\n });\n }\n\n return new Response(body, {\n status: 200,\n headers: {\n 'Cache-Control': cacheControl,\n 'Content-Type': 'application/json',\n ETag: etag,\n },\n });\n}\n\n/** Generation-time context for the emitted SvelteKit route helper. */\nexport interface ConditionalGetRouteHelperOptions\n extends ReadCacheControlOptions {\n /** Model name used for the one-time sMaxage-neutralized warning. */\n modelName?: string;\n}\n\n/**\n * Emit the conditional-GET helper inlined into generated SvelteKit route\n * files, following the generator's existing inline-helper convention\n * (auth guard, tenant context, writable policy). The Cache-Control policy is\n * resolved at generation time from the object's `@smrt({ api })` config plus\n * the model's tenant scoping, and baked in as a constant.\n *\n * Kept textually in lockstep with the runtime helpers above — the `.spec`\n * suite drives both through the same HTTP semantics.\n */\nexport function generateConditionalGetRouteHelper(\n apiConfig: unknown,\n options: ConditionalGetRouteHelperOptions = {},\n): string {\n // All branches of resolveReadCacheControl return fixed framework-owned\n // strings (no user text), so interpolating into a single-quoted literal is\n // safe and matches the generated-code quoting style.\n const cacheControl = resolveReadCacheControl(apiConfig, options);\n if (options.modelName) {\n warnIfSharedCacheNeutralized(\n options.modelName,\n apiConfig,\n options.tenantScoped === true,\n );\n warnIfTenantScopedPublicRead(\n options.modelName,\n apiConfig,\n options.tenantScoped === true,\n );\n }\n\n return `\n// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an\n// empty body. Reads stay private unless the model is public AND opts into\n// shared caching via @smrt({ api: { cache: { sMaxage } } }).\nimport { createHash } from 'node:crypto';\n\nconst READ_CACHE_CONTROL = '${cacheControl}';\n\nfunction bodyEtag(body: string): string {\n return \\`\"\\${createHash('sha256').update(body).digest('base64url')}\"\\`;\n}\n\nfunction ifNoneMatchSatisfied(header: string | null, etag: string): boolean {\n if (!header) return false;\n if (header.trim() === '*') return true;\n return header.split(',').some((candidate) => {\n const tag = candidate.trim();\n const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;\n return opaque === etag;\n });\n}\n\nfunction conditionalJson(request: Request, payload: unknown): Response {\n const body = JSON.stringify(payload);\n const etag = bodyEtag(body);\n if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) {\n return new Response(null, {\n status: 304,\n headers: { 'cache-control': READ_CACHE_CONTROL, etag },\n });\n }\n return new Response(body, {\n status: 200,\n headers: {\n 'cache-control': READ_CACHE_CONTROL,\n 'content-type': 'application/json',\n etag,\n },\n });\n}\n`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,IAAa,6BAA6B;;;;;;;;AAS1C,SAAgB,gBAAgB,MAAsB;CACpD,OAAO,IAAI,WAAW,QAAQ,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,OAAO,WAAW,EAAE;AACnE;;;;;;;AAQA,SAAgB,qBACd,QACA,MACS;CACT,IAAI,CAAC,QAAQ,OAAO;CACpB,IAAI,OAAO,KAAK,MAAM,KAAK,OAAO;CAClC,OAAO,OAAO,MAAM,GAAG,CAAC,CAAC,MAAM,cAAc;EAC3C,MAAM,MAAM,UAAU,KAAK;EAE3B,QADe,IAAI,WAAW,IAAI,IAAI,IAAI,MAAM,CAAC,IAAI,SACnC;CACpB,CAAC;AACH;;;;;;AA0BA,SAAS,4BAA4B,WAAmC;CACtE,IAAI,CAAC,aAAa,OAAO,cAAc,UACrC,OAAO;CAGT,MAAM,SAAS;CACf,MAAM,aAAa,OAAO,WAAW,QAAQ,OAAO,WAAW;CAC/D,MAAM,UAAU,OAAO,OAAO;CAE9B,IACE,cACA,OAAO,YAAY,YACnB,OAAO,SAAS,OAAO,KACvB,UAAU,GAIV,OAAO,+BAA+B,KAAK,MAAM,OAAO;CAG1D,OAAO;AACT;;;;;;;;;;;;;;;;AAiBA,SAAgB,wBACd,WACA,UAAmC,CAAC,GAC5B;CACR,IAAI,QAAQ,cACV,OAAO;CAGT,OAAO,4BAA4B,SAAS,KAAA;AAC9C;;AAGA,SAAS,aAAa,WAA6B;CACjD,IAAI,CAAC,aAAa,OAAO,cAAc,UACrC,OAAO;CAET,MAAM,QAAS,UAA4B;CAC3C,OAAO,UAAU,QAAQ,UAAU;AACrC;AAIA,IAAM,+CAA+B,IAAI,IAAY;AAGrD,IAAM,+CAA+B,IAAI,IAAY;;;;;;;;;;;;AAarD,SAAgB,6BACd,WACA,WACA,cACM;CACN,IAAI,CAAC,cAAc;CACnB,IAAI,CAAC,aAAa,SAAS,GAAG;CAC9B,IAAI,6BAA6B,IAAI,SAAS,GAAG;CACjD,6BAA6B,IAAI,SAAS;CAC1C,QAAQ,KACN,8BAA8B,UAAU,wPAK1C;AACF;;;;;;;;AASA,SAAgB,6BACd,WACA,WACA,cACM;CACN,IAAI,CAAC,cAAc;CACnB,IAAI,4BAA4B,SAAS,MAAM,MAAM;CACrD,IAAI,6BAA6B,IAAI,SAAS,GAAG;CACjD,6BAA6B,IAAI,SAAS;CAC1C,QAAQ,KACN,4DAA4D,UAAU,0DAEhE,2BAA2B,mBACnC;AACF;;;;;;;;;AAUA,SAAgB,wBACd,SACA,SACA,cACU;CACV,MAAM,OAAO,KAAK,UAAU,OAAO;CACnC,MAAM,OAAO,gBAAgB,IAAI;CAEjC,IAAI,qBAAqB,QAAQ,QAAQ,IAAI,eAAe,GAAG,IAAI,GACjE,OAAO,IAAI,SAAS,MAAM;EACxB,QAAQ;EACR,SAAS;GACP,iBAAiB;GACjB,MAAM;EACR;CACF,CAAC;CAGH,OAAO,IAAI,SAAS,MAAM;EACxB,QAAQ;EACR,SAAS;GACP,iBAAiB;GACjB,gBAAgB;GAChB,MAAM;EACR;CACF,CAAC;AACH;;;;;;;;;;;AAmBA,SAAgB,kCACd,WACA,UAA4C,CAAC,GACrC;CAIR,MAAM,eAAe,wBAAwB,WAAW,OAAO;CAC/D,IAAI,QAAQ,WAAW;EACrB,6BACE,QAAQ,WACR,WACA,QAAQ,iBAAiB,IAC3B;EACA,6BACE,QAAQ,WACR,WACA,QAAQ,iBAAiB,IAC3B;CACF;CAEA,OAAO;;;;;;8BAMqB,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmC3C"}
@@ -85,6 +85,36 @@ export declare class APIGenerator {
85
85
  private isRoutePublic;
86
86
  private isApiActionEnabled;
87
87
  private getCollectionObjectName;
88
+ /**
89
+ * Fail-closed tenant read scope (#1782).
90
+ *
91
+ * A `@TenantScoped` model served over a public/anonymous read has no ambient
92
+ * tenant context, so the tenancy interceptor (optional mode) passes the query
93
+ * through UNFILTERED and returns every tenant's rows. When tenancy is enabled
94
+ * but no tenant is active, this returns a `{ tenantId: null }` filter so reads
95
+ * fail closed to NULL-tenant (global) rows only — mirroring the dispatch
96
+ * resolver's "enforced, no active tenant → global rows only" convention
97
+ * (`resolveDispatchTenantScope` is the core-level trust anchor tenancy fills
98
+ * in; core cannot import tenancy directly). Returns undefined when a tenant IS
99
+ * active (the interceptor filters by it) or tenancy is disabled (no isolation
100
+ * to enforce).
101
+ *
102
+ * Tenant scoping is recognized across BOTH registration forms (#1782): the
103
+ * `@smrt({ tenantScoped })` config / manifest-merged form via
104
+ * `ObjectRegistry.isTenantScoped`, and the standalone `@TenantScoped()`
105
+ * decorator via the tenancy-filled `isTenantScopedClassResolved` hook — so a
106
+ * `@TenantScoped()`-only public model can't slip past the guard regardless of
107
+ * manifest timing.
108
+ */
109
+ private resolveTenantReadScope;
110
+ /**
111
+ * Merge the fail-closed tenant read scope (#1782) into a query's WHERE clause.
112
+ * When the scope is active (global-only), any client-supplied tenant filter is
113
+ * dropped first so a `?tenantId=...` / `?tenant_id[ne]=...` query param can
114
+ * never widen the scope, then NULL-tenant is forced. Returns the original
115
+ * `where` untouched when the scope is inactive.
116
+ */
117
+ private applyTenantReadScope;
88
118
  /**
89
119
  * Handle GET /objects/:id
90
120
  */
@@ -1 +1 @@
1
- {"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,7 @@
1
1
  import { ObjectRegistry } from "../registry.js";
2
- import { conditionalJsonResponse, resolveReadCacheControl, warnIfSharedCacheNeutralized } from "./conditional-get.js";
2
+ import { isTenantScopedClassResolved, resolveDispatchTenantScope } from "../dispatch/tenant-resolver.js";
3
+ import "../dispatch/index.js";
4
+ import { conditionalJsonResponse, resolveReadCacheControl, warnIfSharedCacheNeutralized, warnIfTenantScopedPublicRead } from "./conditional-get.js";
3
5
  import { SYNC_APPLY_ROUTE_SEGMENTS, processSyncApplyBatch } from "../sync/apply.js";
4
6
  import http from "node:http";
5
7
  //#region src/generators/rest.ts
@@ -166,7 +168,7 @@ var APIGenerator = class {
166
168
  try {
167
169
  const action = this.getCrudAction(req.method, objectId);
168
170
  if (action && !this.isApiActionEnabled(objectName, action)) return this.createErrorResponse(405, "Method not allowed");
169
- if (objectId === "count" && req.method === "GET") return await this.handleCount(collection, url.searchParams);
171
+ if (objectId === "count" && req.method === "GET") return await this.handleCount(collection, url.searchParams, objectName);
170
172
  switch (req.method) {
171
173
  case "GET": return objectId ? await this.handleGet(collection, objectId, req, objectName) : await this.handleList(collection, url.searchParams, req, objectName);
172
174
  case "POST": return await this.handleCreate(collection, req, objectName);
@@ -227,10 +229,58 @@ var APIGenerator = class {
227
229
  return registered?.qualifiedName || registered?.name || itemClass.name;
228
230
  }
229
231
  /**
232
+ * Fail-closed tenant read scope (#1782).
233
+ *
234
+ * A `@TenantScoped` model served over a public/anonymous read has no ambient
235
+ * tenant context, so the tenancy interceptor (optional mode) passes the query
236
+ * through UNFILTERED and returns every tenant's rows. When tenancy is enabled
237
+ * but no tenant is active, this returns a `{ tenantId: null }` filter so reads
238
+ * fail closed to NULL-tenant (global) rows only — mirroring the dispatch
239
+ * resolver's "enforced, no active tenant → global rows only" convention
240
+ * (`resolveDispatchTenantScope` is the core-level trust anchor tenancy fills
241
+ * in; core cannot import tenancy directly). Returns undefined when a tenant IS
242
+ * active (the interceptor filters by it) or tenancy is disabled (no isolation
243
+ * to enforce).
244
+ *
245
+ * Tenant scoping is recognized across BOTH registration forms (#1782): the
246
+ * `@smrt({ tenantScoped })` config / manifest-merged form via
247
+ * `ObjectRegistry.isTenantScoped`, and the standalone `@TenantScoped()`
248
+ * decorator via the tenancy-filled `isTenantScopedClassResolved` hook — so a
249
+ * `@TenantScoped()`-only public model can't slip past the guard regardless of
250
+ * manifest timing.
251
+ */
252
+ resolveTenantReadScope(objectName) {
253
+ if (!objectName || !(ObjectRegistry.isTenantScoped(objectName) || !!ObjectRegistry.getConfig(objectName)?.tenantScoped || isTenantScopedClassResolved(objectName))) return;
254
+ const scope = resolveDispatchTenantScope();
255
+ return scope.enforced && scope.tenantId === null ? { tenantId: null } : void 0;
256
+ }
257
+ /**
258
+ * Merge the fail-closed tenant read scope (#1782) into a query's WHERE clause.
259
+ * When the scope is active (global-only), any client-supplied tenant filter is
260
+ * dropped first so a `?tenantId=...` / `?tenant_id[ne]=...` query param can
261
+ * never widen the scope, then NULL-tenant is forced. Returns the original
262
+ * `where` untouched when the scope is inactive.
263
+ */
264
+ applyTenantReadScope(objectName, where) {
265
+ if (!this.resolveTenantReadScope(objectName)) return where;
266
+ const cleaned = {};
267
+ for (const [key, value] of Object.entries(where ?? {})) {
268
+ const field = key.split(/\s+/)[0];
269
+ if (field === "tenantId" || field === "tenant_id") continue;
270
+ cleaned[key] = value;
271
+ }
272
+ cleaned.tenantId = null;
273
+ return cleaned;
274
+ }
275
+ /**
230
276
  * Handle GET /objects/:id
231
277
  */
232
278
  async handleGet(collection, id, req, objectName) {
233
- const object = await collection.get(id);
279
+ const scope = this.resolveTenantReadScope(objectName);
280
+ const object = scope ? await collection.get({
281
+ id,
282
+ ...scope
283
+ }) : await collection.get(id);
234
284
  if (!object) return this.createErrorResponse(404, "Object not found");
235
285
  return this.createReadResponse(req, objectName, this.toPublicData(object));
236
286
  }
@@ -263,8 +313,9 @@ var APIGenerator = class {
263
313
  where[sqlKey] = operator === "in" ? value.split(",") : value;
264
314
  } else where[key] = value;
265
315
  }
316
+ const scopedWhere = this.applyTenantReadScope(objectName, where);
266
317
  const objects = await collection.list({
267
- where: Object.keys(where).length > 0 ? where : void 0,
318
+ where: scopedWhere && Object.keys(scopedWhere).length > 0 ? scopedWhere : void 0,
268
319
  limit,
269
320
  offset,
270
321
  orderBy
@@ -274,7 +325,7 @@ var APIGenerator = class {
274
325
  /**
275
326
  * Handle GET /objects/count
276
327
  */
277
- async handleCount(collection, params) {
328
+ async handleCount(collection, params, objectName) {
278
329
  const where = {};
279
330
  for (const [key, value] of params.entries()) {
280
331
  const match = key.match(/^(.+)\[(.+)\]$/);
@@ -293,7 +344,8 @@ var APIGenerator = class {
293
344
  where[sqlKey] = operator === "in" ? value.split(",") : value;
294
345
  } else where[key] = value;
295
346
  }
296
- const count = await collection.count({ where: Object.keys(where).length > 0 ? where : void 0 });
347
+ const scopedWhere = this.applyTenantReadScope(objectName, where);
348
+ const count = await collection.count({ where: scopedWhere && Object.keys(scopedWhere).length > 0 ? scopedWhere : void 0 });
297
349
  return this.createJsonResponse({ count });
298
350
  }
299
351
  /**
@@ -457,7 +509,10 @@ var APIGenerator = class {
457
509
  const config = objectName ? ObjectRegistry.getConfig(objectName) : void 0;
458
510
  const apiConfig = config?.api;
459
511
  const tenantScoped = objectName ? ObjectRegistry.isTenantScoped(objectName) || !!config?.tenantScoped : false;
460
- if (objectName) warnIfSharedCacheNeutralized(objectName, apiConfig, tenantScoped);
512
+ if (objectName) {
513
+ warnIfSharedCacheNeutralized(objectName, apiConfig, tenantScoped);
514
+ warnIfTenantScopedPublicRead(objectName, apiConfig, tenantScoped);
515
+ }
461
516
  return conditionalJsonResponse(req, payload, resolveReadCacheControl(apiConfig, { tenantScoped }));
462
517
  }
463
518
  /**