@happyvertical/smrt-core 0.38.2 → 0.38.3

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 (128) hide show
  1. package/AGENTS.md +11 -2
  2. package/dist/change-feed.d.ts +19 -10
  3. package/dist/change-feed.d.ts.map +1 -1
  4. package/dist/change-feed.js +43 -13
  5. package/dist/change-feed.js.map +1 -1
  6. package/dist/change-signals.d.ts +83 -0
  7. package/dist/change-signals.d.ts.map +1 -0
  8. package/dist/change-signals.js +272 -0
  9. package/dist/change-signals.js.map +1 -0
  10. package/dist/collection-cache.d.ts +21 -0
  11. package/dist/collection-cache.d.ts.map +1 -1
  12. package/dist/collection-cache.js +0 -0
  13. package/dist/collection-cache.js.map +1 -1
  14. package/dist/config.d.ts +1 -1
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js.map +1 -1
  17. package/dist/decorators/index.d.ts +8 -0
  18. package/dist/decorators/index.d.ts.map +1 -1
  19. package/dist/decorators/index.js.map +1 -1
  20. package/dist/errors.js +2 -1
  21. package/dist/errors.js.map +1 -1
  22. package/dist/generators/changes-route.d.ts +15 -27
  23. package/dist/generators/changes-route.d.ts.map +1 -1
  24. package/dist/generators/changes-route.js +17 -7
  25. package/dist/generators/changes-route.js.map +1 -1
  26. package/dist/generators/cli.d.ts +3 -0
  27. package/dist/generators/cli.d.ts.map +1 -1
  28. package/dist/generators/cli.js +7 -4
  29. package/dist/generators/cli.js.map +1 -1
  30. package/dist/generators/conditional-get.d.ts +17 -6
  31. package/dist/generators/conditional-get.d.ts.map +1 -1
  32. package/dist/generators/conditional-get.js +19 -13
  33. package/dist/generators/conditional-get.js.map +1 -1
  34. package/dist/generators/events-route.d.ts +72 -0
  35. package/dist/generators/events-route.d.ts.map +1 -0
  36. package/dist/generators/events-route.js +234 -0
  37. package/dist/generators/events-route.js.map +1 -0
  38. package/dist/generators/index.d.ts +1 -0
  39. package/dist/generators/index.d.ts.map +1 -1
  40. package/dist/generators/index.js +2 -1
  41. package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
  42. package/dist/generators/mcp-runtime-template.js +11 -5
  43. package/dist/generators/mcp-runtime-template.js.map +1 -1
  44. package/dist/generators/mcp.d.ts +3 -0
  45. package/dist/generators/mcp.d.ts.map +1 -1
  46. package/dist/generators/mcp.js +18 -9
  47. package/dist/generators/mcp.js.map +1 -1
  48. package/dist/generators/rest.d.ts +7 -1
  49. package/dist/generators/rest.d.ts.map +1 -1
  50. package/dist/generators/rest.js +77 -13
  51. package/dist/generators/rest.js.map +1 -1
  52. package/dist/generators.js +2 -1
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +3 -1
  56. package/dist/manifest/generator.js.map +1 -1
  57. package/dist/manifest/manifest-loader.js.map +1 -1
  58. package/dist/manifest/static-manifest.d.ts.map +1 -1
  59. package/dist/manifest/static-manifest.js +7 -3
  60. package/dist/manifest/static-manifest.js.map +1 -1
  61. package/dist/manifest/store.js +1 -1
  62. package/dist/manifest/store.js.map +1 -1
  63. package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
  64. package/dist/manifest/test-manifest-stub.js +1050 -124
  65. package/dist/manifest/test-manifest-stub.js.map +1 -1
  66. package/dist/manifest.json +9 -3
  67. package/dist/mcp-advisor/tools/preview-api-endpoints.d.ts +1 -0
  68. package/dist/mcp-advisor/tools/preview-api-endpoints.d.ts.map +1 -1
  69. package/dist/mcp-advisor/types.d.ts +3 -0
  70. package/dist/mcp-advisor/types.d.ts.map +1 -1
  71. package/dist/migrations/tracker.js +5 -4
  72. package/dist/migrations/tracker.js.map +1 -1
  73. package/dist/migrations/types.d.ts +1 -1
  74. package/dist/migrations/types.d.ts.map +1 -1
  75. package/dist/object.d.ts +17 -8
  76. package/dist/object.d.ts.map +1 -1
  77. package/dist/object.js +42 -26
  78. package/dist/object.js.map +1 -1
  79. package/dist/registry/class-registration.d.ts.map +1 -1
  80. package/dist/registry/class-registration.js +12 -0
  81. package/dist/registry/class-registration.js.map +1 -1
  82. package/dist/registry/shared-state.d.ts +2 -1
  83. package/dist/registry/shared-state.d.ts.map +1 -1
  84. package/dist/registry/shared-state.js.map +1 -1
  85. package/dist/registry/types.d.ts.map +1 -1
  86. package/dist/registry.d.ts.map +1 -1
  87. package/dist/registry.js +1 -1
  88. package/dist/registry.js.map +1 -1
  89. package/dist/runtime/client.d.ts.map +1 -1
  90. package/dist/runtime/client.js.map +1 -1
  91. package/dist/scanner/manifest-generator.js.map +1 -1
  92. package/dist/scanner/types.d.ts +7 -0
  93. package/dist/scanner/types.d.ts.map +1 -1
  94. package/dist/schema/ddl/base-strategy.js +1 -1
  95. package/dist/schema/ddl/base-strategy.js.map +1 -1
  96. package/dist/scripts/migrate-datetime-to-timestamp.ts +1 -1
  97. package/dist/signals/sanitizer.d.ts.map +1 -1
  98. package/dist/signals/sanitizer.js.map +1 -1
  99. package/dist/smrt-knowledge.json +8 -8
  100. package/dist/testing/index.d.ts +1 -0
  101. package/dist/testing/index.d.ts.map +1 -1
  102. package/dist/testing/index.js +2 -1
  103. package/dist/testing/notifications-stub.d.ts +24 -0
  104. package/dist/testing/notifications-stub.d.ts.map +1 -0
  105. package/dist/testing/notifications-stub.js +62 -0
  106. package/dist/testing/notifications-stub.js.map +1 -0
  107. package/dist/testing.js +2 -1
  108. package/dist/tools/tool-generator.js +1 -1
  109. package/dist/tools/tool-generator.js.map +1 -1
  110. package/dist/vite-plugin/changes-route.d.ts +14 -0
  111. package/dist/vite-plugin/changes-route.d.ts.map +1 -1
  112. package/dist/vite-plugin/changes-route.js +2 -2
  113. package/dist/vite-plugin/changes-route.js.map +1 -1
  114. package/dist/vite-plugin/events-route.d.ts +9 -0
  115. package/dist/vite-plugin/events-route.d.ts.map +1 -0
  116. package/dist/vite-plugin/events-route.js +155 -0
  117. package/dist/vite-plugin/events-route.js.map +1 -0
  118. package/dist/vite-plugin/index.d.ts +8 -0
  119. package/dist/vite-plugin/index.d.ts.map +1 -1
  120. package/dist/vite-plugin/index.js +8 -2
  121. package/dist/vite-plugin/index.js.map +1 -1
  122. package/dist/vite-plugin/sveltekit-generator.d.ts +8 -0
  123. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  124. package/dist/vite-plugin/sveltekit-generator.js +193 -31
  125. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  126. package/dist/vite-plugin/web-collections.js +1 -1
  127. package/dist/vite-plugin/web-collections.js.map +1 -1
  128. package/package.json +12 -12
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","names":[],"sources":["../../src/generators/cli.ts"],"sourcesContent":["/**\n * CLI command generator for smrt objects\n *\n * Exposes registered `@smrt()` objects as a runnable admin CLI: each object gets\n * `list`/`get`/`create`/`update`/`delete` commands plus its public custom\n * methods, dispatched as `objectname:action`.\n *\n * Security parity with the REST/MCP generators (#1540, #1547, #1554, #1556):\n * - **Mass-assignment guard** — create/update bodies are filtered through the\n * `@smrt({ api: { writable: [...] } })` allowlist, dropping server-managed and\n * `@field({ readonly })` fields ({@link CLIGenerator.applyWritablePolicy}).\n * - **Exhaustive include** — an `include` list is the COMPLETE allowlist for the\n * surface; custom methods are gated on `isPublic`.\n * - **Sensitive redaction** — command *output* is serialized through\n * {@link CLIGenerator.toPublicData} so `@field({ sensitive })` values never\n * print (input bodies are guarded by the writable allowlist above).\n * - **Fail-closed tenant context** — tenant-scoped reads/writes run inside the\n * tenancy gate; without `--tenant <id>` / `--all-tenants` (and with tenancy\n * enabled) the command throws rather than ranging across all tenants.\n */\n\nimport type { AIClient, AIClientOptions } from '@happyvertical/ai';\nimport type { SmrtCollection } from '../collection';\nimport type { DatabaseConfig } from '../database.js';\nimport type { SmrtObject } from '../object';\nimport { ObjectRegistry } from '../registry';\nimport type { RegisteredClass } from '../registry/types.js';\nimport { runWithTenantGate } from './tenant-gate.js';\n\n/**\n * Public-data view exposed by SMRT objects: `toPublicJSON()` strips\n * `@field({ sensitive })` values before serialization (#1540).\n */\ninterface PublicSerializable {\n toPublicJSON(): unknown;\n}\n\n/**\n * View of an instance/collection for dispatching custom methods by name. Each\n * key is potentially a callable taking the parsed CLI options; callers narrow\n * with `typeof === 'function'` before invoking.\n */\ntype DynamicallyCallable = Record<\n string,\n ((options: Record<string, unknown>) => unknown) | unknown\n>;\n\n/**\n * Configuration for a generated CLI.\n */\nexport interface CLIConfig {\n /** CLI program name (used in help output). */\n name?: string;\n /** CLI version. */\n version?: string;\n /** CLI description. */\n description?: string;\n}\n\n/**\n * Per-invocation context for a generated CLI.\n */\nexport interface CLIContext {\n /** Database handle passed to collections. */\n db?: DatabaseConfig;\n /** AI provider passed to collections. */\n ai?: AIClientOptions | AIClient;\n /** Authenticated operator, when the host establishes one. */\n user?: {\n id: string;\n roles?: string[];\n };\n /**\n * Default tenant for tenant-scoped commands when no `--tenant` flag is given.\n * Hosts that authenticate an operator may set this from the principal.\n */\n tenantId?: string;\n /**\n * Default cross-tenant opt-in when no `--all-tenants` flag is given. Hosts\n * may set this for trusted operator/admin shells.\n */\n allowCrossTenant?: boolean;\n}\n\n/** Standard CRUD verbs handled directly by the generator. */\nconst CRUD_OPERATIONS = ['list', 'get', 'create', 'update', 'delete'];\n\n/**\n * Flags consumed by the CLI itself — never treated as create/update field\n * values or as custom-action options.\n */\nconst RESERVED_FLAGS = new Set([\n 'tenant',\n 'all-tenants',\n 'from-file',\n 'limit',\n 'offset',\n 'order-by',\n 'orderBy',\n 'where',\n 'format',\n 'json',\n 'help',\n 'id',\n]);\n\n/** Parsed CLI invocation. */\ninterface ParsedInvocation {\n /** Object/collection segment (lowercased), e.g. `product`. */\n objectName: string;\n /** Action segment, e.g. `list` or a custom method name. */\n action: string;\n /** First positional argument after the command (typically an id). */\n positional?: string;\n /** Parsed flags. String for `--k v`/`--k=v`, `true` for bare `--flag`. */\n flags: Record<string, string | boolean>;\n}\n\n/**\n * Generate and run an admin CLI for the registered `@smrt()` objects.\n */\nexport class CLIGenerator {\n private config: CLIConfig;\n private context: CLIContext;\n\n constructor(config: CLIConfig = {}, context: CLIContext = {}) {\n this.config = {\n name: 'smrt-cli',\n version: '1.0.0',\n description: 'Auto-generated CLI from smrt objects',\n ...config,\n };\n this.context = context;\n }\n\n /** CLI program name. */\n get name(): string | undefined {\n return this.config.name;\n }\n\n /** CLI version. */\n get version(): string | undefined {\n return this.config.version;\n }\n\n /**\n * Return the argv handler invoked by the generated `setupCLI()` wrapper.\n *\n * @returns An async function accepting the post-`node script` argv slice.\n */\n generateHandler(): (args: string[]) => Promise<void> {\n return async (args: string[]) => {\n await this.run(args ?? []);\n };\n }\n\n /**\n * Parse and execute a single CLI invocation.\n *\n * @param args - argv slice (command + flags).\n */\n async run(args: string[]): Promise<void> {\n if (args.length === 0 || args[0] === '--help' || args[0] === 'help') {\n console.log(await this.helpText());\n return;\n }\n\n const parsed = this.parseArgs(args);\n if (!parsed) {\n console.log(await this.helpText());\n return;\n }\n\n const classInfo = this.resolveClass(parsed.objectName);\n if (!classInfo) {\n throw new Error(`Object type '${parsed.objectName}' not found`);\n }\n const objectName: string = classInfo.name || parsed.objectName;\n\n await this.assertCommandExposed(objectName, parsed.action);\n\n const tenantId =\n typeof parsed.flags.tenant === 'string'\n ? parsed.flags.tenant\n : this.context.tenantId;\n const allowCrossTenant =\n parsed.flags['all-tenants'] === true ||\n this.context.allowCrossTenant === true;\n\n // Tenant-scoping is resolved authoritatively inside tenancy (by class name),\n // matching the interceptor and covering both @TenantScoped and\n // @smrt({ tenantScoped }) registrations (#1554).\n const result = await runWithTenantGate(\n { className: objectName, tenantId, allowCrossTenant, surface: 'CLI' },\n async () => {\n const collection = await this.getCollection(objectName);\n return this.executeAction(collection, objectName, parsed);\n },\n );\n\n if (result !== undefined) {\n console.log(JSON.stringify(this.toPublicData(result), null, 2));\n }\n }\n\n /**\n * Parse argv into a command + flags. Supports `name:action` and\n * `name action` command forms, `--key value`, `--key=value`, and bare\n * `--flag` booleans.\n */\n private parseArgs(args: string[]): ParsedInvocation | null {\n const [command, ...rest] = args;\n if (!command || command.startsWith('-')) return null;\n\n let objectName: string;\n let action: string;\n let tail = rest;\n\n if (command.includes(':')) {\n const [obj, act] = command.split(':');\n objectName = obj.toLowerCase();\n action = act;\n } else if (rest.length > 0 && !rest[0].startsWith('-')) {\n objectName = command.toLowerCase();\n action = rest[0];\n tail = rest.slice(1);\n } else {\n // A bare object name with no action is not a runnable command.\n return null;\n }\n\n if (!objectName || !action) return null;\n\n const flags: Record<string, string | boolean> = {};\n let positional: string | undefined;\n\n for (let i = 0; i < tail.length; i++) {\n const token = tail[i];\n if (token.startsWith('--')) {\n const body = token.slice(2);\n const eq = body.indexOf('=');\n if (eq !== -1) {\n flags[body.slice(0, eq)] = body.slice(eq + 1);\n } else {\n const next = tail[i + 1];\n if (next !== undefined && !next.startsWith('--')) {\n flags[body] = next;\n i++;\n } else {\n flags[body] = true;\n }\n }\n } else if (positional === undefined) {\n positional = token;\n }\n }\n\n return { objectName, action, positional, flags };\n }\n\n /**\n * Resolve a registered class by simple name (case-insensitive), mirroring the\n * MCP generator's lookup.\n */\n private resolveClass(objectName: string): RegisteredClass | null {\n const registeredClasses = ObjectRegistry.getAllClasses();\n for (const [key, info] of registeredClasses) {\n const simpleName = info.name || key;\n if (simpleName.toLowerCase() === objectName.toLowerCase()) {\n return info;\n }\n }\n return null;\n }\n\n /**\n * Throw if `action` is not exposed for `objectName` under its `@smrt({ cli })`\n * config. `cli: false` disables the object entirely; an `include` list is the\n * complete allowlist; custom methods must be `isPublic`.\n */\n private async assertCommandExposed(\n objectName: string,\n action: string,\n ): Promise<void> {\n const config = ObjectRegistry.getConfig(objectName);\n const cliConfig = config?.cli;\n\n if (cliConfig === false) {\n throw new Error(`CLI is disabled for ${objectName}`);\n }\n\n const included: string[] | undefined =\n typeof cliConfig === 'object' ? cliConfig?.include : undefined;\n const excluded: string[] =\n typeof cliConfig === 'object' && cliConfig?.exclude\n ? cliConfig.exclude\n : [];\n\n if (excluded.includes(action)) {\n throw new Error(`Command '${action}' is excluded for ${objectName}`);\n }\n\n const isCrud = CRUD_OPERATIONS.includes(action);\n\n if (isCrud) {\n // An include list, when present, is the complete allowlist for CRUD too.\n if (included && !included.includes(action)) {\n throw new Error(`Command '${action}' is not enabled for ${objectName}`);\n }\n return;\n }\n\n // Custom method: must be public, and — when an include list is present — it\n // is the COMPLETE allowlist (parity with MCP exhaustive-include, #1547).\n const methods = await ObjectRegistry.getAllMethods(objectName);\n const methodDef = methods.get(action);\n if (!methodDef) {\n throw new Error(`Unknown command '${action}' for ${objectName}`);\n }\n if (!methodDef.isPublic) {\n throw new Error(`Command '${action}' is not public on ${objectName}`);\n }\n if (included !== undefined && !included.includes(action)) {\n throw new Error(`Command '${action}' is not enabled for ${objectName}`);\n }\n }\n\n /**\n * Get the collection for an object via the registry factory. Uses\n * `ObjectRegistry.getCollection`, which caches, initializes, and — for plain\n * `@smrt()` models without a hand-written collection class — auto-creates the\n * default collection. (A bare `collectionConstructor` check would wrongly\n * throw for those models even though CRUD is advertised.)\n */\n private async getCollection(\n objectName: string,\n ): Promise<SmrtCollection<SmrtObject>> {\n return ObjectRegistry.getCollection(objectName, {\n ai: this.context.ai,\n db: this.context.db,\n });\n }\n\n /**\n * Execute a parsed command against a collection.\n */\n private async executeAction(\n collection: SmrtCollection<SmrtObject>,\n objectName: string,\n parsed: ParsedInvocation,\n ): Promise<unknown> {\n const { action, positional, flags } = parsed;\n\n switch (action) {\n case 'list': {\n const listOptions: {\n limit: number;\n offset: number;\n orderBy?: string;\n where?: Record<string, unknown>;\n } = {\n limit: Math.min(this.toNumber(flags.limit, 50), 1000),\n offset: this.toNumber(flags.offset, 0),\n };\n const orderBy = flags['order-by'] ?? flags.orderBy;\n if (typeof orderBy === 'string') listOptions.orderBy = orderBy;\n if (typeof flags.where === 'string') {\n listOptions.where = JSON.parse(flags.where);\n }\n return collection.list(listOptions);\n }\n\n case 'get': {\n const id = positional ?? this.flagString(flags.id);\n if (!id) throw new Error('An id is required for get');\n const item = await collection.get(id);\n if (!item) throw new Error(`${objectName} not found`);\n return item;\n }\n\n case 'create': {\n const data = this.applyWritablePolicy(\n objectName,\n await this.readPayload(flags),\n );\n if (this.context.user) {\n data.created_by = this.context.user.id;\n data.owner_id = this.context.user.id;\n }\n const created = await collection.create(data);\n await created.save();\n return created;\n }\n\n case 'update': {\n const id = positional ?? this.flagString(flags.id);\n if (!id) throw new Error('An id is required for update');\n const existing = await collection.get(id);\n if (!existing) throw new Error(`${objectName} not found`);\n const data = this.applyWritablePolicy(\n objectName,\n await this.readPayload(flags),\n );\n Object.assign(existing, data);\n if (this.context.user) {\n // Stamp the server-managed audit column on the instance; this field\n // is not part of the public SmrtObject surface.\n (existing as unknown as Record<string, unknown>).updated_by =\n this.context.user.id;\n }\n await existing.save();\n return existing;\n }\n\n case 'delete': {\n const id = positional ?? this.flagString(flags.id);\n if (!id) throw new Error('An id is required for delete');\n const existing = await collection.get(id);\n if (!existing) throw new Error(`${objectName} not found`);\n await existing.delete();\n return { success: true, id, message: `${objectName} deleted` };\n }\n\n default:\n return this.executeCustomAction(collection, action, positional, flags);\n }\n }\n\n /**\n * Execute a custom method on an instance (when an id is given) or on the\n * collection (singleton actions). Mirrors the MCP custom-action path.\n */\n private async executeCustomAction(\n collection: SmrtCollection<SmrtObject>,\n action: string,\n positional: string | undefined,\n flags: Record<string, string | boolean>,\n ): Promise<unknown> {\n const options = this.customOptions(flags);\n const id = positional ?? this.flagString(flags.id);\n\n if (id) {\n const object = await collection.get(id);\n if (!object) throw new Error('Object not found');\n // Custom methods are dispatched dynamically by name; index through a\n // callable-keyed view of the instance, narrowing before invoking.\n const candidate = (object as unknown as DynamicallyCallable)[action];\n if (typeof candidate !== 'function') {\n throw new Error(`Method '${action}' not found on object instance`);\n }\n return candidate.call(object, options);\n }\n\n const collectionCandidate = (collection as unknown as DynamicallyCallable)[\n action\n ];\n if (typeof collectionCandidate === 'function') {\n return collectionCandidate.call(collection, options);\n }\n throw new Error(\n `Method '${action}' not found on collection. Provide an id for object-specific actions.`,\n );\n }\n\n /**\n * Resolve the create/update payload: `--from-file <path>` (JSON) takes\n * precedence; otherwise non-reserved `--field value` flags form the body.\n * Reading from a file also keeps secrets off the process argv (#1556).\n */\n private async readPayload(\n flags: Record<string, string | boolean>,\n ): Promise<Record<string, unknown>> {\n const fromFile = flags['from-file'];\n if (typeof fromFile === 'string' && fromFile) {\n const { readFile } = await import('node:fs/promises');\n const content = await readFile(fromFile, 'utf-8');\n const parsed = JSON.parse(content);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error('--from-file must contain a JSON object');\n }\n return parsed as Record<string, unknown>;\n }\n\n const data: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(flags)) {\n if (RESERVED_FLAGS.has(key)) continue;\n data[key] = this.coerceValue(value);\n }\n return data;\n }\n\n /** Collect non-reserved flags as custom-action options. */\n private customOptions(\n flags: Record<string, string | boolean>,\n ): Record<string, unknown> {\n const options: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(flags)) {\n if (RESERVED_FLAGS.has(key)) continue;\n options[key] = this.coerceValue(value);\n }\n return options;\n }\n\n /**\n * Mass-assignment guard (#1540/#1556): strip framework/server-managed and\n * `@field({ readonly })` fields, and — when an `@smrt({ api: { writable } })`\n * allowlist is set — intersect with it. Identical policy to the REST/MCP\n * generators so a CLI create/update cannot set fields the API forbids.\n */\n private applyWritablePolicy(\n objectName: string | undefined,\n data: unknown,\n ): Record<string, unknown> {\n if (!data || typeof data !== 'object') return {};\n\n const serverManaged = new Set([\n 'id',\n 'tenantId',\n 'tenant_id',\n 'createdAt',\n 'created_at',\n 'updatedAt',\n 'updated_at',\n ]);\n\n const readonly = new Set<string>();\n let writable: string[] | null = null;\n\n if (objectName) {\n const apiConfig = ObjectRegistry.getConfig(objectName)?.api;\n if (\n apiConfig &&\n typeof apiConfig === 'object' &&\n Array.isArray((apiConfig as { writable?: unknown }).writable)\n ) {\n writable = (apiConfig as { writable: string[] }).writable;\n }\n\n for (const [name, def] of ObjectRegistry.getFields(objectName)) {\n if (def && (def.readonly === true || def._meta?.readonly === true)) {\n readonly.add(name);\n }\n }\n }\n\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(data)) {\n if (key.startsWith('_')) continue;\n if (serverManaged.has(key)) continue;\n if (readonly.has(key)) continue;\n if (writable && !writable.includes(key)) continue;\n result[key] = value;\n }\n return result;\n }\n\n /**\n * Serialize command output, excluding `@field({ sensitive })` fields (#1540).\n * Recurses arrays/plain objects so a SmrtObject nested in a custom-action\n * result is stripped too; a cycle guard prevents infinite loops.\n */\n private toPublicData(\n value: unknown,\n seen: WeakSet<object> = new WeakSet(),\n ): unknown {\n if (value === null || typeof value !== 'object') return value;\n if (\n typeof (value as Partial<PublicSerializable>).toPublicJSON === 'function'\n ) {\n return (value as PublicSerializable).toPublicJSON();\n }\n if (Array.isArray(value)) {\n if (seen.has(value)) return value;\n seen.add(value);\n return value.map((entry) => this.toPublicData(entry, seen));\n }\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) return value;\n if (seen.has(value)) return value;\n seen.add(value);\n const out: Record<string, unknown> = {};\n for (const [key, entry] of Object.entries(value)) {\n out[key] = this.toPublicData(entry, seen);\n }\n return out;\n }\n\n /** List the runnable commands grouped by object, for help output. */\n async listCommands(): Promise<string[]> {\n const commands: string[] = [];\n for (const [key, classInfo] of ObjectRegistry.getAllClasses()) {\n const objectName = classInfo.name || key;\n const config = ObjectRegistry.getConfig(objectName);\n const cliConfig = config?.cli;\n if (cliConfig === false) continue;\n\n const lower = objectName.toLowerCase();\n const included: string[] | undefined =\n typeof cliConfig === 'object' ? cliConfig?.include : undefined;\n const excluded: string[] =\n typeof cliConfig === 'object' && cliConfig?.exclude\n ? cliConfig.exclude\n : [];\n\n const expose = (cmd: string) =>\n !excluded.includes(cmd) && (!included || included.includes(cmd));\n\n for (const verb of CRUD_OPERATIONS) {\n if (expose(verb)) commands.push(`${lower}:${verb}`);\n }\n\n const methods = await ObjectRegistry.getAllMethods(objectName);\n for (const [methodName, methodDef] of methods) {\n if (CRUD_OPERATIONS.includes(methodName)) continue;\n if (!methodDef.isPublic) continue;\n if (excluded.includes(methodName)) continue;\n if (included !== undefined && !included.includes(methodName)) continue;\n commands.push(`${lower}:${methodName}`);\n }\n }\n return commands.sort();\n }\n\n private async helpText(): Promise<string> {\n const commands = await this.listCommands();\n return [\n `${this.config.name} ${this.config.version}`,\n this.config.description ?? '',\n '',\n 'Usage: <object>:<action> [id] [--flags]',\n '',\n 'Global flags:',\n ' --tenant <id> run the command inside a specific tenant',\n ' --all-tenants allow cross-tenant access (operator opt-in)',\n ' --from-file <p> read create/update payload from a JSON file',\n ' --where <json> filter for list',\n ' --limit / --offset / --order-by',\n '',\n 'Commands:',\n ...commands.map((c) => ` ${c}`),\n ].join('\\n');\n }\n\n private toNumber(\n value: string | boolean | undefined,\n fallback: number,\n ): number {\n if (typeof value !== 'string') return fallback;\n const n = Number.parseInt(value, 10);\n return Number.isNaN(n) ? fallback : n;\n }\n\n private flagString(value: string | boolean | undefined): string | undefined {\n return typeof value === 'string' ? value : undefined;\n }\n\n /** Coerce a flag value to a JSON scalar/object where it parses cleanly. */\n private coerceValue(value: string | boolean): unknown {\n if (typeof value !== 'string') return value;\n if (value === 'true') return true;\n if (value === 'false') return false;\n if (value !== '' && !Number.isNaN(Number(value))) return Number(value);\n if (\n (value.startsWith('{') && value.endsWith('}')) ||\n (value.startsWith('[') && value.endsWith(']'))\n ) {\n try {\n return JSON.parse(value);\n } catch {\n return value;\n }\n }\n return value;\n }\n}\n\n/**\n * Convenience runner used by the generated `setupCLI()` wrapper.\n *\n * @param config - CLI configuration.\n * @param context - Per-invocation context.\n * @returns A `{ run, generator }` pair; `run(argv)` strips the leading\n * `node script` entries before dispatching.\n */\nexport function setupCLI(config: CLIConfig = {}, context: CLIContext = {}) {\n const generator = new CLIGenerator(config, context);\n return {\n run: async (argv: string[]) => {\n const handler = generator.generateHandler();\n await handler(argv.slice(2));\n },\n generator,\n };\n}\n\n/**\n * Get a bare argv handler for a generated CLI.\n *\n * @param config - CLI configuration.\n * @param context - Per-invocation context.\n * @returns An async argv-slice handler.\n */\nexport function getCLIHandler(\n config: CLIConfig = {},\n context: CLIContext = {},\n) {\n const generator = new CLIGenerator(config, context);\n return generator.generateHandler();\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../../src/generators/cli.ts"],"sourcesContent":["/**\n * CLI command generator for smrt objects\n *\n * Exposes registered `@smrt()` objects as a runnable admin CLI: each object gets\n * `list`/`get`/`create`/`update`/`delete` commands plus its public custom\n * methods, dispatched as `objectname:action`.\n *\n * Security parity with the REST/MCP generators (#1540, #1547, #1554, #1556):\n * - **Mass-assignment guard** — create/update bodies are filtered through the\n * `@smrt({ api: { writable: [...] } })` allowlist, dropping server-managed and\n * `@field({ readonly })` fields ({@link CLIGenerator.applyWritablePolicy}).\n * - **Exhaustive include** — an `include` list is the COMPLETE allowlist for the\n * surface; custom methods are gated on `isPublic`.\n * - **Sensitive redaction** — command *output* is serialized through\n * {@link CLIGenerator.toPublicData} so `@field({ sensitive })` values never\n * print (input bodies are guarded by the writable allowlist above).\n * - **Fail-closed tenant context** — tenant-scoped reads/writes run inside the\n * tenancy gate; without `--tenant <id>` / `--all-tenants` (and with tenancy\n * enabled) the command throws rather than ranging across all tenants.\n */\n\nimport type { AIClient, AIClientOptions } from '@happyvertical/ai';\nimport type { SmrtCollection } from '../collection';\nimport type { DatabaseConfig } from '../database.js';\nimport type { PublicJsonOptions, SmrtObject } from '../object';\nimport { ObjectRegistry } from '../registry';\nimport type { RegisteredClass } from '../registry/types.js';\nimport { runWithTenantGate } from './tenant-gate.js';\n\n/**\n * Public-data view exposed by SMRT objects: `toPublicJSON()` strips\n * `@field({ sensitive })` values before serialization (#1540).\n */\ninterface PublicSerializable {\n toPublicJSON(options?: PublicJsonOptions): unknown;\n}\n\n/**\n * View of an instance/collection for dispatching custom methods by name. Each\n * key is potentially a callable taking the parsed CLI options; callers narrow\n * with `typeof === 'function'` before invoking.\n */\ntype DynamicallyCallable = Record<\n string,\n ((options: Record<string, unknown>) => unknown) | unknown\n>;\n\n/**\n * Configuration for a generated CLI.\n */\nexport interface CLIConfig {\n /** CLI program name (used in help output). */\n name?: string;\n /** CLI version. */\n version?: string;\n /** CLI description. */\n description?: string;\n}\n\n/**\n * Per-invocation context for a generated CLI.\n */\nexport interface CLIContext {\n /** Database handle passed to collections. */\n db?: DatabaseConfig;\n /** AI provider passed to collections. */\n ai?: AIClientOptions | AIClient;\n /** Authenticated operator, when the host establishes one. */\n user?: {\n id: string;\n roles?: string[];\n };\n /** Resolved permission slugs held by the operator. */\n permissions?: Iterable<string>;\n /**\n * Default tenant for tenant-scoped commands when no `--tenant` flag is given.\n * Hosts that authenticate an operator may set this from the principal.\n */\n tenantId?: string;\n /**\n * Default cross-tenant opt-in when no `--all-tenants` flag is given. Hosts\n * may set this for trusted operator/admin shells.\n */\n allowCrossTenant?: boolean;\n}\n\n/** Standard CRUD verbs handled directly by the generator. */\nconst CRUD_OPERATIONS = ['list', 'get', 'create', 'update', 'delete'];\n\n/**\n * Flags consumed by the CLI itself — never treated as create/update field\n * values or as custom-action options.\n */\nconst RESERVED_FLAGS = new Set([\n 'tenant',\n 'all-tenants',\n 'from-file',\n 'limit',\n 'offset',\n 'order-by',\n 'orderBy',\n 'where',\n 'format',\n 'json',\n 'help',\n 'id',\n]);\n\n/** Parsed CLI invocation. */\ninterface ParsedInvocation {\n /** Object/collection segment (lowercased), e.g. `product`. */\n objectName: string;\n /** Action segment, e.g. `list` or a custom method name. */\n action: string;\n /** First positional argument after the command (typically an id). */\n positional?: string;\n /** Parsed flags. String for `--k v`/`--k=v`, `true` for bare `--flag`. */\n flags: Record<string, string | boolean>;\n}\n\n/**\n * Generate and run an admin CLI for the registered `@smrt()` objects.\n */\nexport class CLIGenerator {\n private config: CLIConfig;\n private context: CLIContext;\n\n constructor(config: CLIConfig = {}, context: CLIContext = {}) {\n this.config = {\n name: 'smrt-cli',\n version: '1.0.0',\n description: 'Auto-generated CLI from smrt objects',\n ...config,\n };\n this.context = context;\n }\n\n /** CLI program name. */\n get name(): string | undefined {\n return this.config.name;\n }\n\n /** CLI version. */\n get version(): string | undefined {\n return this.config.version;\n }\n\n /**\n * Return the argv handler invoked by the generated `setupCLI()` wrapper.\n *\n * @returns An async function accepting the post-`node script` argv slice.\n */\n generateHandler(): (args: string[]) => Promise<void> {\n return async (args: string[]) => {\n await this.run(args ?? []);\n };\n }\n\n /**\n * Parse and execute a single CLI invocation.\n *\n * @param args - argv slice (command + flags).\n */\n async run(args: string[]): Promise<void> {\n if (args.length === 0 || args[0] === '--help' || args[0] === 'help') {\n console.log(await this.helpText());\n return;\n }\n\n const parsed = this.parseArgs(args);\n if (!parsed) {\n console.log(await this.helpText());\n return;\n }\n\n const classInfo = this.resolveClass(parsed.objectName);\n if (!classInfo) {\n throw new Error(`Object type '${parsed.objectName}' not found`);\n }\n const objectName: string = classInfo.name || parsed.objectName;\n\n await this.assertCommandExposed(objectName, parsed.action);\n\n const tenantId =\n typeof parsed.flags.tenant === 'string'\n ? parsed.flags.tenant\n : this.context.tenantId;\n const allowCrossTenant =\n parsed.flags['all-tenants'] === true ||\n this.context.allowCrossTenant === true;\n\n // Tenant-scoping is resolved authoritatively inside tenancy (by class name),\n // matching the interceptor and covering both @TenantScoped and\n // @smrt({ tenantScoped }) registrations (#1554).\n const result = await runWithTenantGate(\n { className: objectName, tenantId, allowCrossTenant, surface: 'CLI' },\n async () => {\n const collection = await this.getCollection(objectName);\n return this.executeAction(collection, objectName, parsed);\n },\n );\n\n if (result !== undefined) {\n console.log(JSON.stringify(this.toPublicData(result), null, 2));\n }\n }\n\n /**\n * Parse argv into a command + flags. Supports `name:action` and\n * `name action` command forms, `--key value`, `--key=value`, and bare\n * `--flag` booleans.\n */\n private parseArgs(args: string[]): ParsedInvocation | null {\n const [command, ...rest] = args;\n if (!command || command.startsWith('-')) return null;\n\n let objectName: string;\n let action: string;\n let tail = rest;\n\n if (command.includes(':')) {\n const [obj, act] = command.split(':');\n objectName = obj.toLowerCase();\n action = act;\n } else if (rest.length > 0 && !rest[0].startsWith('-')) {\n objectName = command.toLowerCase();\n action = rest[0];\n tail = rest.slice(1);\n } else {\n // A bare object name with no action is not a runnable command.\n return null;\n }\n\n if (!objectName || !action) return null;\n\n const flags: Record<string, string | boolean> = {};\n let positional: string | undefined;\n\n for (let i = 0; i < tail.length; i++) {\n const token = tail[i];\n if (token.startsWith('--')) {\n const body = token.slice(2);\n const eq = body.indexOf('=');\n if (eq !== -1) {\n flags[body.slice(0, eq)] = body.slice(eq + 1);\n } else {\n const next = tail[i + 1];\n if (next !== undefined && !next.startsWith('--')) {\n flags[body] = next;\n i++;\n } else {\n flags[body] = true;\n }\n }\n } else if (positional === undefined) {\n positional = token;\n }\n }\n\n return { objectName, action, positional, flags };\n }\n\n /**\n * Resolve a registered class by simple name (case-insensitive), mirroring the\n * MCP generator's lookup.\n */\n private resolveClass(objectName: string): RegisteredClass | null {\n const registeredClasses = ObjectRegistry.getAllClasses();\n for (const [key, info] of registeredClasses) {\n const simpleName = info.name || key;\n if (simpleName.toLowerCase() === objectName.toLowerCase()) {\n return info;\n }\n }\n return null;\n }\n\n /**\n * Throw if `action` is not exposed for `objectName` under its `@smrt({ cli })`\n * config. `cli: false` disables the object entirely; an `include` list is the\n * complete allowlist; custom methods must be `isPublic`.\n */\n private async assertCommandExposed(\n objectName: string,\n action: string,\n ): Promise<void> {\n const config = ObjectRegistry.getConfig(objectName);\n const cliConfig = config?.cli;\n\n if (cliConfig === false) {\n throw new Error(`CLI is disabled for ${objectName}`);\n }\n\n const included: string[] | undefined =\n typeof cliConfig === 'object' ? cliConfig?.include : undefined;\n const excluded: string[] =\n typeof cliConfig === 'object' && cliConfig?.exclude\n ? cliConfig.exclude\n : [];\n\n if (excluded.includes(action)) {\n throw new Error(`Command '${action}' is excluded for ${objectName}`);\n }\n\n const isCrud = CRUD_OPERATIONS.includes(action);\n\n if (isCrud) {\n // An include list, when present, is the complete allowlist for CRUD too.\n if (included && !included.includes(action)) {\n throw new Error(`Command '${action}' is not enabled for ${objectName}`);\n }\n return;\n }\n\n // Custom method: must be public, and — when an include list is present — it\n // is the COMPLETE allowlist (parity with MCP exhaustive-include, #1547).\n const methods = await ObjectRegistry.getAllMethods(objectName);\n const methodDef = methods.get(action);\n if (!methodDef) {\n throw new Error(`Unknown command '${action}' for ${objectName}`);\n }\n if (!methodDef.isPublic) {\n throw new Error(`Command '${action}' is not public on ${objectName}`);\n }\n if (included !== undefined && !included.includes(action)) {\n throw new Error(`Command '${action}' is not enabled for ${objectName}`);\n }\n }\n\n /**\n * Get the collection for an object via the registry factory. Uses\n * `ObjectRegistry.getCollection`, which caches, initializes, and — for plain\n * `@smrt()` models without a hand-written collection class — auto-creates the\n * default collection. (A bare `collectionConstructor` check would wrongly\n * throw for those models even though CRUD is advertised.)\n */\n private async getCollection(\n objectName: string,\n ): Promise<SmrtCollection<SmrtObject>> {\n return ObjectRegistry.getCollection(objectName, {\n ai: this.context.ai,\n db: this.context.db,\n });\n }\n\n /**\n * Execute a parsed command against a collection.\n */\n private async executeAction(\n collection: SmrtCollection<SmrtObject>,\n objectName: string,\n parsed: ParsedInvocation,\n ): Promise<unknown> {\n const { action, positional, flags } = parsed;\n\n switch (action) {\n case 'list': {\n const listOptions: {\n limit: number;\n offset: number;\n orderBy?: string;\n where?: Record<string, unknown>;\n } = {\n limit: Math.min(this.toNumber(flags.limit, 50), 1000),\n offset: this.toNumber(flags.offset, 0),\n };\n const orderBy = flags['order-by'] ?? flags.orderBy;\n if (typeof orderBy === 'string') listOptions.orderBy = orderBy;\n if (typeof flags.where === 'string') {\n listOptions.where = JSON.parse(flags.where);\n }\n return collection.list(listOptions);\n }\n\n case 'get': {\n const id = positional ?? this.flagString(flags.id);\n if (!id) throw new Error('An id is required for get');\n const item = await collection.get(id);\n if (!item) throw new Error(`${objectName} not found`);\n return item;\n }\n\n case 'create': {\n const data = this.applyWritablePolicy(\n objectName,\n await this.readPayload(flags),\n );\n if (this.context.user) {\n data.created_by = this.context.user.id;\n data.owner_id = this.context.user.id;\n }\n const created = await collection.create(data);\n await created.save();\n return created;\n }\n\n case 'update': {\n const id = positional ?? this.flagString(flags.id);\n if (!id) throw new Error('An id is required for update');\n const existing = await collection.get(id);\n if (!existing) throw new Error(`${objectName} not found`);\n const data = this.applyWritablePolicy(\n objectName,\n await this.readPayload(flags),\n );\n Object.assign(existing, data);\n if (this.context.user) {\n // Stamp the server-managed audit column on the instance; this field\n // is not part of the public SmrtObject surface.\n (existing as unknown as Record<string, unknown>).updated_by =\n this.context.user.id;\n }\n await existing.save();\n return existing;\n }\n\n case 'delete': {\n const id = positional ?? this.flagString(flags.id);\n if (!id) throw new Error('An id is required for delete');\n const existing = await collection.get(id);\n if (!existing) throw new Error(`${objectName} not found`);\n await existing.delete();\n return { success: true, id, message: `${objectName} deleted` };\n }\n\n default:\n return this.executeCustomAction(collection, action, positional, flags);\n }\n }\n\n /**\n * Execute a custom method on an instance (when an id is given) or on the\n * collection (singleton actions). Mirrors the MCP custom-action path.\n */\n private async executeCustomAction(\n collection: SmrtCollection<SmrtObject>,\n action: string,\n positional: string | undefined,\n flags: Record<string, string | boolean>,\n ): Promise<unknown> {\n const options = this.customOptions(flags);\n const id = positional ?? this.flagString(flags.id);\n\n if (id) {\n const object = await collection.get(id);\n if (!object) throw new Error('Object not found');\n // Custom methods are dispatched dynamically by name; index through a\n // callable-keyed view of the instance, narrowing before invoking.\n const candidate = (object as unknown as DynamicallyCallable)[action];\n if (typeof candidate !== 'function') {\n throw new Error(`Method '${action}' not found on object instance`);\n }\n return candidate.call(object, options);\n }\n\n const collectionCandidate = (collection as unknown as DynamicallyCallable)[\n action\n ];\n if (typeof collectionCandidate === 'function') {\n return collectionCandidate.call(collection, options);\n }\n throw new Error(\n `Method '${action}' not found on collection. Provide an id for object-specific actions.`,\n );\n }\n\n /**\n * Resolve the create/update payload: `--from-file <path>` (JSON) takes\n * precedence; otherwise non-reserved `--field value` flags form the body.\n * Reading from a file also keeps secrets off the process argv (#1556).\n */\n private async readPayload(\n flags: Record<string, string | boolean>,\n ): Promise<Record<string, unknown>> {\n const fromFile = flags['from-file'];\n if (typeof fromFile === 'string' && fromFile) {\n const { readFile } = await import('node:fs/promises');\n const content = await readFile(fromFile, 'utf-8');\n const parsed = JSON.parse(content);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error('--from-file must contain a JSON object');\n }\n return parsed as Record<string, unknown>;\n }\n\n const data: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(flags)) {\n if (RESERVED_FLAGS.has(key)) continue;\n data[key] = this.coerceValue(value);\n }\n return data;\n }\n\n /** Collect non-reserved flags as custom-action options. */\n private customOptions(\n flags: Record<string, string | boolean>,\n ): Record<string, unknown> {\n const options: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(flags)) {\n if (RESERVED_FLAGS.has(key)) continue;\n options[key] = this.coerceValue(value);\n }\n return options;\n }\n\n /**\n * Mass-assignment guard (#1540/#1556): strip framework/server-managed and\n * `@field({ readonly })` fields, and — when an `@smrt({ api: { writable } })`\n * allowlist is set — intersect with it. Identical policy to the REST/MCP\n * generators so a CLI create/update cannot set fields the API forbids.\n */\n private applyWritablePolicy(\n objectName: string | undefined,\n data: unknown,\n ): Record<string, unknown> {\n if (!data || typeof data !== 'object') return {};\n\n const serverManaged = new Set([\n 'id',\n 'tenantId',\n 'tenant_id',\n 'createdAt',\n 'created_at',\n 'updatedAt',\n 'updated_at',\n ]);\n\n const readonly = new Set<string>();\n let writable: string[] | null = null;\n\n if (objectName) {\n const apiConfig = ObjectRegistry.getConfig(objectName)?.api;\n if (\n apiConfig &&\n typeof apiConfig === 'object' &&\n Array.isArray((apiConfig as { writable?: unknown }).writable)\n ) {\n writable = (apiConfig as { writable: string[] }).writable;\n }\n\n for (const [name, def] of ObjectRegistry.getFields(objectName)) {\n if (def && (def.readonly === true || def._meta?.readonly === true)) {\n readonly.add(name);\n }\n }\n }\n\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(data)) {\n if (key.startsWith('_')) continue;\n if (serverManaged.has(key)) continue;\n if (readonly.has(key)) continue;\n if (writable && !writable.includes(key)) continue;\n result[key] = value;\n }\n return result;\n }\n\n /**\n * Serialize command output, excluding `@field({ sensitive })` fields (#1540).\n * Recurses arrays/plain objects so a SmrtObject nested in a custom-action\n * result is stripped too; a cycle guard prevents infinite loops.\n */\n private toPublicData(\n value: unknown,\n seen: WeakSet<object> = new WeakSet(),\n options: PublicJsonOptions = this.getPublicJsonOptions(),\n ): unknown {\n if (value === null || typeof value !== 'object') return value;\n if (\n typeof (value as Partial<PublicSerializable>).toPublicJSON === 'function'\n ) {\n return (value as PublicSerializable).toPublicJSON(options);\n }\n if (Array.isArray(value)) {\n if (seen.has(value)) return value;\n seen.add(value);\n return value.map((entry) => this.toPublicData(entry, seen, options));\n }\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) return value;\n if (seen.has(value)) return value;\n seen.add(value);\n const out: Record<string, unknown> = {};\n for (const [key, entry] of Object.entries(value)) {\n out[key] = this.toPublicData(entry, seen, options);\n }\n return out;\n }\n\n private getPublicJsonOptions(): PublicJsonOptions {\n return { permissions: this.context.permissions };\n }\n\n /** List the runnable commands grouped by object, for help output. */\n async listCommands(): Promise<string[]> {\n const commands: string[] = [];\n for (const [key, classInfo] of ObjectRegistry.getAllClasses()) {\n const objectName = classInfo.name || key;\n const config = ObjectRegistry.getConfig(objectName);\n const cliConfig = config?.cli;\n if (cliConfig === false) continue;\n\n const lower = objectName.toLowerCase();\n const included: string[] | undefined =\n typeof cliConfig === 'object' ? cliConfig?.include : undefined;\n const excluded: string[] =\n typeof cliConfig === 'object' && cliConfig?.exclude\n ? cliConfig.exclude\n : [];\n\n const expose = (cmd: string) =>\n !excluded.includes(cmd) && (!included || included.includes(cmd));\n\n for (const verb of CRUD_OPERATIONS) {\n if (expose(verb)) commands.push(`${lower}:${verb}`);\n }\n\n const methods = await ObjectRegistry.getAllMethods(objectName);\n for (const [methodName, methodDef] of methods) {\n if (CRUD_OPERATIONS.includes(methodName)) continue;\n if (!methodDef.isPublic) continue;\n if (excluded.includes(methodName)) continue;\n if (included !== undefined && !included.includes(methodName)) continue;\n commands.push(`${lower}:${methodName}`);\n }\n }\n return commands.sort();\n }\n\n private async helpText(): Promise<string> {\n const commands = await this.listCommands();\n return [\n `${this.config.name} ${this.config.version}`,\n this.config.description ?? '',\n '',\n 'Usage: <object>:<action> [id] [--flags]',\n '',\n 'Global flags:',\n ' --tenant <id> run the command inside a specific tenant',\n ' --all-tenants allow cross-tenant access (operator opt-in)',\n ' --from-file <p> read create/update payload from a JSON file',\n ' --where <json> filter for list',\n ' --limit / --offset / --order-by',\n '',\n 'Commands:',\n ...commands.map((c) => ` ${c}`),\n ].join('\\n');\n }\n\n private toNumber(\n value: string | boolean | undefined,\n fallback: number,\n ): number {\n if (typeof value !== 'string') return fallback;\n const n = Number.parseInt(value, 10);\n return Number.isNaN(n) ? fallback : n;\n }\n\n private flagString(value: string | boolean | undefined): string | undefined {\n return typeof value === 'string' ? value : undefined;\n }\n\n /** Coerce a flag value to a JSON scalar/object where it parses cleanly. */\n private coerceValue(value: string | boolean): unknown {\n if (typeof value !== 'string') return value;\n if (value === 'true') return true;\n if (value === 'false') return false;\n if (value !== '' && !Number.isNaN(Number(value))) return Number(value);\n if (\n (value.startsWith('{') && value.endsWith('}')) ||\n (value.startsWith('[') && value.endsWith(']'))\n ) {\n try {\n return JSON.parse(value);\n } catch {\n return value;\n }\n }\n return value;\n }\n}\n\n/**\n * Convenience runner used by the generated `setupCLI()` wrapper.\n *\n * @param config - CLI configuration.\n * @param context - Per-invocation context.\n * @returns A `{ run, generator }` pair; `run(argv)` strips the leading\n * `node script` entries before dispatching.\n */\nexport function setupCLI(config: CLIConfig = {}, context: CLIContext = {}) {\n const generator = new CLIGenerator(config, context);\n return {\n run: async (argv: string[]) => {\n const handler = generator.generateHandler();\n await handler(argv.slice(2));\n },\n generator,\n };\n}\n\n/**\n * Get a bare argv handler for a generated CLI.\n *\n * @param config - CLI configuration.\n * @param context - Per-invocation context.\n * @returns An async argv-slice handler.\n */\nexport function getCLIHandler(\n config: CLIConfig = {},\n context: CLIContext = {},\n) {\n const generator = new CLIGenerator(config, context);\n return generator.generateHandler();\n}\n"],"mappings":";;;;AAuFA,IAAM,kBAAkB;CAAC;CAAQ;CAAO;CAAU;CAAU;AAAQ;;;;;AAMpE,IAAM,iCAAiB,IAAI,IAAI;CAC7B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;;;AAiBD,IAAa,eAAb,MAA0B;CACxB;CACA;CAEA,YAAY,SAAoB,CAAC,GAAG,UAAsB,CAAC,GAAG;EAC5D,KAAK,SAAS;GACZ,MAAM;GACN,SAAS;GACT,aAAa;GACb,GAAG;EACL;EACA,KAAK,UAAU;CACjB;;CAGA,IAAI,OAA2B;EAC7B,OAAO,KAAK,OAAO;CACrB;;CAGA,IAAI,UAA8B;EAChC,OAAO,KAAK,OAAO;CACrB;;;;;;CAOA,kBAAqD;EACnD,OAAO,OAAO,SAAmB;GAC/B,MAAM,KAAK,IAAI,QAAQ,CAAC,CAAC;EAC3B;CACF;;;;;;CAOA,MAAM,IAAI,MAA+B;EACvC,IAAI,KAAK,WAAW,KAAK,KAAK,OAAO,YAAY,KAAK,OAAO,QAAQ;GACnE,QAAQ,IAAI,MAAM,KAAK,SAAS,CAAC;GACjC;EACF;EAEA,MAAM,SAAS,KAAK,UAAU,IAAI;EAClC,IAAI,CAAC,QAAQ;GACX,QAAQ,IAAI,MAAM,KAAK,SAAS,CAAC;GACjC;EACF;EAEA,MAAM,YAAY,KAAK,aAAa,OAAO,UAAU;EACrD,IAAI,CAAC,WACH,MAAM,IAAI,MAAM,gBAAgB,OAAO,WAAW,YAAY;EAEhE,MAAM,aAAqB,UAAU,QAAQ,OAAO;EAEpD,MAAM,KAAK,qBAAqB,YAAY,OAAO,MAAM;EAazD,MAAM,SAAS,MAAM,kBACnB;GAAE,WAAW;GAAY,UAXzB,OAAO,OAAO,MAAM,WAAW,WAC3B,OAAO,MAAM,SACb,KAAK,QAAQ;GASkB,kBAPnC,OAAO,MAAM,mBAAmB,QAChC,KAAK,QAAQ,qBAAqB;GAMmB,SAAS;EAAM,GACpE,YAAY;GACV,MAAM,aAAa,MAAM,KAAK,cAAc,UAAU;GACtD,OAAO,KAAK,cAAc,YAAY,YAAY,MAAM;EAC1D,CACF;EAEA,IAAI,WAAW,KAAA,GACb,QAAQ,IAAI,KAAK,UAAU,KAAK,aAAa,MAAM,GAAG,MAAM,CAAC,CAAC;CAElE;;;;;;CAOA,UAAkB,MAAyC;EACzD,MAAM,CAAC,SAAS,GAAG,QAAQ;EAC3B,IAAI,CAAC,WAAW,QAAQ,WAAW,GAAG,GAAG,OAAO;EAEhD,IAAI;EACJ,IAAI;EACJ,IAAI,OAAO;EAEX,IAAI,QAAQ,SAAS,GAAG,GAAG;GACzB,MAAM,CAAC,KAAK,OAAO,QAAQ,MAAM,GAAG;GACpC,aAAa,IAAI,YAAY;GAC7B,SAAS;EACX,OAAO,IAAI,KAAK,SAAS,KAAK,CAAC,KAAK,EAAE,CAAC,WAAW,GAAG,GAAG;GACtD,aAAa,QAAQ,YAAY;GACjC,SAAS,KAAK;GACd,OAAO,KAAK,MAAM,CAAC;EACrB,OAEE,OAAO;EAGT,IAAI,CAAC,cAAc,CAAC,QAAQ,OAAO;EAEnC,MAAM,QAA0C,CAAC;EACjD,IAAI;EAEJ,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;GACpC,MAAM,QAAQ,KAAK;GACnB,IAAI,MAAM,WAAW,IAAI,GAAG;IAC1B,MAAM,OAAO,MAAM,MAAM,CAAC;IAC1B,MAAM,KAAK,KAAK,QAAQ,GAAG;IAC3B,IAAI,OAAO,IACT,MAAM,KAAK,MAAM,GAAG,EAAE,KAAK,KAAK,MAAM,KAAK,CAAC;SACvC;KACL,MAAM,OAAO,KAAK,IAAI;KACtB,IAAI,SAAS,KAAA,KAAa,CAAC,KAAK,WAAW,IAAI,GAAG;MAChD,MAAM,QAAQ;MACd;KACF,OACE,MAAM,QAAQ;IAElB;GACF,OAAO,IAAI,eAAe,KAAA,GACxB,aAAa;EAEjB;EAEA,OAAO;GAAE;GAAY;GAAQ;GAAY;EAAM;CACjD;;;;;CAMA,aAAqB,YAA4C;EAC/D,MAAM,oBAAoB,eAAe,cAAc;EACvD,KAAK,MAAM,CAAC,KAAK,SAAS,mBAExB,KADmB,KAAK,QAAQ,IAAA,CACjB,YAAY,MAAM,WAAW,YAAY,GACtD,OAAO;EAGX,OAAO;CACT;;;;;;CAOA,MAAc,qBACZ,YACA,QACe;EAEf,MAAM,YADS,eAAe,UAAU,UACtB,CAAA,EAAQ;EAE1B,IAAI,cAAc,OAChB,MAAM,IAAI,MAAM,uBAAuB,YAAY;EAGrD,MAAM,WACJ,OAAO,cAAc,WAAW,WAAW,UAAU,KAAA;EAMvD,KAJE,OAAO,cAAc,YAAY,WAAW,UACxC,UAAU,UACV,CAAC,EAAA,CAEM,SAAS,MAAM,GAC1B,MAAM,IAAI,MAAM,YAAY,OAAO,oBAAoB,YAAY;EAKrE,IAFe,gBAAgB,SAAS,MAEpC,GAAQ;GAEV,IAAI,YAAY,CAAC,SAAS,SAAS,MAAM,GACvC,MAAM,IAAI,MAAM,YAAY,OAAO,uBAAuB,YAAY;GAExE;EACF;EAKA,MAAM,aAAY,MADI,eAAe,cAAc,UAAU,EAAA,CACnC,IAAI,MAAM;EACpC,IAAI,CAAC,WACH,MAAM,IAAI,MAAM,oBAAoB,OAAO,QAAQ,YAAY;EAEjE,IAAI,CAAC,UAAU,UACb,MAAM,IAAI,MAAM,YAAY,OAAO,qBAAqB,YAAY;EAEtE,IAAI,aAAa,KAAA,KAAa,CAAC,SAAS,SAAS,MAAM,GACrD,MAAM,IAAI,MAAM,YAAY,OAAO,uBAAuB,YAAY;CAE1E;;;;;;;;CASA,MAAc,cACZ,YACqC;EACrC,OAAO,eAAe,cAAc,YAAY;GAC9C,IAAI,KAAK,QAAQ;GACjB,IAAI,KAAK,QAAQ;EACnB,CAAC;CACH;;;;CAKA,MAAc,cACZ,YACA,YACA,QACkB;EAClB,MAAM,EAAE,QAAQ,YAAY,UAAU;EAEtC,QAAQ,QAAR;GACE,KAAK,QAAQ;IACX,MAAM,cAKF;KACF,OAAO,KAAK,IAAI,KAAK,SAAS,MAAM,OAAO,EAAE,GAAG,GAAI;KACpD,QAAQ,KAAK,SAAS,MAAM,QAAQ,CAAC;IACvC;IACA,MAAM,UAAU,MAAM,eAAe,MAAM;IAC3C,IAAI,OAAO,YAAY,UAAU,YAAY,UAAU;IACvD,IAAI,OAAO,MAAM,UAAU,UACzB,YAAY,QAAQ,KAAK,MAAM,MAAM,KAAK;IAE5C,OAAO,WAAW,KAAK,WAAW;GACpC;GAEA,KAAK,OAAO;IACV,MAAM,KAAK,cAAc,KAAK,WAAW,MAAM,EAAE;IACjD,IAAI,CAAC,IAAI,MAAM,IAAI,MAAM,2BAA2B;IACpD,MAAM,OAAO,MAAM,WAAW,IAAI,EAAE;IACpC,IAAI,CAAC,MAAM,MAAM,IAAI,MAAM,GAAG,WAAW,WAAW;IACpD,OAAO;GACT;GAEA,KAAK,UAAU;IACb,MAAM,OAAO,KAAK,oBAChB,YACA,MAAM,KAAK,YAAY,KAAK,CAC9B;IACA,IAAI,KAAK,QAAQ,MAAM;KACrB,KAAK,aAAa,KAAK,QAAQ,KAAK;KACpC,KAAK,WAAW,KAAK,QAAQ,KAAK;IACpC;IACA,MAAM,UAAU,MAAM,WAAW,OAAO,IAAI;IAC5C,MAAM,QAAQ,KAAK;IACnB,OAAO;GACT;GAEA,KAAK,UAAU;IACb,MAAM,KAAK,cAAc,KAAK,WAAW,MAAM,EAAE;IACjD,IAAI,CAAC,IAAI,MAAM,IAAI,MAAM,8BAA8B;IACvD,MAAM,WAAW,MAAM,WAAW,IAAI,EAAE;IACxC,IAAI,CAAC,UAAU,MAAM,IAAI,MAAM,GAAG,WAAW,WAAW;IACxD,MAAM,OAAO,KAAK,oBAChB,YACA,MAAM,KAAK,YAAY,KAAK,CAC9B;IACA,OAAO,OAAO,UAAU,IAAI;IAC5B,IAAI,KAAK,QAAQ,MAGf,SAAiD,aAC/C,KAAK,QAAQ,KAAK;IAEtB,MAAM,SAAS,KAAK;IACpB,OAAO;GACT;GAEA,KAAK,UAAU;IACb,MAAM,KAAK,cAAc,KAAK,WAAW,MAAM,EAAE;IACjD,IAAI,CAAC,IAAI,MAAM,IAAI,MAAM,8BAA8B;IACvD,MAAM,WAAW,MAAM,WAAW,IAAI,EAAE;IACxC,IAAI,CAAC,UAAU,MAAM,IAAI,MAAM,GAAG,WAAW,WAAW;IACxD,MAAM,SAAS,OAAO;IACtB,OAAO;KAAE,SAAS;KAAM;KAAI,SAAS,GAAG,WAAW;IAAU;GAC/D;GAEA,SACE,OAAO,KAAK,oBAAoB,YAAY,QAAQ,YAAY,KAAK;EACzE;CACF;;;;;CAMA,MAAc,oBACZ,YACA,QACA,YACA,OACkB;EAClB,MAAM,UAAU,KAAK,cAAc,KAAK;EACxC,MAAM,KAAK,cAAc,KAAK,WAAW,MAAM,EAAE;EAEjD,IAAI,IAAI;GACN,MAAM,SAAS,MAAM,WAAW,IAAI,EAAE;GACtC,IAAI,CAAC,QAAQ,MAAM,IAAI,MAAM,kBAAkB;GAG/C,MAAM,YAAa,OAA0C;GAC7D,IAAI,OAAO,cAAc,YACvB,MAAM,IAAI,MAAM,WAAW,OAAO,+BAA+B;GAEnE,OAAO,UAAU,KAAK,QAAQ,OAAO;EACvC;EAEA,MAAM,sBAAuB,WAC3B;EAEF,IAAI,OAAO,wBAAwB,YACjC,OAAO,oBAAoB,KAAK,YAAY,OAAO;EAErD,MAAM,IAAI,MACR,WAAW,OAAO,sEACpB;CACF;;;;;;CAOA,MAAc,YACZ,OACkC;EAClC,MAAM,WAAW,MAAM;EACvB,IAAI,OAAO,aAAa,YAAY,UAAU;GAC5C,MAAM,EAAE,aAAa,MAAM,OAAO;GAClC,MAAM,UAAU,MAAM,SAAS,UAAU,OAAO;GAChD,MAAM,SAAS,KAAK,MAAM,OAAO;GACjC,IAAI,CAAC,UAAU,OAAO,WAAW,YAAY,MAAM,QAAQ,MAAM,GAC/D,MAAM,IAAI,MAAM,wCAAwC;GAE1D,OAAO;EACT;EAEA,MAAM,OAAgC,CAAC;EACvC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAAG;GAChD,IAAI,eAAe,IAAI,GAAG,GAAG;GAC7B,KAAK,OAAO,KAAK,YAAY,KAAK;EACpC;EACA,OAAO;CACT;;CAGA,cACE,OACyB;EACzB,MAAM,UAAmC,CAAC;EAC1C,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAAG;GAChD,IAAI,eAAe,IAAI,GAAG,GAAG;GAC7B,QAAQ,OAAO,KAAK,YAAY,KAAK;EACvC;EACA,OAAO;CACT;;;;;;;CAQA,oBACE,YACA,MACyB;EACzB,IAAI,CAAC,QAAQ,OAAO,SAAS,UAAU,OAAO,CAAC;EAE/C,MAAM,gCAAgB,IAAI,IAAI;GAC5B;GACA;GACA;GACA;GACA;GACA;GACA;EACF,CAAC;EAED,MAAM,2BAAW,IAAI,IAAY;EACjC,IAAI,WAA4B;EAEhC,IAAI,YAAY;GACd,MAAM,YAAY,eAAe,UAAU,UAAU,CAAC,EAAE;GACxD,IACE,aACA,OAAO,cAAc,YACrB,MAAM,QAAS,UAAqC,QAAQ,GAE5D,WAAY,UAAqC;GAGnD,KAAK,MAAM,CAAC,MAAM,QAAQ,eAAe,UAAU,UAAU,GAC3D,IAAI,QAAQ,IAAI,aAAa,QAAQ,IAAI,OAAO,aAAa,OAC3D,SAAS,IAAI,IAAI;EAGvB;EAEA,MAAM,SAAkC,CAAC;EACzC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAAI,GAAG;GAC/C,IAAI,IAAI,WAAW,GAAG,GAAG;GACzB,IAAI,cAAc,IAAI,GAAG,GAAG;GAC5B,IAAI,SAAS,IAAI,GAAG,GAAG;GACvB,IAAI,YAAY,CAAC,SAAS,SAAS,GAAG,GAAG;GACzC,OAAO,OAAO;EAChB;EACA,OAAO;CACT;;;;;;CAOA,aACE,OACA,uBAAwB,IAAI,QAAQ,GACpC,UAA6B,KAAK,qBAAqB,GAC9C;EACT,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU,OAAO;EACxD,IACE,OAAQ,MAAsC,iBAAiB,YAE/D,OAAQ,MAA6B,aAAa,OAAO;EAE3D,IAAI,MAAM,QAAQ,KAAK,GAAG;GACxB,IAAI,KAAK,IAAI,KAAK,GAAG,OAAO;GAC5B,KAAK,IAAI,KAAK;GACd,OAAO,MAAM,KAAK,UAAU,KAAK,aAAa,OAAO,MAAM,OAAO,CAAC;EACrE;EACA,MAAM,QAAQ,OAAO,eAAe,KAAK;EACzC,IAAI,UAAU,OAAO,aAAa,UAAU,MAAM,OAAO;EACzD,IAAI,KAAK,IAAI,KAAK,GAAG,OAAO;EAC5B,KAAK,IAAI,KAAK;EACd,MAAM,MAA+B,CAAC;EACtC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAC7C,IAAI,OAAO,KAAK,aAAa,OAAO,MAAM,OAAO;EAEnD,OAAO;CACT;CAEA,uBAAkD;EAChD,OAAO,EAAE,aAAa,KAAK,QAAQ,YAAY;CACjD;;CAGA,MAAM,eAAkC;EACtC,MAAM,WAAqB,CAAC;EAC5B,KAAK,MAAM,CAAC,KAAK,cAAc,eAAe,cAAc,GAAG;GAC7D,MAAM,aAAa,UAAU,QAAQ;GAErC,MAAM,YADS,eAAe,UAAU,UACtB,CAAA,EAAQ;GAC1B,IAAI,cAAc,OAAO;GAEzB,MAAM,QAAQ,WAAW,YAAY;GACrC,MAAM,WACJ,OAAO,cAAc,WAAW,WAAW,UAAU,KAAA;GACvD,MAAM,WACJ,OAAO,cAAc,YAAY,WAAW,UACxC,UAAU,UACV,CAAC;GAEP,MAAM,UAAU,QACd,CAAC,SAAS,SAAS,GAAG,MAAM,CAAC,YAAY,SAAS,SAAS,GAAG;GAEhE,KAAK,MAAM,QAAQ,iBACjB,IAAI,OAAO,IAAI,GAAG,SAAS,KAAK,GAAG,MAAM,GAAG,MAAM;GAGpD,MAAM,UAAU,MAAM,eAAe,cAAc,UAAU;GAC7D,KAAK,MAAM,CAAC,YAAY,cAAc,SAAS;IAC7C,IAAI,gBAAgB,SAAS,UAAU,GAAG;IAC1C,IAAI,CAAC,UAAU,UAAU;IACzB,IAAI,SAAS,SAAS,UAAU,GAAG;IACnC,IAAI,aAAa,KAAA,KAAa,CAAC,SAAS,SAAS,UAAU,GAAG;IAC9D,SAAS,KAAK,GAAG,MAAM,GAAG,YAAY;GACxC;EACF;EACA,OAAO,SAAS,KAAK;CACvB;CAEA,MAAc,WAA4B;EACxC,MAAM,WAAW,MAAM,KAAK,aAAa;EACzC,OAAO;GACL,GAAG,KAAK,OAAO,KAAK,GAAG,KAAK,OAAO;GACnC,KAAK,OAAO,eAAe;GAC3B;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA,GAAG,SAAS,KAAK,MAAM,KAAK,GAAG;EACjC,CAAC,CAAC,KAAK,IAAI;CACb;CAEA,SACE,OACA,UACQ;EACR,IAAI,OAAO,UAAU,UAAU,OAAO;EACtC,MAAM,IAAI,OAAO,SAAS,OAAO,EAAE;EACnC,OAAO,OAAO,MAAM,CAAC,IAAI,WAAW;CACtC;CAEA,WAAmB,OAAyD;EAC1E,OAAO,OAAO,UAAU,WAAW,QAAQ,KAAA;CAC7C;;CAGA,YAAoB,OAAkC;EACpD,IAAI,OAAO,UAAU,UAAU,OAAO;EACtC,IAAI,UAAU,QAAQ,OAAO;EAC7B,IAAI,UAAU,SAAS,OAAO;EAC9B,IAAI,UAAU,MAAM,CAAC,OAAO,MAAM,OAAO,KAAK,CAAC,GAAG,OAAO,OAAO,KAAK;EACrE,IACG,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,KAC3C,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,GAE5C,IAAI;GACF,OAAO,KAAK,MAAM,KAAK;EACzB,QAAQ;GACN,OAAO;EACT;EAEF,OAAO;CACT;AACF;;;;;;;;;AAUA,SAAgB,SAAS,SAAoB,CAAC,GAAG,UAAsB,CAAC,GAAG;CACzE,MAAM,YAAY,IAAI,aAAa,QAAQ,OAAO;CAClD,OAAO;EACL,KAAK,OAAO,SAAmB;GAE7B,MADgB,UAAU,gBACpB,CAAA,CAAQ,KAAK,MAAM,CAAC,CAAC;EAC7B;EACA;CACF;AACF;;;;;;;;AASA,SAAgB,cACd,SAAoB,CAAC,GACrB,UAAsB,CAAC,GACvB;CAEA,OAAO,IADe,aAAa,QAAQ,OACpC,CAAA,CAAU,gBAAgB;AACnC"}
@@ -20,6 +20,10 @@
20
20
  * mode) NEVER emit shared-cache headers: their bodies vary with the tenant
21
21
  * context, which URL-keyed shared caches cannot see. `sMaxage` is ignored
22
22
  * with a one-time warning.
23
+ * - Field-level read-permission models NEVER emit shared-cache headers: their
24
+ * bodies vary with the caller's resolved permission set, which shared caches
25
+ * cannot see. These routes use the v1 body-hash ETag so the validator covers
26
+ * the redacted payload actually returned to that caller.
23
27
  *
24
28
  * Consumed by both the runtime REST generator (`./rest.ts`) and — as an
25
29
  * emitted code snippet — the SvelteKit route generator
@@ -55,6 +59,12 @@ export interface ReadCacheControlOptions {
55
59
  * headers (#1757 review finding).
56
60
  */
57
61
  tenantScoped?: boolean;
62
+ /**
63
+ * Whether the response body varies with caller permissions because at least
64
+ * one field has `@field({ readPermission })`. Shared caches key on URL, not
65
+ * user permission sets, so this also fails private regardless of `sMaxage`.
66
+ */
67
+ permissionScoped?: boolean;
58
68
  }
59
69
  /**
60
70
  * Resolve the Cache-Control header for a generated read response from a
@@ -66,10 +76,10 @@ export interface ReadCacheControlOptions {
66
76
  * configure a positive `cache.sMaxage`. Everything else — including a
67
77
  * non-public model that configures `sMaxage` — stays `private, no-cache`.
68
78
  *
69
- * Tenant-scoped models are ALWAYS `private, no-cache` regardless of config:
70
- * their response bodies vary with the tenant context (resolved from session
71
- * cookies, invisible to URL-keyed shared caches), so shared caching would
72
- * leak one tenant's rows to other tenants or anonymous visitors.
79
+ * Tenant-scoped and permission-scoped models are ALWAYS `private, no-cache`
80
+ * regardless of config: their response bodies vary with request identity
81
+ * (tenant or permissions, invisible to URL-keyed shared caches), so shared
82
+ * caching would leak one caller's representation to another caller.
73
83
  */
74
84
  export declare function resolveReadCacheControl(apiConfig: unknown, options?: ReadCacheControlOptions): string;
75
85
  /**
@@ -91,7 +101,7 @@ export declare function warnIfTenantScopedPublicRead(modelName: string, apiConfi
91
101
  * CDN caching happens. Called from both the REST runtime and the SvelteKit
92
102
  * route generator so the message surfaces wherever the model is served.
93
103
  */
94
- export declare function warnIfSharedCacheNeutralized(modelName: string, apiConfig: unknown, tenantScoped: boolean): void;
104
+ export declare function warnIfSharedCacheNeutralized(modelName: string, apiConfig: unknown, tenantScoped: boolean, permissionScoped?: boolean): void;
95
105
  /**
96
106
  * Build the JSON response for a generated read, honoring `If-None-Match`.
97
107
  *
@@ -221,7 +231,8 @@ export interface ConditionalGetRouteHelperOptions extends ReadCacheControlOption
221
231
  * pure function of the base table — the `toPublicJSON` path.
222
232
  * - **v1 (#1757, `useBodyHash`)** — the inlined body-hash `conditionalJson`,
223
233
  * used where a custom serializer can pull in related tables the base-table
224
- * version can't see (see {@link ConditionalGetRouteHelperOptions.useBodyHash}).
234
+ * version can't see, or where `@field({ readPermission })` means the body
235
+ * differs by caller permissions.
225
236
  *
226
237
  * For tenant-scoped models the v2 representation folds in the active tenant
227
238
  * ({@link resolveTenantEtagDiscriminator}) so one tenant's cached validator
@@ -1 +1 @@
1
- {"version":3,"file":"conditional-get.d.ts","sourceRoot":"","sources":["../../src/generators/conditional-get.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAKH,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;AAqCD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,MAAM,GACrB,MAAM,CAIR;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,OAAO,EAChB,KAAK,CAAC,EAAE,MAAM,GACb,MAAM,CAYR;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,8BAA8B,IAAI,MAAM,GAAG,SAAS,CAInE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GACX,OAAO,CAQT;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GAC7C,OAAO,CAAC,QAAQ,CAAC,CA4BnB;AAED,sEAAsE;AACtE,MAAM,WAAW,gCACf,SAAQ,uBAAuB;IAC/B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,OAAO,EAClB,OAAO,GAAE,gCAAqC,GAC7C,MAAM,CAkIR"}
1
+ {"version":3,"file":"conditional-get.d.ts","sourceRoot":"","sources":["../../src/generators/conditional-get.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAKH,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;IACvB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;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,EACrB,gBAAgB,UAAQ,GACvB,IAAI,CAwBN;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,MAAM,GACnB,QAAQ,CAsBV;AAqCD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,MAAM,GACrB,MAAM,CAIR;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,OAAO,EAChB,KAAK,CAAC,EAAE,MAAM,GACb,MAAM,CAYR;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,8BAA8B,IAAI,MAAM,GAAG,SAAS,CAInE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,IAAI,EAAE,MAAM,GACX,OAAO,CAQT;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GAC7C,OAAO,CAAC,QAAQ,CAAC,CA4BnB;AAED,sEAAsE;AACtE,MAAM,WAAW,gCACf,SAAQ,uBAAuB;IAC/B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,OAAO,EAClB,OAAO,GAAE,gCAAqC,GAC7C,MAAM,CAoIR"}
@@ -23,6 +23,10 @@ import { createHash } from "node:crypto";
23
23
  * mode) NEVER emit shared-cache headers: their bodies vary with the tenant
24
24
  * context, which URL-keyed shared caches cannot see. `sMaxage` is ignored
25
25
  * with a one-time warning.
26
+ * - Field-level read-permission models NEVER emit shared-cache headers: their
27
+ * bodies vary with the caller's resolved permission set, which shared caches
28
+ * cannot see. These routes use the v1 body-hash ETag so the validator covers
29
+ * the redacted payload actually returned to that caller.
26
30
  *
27
31
  * Consumed by both the runtime REST generator (`./rest.ts`) and — as an
28
32
  * emitted code snippet — the SvelteKit route generator
@@ -78,13 +82,13 @@ function requestedSharedCacheControl(apiConfig) {
78
82
  * configure a positive `cache.sMaxage`. Everything else — including a
79
83
  * non-public model that configures `sMaxage` — stays `private, no-cache`.
80
84
  *
81
- * Tenant-scoped models are ALWAYS `private, no-cache` regardless of config:
82
- * their response bodies vary with the tenant context (resolved from session
83
- * cookies, invisible to URL-keyed shared caches), so shared caching would
84
- * leak one tenant's rows to other tenants or anonymous visitors.
85
+ * Tenant-scoped and permission-scoped models are ALWAYS `private, no-cache`
86
+ * regardless of config: their response bodies vary with request identity
87
+ * (tenant or permissions, invisible to URL-keyed shared caches), so shared
88
+ * caching would leak one caller's representation to another caller.
85
89
  */
86
90
  function resolveReadCacheControl(apiConfig, options = {}) {
87
- if (options.tenantScoped) return PRIVATE_READ_CACHE_CONTROL;
91
+ if (options.tenantScoped || options.permissionScoped) return PRIVATE_READ_CACHE_CONTROL;
88
92
  return requestedSharedCacheControl(apiConfig) ?? "private, no-cache";
89
93
  }
90
94
  /** Whether an `api` config opts reads out of auth (`public: true | 'read'`). */
@@ -120,12 +124,13 @@ function warnIfTenantScopedPublicRead(modelName, apiConfig, tenantScoped) {
120
124
  * CDN caching happens. Called from both the REST runtime and the SvelteKit
121
125
  * route generator so the message surfaces wherever the model is served.
122
126
  */
123
- function warnIfSharedCacheNeutralized(modelName, apiConfig, tenantScoped) {
124
- if (!tenantScoped) return;
127
+ function warnIfSharedCacheNeutralized(modelName, apiConfig, tenantScoped, permissionScoped = false) {
128
+ if (!tenantScoped && !permissionScoped) return;
125
129
  if (requestedSharedCacheControl(apiConfig) === null) return;
126
- if (sharedCacheNeutralizedWarned.has(modelName)) return;
127
- sharedCacheNeutralizedWarned.add(modelName);
128
- 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).`);
130
+ const warningKey = `${modelName}:${`${tenantScoped ? "tenant" : ""}:${permissionScoped ? "permission" : ""}`}`;
131
+ if (sharedCacheNeutralizedWarned.has(warningKey)) return;
132
+ sharedCacheNeutralizedWarned.add(warningKey);
133
+ console.warn(`[smrt] api.cache.sMaxage ignored for ${tenantScoped && permissionScoped ? "tenant-scoped/read-permission model" : tenantScoped ? "tenant-scoped model" : "read-permission model"} ${modelName}: shared caches cannot key on ${tenantScoped && permissionScoped ? "tenant/read-permission context" : tenantScoped ? "tenant context" : "caller permissions"} — serving '${PRIVATE_READ_CACHE_CONTROL}' instead (#1757).`);
129
134
  }
130
135
  /**
131
136
  * Build the JSON response for a generated read, honoring `If-None-Match`.
@@ -294,7 +299,8 @@ async function versionConditionalResponse(request, etag, cacheControl, buildPayl
294
299
  * pure function of the base table — the `toPublicJSON` path.
295
300
  * - **v1 (#1757, `useBodyHash`)** — the inlined body-hash `conditionalJson`,
296
301
  * used where a custom serializer can pull in related tables the base-table
297
- * version can't see (see {@link ConditionalGetRouteHelperOptions.useBodyHash}).
302
+ * version can't see, or where `@field({ readPermission })` means the body
303
+ * differs by caller permissions.
298
304
  *
299
305
  * For tenant-scoped models the v2 representation folds in the active tenant
300
306
  * ({@link resolveTenantEtagDiscriminator}) so one tenant's cached validator
@@ -306,10 +312,10 @@ async function versionConditionalResponse(request, etag, cacheControl, buildPayl
306
312
  function generateConditionalGetRouteHelper(apiConfig, options = {}) {
307
313
  const cacheControl = resolveReadCacheControl(apiConfig, options);
308
314
  if (options.modelName) {
309
- warnIfSharedCacheNeutralized(options.modelName, apiConfig, options.tenantScoped === true);
315
+ warnIfSharedCacheNeutralized(options.modelName, apiConfig, options.tenantScoped === true, options.permissionScoped === true);
310
316
  warnIfTenantScopedPublicRead(options.modelName, apiConfig, options.tenantScoped === true);
311
317
  }
312
- if (options.useBodyHash) return `
318
+ if (options.useBodyHash || options.permissionScoped === true) return `
313
319
  // Conditional GET (#1757 v1): a strong body-hash ETag over the serialized
314
320
  // response — used where a custom serializer can render data from related tables
315
321
  // that the per-table change-feed version cannot observe, so the ETag must cover
@@ -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';\nimport { resolveDispatchTenantScope } from '../dispatch/tenant-resolver.js';\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// ===========================================================================\n// ETag v2: per-table change-feed version source (#1765)\n//\n// v1 (above) hashes the serialized response body, so a 304 still runs the\n// query — the win is transfer, not the database round trip. v2 derives the\n// ETag from the change feed's per-table version (getTableVersion in\n// ../change-feed) plus the request representation, so a matching If-None-Match\n// short-circuits into a 304 BEFORE the collection query runs. The Cache-Control\n// policy, If-None-Match matching, and 304/200 response shape are all preserved\n// verbatim from v1 — only the ETag SOURCE changes.\n//\n// ## Consistency model (the deliberate cost of zero-query revalidation)\n//\n// Revalidating against a version PROXY instead of the response body — the whole\n// point of \"zero database work\" — means v2 is weakly, not strongly, consistent.\n// Two bounded windows follow, both acceptable for the sites-track read cache\n// this serves; a route that needs strong consistency keeps the v1 body-hash\n// path (which reads the data), as serializer routes do:\n//\n// 1. Write→feed gap. On the autocommit save()/delete() path the data row\n// commits and THEN the afterSave/afterDelete interceptor appends the feed\n// row (a separate statement — see change-feed.ts). A revalidation landing\n// in that sub-statement window reads the pre-write version and can return a\n// stale 304; it self-heals on the next revalidation once the feed advances.\n// (Wrapping save() + append in one transaction would close it, but that is\n// a change-feed write-path concern, not this consumer's.)\n// 2. Shape change without a table write. The ETag reflects the table version\n// and request, NOT the serialization shape. A deploy that changes fields /\n// toPublicJSON / transformJSON / sensitive markings without any table write\n// leaves ETags unchanged, so clients keep the old shape until the table\n// next changes. Deploy-time invalidation — salting the ETag with the\n// manifest/build hash — is version-awareness (#1764) territory; shared-cache\n// operators should purge on a shape-changing deploy in the meantime.\n// ===========================================================================\n\n/**\n * Compute the strong ETag for a generated read from the table's change-feed\n * version and the request representation.\n *\n * Keying the ETag on the representation as well as the version is what keeps\n * two different reads of the SAME table from colliding: `?limit=10` and\n * `?limit=20` share a table version but produce different ETags, so a client\n * caching one can never be wrongly answered `304` for the other. Any write to\n * the table advances its version (see {@link getTableVersion}) and therefore\n * every representation's ETag.\n *\n * The `version:representation` join is injective because `version` is a\n * non-negative integer with no `:` — the first colon unambiguously delimits it\n * from the representation, so `(1, ':x')` and `(1, 'x')` never collide.\n * Deterministic and carrying no per-process state, so it is replica-stable.\n */\nexport function computeTableVersionEtag(\n version: number,\n representation: string,\n): string {\n return `\"${createHash('sha256')\n .update(`${version}:${representation}`)\n .digest('base64url')}\"`;\n}\n\n/**\n * Build a canonical, order-independent representation string for a read\n * request: the URL path plus its query parameters sorted by name, and an\n * optional extra discriminator (e.g. the resolved tenant scope) folded in.\n *\n * Two requests that must return the same body produce the same string (so they\n * share an ETag and revalidate cheaply); any difference that changes the body —\n * a different path, a different filter/limit/offset, or a different tenant —\n * produces a different string and therefore a different ETag.\n *\n * Sorting is by parameter NAME only (a stable sort, so repeated keys keep their\n * original relative order). Sorting by value too would make `?limit=10&limit=20`\n * and `?limit=20&limit=10` canonicalize identically, yet the generated handlers\n * read `searchParams.get('limit')` (the FIRST value) — different reads that must\n * not share an ETag. Name-only sorting keeps different orderings of the same\n * keys distinct while still making `?a=1&b=2` and `?b=2&a=1` equivalent.\n *\n * Names, values, and the extra discriminator are percent-ENCODED before being\n * joined — the `searchParams` entries arrive already decoded, so re-joining them\n * raw with `&`/`=`/`|` would let a value containing those characters collide with\n * a structurally different request (`?q=a%26b=c` vs `?q=a&b=c` both decode-then-\n * rejoin to `q=a&b=c`), a false-304 vector. Encoding makes the string injective.\n */\nexport function canonicalReadRepresentation(\n request: Request,\n extra?: string,\n): string {\n const url = new URL(request.url);\n const params = [...url.searchParams.entries()].sort((a, b) =>\n a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,\n );\n const search = params\n .map(\n ([key, value]) =>\n `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,\n )\n .join('&');\n return `${url.pathname}?${search}${extra ? `|${encodeURIComponent(extra)}` : ''}`;\n}\n\n/**\n * The active tenant folded into a read's ETag representation, or `undefined`\n * when tenancy is not being enforced.\n *\n * This closes a cross-tenant hole specific to per-table version ETags: the\n * table version spans all tenants, so without a tenant component two tenants —\n * or one client switching tenants — would compute the SAME ETag for the same\n * URL. Since tenant-scoped reads are `private, no-cache` (never shared-cached\n * but still browser-cached), a client that viewed tenant A and then switched to\n * tenant B could revalidate B's request with A's cached validator and be\n * wrongly served A's rows from its own cache. Keying the ETag on the active\n * tenant makes A's and B's validators distinct, so the switch forces a fresh\n * `200`. Mirrors the fail-closed dispatch rule: enforced with no context →\n * `global`, so a missing context never collides with a real tenant.\n */\nexport function resolveTenantEtagDiscriminator(): string | undefined {\n const scope = resolveDispatchTenantScope();\n if (!scope.enforced) return undefined;\n return `t:${scope.tenantId ?? 'global'}`;\n}\n\n/**\n * Whether an `If-None-Match` header carries a CONCRETE ETag match — a specific\n * quoted tag equal to `etag` — as opposed to the wildcard `*`.\n *\n * The version fast-path uses this rather than {@link ifNoneMatchSatisfied}\n * because `*` matches unconditionally: per RFC 9110 `*` is satisfied only when a\n * current representation EXISTS, which the pre-query fast-path cannot know. A\n * concrete match, by contrast, can only be held by a client that received it\n * from a prior `200` — and any delete of that row advances the table version,\n * so the concrete ETag would no longer match — making a `304` without the query\n * safe. `*` is deferred until existence is confirmed (see\n * {@link versionConditionalResponse}).\n */\nexport function ifNoneMatchHasConcreteMatch(\n header: string | null | undefined,\n etag: string,\n): boolean {\n if (!header) return false;\n return header.split(',').some((candidate) => {\n const tag = candidate.trim();\n if (tag === '*') return false;\n const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;\n return opaque === etag;\n });\n}\n\n/**\n * Build a generated read response from a precomputed version ETag, skipping the\n * query on a conditional hit (#1765).\n *\n * A CONCRETE `If-None-Match` match returns `304 Not Modified` with an empty body\n * and **never invokes `buildPayload`** — the collection query does not run,\n * which is the point of ETag v2. Otherwise `buildPayload` runs; if it succeeds\n * (a current representation therefore exists) a wildcard `If-None-Match: *` is\n * honored with a `304` — deferring `*` past the build is what stops a\n * `304` from being returned for a row that no longer exists (a `buildPayload`\n * that throws, e.g. a `404` for a missing item, propagates and is never a 304).\n * Mirrors {@link conditionalJsonResponse}'s response shape and header policy.\n */\nexport async function versionConditionalResponse(\n request: Request,\n etag: string,\n cacheControl: string,\n buildPayload: () => unknown | Promise<unknown>,\n): Promise<Response> {\n const notModified = () =>\n new Response(null, {\n status: 304,\n headers: {\n 'Cache-Control': cacheControl,\n ETag: etag,\n },\n });\n\n const ifNoneMatch = request.headers.get('if-none-match');\n if (ifNoneMatchHasConcreteMatch(ifNoneMatch, etag)) {\n return notModified();\n }\n\n const payload = await buildPayload();\n // Existence confirmed by a successful build → honor a wildcard `*` now.\n if (ifNoneMatchSatisfied(ifNoneMatch, etag)) {\n return notModified();\n }\n return new Response(JSON.stringify(payload), {\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 * Emit the v1 body-hash helper (`conditionalJson`, query-first) instead of the\n * v2 version-first `conditionalVersionedRead`. Set when the route's GET handler\n * renders via a CUSTOM serializer whose output can depend on RELATED tables\n * (e.g. content's `serializeContent` loads assets/references): the per-base-\n * table version cannot observe those changes, so a version-derived `304` would\n * serve stale serialized fields. The body hash covers the whole rendered\n * payload, so it stays correct — at the cost of running the query (a\n * transfer-saving 304, not zero-query). The default `toPublicJSON` payload IS a\n * pure function of the base table, so it uses v2.\n */\n useBodyHash?: boolean;\n}\n\n/**\n * Emit the conditional-GET helper inlined into generated SvelteKit route files,\n * following the generator's existing inline-helper convention (auth guard,\n * tenant context, writable policy). The Cache-Control policy is resolved at\n * generation time from the object's `@smrt({ api })` config plus tenant scoping\n * and baked in as a constant.\n *\n * Two shapes, chosen per route by `useBodyHash`:\n * - **v2 (default, #1765)** — `conditionalVersionedRead(request, db, tableName,\n * buildPayload)` derives the ETag from the table's change-feed version\n * ({@link getTableVersion}) keyed by the request representation, so a concrete\n * `If-None-Match` returns a `304` and `buildPayload` — the collection query —\n * never runs. Imports its primitives from `@happyvertical/smrt-core` (the\n * version lookup is dialect-aware SQL that cannot be inlined portably),\n * mirroring the generated `_changes` route. Correct only when the payload is a\n * pure function of the base table — the `toPublicJSON` path.\n * - **v1 (#1757, `useBodyHash`)** — the inlined body-hash `conditionalJson`,\n * used where a custom serializer can pull in related tables the base-table\n * version can't see (see {@link ConditionalGetRouteHelperOptions.useBodyHash}).\n *\n * For tenant-scoped models the v2 representation folds in the active tenant\n * ({@link resolveTenantEtagDiscriminator}) so one tenant's cached validator\n * never satisfies another's read of the same URL — the cross-tenant false-304\n * guard. The v2 runtime behavior is exercised end to end (query observation, 304\n * without a query, mutation bumps the version) by the REST `conditional-get.spec`\n * over the SAME core primitives this route calls.\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 // Serializer-backed routes: the body can depend on related tables the base-\n // table version can't see, so keep the v1 body-hash ETag (query-first but\n // correct). See useBodyHash.\n if (options.useBodyHash) {\n return `\n// Conditional GET (#1757 v1): a strong body-hash ETag over the serialized\n// response — used where a custom serializer can render data from related tables\n// that the per-table change-feed version cannot observe, so the ETag must cover\n// the whole rendered body.\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\n // Tenant-scoped models key the ETag on the active tenant; non-tenant models\n // omit the discriminator (their bodies do not vary by tenant), so the import\n // and the representation argument are conditional on tenant scoping.\n const tenantScoped = options.tenantScoped === true;\n const coreImports = [\n 'canonicalReadRepresentation',\n 'computeTableVersionEtag',\n 'getTableVersion',\n 'ifNoneMatchHasConcreteMatch',\n 'ifNoneMatchSatisfied',\n ...(tenantScoped ? ['resolveTenantEtagDiscriminator'] : []),\n ].join(',\\n ');\n const representationExtra = tenantScoped\n ? 'resolveTenantEtagDiscriminator()'\n : 'undefined';\n\n return `\n// Conditional GET (#1765): the ETag is the table's change-feed version keyed by\n// the request representation, so a CONCRETE If-None-Match returns 304 BEFORE the\n// collection query runs. A wildcard \\`*\\` is honored only after the payload builds\n// (existence confirmed), so a 304 is never returned for a missing row. Reads stay\n// private unless the model is public AND opts into shared caching via\n// @smrt({ api: { cache: { sMaxage } } }).\nimport {\n ${coreImports},\n} from '@happyvertical/smrt-core';\n\nconst READ_CACHE_CONTROL = '${cacheControl}';\n\nasync function conditionalVersionedRead(\n request: Request,\n db: Parameters<typeof getTableVersion>[0],\n tableName: string,\n buildPayload: () => Promise<unknown>,\n): Promise<Response> {\n const version = await getTableVersion(db, tableName);\n const etag = computeTableVersionEtag(\n version,\n canonicalReadRepresentation(request, ${representationExtra}),\n );\n const ifNoneMatch = request.headers.get('if-none-match');\n const notModified = () =>\n new Response(null, {\n status: 304,\n headers: { 'cache-control': READ_CACHE_CONTROL, etag },\n });\n if (ifNoneMatchHasConcreteMatch(ifNoneMatch, etag)) {\n return notModified();\n }\n const payload = await buildPayload();\n // Existence confirmed by a successful build → honor a wildcard \\`*\\` now.\n if (ifNoneMatchSatisfied(ifNoneMatch, etag)) {\n return notModified();\n }\n return new Response(JSON.stringify(payload), {\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":"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"}
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 * - Field-level read-permission models NEVER emit shared-cache headers: their\n * bodies vary with the caller's resolved permission set, which shared caches\n * cannot see. These routes use the v1 body-hash ETag so the validator covers\n * the redacted payload actually returned to that caller.\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';\nimport { resolveDispatchTenantScope } from '../dispatch/tenant-resolver.js';\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 * Whether the response body varies with caller permissions because at least\n * one field has `@field({ readPermission })`. Shared caches key on URL, not\n * user permission sets, so this also fails private regardless of `sMaxage`.\n */\n permissionScoped?: 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 and permission-scoped models are ALWAYS `private, no-cache`\n * regardless of config: their response bodies vary with request identity\n * (tenant or permissions, invisible to URL-keyed shared caches), so shared\n * caching would leak one caller's representation to another caller.\n */\nexport function resolveReadCacheControl(\n apiConfig: unknown,\n options: ReadCacheControlOptions = {},\n): string {\n if (options.tenantScoped || options.permissionScoped) {\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 permissionScoped = false,\n): void {\n if (!tenantScoped && !permissionScoped) return;\n if (requestedSharedCacheControl(apiConfig) === null) return;\n const reasonKey = `${tenantScoped ? 'tenant' : ''}:${permissionScoped ? 'permission' : ''}`;\n const warningKey = `${modelName}:${reasonKey}`;\n if (sharedCacheNeutralizedWarned.has(warningKey)) return;\n sharedCacheNeutralizedWarned.add(warningKey);\n const scopeDescription =\n tenantScoped && permissionScoped\n ? 'tenant/read-permission context'\n : tenantScoped\n ? 'tenant context'\n : 'caller permissions';\n const modelDescription =\n tenantScoped && permissionScoped\n ? 'tenant-scoped/read-permission model'\n : tenantScoped\n ? 'tenant-scoped model'\n : 'read-permission model';\n console.warn(\n `[smrt] api.cache.sMaxage ignored for ${modelDescription} ${modelName}: ` +\n `shared caches cannot key on ${scopeDescription} — 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// ===========================================================================\n// ETag v2: per-table change-feed version source (#1765)\n//\n// v1 (above) hashes the serialized response body, so a 304 still runs the\n// query — the win is transfer, not the database round trip. v2 derives the\n// ETag from the change feed's per-table version (getTableVersion in\n// ../change-feed) plus the request representation, so a matching If-None-Match\n// short-circuits into a 304 BEFORE the collection query runs. The Cache-Control\n// policy, If-None-Match matching, and 304/200 response shape are all preserved\n// verbatim from v1 — only the ETag SOURCE changes.\n//\n// ## Consistency model (the deliberate cost of zero-query revalidation)\n//\n// Revalidating against a version PROXY instead of the response body — the whole\n// point of \"zero database work\" — means v2 is weakly, not strongly, consistent.\n// Two bounded windows follow, both acceptable for the sites-track read cache\n// this serves; a route that needs strong consistency keeps the v1 body-hash\n// path (which reads the data), as serializer routes do:\n//\n// 1. Write→feed gap. On the autocommit save()/delete() path the data row\n// commits and THEN the afterSave/afterDelete interceptor appends the feed\n// row (a separate statement — see change-feed.ts). A revalidation landing\n// in that sub-statement window reads the pre-write version and can return a\n// stale 304; it self-heals on the next revalidation once the feed advances.\n// (Wrapping save() + append in one transaction would close it, but that is\n// a change-feed write-path concern, not this consumer's.)\n// 2. Shape change without a table write. The ETag reflects the table version\n// and request, NOT the serialization shape. A deploy that changes fields /\n// toPublicJSON / transformJSON / sensitive markings without any table write\n// leaves ETags unchanged, so clients keep the old shape until the table\n// next changes. Deploy-time invalidation — salting the ETag with the\n// manifest/build hash — is version-awareness (#1764) territory; shared-cache\n// operators should purge on a shape-changing deploy in the meantime.\n// ===========================================================================\n\n/**\n * Compute the strong ETag for a generated read from the table's change-feed\n * version and the request representation.\n *\n * Keying the ETag on the representation as well as the version is what keeps\n * two different reads of the SAME table from colliding: `?limit=10` and\n * `?limit=20` share a table version but produce different ETags, so a client\n * caching one can never be wrongly answered `304` for the other. Any write to\n * the table advances its version (see {@link getTableVersion}) and therefore\n * every representation's ETag.\n *\n * The `version:representation` join is injective because `version` is a\n * non-negative integer with no `:` — the first colon unambiguously delimits it\n * from the representation, so `(1, ':x')` and `(1, 'x')` never collide.\n * Deterministic and carrying no per-process state, so it is replica-stable.\n */\nexport function computeTableVersionEtag(\n version: number,\n representation: string,\n): string {\n return `\"${createHash('sha256')\n .update(`${version}:${representation}`)\n .digest('base64url')}\"`;\n}\n\n/**\n * Build a canonical, order-independent representation string for a read\n * request: the URL path plus its query parameters sorted by name, and an\n * optional extra discriminator (e.g. the resolved tenant scope) folded in.\n *\n * Two requests that must return the same body produce the same string (so they\n * share an ETag and revalidate cheaply); any difference that changes the body —\n * a different path, a different filter/limit/offset, or a different tenant —\n * produces a different string and therefore a different ETag.\n *\n * Sorting is by parameter NAME only (a stable sort, so repeated keys keep their\n * original relative order). Sorting by value too would make `?limit=10&limit=20`\n * and `?limit=20&limit=10` canonicalize identically, yet the generated handlers\n * read `searchParams.get('limit')` (the FIRST value) — different reads that must\n * not share an ETag. Name-only sorting keeps different orderings of the same\n * keys distinct while still making `?a=1&b=2` and `?b=2&a=1` equivalent.\n *\n * Names, values, and the extra discriminator are percent-ENCODED before being\n * joined — the `searchParams` entries arrive already decoded, so re-joining them\n * raw with `&`/`=`/`|` would let a value containing those characters collide with\n * a structurally different request (`?q=a%26b=c` vs `?q=a&b=c` both decode-then-\n * rejoin to `q=a&b=c`), a false-304 vector. Encoding makes the string injective.\n */\nexport function canonicalReadRepresentation(\n request: Request,\n extra?: string,\n): string {\n const url = new URL(request.url);\n const params = [...url.searchParams.entries()].sort((a, b) =>\n a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,\n );\n const search = params\n .map(\n ([key, value]) =>\n `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,\n )\n .join('&');\n return `${url.pathname}?${search}${extra ? `|${encodeURIComponent(extra)}` : ''}`;\n}\n\n/**\n * The active tenant folded into a read's ETag representation, or `undefined`\n * when tenancy is not being enforced.\n *\n * This closes a cross-tenant hole specific to per-table version ETags: the\n * table version spans all tenants, so without a tenant component two tenants —\n * or one client switching tenants — would compute the SAME ETag for the same\n * URL. Since tenant-scoped reads are `private, no-cache` (never shared-cached\n * but still browser-cached), a client that viewed tenant A and then switched to\n * tenant B could revalidate B's request with A's cached validator and be\n * wrongly served A's rows from its own cache. Keying the ETag on the active\n * tenant makes A's and B's validators distinct, so the switch forces a fresh\n * `200`. Mirrors the fail-closed dispatch rule: enforced with no context →\n * `global`, so a missing context never collides with a real tenant.\n */\nexport function resolveTenantEtagDiscriminator(): string | undefined {\n const scope = resolveDispatchTenantScope();\n if (!scope.enforced) return undefined;\n return `t:${scope.tenantId ?? 'global'}`;\n}\n\n/**\n * Whether an `If-None-Match` header carries a CONCRETE ETag match — a specific\n * quoted tag equal to `etag` — as opposed to the wildcard `*`.\n *\n * The version fast-path uses this rather than {@link ifNoneMatchSatisfied}\n * because `*` matches unconditionally: per RFC 9110 `*` is satisfied only when a\n * current representation EXISTS, which the pre-query fast-path cannot know. A\n * concrete match, by contrast, can only be held by a client that received it\n * from a prior `200` — and any delete of that row advances the table version,\n * so the concrete ETag would no longer match — making a `304` without the query\n * safe. `*` is deferred until existence is confirmed (see\n * {@link versionConditionalResponse}).\n */\nexport function ifNoneMatchHasConcreteMatch(\n header: string | null | undefined,\n etag: string,\n): boolean {\n if (!header) return false;\n return header.split(',').some((candidate) => {\n const tag = candidate.trim();\n if (tag === '*') return false;\n const opaque = tag.startsWith('W/') ? tag.slice(2) : tag;\n return opaque === etag;\n });\n}\n\n/**\n * Build a generated read response from a precomputed version ETag, skipping the\n * query on a conditional hit (#1765).\n *\n * A CONCRETE `If-None-Match` match returns `304 Not Modified` with an empty body\n * and **never invokes `buildPayload`** — the collection query does not run,\n * which is the point of ETag v2. Otherwise `buildPayload` runs; if it succeeds\n * (a current representation therefore exists) a wildcard `If-None-Match: *` is\n * honored with a `304` — deferring `*` past the build is what stops a\n * `304` from being returned for a row that no longer exists (a `buildPayload`\n * that throws, e.g. a `404` for a missing item, propagates and is never a 304).\n * Mirrors {@link conditionalJsonResponse}'s response shape and header policy.\n */\nexport async function versionConditionalResponse(\n request: Request,\n etag: string,\n cacheControl: string,\n buildPayload: () => unknown | Promise<unknown>,\n): Promise<Response> {\n const notModified = () =>\n new Response(null, {\n status: 304,\n headers: {\n 'Cache-Control': cacheControl,\n ETag: etag,\n },\n });\n\n const ifNoneMatch = request.headers.get('if-none-match');\n if (ifNoneMatchHasConcreteMatch(ifNoneMatch, etag)) {\n return notModified();\n }\n\n const payload = await buildPayload();\n // Existence confirmed by a successful build → honor a wildcard `*` now.\n if (ifNoneMatchSatisfied(ifNoneMatch, etag)) {\n return notModified();\n }\n return new Response(JSON.stringify(payload), {\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 * Emit the v1 body-hash helper (`conditionalJson`, query-first) instead of the\n * v2 version-first `conditionalVersionedRead`. Set when the route's GET handler\n * renders via a CUSTOM serializer whose output can depend on RELATED tables\n * (e.g. content's `serializeContent` loads assets/references): the per-base-\n * table version cannot observe those changes, so a version-derived `304` would\n * serve stale serialized fields. The body hash covers the whole rendered\n * payload, so it stays correct — at the cost of running the query (a\n * transfer-saving 304, not zero-query). The default `toPublicJSON` payload IS a\n * pure function of the base table, so it uses v2.\n */\n useBodyHash?: boolean;\n}\n\n/**\n * Emit the conditional-GET helper inlined into generated SvelteKit route files,\n * following the generator's existing inline-helper convention (auth guard,\n * tenant context, writable policy). The Cache-Control policy is resolved at\n * generation time from the object's `@smrt({ api })` config plus tenant scoping\n * and baked in as a constant.\n *\n * Two shapes, chosen per route by `useBodyHash`:\n * - **v2 (default, #1765)** — `conditionalVersionedRead(request, db, tableName,\n * buildPayload)` derives the ETag from the table's change-feed version\n * ({@link getTableVersion}) keyed by the request representation, so a concrete\n * `If-None-Match` returns a `304` and `buildPayload` — the collection query —\n * never runs. Imports its primitives from `@happyvertical/smrt-core` (the\n * version lookup is dialect-aware SQL that cannot be inlined portably),\n * mirroring the generated `_changes` route. Correct only when the payload is a\n * pure function of the base table — the `toPublicJSON` path.\n * - **v1 (#1757, `useBodyHash`)** — the inlined body-hash `conditionalJson`,\n * used where a custom serializer can pull in related tables the base-table\n * version can't see, or where `@field({ readPermission })` means the body\n * differs by caller permissions.\n *\n * For tenant-scoped models the v2 representation folds in the active tenant\n * ({@link resolveTenantEtagDiscriminator}) so one tenant's cached validator\n * never satisfies another's read of the same URL — the cross-tenant false-304\n * guard. The v2 runtime behavior is exercised end to end (query observation, 304\n * without a query, mutation bumps the version) by the REST `conditional-get.spec`\n * over the SAME core primitives this route calls.\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 options.permissionScoped === true,\n );\n warnIfTenantScopedPublicRead(\n options.modelName,\n apiConfig,\n options.tenantScoped === true,\n );\n }\n\n // Serializer-backed or permission-scoped routes: the body can depend on data\n // the base-table version representation cannot see, so keep the v1 body-hash\n // ETag (query-first but correct). See useBodyHash.\n const useBodyHash = options.useBodyHash || options.permissionScoped === true;\n if (useBodyHash) {\n return `\n// Conditional GET (#1757 v1): a strong body-hash ETag over the serialized\n// response — used where a custom serializer can render data from related tables\n// that the per-table change-feed version cannot observe, so the ETag must cover\n// the whole rendered body.\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\n // Tenant-scoped models key the ETag on the active tenant; non-tenant models\n // omit the discriminator (their bodies do not vary by tenant), so the import\n // and the representation argument are conditional on tenant scoping.\n const tenantScoped = options.tenantScoped === true;\n const coreImports = [\n 'canonicalReadRepresentation',\n 'computeTableVersionEtag',\n 'getTableVersion',\n 'ifNoneMatchHasConcreteMatch',\n 'ifNoneMatchSatisfied',\n ...(tenantScoped ? ['resolveTenantEtagDiscriminator'] : []),\n ].join(',\\n ');\n const representationExtra = tenantScoped\n ? 'resolveTenantEtagDiscriminator()'\n : 'undefined';\n\n return `\n// Conditional GET (#1765): the ETag is the table's change-feed version keyed by\n// the request representation, so a CONCRETE If-None-Match returns 304 BEFORE the\n// collection query runs. A wildcard \\`*\\` is honored only after the payload builds\n// (existence confirmed), so a 304 is never returned for a missing row. Reads stay\n// private unless the model is public AND opts into shared caching via\n// @smrt({ api: { cache: { sMaxage } } }).\nimport {\n ${coreImports},\n} from '@happyvertical/smrt-core';\n\nconst READ_CACHE_CONTROL = '${cacheControl}';\n\nasync function conditionalVersionedRead(\n request: Request,\n db: Parameters<typeof getTableVersion>[0],\n tableName: string,\n buildPayload: () => Promise<unknown>,\n): Promise<Response> {\n const version = await getTableVersion(db, tableName);\n const etag = computeTableVersionEtag(\n version,\n canonicalReadRepresentation(request, ${representationExtra}),\n );\n const ifNoneMatch = request.headers.get('if-none-match');\n const notModified = () =>\n new Response(null, {\n status: 304,\n headers: { 'cache-control': READ_CACHE_CONTROL, etag },\n });\n if (ifNoneMatchHasConcreteMatch(ifNoneMatch, etag)) {\n return notModified();\n }\n const payload = await buildPayload();\n // Existence confirmed by a successful build → honor a wildcard \\`*\\` now.\n if (ifNoneMatchSatisfied(ifNoneMatch, etag)) {\n return notModified();\n }\n return new Response(JSON.stringify(payload), {\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":"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"}
@@ -0,0 +1,72 @@
1
+ import { DatabaseInterface } from '@happyvertical/sql';
2
+ import { ChangeSignal } from '../change-signals.js';
3
+ import { DispatchTenantScope } from '../dispatch/tenant-resolver.js';
4
+ import { ChangesAuthMiddleware } from './changes-route.js';
5
+ /** Options for the `_events` route handler. */
6
+ export interface EventsRouteOptions {
7
+ /** The generator's configured auth middleware, if any. */
8
+ authMiddleware?: ChangesAuthMiddleware;
9
+ /** The generator's `APIContext.db` (instance, config object, or URL string). */
10
+ db?: unknown;
11
+ }
12
+ /**
13
+ * Pseudo object name passed to the auth middleware for the events route, so
14
+ * middlewares can recognize and specially authorize it (mirrors `_changes`).
15
+ */
16
+ export declare const EVENTS_ROUTE_OBJECT_NAME = "_events";
17
+ /** Default heartbeat interval (ms). Overridable via stream options. */
18
+ export declare const DEFAULT_EVENTS_HEARTBEAT_MS = 15000;
19
+ /** Options for {@link buildChangeEventStream}. */
20
+ export interface ChangeEventStreamOptions {
21
+ /**
22
+ * Catch-up cursor. When a non-negative number, changes after it are replayed
23
+ * before going live; `null` means live-forward only (no catch-up).
24
+ */
25
+ cursor: number | null;
26
+ /**
27
+ * Tenant scope captured at connection open. Delivery filters against this
28
+ * fixed value — it must NOT be re-resolved per signal (delivery runs outside
29
+ * any tenant ALS context).
30
+ */
31
+ tenantScope: DispatchTenantScope;
32
+ /** Heartbeat interval (ms). Defaults to {@link DEFAULT_EVENTS_HEARTBEAT_MS}. */
33
+ heartbeatMs?: number;
34
+ }
35
+ /**
36
+ * Whether a signal is visible to a captured tenant scope. Exact same rule as
37
+ * `getChangesSince`'s tenantId filter, run **synchronously server-side** inside
38
+ * the enqueue callback before any byte hits the wire:
39
+ * - not enforced → visible.
40
+ * - enforced, no active tenant (`tenantId === null`) → only global signals.
41
+ * - enforced, tenant `T` → `T`'s signals plus global signals.
42
+ */
43
+ export declare function signalVisibleToTenant(sig: ChangeSignal, scope: DispatchTenantScope): boolean;
44
+ /**
45
+ * Build the SSE body stream for an `_events` connection.
46
+ *
47
+ * `start(controller)`:
48
+ * a. **Subscribe FIRST**, before catch-up. Subscribing before the catch-up
49
+ * read closes the gap window: a write landing between subscribe and the
50
+ * catch-up read is delivered twice (once live, once in the replay) — which
51
+ * is safe, since the client dedupes by the SSE `id:`/seq.
52
+ * b. Write the `retry:` reconnection hint.
53
+ * c. If a cursor was supplied, replay changes after it (paging until
54
+ * exhausted); on `resyncRequired`, emit `event: resync` at the server's
55
+ * fresh horizon.
56
+ * d. Start the heartbeat interval.
57
+ *
58
+ * `cancel()` tears down on disconnect: clears the heartbeat and unsubscribes,
59
+ * so a dropped client never leaks its subscription (which would pin the dead
60
+ * controller and keep the cross-replica listener refcount above 0).
61
+ */
62
+ export declare function buildChangeEventStream(db: DatabaseInterface, options: ChangeEventStreamOptions): ReadableStream<Uint8Array>;
63
+ /**
64
+ * Handle a request against the generated `_events` route.
65
+ *
66
+ * Returns 405 for non-GET; 401 when no auth middleware is configured
67
+ * (fail-closed) or the middleware rejects; 503 when the generator has no
68
+ * database; otherwise a 200 `text/event-stream` response whose body is the
69
+ * live signal stream (built by {@link buildChangeEventStream}).
70
+ */
71
+ export declare function handleEventsRoute(req: Request, options: EventsRouteOptions): Promise<Response>;
72
+ //# sourceMappingURL=events-route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events-route.d.ts","sourceRoot":"","sources":["../../src/generators/events-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,EACL,KAAK,YAAY,EAElB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,KAAK,mBAAmB,EAEzB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,KAAK,qBAAqB,EAE3B,MAAM,oBAAoB,CAAC;AAI5B,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,0DAA0D;IAC1D,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,gFAAgF;IAChF,EAAE,CAAC,EAAE,OAAO,CAAC;CACd;AAED;;;GAGG;AACH,eAAO,MAAM,wBAAwB,YAAY,CAAC;AAElD,uEAAuE;AACvE,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AAIjD,kDAAkD;AAClD,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB;;;;OAIG;IACH,WAAW,EAAE,mBAAmB,CAAC;IACjC,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAIT;AA2BD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,iBAAiB,EACrB,OAAO,EAAE,wBAAwB,GAChC,cAAc,CAAC,UAAU,CAAC,CA8H5B;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,QAAQ,CAAC,CAyDnB"}