@warlock.js/core 5.16.0 → 5.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/esm/cli/commands/migrate.command.mjs +5 -1
  3. package/esm/cli/commands/migrate.command.mjs.map +1 -1
  4. package/esm/cli/commands/seed.command.mjs +2 -1
  5. package/esm/cli/commands/seed.command.mjs.map +1 -1
  6. package/esm/cli/commands/typings-generator.command.mjs +16 -23
  7. package/esm/cli/commands/typings-generator.command.mjs.map +1 -1
  8. package/esm/config/locale-configuration.mjs +7 -1
  9. package/esm/config/locale-configuration.mjs.map +1 -1
  10. package/esm/connectors/http-connector.d.mts.map +1 -1
  11. package/esm/connectors/http-connector.mjs +1 -18
  12. package/esm/connectors/http-connector.mjs.map +1 -1
  13. package/esm/connectors/read-bound-port.mjs +32 -0
  14. package/esm/connectors/read-bound-port.mjs.map +1 -0
  15. package/esm/dev-server/development-server.mjs +6 -2
  16. package/esm/dev-server/development-server.mjs.map +1 -1
  17. package/esm/dev-server/extract-translation-keys.mjs +68 -0
  18. package/esm/dev-server/extract-translation-keys.mjs.map +1 -0
  19. package/esm/dev-server/health-checker/checkers/typescript-health-checker.mjs +5 -1
  20. package/esm/dev-server/health-checker/checkers/typescript-health-checker.mjs.map +1 -1
  21. package/esm/dev-server/health-checker/workers/ts-health.worker.mjs +37 -8
  22. package/esm/dev-server/health-checker/workers/ts-health.worker.mjs.map +1 -1
  23. package/esm/dev-server/translation-keys-sources.mjs +19 -0
  24. package/esm/dev-server/translation-keys-sources.mjs.map +1 -0
  25. package/esm/dev-server/type-generator.mjs +64 -11
  26. package/esm/dev-server/type-generator.mjs.map +1 -1
  27. package/esm/generations/stubs.mjs +7 -6
  28. package/esm/generations/stubs.mjs.map +1 -1
  29. package/esm/http/csrf-default-guard.d.mts +32 -0
  30. package/esm/http/csrf-default-guard.d.mts.map +1 -0
  31. package/esm/http/csrf-default-guard.mjs +156 -0
  32. package/esm/http/csrf-default-guard.mjs.map +1 -0
  33. package/esm/http/csrf-origin-policy.d.mts +53 -0
  34. package/esm/http/csrf-origin-policy.d.mts.map +1 -0
  35. package/esm/http/csrf-origin-policy.mjs +104 -0
  36. package/esm/http/csrf-origin-policy.mjs.map +1 -0
  37. package/esm/http/error-codes.d.mts +14 -1
  38. package/esm/http/error-codes.d.mts.map +1 -1
  39. package/esm/http/error-codes.mjs +13 -0
  40. package/esm/http/error-codes.mjs.map +1 -1
  41. package/esm/http/index.d.mts +7 -1
  42. package/esm/http/index.mjs +7 -1
  43. package/esm/http/middleware/concurrency-limit.middleware.mjs +1 -1
  44. package/esm/http/middleware/idempotency.middleware.mjs +1 -1
  45. package/esm/http/middleware/inject-request-context.d.mts.map +1 -1
  46. package/esm/http/middleware/inject-request-context.mjs +6 -0
  47. package/esm/http/middleware/inject-request-context.mjs.map +1 -1
  48. package/esm/http/middleware/ip-filter.middleware.mjs +1 -1
  49. package/esm/http/middleware/maintenance.middleware.mjs +1 -1
  50. package/esm/http/middleware/max-body-size.middleware.mjs +1 -1
  51. package/esm/http/middleware/rate-limit.middleware.mjs +1 -1
  52. package/esm/http/request.d.mts +2 -2
  53. package/esm/http/request.d.mts.map +1 -1
  54. package/esm/http/request.mjs +7 -3
  55. package/esm/http/request.mjs.map +1 -1
  56. package/esm/http/response.d.mts +3 -3
  57. package/esm/http/response.d.mts.map +1 -1
  58. package/esm/http/response.mjs +7 -1
  59. package/esm/http/response.mjs.map +1 -1
  60. package/esm/http/uploads/detect-image-format.mjs +59 -0
  61. package/esm/http/uploads/detect-image-format.mjs.map +1 -0
  62. package/esm/http/uploads/generate-image-variant.mjs +46 -0
  63. package/esm/http/uploads/generate-image-variant.mjs.map +1 -0
  64. package/esm/http/uploads/generate-image-variants.d.mts +49 -0
  65. package/esm/http/uploads/generate-image-variants.d.mts.map +1 -0
  66. package/esm/http/uploads/generate-image-variants.mjs +141 -0
  67. package/esm/http/uploads/generate-image-variants.mjs.map +1 -0
  68. package/esm/http/uploads/image-variant-types.d.mts +93 -0
  69. package/esm/http/uploads/image-variant-types.d.mts.map +1 -0
  70. package/esm/http/uploads/image-variants-config-error.d.mts +13 -0
  71. package/esm/http/uploads/image-variants-config-error.d.mts.map +1 -0
  72. package/esm/http/uploads/image-variants-config-error.mjs +17 -0
  73. package/esm/http/uploads/image-variants-config-error.mjs.map +1 -0
  74. package/esm/http/uploads/index.d.mts +4 -0
  75. package/esm/http/uploads/index.mjs +5 -0
  76. package/esm/http/uploads/is-path-inside.mjs +26 -0
  77. package/esm/http/uploads/is-path-inside.mjs.map +1 -0
  78. package/esm/http/uploads/load-variant-source.mjs +42 -0
  79. package/esm/http/uploads/load-variant-source.mjs.map +1 -0
  80. package/esm/http/uploads/matches-if-none-match.mjs +13 -0
  81. package/esm/http/uploads/matches-if-none-match.mjs.map +1 -0
  82. package/esm/http/uploads/parse-uploaded-file-query.mjs +51 -0
  83. package/esm/http/uploads/parse-uploaded-file-query.mjs.map +1 -0
  84. package/esm/http/uploads/read-file-head.mjs +20 -0
  85. package/esm/http/uploads/read-file-head.mjs.map +1 -0
  86. package/esm/http/uploads/resolve-image-variants-config.mjs +91 -0
  87. package/esm/http/uploads/resolve-image-variants-config.mjs.map +1 -0
  88. package/esm/http/uploads/resolve-inline-image-content-type.mjs +53 -0
  89. package/esm/http/uploads/resolve-inline-image-content-type.mjs.map +1 -0
  90. package/esm/http/uploads/resolve-original-content-type.mjs +31 -0
  91. package/esm/http/uploads/resolve-original-content-type.mjs.map +1 -0
  92. package/esm/http/uploads/resolve-upload-path.mjs +56 -0
  93. package/esm/http/uploads/resolve-upload-path.mjs.map +1 -0
  94. package/esm/http/uploads/resolve-variant-candidate.mjs +39 -0
  95. package/esm/http/uploads/resolve-variant-candidate.mjs.map +1 -0
  96. package/esm/http/uploads/single-flight.mjs +40 -0
  97. package/esm/http/uploads/single-flight.mjs.map +1 -0
  98. package/esm/http/uploads/uploaded-file.controller.d.mts +24 -0
  99. package/esm/http/uploads/uploaded-file.controller.d.mts.map +1 -0
  100. package/esm/http/uploads/uploaded-file.controller.mjs +174 -0
  101. package/esm/http/uploads/uploaded-file.controller.mjs.map +1 -0
  102. package/esm/http/uploads/variant-cache-key.mjs +45 -0
  103. package/esm/http/uploads/variant-cache-key.mjs.map +1 -0
  104. package/esm/http/uploads/variant-generations.mjs +16 -0
  105. package/esm/http/uploads/variant-generations.mjs.map +1 -0
  106. package/esm/http/uploads-types.d.mts +102 -1
  107. package/esm/http/uploads-types.d.mts.map +1 -1
  108. package/esm/index.d.mts +9 -2
  109. package/esm/index.mjs +8 -2
  110. package/esm/router/types.d.mts +23 -0
  111. package/esm/router/types.d.mts.map +1 -1
  112. package/esm/storage/index.d.mts +1 -0
  113. package/esm/storage/index.mjs +1 -0
  114. package/esm/storage/scoped-storage.d.mts +1 -0
  115. package/esm/storage/scoped-storage.d.mts.map +1 -1
  116. package/esm/storage/scoped-storage.mjs +3 -0
  117. package/esm/storage/scoped-storage.mjs.map +1 -1
  118. package/esm/storage/storage.d.mts +2 -0
  119. package/esm/storage/storage.d.mts.map +1 -1
  120. package/esm/storage/storage.mjs +4 -0
  121. package/esm/storage/storage.mjs.map +1 -1
  122. package/esm/storage/utils/storage-not-initialized-error.d.mts +22 -0
  123. package/esm/storage/utils/storage-not-initialized-error.d.mts.map +1 -0
  124. package/esm/storage/utils/storage-not-initialized-error.mjs +24 -0
  125. package/esm/storage/utils/storage-not-initialized-error.mjs.map +1 -0
  126. package/llms-full.txt +17 -1
  127. package/package.json +22 -21
  128. package/skills/use-localization/SKILL.md +16 -0
  129. package/skills/use-middleware/SKILL.md +1 -1
  130. package/esm/dev-server/translation-type-generator.mjs +0 -28
  131. package/esm/dev-server/translation-type-generator.mjs.map +0 -1
@@ -724,7 +724,7 @@ import { setCurrentLocaleCode } from "@mongez/localization";
724
724
  import { Form, useFormControl, type FormControlProps } from "@mongez/react-form";
725
725
  import { transX } from "@mongez/react-localization";
726
726
  import { v } from "@warlock.js/seal";
727
- import { Link, type PageProps } from "@warlock.js/web";
727
+ import { Link, type PageConfig, type PageProps } from "@warlock.js/web";
728
728
  import { useState } from "react";
729
729
 
730
730
  export { register } from "./index.register";
@@ -734,13 +734,14 @@ export { register } from "./index.register";
734
734
  * instead of returning JSON.
735
735
  *
736
736
  * The URL and stable hydration name are the ones this file DECLARES below.
