@happyvertical/smrt-core 0.38.2 → 0.38.4

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 (130) hide show
  1. package/AGENTS.md +11 -2
  2. package/dist/__tests__/fixtures/advisor-test-classes.d.ts +9 -0
  3. package/dist/__tests__/fixtures/advisor-test-classes.d.ts.map +1 -1
  4. package/dist/change-feed.d.ts +19 -10
  5. package/dist/change-feed.d.ts.map +1 -1
  6. package/dist/change-feed.js +43 -13
  7. package/dist/change-feed.js.map +1 -1
  8. package/dist/change-signals.d.ts +83 -0
  9. package/dist/change-signals.d.ts.map +1 -0
  10. package/dist/change-signals.js +272 -0
  11. package/dist/change-signals.js.map +1 -0
  12. package/dist/collection-cache.d.ts +21 -0
  13. package/dist/collection-cache.d.ts.map +1 -1
  14. package/dist/collection-cache.js +0 -0
  15. package/dist/collection-cache.js.map +1 -1
  16. package/dist/config.d.ts +1 -1
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js.map +1 -1
  19. package/dist/decorators/index.d.ts +8 -0
  20. package/dist/decorators/index.d.ts.map +1 -1
  21. package/dist/decorators/index.js.map +1 -1
  22. package/dist/errors.js +2 -1
  23. package/dist/errors.js.map +1 -1
  24. package/dist/generators/changes-route.d.ts +15 -27
  25. package/dist/generators/changes-route.d.ts.map +1 -1
  26. package/dist/generators/changes-route.js +17 -7
  27. package/dist/generators/changes-route.js.map +1 -1
  28. package/dist/generators/cli.d.ts +3 -0
  29. package/dist/generators/cli.d.ts.map +1 -1
  30. package/dist/generators/cli.js +7 -4
  31. package/dist/generators/cli.js.map +1 -1
  32. package/dist/generators/conditional-get.d.ts +17 -6
  33. package/dist/generators/conditional-get.d.ts.map +1 -1
  34. package/dist/generators/conditional-get.js +19 -13
  35. package/dist/generators/conditional-get.js.map +1 -1
  36. package/dist/generators/events-route.d.ts +72 -0
  37. package/dist/generators/events-route.d.ts.map +1 -0
  38. package/dist/generators/events-route.js +234 -0
  39. package/dist/generators/events-route.js.map +1 -0
  40. package/dist/generators/index.d.ts +1 -0
  41. package/dist/generators/index.d.ts.map +1 -1
  42. package/dist/generators/index.js +2 -1
  43. package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
  44. package/dist/generators/mcp-runtime-template.js +11 -5
  45. package/dist/generators/mcp-runtime-template.js.map +1 -1
  46. package/dist/generators/mcp.d.ts +3 -0
  47. package/dist/generators/mcp.d.ts.map +1 -1
  48. package/dist/generators/mcp.js +18 -9
  49. package/dist/generators/mcp.js.map +1 -1
  50. package/dist/generators/rest.d.ts +7 -1
  51. package/dist/generators/rest.d.ts.map +1 -1
  52. package/dist/generators/rest.js +77 -13
  53. package/dist/generators/rest.js.map +1 -1
  54. package/dist/generators.js +2 -1
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +3 -1
  58. package/dist/manifest/generator.js.map +1 -1
  59. package/dist/manifest/manifest-loader.js.map +1 -1
  60. package/dist/manifest/static-manifest.d.ts.map +1 -1
  61. package/dist/manifest/static-manifest.js +7 -3
  62. package/dist/manifest/static-manifest.js.map +1 -1
  63. package/dist/manifest/store.js +1 -1
  64. package/dist/manifest/store.js.map +1 -1
  65. package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
  66. package/dist/manifest/test-manifest-stub.js +1171 -90
  67. package/dist/manifest/test-manifest-stub.js.map +1 -1
  68. package/dist/manifest.json +9 -3
  69. package/dist/mcp-advisor/tools/preview-api-endpoints.d.ts +1 -0
  70. package/dist/mcp-advisor/tools/preview-api-endpoints.d.ts.map +1 -1
  71. package/dist/mcp-advisor/types.d.ts +4 -0
  72. package/dist/mcp-advisor/types.d.ts.map +1 -1
  73. package/dist/migrations/tracker.js +5 -4
  74. package/dist/migrations/tracker.js.map +1 -1
  75. package/dist/migrations/types.d.ts +1 -1
  76. package/dist/migrations/types.d.ts.map +1 -1
  77. package/dist/object.d.ts +17 -8
  78. package/dist/object.d.ts.map +1 -1
  79. package/dist/object.js +42 -26
  80. package/dist/object.js.map +1 -1
  81. package/dist/registry/class-registration.d.ts.map +1 -1
  82. package/dist/registry/class-registration.js +12 -0
  83. package/dist/registry/class-registration.js.map +1 -1
  84. package/dist/registry/shared-state.d.ts +2 -1
  85. package/dist/registry/shared-state.d.ts.map +1 -1
  86. package/dist/registry/shared-state.js.map +1 -1
  87. package/dist/registry/types.d.ts.map +1 -1
  88. package/dist/registry.d.ts.map +1 -1
  89. package/dist/registry.js +1 -1
  90. package/dist/registry.js.map +1 -1
  91. package/dist/runtime/client.d.ts.map +1 -1
  92. package/dist/runtime/client.js.map +1 -1
  93. package/dist/scanner/manifest-generator.js.map +1 -1
  94. package/dist/scanner/types.d.ts +7 -0
  95. package/dist/scanner/types.d.ts.map +1 -1
  96. package/dist/schema/ddl/base-strategy.js +1 -1
  97. package/dist/schema/ddl/base-strategy.js.map +1 -1
  98. package/dist/scripts/migrate-datetime-to-timestamp.ts +1 -1
  99. package/dist/signals/sanitizer.d.ts.map +1 -1
  100. package/dist/signals/sanitizer.js.map +1 -1
  101. package/dist/smrt-knowledge.json +8 -8
  102. package/dist/testing/index.d.ts +1 -0
  103. package/dist/testing/index.d.ts.map +1 -1
  104. package/dist/testing/index.js +2 -1
  105. package/dist/testing/notifications-stub.d.ts +24 -0
  106. package/dist/testing/notifications-stub.d.ts.map +1 -0
  107. package/dist/testing/notifications-stub.js +62 -0
  108. package/dist/testing/notifications-stub.js.map +1 -0
  109. package/dist/testing.js +2 -1
  110. package/dist/tools/tool-generator.js +1 -1
  111. package/dist/tools/tool-generator.js.map +1 -1
  112. package/dist/vite-plugin/changes-route.d.ts +14 -0
  113. package/dist/vite-plugin/changes-route.d.ts.map +1 -1
  114. package/dist/vite-plugin/changes-route.js +2 -2
  115. package/dist/vite-plugin/changes-route.js.map +1 -1
  116. package/dist/vite-plugin/events-route.d.ts +9 -0
  117. package/dist/vite-plugin/events-route.d.ts.map +1 -0
  118. package/dist/vite-plugin/events-route.js +155 -0
  119. package/dist/vite-plugin/events-route.js.map +1 -0
  120. package/dist/vite-plugin/index.d.ts +8 -0
  121. package/dist/vite-plugin/index.d.ts.map +1 -1
  122. package/dist/vite-plugin/index.js +8 -2
  123. package/dist/vite-plugin/index.js.map +1 -1
  124. package/dist/vite-plugin/sveltekit-generator.d.ts +8 -0
  125. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  126. package/dist/vite-plugin/sveltekit-generator.js +239 -38
  127. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  128. package/dist/vite-plugin/web-collections.js +1 -1
  129. package/dist/vite-plugin/web-collections.js.map +1 -1
  130. package/package.json +12 -12
