@happyvertical/smrt-core 0.37.6 → 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 (71) hide show
  1. package/AGENTS.md +17 -6
  2. package/dist/collection.d.ts +7 -0
  3. package/dist/collection.d.ts.map +1 -1
  4. package/dist/collection.js +2 -0
  5. package/dist/collection.js.map +1 -1
  6. package/dist/consumer-plugin/index.js +26 -3
  7. package/dist/consumer-plugin/index.js.map +1 -1
  8. package/dist/dispatch/index.d.ts +1 -1
  9. package/dist/dispatch/index.d.ts.map +1 -1
  10. package/dist/dispatch/index.js +2 -2
  11. package/dist/dispatch/tenant-resolver.d.ts +23 -0
  12. package/dist/dispatch/tenant-resolver.d.ts.map +1 -1
  13. package/dist/dispatch/tenant-resolver.js +33 -1
  14. package/dist/dispatch/tenant-resolver.js.map +1 -1
  15. package/dist/generators/conditional-get.d.ts +120 -0
  16. package/dist/generators/conditional-get.d.ts.map +1 -0
  17. package/dist/generators/conditional-get.js +217 -0
  18. package/dist/generators/conditional-get.js.map +1 -0
  19. package/dist/generators/index.d.ts +1 -0
  20. package/dist/generators/index.d.ts.map +1 -1
  21. package/dist/generators/index.js +2 -1
  22. package/dist/generators/rest.d.ts +52 -0
  23. package/dist/generators/rest.d.ts.map +1 -1
  24. package/dist/generators/rest.js +146 -10
  25. package/dist/generators/rest.js.map +1 -1
  26. package/dist/generators.js +2 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4 -2
  30. package/dist/manifest/manifest-loader.d.ts +10 -1
  31. package/dist/manifest/manifest-loader.d.ts.map +1 -1
  32. package/dist/manifest/manifest-loader.js +18 -5
  33. package/dist/manifest/manifest-loader.js.map +1 -1
  34. package/dist/manifest/static-manifest.d.ts.map +1 -1
  35. package/dist/manifest/static-manifest.js +10 -2
  36. package/dist/manifest/static-manifest.js.map +1 -1
  37. package/dist/manifest/store.js +1 -1
  38. package/dist/manifest/store.js.map +1 -1
  39. package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
  40. package/dist/manifest/test-manifest-stub.js +2108 -223
  41. package/dist/manifest/test-manifest-stub.js.map +1 -1
  42. package/dist/manifest.json +10 -2
  43. package/dist/object.d.ts +19 -0
  44. package/dist/object.d.ts.map +1 -1
  45. package/dist/object.js +24 -2
  46. package/dist/object.js.map +1 -1
  47. package/dist/registry/index.d.ts +1 -1
  48. package/dist/registry/index.d.ts.map +1 -1
  49. package/dist/registry/shared-state.d.ts +8 -1
  50. package/dist/registry/shared-state.d.ts.map +1 -1
  51. package/dist/registry/shared-state.js +11 -3
  52. package/dist/registry/shared-state.js.map +1 -1
  53. package/dist/registry/types.d.ts +34 -0
  54. package/dist/registry/types.d.ts.map +1 -1
  55. package/dist/smrt-knowledge.json +7 -6
  56. package/dist/sync/apply.d.ts +234 -0
  57. package/dist/sync/apply.d.ts.map +1 -0
  58. package/dist/sync/apply.js +378 -0
  59. package/dist/sync/apply.js.map +1 -0
  60. package/dist/utils/stack-frames.d.ts +50 -0
  61. package/dist/utils/stack-frames.d.ts.map +1 -0
  62. package/dist/utils/stack-frames.js +64 -0
  63. package/dist/utils/stack-frames.js.map +1 -0
  64. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  65. package/dist/vite-plugin/sveltekit-generator.js +60 -27
  66. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  67. package/dist/vite-plugin/sync-apply-route.d.ts +40 -0
  68. package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -0
  69. package/dist/vite-plugin/sync-apply-route.js +240 -0
  70. package/dist/vite-plugin/sync-apply-route.js.map +1 -0
  71. package/package.json +4 -4