737
- * This page answers \`GET "/"\` because \`route.path = "/"\`, not because of
737
+ * This page answers \`GET "/"\` because \`config.route.path = "/"\`, not because of
738
738
  * where the file lives. A page file with
739
- * no \`route\` export is REFUSED by both the dev server and the build.
739
+ * no \`config.route\` declaration derives its URL from the filesystem.
740
740
  */
741
- export const route = { path: "/", name: "index" } as const;
742
-
743
- export const metadata = { title: "Home" };
741
+ export const config = {
742
+ route: { path: "/", name: "index" },
743
+ metadata: { title: "Home" },
744
+ } as const satisfies PageConfig;
744
745
 
745
746
  const contactSchema = v.object({
746
747
  name: v.string().min(2),
@@ -1 +1 @@
1
- {"version":3,"file":"stubs.mjs","names":[],"sources":["../../../../../../../core/src/generations/stubs.ts"],"sourcesContent":["export const accessConfigStub = `import { type AccessConfigurations } from \"@warlock.js/access\";\nimport { DatabaseAccessResolver } from \"app/access/services/access-resolver\";\n\n/**\n * Authorization configuration — read by @warlock.js/access on boot.\n *\n * The resolver is the one required piece: it tells the engine how to read a\n * user's roles + permissions. The ejected DatabaseAccessResolver reads roles\n * from the user_roles table and maps them through the roles catalog table (so\n * roles + their permissions are managed at runtime, in the DB).\n *\n * For a fixed, code-defined catalog with no tables, swap in DefaultAccessResolver:\n * import { DefaultAccessResolver } from \"@warlock.js/access\";\n * resolver: new DefaultAccessResolver({ admin: [\"*\"], editor: [\"orders.*\"] }),\n *\n * Multi-tenant? Add a \\`resolveTenant()\\` to the resolver to read the active\n * tenant from the request; checks then scope to it automatically.\n */\nconst access: AccessConfigurations = {\n resolver: new DatabaseAccessResolver(),\n\n // Cache resolved permission sets (default \"10m\").\n // cache: { ttl: \"10m\" },\n};\n\nexport default access;\n`;\n\nexport const aiConfigStub = `import type { AIConfig } from \"@warlock.js/ai\";\n\n// >>> warlock:ai-packages (auto-managed) >>>\n// Satellite packages augment the \"ai\" object on import — e.g. ai.workspace,\n// ai.tools / ai.mcp, and panoptic's ai.config({ panoptic }) wiring. The command\n// \"warlock add ai-workspace | ai-tools | ai-panoptic\" adds the matching\n// side-effect import below; keep them so the augmentation + runtime registration\n// load before the ai connector applies this config.\n// <<< warlock:ai-packages <<<\n\n/**\n * AI configuration — applied on boot by the ai connector, which calls\n * ai.config(...) with the object below. Cross-cutting defaults live here\n * (shared cache / snapshot stores, observability); per-call options always win.\n *\n * Wire a default model from a provider you installed, e.g.:\n * import { OpenAISDK } from \"@warlock.js/ai-openai\";\n * const openai = OpenAISDK({ apiKey: env(\"OPENAI_API_KEY\") });\n * // then pass openai.model({ name: \"gpt-4o-mini\" }) into your agents.\n */\nconst ai: Partial<AIConfig> = {\n // Default cache driver for cache-backed AI features (semantic cache, rag / memory vector stores).\n // defaultStore: cache.driver(\"redis\", { client }),\n\n // Observability — requires \"warlock add ai-panoptic\". Exporters + the local dashboard.\n // panoptic: { exporters: [], dashboard: false, observeAll: false },\n};\n\nexport default ai;\n`;\n\nexport const accessRoleModelStub = `import { Model, RegisterModel } from \"@warlock.js/cascade\";\nimport { type Infer, v } from \"@warlock.js/seal\";\n\n/**\n * Validation schema for the roles catalog — mirrors the migration columns\n * (snake_case). Each row is a role name plus the permission strings it grants;\n * wildcards work (\"orders.*\", \"*\"). The DatabaseAccessResolver maps a user's\n * assigned role names through this table to their effective permissions.\n */\nexport const roleSchema = v.object({\n name: v.string(),\n permissions: v.array(v.string()).default([]),\n});\n\nexport type RoleSchema = Infer<typeof roleSchema>;\n\n/**\n * The roles catalog — role name → the permissions it grants. Managed at runtime\n * (admins add roles + edit their permissions), unlike a fixed code map. Read by\n * DatabaseAccessResolver.resolvePermissions to expand a user's roles to permissions.\n */\n@RegisterModel()\nexport class Role extends Model<RoleSchema> {\n public static table = \"roles\";\n\n public static schema = roleSchema;\n\n /** The permission strings this role grants. */\n public get permissions(): string[] {\n return this.get<string[]>(\"permissions\", []);\n }\n}\n`;\n\nexport const accessRoleModelIndexStub = `export * from \"./role.model\";\n`;\n\nexport const accessRoleMigrationStub = `import { arrayText, Migration, text } from \"@warlock.js/cascade\";\nimport { Role } from \"../role.model\";\n\n/**\n * Roles catalog table. \\`name\\` is unique (one row per role); \\`permissions\\` is a\n * text array of the permission strings the role grants.\n */\nexport default Migration.create(Role, {\n name: text().notNullable().unique(),\n permissions: arrayText().nullable(),\n});\n`;\n\nexport const accessUserRoleModelStub = `import { access } from \"@warlock.js/access\";\nimport type { Auth } from \"@warlock.js/auth\";\nimport { Model, RegisterModel } from \"@warlock.js/cascade\";\nimport { type Infer, v } from \"@warlock.js/seal\";\n\n/**\n * Validation schema for a role assignment — mirrors the migration columns\n * (snake_case). \\`tenant\\` is nullable: a null tenant is a GLOBAL assignment.\n */\nexport const userRoleSchema = v.object({\n user_id: v.string(),\n user_type: v.string(),\n role: v.string(),\n tenant: v.string().optional(),\n});\n\nexport type UserRoleSchema = Infer<typeof userRoleSchema>;\n\n/**\n * The role-assignment table — which roles a user holds, optionally per tenant.\n * Read by DatabaseAccessResolver.resolveRoles; mutated via the statics below.\n * \\`assign\\` / \\`revoke\\` flush the cached permission set automatically, so callers\n * never need to call \\`access.flush(user, tenant)\\` themselves.\n */\n@RegisterModel()\nexport class UserRole extends Model<UserRoleSchema> {\n public static table = \"user_roles\";\n\n public static schema = userRoleSchema;\n\n /**\n * Role names assigned to the user in the given tenant.\n *\n * An unresolved tenant (\\`undefined\\`) scopes to GLOBAL roles only — the rows\n * stored with no tenant (\\`null\\`) — never the union across every tenant. The\n * union would be a privilege-escalation: a user who is \\`owner\\` in one tenant\n * must not be treated as \\`owner\\` everywhere just because a check didn't carry\n * a tenant. This mirrors how \\`assign(user, role)\\` stores a global row.\n */\n public static async rolesFor(user: Auth, tenant?: string): Promise<string[]> {\n const rows = await this.query()\n .where({\n user_id: user.id,\n user_type: user.userType,\n tenant: tenant ?? null,\n })\n .get();\n\n // De-dupe so a duplicate row (a concurrent assign that slipped past the\n // existence check) can't distort the resolved set.\n return [...new Set(rows.map((row) => row.get(\"role\") as string))];\n }\n\n /**\n * Assign a role to the user. No-op if the assignment already exists.\n * Flushes the user's cached permission set automatically.\n */\n public static async assign(user: Auth, role: string, tenant?: string): Promise<void> {\n const existing = await this.first({\n user_id: user.id,\n user_type: user.userType,\n role,\n tenant: tenant ?? null,\n });\n\n if (existing) return;\n\n await this.create({\n user_id: user.id,\n user_type: user.userType,\n role,\n tenant,\n });\n\n await access.flush(user, tenant);\n }\n\n /**\n * Remove a role assignment from the user.\n * Flushes the user's cached permission set automatically.\n */\n public static async revoke(user: Auth, role: string, tenant?: string): Promise<void> {\n await this.delete({\n user_id: user.id,\n user_type: user.userType,\n role,\n tenant: tenant ?? null,\n });\n\n await access.flush(user, tenant);\n }\n}\n`;\n\nexport const accessUserRoleModelIndexStub = `export * from \"./user-role.model\";\n`;\n\nexport const accessUserRoleMigrationStub = `import { Migration, text, uuid } from \"@warlock.js/cascade\";\nimport { UserRole } from \"../user-role.model\";\n\n/**\n * Role-assignment table. \\`user_id\\` is a UUID — override this migration if your\n * user ids are integers. The composite index powers the per-user (per-tenant)\n * lookup the resolver runs on every check.\n */\nexport default Migration.create(\n UserRole,\n {\n user_id: uuid().notNullable().index(),\n user_type: text().notNullable(),\n role: text().notNullable().index(),\n tenant: text().nullable().index(),\n },\n {\n index: [{ columns: [\"user_id\", \"user_type\", \"tenant\"] }],\n },\n);\n`;\n\nexport const accessResolverStub = `import type { AccessResolver } from \"@warlock.js/access\";\nimport type { Auth } from \"@warlock.js/auth\";\nimport { Role } from \"app/access/models/role\";\nimport { UserRole } from \"app/access/models/user-role\";\n\n/**\n * The app's access adapter — connects @warlock.js/access to the ejected role\n * tables. Roles come from the user_roles assignment table; permissions are\n * expanded by mapping those role names through the roles catalog table. Both\n * are managed at runtime (in the DB), so admins can add roles + edit their\n * permissions without a deploy.\n *\n * The engine owns the hard parts (wildcard matching, caching, fail-closed); this\n * resolver only fetches — keep it dumb, never cache inside it.\n */\nexport class DatabaseAccessResolver implements AccessResolver {\n /** The role names this user holds (powers \\`hasRole\\` / \\`hasAnyRole\\`). */\n public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {\n return UserRole.rolesFor(user, tenant);\n }\n\n /** The effective permission strings this user has (powers \\`can\\` / \\`authorize\\`). */\n public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {\n const names = await this.resolveRoles(user, tenant);\n\n if (names.length === 0) return [];\n\n const roles = await Role.query().whereIn(\"name\", names).get();\n\n // Flatten + de-dupe so two roles granting the same permission yield one entry.\n return [...new Set(roles.flatMap((role) => role.permissions))];\n }\n\n /**\n * Optional. Resolve the ambient tenant when a check doesn't pass one\n * explicitly — derive it from the authenticated user (safer than reading\n * client request input, which a caller could spoof). Uncomment + adapt for a\n * multi-tenant app (single-tenant apps leave this off and return undefined).\n */\n // public resolveTenant(user: Auth): string | undefined {\n // return user.get(\"organization_id\");\n // }\n}\n`;\n\nexport const socketConfigStub = `import type { SocketOptions } from \"@warlock.js/core\";\n\n/**\n * Socket.IO configuration — read by the framework's socket connector\n * on boot. When the HTTP server is running the socket server attaches\n * to it; otherwise it listens on its own configured port.\n *\n * Remove this file to disable the socket server entirely.\n */\nexport default {\n options: {\n cors: {\n origin: \"*\",\n },\n },\n} as SocketOptions;\n`;\n\nexport const communicatorsConfigStub = `import { env } from \"@warlock.js/core\";\nimport type { BrokerConfigurations, RabbitMQClientOptions } from \"@warlock.js/herald\";\n\nconst heraldConfigurations: BrokerConfigurations<RabbitMQClientOptions> = {\n driver: \"rabbitmq\",\n name: \"default\",\n isDefault: true,\n\n // ============================================================================\n // Connection Settings\n // ============================================================================\n\n host: env(\"RABBITMQ_HOST\", \"localhost\"),\n port: env(\"RABBITMQ_PORT\", 5672),\n username: env(\"RABBITMQ_USERNAME\", \"guest\"),\n password: env(\"RABBITMQ_PASSWORD\", \"guest\"),\n vhost: env(\"RABBITMQ_VHOST\", \"/\"),\n\n // Or use connection URI (takes precedence over host/port)\n // uri: env(\"RABBITMQ_URL\"),\n\n // ============================================================================\n // Connection Options\n // ============================================================================\n\n /** Heartbeat interval in seconds */\n heartbeat: 60,\n\n /** Connection timeout in milliseconds */\n connectionTimeout: 10000,\n\n /** Enable automatic reconnection on disconnect */\n reconnect: true,\n\n /** Delay between reconnection attempts in milliseconds */\n reconnectDelay: 5_000,\n\n // ============================================================================\n // Consumer Options\n // ============================================================================\n\n /** Default prefetch count (number of unacknowledged messages per consumer) */\n prefetch: 10,\n\n // ============================================================================\n // Client Options (Native amqplib options)\n // ============================================================================\n // These options are passed directly to amqplib.connect()\n // for low-level configuration like frame size, TLS, socket options, etc.\n // ============================================================================\n clientOptions: {\n // Frame max size in bytes (0 = no limit)\n // frameMax: 0,\n\n // Channel max (0 = unlimited)\n // channelMax: 0,\n\n // Socket options\n socket: {\n // Enable TCP keep-alive\n keepAlive: true,\n\n // Disable Nagle's algorithm for lower latency\n noDelay: true,\n\n // Socket timeout (in addition to heartbeat)\n // timeout: 30000,\n },\n\n // TLS/SSL options (uncomment for secure connections)\n // socket: {\n // ca: fs.readFileSync('/path/to/ca.pem'),\n // cert: fs.readFileSync('/path/to/cert.pem'),\n // key: fs.readFileSync('/path/to/key.pem'),\n // rejectUnauthorized: true,\n // },\n },\n};\n\nexport default heraldConfigurations;\n`;\n\nexport const notificationsConfigStub = `import { type NotificationConfig, inApp, mailChannel } from \"@warlock.js/notifications\";\nimport { Notification } from \"app/notifications/notification.model\";\n\n/**\n * Notifications configuration. Auto-loaded from src/config on boot — the\n * framework's notifications connector reads this default export and hands it to\n * setNotificationConfig, so this file stays declarative (no side-effect call).\n *\n * Each channel is payload-typed, so notify.mail(...) / notify.database(...)\n * and defineNotification are type-checked against the registry.\n *\n * Channels enabled here:\n * - mail wraps @warlock.js/core sendMail; route is notifiable.email.\n * The \"from\" address defaults to config/mail.ts; override per\n * channel with mailChannel({ from: \"no-reply@yourapp.com\" }).\n * - database in-app store backed by the Notification model. The \"inApp\"\n * facade exposes the recipient-scoped read API: listUnread,\n * countUnread, markAsRead, dismiss, ...\n *\n * Async delivery (.queue()) is OPTIONAL: run \"npx warlock add herald\",\n * import { heraldQueue } from \"@warlock.js/notifications\", and uncomment the\n * queue line below.\n */\nconst config: NotificationConfig = {\n channels: {\n mail: mailChannel(),\n database: inApp.configure({ model: Notification }),\n },\n\n // Async queue — requires @warlock.js/herald (npx warlock add herald):\n // queue: heraldQueue(),\n};\n\nexport default config;\n`;\n\nexport const notificationModelStub = `import { RegisterModel } from \"@warlock.js/cascade\";\nimport { DatabaseNotification, type NotificationColumnMap } from \"@warlock.js/notifications\";\nimport { v } from \"@warlock.js/seal\";\n\n/**\n * Validation schema for the notifications table — mirrors the migration\n * columns (snake_case). Cascade validates + casts every write against it:\n * nullable columns use .nullish() (may be absent or null), and payload is\n * free-form JSON. Keep this in sync with the migration + columnMap when you\n * add or rename columns.\n */\nconst notificationSchema = v.object({\n user_id: v.string(),\n type: v.string(),\n title: v.string(),\n body: v.string().nullish(),\n payload: v.record(v.any()).nullish(),\n read_at: v.date().nullish(),\n idempotency_key: v.string().nullish(),\n});\n\n/**\n * In-app notification model.\n *\n * Extends the package's DatabaseNotification base, which provides the stable\n * accessors (recipientId, tenantId, isRead, readAt, markRead) — all derived\n * from the columnMap below. The read/write API lives on the inApp facade\n * (configured in config/notifications.ts); you rarely touch this class directly.\n */\n@RegisterModel()\nexport class Notification extends DatabaseNotification {\n public static table = \"notifications\";\n public static schema = notificationSchema;\n\n /**\n * Maps the in-app store's roles to your columns. This default is\n * single-tenant + read_at-only. Add tenant: \"organization_id\" for\n * multi-tenant; use isRead: \"is_read\" (instead of, or alongside, readAt) to\n * track a boolean read flag. The migration + accessors all follow this map.\n */\n public static columnMap: NotificationColumnMap = { readAt: \"read_at\" };\n}\n`;\n\nexport const notificationMigrationStub = `import { Migration } from \"@warlock.js/cascade\";\nimport { notificationColumns } from \"@warlock.js/notifications\";\nimport { Notification } from \"../notification.model\";\n\n/**\n * Notifications table.\n *\n * Columns come from notificationColumns(Notification) — the recipient / tenant\n * / read-state names follow the model's columnMap; type / title / body /\n * payload / idempotency_key are fixed. Spread it to add your own columns\n * (remember to mirror them in the model schema):\n *\n * import { uuid } from \"@warlock.js/cascade\";\n *\n * export default Migration.create(Notification, {\n * ...notificationColumns(Notification),\n * // category_id: uuid().index().nullable(),\n * });\n */\nexport default Migration.create(Notification, notificationColumns(Notification));\n`;\n\nexport const notificationControllersStub = `import { type RequestHandler } from \"@warlock.js/core\";\nimport { inApp, type Id } from \"@warlock.js/notifications\";\n\n/**\n * The authenticated user's notification HTTP surface — thin wrappers over the\n * recipient-scoped \\`inApp\\` facade (a foreign id can never touch another user's\n * rows). Notifications are produced by domain events, never over HTTP, so there\n * is no create. Trim or split these as your app grows.\n */\n\n/**\n * Read \\`id\\` off \\`request.locals.user\\` without assuming this app's\n * \\`RequestUser\\` augmentation declares it — \\`RequestUser\\` (declared by\n * \\`@warlock.js/auth\\`) is empty by default, so a narrow runtime read survives\n * any augmentation shape instead of assuming \\`.id\\` exists at the type level.\n * \\`inApp\\` only ever needs the id (it reduces a \\`Notifiable\\` to one via\n * \\`recipient.id\\` internally), so reading it here — rather than forwarding\n * \\`request.locals.user\\` itself — also skips a needless \\`Notifiable\\` cast.\n */\nfunction recipientId(user: unknown): Id {\n if (user && typeof user === \"object\" && \"id\" in user) {\n const id = (user as { id?: unknown }).id;\n\n if (typeof id === \"string\" || typeof id === \"number\") return id;\n }\n\n throw new Error(\"Authenticated request is missing a usable user id\");\n}\n\n/** GET /notifications — list, most recent first (page / limit / type / unread via query). */\nexport const listNotificationsController: RequestHandler = async ({ request, response }) => {\n const { data, pagination } = await inApp.list(recipientId(request.locals.user), request.all());\n\n return response.success({ notifications: data, pagination });\n};\n\nlistNotificationsController.description = \"List notifications\";\n\n/** GET /notifications/unread-count — drives the bell badge. */\nexport const unreadNotificationsCountController: RequestHandler = async ({\n request,\n response,\n}) => {\n const count = await inApp.countUnread(recipientId(request.locals.user));\n\n return response.success({ count });\n};\n\nunreadNotificationsCountController.description = \"Unread notifications count\";\n\n/** PATCH /notifications/:id/read — mark one read, return the updated row. */\nexport const markNotificationReadController: RequestHandler = async ({ request, response }) => {\n const id = request.input(\"id\");\n const userId = recipientId(request.locals.user);\n\n await inApp.markAsRead(userId, id);\n const notification = await inApp.find(userId, id);\n\n return response.success({ notification });\n};\n\nmarkNotificationReadController.description = \"Mark notification read\";\n\n/** PATCH /notifications/read-all — mark every unread one read. */\nexport const markAllNotificationsReadController: RequestHandler = async ({\n request,\n response,\n}) => {\n const count = await inApp.markAsRead(recipientId(request.locals.user));\n\n return response.success({ count });\n};\n\nmarkAllNotificationsReadController.description = \"Mark all notifications read\";\n\n/** DELETE /notifications — dismiss all for the user. */\nexport const clearNotificationsController: RequestHandler = async ({ request, response }) => {\n await inApp.dismiss(recipientId(request.locals.user));\n\n return response.noContent();\n};\n\nclearNotificationsController.description = \"Clear notifications\";\n\n/** DELETE /notifications/:id — dismiss one. */\nexport const deleteNotificationController: RequestHandler = async ({ request, response }) => {\n await inApp.dismiss(recipientId(request.locals.user), request.input(\"id\"));\n\n return response.noContent();\n};\n\ndeleteNotificationController.description = \"Delete notification\";\n`;\n\nexport const notificationRoutesStub = `import { authMiddleware } from \"@warlock.js/auth\";\nimport { router } from \"@warlock.js/core\";\nimport {\n clearNotificationsController,\n deleteNotificationController,\n listNotificationsController,\n markAllNotificationsReadController,\n markNotificationReadController,\n unreadNotificationsCountController,\n} from \"./controllers/notifications.controller\";\n\n/**\n * Notification routes — the authenticated user's read + dismiss surface.\n *\n * Notifications are produced by domain events (never created over HTTP), so\n * there is no POST. Every route is gated by \\`authMiddleware\\` and recipient-\n * scoped by \\`inApp\\` (a foreign id touches zero rows). Delete any endpoint you\n * don't need; if your app reads notifications over sockets/GraphQL instead,\n * delete this file + the controllers entirely.\n */\nrouter.group({ prefix: \"/notifications\", middleware: [authMiddleware([])] }, () => {\n router.get(\"/\", listNotificationsController);\n router.get(\"/unread-count\", unreadNotificationsCountController);\n router.patch(\"/read-all\", markAllNotificationsReadController);\n router.patch(\"/:id/read\", markNotificationReadController);\n router.delete(\"/\", clearNotificationsController);\n router.delete(\"/:id\", deleteNotificationController);\n});\n`;\n\n/**\n * `src/web/root.tsx` — the application root for the SSR page layer.\n *\n * Deliberately minimal. The framework ships a default root, so this exists to\n * give you a place to start rather than because anything requires it. The\n * reference app (`v5/app/src/web/root.tsx`) is where to look for the fuller\n * shape: middleware, an app-level loader, locales, an ErrorBoundary.\n */\nexport const webRootStub = `import type { AppProps } from \"@warlock.js/web\";\nimport { Head, Scripts } from \"@warlock.js/web\";\n\n/**\n * The application root.\n *\n * NOT async, and it receives no request/response: it renders on the server and\n * again in the browser during hydration, where neither exists.\n */\nexport default function App({ children }: AppProps) {\n return (\n <html lang=\"en\">\n <head>\n {/*\n Placement only. The framework injects the page's \\`metadata\\`, the\n stylesheet and preload tags for this route, and the canonical links\n into <head> by default — <Head /> just says WHERE they land.\n\n Do not add a <title> here: the page's \\`metadata\\` owns it, and a root\n that emits one too produces two.\n */}\n <Head />\n <link rel=\"icon\" href=\"data:,\" />\n </head>\n <body>\n {/*\n REQUIRED — this is the hydration mount point, not a styling wrapper.\n\n The browser runtime looks up \\`#vessel\\` and hydrates that element only.\n Remove this div, or rename the id, and the page still renders from the\n server but never becomes interactive: the runtime throws in the console\n and nothing on screen changes.\n\n Wrap it in your own markup freely, and put anything that must live\n outside the hydrated tree (a static footer, a portal target) outside\n it — just keep an element with \\`id=\"vessel\"\\` around {children}.\n */}\n <div id=\"vessel\">{children}</div>\n {/*\n The hydration payload and module tags. Written explicitly because\n placement occasionally matters — a CSP nonce, or ordering against\n your own scripts.\n */}\n <Scripts />\n </body>\n </html>\n );\n}\n`;\n\n/**\n * `src/app/contact/controllers/contact.controller.ts` — a real API endpoint\n * for the Web starter's contact form. It intentionally has no persistence\n * dependency: replace the acknowledgement with a mail/job/database action.\n */\nexport const webContactControllerStub = `import { type Request, type RequestHandler } from \"@warlock.js/core\";\nimport { type Infer, v } from \"@warlock.js/seal\";\n\nexport const contactSchema = v.object({\n name: v.string().min(2),\n email: v.email(),\n message: v.string().min(10),\n});\n\nexport type ContactSchema = Infer.Output<typeof contactSchema>;\n\n/** POST /api/contact — validates the starter contact form. */\nexport const contactController: RequestHandler<Request<ContactSchema>> = async ({\n request,\n response,\n}) => {\n const contact = request.validated();\n\n // Replace this with delivery/persistence for your app. Keeping the accepted\n // payload visible makes the endpoint useful while remaining side-effect free.\n return response.success({\n message: \"Thanks, \" + contact.name + \". Your message has been received.\",\n });\n};\n\ncontactController.validation = { schema: contactSchema };\n`;\n\n/** `src/app/contact/routes.ts` — discovered by the standard app route loader. */\nexport const webContactRoutesStub = `import { router } from \"@warlock.js/core\";\nimport { contactController } from \"./controllers/contact.controller\";\n\nrouter.post(\"/api/contact\", contactController);\n`;\n\n/**\n * `src/web/index.register.ts` — universal static setup for the starter page.\n *\n * The page re-exports this stable binding so Warlock's `register()` lifecycle\n * still sees it in both realms without making React Fast Refresh treat every\n * JSX edit as an incompatible function-export replacement.\n */\nexport const webHomeRegisterStub = `import { extend } from \"@mongez/localization\";\n\nexport function register() {\n extend(\"en\", {\n starter: {\n title: \"Your Warlock app is running.\",\n introduction: \"This page is rendered on the server and hydrated in the browser.\",\n language: \"العربية\",\n contact: \"Send a message\",\n name: \"Name\",\n email: \"Email\",\n message: \"Message\",\n submit: \"Send message\",\n sent: \"Thanks — your message has been received.\",\n },\n });\n extend(\"ar\", {\n starter: {\n title: \"تطبيق Warlock يعمل الآن.\",\n introduction: \"تُعرض هذه الصفحة على الخادم ثم تُفعَّل في المتصفح.\",\n language: \"English\",\n contact: \"أرسل رسالة\",\n name: \"الاسم\",\n email: \"البريد الإلكتروني\",\n message: \"الرسالة\",\n submit: \"إرسال الرسالة\",\n sent: \"شكرًا — تم استلام رسالتك.\",\n },\n });\n}\n`;\n\n/**\n * `src/web/index.page.tsx` — one page, so \\`warlock dev\\` has something to serve\n * the moment this finishes.\n */\nexport const webHomePageStub = `import { http } from \"@mongez/http\";\nimport { setCurrentLocaleCode } from \"@mongez/localization\";\nimport { Form, useFormControl, type FormControlProps } from \"@mongez/react-form\";\nimport { transX } from \"@mongez/react-localization\";\nimport { v } from \"@warlock.js/seal\";\nimport { Link, type PageProps } from \"@warlock.js/web\";\nimport { useState } from \"react\";\n\nexport { register } from \"./index.register\";\n\n/**\n * A page route is an ordinary Warlock route whose handler renders React\n * instead of returning JSON.\n *\n * The URL and stable hydration name are the ones this file DECLARES below.\n * This page answers \\`GET \"/\"\\` because \\`route.path = \"/\"\\`, not because of\n * where the file lives. A page file with\n * no \\`route\\` export is REFUSED by both the dev server and the build.\n */\nexport const route = { path: \"/\", name: \"index\" } as const;\n\nexport const metadata = { title: \"Home\" };\n\nconst contactSchema = v.object({\n name: v.string().min(2),\n email: v.email(),\n message: v.string().min(10),\n});\n\nfunction TextInput({ label, ...controlProps }: FormControlProps & { label: string }) {\n const { error, getErrorProps, getInputProps } = useFormControl(controlProps);\n\n return (\n <div className=\"wk-field\">\n <label htmlFor={controlProps.name}>{label}</label>\n <input {...getInputProps()} />\n {error && <p {...getErrorProps()}>{error}</p>}\n </div>\n );\n}\n\n/**\n * Add a \\`loader\\` export to fetch data on the server, and it arrives here as\n * \\`data\\`, typed:\n *\n * export const loader = (async () => ({ items: await itemsRepository.all() }));\n * export default function HomePage({ data }: PageProps<typeof loader>) { ... }\n */\nexport default function HomePage(_props: PageProps) {\n // Live state. If the button below does nothing, the page rendered on the\n // server but never hydrated — the runtime never mounted at \\`#vessel\\`. This is\n // deliberately here so that failure is impossible to miss.\n const [count, setCount] = useState(0);\n const [locale, setLocale] = useState<\"en\" | \"ar\">(\"en\");\n const [submitted, setSubmitted] = useState(false);\n const [submitError, setSubmitError] = useState<string | null>(null);\n\n const toggleLocale = () => {\n const nextLocale = locale === \"en\" ? \"ar\" : \"en\";\n setCurrentLocaleCode(nextLocale);\n setLocale(nextLocale);\n };\n\n return (\n <>\n {/*\n Self-contained, dependency-free styling: plain CSS, system fonts, and\n CSS custom properties, scoped to this page. No CSS framework, no utility\n classes, no external stylesheet — this page looks the same whether or\n not \\`warlock add tailwind\\` has ever been run.\n */}\n <style>{\\`\n .wk-home {\n --wk-fg: #0f172a;\n --wk-muted: #64748b;\n --wk-accent: #4f46e5;\n --wk-border: #e2e8f0;\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n color: var(--wk-fg);\n max-width: 42rem;\n margin: 4rem auto;\n padding: 0 1.5rem;\n line-height: 1.6;\n }\n .wk-home h1 { font-size: 2.25rem; margin: 0 0 0.5rem; }\n .wk-home p { color: var(--wk-muted); margin: 0 0 1.5rem; }\n .wk-home code {\n font-family: ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n background: #f1f5f9;\n padding: 0.1rem 0.35rem;\n border-radius: 0.25rem;\n }\n .wk-check {\n border: 1px solid var(--wk-border);\n border-radius: 0.75rem;\n padding: 1.25rem 1.5rem;\n margin: 2rem 0;\n }\n .wk-check strong { display: block; font-size: 1.5rem; }\n .wk-check button {\n font: inherit;\n cursor: pointer;\n background: var(--wk-accent);\n color: #fff;\n border: 0;\n border-radius: 0.5rem;\n padding: 0.5rem 1rem;\n margin-top: 0.75rem;\n }\n .wk-links { display: flex; gap: 1.25rem; font-size: 0.95rem; }\n .wk-links a { color: var(--wk-accent); text-decoration: none; }\n .wk-links a:hover { text-decoration: underline; }\n .wk-language { margin-left: auto; }\n .wk-contact { margin-top: 2rem; }\n .wk-field { display: grid; gap: 0.35rem; margin: 0.8rem 0; }\n .wk-field input, .wk-field textarea { font: inherit; padding: 0.55rem; }\n .wk-field p, .wk-submit-error { color: #b91c1c; margin: 0; }\n .wk-success { color: #047857; }\n \\`}</style>\n\n <main className=\"wk-home\" dir={locale === \"ar\" ? \"rtl\" : \"ltr\"}>\n <nav className=\"wk-links\" aria-label=\"Starter links\">\n <a href=\"https://warlock.js.org\" target=\"_blank\" rel=\"noreferrer\">\n Docs\n </a>\n <Link href=\"/\" aria-current=\"page\">\n Home\n </Link>\n <button\n className=\"wk-language\"\n type=\"button\"\n aria-pressed={locale === \"ar\"}\n onClick={toggleLocale}\n >\n {transX(\"starter.language\")}\n </button>\n </nav>\n\n <h1>{transX(\"starter.title\")}</h1>\n <p>{transX(\"starter.introduction\")}</p>\n\n <section className=\"wk-check\">\n <label>If this number goes up when you click, React is hydrated:</label>\n <strong>{count}</strong>\n <button type=\"button\" onClick={() => setCount((c) => c + 1)}>\n Count up\n </button>\n </section>\n\n <section className=\"wk-contact\" aria-labelledby=\"contact-heading\">\n <h2 id=\"contact-heading\">{transX(\"starter.contact\")}</h2>\n <Form<typeof contactSchema>\n id=\"contact-form\"\n schema={contactSchema}\n onSubmit={async ({ form, values }) => {\n setSubmitted(false);\n setSubmitError(null);\n const result = await http.post<{ message: string }>(\"/api/contact\", values);\n\n if (result.error) {\n if (result.error.isValidationError) {\n const body = result.error.body as {\n errors?: Array<{ input: string; error: string }>;\n message?: string;\n };\n form.setErrors(\n Object.fromEntries(\n (body.errors ?? []).map(({ input, error }) => [input, error]),\n ),\n );\n setSubmitError(body.message ?? \"Please correct the highlighted fields.\");\n } else {\n setSubmitError(\"Your message could not be sent. Please try again.\");\n }\n return;\n }\n\n setSubmitted(true);\n form.reset();\n }}\n >\n <TextInput name=\"name\" label={transX(\"starter.name\")} autoComplete=\"name\" />\n <TextInput\n name=\"email\"\n label={transX(\"starter.email\")}\n type=\"email\"\n autoComplete=\"email\"\n />\n <ContactMessage />\n <button type=\"submit\">{transX(\"starter.submit\")}</button>\n {submitError && (\n <p className=\"wk-submit-error\" role=\"alert\">\n {submitError}\n </p>\n )}\n {submitted && (\n <p className=\"wk-success\" role=\"status\">\n {transX(\"starter.sent\")}\n </p>\n )}\n </Form>\n </section>\n </main>\n </>\n );\n}\n\nfunction ContactMessage() {\n const { error, getErrorProps, getInputProps } = useFormControl({ name: \"message\" });\n\n return (\n <div className=\"wk-field\">\n <label htmlFor=\"message\">{transX(\"starter.message\")}</label>\n <textarea {...getInputProps()} rows={5} />\n {error && <p {...getErrorProps()}>{error}</p>}\n </div>\n );\n}\n`;\n"],"mappings":";AAAA,MAAa,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BhC,MAAa,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B5B,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCnC,MAAa,2BAA2B;;AAGxC,MAAa,0BAA0B;;;;;;;;;;;;AAavC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8FvC,MAAa,+BAA+B;;AAG5C,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;AAsB3C,MAAa,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6ClC,MAAa,mBAAmB;;;;;;;;;;;;;;;;;AAkBhC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkFvC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCvC,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CrC,MAAa,4BAA4B;;;;;;;;;;;;;;;;;;;;;AAsBzC,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8F3C,MAAa,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCtC,MAAa,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuD3B,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BxC,MAAa,uBAAuB;;;;;;;;;;;;AAapC,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCnC,MAAa,kBAAkB"}
1
+ {"version":3,"file":"stubs.mjs","names":[],"sources":["../../../../../../../core/src/generations/stubs.ts"],"sourcesContent":["export const accessConfigStub = `import { type AccessConfigurations } from \"@warlock.js/access\";\nimport { DatabaseAccessResolver } from \"app/access/services/access-resolver\";\n\n/**\n * Authorization configuration — read by @warlock.js/access on boot.\n *\n * The resolver is the one required piece: it tells the engine how to read a\n * user's roles + permissions. The ejected DatabaseAccessResolver reads roles\n * from the user_roles table and maps them through the roles catalog table (so\n * roles + their permissions are managed at runtime, in the DB).\n *\n * For a fixed, code-defined catalog with no tables, swap in DefaultAccessResolver:\n * import { DefaultAccessResolver } from \"@warlock.js/access\";\n * resolver: new DefaultAccessResolver({ admin: [\"*\"], editor: [\"orders.*\"] }),\n *\n * Multi-tenant? Add a \\`resolveTenant()\\` to the resolver to read the active\n * tenant from the request; checks then scope to it automatically.\n */\nconst access: AccessConfigurations = {\n resolver: new DatabaseAccessResolver(),\n\n // Cache resolved permission sets (default \"10m\").\n // cache: { ttl: \"10m\" },\n};\n\nexport default access;\n`;\n\nexport const aiConfigStub = `import type { AIConfig } from \"@warlock.js/ai\";\n\n// >>> warlock:ai-packages (auto-managed) >>>\n// Satellite packages augment the \"ai\" object on import — e.g. ai.workspace,\n// ai.tools / ai.mcp, and panoptic's ai.config({ panoptic }) wiring. The command\n// \"warlock add ai-workspace | ai-tools | ai-panoptic\" adds the matching\n// side-effect import below; keep them so the augmentation + runtime registration\n// load before the ai connector applies this config.\n// <<< warlock:ai-packages <<<\n\n/**\n * AI configuration — applied on boot by the ai connector, which calls\n * ai.config(...) with the object below. Cross-cutting defaults live here\n * (shared cache / snapshot stores, observability); per-call options always win.\n *\n * Wire a default model from a provider you installed, e.g.:\n * import { OpenAISDK } from \"@warlock.js/ai-openai\";\n * const openai = OpenAISDK({ apiKey: env(\"OPENAI_API_KEY\") });\n * // then pass openai.model({ name: \"gpt-4o-mini\" }) into your agents.\n */\nconst ai: Partial<AIConfig> = {\n // Default cache driver for cache-backed AI features (semantic cache, rag / memory vector stores).\n // defaultStore: cache.driver(\"redis\", { client }),\n\n // Observability — requires \"warlock add ai-panoptic\". Exporters + the local dashboard.\n // panoptic: { exporters: [], dashboard: false, observeAll: false },\n};\n\nexport default ai;\n`;\n\nexport const accessRoleModelStub = `import { Model, RegisterModel } from \"@warlock.js/cascade\";\nimport { type Infer, v } from \"@warlock.js/seal\";\n\n/**\n * Validation schema for the roles catalog — mirrors the migration columns\n * (snake_case). Each row is a role name plus the permission strings it grants;\n * wildcards work (\"orders.*\", \"*\"). The DatabaseAccessResolver maps a user's\n * assigned role names through this table to their effective permissions.\n */\nexport const roleSchema = v.object({\n name: v.string(),\n permissions: v.array(v.string()).default([]),\n});\n\nexport type RoleSchema = Infer<typeof roleSchema>;\n\n/**\n * The roles catalog — role name → the permissions it grants. Managed at runtime\n * (admins add roles + edit their permissions), unlike a fixed code map. Read by\n * DatabaseAccessResolver.resolvePermissions to expand a user's roles to permissions.\n */\n@RegisterModel()\nexport class Role extends Model<RoleSchema> {\n public static table = \"roles\";\n\n public static schema = roleSchema;\n\n /** The permission strings this role grants. */\n public get permissions(): string[] {\n return this.get<string[]>(\"permissions\", []);\n }\n}\n`;\n\nexport const accessRoleModelIndexStub = `export * from \"./role.model\";\n`;\n\nexport const accessRoleMigrationStub = `import { arrayText, Migration, text } from \"@warlock.js/cascade\";\nimport { Role } from \"../role.model\";\n\n/**\n * Roles catalog table. \\`name\\` is unique (one row per role); \\`permissions\\` is a\n * text array of the permission strings the role grants.\n */\nexport default Migration.create(Role, {\n name: text().notNullable().unique(),\n permissions: arrayText().nullable(),\n});\n`;\n\nexport const accessUserRoleModelStub = `import { access } from \"@warlock.js/access\";\nimport type { Auth } from \"@warlock.js/auth\";\nimport { Model, RegisterModel } from \"@warlock.js/cascade\";\nimport { type Infer, v } from \"@warlock.js/seal\";\n\n/**\n * Validation schema for a role assignment — mirrors the migration columns\n * (snake_case). \\`tenant\\` is nullable: a null tenant is a GLOBAL assignment.\n */\nexport const userRoleSchema = v.object({\n user_id: v.string(),\n user_type: v.string(),\n role: v.string(),\n tenant: v.string().optional(),\n});\n\nexport type UserRoleSchema = Infer<typeof userRoleSchema>;\n\n/**\n * The role-assignment table — which roles a user holds, optionally per tenant.\n * Read by DatabaseAccessResolver.resolveRoles; mutated via the statics below.\n * \\`assign\\` / \\`revoke\\` flush the cached permission set automatically, so callers\n * never need to call \\`access.flush(user, tenant)\\` themselves.\n */\n@RegisterModel()\nexport class UserRole extends Model<UserRoleSchema> {\n public static table = \"user_roles\";\n\n public static schema = userRoleSchema;\n\n /**\n * Role names assigned to the user in the given tenant.\n *\n * An unresolved tenant (\\`undefined\\`) scopes to GLOBAL roles only — the rows\n * stored with no tenant (\\`null\\`) — never the union across every tenant. The\n * union would be a privilege-escalation: a user who is \\`owner\\` in one tenant\n * must not be treated as \\`owner\\` everywhere just because a check didn't carry\n * a tenant. This mirrors how \\`assign(user, role)\\` stores a global row.\n */\n public static async rolesFor(user: Auth, tenant?: string): Promise<string[]> {\n const rows = await this.query()\n .where({\n user_id: user.id,\n user_type: user.userType,\n tenant: tenant ?? null,\n })\n .get();\n\n // De-dupe so a duplicate row (a concurrent assign that slipped past the\n // existence check) can't distort the resolved set.\n return [...new Set(rows.map((row) => row.get(\"role\") as string))];\n }\n\n /**\n * Assign a role to the user. No-op if the assignment already exists.\n * Flushes the user's cached permission set automatically.\n */\n public static async assign(user: Auth, role: string, tenant?: string): Promise<void> {\n const existing = await this.first({\n user_id: user.id,\n user_type: user.userType,\n role,\n tenant: tenant ?? null,\n });\n\n if (existing) return;\n\n await this.create({\n user_id: user.id,\n user_type: user.userType,\n role,\n tenant,\n });\n\n await access.flush(user, tenant);\n }\n\n /**\n * Remove a role assignment from the user.\n * Flushes the user's cached permission set automatically.\n */\n public static async revoke(user: Auth, role: string, tenant?: string): Promise<void> {\n await this.delete({\n user_id: user.id,\n user_type: user.userType,\n role,\n tenant: tenant ?? null,\n });\n\n await access.flush(user, tenant);\n }\n}\n`;\n\nexport const accessUserRoleModelIndexStub = `export * from \"./user-role.model\";\n`;\n\nexport const accessUserRoleMigrationStub = `import { Migration, text, uuid } from \"@warlock.js/cascade\";\nimport { UserRole } from \"../user-role.model\";\n\n/**\n * Role-assignment table. \\`user_id\\` is a UUID — override this migration if your\n * user ids are integers. The composite index powers the per-user (per-tenant)\n * lookup the resolver runs on every check.\n */\nexport default Migration.create(\n UserRole,\n {\n user_id: uuid().notNullable().index(),\n user_type: text().notNullable(),\n role: text().notNullable().index(),\n tenant: text().nullable().index(),\n },\n {\n index: [{ columns: [\"user_id\", \"user_type\", \"tenant\"] }],\n },\n);\n`;\n\nexport const accessResolverStub = `import type { AccessResolver } from \"@warlock.js/access\";\nimport type { Auth } from \"@warlock.js/auth\";\nimport { Role } from \"app/access/models/role\";\nimport { UserRole } from \"app/access/models/user-role\";\n\n/**\n * The app's access adapter — connects @warlock.js/access to the ejected role\n * tables. Roles come from the user_roles assignment table; permissions are\n * expanded by mapping those role names through the roles catalog table. Both\n * are managed at runtime (in the DB), so admins can add roles + edit their\n * permissions without a deploy.\n *\n * The engine owns the hard parts (wildcard matching, caching, fail-closed); this\n * resolver only fetches — keep it dumb, never cache inside it.\n */\nexport class DatabaseAccessResolver implements AccessResolver {\n /** The role names this user holds (powers \\`hasRole\\` / \\`hasAnyRole\\`). */\n public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {\n return UserRole.rolesFor(user, tenant);\n }\n\n /** The effective permission strings this user has (powers \\`can\\` / \\`authorize\\`). */\n public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {\n const names = await this.resolveRoles(user, tenant);\n\n if (names.length === 0) return [];\n\n const roles = await Role.query().whereIn(\"name\", names).get();\n\n // Flatten + de-dupe so two roles granting the same permission yield one entry.\n return [...new Set(roles.flatMap((role) => role.permissions))];\n }\n\n /**\n * Optional. Resolve the ambient tenant when a check doesn't pass one\n * explicitly — derive it from the authenticated user (safer than reading\n * client request input, which a caller could spoof). Uncomment + adapt for a\n * multi-tenant app (single-tenant apps leave this off and return undefined).\n */\n // public resolveTenant(user: Auth): string | undefined {\n // return user.get(\"organization_id\");\n // }\n}\n`;\n\nexport const socketConfigStub = `import type { SocketOptions } from \"@warlock.js/core\";\n\n/**\n * Socket.IO configuration — read by the framework's socket connector\n * on boot. When the HTTP server is running the socket server attaches\n * to it; otherwise it listens on its own configured port.\n *\n * Remove this file to disable the socket server entirely.\n */\nexport default {\n options: {\n cors: {\n origin: \"*\",\n },\n },\n} as SocketOptions;\n`;\n\nexport const communicatorsConfigStub = `import { env } from \"@warlock.js/core\";\nimport type { BrokerConfigurations, RabbitMQClientOptions } from \"@warlock.js/herald\";\n\nconst heraldConfigurations: BrokerConfigurations<RabbitMQClientOptions> = {\n driver: \"rabbitmq\",\n name: \"default\",\n isDefault: true,\n\n // ============================================================================\n // Connection Settings\n // ============================================================================\n\n host: env(\"RABBITMQ_HOST\", \"localhost\"),\n port: env(\"RABBITMQ_PORT\", 5672),\n username: env(\"RABBITMQ_USERNAME\", \"guest\"),\n password: env(\"RABBITMQ_PASSWORD\", \"guest\"),\n vhost: env(\"RABBITMQ_VHOST\", \"/\"),\n\n // Or use connection URI (takes precedence over host/port)\n // uri: env(\"RABBITMQ_URL\"),\n\n // ============================================================================\n // Connection Options\n // ============================================================================\n\n /** Heartbeat interval in seconds */\n heartbeat: 60,\n\n /** Connection timeout in milliseconds */\n connectionTimeout: 10000,\n\n /** Enable automatic reconnection on disconnect */\n reconnect: true,\n\n /** Delay between reconnection attempts in milliseconds */\n reconnectDelay: 5_000,\n\n // ============================================================================\n // Consumer Options\n // ============================================================================\n\n /** Default prefetch count (number of unacknowledged messages per consumer) */\n prefetch: 10,\n\n // ============================================================================\n // Client Options (Native amqplib options)\n // ============================================================================\n // These options are passed directly to amqplib.connect()\n // for low-level configuration like frame size, TLS, socket options, etc.\n // ============================================================================\n clientOptions: {\n // Frame max size in bytes (0 = no limit)\n // frameMax: 0,\n\n // Channel max (0 = unlimited)\n // channelMax: 0,\n\n // Socket options\n socket: {\n // Enable TCP keep-alive\n keepAlive: true,\n\n // Disable Nagle's algorithm for lower latency\n noDelay: true,\n\n // Socket timeout (in addition to heartbeat)\n // timeout: 30000,\n },\n\n // TLS/SSL options (uncomment for secure connections)\n // socket: {\n // ca: fs.readFileSync('/path/to/ca.pem'),\n // cert: fs.readFileSync('/path/to/cert.pem'),\n // key: fs.readFileSync('/path/to/key.pem'),\n // rejectUnauthorized: true,\n // },\n },\n};\n\nexport default heraldConfigurations;\n`;\n\nexport const notificationsConfigStub = `import { type NotificationConfig, inApp, mailChannel } from \"@warlock.js/notifications\";\nimport { Notification } from \"app/notifications/notification.model\";\n\n/**\n * Notifications configuration. Auto-loaded from src/config on boot — the\n * framework's notifications connector reads this default export and hands it to\n * setNotificationConfig, so this file stays declarative (no side-effect call).\n *\n * Each channel is payload-typed, so notify.mail(...) / notify.database(...)\n * and defineNotification are type-checked against the registry.\n *\n * Channels enabled here:\n * - mail wraps @warlock.js/core sendMail; route is notifiable.email.\n * The \"from\" address defaults to config/mail.ts; override per\n * channel with mailChannel({ from: \"no-reply@yourapp.com\" }).\n * - database in-app store backed by the Notification model. The \"inApp\"\n * facade exposes the recipient-scoped read API: listUnread,\n * countUnread, markAsRead, dismiss, ...\n *\n * Async delivery (.queue()) is OPTIONAL: run \"npx warlock add herald\",\n * import { heraldQueue } from \"@warlock.js/notifications\", and uncomment the\n * queue line below.\n */\nconst config: NotificationConfig = {\n channels: {\n mail: mailChannel(),\n database: inApp.configure({ model: Notification }),\n },\n\n // Async queue — requires @warlock.js/herald (npx warlock add herald):\n // queue: heraldQueue(),\n};\n\nexport default config;\n`;\n\nexport const notificationModelStub = `import { RegisterModel } from \"@warlock.js/cascade\";\nimport { DatabaseNotification, type NotificationColumnMap } from \"@warlock.js/notifications\";\nimport { v } from \"@warlock.js/seal\";\n\n/**\n * Validation schema for the notifications table — mirrors the migration\n * columns (snake_case). Cascade validates + casts every write against it:\n * nullable columns use .nullish() (may be absent or null), and payload is\n * free-form JSON. Keep this in sync with the migration + columnMap when you\n * add or rename columns.\n */\nconst notificationSchema = v.object({\n user_id: v.string(),\n type: v.string(),\n title: v.string(),\n body: v.string().nullish(),\n payload: v.record(v.any()).nullish(),\n read_at: v.date().nullish(),\n idempotency_key: v.string().nullish(),\n});\n\n/**\n * In-app notification model.\n *\n * Extends the package's DatabaseNotification base, which provides the stable\n * accessors (recipientId, tenantId, isRead, readAt, markRead) — all derived\n * from the columnMap below. The read/write API lives on the inApp facade\n * (configured in config/notifications.ts); you rarely touch this class directly.\n */\n@RegisterModel()\nexport class Notification extends DatabaseNotification {\n public static table = \"notifications\";\n public static schema = notificationSchema;\n\n /**\n * Maps the in-app store's roles to your columns. This default is\n * single-tenant + read_at-only. Add tenant: \"organization_id\" for\n * multi-tenant; use isRead: \"is_read\" (instead of, or alongside, readAt) to\n * track a boolean read flag. The migration + accessors all follow this map.\n */\n public static columnMap: NotificationColumnMap = { readAt: \"read_at\" };\n}\n`;\n\nexport const notificationMigrationStub = `import { Migration } from \"@warlock.js/cascade\";\nimport { notificationColumns } from \"@warlock.js/notifications\";\nimport { Notification } from \"../notification.model\";\n\n/**\n * Notifications table.\n *\n * Columns come from notificationColumns(Notification) — the recipient / tenant\n * / read-state names follow the model's columnMap; type / title / body /\n * payload / idempotency_key are fixed. Spread it to add your own columns\n * (remember to mirror them in the model schema):\n *\n * import { uuid } from \"@warlock.js/cascade\";\n *\n * export default Migration.create(Notification, {\n * ...notificationColumns(Notification),\n * // category_id: uuid().index().nullable(),\n * });\n */\nexport default Migration.create(Notification, notificationColumns(Notification));\n`;\n\nexport const notificationControllersStub = `import { type RequestHandler } from \"@warlock.js/core\";\nimport { inApp, type Id } from \"@warlock.js/notifications\";\n\n/**\n * The authenticated user's notification HTTP surface — thin wrappers over the\n * recipient-scoped \\`inApp\\` facade (a foreign id can never touch another user's\n * rows). Notifications are produced by domain events, never over HTTP, so there\n * is no create. Trim or split these as your app grows.\n */\n\n/**\n * Read \\`id\\` off \\`request.locals.user\\` without assuming this app's\n * \\`RequestUser\\` augmentation declares it — \\`RequestUser\\` (declared by\n * \\`@warlock.js/auth\\`) is empty by default, so a narrow runtime read survives\n * any augmentation shape instead of assuming \\`.id\\` exists at the type level.\n * \\`inApp\\` only ever needs the id (it reduces a \\`Notifiable\\` to one via\n * \\`recipient.id\\` internally), so reading it here — rather than forwarding\n * \\`request.locals.user\\` itself — also skips a needless \\`Notifiable\\` cast.\n */\nfunction recipientId(user: unknown): Id {\n if (user && typeof user === \"object\" && \"id\" in user) {\n const id = (user as { id?: unknown }).id;\n\n if (typeof id === \"string\" || typeof id === \"number\") return id;\n }\n\n throw new Error(\"Authenticated request is missing a usable user id\");\n}\n\n/** GET /notifications — list, most recent first (page / limit / type / unread via query). */\nexport const listNotificationsController: RequestHandler = async ({ request, response }) => {\n const { data, pagination } = await inApp.list(recipientId(request.locals.user), request.all());\n\n return response.success({ notifications: data, pagination });\n};\n\nlistNotificationsController.description = \"List notifications\";\n\n/** GET /notifications/unread-count — drives the bell badge. */\nexport const unreadNotificationsCountController: RequestHandler = async ({\n request,\n response,\n}) => {\n const count = await inApp.countUnread(recipientId(request.locals.user));\n\n return response.success({ count });\n};\n\nunreadNotificationsCountController.description = \"Unread notifications count\";\n\n/** PATCH /notifications/:id/read — mark one read, return the updated row. */\nexport const markNotificationReadController: RequestHandler = async ({ request, response }) => {\n const id = request.input(\"id\");\n const userId = recipientId(request.locals.user);\n\n await inApp.markAsRead(userId, id);\n const notification = await inApp.find(userId, id);\n\n return response.success({ notification });\n};\n\nmarkNotificationReadController.description = \"Mark notification read\";\n\n/** PATCH /notifications/read-all — mark every unread one read. */\nexport const markAllNotificationsReadController: RequestHandler = async ({\n request,\n response,\n}) => {\n const count = await inApp.markAsRead(recipientId(request.locals.user));\n\n return response.success({ count });\n};\n\nmarkAllNotificationsReadController.description = \"Mark all notifications read\";\n\n/** DELETE /notifications — dismiss all for the user. */\nexport const clearNotificationsController: RequestHandler = async ({ request, response }) => {\n await inApp.dismiss(recipientId(request.locals.user));\n\n return response.noContent();\n};\n\nclearNotificationsController.description = \"Clear notifications\";\n\n/** DELETE /notifications/:id — dismiss one. */\nexport const deleteNotificationController: RequestHandler = async ({ request, response }) => {\n await inApp.dismiss(recipientId(request.locals.user), request.input(\"id\"));\n\n return response.noContent();\n};\n\ndeleteNotificationController.description = \"Delete notification\";\n`;\n\nexport const notificationRoutesStub = `import { authMiddleware } from \"@warlock.js/auth\";\nimport { router } from \"@warlock.js/core\";\nimport {\n clearNotificationsController,\n deleteNotificationController,\n listNotificationsController,\n markAllNotificationsReadController,\n markNotificationReadController,\n unreadNotificationsCountController,\n} from \"./controllers/notifications.controller\";\n\n/**\n * Notification routes — the authenticated user's read + dismiss surface.\n *\n * Notifications are produced by domain events (never created over HTTP), so\n * there is no POST. Every route is gated by \\`authMiddleware\\` and recipient-\n * scoped by \\`inApp\\` (a foreign id touches zero rows). Delete any endpoint you\n * don't need; if your app reads notifications over sockets/GraphQL instead,\n * delete this file + the controllers entirely.\n */\nrouter.group({ prefix: \"/notifications\", middleware: [authMiddleware([])] }, () => {\n router.get(\"/\", listNotificationsController);\n router.get(\"/unread-count\", unreadNotificationsCountController);\n router.patch(\"/read-all\", markAllNotificationsReadController);\n router.patch(\"/:id/read\", markNotificationReadController);\n router.delete(\"/\", clearNotificationsController);\n router.delete(\"/:id\", deleteNotificationController);\n});\n`;\n\n/**\n * `src/web/root.tsx` — the application root for the SSR page layer.\n *\n * Deliberately minimal. The framework ships a default root, so this exists to\n * give you a place to start rather than because anything requires it. The\n * reference app (`v5/app/src/web/root.tsx`) is where to look for the fuller\n * shape: middleware, an app-level loader, locales, an ErrorBoundary.\n */\nexport const webRootStub = `import type { AppProps } from \"@warlock.js/web\";\nimport { Head, Scripts } from \"@warlock.js/web\";\n\n/**\n * The application root.\n *\n * NOT async, and it receives no request/response: it renders on the server and\n * again in the browser during hydration, where neither exists.\n */\nexport default function App({ children }: AppProps) {\n return (\n <html lang=\"en\">\n <head>\n {/*\n Placement only. The framework injects the page's \\`metadata\\`, the\n stylesheet and preload tags for this route, and the canonical links\n into <head> by default — <Head /> just says WHERE they land.\n\n Do not add a <title> here: the page's \\`metadata\\` owns it, and a root\n that emits one too produces two.\n */}\n <Head />\n <link rel=\"icon\" href=\"data:,\" />\n </head>\n <body>\n {/*\n REQUIRED — this is the hydration mount point, not a styling wrapper.\n\n The browser runtime looks up \\`#vessel\\` and hydrates that element only.\n Remove this div, or rename the id, and the page still renders from the\n server but never becomes interactive: the runtime throws in the console\n and nothing on screen changes.\n\n Wrap it in your own markup freely, and put anything that must live\n outside the hydrated tree (a static footer, a portal target) outside\n it — just keep an element with \\`id=\"vessel\"\\` around {children}.\n */}\n <div id=\"vessel\">{children}</div>\n {/*\n The hydration payload and module tags. Written explicitly because\n placement occasionally matters — a CSP nonce, or ordering against\n your own scripts.\n */}\n <Scripts />\n </body>\n </html>\n );\n}\n`;\n\n/**\n * `src/app/contact/controllers/contact.controller.ts` — a real API endpoint\n * for the Web starter's contact form. It intentionally has no persistence\n * dependency: replace the acknowledgement with a mail/job/database action.\n */\nexport const webContactControllerStub = `import { type Request, type RequestHandler } from \"@warlock.js/core\";\nimport { type Infer, v } from \"@warlock.js/seal\";\n\nexport const contactSchema = v.object({\n name: v.string().min(2),\n email: v.email(),\n message: v.string().min(10),\n});\n\nexport type ContactSchema = Infer.Output<typeof contactSchema>;\n\n/** POST /api/contact — validates the starter contact form. */\nexport const contactController: RequestHandler<Request<ContactSchema>> = async ({\n request,\n response,\n}) => {\n const contact = request.validated();\n\n // Replace this with delivery/persistence for your app. Keeping the accepted\n // payload visible makes the endpoint useful while remaining side-effect free.\n return response.success({\n message: \"Thanks, \" + contact.name + \". Your message has been received.\",\n });\n};\n\ncontactController.validation = { schema: contactSchema };\n`;\n\n/** `src/app/contact/routes.ts` — discovered by the standard app route loader. */\nexport const webContactRoutesStub = `import { router } from \"@warlock.js/core\";\nimport { contactController } from \"./controllers/contact.controller\";\n\nrouter.post(\"/api/contact\", contactController);\n`;\n\n/**\n * `src/web/index.register.ts` — universal static setup for the starter page.\n *\n * The page re-exports this stable binding so Warlock's `register()` lifecycle\n * still sees it in both realms without making React Fast Refresh treat every\n * JSX edit as an incompatible function-export replacement.\n */\nexport const webHomeRegisterStub = `import { extend } from \"@mongez/localization\";\n\nexport function register() {\n extend(\"en\", {\n starter: {\n title: \"Your Warlock app is running.\",\n introduction: \"This page is rendered on the server and hydrated in the browser.\",\n language: \"العربية\",\n contact: \"Send a message\",\n name: \"Name\",\n email: \"Email\",\n message: \"Message\",\n submit: \"Send message\",\n sent: \"Thanks — your message has been received.\",\n },\n });\n extend(\"ar\", {\n starter: {\n title: \"تطبيق Warlock يعمل الآن.\",\n introduction: \"تُعرض هذه الصفحة على الخادم ثم تُفعَّل في المتصفح.\",\n language: \"English\",\n contact: \"أرسل رسالة\",\n name: \"الاسم\",\n email: \"البريد الإلكتروني\",\n message: \"الرسالة\",\n submit: \"إرسال الرسالة\",\n sent: \"شكرًا — تم استلام رسالتك.\",\n },\n });\n}\n`;\n\n/**\n * `src/web/index.page.tsx` — one page, so \\`warlock dev\\` has something to serve\n * the moment this finishes.\n */\nexport const webHomePageStub = `import { http } from \"@mongez/http\";\nimport { setCurrentLocaleCode } from \"@mongez/localization\";\nimport { Form, useFormControl, type FormControlProps } from \"@mongez/react-form\";\nimport { transX } from \"@mongez/react-localization\";\nimport { v } from \"@warlock.js/seal\";\nimport { Link, type PageConfig, type PageProps } from \"@warlock.js/web\";\nimport { useState } from \"react\";\n\nexport { register } from \"./index.register\";\n\n/**\n * A page route is an ordinary Warlock route whose handler renders React\n * instead of returning JSON.\n *\n * The URL and stable hydration name are the ones this file DECLARES below.\n * This page answers \\`GET \"/\"\\` because \\`config.route.path = \"/\"\\`, not because of\n * where the file lives. A page file with\n * no \\`config.route\\` declaration derives its URL from the filesystem.\n */\nexport const config = {\n route: { path: \"/\", name: \"index\" },\n metadata: { title: \"Home\" },\n} as const satisfies PageConfig;\n\nconst contactSchema = v.object({\n name: v.string().min(2),\n email: v.email(),\n message: v.string().min(10),\n});\n\nfunction TextInput({ label, ...controlProps }: FormControlProps & { label: string }) {\n const { error, getErrorProps, getInputProps } = useFormControl(controlProps);\n\n return (\n <div className=\"wk-field\">\n <label htmlFor={controlProps.name}>{label}</label>\n <input {...getInputProps()} />\n {error && <p {...getErrorProps()}>{error}</p>}\n </div>\n );\n}\n\n/**\n * Add a \\`loader\\` export to fetch data on the server, and it arrives here as\n * \\`data\\`, typed:\n *\n * export const loader = (async () => ({ items: await itemsRepository.all() }));\n * export default function HomePage({ data }: PageProps<typeof loader>) { ... }\n */\nexport default function HomePage(_props: PageProps) {\n // Live state. If the button below does nothing, the page rendered on the\n // server but never hydrated — the runtime never mounted at \\`#vessel\\`. This is\n // deliberately here so that failure is impossible to miss.\n const [count, setCount] = useState(0);\n const [locale, setLocale] = useState<\"en\" | \"ar\">(\"en\");\n const [submitted, setSubmitted] = useState(false);\n const [submitError, setSubmitError] = useState<string | null>(null);\n\n const toggleLocale = () => {\n const nextLocale = locale === \"en\" ? \"ar\" : \"en\";\n setCurrentLocaleCode(nextLocale);\n setLocale(nextLocale);\n };\n\n return (\n <>\n {/*\n Self-contained, dependency-free styling: plain CSS, system fonts, and\n CSS custom properties, scoped to this page. No CSS framework, no utility\n classes, no external stylesheet — this page looks the same whether or\n not \\`warlock add tailwind\\` has ever been run.\n */}\n <style>{\\`\n .wk-home {\n --wk-fg: #0f172a;\n --wk-muted: #64748b;\n --wk-accent: #4f46e5;\n --wk-border: #e2e8f0;\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n color: var(--wk-fg);\n max-width: 42rem;\n margin: 4rem auto;\n padding: 0 1.5rem;\n line-height: 1.6;\n }\n .wk-home h1 { font-size: 2.25rem; margin: 0 0 0.5rem; }\n .wk-home p { color: var(--wk-muted); margin: 0 0 1.5rem; }\n .wk-home code {\n font-family: ui-monospace, \"SFMono-Regular\", Menlo, monospace;\n background: #f1f5f9;\n padding: 0.1rem 0.35rem;\n border-radius: 0.25rem;\n }\n .wk-check {\n border: 1px solid var(--wk-border);\n border-radius: 0.75rem;\n padding: 1.25rem 1.5rem;\n margin: 2rem 0;\n }\n .wk-check strong { display: block; font-size: 1.5rem; }\n .wk-check button {\n font: inherit;\n cursor: pointer;\n background: var(--wk-accent);\n color: #fff;\n border: 0;\n border-radius: 0.5rem;\n padding: 0.5rem 1rem;\n margin-top: 0.75rem;\n }\n .wk-links { display: flex; gap: 1.25rem; font-size: 0.95rem; }\n .wk-links a { color: var(--wk-accent); text-decoration: none; }\n .wk-links a:hover { text-decoration: underline; }\n .wk-language { margin-left: auto; }\n .wk-contact { margin-top: 2rem; }\n .wk-field { display: grid; gap: 0.35rem; margin: 0.8rem 0; }\n .wk-field input, .wk-field textarea { font: inherit; padding: 0.55rem; }\n .wk-field p, .wk-submit-error { color: #b91c1c; margin: 0; }\n .wk-success { color: #047857; }\n \\`}</style>\n\n <main className=\"wk-home\" dir={locale === \"ar\" ? \"rtl\" : \"ltr\"}>\n <nav className=\"wk-links\" aria-label=\"Starter links\">\n <a href=\"https://warlock.js.org\" target=\"_blank\" rel=\"noreferrer\">\n Docs\n </a>\n <Link href=\"/\" aria-current=\"page\">\n Home\n </Link>\n <button\n className=\"wk-language\"\n type=\"button\"\n aria-pressed={locale === \"ar\"}\n onClick={toggleLocale}\n >\n {transX(\"starter.language\")}\n </button>\n </nav>\n\n <h1>{transX(\"starter.title\")}</h1>\n <p>{transX(\"starter.introduction\")}</p>\n\n <section className=\"wk-check\">\n <label>If this number goes up when you click, React is hydrated:</label>\n <strong>{count}</strong>\n <button type=\"button\" onClick={() => setCount((c) => c + 1)}>\n Count up\n </button>\n </section>\n\n <section className=\"wk-contact\" aria-labelledby=\"contact-heading\">\n <h2 id=\"contact-heading\">{transX(\"starter.contact\")}</h2>\n <Form<typeof contactSchema>\n id=\"contact-form\"\n schema={contactSchema}\n onSubmit={async ({ form, values }) => {\n setSubmitted(false);\n setSubmitError(null);\n const result = await http.post<{ message: string }>(\"/api/contact\", values);\n\n if (result.error) {\n if (result.error.isValidationError) {\n const body = result.error.body as {\n errors?: Array<{ input: string; error: string }>;\n message?: string;\n };\n form.setErrors(\n Object.fromEntries(\n (body.errors ?? []).map(({ input, error }) => [input, error]),\n ),\n );\n setSubmitError(body.message ?? \"Please correct the highlighted fields.\");\n } else {\n setSubmitError(\"Your message could not be sent. Please try again.\");\n }\n return;\n }\n\n setSubmitted(true);\n form.reset();\n }}\n >\n <TextInput name=\"name\" label={transX(\"starter.name\")} autoComplete=\"name\" />\n <TextInput\n name=\"email\"\n label={transX(\"starter.email\")}\n type=\"email\"\n autoComplete=\"email\"\n />\n <ContactMessage />\n <button type=\"submit\">{transX(\"starter.submit\")}</button>\n {submitError && (\n <p className=\"wk-submit-error\" role=\"alert\">\n {submitError}\n </p>\n )}\n {submitted && (\n <p className=\"wk-success\" role=\"status\">\n {transX(\"starter.sent\")}\n </p>\n )}\n </Form>\n </section>\n </main>\n </>\n );\n}\n\nfunction ContactMessage() {\n const { error, getErrorProps, getInputProps } = useFormControl({ name: \"message\" });\n\n return (\n <div className=\"wk-field\">\n <label htmlFor=\"message\">{transX(\"starter.message\")}</label>\n <textarea {...getInputProps()} rows={5} />\n {error && <p {...getErrorProps()}>{error}</p>}\n </div>\n );\n}\n`;\n"],"mappings":";AAAA,MAAa,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BhC,MAAa,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B5B,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCnC,MAAa,2BAA2B;;AAGxC,MAAa,0BAA0B;;;;;;;;;;;;AAavC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8FvC,MAAa,+BAA+B;;AAG5C,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;AAsB3C,MAAa,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6ClC,MAAa,mBAAmB;;;;;;;;;;;;;;;;;AAkBhC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkFvC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCvC,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CrC,MAAa,4BAA4B;;;;;;;;;;;;;;;;;;;;;AAsBzC,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8F3C,MAAa,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCtC,MAAa,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuD3B,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BxC,MAAa,uBAAuB;;;;;;;;;;;;AAapC,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCnC,MAAa,kBAAkB"}
@@ -0,0 +1,32 @@
1
+ import { Response } from "./response.mjs";
2
+ import { Request } from "./request.mjs";
3
+
4
+ //#region ../core/src/http/csrf-default-guard.d.ts
5
+ /**
6
+ * Whether the current request is in scope for the default core CSRF-Origin
7
+ * guard at all.
8
+ */
9
+ declare function requiresDefaultCsrfGuard(request: Request): boolean;
10
+ /**
11
+ * Run the default core CSRF-Origin guard for the current request.
12
+ *
13
+ * Returns `undefined` when the request is out of scope or passes the
14
+ * Origin/Referer check — the caller continues into route middleware and the
15
+ * handler as normal. Returns the 403 {@link Response} when the guard refuses
16
+ * the request; the caller must return that value immediately, the same
17
+ * short-circuit contract every other middleware in the chain follows.
18
+ *
19
+ * Uses the SAME translated message and client-visible error code
20
+ * `authMiddleware("cookie:*")`'s own CSRF-Origin check uses
21
+ * (`auth.errors.csrfOriginMismatch`, `HttpErrorCodes.CsrfOriginMismatch` /
22
+ * `AuthErrorCodes.CsrfOriginMismatch`, both `"EC006"`) — one message, one
23
+ * error code, regardless of which seam caught the request. Logs exactly once
24
+ * per rejection here; a route that also carries `authMiddleware("cookie:*")`
25
+ * never reaches that middleware's own check for a request THIS guard already
26
+ * rejected, because a guard rejection short-circuits `runMiddleware()`
27
+ * entirely — so a rejected request is never logged twice.
28
+ */
29
+ declare function runDefaultCsrfGuard(request: Request, response: Response, translate: (key: string) => string, logRejection: (reason: string) => void): Promise<Response | undefined>;
30
+ //#endregion
31
+ export { requiresDefaultCsrfGuard, runDefaultCsrfGuard };
32
+ //# sourceMappingURL=csrf-default-guard.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf-default-guard.d.mts","names":[],"sources":["../../../../../../../core/src/http/csrf-default-guard.ts"],"mappings":";;;;;;AAyIA;;iBAAgB,wBAAA,CAAyB,OAAgB,EAAP,OAAO;;AAAA;AA2BzD;;;;;;;;;;;;;;;;;iBAAsB,mBAAA,CACpB,OAAA,EAAS,OAAA,EACT,QAAA,EAAU,QAAA,EACV,SAAA,GAAY,GAAA,qBACZ,YAAA,GAAe,MAAA,oBACd,OAAA,CAAQ,QAAA"}
@@ -0,0 +1,156 @@
1
+ import { LOCALE_COOKIE_NAME, LOCALE_PREFERENCE_COOKIE_NAME } from "../config/locale-configuration.mjs";
2
+ import { HttpErrorCodes } from "./error-codes.mjs";
3
+ import { resolveCsrfOriginVerdict } from "./csrf-origin-policy.mjs";
4
+
5
+ //#region ../core/src/http/csrf-default-guard.ts
6
+ /**
7
+ * The DEFAULT core CSRF-Origin guard — SECURITY, card 8a752ab2 (5.17), lead
8
+ * ruling from Aria (`releases/v5.17-web-readiness-audit.md` §3.1, quoted):
9
+ *
10
+ * > "Scope the default guard to unsafe browser requests carrying a
11
+ * > non-locale credential-looking cookie (fail closed on unknown names), at
12
+ * > an early core/auth HTTP seam before app handlers; give explicit,
13
+ * > narrowly scoped route-level exemptions for third-party callbacks and
14
+ * > machine-to-machine routes, documented as dangerous. Reuse current
15
+ * > same-origin/allowedOrigins policy, trustProxy-aware own origin, and fail
16
+ * > closed on missing Origin/Referer where in scope."
17
+ *
18
+ * Before this guard, the CSRF Origin check (`csrf-origin-policy.ts`, moved
19
+ * here from `@warlock.js/auth` in this same change) only ran inside
20
+ * `authMiddleware("cookie:*")` — a cookie-authenticated write reaching any
21
+ * OTHER path (an app-owned optional-auth pattern reading its own `token`
22
+ * cookie without ever calling `authMiddleware`) was never checked at all.
23
+ *
24
+ * This guard closes that gap at the earliest seam common to EVERY request —
25
+ * production (`Router.scan()`) and the dev wildcard dispatcher both funnel
26
+ * into `Router["handleRoute"]` → `request.execute()` →
27
+ * `createRequestStore()` (`./middleware/inject-request-context.ts`), which is
28
+ * where this is wired, BEFORE `request.runMiddleware()` runs the route's own
29
+ * middleware (so it applies with or without `authMiddleware` on the route)
30
+ * and before any app handler. It lives in `@warlock.js/core`, not
31
+ * `@warlock.js/auth`, because `core` — not `auth` — is a dependency of every
32
+ * Warlock app, so this is the only seam that reaches an app that never
33
+ * installs `@warlock.js/auth` at all.
34
+ *
35
+ * Scope (mirrors `web`'s page-cache Cookie-header bypass rule,
36
+ * `web/src/server/page-cache-cookie-bypass.ts`, card ad861076 — same
37
+ * fail-closed strict-parse posture, independently reimplemented here because
38
+ * `core` cannot depend on `web`, the dependency runs the other way):
39
+ * - method is POST/PUT/PATCH/DELETE, AND
40
+ * - the `Cookie` header carries any cookie other than the framework's own
41
+ * `locale` cookies (legacy plus the browser preference; a header that fails to parse cleanly counts as
42
+ * carrying one — fail closed), AND
43
+ * - the route is not exempted via `{ csrf: false }` (`RouteOptions.csrf`,
44
+ * `../router/types.ts`) — a narrow, per-route, explicitly "dangerous" opt
45
+ * out for third-party callbacks and machine-to-machine routes.
46
+ *
47
+ * A header-only API request (`Authorization: Bearer …`, no `Cookie` header
48
+ * at all) never reaches the cookie check above and is entirely unaffected.
49
+ */
50
+ /** HTTP methods the default CSRF guard is scoped to — every unsafe method. */
51
+ const UNSAFE_METHODS = new Set([
52
+ "POST",
53
+ "PUT",
54
+ "PATCH",
55
+ "DELETE"
56
+ ]);
57
+ /**
58
+ * Strictly parse a raw `Cookie` request header into a set of cookie names, or
59
+ * `undefined` when the header does not parse cleanly.
60
+ *
61
+ * Deliberately NOT a lenient cookie parser that silently drops malformed
62
+ * pairs and returns whatever it could salvage: a header this function cannot
63
+ * fully account for must read as "unknown cookies present", never as "the
64
+ * pairs we understood, minus the ones we didn't" — dropping a pair is how a
65
+ * malformed header could otherwise be misread as carrying only the locale
66
+ * cookie (or none) and wrongly skip the guard. Mirrors
67
+ * `web/src/server/page-cache-cookie-bypass.ts`'s `parseCookieHeaderStrict`.
68
+ *
69
+ * Fails (`undefined`) on:
70
+ * - an empty or whitespace-only header;
71
+ * - any empty segment between/around `;`;
72
+ * - any segment with no `=`, or an empty name.
73
+ */
74
+ function parseCookieNamesStrict(rawHeader) {
75
+ if (rawHeader.trim() === "") return void 0;
76
+ const names = /* @__PURE__ */ new Set();
77
+ for (const segment of rawHeader.split(";")) {
78
+ const pair = segment.trim();
79
+ if (pair === "") return void 0;
80
+ const separatorIndex = pair.indexOf("=");
81
+ if (separatorIndex <= 0) return void 0;
82
+ const name = pair.slice(0, separatorIndex).trim();
83
+ if (name === "") return void 0;
84
+ names.add(name);
85
+ }
86
+ return names;
87
+ }
88
+ /**
89
+ * Whether the request carries a `Cookie` header naming anything other than
90
+ * the framework's locale cookies.
91
+ *
92
+ * - No `Cookie` header at all ⇒ `false` — nothing to guard on.
93
+ * - A header that fails to parse cleanly ⇒ `true` — fails CLOSED.
94
+ * - A header whose parsed names are a non-empty set equal to exactly
95
+ * `{locale}` ⇒ `false` — the one exemption.
96
+ * - Anything else (any other cookie name, alone or alongside `locale`) ⇒ `true`.
97
+ */
98
+ function carriesNonLocaleCookie(request) {
99
+ const rawCookieHeader = request.header("cookie", void 0);
100
+ if (rawCookieHeader === void 0 || rawCookieHeader === null) return false;
101
+ if (typeof rawCookieHeader !== "string") return true;
102
+ const names = parseCookieNamesStrict(rawCookieHeader);
103
+ if (names === void 0 || names.size === 0) return true;
104
+ for (const name of names) if (name !== "locale" && name !== "warlock.locale-preference") return true;
105
+ return false;
106
+ }
107
+ /**
108
+ * Whether the matched route opted out of the default CSRF guard via
109
+ * `{ csrf: false }` (`RouteOptions.csrf`). Any other value — including
110
+ * `undefined` — leaves the route in scope.
111
+ */
112
+ function isCsrfExempt(request) {
113
+ return request.route?.csrf === false;
114
+ }
115
+ /**
116
+ * Whether the current request is in scope for the default core CSRF-Origin
117
+ * guard at all.
118
+ */
119
+ function requiresDefaultCsrfGuard(request) {
120
+ if (!UNSAFE_METHODS.has(request.method.toUpperCase())) return false;
121
+ if (isCsrfExempt(request)) return false;
122
+ return carriesNonLocaleCookie(request);
123
+ }
124
+ /**
125
+ * Run the default core CSRF-Origin guard for the current request.
126
+ *
127
+ * Returns `undefined` when the request is out of scope or passes the
128
+ * Origin/Referer check — the caller continues into route middleware and the
129
+ * handler as normal. Returns the 403 {@link Response} when the guard refuses
130
+ * the request; the caller must return that value immediately, the same
131
+ * short-circuit contract every other middleware in the chain follows.
132
+ *
133
+ * Uses the SAME translated message and client-visible error code
134
+ * `authMiddleware("cookie:*")`'s own CSRF-Origin check uses
135
+ * (`auth.errors.csrfOriginMismatch`, `HttpErrorCodes.CsrfOriginMismatch` /
136
+ * `AuthErrorCodes.CsrfOriginMismatch`, both `"EC006"`) — one message, one
137
+ * error code, regardless of which seam caught the request. Logs exactly once
138
+ * per rejection here; a route that also carries `authMiddleware("cookie:*")`
139
+ * never reaches that middleware's own check for a request THIS guard already
140
+ * rejected, because a guard rejection short-circuits `runMiddleware()`
141
+ * entirely — so a rejected request is never logged twice.
142
+ */
143
+ async function runDefaultCsrfGuard(request, response, translate, logRejection) {
144
+ if (!requiresDefaultCsrfGuard(request)) return void 0;
145
+ const verdict = resolveCsrfOriginVerdict(request);
146
+ if (verdict.allowed) return void 0;
147
+ logRejection(verdict.reason);
148
+ return response.forbidden({
149
+ error: translate("auth.errors.csrfOriginMismatch"),
150
+ errorCode: "EC006"
151
+ });
152
+ }
153
+
154
+ //#endregion
155
+ export { requiresDefaultCsrfGuard, runDefaultCsrfGuard };
156
+ //# sourceMappingURL=csrf-default-guard.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf-default-guard.mjs","names":[],"sources":["../../../../../../../core/src/http/csrf-default-guard.ts"],"sourcesContent":["/**\n * The DEFAULT core CSRF-Origin guard — SECURITY, card 8a752ab2 (5.17), lead\n * ruling from Aria (`releases/v5.17-web-readiness-audit.md` §3.1, quoted):\n *\n * > \"Scope the default guard to unsafe browser requests carrying a\n * > non-locale credential-looking cookie (fail closed on unknown names), at\n * > an early core/auth HTTP seam before app handlers; give explicit,\n * > narrowly scoped route-level exemptions for third-party callbacks and\n * > machine-to-machine routes, documented as dangerous. Reuse current\n * > same-origin/allowedOrigins policy, trustProxy-aware own origin, and fail\n * > closed on missing Origin/Referer where in scope.\"\n *\n * Before this guard, the CSRF Origin check (`csrf-origin-policy.ts`, moved\n * here from `@warlock.js/auth` in this same change) only ran inside\n * `authMiddleware(\"cookie:*\")` — a cookie-authenticated write reaching any\n * OTHER path (an app-owned optional-auth pattern reading its own `token`\n * cookie without ever calling `authMiddleware`) was never checked at all.\n *\n * This guard closes that gap at the earliest seam common to EVERY request —\n * production (`Router.scan()`) and the dev wildcard dispatcher both funnel\n * into `Router[\"handleRoute\"]` → `request.execute()` →\n * `createRequestStore()` (`./middleware/inject-request-context.ts`), which is\n * where this is wired, BEFORE `request.runMiddleware()` runs the route's own\n * middleware (so it applies with or without `authMiddleware` on the route)\n * and before any app handler. It lives in `@warlock.js/core`, not\n * `@warlock.js/auth`, because `core` — not `auth` — is a dependency of every\n * Warlock app, so this is the only seam that reaches an app that never\n * installs `@warlock.js/auth` at all.\n *\n * Scope (mirrors `web`'s page-cache Cookie-header bypass rule,\n * `web/src/server/page-cache-cookie-bypass.ts`, card ad861076 — same\n * fail-closed strict-parse posture, independently reimplemented here because\n * `core` cannot depend on `web`, the dependency runs the other way):\n * - method is POST/PUT/PATCH/DELETE, AND\n * - the `Cookie` header carries any cookie other than the framework's own\n * `locale` cookies (legacy plus the browser preference; a header that fails to parse cleanly counts as\n * carrying one — fail closed), AND\n * - the route is not exempted via `{ csrf: false }` (`RouteOptions.csrf`,\n * `../router/types.ts`) — a narrow, per-route, explicitly \"dangerous\" opt\n * out for third-party callbacks and machine-to-machine routes.\n *\n * A header-only API request (`Authorization: Bearer …`, no `Cookie` header\n * at all) never reaches the cookie check above and is entirely unaffected.\n */\nimport { LOCALE_COOKIE_NAME, LOCALE_PREFERENCE_COOKIE_NAME } from \"../config/locale-configuration\";\nimport { HttpErrorCodes } from \"./error-codes\";\nimport { resolveCsrfOriginVerdict } from \"./csrf-origin-policy\";\nimport type { Request } from \"./request\";\nimport type { Response } from \"./response\";\n\n/** HTTP methods the default CSRF guard is scoped to — every unsafe method. */\nconst UNSAFE_METHODS = new Set([\"POST\", \"PUT\", \"PATCH\", \"DELETE\"]);\n\n/**\n * Strictly parse a raw `Cookie` request header into a set of cookie names, or\n * `undefined` when the header does not parse cleanly.\n *\n * Deliberately NOT a lenient cookie parser that silently drops malformed\n * pairs and returns whatever it could salvage: a header this function cannot\n * fully account for must read as \"unknown cookies present\", never as \"the\n * pairs we understood, minus the ones we didn't\" — dropping a pair is how a\n * malformed header could otherwise be misread as carrying only the locale\n * cookie (or none) and wrongly skip the guard. Mirrors\n * `web/src/server/page-cache-cookie-bypass.ts`'s `parseCookieHeaderStrict`.\n *\n * Fails (`undefined`) on:\n * - an empty or whitespace-only header;\n * - any empty segment between/around `;`;\n * - any segment with no `=`, or an empty name.\n */\nfunction parseCookieNamesStrict(rawHeader: string): Set<string> | undefined {\n if (rawHeader.trim() === \"\") return undefined;\n\n const names = new Set<string>();\n\n for (const segment of rawHeader.split(\";\")) {\n const pair = segment.trim();\n\n if (pair === \"\") return undefined;\n\n const separatorIndex = pair.indexOf(\"=\");\n\n if (separatorIndex <= 0) return undefined;\n\n const name = pair.slice(0, separatorIndex).trim();\n\n if (name === \"\") return undefined;\n\n names.add(name);\n }\n\n return names;\n}\n\n/**\n * Whether the request carries a `Cookie` header naming anything other than\n * the framework's locale cookies.\n *\n * - No `Cookie` header at all ⇒ `false` — nothing to guard on.\n * - A header that fails to parse cleanly ⇒ `true` — fails CLOSED.\n * - A header whose parsed names are a non-empty set equal to exactly\n * `{locale}` ⇒ `false` — the one exemption.\n * - Anything else (any other cookie name, alone or alongside `locale`) ⇒ `true`.\n */\nfunction carriesNonLocaleCookie(request: Request): boolean {\n const rawCookieHeader = request.header(\"cookie\", undefined);\n\n // Only an ABSENT header is cookie-free. A present header in any other\n // shape (an array from a duplicated Cookie header) fails closed.\n if (rawCookieHeader === undefined || rawCookieHeader === null) return false;\n\n if (typeof rawCookieHeader !== \"string\") return true;\n\n const names = parseCookieNamesStrict(rawCookieHeader);\n\n if (names === undefined || names.size === 0) return true;\n\n for (const name of names) {\n if (name !== LOCALE_COOKIE_NAME && name !== LOCALE_PREFERENCE_COOKIE_NAME) return true;\n }\n\n return false;\n}\n\n/**\n * Whether the matched route opted out of the default CSRF guard via\n * `{ csrf: false }` (`RouteOptions.csrf`). Any other value — including\n * `undefined` — leaves the route in scope.\n */\nfunction isCsrfExempt(request: Request): boolean {\n return request.route?.csrf === false;\n}\n\n/**\n * Whether the current request is in scope for the default core CSRF-Origin\n * guard at all.\n */\nexport function requiresDefaultCsrfGuard(request: Request): boolean {\n if (!UNSAFE_METHODS.has(request.method.toUpperCase())) return false;\n\n if (isCsrfExempt(request)) return false;\n\n return carriesNonLocaleCookie(request);\n}\n\n/**\n * Run the default core CSRF-Origin guard for the current request.\n *\n * Returns `undefined` when the request is out of scope or passes the\n * Origin/Referer check — the caller continues into route middleware and the\n * handler as normal. Returns the 403 {@link Response} when the guard refuses\n * the request; the caller must return that value immediately, the same\n * short-circuit contract every other middleware in the chain follows.\n *\n * Uses the SAME translated message and client-visible error code\n * `authMiddleware(\"cookie:*\")`'s own CSRF-Origin check uses\n * (`auth.errors.csrfOriginMismatch`, `HttpErrorCodes.CsrfOriginMismatch` /\n * `AuthErrorCodes.CsrfOriginMismatch`, both `\"EC006\"`) — one message, one\n * error code, regardless of which seam caught the request. Logs exactly once\n * per rejection here; a route that also carries `authMiddleware(\"cookie:*\")`\n * never reaches that middleware's own check for a request THIS guard already\n * rejected, because a guard rejection short-circuits `runMiddleware()`\n * entirely — so a rejected request is never logged twice.\n */\nexport async function runDefaultCsrfGuard(\n request: Request,\n response: Response,\n translate: (key: string) => string,\n logRejection: (reason: string) => void,\n): Promise<Response | undefined> {\n if (!requiresDefaultCsrfGuard(request)) return undefined;\n\n const verdict = resolveCsrfOriginVerdict(request);\n\n if (verdict.allowed) return undefined;\n\n logRejection(verdict.reason);\n\n return response.forbidden({\n error: translate(\"auth.errors.csrfOriginMismatch\"),\n errorCode: HttpErrorCodes.CsrfOriginMismatch,\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmDA,MAAM,iBAAiB,IAAI,IAAI;CAAC;CAAQ;CAAO;CAAS;AAAQ,CAAC;;;;;;;;;;;;;;;;;;AAmBjE,SAAS,uBAAuB,WAA4C;CAC1E,IAAI,UAAU,KAAK,MAAM,IAAI,OAAO;CAEpC,MAAM,wBAAQ,IAAI,IAAY;CAE9B,KAAK,MAAM,WAAW,UAAU,MAAM,GAAG,GAAG;EAC1C,MAAM,OAAO,QAAQ,KAAK;EAE1B,IAAI,SAAS,IAAI,OAAO;EAExB,MAAM,iBAAiB,KAAK,QAAQ,GAAG;EAEvC,IAAI,kBAAkB,GAAG,OAAO;EAEhC,MAAM,OAAO,KAAK,MAAM,GAAG,cAAc,CAAC,CAAC,KAAK;EAEhD,IAAI,SAAS,IAAI,OAAO;EAExB,MAAM,IAAI,IAAI;CAChB;CAEA,OAAO;AACT;;;;;;;;;;;AAYA,SAAS,uBAAuB,SAA2B;CACzD,MAAM,kBAAkB,QAAQ,OAAO,UAAU,MAAS;CAI1D,IAAI,oBAAoB,UAAa,oBAAoB,MAAM,OAAO;CAEtE,IAAI,OAAO,oBAAoB,UAAU,OAAO;CAEhD,MAAM,QAAQ,uBAAuB,eAAe;CAEpD,IAAI,UAAU,UAAa,MAAM,SAAS,GAAG,OAAO;CAEpD,KAAK,MAAM,QAAQ,OACjB,IAAI,qBAA+B,sCAAwC,OAAO;CAGpF,OAAO;AACT;;;;;;AAOA,SAAS,aAAa,SAA2B;CAC/C,OAAO,QAAQ,OAAO,SAAS;AACjC;;;;;AAMA,SAAgB,yBAAyB,SAA2B;CAClE,IAAI,CAAC,eAAe,IAAI,QAAQ,OAAO,YAAY,CAAC,GAAG,OAAO;CAE9D,IAAI,aAAa,OAAO,GAAG,OAAO;CAElC,OAAO,uBAAuB,OAAO;AACvC;;;;;;;;;;;;;;;;;;;;AAqBA,eAAsB,oBACpB,SACA,UACA,WACA,cAC+B;CAC/B,IAAI,CAAC,yBAAyB,OAAO,GAAG,OAAO;CAE/C,MAAM,UAAU,yBAAyB,OAAO;CAEhD,IAAI,QAAQ,SAAS,OAAO;CAE5B,aAAa,QAAQ,MAAM;CAE3B,OAAO,SAAS,UAAU;EACxB,OAAO,UAAU,gCAAgC;EACjD;CACF,CAAC;AACH"}
@@ -0,0 +1,53 @@
1
+ import { Request } from "./request.mjs";
2
+
3
+ //#region ../core/src/http/csrf-origin-policy.d.ts
4
+ /** Why {@link resolveCsrfOriginVerdict} refused a request. */
5
+ type CsrfOriginCheckReason = "origin-mismatch" | "referer-mismatch" | "missing-origin-and-referer";
6
+ /** The verdict {@link resolveCsrfOriginVerdict} reaches for one request. */
7
+ type CsrfOriginVerdict = {
8
+ allowed: true;
9
+ } | {
10
+ allowed: false;
11
+ reason: CsrfOriginCheckReason;
12
+ };
13
+ /**
14
+ * The request's own origin — what an `Origin`/`Referer` header must match.
15
+ *
16
+ * `request.hostname` (core's `Request`, backed by Fastify's `hostname`) never
17
+ * carries a port. A browser's `Origin` header on a non-default port (e.g. any
18
+ * `warlock dev` session) does, so the port must come from the raw `Host`
19
+ * header instead — core exposes no getter for that, so it is read directly
20
+ * here.
21
+ *
22
+ * Trust-proxy-aware for free: `request.protocol` is Fastify's own
23
+ * `request.protocol`, which already honours `http.trustProxy`
24
+ * (`X-Forwarded-Proto`) when the app has configured it (`server.ts`) — this
25
+ * function does not need its own proxy-trust logic on top of that.
26
+ */
27
+ declare function ownOrigin(request: Request): string;
28
+ /** Extract `scheme://host` from a full URL (e.g. a `Referer` header value). */
29
+ declare function originOf(rawUrl: string): string | undefined;
30
+ /**
31
+ * Normalize an origin string (`scheme://host[:port]`) so that a default port
32
+ * (`:80` on `http:`, `:443` on `https:`) compares equal to the same origin
33
+ * written without a port. Falls back to the raw value if it does not parse
34
+ * as a URL (in which case it will simply fail the exact-match comparison).
35
+ */
36
+ declare function normalizeOrigin(origin: string): string;
37
+ /** Same-origin, or an explicit entry in `auth.csrf.allowedOrigins` (default `[]`). */
38
+ declare function isAllowedCsrfOrigin(origin: string, request: Request): boolean;
39
+ /**
40
+ * The shared CSRF Origin/Referer verdict (lead decision, card 8a752ab2 §3.1):
41
+ * allowed when `Origin` — or, when `Origin` is absent, `Referer` — names the
42
+ * request's own origin or an entry in `auth.csrf.allowedOrigins`. Refused
43
+ * otherwise, including when BOTH headers are absent (fail closed).
44
+ *
45
+ * Callers decide WHETHER a request is in scope for this check at all (a
46
+ * cookie-sourced credential / a non-locale cookie, an unsafe method, no route
47
+ * exemption) — this function only answers the Origin/Referer question once a
48
+ * caller has already decided the check applies.
49
+ */
50
+ declare function resolveCsrfOriginVerdict(request: Request): CsrfOriginVerdict;
51
+ //#endregion
52
+ export { CsrfOriginCheckReason, CsrfOriginVerdict, isAllowedCsrfOrigin, normalizeOrigin, originOf, ownOrigin, resolveCsrfOriginVerdict };
53
+ //# sourceMappingURL=csrf-origin-policy.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf-origin-policy.d.mts","names":[],"sources":["../../../../../../../core/src/http/csrf-origin-policy.ts"],"mappings":";;;;KAyBY,qBAAA;AAAZ;AAAA,KAMY,iBAAA;EACN,OAAA;AAAA;EACA,OAAA;EAAgB,MAAA,EAAQ,qBAAqB;AAAA;;;;;;;;AAAA;AAgBnD;;;;AAA0C;AAQ1C;iBARgB,SAAA,CAAU,OAAgB,EAAP,OAAO;;iBAQ1B,QAAA,CAAS,MAAc;AAAA;AAgBvC;;;;AAA8C;AAhBP,iBAgBvB,eAAA,CAAgB,MAAc;;iBAc9B,mBAAA,CAAoB,MAAA,UAAgB,OAAA,EAAS,OAAO;;;;;;AAAA;AAmBpE;;;;;iBAAgB,wBAAA,CAAyB,OAAA,EAAS,OAAA,GAAU,iBAAiB"}
@@ -0,0 +1,104 @@
1
+ import config from "@mongez/config";
2
+
3
+ //#region ../core/src/http/csrf-origin-policy.ts
4
+ /**
5
+ * The Origin/Referer same-origin (or `auth.csrf.allowedOrigins`) policy — the
6
+ * one comparison every CSRF-Origin check in the framework must agree on.
7
+ *
8
+ * SECURITY, card 8a752ab2 (5.17). Extracted from
9
+ * `@warlock.js/auth`'s `csrf-origin-check.ts` (the ONLY prior owner of this
10
+ * logic) into `@warlock.js/core` so both call sites — the
11
+ * `authMiddleware("cookie:*")` check in `@warlock.js/auth` and the default
12
+ * core HTTP-seam guard in `./csrf-default-guard.ts` — share ONE
13
+ * implementation instead of drifting copies. `@warlock.js/core` cannot depend
14
+ * on `@warlock.js/auth` (the dependency runs the other way), so the shared
15
+ * logic had to move down into `core`, not up into a new shared package —
16
+ * `auth`'s `csrf-origin-check.ts` now delegates to
17
+ * {@link resolveCsrfOriginVerdict} instead of reimplementing it.
18
+ *
19
+ * Reads `auth.csrf.allowedOrigins` directly via `@mongez/config` (the exact
20
+ * same config key `@warlock.js/auth`'s `authConfig.csrf.allowedOrigins()`
21
+ * reads) rather than importing `@warlock.js/auth`, so an app's one
22
+ * `auth.csrf.allowedOrigins` setting governs both checks without `core`
23
+ * taking a hard dependency on `auth`.
24
+ */
25
+ /**
26
+ * The request's own origin — what an `Origin`/`Referer` header must match.
27
+ *
28
+ * `request.hostname` (core's `Request`, backed by Fastify's `hostname`) never
29
+ * carries a port. A browser's `Origin` header on a non-default port (e.g. any
30
+ * `warlock dev` session) does, so the port must come from the raw `Host`
31
+ * header instead — core exposes no getter for that, so it is read directly
32
+ * here.
33
+ *
34
+ * Trust-proxy-aware for free: `request.protocol` is Fastify's own
35
+ * `request.protocol`, which already honours `http.trustProxy`
36
+ * (`X-Forwarded-Proto`) when the app has configured it (`server.ts`) — this
37
+ * function does not need its own proxy-trust logic on top of that.
38
+ */
39
+ function ownOrigin(request) {
40
+ const hostHeader = request.header("host");
41
+ const host = typeof hostHeader === "string" && hostHeader ? hostHeader : request.hostname;
42
+ return `${request.protocol}://${host}`;
43
+ }
44
+ /** Extract `scheme://host` from a full URL (e.g. a `Referer` header value). */
45
+ function originOf(rawUrl) {
46
+ try {
47
+ const url = new URL(rawUrl);
48
+ return `${url.protocol}//${url.host}`;
49
+ } catch {
50
+ return;
51
+ }
52
+ }
53
+ /**
54
+ * Normalize an origin string (`scheme://host[:port]`) so that a default port
55
+ * (`:80` on `http:`, `:443` on `https:`) compares equal to the same origin
56
+ * written without a port. Falls back to the raw value if it does not parse
57
+ * as a URL (in which case it will simply fail the exact-match comparison).
58
+ */
59
+ function normalizeOrigin(origin) {
60
+ try {
61
+ const url = new URL(origin);
62
+ const isDefaultPort = url.protocol === "http:" && (url.port === "" || url.port === "80") || url.protocol === "https:" && (url.port === "" || url.port === "443");
63
+ return `${url.protocol}//${isDefaultPort ? url.hostname : url.host}`;
64
+ } catch {
65
+ return origin;
66
+ }
67
+ }
68
+ /** Same-origin, or an explicit entry in `auth.csrf.allowedOrigins` (default `[]`). */
69
+ function isAllowedCsrfOrigin(origin, request) {
70
+ if (normalizeOrigin(origin) === normalizeOrigin(ownOrigin(request))) return true;
71
+ return config.get("auth.csrf.allowedOrigins", []).includes(origin);
72
+ }
73
+ /**
74
+ * The shared CSRF Origin/Referer verdict (lead decision, card 8a752ab2 §3.1):
75
+ * allowed when `Origin` — or, when `Origin` is absent, `Referer` — names the
76
+ * request's own origin or an entry in `auth.csrf.allowedOrigins`. Refused
77
+ * otherwise, including when BOTH headers are absent (fail closed).
78
+ *
79
+ * Callers decide WHETHER a request is in scope for this check at all (a
80
+ * cookie-sourced credential / a non-locale cookie, an unsafe method, no route
81
+ * exemption) — this function only answers the Origin/Referer question once a
82
+ * caller has already decided the check applies.
83
+ */
84
+ function resolveCsrfOriginVerdict(request) {
85
+ const origin = request.origin;
86
+ if (origin) return isAllowedCsrfOrigin(origin, request) ? { allowed: true } : {
87
+ allowed: false,
88
+ reason: "origin-mismatch"
89
+ };
90
+ const referer = request.header("referer");
91
+ const refererOrigin = typeof referer === "string" ? originOf(referer) : void 0;
92
+ if (refererOrigin) return isAllowedCsrfOrigin(refererOrigin, request) ? { allowed: true } : {
93
+ allowed: false,
94
+ reason: "referer-mismatch"
95
+ };
96
+ return {
97
+ allowed: false,
98
+ reason: "missing-origin-and-referer"
99
+ };
100
+ }
101
+
102
+ //#endregion
103
+ export { isAllowedCsrfOrigin, normalizeOrigin, originOf, ownOrigin, resolveCsrfOriginVerdict };
104
+ //# sourceMappingURL=csrf-origin-policy.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf-origin-policy.mjs","names":[],"sources":["../../../../../../../core/src/http/csrf-origin-policy.ts"],"sourcesContent":["/**\n * The Origin/Referer same-origin (or `auth.csrf.allowedOrigins`) policy — the\n * one comparison every CSRF-Origin check in the framework must agree on.\n *\n * SECURITY, card 8a752ab2 (5.17). Extracted from\n * `@warlock.js/auth`'s `csrf-origin-check.ts` (the ONLY prior owner of this\n * logic) into `@warlock.js/core` so both call sites — the\n * `authMiddleware(\"cookie:*\")` check in `@warlock.js/auth` and the default\n * core HTTP-seam guard in `./csrf-default-guard.ts` — share ONE\n * implementation instead of drifting copies. `@warlock.js/core` cannot depend\n * on `@warlock.js/auth` (the dependency runs the other way), so the shared\n * logic had to move down into `core`, not up into a new shared package —\n * `auth`'s `csrf-origin-check.ts` now delegates to\n * {@link resolveCsrfOriginVerdict} instead of reimplementing it.\n *\n * Reads `auth.csrf.allowedOrigins` directly via `@mongez/config` (the exact\n * same config key `@warlock.js/auth`'s `authConfig.csrf.allowedOrigins()`\n * reads) rather than importing `@warlock.js/auth`, so an app's one\n * `auth.csrf.allowedOrigins` setting governs both checks without `core`\n * taking a hard dependency on `auth`.\n */\nimport config from \"@mongez/config\";\nimport type { Request } from \"./request\";\n\n/** Why {@link resolveCsrfOriginVerdict} refused a request. */\nexport type CsrfOriginCheckReason =\n | \"origin-mismatch\"\n | \"referer-mismatch\"\n | \"missing-origin-and-referer\";\n\n/** The verdict {@link resolveCsrfOriginVerdict} reaches for one request. */\nexport type CsrfOriginVerdict =\n | { allowed: true }\n | { allowed: false; reason: CsrfOriginCheckReason };\n\n/**\n * The request's own origin — what an `Origin`/`Referer` header must match.\n *\n * `request.hostname` (core's `Request`, backed by Fastify's `hostname`) never\n * carries a port. A browser's `Origin` header on a non-default port (e.g. any\n * `warlock dev` session) does, so the port must come from the raw `Host`\n * header instead — core exposes no getter for that, so it is read directly\n * here.\n *\n * Trust-proxy-aware for free: `request.protocol` is Fastify's own\n * `request.protocol`, which already honours `http.trustProxy`\n * (`X-Forwarded-Proto`) when the app has configured it (`server.ts`) — this\n * function does not need its own proxy-trust logic on top of that.\n */\nexport function ownOrigin(request: Request): string {\n const hostHeader = request.header(\"host\");\n const host = typeof hostHeader === \"string\" && hostHeader ? hostHeader : request.hostname;\n\n return `${request.protocol}://${host}`;\n}\n\n/** Extract `scheme://host` from a full URL (e.g. a `Referer` header value). */\nexport function originOf(rawUrl: string): string | undefined {\n try {\n const url = new URL(rawUrl);\n\n return `${url.protocol}//${url.host}`;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Normalize an origin string (`scheme://host[:port]`) so that a default port\n * (`:80` on `http:`, `:443` on `https:`) compares equal to the same origin\n * written without a port. Falls back to the raw value if it does not parse\n * as a URL (in which case it will simply fail the exact-match comparison).\n */\nexport function normalizeOrigin(origin: string): string {\n try {\n const url = new URL(origin);\n const isDefaultPort =\n (url.protocol === \"http:\" && (url.port === \"\" || url.port === \"80\")) ||\n (url.protocol === \"https:\" && (url.port === \"\" || url.port === \"443\"));\n\n return `${url.protocol}//${isDefaultPort ? url.hostname : url.host}`;\n } catch {\n return origin;\n }\n}\n\n/** Same-origin, or an explicit entry in `auth.csrf.allowedOrigins` (default `[]`). */\nexport function isAllowedCsrfOrigin(origin: string, request: Request): boolean {\n if (normalizeOrigin(origin) === normalizeOrigin(ownOrigin(request))) return true;\n\n const allowedOrigins: string[] = config.get(\"auth.csrf.allowedOrigins\", []);\n\n return allowedOrigins.includes(origin);\n}\n\n/**\n * The shared CSRF Origin/Referer verdict (lead decision, card 8a752ab2 §3.1):\n * allowed when `Origin` — or, when `Origin` is absent, `Referer` — names the\n * request's own origin or an entry in `auth.csrf.allowedOrigins`. Refused\n * otherwise, including when BOTH headers are absent (fail closed).\n *\n * Callers decide WHETHER a request is in scope for this check at all (a\n * cookie-sourced credential / a non-locale cookie, an unsafe method, no route\n * exemption) — this function only answers the Origin/Referer question once a\n * caller has already decided the check applies.\n */\nexport function resolveCsrfOriginVerdict(request: Request): CsrfOriginVerdict {\n const origin = request.origin;\n\n if (origin) {\n return isAllowedCsrfOrigin(origin, request)\n ? { allowed: true }\n : { allowed: false, reason: \"origin-mismatch\" };\n }\n\n const referer = request.header(\"referer\");\n const refererOrigin = typeof referer === \"string\" ? originOf(referer) : undefined;\n\n if (refererOrigin) {\n return isAllowedCsrfOrigin(refererOrigin, request)\n ? { allowed: true }\n : { allowed: false, reason: \"referer-mismatch\" };\n }\n\n return { allowed: false, reason: \"missing-origin-and-referer\" };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiDA,SAAgB,UAAU,SAA0B;CAClD,MAAM,aAAa,QAAQ,OAAO,MAAM;CACxC,MAAM,OAAO,OAAO,eAAe,YAAY,aAAa,aAAa,QAAQ;CAEjF,OAAO,GAAG,QAAQ,SAAS,KAAK;AAClC;;AAGA,SAAgB,SAAS,QAAoC;CAC3D,IAAI;EACF,MAAM,MAAM,IAAI,IAAI,MAAM;EAE1B,OAAO,GAAG,IAAI,SAAS,IAAI,IAAI;CACjC,QAAQ;EACN;CACF;AACF;;;;;;;AAQA,SAAgB,gBAAgB,QAAwB;CACtD,IAAI;EACF,MAAM,MAAM,IAAI,IAAI,MAAM;EAC1B,MAAM,gBACH,IAAI,aAAa,YAAY,IAAI,SAAS,MAAM,IAAI,SAAS,SAC7D,IAAI,aAAa,aAAa,IAAI,SAAS,MAAM,IAAI,SAAS;EAEjE,OAAO,GAAG,IAAI,SAAS,IAAI,gBAAgB,IAAI,WAAW,IAAI;CAChE,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,oBAAoB,QAAgB,SAA2B;CAC7E,IAAI,gBAAgB,MAAM,MAAM,gBAAgB,UAAU,OAAO,CAAC,GAAG,OAAO;CAI5E,OAFiC,OAAO,IAAI,4BAA4B,CAAC,CAErD,CAAC,CAAC,SAAS,MAAM;AACvC;;;;;;;;;;;;AAaA,SAAgB,yBAAyB,SAAqC;CAC5E,MAAM,SAAS,QAAQ;CAEvB,IAAI,QACF,OAAO,oBAAoB,QAAQ,OAAO,IACtC,EAAE,SAAS,KAAK,IAChB;EAAE,SAAS;EAAO,QAAQ;CAAkB;CAGlD,MAAM,UAAU,QAAQ,OAAO,SAAS;CACxC,MAAM,gBAAgB,OAAO,YAAY,WAAW,SAAS,OAAO,IAAI;CAExE,IAAI,eACF,OAAO,oBAAoB,eAAe,OAAO,IAC7C,EAAE,SAAS,KAAK,IAChB;EAAE,SAAS;EAAO,QAAQ;CAAmB;CAGnD,OAAO;EAAE,SAAS;EAAO,QAAQ;CAA6B;AAChE"}