@@ -1,6 +1,7 @@
1
1
  import { generateConditionalGetRouteHelper } from "../generators/conditional-get.js";
2
2
  import { AUTO_GENERATED_ROUTE_HEADER } from "./route-header.js";
3
3
  import { generateChangesRoute } from "./changes-route.js";
4
+ import { generateEventsRoute } from "./events-route.js";
4
5
  import { generateSyncApplyRoute } from "./sync-apply-route.js";
5
6
  import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
6
7
  import { join, relative } from "node:path";
@@ -175,6 +176,59 @@ function getApiPublicAccess(apiConfig) {
175
176
  if (value === true || value === "read") return value;
176
177
  return false;
177
178
  }
179
+ function toFieldColumnAlias(fieldName) {
180
+ return fieldName.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").replace(/([a-z\d])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase();
181
+ }
182
+ function manifestObjectKey(objectDef) {
183
+ return objectDef.qualifiedName ?? objectDef.className;
184
+ }
185
+ function manifestObjectExtends(objectDef, parent) {
186
+ const extendsQualified = objectDef.extendsQualified;
187
+ const extendsName = objectDef.extends;
188
+ return typeof extendsQualified === "string" && extendsQualified === parent.qualifiedName || typeof extendsName === "string" && (extendsName === parent.className || extendsName === parent.qualifiedName);
189
+ }
190
+ function findManifestParent(objectDef, manifest) {
191
+ return Object.values(manifest.objects).find((candidate) => manifestObjectExtends(objectDef, candidate));
192
+ }
193
+ function collectStiHierarchyObjects(objectDef, manifest) {
194
+ if (!manifest) return [objectDef];
195
+ let cursor = objectDef;
196
+ let stiBase = cursor.decoratorConfig?.tableStrategy === "sti" ? cursor : void 0;
197
+ while (cursor) {
198
+ const parent = findManifestParent(cursor, manifest);
199
+ if (!parent) break;
200
+ if (parent.decoratorConfig?.tableStrategy === "sti") stiBase = parent;
201
+ cursor = parent;
202
+ }
203
+ if (!stiBase) return [objectDef];
204
+ const members = /* @__PURE__ */ new Map();
205
+ const visit = (current) => {
206
+ const key = manifestObjectKey(current);
207
+ if (members.has(key)) return;
208
+ members.set(key, current);
209
+ for (const candidate of Object.values(manifest.objects)) {
210
+ if (candidate === current) continue;
211
+ if (manifestObjectExtends(candidate, current)) visit(candidate);
212
+ }
213
+ };
214
+ visit(stiBase);
215
+ return [...members.values()];
216
+ }
217
+ function collectReadPermissionFields(objectDef, manifest) {
218
+ const result = [];
219
+ const seen = /* @__PURE__ */ new Set();
220
+ for (const member of collectStiHierarchyObjects(objectDef, manifest)) for (const [name, def] of Object.entries(member.fields ?? {})) {
221
+ const permission = typeof def.readPermission === "string" ? def.readPermission : typeof def._meta?.readPermission === "string" ? def._meta.readPermission : void 0;
222
+ if (!permission) continue;
223
+ for (const fieldName of [name, toFieldColumnAlias(name)]) {
224
+ const key = `${fieldName}\0${permission}`;
225
+ if (seen.has(key)) continue;
226
+ seen.add(key);
227
+ result.push([fieldName, permission]);
228
+ }
229
+ }
230
+ return result;
231
+ }
178
232
  /**
179
233
  * Emit the fail-closed authorization guard injected into generated route files
180
234
  * (#1540, 2c). Every generated CRUD/action handler calls `requireRouteAuth`,
@@ -182,12 +236,18 @@ function getApiPublicAccess(apiConfig) {
182
236
  * authenticated principal. Mirrors the knowledge-route `isKnowledgeAdmin`
183
237
  * precedent. Mutating verbs require auth even when reads are public.
184
238
  */