@@ -0,0 +1,217 @@
1
+ import { createHash } from "node:crypto";
2
+ //#region src/generators/conditional-get.ts
3
+ /**
4
+ * Conditional GET v1 for generated read routes (#1757).
5
+ *
6
+ * Generated `list`/`get` responses carry a strong ETag computed from the
7
+ * serialized JSON body, and a matching `If-None-Match` answers
8
+ * `304 Not Modified` with an empty body. v1 deliberately still runs the query
9
+ * — the win is transfer, parse, and re-render, not the database round trip
10
+ * (a later slice upgrades the ETag source to the change-feed table version).
11
+ *
12
+ * Cache-Control policy (fail-private, mirroring the #1540 posture):
13
+ * - Default reads: `private, no-cache` — responses may be stored by the
14
+ * browser but MUST be revalidated before reuse, and shared caches never
15
+ * store them.
16
+ * - `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` reads:
17
+ * `public, max-age=0, s-maxage=<n>` — CDNs/shared caches may serve the
18
+ * response for `n` seconds while browsers still revalidate (cheap 304s).
19
+ * Models without the public flag NEVER emit shared-cache headers, even when
20
+ * `cache.sMaxage` is configured.
21
+ * - Tenant-scoped models (`@smrt({ tenantScoped })` / `@TenantScoped()`, any
22
+ * mode) NEVER emit shared-cache headers: their bodies vary with the tenant
23
+ * context, which URL-keyed shared caches cannot see. `sMaxage` is ignored
24
+ * with a one-time warning.
25
+ *
26
+ * Consumed by both the runtime REST generator (`./rest.ts`) and — as an
27
+ * emitted code snippet — the SvelteKit route generator
28
+ * (`../vite-plugin/sveltekit-generator.ts`). Keeping every piece here keeps
29
+ * the two generators' diffs minimal and the policy in one place.
30
+ */
31
+ /** Default Cache-Control for generated reads: private conditional revalidation. */
32
+ var PRIVATE_READ_CACHE_CONTROL = "private, no-cache";
33
+ /**
34
+ * Compute the strong ETag for a serialized response body.
35
+ *
36
+ * SHA-256 of the exact JSON text, base64url-encoded and quoted per RFC 9110.
37
+ * Deterministic for a given body, so any change to the underlying data (which
38
+ * changes the serialized JSON) changes the ETag.
39
+ */
40
+ function computeBodyEtag(body) {
41
+ return `"${createHash("sha256").update(body).digest("base64url")}"`;
42
+ }
43
+ /**
44
+ * Whether an `If-None-Match` request header matches the response ETag.
45
+ *
46
+ * Implements RFC 9110 §13.1.2 weak comparison: `*` matches anything, the
47
+ * header may carry a comma-separated list, and a `W/` prefix is ignored.
48
+ */
49
+ function ifNoneMatchSatisfied(header, etag) {
50
+ if (!header) return false;
51
+ if (header.trim() === "*") return true;
52
+ return header.split(",").some((candidate) => {
53
+ const tag = candidate.trim();
54
+ return (tag.startsWith("W/") ? tag.slice(2) : tag) === etag;
55
+ });
56
+ }
57
+ /**
58
+ * The shared Cache-Control string the `api` config asks for, or null when the
59
+ * config does not (validly) opt into shared caching. Config-only — the
60
+ * tenant-scoped restriction is applied by `resolveReadCacheControl`.
61
+ */
62
+ function requestedSharedCacheControl(apiConfig) {
63
+ if (!apiConfig || typeof apiConfig !== "object") return null;
64
+ const config = apiConfig;
65
+ const publicRead = config.public === true || config.public === "read";
66
+ const sMaxage = config.cache?.sMaxage;
67
+ if (publicRead && typeof sMaxage === "number" && Number.isFinite(sMaxage) && sMaxage > 0) return `public, max-age=0, s-maxage=${Math.floor(sMaxage)}`;
68
+ return null;
69
+ }
70
+ /**
71
+ * Resolve the Cache-Control header for a generated read response from a
72
+ * model's `@smrt({ api })` config (defensively typed — the config arrives as
73
+ * `unknown` from the registry at runtime and from the manifest at build time).
74
+ *
75
+ * Only models that opted out of auth via `public: true` (or `'read'`, which
76
+ * makes reads public) may emit shared-cache headers, and only when they also
77
+ * configure a positive `cache.sMaxage`. Everything else — including a
78
+ * non-public model that configures `sMaxage` — stays `private, no-cache`.
79
+ *
80
+ * Tenant-scoped models are ALWAYS `private, no-cache` regardless of config:
81
+ * their response bodies vary with the tenant context (resolved from session
82
+ * cookies, invisible to URL-keyed shared caches), so shared caching would
83
+ * leak one tenant's rows to other tenants or anonymous visitors.
84
+ */
85
+ function resolveReadCacheControl(apiConfig, options = {}) {
86
+ if (options.tenantScoped) return PRIVATE_READ_CACHE_CONTROL;
87
+ return requestedSharedCacheControl(apiConfig) ?? "private, no-cache";
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
+ }
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
+ }
115
+ /**
116
+ * Warn (once per model) when a tenant-scoped model configures
117
+ * `api.cache.sMaxage`: the knob is deliberately neutralized to private
118
+ * caching, and silently ignoring it would leave developers wondering why no
119
+ * CDN caching happens. Called from both the REST runtime and the SvelteKit
120
+ * route generator so the message surfaces wherever the model is served.
121
+ */
122
+ function warnIfSharedCacheNeutralized(modelName, apiConfig, tenantScoped) {
123
+ if (!tenantScoped) return;
124
+ if (requestedSharedCacheControl(apiConfig) === null) return;
125
+ if (sharedCacheNeutralizedWarned.has(modelName)) return;
126
+ sharedCacheNeutralizedWarned.add(modelName);
127
+ console.warn(`[smrt] api.cache.sMaxage ignored for tenant-scoped model ${modelName}: shared caches cannot key on tenant context — serving '${PRIVATE_READ_CACHE_CONTROL}' instead (#1757).`);
128
+ }
129
+ /**
130
+ * Build the JSON response for a generated read, honoring `If-None-Match`.
131
+ *
132
+ * Returns `304 Not Modified` with an EMPTY body when the request's
133
+ * `If-None-Match` matches the body ETag; otherwise a 200 with the serialized
134
+ * payload. Both carry the ETag and the resolved Cache-Control so clients can
135
+ * revalidate the representation they hold.
136
+ */
137
+ function conditionalJsonResponse(request, payload, cacheControl) {
138
+ const body = JSON.stringify(payload);
139
+ const etag = computeBodyEtag(body);
140
+ if (ifNoneMatchSatisfied(request.headers.get("if-none-match"), etag)) return new Response(null, {
141
+ status: 304,
142
+ headers: {
143
+ "Cache-Control": cacheControl,
144
+ ETag: etag
145
+ }
146
+ });
147
+ return new Response(body, {
148
+ status: 200,
149
+ headers: {
150
+ "Cache-Control": cacheControl,
151
+ "Content-Type": "application/json",
152
+ ETag: etag
153
+ }
154
+ });
155
+ }
156
+ /**
157
+ * Emit the conditional-GET helper inlined into generated SvelteKit route
158
+ * files, following the generator's existing inline-helper convention
159
+ * (auth guard, tenant context, writable policy). The Cache-Control policy is
160
+ * resolved at generation time from the object's `@smrt({ api })` config plus
161
+ * the model's tenant scoping, and baked in as a constant.
162
+ *
163
+ * Kept textually in lockstep with the runtime helpers above — the `.spec`
164
+ * suite drives both through the same HTTP semantics.
165
+ */
166
+ function generateConditionalGetRouteHelper(apiConfig, options = {}) {
167
+ const cacheControl = resolveReadCacheControl(apiConfig, options);
168
+ if (options.modelName) {
169
+ warnIfSharedCacheNeutralized(options.modelName, apiConfig, options.tenantScoped === true);
170
+ warnIfTenantScopedPublicRead(options.modelName, apiConfig, options.tenantScoped === true);
171
+ }
172
+ return `
173
+ // Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an
174
+ // empty body. Reads stay private unless the model is public AND opts into
175
+ // shared caching via @smrt({ api: { cache: { sMaxage } } }).
176
+ import { createHash } from 'node:crypto';
177
+
178
+ const READ_CACHE_CONTROL = '${cacheControl}';
179
+
180
+ function bodyEtag(body: string): string {
181
+ return \`"\${createHash('sha256').update(body).digest('base64url')}"\`;
182
+ }
183
+
184
+ function ifNoneMatchSatisfied(header: string | null, etag: string): boolean {
185
+ if (!header) return false;
186
+ if (header.trim() === '*') return true;
187
+ return header.split(',').some((candidate) => {
188
+ const tag = candidate.trim();
189
+ const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;
190
+ return opaque === etag;
191
+ });
192
+ }
193
+
194
+ function conditionalJson(request: Request, payload: unknown): Response {
195
+ const body = JSON.stringify(payload);
196
+ const etag = bodyEtag(body);
197
+ if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) {
198
+ return new Response(null, {
199
+ status: 304,
200
+ headers: { 'cache-control': READ_CACHE_CONTROL, etag },
201
+ });
202
+ }
203
+ return new Response(body, {
204
+ status: 200,
205
+ headers: {
206
+ 'cache-control': READ_CACHE_CONTROL,
207
+ 'content-type': 'application/json',
208
+ etag,
209
+ },
210
+ });
211
+ }
212
+ `;
213
+ }
214
+ //#endregion
215
+ export { PRIVATE_READ_CACHE_CONTROL, computeBodyEtag, conditionalJsonResponse, generateConditionalGetRouteHelper, ifNoneMatchSatisfied, resolveReadCacheControl, warnIfSharedCacheNeutralized, warnIfTenantScopedPublicRead };
216
+
217
+ //# sourceMappingURL=conditional-get.js.map
@@ -0,0 +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/** 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"}
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export type { CLIConfig, CLIContext } from './cli';
5
5
  export { CLIGenerator, getCLIHandler, setupCLI } from './cli';
6
+ export { computeBodyEtag, conditionalJsonResponse, ifNoneMatchSatisfied, PRIVATE_READ_CACHE_CONTROL, type ReadCacheControlOptions, resolveReadCacheControl, warnIfSharedCacheNeutralized, } from './conditional-get';
6
7
  export type { MCPConfig, MCPContext, MCPRequest, MCPResponse, MCPTool, } from './mcp';
7
8
  export { MCPGenerator } from './mcp';
8
9
  export type { APIConfig, APIContext, RestServerConfig } from './rest';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/generators/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAEnD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAC9D,YAAY,EACV,SAAS,EACT,UAAU,EACV,UAAU,EACV,WAAW,EACX,OAAO,GACR,MAAM,OAAO,CAAC;AAEf,OAAO,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AACrC,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAEtE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAC;AACzE,YAAY,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAE/C,OAAO,EACL,mBAAmB,EACnB,cAAc,GACf,MAAM,WAAW,CAAC;AAEnB,OAAO,EACL,iBAAiB,EACjB,yBAAyB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,GACvB,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/generators/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAEnD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAE9D,OAAO,EACL,eAAe,EACf,uBAAuB,EACvB,oBAAoB,EACpB,0BAA0B,EAC1B,KAAK,uBAAuB,EAC5B,uBAAuB,EACvB,4BAA4B,GAC7B,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,SAAS,EACT,UAAU,EACV,UAAU,EACV,WAAW,EACX,OAAO,GACR,MAAM,OAAO,CAAC;AAEf,OAAO,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AACrC,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAEtE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAC;AACzE,YAAY,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAE/C,OAAO,EACL,mBAAmB,EACnB,cAAc,GACf,MAAM,WAAW,CAAC;AAEnB,OAAO,EACL,iBAAiB,EACjB,yBAAyB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,iBAAiB,GACvB,MAAM,eAAe,CAAC"}
@@ -1,6 +1,7 @@
1
1
  import { runWithTenantGate, setTenantEntryPointRunner } from "./tenant-gate.js";
2
2
  import { CLIGenerator, getCLIHandler, setupCLI } from "./cli.js";
3
+ import { PRIVATE_READ_CACHE_CONTROL, computeBodyEtag, conditionalJsonResponse, ifNoneMatchSatisfied, resolveReadCacheControl, warnIfSharedCacheNeutralized } from "./conditional-get.js";
3
4
  import { MCPGenerator } from "./mcp.js";
4
5
  import { APIGenerator, createRestServer, startRestServer } from "./rest.js";
5
6
  import { generateOpenAPISpec, setupSwaggerUI } from "./swagger.js";
6
- export { APIGenerator, CLIGenerator, MCPGenerator, createRestServer, generateOpenAPISpec, getCLIHandler, runWithTenantGate, setTenantEntryPointRunner, setupCLI, setupSwaggerUI, startRestServer };
7
+ export { APIGenerator, CLIGenerator, MCPGenerator, PRIVATE_READ_CACHE_CONTROL, computeBodyEtag, conditionalJsonResponse, createRestServer, generateOpenAPISpec, getCLIHandler, ifNoneMatchSatisfied, resolveReadCacheControl, runWithTenantGate, setTenantEntryPointRunner, setupCLI, setupSwaggerUI, startRestServer, warnIfSharedCacheNeutralized };
@@ -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
  */