185
- function generateAuthGuardHelper(objectDef) {
239
+ function generateAuthGuardHelper(objectDef, manifest) {
186
240
  const publicAccess = getApiPublicAccess(objectDef.decoratorConfig?.api);
241
+ const readPermissionFields = collectReadPermissionFields(objectDef, manifest);
187
242
  return `
188
243
  // Fail-closed authorization (#1540): generated routes require an authenticated
189
244
  // principal on \`locals\` unless explicitly marked \`@smrt({ api: { public } })\`.
190
245
  const PUBLIC_ACCESS: boolean | 'read' = ${JSON.stringify(publicAccess)};
246
+ const READ_PERMISSION_FIELDS: Array<[string, string]> = ${JSON.stringify(readPermissionFields)};
247
+
248
+ interface PublicJsonOptions {
249
+ permissions?: Iterable<string>;
250
+ }
191
251
 
192
252
  function hasAuthenticatedPrincipal(locals: unknown): boolean {
193
253
  if (!locals || typeof locals !== 'object') return false;
@@ -215,13 +275,74 @@ function requireRouteAuth(locals: unknown, mutating: boolean): void {
215
275
  }
216
276
  }
217
277
 
278
+ function getPublicJsonOptions(locals: unknown): PublicJsonOptions {
279
+ const l = readJsonRecord(locals);
280
+ const permissions = l.permissions ?? l.permissionSet ?? l.smrtPermissions;
281
+ if (Array.isArray(permissions) || permissions instanceof Set) {
282
+ return { permissions };
283
+ }
284
+ return {};
285
+ }
286
+
287
+ function hasReadPermission(
288
+ options: PublicJsonOptions,
289
+ permission: string,
290
+ ): boolean {
291
+ if (!options.permissions) return false;
292
+ for (const granted of options.permissions) {
293
+ if (granted === permission) return true;
294
+ }
295
+ return false;
296
+ }
297
+
298
+ function applyReadPermissionRedaction(
299
+ value: unknown,
300
+ options: PublicJsonOptions,
301
+ seen: WeakSet<object> = new WeakSet(),
302
+ ): unknown {
303
+ if (value === null || typeof value !== 'object') return value;
304
+ if (seen.has(value)) return null;
305
+ if (hasPublicJson(value)) {
306
+ seen.add(value);
307
+ return applyReadPermissionRedaction(
308
+ value.toPublicJSON(options),
309
+ options,
310
+ seen,
311
+ );
312
+ }
313
+ if (Array.isArray(value)) {
314
+ seen.add(value);
315
+ return value.map((entry) =>
316
+ applyReadPermissionRedaction(entry, options, seen),
317
+ );
318
+ }
319
+ const proto = Object.getPrototypeOf(value);
320
+ if (proto !== Object.prototype && proto !== null) return value;
321
+ seen.add(value);
322
+ const out = { ...(value as Record<string, unknown>) };
323
+ const metaData =
324
+ out._meta_data && typeof out._meta_data === 'object'
325
+ ? { ...(out._meta_data as Record<string, unknown>) }
326
+ : null;
327
+ for (const [fieldName, permission] of READ_PERMISSION_FIELDS) {
328
+ if (hasReadPermission(options, permission)) continue;
329
+ delete out[fieldName];
330
+ if (metaData) delete metaData[fieldName];
331
+ }
332
+ if (metaData) out._meta_data = metaData;
333
+ for (const [key, entry] of Object.entries(out)) {
334
+ out[key] = applyReadPermissionRedaction(entry, options, seen);
335
+ }
336
+ return out;
337
+ }
338
+
218
339
  // Sensitive-field-safe serialization for custom-action results (#1540): a
219
340
  // custom method may return a SmrtObject (or one nested in an array/plain
220
341
  // object), so recurse and route each through toPublicJSON() rather than letting
221
342
  // JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives
222
343
  // pass through; a cycle guard prevents infinite loops.
223
344
  interface PublicJsonSource {
224
- toPublicJSON(): unknown;
345
+ toPublicJSON(options?: PublicJsonOptions): unknown;
225
346
  }
226
347
 
227
348
  function hasPublicJson(value: object): value is PublicJsonSource {
@@ -238,17 +359,18 @@ function readJsonRecord(value: unknown): Record<string, unknown> {
238
359
 
239
360
  function toPublicResult(
240
361
  value: unknown,
362
+ options: PublicJsonOptions,
241
363
  seen: WeakSet<object> = new WeakSet(),
242
364
  ): unknown {
243
365
  if (value === null || typeof value !== 'object') return value;
244
366
  if (seen.has(value)) return null;
245
367
  if (hasPublicJson(value)) {
246
368
  seen.add(value);
247
- return toPublicResult(value.toPublicJSON(), seen);
369
+ return toPublicResult(value.toPublicJSON(options), options, seen);
248
370
  }
249
371
  if (Array.isArray(value)) {
250
372
  seen.add(value);
251
- return value.map((entry) => toPublicResult(entry, seen));
373
+ return value.map((entry) => toPublicResult(entry, options, seen));
252
374
  }
253
375
  const proto = Object.getPrototypeOf(value);
254
376
  if (proto !== Object.prototype && proto !== null) return value;
@@ -257,7 +379,7 @@ function toPublicResult(
257
379
  for (const [key, entry] of Object.entries(
258
380
  value as Record<string, unknown>,
259
381
  )) {
260
- out[key] = toPublicResult(entry, seen);
382
+ out[key] = toPublicResult(entry, options, seen);
261
383
  }
262
384
  return out;
263
385
  }
@@ -293,7 +415,7 @@ function isTenantScoped(objectDef) {
293
415
  */
294
416
  function generateTenantContextHelper() {
295
417
  return `
296
- import { enterTenantContext, hasTenantContext, isTenancyEnabled } from '@happyvertical/smrt-tenancy';
418
+ import { enterTenantContext, getCurrentTenant, hasTenantContext, isSuperAdminBypass, isTenancyEnabled } from '@happyvertical/smrt-tenancy';
297
419
 
298
420
  function establishTenantContext(locals: unknown): void {
299
421
  if (hasTenantContext()) return;
@@ -319,6 +441,18 @@ function tenantReadScope(): { tenantId: null } | undefined {
319
441
  ? { tenantId: null }
320
442
  : undefined;
321
443
  }
444
+
445
+ // Custom GET actions that accept a single options object may perform their
446
+ // own reads instead of calling generated list/get handlers. Give those actions
447
+ // an explicit tenant scope: active tenant when present, NULL-tenant global rows
448
+ // when tenancy is enabled but no tenant context exists. Super-admin bypass
449
+ // stays unscoped so admin callers keep deliberate cross-tenant access.
450
+ function tenantReadOptionsScope(): { tenantId: string | null } | undefined {
451
+ if (!isTenancyEnabled() || isSuperAdminBypass()) {
452
+ return undefined;
453
+ }
454
+ return { tenantId: getCurrentTenant()?.tenantId ?? null };
455
+ }
322
456
  `;
323
457
  }
324
458
  /**
@@ -388,10 +522,14 @@ function buildPathParamsObjectLiteral(pathParamNames) {
388
522
  ${pathParamNames.map((paramName) => ` ${buildObjectTypeProperty(paramName)}: ${buildRouteParamAccess(paramName)},`).join("\n")}
389
523
  }`;
390
524
  }
391
- function buildActionInvocationArgs(actionDef) {
525
+ function hasSingleOptionsParameter(actionDef) {
526
+ const parameters = Array.isArray(actionDef.parameters) ? actionDef.parameters : [];
527
+ return parameters.length === 1 && parameters[0]?.name === "options";
528
+ }
529
+ function buildActionInvocationArgs(actionDef, optionsIdentifier = "options") {
392
530
  const parameters = Array.isArray(actionDef.parameters) ? actionDef.parameters : [];
393
531
  if (parameters.length === 0) return [];
394
- if (parameters.length === 1 && parameters[0]?.name === "options") return ["options"];
532
+ if (hasSingleOptionsParameter(actionDef)) return [optionsIdentifier];
395
533
  return parameters.map((parameter) => buildOptionsPropertyAccess(parameter.name));
396
534
  }
397
535
  function buildObjectTypeProperty(propertyName) {
@@ -456,6 +594,21 @@ function buildActionInvocationExpression(receiverExpression, actionName, invocat
456
594
  " )"
457
595
  ].join("\n");
458
596
  }
597
+ function buildScopedOptionsForTenantRead(actionDef, routeConfig, tenantScoped) {
598
+ if (!tenantScoped || routeConfig.method !== "GET" || !hasSingleOptionsParameter(actionDef)) return {
599
+ source: "",
600
+ optionsIdentifier: "options"
601
+ };
602
+ return {
603
+ source: ` const readScope = tenantReadOptionsScope();
604
+ const scopedOptions = readScope
605
+ ? ({ ...options, ...readScope } as ActionArgs[0])
606
+ : options;
607
+
608
+ `,
609
+ optionsIdentifier: "scopedOptions"
610
+ };
611
+ }
459
612
  function findItemClassRegistryKey(className, objectDef, manifest) {
460
613
  const itemClassName = objectDef.extendsTypeArg || (className.endsWith("Collection") ? className.slice(0, -10) : void 0);
461
614
  if (!itemClassName) return className;
@@ -492,12 +645,13 @@ async function generateSvelteKitRoutes(projectRoot, manifest, options) {
492
645
  }
493
646
  continue;
494
647
  }
495
- await generateRoutesForObject(projectRoot, className, objectDef, options);
648
+ await generateRoutesForObject(projectRoot, className, objectDef, manifest, options);
496
649
  generatedCount++;
497
650
  }
498
651
  if (options.knowledge?.api?.enabled) generateKnowledgeRoute(projectRoot, options);
499
652
  generateSyncApplyRoute(projectRoot, manifest, options);
500
653
  generateChangesRoute(projectRoot, manifest, options);
654
+ generateEventsRoute(projectRoot, manifest, options);
501
655
  updateGitignore(projectRoot, options);
502
656
  const skippedMsg = skippedCollections > 0 ? ` (skipped ${skippedCollections} collection classes)` : "";
503
657
  console.log(`[smrt] Generated routes for ${generatedCount} SMRT objects${skippedMsg}`);
@@ -780,7 +934,7 @@ export async function getCollection<
780
934
  /**
781
935
  * Generates route files for a single SMRT object
782
936
  */
783
- async function generateRoutesForObject(projectRoot, className, objectDef, options) {
937
+ async function generateRoutesForObject(projectRoot, className, objectDef, manifest, options) {
784
938
  const collectionName = objectDef.collection;
785
939
  const routeDir = join(projectRoot, options.routesDir, collectionName);
786
940
  const apiConfig = objectDef.decoratorConfig?.api;
@@ -789,9 +943,9 @@ async function generateRoutesForObject(projectRoot, className, objectDef, option
789
943
  return;
790
944
  }
791
945
  const includedActions = resolveStandardCrudActions(apiConfig);
792
- if (includedActions.includes("list") || includedActions.includes("create")) writeRoute(routeDir, "+server.ts", generateCollectionRouteTemplate(projectRoot, className, objectDef, includedActions, options, routeDir));
946
+ if (includedActions.includes("list") || includedActions.includes("create")) writeRoute(routeDir, "+server.ts", generateCollectionRouteTemplate(projectRoot, className, objectDef, manifest, includedActions, options, routeDir));
793
947
  if (includedActions.includes("get") || includedActions.includes("update") || includedActions.includes("delete")) {
794
- const itemRoute = generateItemRouteTemplate(projectRoot, className, objectDef, includedActions, options, join(routeDir, "[id]"));
948
+ const itemRoute = generateItemRouteTemplate(projectRoot, className, objectDef, manifest, includedActions, options, join(routeDir, "[id]"));
795
949
  writeRoute(join(routeDir, "[id]"), "+server.ts", itemRoute);
796
950
  }
797
951
  const customActions = Object.entries(objectDef.methods).filter(([name, method]) => !STANDARD_API_ACTIONS.includes(name) && method.isPublic && shouldIncludeInApi(name, apiConfig));
@@ -816,7 +970,7 @@ async function generateRoutesForObject(projectRoot, className, objectDef, option
816
970
  }
817
971
  });
818
972
  }
819
- for (const [actionRouteDir, routeSpecs] of groupCustomActionRoutes(actionSpecs)) writeRoute(actionRouteDir, "+server.ts", generateActionRouteTemplate(projectRoot, actionRouteDir, routeSpecs, objectDef, options));
973
+ for (const [actionRouteDir, routeSpecs] of groupCustomActionRoutes(actionSpecs)) writeRoute(actionRouteDir, "+server.ts", generateActionRouteTemplate(projectRoot, actionRouteDir, routeSpecs, objectDef, manifest, options));
820
974
  }
821
975
  async function generateCollectionRoutesForObject(projectRoot, className, objectDef, manifest, options) {
822
976
  const apiConfig = objectDef.decoratorConfig?.api;
@@ -851,7 +1005,7 @@ async function generateCollectionRoutesForObject(projectRoot, className, objectD
851
1005
  });
852
1006
  }
853
1007
  for (const [actionRouteDir, routeSpecs] of groupCustomActionRoutes(actionSpecs)) {
854
- writeRoute(actionRouteDir, "+server.ts", generateActionRouteTemplate(projectRoot, actionRouteDir, routeSpecs, objectDef, options));
1008
+ writeRoute(actionRouteDir, "+server.ts", generateActionRouteTemplate(projectRoot, actionRouteDir, routeSpecs, objectDef, manifest, options));
855
1009
  generatedAnyRoutes = true;
856
1010
  }
857
1011
  return generatedAnyRoutes;
@@ -998,13 +1152,15 @@ function isLocalObject(projectRoot, objectDef) {
998
1152
  /**
999
1153
  * Generates collection route template (GET list, POST create)
1000
1154
  */
1001
- function generateCollectionRouteTemplate(projectRoot, className, objectDef, includedActions, options, routeDir) {
1155
+ function generateCollectionRouteTemplate(projectRoot, className, objectDef, manifest, includedActions, options, routeDir) {
1002
1156
  const hasGet = includedActions.includes("list");
1003
1157
  const hasPost = includedActions.includes("create");
1004
1158
  const modelType = resolveObjectTypeReference(projectRoot, className, objectDef, options, routeDir);
1005
1159
  const serializers = resolveStandardRouteSerializers(objectDef.decoratorConfig?.api);
1006
1160
  const serializerImports = serializers.importStatements.join("\n");
1007
1161
  const listUsesSerializer = !!serializers.listItemSerializerName;
1162
+ const listUsesPermissionScopedBody = collectReadPermissionFields(objectDef, manifest).length > 0;
1163
+ const listUsesBodyHash = listUsesSerializer || listUsesPermissionScopedBody;
1008
1164
  const imports = `${AUTO_GENERATED_ROUTE_HEADER}
1009
1165
  // DO NOT EDIT - changes will be overwritten
1010
1166
 
@@ -1012,10 +1168,11 @@ import { error${hasPost ? ", json" : ""} } from '@sveltejs/kit';
1012
1168
  ${serializerImports ? `${serializerImports}\n` : ""}import { getCollection } from '$lib/server/smrt';
1013
1169
  ${modelType.importStatement ? `${modelType.importStatement}\n` : ""}import type { RequestHandler } from './$types';
1014
1170
  // Note: ${className} is auto-registered by the Vite plugin scanner
1015
- ${generateAuthGuardHelper(objectDef)}${isTenantScoped(objectDef) ? generateTenantContextHelper() : ""}${hasPost ? generateWritablePolicyHelper(objectDef) : ""}${hasGet ? generateConditionalGetRouteHelper(objectDef.decoratorConfig?.api, {
1171
+ ${generateAuthGuardHelper(objectDef, manifest)}${isTenantScoped(objectDef) ? generateTenantContextHelper() : ""}${hasPost ? generateWritablePolicyHelper(objectDef) : ""}${hasGet ? generateConditionalGetRouteHelper(objectDef.decoratorConfig?.api, {
1016
1172
  tenantScoped: isTenantScoped(objectDef),
1173
+ permissionScoped: listUsesPermissionScopedBody,
1017
1174
  modelName: className,
1018
- useBodyHash: listUsesSerializer
1175
+ useBodyHash: listUsesBodyHash
1019
1176
  }) : ""}`;
1020
1177
  const listAndCount = isTenantScoped(objectDef) ? ` const readScope = tenantReadScope();
1021
1178
  const items = await collection.list({ limit, offset, where: readScope });
@@ -1025,6 +1182,7 @@ ${generateAuthGuardHelper(objectDef)}${isTenantScoped(objectDef) ? generateTenan
1025
1182
  // List all ${className.toLowerCase()}s
1026
1183
  export const GET: RequestHandler = async ({ locals, url, request }) => {
1027
1184
  ${routeGuardPreamble(objectDef, false)}
1185
+ const publicJsonOptions = getPublicJsonOptions(locals);
1028
1186
  const limit = Number(url.searchParams.get('limit')) || 50;
1029
1187
  const offset = Number(url.searchParams.get('offset')) || 0;
1030
1188
 
@@ -1032,13 +1190,21 @@ ${generateCollectionLoad(className, { typeName: modelType.typeName })}
1032
1190
  ${listUsesSerializer ? `${listAndCount}
1033
1191
  // Custom serializer may render related-table data → v1 body-hash ETag (#1765).
1034
1192
  const serializedItems = await Promise.all(
1035
- items.map((item) => ${serializers.listItemSerializerName}(item)),
1193
+ items.map(async (item) =>
1194
+ applyReadPermissionRedaction(
1195
+ await ${serializers.listItemSerializerName}(item),
1196
+ publicJsonOptions,
1197
+ ),
1198
+ ),
1036
1199
  );
1037
- return conditionalJson(request, { items: serializedItems, count, limit, offset });` : ` // ETag v2 (#1765): the table-version ETag is checked first; on a concrete
1200
+ return conditionalJson(request, { items: serializedItems, count, limit, offset });` : listUsesPermissionScopedBody ? `${listAndCount}
1201
+ // Field read permissions make the body vary by caller permissions → v1 body-hash ETag (#1565).
1202
+ const items_public = items.map((item) => item.toPublicJSON(publicJsonOptions));
1203
+ return conditionalJson(request, { items: items_public, count, limit, offset });` : ` // ETag v2 (#1765): the table-version ETag is checked first; on a concrete
1038
1204
  // If-None-Match the list query below never runs (zero-query 304).
1039
1205
  return conditionalVersionedRead(request, collection.db, collection.tableName, async () => {
1040
1206
  ${listAndCount}
1041
- const items_public = items.map((item) => item.toPublicJSON());
1207
+ const items_public = items.map((item) => item.toPublicJSON(publicJsonOptions));
1042
1208
  return { items: items_public, count, limit, offset };
1043
1209
  });`}
1044
1210
  };
@@ -1047,6 +1213,7 @@ ${listAndCount}
1047
1213
  // Create new ${className.toLowerCase()}
1048
1214
  export const POST: RequestHandler = async ({ locals, request }) => {
1049
1215
  ${routeGuardPreamble(objectDef, true)}
1216
+ const publicJsonOptions = getPublicJsonOptions(locals);
1050
1217
  const body: unknown = await request.json();
1051
1218
  const data = applyWritablePolicy(body);
1052
1219
 
@@ -1056,8 +1223,11 @@ ${generateCollectionLoad(className, { typeName: modelType.typeName })}
1056
1223
  ${serializers.itemSerializerName ? `
1057
1224
  const serializedItem = await ${serializers.itemSerializerName}(item);
1058
1225
 
1059
- return json(serializedItem, { status: 201 });` : `
1060
- return json(item.toPublicJSON(), { status: 201 });`}
1226
+ return json(
1227
+ applyReadPermissionRedaction(serializedItem, publicJsonOptions),
1228
+ { status: 201 },
1229
+ );` : `
1230
+ return json(item.toPublicJSON(publicJsonOptions), { status: 201 });`}
1061
1231
  };
1062
1232
  ` : "";
1063
1233
  return imports + getHandler + postHandler;
@@ -1101,7 +1271,7 @@ function generateClassNotRegisteredError(className) {
1101
1271
  /**
1102
1272
  * Generates item route template (GET, PUT, DELETE)
1103
1273
  */
1104
- function generateItemRouteTemplate(projectRoot, className, objectDef, includedActions, options, routeDir) {
1274
+ function generateItemRouteTemplate(projectRoot, className, objectDef, manifest, includedActions, options, routeDir) {
1105
1275
  const hasGet = includedActions.includes("get");
1106
1276
  const hasPut = includedActions.includes("update");
1107
1277
  const hasDelete = includedActions.includes("delete");
@@ -1110,16 +1280,19 @@ function generateItemRouteTemplate(projectRoot, className, objectDef, includedAc
1110
1280
  const serializers = resolveStandardRouteSerializers(objectDef.decoratorConfig?.api);
1111
1281
  const serializerImports = serializers.importStatements.join("\n");
1112
1282
  const getUsesSerializer = !!serializers.itemSerializerName;
1283
+ const getUsesPermissionScopedBody = collectReadPermissionFields(objectDef, manifest).length > 0;
1284
+ const getUsesBodyHash = getUsesSerializer || getUsesPermissionScopedBody;
1113
1285
  const imports = `${AUTO_GENERATED_ROUTE_HEADER}
1114
1286
  // DO NOT EDIT - changes will be overwritten
1115
1287
 
1116
1288
  import { error${hasPut || hasDelete ? ", json" : ""} } from '@sveltejs/kit';
1117
1289
  ${serializerImports ? `${serializerImports}\n` : ""}import { getCollection } from '$lib/server/smrt';
1118
1290
  ${modelType.importStatement ? `${modelType.importStatement}\n` : ""}import type { RequestHandler } from './$types';
1119
- ${generateAuthGuardHelper(objectDef)}${isTenantScoped(objectDef) ? generateTenantContextHelper() : ""}${hasPut ? generateWritablePolicyHelper(objectDef) : ""}${hasGet ? generateConditionalGetRouteHelper(objectDef.decoratorConfig?.api, {
1291
+ ${generateAuthGuardHelper(objectDef, manifest)}${isTenantScoped(objectDef) ? generateTenantContextHelper() : ""}${hasPut ? generateWritablePolicyHelper(objectDef) : ""}${hasGet ? generateConditionalGetRouteHelper(objectDef.decoratorConfig?.api, {
1120
1292
  tenantScoped: isTenantScoped(objectDef),
1293
+ permissionScoped: getUsesPermissionScopedBody,
1121
1294
  modelName: className,
1122
- useBodyHash: getUsesSerializer
1295
+ useBodyHash: getUsesBodyHash
1123
1296
  }) : ""}`;
1124
1297
  const getForRead = isTenantScoped(objectDef) ? ` const readScope = tenantReadScope();
1125
1298
  const item = await collection.get(
@@ -1129,18 +1302,25 @@ ${generateAuthGuardHelper(objectDef)}${isTenantScoped(objectDef) ? generateTenan
1129
1302
  // Get single ${simpleClassName.toLowerCase()}
1130
1303
  export const GET: RequestHandler = async ({ locals, params, request }) => {
1131
1304
  ${routeGuardPreamble(objectDef, false)}
1305
+ const publicJsonOptions = getPublicJsonOptions(locals);
1132
1306
  ${generateCollectionLoad(className, { typeName: modelType.typeName })}
1133
1307
  ${getUsesSerializer ? `${getForRead}
1134
1308
  ${generateNotFoundError(className)}
1135
1309
  // Custom serializer may render related-table data → v1 body-hash ETag (#1765).
1136
1310
  const serializedItem = await ${serializers.itemSerializerName}(item);
1137
- return conditionalJson(request, serializedItem);` : ` // ETag v2 (#1765): a concrete If-None-Match answers 304 without the row fetch
1311
+ return conditionalJson(
1312
+ request,
1313
+ applyReadPermissionRedaction(serializedItem, publicJsonOptions),
1314
+ );` : getUsesPermissionScopedBody ? `${getForRead}
1315
+ ${generateNotFoundError(className)}
1316
+ // Field read permissions make the body vary by caller permissions → v1 body-hash ETag (#1565).
1317
+ return conditionalJson(request, item.toPublicJSON(publicJsonOptions));` : ` // ETag v2 (#1765): a concrete If-None-Match answers 304 without the row fetch
1138
1318
  // (a delete advances the version, so a since-deleted row can't false-match);
1139
1319
  // a wildcard \`*\` is deferred until the fetch confirms the row exists.
1140
1320
  return conditionalVersionedRead(request, collection.db, collection.tableName, async () => {
1141
1321
  ${getForRead}
1142
1322
  ${generateNotFoundError(className)}
1143
- return item.toPublicJSON();
1323
+ return item.toPublicJSON(publicJsonOptions);
1144
1324
  });`}
1145
1325
  };
1146
1326
  ` : "";
@@ -1148,6 +1328,7 @@ ${generateNotFoundError(className)}
1148
1328
  // Update ${simpleClassName.toLowerCase()}
1149
1329
  export const PUT: RequestHandler = async ({ locals, params, request }) => {
1150
1330
  ${routeGuardPreamble(objectDef, true)}
1331
+ const publicJsonOptions = getPublicJsonOptions(locals);
1151
1332
  ${generateCollectionLoad(className, { typeName: modelType.typeName })}
1152
1333
  const item = await collection.get(params.id);
1153
1334
  ${generateNotFoundError(className)}
@@ -1159,8 +1340,8 @@ ${generateNotFoundError(className)}
1159
1340
  ${serializers.itemSerializerName ? `
1160
1341
  const serializedItem = await ${serializers.itemSerializerName}(item);
1161
1342
 
1162
- return json(serializedItem);` : `
1163
- return json(item.toPublicJSON());`}
1343
+ return json(applyReadPermissionRedaction(serializedItem, publicJsonOptions));` : `
1344
+ return json(item.toPublicJSON(publicJsonOptions));`}
1164
1345
  };
1165
1346
  ` : "";
1166
1347
  const deleteHandler = hasDelete ? `
@@ -1180,7 +1361,7 @@ ${generateNotFoundError(className)}
1180
1361
  /**
1181
1362
  * Generates custom action route template
1182
1363
  */
1183
- function generateActionRouteTemplate(projectRoot, routeDir, routeSpecs, objectDef, options) {
1364
+ function generateActionRouteTemplate(projectRoot, routeDir, routeSpecs, objectDef, manifest, options) {
1184
1365
  if (routeSpecs.length === 0) throw new Error("Cannot generate a custom action route without handlers");
1185
1366
  const [firstSpec] = routeSpecs;
1186
1367
  const { lookupClassName, routeConfig, hostType } = firstSpec;
@@ -1200,7 +1381,7 @@ function generateActionRouteTemplate(projectRoot, routeDir, routeSpecs, objectDe
1200
1381
  // DO NOT EDIT - changes will be overwritten
1201
1382
 
1202
1383
  ${importBlock}
1203
- ${generateAuthGuardHelper(objectDef)}${tenantScoped ? generateTenantContextHelper() : ""}
1384
+ ${generateAuthGuardHelper(objectDef, manifest)}${tenantScoped ? generateTenantContextHelper() : ""}
1204
1385
  ${handlers}`;
1205
1386
  }
1206
1387
  function generateActionRouteHandler(lookupClassName, actionName, actionDef, routeConfig, hostType, tenantScoped, lookupTypeName, hostTypeName) {
@@ -1209,53 +1390,73 @@ function generateActionRouteHandler(lookupClassName, actionName, actionDef, rout
1209
1390
  if (tenantScoped) guardLines.push(" establishTenantContext(locals);");
1210
1391
  const authGuardLine = guardLines.join("\n");
1211
1392
  const needsRequest = actionDef.parameters.length > 0;
1212
- const invocationArgs = buildActionInvocationArgs(actionDef);
1213
1393
  const collectionHandlerArgs = buildRouteHandlerArgs(routeConfig.pathParamNames.length > 0, needsRequest);
1214
1394
  const itemHandlerArgs = buildRouteHandlerArgs(true, needsRequest);
1215
1395
  if (hostType === "collection") {
1216
1396
  const optionsLoad = buildActionOptionsLoad(actionName, actionDef, routeConfig, hostTypeName, actionDef.isStatic);
1217
1397
  const receiverExpression = actionDef.isStatic ? "CollectionClass" : "typedCollection";
1218
1398
  const staticTargetLoad = actionDef.isStatic ? ` const CollectionClass = typedCollection.constructor as typeof ${hostTypeName};\n` : "";
1399
+ const scopedOptions = buildScopedOptionsForTenantRead(actionDef, routeConfig, tenantScoped);
1400
+ const invocationArgs = buildActionInvocationArgs(actionDef, scopedOptions.optionsIdentifier);
1219
1401
  return `// Custom collection method: ${actionName}
1220
1402
  export const ${handlerName}: RequestHandler = async (${collectionHandlerArgs}) => {
1221
1403
  ${authGuardLine}
1404
+ const publicJsonOptions = getPublicJsonOptions(locals);
1222
1405
  ${generateCollectionLoad(lookupClassName, { typeName: lookupTypeName })}
1223
1406
  const typedCollection = collection as unknown as ${hostTypeName};
1224
1407
  ${generateCollectionNotRegisteredError(lookupClassName)}
1225
1408
  ${staticTargetLoad}
1226
1409
 
1227
- ${optionsLoad} const result = ${buildActionInvocationExpression(receiverExpression, actionName, invocationArgs)};
1410
+ ${optionsLoad}${scopedOptions.source} const result = ${buildActionInvocationExpression(receiverExpression, actionName, invocationArgs)};
1228
1411
 
1229
- return json({ action: '${actionName}', result: toPublicResult(result) });
1412
+ return json({
1413
+ action: '${actionName}',
1414
+ result: toPublicResult(result, publicJsonOptions),
1415
+ });
1230
1416
  };
1231
1417
  `;
1232
1418
  }
1233
1419
  if (routeConfig.scope === "collection") {
1234
1420
  const optionsLoad = buildActionOptionsLoad(actionName, actionDef, routeConfig, hostTypeName, true);
1421
+ const scopedOptions = buildScopedOptionsForTenantRead(actionDef, routeConfig, tenantScoped);
1422
+ const invocationArgs = buildActionInvocationArgs(actionDef, scopedOptions.optionsIdentifier);
1235
1423
  return `// Custom collection action: ${actionName}
1236
1424
  export const ${handlerName}: RequestHandler = async (${collectionHandlerArgs}) => {
1237
1425
  ${authGuardLine}
1426
+ const publicJsonOptions = getPublicJsonOptions(locals);
1238
1427
  const registered = ObjectRegistry.getClass('${lookupClassName}');
1239
1428
  ${generateClassNotRegisteredError(lookupClassName)}
1240
1429
 
1241
- ${optionsLoad} const ClassRef = registered.constructor as typeof ${hostTypeName};
1430
+ ${optionsLoad}${scopedOptions.source} const ClassRef = registered.constructor as typeof ${hostTypeName};
1242
1431
  const result = ${buildActionInvocationExpression("ClassRef", actionName, invocationArgs)};
1243
1432
 
1244
- return json({ action: '${actionName}', result: toPublicResult(result) });
1433
+ return json({
1434
+ action: '${actionName}',
1435
+ result: toPublicResult(result, publicJsonOptions),
1436
+ });
1245
1437
  };
1246
1438
  `;
1247
1439
  }
1248
1440
  const optionsLoad = buildActionOptionsLoad(actionName, actionDef, routeConfig, hostTypeName, false);
1441
+ const scopedItemLoad = tenantScoped && routeConfig.method === "GET" ? ` const readScope = tenantReadScope();
1442
+ const item = await collection.get(
1443
+ readScope ? { id: params.id, ...readScope } : params.id,
1444
+ );` : " const item = await collection.get(params.id);";
1445
+ const invocationArgs = buildActionInvocationArgs(actionDef);
1249
1446
  return `// Custom action: ${actionName}
1250
1447
  export const ${handlerName}: RequestHandler = async (${itemHandlerArgs}) => {
1251
1448
  ${authGuardLine}
1449
+ const publicJsonOptions = getPublicJsonOptions(locals);
1252
1450
  ${generateCollectionLoad(lookupClassName, { typeName: lookupTypeName })}
1253
- const item = await collection.get(params.id);
1451
+ ${scopedItemLoad}
1254
1452
  ${generateNotFoundError(lookupClassName)}
1255
1453
 
1256
1454
  ${optionsLoad} const result = ${buildActionInvocationExpression("item", actionName, invocationArgs)};
1257
1455
 
1258
- return json({ action: '${actionName}', result: toPublicResult(result) });
1456
+ return json({
1457
+ action: '${actionName}',
1458
+ result: toPublicResult(result, publicJsonOptions),
1459
+ });
1259
1460
  };
1260
1461
  `;
1261
1462
  }