@@ -109,6 +139,19 @@ export declare class APIGenerator {
109
139
  * Handle DELETE /objects/:id
110
140
  */
111
141
  private handleDelete;
142
+ /**
143
+ * Handle POST /sync/apply — the idempotent batch write contract (#1759).
144
+ * All processing lives in `sync/apply.ts`; this method only adapts the
145
+ * generator's existing collection resolution, auth, action gating, and
146
+ * writable policy into a {@link SyncApplyTarget} per item.
147
+ */
148
+ private handleSyncApply;
149
+ /**
150
+ * Resolve a sync item's `object` segment exactly like `handleObjectRoute`
151
+ * resolves a CRUD URL segment (registered collections first, then registry
152
+ * auto-discovery), and wrap the generator's per-object machinery.
153
+ */
154
+ private resolveSyncApplyTarget;
112
155
  /**
113
156
  * Get or create collection instance
114
157
  */
@@ -124,6 +167,15 @@ export declare class APIGenerator {
124
167
  * (#1540). Falls back to the value unchanged for non-SmrtObject payloads.
125
168
  */
126
169
  private toPublicData;
170
+ /**
171
+ * Create a JSON read response with conditional-GET support (#1757): a strong
172
+ * body-hash ETag, `If-None-Match` → 304 with an empty body, and the
173
+ * Cache-Control policy resolved from the object's `@smrt({ api })` config
174
+ * (private + revalidatable by default; shared `s-maxage` only for public
175
+ * models that opt in). Tenant-scoped models are always private: their bodies
176
+ * vary with tenant context, which URL-keyed shared caches cannot see.
177
+ */
178
+ private createReadResponse;
127
179
  /**
128
180
  * Create JSON response with proper headers
129
181
  */
@@ -1 +1 @@
1
- {"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAI5C,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;IACnE,cAAc,CAAC,EAAE,CACf,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,KACX,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,GAAG,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,IAAI,CAAC,EAAE;QACL,EAAE,EAAE,MAAM,CAAC;QACX,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;KAClB,CAAC;CACH;AAED;;GAEG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,MAAM,CAAY;IAC1B,OAAO,CAAC,WAAW,CAAiD;IACpE,OAAO,CAAC,OAAO,CAAa;gBAEhB,MAAM,GAAE,SAAc,EAAE,OAAO,GAAE,UAAe;IAa5D;;;;;OAKG;IACH,kBAAkB,CAChB,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,cAAc,CAAC,UAAU,CAAC,GACrC,IAAI;IAIP;;OAEG;IACH,YAAY,IAAI;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE;IAoBpD;;OAEG;YACW,cAAc;IAQ5B;;OAEG;YACW,uBAAuB;IAyBrC;;OAEG;YACW,yBAAyB;IAwBvC;;OAEG;IACH,eAAe,IAAI,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC;IAItD;;OAEG;YACW,aAAa;IA4B3B;;OAEG;YACW,iBAAiB;IAkG/B;;OAEG;YACW,oBAAoB;IAwDlC,OAAO,CAAC,aAAa;IAmBrB;;;;;;OAMG;IACH,OAAO,CAAC,aAAa;IAarB,OAAO,CAAC,kBAAkB;IA4B1B,OAAO,CAAC,uBAAuB;IAuB/B;;OAEG;YACW,SAAS;IAWvB;;OAEG;YACW,UAAU;IAkDxB;;OAEG;YACW,WAAW;IAsCzB;;OAEG;YACW,YAAY;IAW1B;;OAEG;YACW,YAAY;IAoB1B;;OAEG;YACW,YAAY;IAc1B;;OAEG;IACH,OAAO,CAAC,aAAa;IAqBrB;;;;OAIG;IACH,OAAO,CAAC,mBAAmB;IAiD3B;;;OAGG;IACH,OAAO,CAAC,YAAY;IAOpB;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAS1B;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAS3B;;;;OAIG;IACH,OAAO,CAAC,oBAAoB;IAQ5B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAc1B;;OAEG;IACH,OAAO,CAAC,cAAc;IAoBtB;;OAEG;IACH,OAAO,CAAC,SAAS;CASlB;AAID,MAAM,WAAW,gBAAiB,SAAQ,SAAS;IACjD,WAAW,CAAC,EAAE;QACZ,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,YAAY,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC;KAC3C,CAAC;CACH;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,CAAC,OAAO,UAAU,CAAC,EAAE,EAC9B,OAAO,GAAE,UAAe,EACxB,MAAM,GAAE,gBAAqB,GAC5B;IAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CActC;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,CAAC,OAAO,UAAU,CAAC,EAAE,EAC9B,OAAO,GAAE,UAAe,EACxB,MAAM,GAAE,gBAAqB,GAC5B,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAwB9B"}
1
+ {"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,8 @@
1
1
  import { ObjectRegistry } from "../registry.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";
5
+ import { SYNC_APPLY_ROUTE_SEGMENTS, processSyncApplyBatch } from "../sync/apply.js";
2
6
  import http from "node:http";
3
7
  //#region src/generators/rest.ts
4
8
  /**
@@ -124,6 +128,10 @@ var APIGenerator = class {
124
128
  async handleObjectRoute(req, url) {
125
129
  const pathParts = url.pathname.replace(this.config.basePath || "", "").split("/").filter(Boolean);
126
130
  if (pathParts.length === 0) return this.createErrorResponse(400, "Object type required");
131
+ if (pathParts.length === SYNC_APPLY_ROUTE_SEGMENTS.length && SYNC_APPLY_ROUTE_SEGMENTS.every((segment, i) => pathParts[i] === segment)) {
132
+ if (req.method !== "POST") return this.createErrorResponse(405, "Method not allowed");
133
+ return await this.handleSyncApply(req);
134
+ }
127
135
  const objectType = pathParts[0];
128
136
  const objectId = pathParts[1];
129
137
  if (this.collections.has(objectType)) {
@@ -160,9 +168,9 @@ var APIGenerator = class {
160
168
  try {
161
169
  const action = this.getCrudAction(req.method, objectId);
162
170
  if (action && !this.isApiActionEnabled(objectName, action)) return this.createErrorResponse(405, "Method not allowed");
163
- 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);
164
172
  switch (req.method) {
165
- case "GET": return objectId ? await this.handleGet(collection, objectId) : await this.handleList(collection, url.searchParams);
173
+ case "GET": return objectId ? await this.handleGet(collection, objectId, req, objectName) : await this.handleList(collection, url.searchParams, req, objectName);
166
174
  case "POST": return await this.handleCreate(collection, req, objectName);
167
175
  case "PUT":
168
176
  case "PATCH":
@@ -221,17 +229,65 @@ var APIGenerator = class {
221
229
  return registered?.qualifiedName || registered?.name || itemClass.name;
222
230
  }
223
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
+ /**
224
276
  * Handle GET /objects/:id
225
277
  */
226
- async handleGet(collection, id) {
227
- const object = await collection.get(id);
278
+ async handleGet(collection, id, req, objectName) {
279
+ const scope = this.resolveTenantReadScope(objectName);
280
+ const object = scope ? await collection.get({
281
+ id,
282
+ ...scope
283
+ }) : await collection.get(id);
228
284
  if (!object) return this.createErrorResponse(404, "Object not found");
229
- return this.createJsonResponse(this.toPublicData(object));
285
+ return this.createReadResponse(req, objectName, this.toPublicData(object));
230
286
  }
231
287
  /**
232
288
  * Handle GET /objects (list with query params)
233
289
  */
234
- async handleList(collection, params) {
290
+ async handleList(collection, params, req, objectName) {
235
291
  const limit = Number.parseInt(params.get("limit") || "50", 10);
236
292
  const offset = Number.parseInt(params.get("offset") || "0", 10);
237
293
  const orderBy = params.get("orderBy") || "created_at DESC";
@@ -257,18 +313,19 @@ var APIGenerator = class {
257
313
  where[sqlKey] = operator === "in" ? value.split(",") : value;
258
314
  } else where[key] = value;
259
315
  }
316
+ const scopedWhere = this.applyTenantReadScope(objectName, where);
260
317
  const objects = await collection.list({
261
- where: Object.keys(where).length > 0 ? where : void 0,
318
+ where: scopedWhere && Object.keys(scopedWhere).length > 0 ? scopedWhere : void 0,
262
319
  limit,
263
320
  offset,
264
321
  orderBy
265
322
  });
266
- return this.createJsonResponse(objects.map((object) => this.toPublicData(object)));
323
+ return this.createReadResponse(req, objectName, objects.map((object) => this.toPublicData(object)));
267
324
  }
268
325
  /**
269
326
  * Handle GET /objects/count
270
327
  */
271
- async handleCount(collection, params) {
328
+ async handleCount(collection, params, objectName) {
272
329
  const where = {};
273
330
  for (const [key, value] of params.entries()) {
274
331
  const match = key.match(/^(.+)\[(.+)\]$/);
@@ -287,7 +344,8 @@ var APIGenerator = class {
287
344
  where[sqlKey] = operator === "in" ? value.split(",") : value;
288
345
  } else where[key] = value;
289
346
  }
290
- 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 });
291
349
  return this.createJsonResponse({ count });
292
350
  }
293
351
  /**
@@ -323,6 +381,66 @@ var APIGenerator = class {
323
381
  return new Response(null, { status: 204 });
324
382
  }
325
383
  /**
384
+ * Handle POST /sync/apply — the idempotent batch write contract (#1759).
385
+ * All processing lives in `sync/apply.ts`; this method only adapts the
386
+ * generator's existing collection resolution, auth, action gating, and
387
+ * writable policy into a {@link SyncApplyTarget} per item.
388
+ */
389
+ async handleSyncApply(req) {
390
+ let rawBody;
391
+ let body;
392
+ try {
393
+ rawBody = await req.text();
394
+ body = JSON.parse(rawBody);
395
+ } catch {
396
+ return this.createErrorResponse(400, "Invalid JSON body");
397
+ }
398
+ const outcome = await processSyncApplyBatch(body, { resolveTarget: (objectSegment) => this.resolveSyncApplyTarget(objectSegment, req, rawBody) });
399
+ return this.createJsonResponse(outcome.body, outcome.status);
400
+ }
401
+ /**
402
+ * Resolve a sync item's `object` segment exactly like `handleObjectRoute`
403
+ * resolves a CRUD URL segment (registered collections first, then registry
404
+ * auto-discovery), and wrap the generator's per-object machinery.
405
+ */
406
+ resolveSyncApplyTarget(objectSegment, req, rawBody) {
407
+ let collection = null;
408
+ let objectName = null;
409
+ const registered = this.collections.get(objectSegment);
410
+ if (registered) {
411
+ collection = registered;
412
+ objectName = this.getCollectionObjectName(registered) || objectSegment;
413
+ } else {
414
+ const pluralName = this.pluralize(objectSegment);
415
+ for (const [key, info] of ObjectRegistry.getAllClasses()) if (this.pluralize((info.name || key).toLowerCase()) === pluralName) {
416
+ collection = this.getCollection(info);
417
+ objectName = info.name;
418
+ break;
419
+ }
420
+ }
421
+ if (!collection || !objectName) return null;
422
+ const resolvedName = objectName;
423
+ return {
424
+ objectName: resolvedName,
425
+ collection,
426
+ isOpAllowed: (op) => this.isApiActionEnabled(resolvedName, op),
427
+ authorize: async (op) => {
428
+ if (this.config.authMiddleware) {
429
+ const verb = op === "create" ? "post" : op === "update" ? "put" : "delete";
430
+ const authResult = await this.config.authMiddleware(resolvedName, verb)(new Request(req.url, {
431
+ method: req.method,
432
+ headers: req.headers,
433
+ body: rawBody
434
+ }));
435
+ if (authResult instanceof Response) return authResult.status === 401 ? "auth_required" : "forbidden";
436
+ return "ok";
437
+ }
438
+ return this.isRoutePublic(resolvedName, "POST") ? "ok" : "auth_required";
439
+ },
440
+ prepare: (payload) => this.applyWritablePolicy(resolvedName, payload)
441
+ };
442
+ }
443
+ /**
326
444
  * Get or create collection instance
327
445
  */
328
446
  getCollection(classInfo) {
@@ -380,6 +498,24 @@ var APIGenerator = class {
380
498
  return typeof serializable?.toPublicJSON === "function" ? serializable.toPublicJSON() : object;
381
499
  }
382
500
  /**
501
+ * Create a JSON read response with conditional-GET support (#1757): a strong
502
+ * body-hash ETag, `If-None-Match` → 304 with an empty body, and the
503
+ * Cache-Control policy resolved from the object's `@smrt({ api })` config
504
+ * (private + revalidatable by default; shared `s-maxage` only for public
505
+ * models that opt in). Tenant-scoped models are always private: their bodies
506
+ * vary with tenant context, which URL-keyed shared caches cannot see.
507
+ */
508
+ createReadResponse(req, objectName, payload) {
509
+ const config = objectName ? ObjectRegistry.getConfig(objectName) : void 0;
510
+ const apiConfig = config?.api;
511
+ const tenantScoped = objectName ? ObjectRegistry.isTenantScoped(objectName) || !!config?.tenantScoped : false;
512
+ if (objectName) {
513
+ warnIfSharedCacheNeutralized(objectName, apiConfig, tenantScoped);
514
+ warnIfTenantScopedPublicRead(objectName, apiConfig, tenantScoped);
515
+ }
516
+ return conditionalJsonResponse(req, payload, resolveReadCacheControl(apiConfig, { tenantScoped }));
517
+ }
518
+ /**
383
519
  * Create JSON response with proper headers
384
520
  */
385
521
  createJsonResponse(data, status = 200) {