@shware/workflow 6.1.0 → 7.0.0

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 (114) hide show
  1. package/README.md +19 -3
  2. package/dist/aws/index.cjs +3 -2
  3. package/dist/aws/index.cjs.map +1 -1
  4. package/dist/aws/index.mjs +3 -2
  5. package/dist/aws/index.mjs.map +1 -1
  6. package/dist/cloudflare/worker.cjs +1 -1
  7. package/dist/cloudflare/worker.cjs.map +1 -1
  8. package/dist/cloudflare/worker.mjs +1 -1
  9. package/dist/cloudflare/worker.mjs.map +1 -1
  10. package/dist/email-sender.cjs +8 -1
  11. package/dist/email-sender.cjs.map +1 -1
  12. package/dist/email-sender.d.cts +2 -2
  13. package/dist/email-sender.d.cts.map +1 -1
  14. package/dist/email-sender.d.mts +2 -2
  15. package/dist/email-sender.d.mts.map +1 -1
  16. package/dist/email-sender.mjs +9 -2
  17. package/dist/email-sender.mjs.map +1 -1
  18. package/dist/email.cjs +5 -0
  19. package/dist/email.cjs.map +1 -1
  20. package/dist/email.d.cts +2 -0
  21. package/dist/email.d.cts.map +1 -1
  22. package/dist/email.d.mts +2 -0
  23. package/dist/email.d.mts.map +1 -1
  24. package/dist/email.mjs +5 -1
  25. package/dist/email.mjs.map +1 -1
  26. package/dist/engine/condition.cjs +4 -2
  27. package/dist/engine/condition.cjs.map +1 -1
  28. package/dist/engine/condition.d.cts.map +1 -1
  29. package/dist/engine/condition.d.mts.map +1 -1
  30. package/dist/engine/condition.mjs +4 -2
  31. package/dist/engine/condition.mjs.map +1 -1
  32. package/dist/engine/interpreter.cjs +2 -1
  33. package/dist/engine/interpreter.cjs.map +1 -1
  34. package/dist/engine/interpreter.mjs +2 -1
  35. package/dist/engine/interpreter.mjs.map +1 -1
  36. package/dist/engine/ports.d.cts +19 -3
  37. package/dist/engine/ports.d.cts.map +1 -1
  38. package/dist/engine/ports.d.mts +19 -3
  39. package/dist/engine/ports.d.mts.map +1 -1
  40. package/dist/host/channels.cjs +17 -1
  41. package/dist/host/channels.cjs.map +1 -1
  42. package/dist/host/channels.d.cts.map +1 -1
  43. package/dist/host/channels.d.mts.map +1 -1
  44. package/dist/host/channels.mjs +17 -1
  45. package/dist/host/channels.mjs.map +1 -1
  46. package/dist/host/http.cjs +2 -2
  47. package/dist/host/http.cjs.map +1 -1
  48. package/dist/host/http.d.cts +3 -1
  49. package/dist/host/http.d.cts.map +1 -1
  50. package/dist/host/http.d.mts +3 -1
  51. package/dist/host/http.d.mts.map +1 -1
  52. package/dist/host/http.mjs +2 -2
  53. package/dist/host/http.mjs.map +1 -1
  54. package/dist/host/ingest.cjs +112 -13
  55. package/dist/host/ingest.cjs.map +1 -1
  56. package/dist/host/ingest.d.cts +21 -2
  57. package/dist/host/ingest.d.cts.map +1 -1
  58. package/dist/host/ingest.d.mts +21 -2
  59. package/dist/host/ingest.d.mts.map +1 -1
  60. package/dist/host/ingest.mjs +111 -14
  61. package/dist/host/ingest.mjs.map +1 -1
  62. package/dist/host/journey-host.cjs +2 -1
  63. package/dist/host/journey-host.cjs.map +1 -1
  64. package/dist/host/journey-host.d.cts +5 -2
  65. package/dist/host/journey-host.d.cts.map +1 -1
  66. package/dist/host/journey-host.d.mts +5 -2
  67. package/dist/host/journey-host.d.mts.map +1 -1
  68. package/dist/host/journey-host.mjs +2 -1
  69. package/dist/host/journey-host.mjs.map +1 -1
  70. package/dist/index.cjs +1 -0
  71. package/dist/index.d.cts +2 -2
  72. package/dist/index.d.mts +2 -2
  73. package/dist/index.mjs +2 -2
  74. package/dist/reporting/observer.cjs +1 -0
  75. package/dist/reporting/observer.cjs.map +1 -1
  76. package/dist/reporting/observer.mjs +1 -0
  77. package/dist/reporting/observer.mjs.map +1 -1
  78. package/dist/store/facts.cjs +18 -2
  79. package/dist/store/facts.cjs.map +1 -1
  80. package/dist/store/facts.d.cts +1 -0
  81. package/dist/store/facts.d.cts.map +1 -1
  82. package/dist/store/facts.d.mts +1 -0
  83. package/dist/store/facts.d.mts.map +1 -1
  84. package/dist/store/facts.mjs +18 -2
  85. package/dist/store/facts.mjs.map +1 -1
  86. package/dist/store/index.cjs.map +1 -1
  87. package/dist/store/index.d.cts +70 -10
  88. package/dist/store/index.d.cts.map +1 -1
  89. package/dist/store/index.d.mts +70 -10
  90. package/dist/store/index.d.mts.map +1 -1
  91. package/dist/store/index.mjs.map +1 -1
  92. package/dist/store/postgres-reporting.cjs +4 -0
  93. package/dist/store/postgres-reporting.cjs.map +1 -1
  94. package/dist/store/postgres-reporting.d.cts +1 -0
  95. package/dist/store/postgres-reporting.d.cts.map +1 -1
  96. package/dist/store/postgres-reporting.d.mts +1 -0
  97. package/dist/store/postgres-reporting.d.mts.map +1 -1
  98. package/dist/store/postgres-reporting.mjs +4 -0
  99. package/dist/store/postgres-reporting.mjs.map +1 -1
  100. package/dist/store/postgres.cjs +65 -11
  101. package/dist/store/postgres.cjs.map +1 -1
  102. package/dist/store/postgres.d.cts +13 -4
  103. package/dist/store/postgres.d.cts.map +1 -1
  104. package/dist/store/postgres.d.mts +13 -4
  105. package/dist/store/postgres.d.mts.map +1 -1
  106. package/dist/store/postgres.mjs +65 -11
  107. package/dist/store/postgres.mjs.map +1 -1
  108. package/dist/store/tables.cjs +4 -3
  109. package/dist/store/tables.cjs.map +1 -1
  110. package/dist/store/tables.d.cts.map +1 -1
  111. package/dist/store/tables.d.mts.map +1 -1
  112. package/dist/store/tables.mjs +4 -3
  113. package/dist/store/tables.mjs.map +1 -1
  114. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"channels.cjs","names":["resolveRuntime","PermanentError","Buffer","EmailSender","registryRenderer","registryPushRenderer","removeDevice","apnsEnvironment","ApnsProviderToken","apnsKeyFromBase64","ApnsPushSender","APNS_DEAD_TOKEN_REASONS","serviceAccountFromBase64","FcmAccessToken","FcmPushSender","FCM_DEAD_TOKEN_REASONS","VapidSigner","WebPushSender","WEB_PUSH_DEAD_TOKEN_REASONS","routeByPlatform","indexTemplates","DiscordWebhookSender","lookupTemplate","renderDiscordContent","SlackWebhookSender","renderSlackContent","routeByChannel"],"sources":["../../src/host/channels.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport {\n DEAD_TOKEN_REASONS as APNS_DEAD_TOKEN_REASONS,\n ApnsProviderToken,\n ApnsPushSender,\n apnsKeyFromBase64,\n} from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport { EmailSender, type EmailTransport } from '../email-sender';\nimport type { MessageSender } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport {\n DEAD_TOKEN_REASONS as FCM_DEAD_TOKEN_REASONS,\n FcmAccessToken,\n FcmPushSender,\n serviceAccountFromBase64,\n} from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport {\n VapidSigner,\n DEAD_TOKEN_REASONS as WEB_PUSH_DEAD_TOKEN_REASONS,\n WebPushSender,\n} from '../webpush';\nimport { removeDevice } from './ingest';\nimport type { JourneyEnv } from './platform';\nimport { routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/**\n * A sender that fails every message with the reason the channel cannot deliver — for good: the\n * configuration is the same on every attempt, so the step does not retry into it.\n */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new PermanentError(\n `cannot send '${message.template}' on ${message.channel}: ${reason}`\n );\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n // Missing on this attempt, missing on the next: the deployment has to change, not the step\n throw new PermanentError(\n `${name} is not set: the environment must hold it for the configured channel`\n );\n }\n return value;\n}\n\n/**\n * The provider credentials — `ApnsProviderToken`, `FcmAccessToken`, `VapidSigner` — for as long\n * as the host lives. `channelsFromConfig` runs once per workflow run (every wake of every\n * instance) and builds the senders anew each time; what must outlive the run is the credential\n * state they hold: the APNs provider JWT must be reused for at least 20 minutes (Apple answers a\n * sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`), the FCM access\n * token is good for an hour, the VAPID key import is wasted work twice.\n *\n * The cache is the host's (`createJourneyHost` makes one and hands it to every run), not the\n * run's `env`: a Lambda invocation builds a fresh env object for each run (its instance port is\n * in it), so a cache keyed on env would miss on every wake. Inside, the key names the provider,\n * the ids that identify the key and a digest of the secret — so a rotated secret or a reloaded\n * host mints a new credential instead of reusing the old, and the secret itself is in no key and\n * no log. Nothing that shapes the sender (topic, environment, relay, registry, store) is in it:\n * the sender is built per call around the shared credential.\n */\nexport interface ProviderCache {\n /** The credential under `key`, built on the first call and returned as is after that. */\n get<T>(key: string, build: () => T): T;\n}\n\nexport function createProviderCache(): ProviderCache {\n const credentials = new Map<string, unknown>();\n return {\n get<T>(key: string, build: () => T): T {\n if (!credentials.has(key)) credentials.set(key, build());\n return credentials.get(key) as T;\n },\n };\n}\n\n/** A number for each fetch implementation, so a credential minted through one is not reused with another. */\nconst identities = new WeakMap<object, number>();\nlet nextIdentity = 1;\nfunction identity(value: object | undefined): string {\n if (value === undefined) return 'global';\n let id = identities.get(value);\n if (id === undefined) {\n id = nextIdentity++;\n identities.set(value, id);\n }\n return String(id);\n}\n\n/** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */\nasync function secretDigest(secret: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));\n return Buffer.from(digest.slice(0, 4)).toString('hex');\n}\n\n/**\n * A sender built on its first message, then kept for this call. `build` reads the secrets (and\n * so may throw a `PermanentError` naming the missing variable): a platform's secret is only\n * required when something is actually sent there, so a journey that only emails must not fail\n * because the APNs key is missing from a local `.dev.vars`. A build that failed is not kept.\n */\nfunction lazy(build: () => Promise<MessageSender>): MessageSender {\n let sender: Promise<MessageSender> | undefined;\n return {\n async send(message) {\n sender ??= build().catch((error: unknown) => {\n sender = undefined;\n throw error;\n });\n return (await sender).send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n /** Where finished emails go — the host's choice, independent of where the workflow runs. */\n email?: EmailTransport | undefined;\n fetch?: typeof fetch;\n /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */\n apnsFetch?: typeof fetch | undefined;\n /**\n * Where the provider credentials live across runs (`createProviderCache`): the host's, made\n * once with it. Without one, this call's credentials are its own — nothing is reused.\n */\n providers?: ProviderCache | undefined;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n const providers = options.providers ?? createProviderCache();\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n const transport = options.email;\n if (transport === undefined) {\n return unavailable(\"no email transport is configured (the host's `email` option)\");\n }\n // Each template names its own sender: there is no global one to require\n return new EmailSender({ transport, render: registryRenderer(registry, config.email) });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n /**\n * A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n * waits that read devices, as a removal through the API does. Done here, per run, on the\n * settled result, so the credential cached across runs (see `ProviderCache`) never holds a\n * run's store.\n */\n const dropDeadTokens = (\n provider: MessageSender,\n deadTokenReasons: ReadonlySet<string>\n ): MessageSender => ({\n async send(message) {\n const result = await provider.send(message);\n if (\n result !== undefined &&\n deadTokenReasons.has(result.reason) &&\n message.device !== undefined &&\n options.store !== undefined\n ) {\n await removeDevice(env, message.device.token, options.store);\n }\n return result;\n },\n });\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n const apnsFetch = options.apnsFetch ?? options.fetch;\n const environment = apnsEnvironment(apns, mode);\n platforms.ios = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'APNS_PRIVATE_KEY_BASE64');\n const credentials = providers.get(\n JSON.stringify(['apns', apns.keyId, apns.teamId, await secretDigest(secret)]),\n () =>\n new ApnsProviderToken({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(secret),\n })\n );\n return new ApnsPushSender({\n credentials,\n topic: apns.topic,\n environment,\n render,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...(apnsFetch === undefined ? {} : { fetch: apnsFetch }),\n });\n }),\n APNS_DEAD_TOKEN_REASONS\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64');\n const serviceAccount = serviceAccountFromBase64(secret);\n // The token endpoint is called through the fetch: another fetch is another credential\n const credentials = providers.get(\n JSON.stringify([\n 'fcm',\n serviceAccount.client_email,\n serviceAccount.project_id,\n identity(options.fetch),\n await secretDigest(secret),\n ]),\n () =>\n new FcmAccessToken({\n serviceAccount,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n return new FcmPushSender({\n credentials,\n render,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n FCM_DEAD_TOKEN_REASONS\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = dropDeadTokens(\n lazy(async () => {\n const publicKey = requireSecret(env, 'VAPID_PUBLIC_KEY');\n const privateKey = requireSecret(env, 'VAPID_PRIVATE_KEY');\n const credentials = providers.get(\n JSON.stringify(['vapid', webPush.subject, publicKey, await secretDigest(privateKey)]),\n () => new VapidSigner({ subject: webPush.subject, publicKey, privateKey })\n );\n return new WebPushSender({\n credentials,\n render,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n WEB_PUSH_DEAD_TOKEN_REASONS\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new PermanentError(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"channels.cjs","names":["resolveRuntime","PermanentError","Buffer","EmailSender","registryRenderer","registryPushRenderer","removeDevice","apnsEnvironment","ApnsProviderToken","apnsKeyFromBase64","ApnsPushSender","APNS_DEAD_TOKEN_REASONS","serviceAccountFromBase64","FcmAccessToken","FcmPushSender","FCM_DEAD_TOKEN_REASONS","VapidSigner","WebPushSender","WEB_PUSH_DEAD_TOKEN_REASONS","routeByPlatform","indexTemplates","DiscordWebhookSender","lookupTemplate","renderDiscordContent","SlackWebhookSender","renderSlackContent","routeByChannel"],"sources":["../../src/host/channels.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport {\n DEAD_TOKEN_REASONS as APNS_DEAD_TOKEN_REASONS,\n ApnsProviderToken,\n ApnsPushSender,\n apnsKeyFromBase64,\n} from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport { EmailSender, type EmailTransport } from '../email-sender';\nimport type { MessageSender } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport {\n DEAD_TOKEN_REASONS as FCM_DEAD_TOKEN_REASONS,\n FcmAccessToken,\n FcmPushSender,\n serviceAccountFromBase64,\n} from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport {\n VapidSigner,\n DEAD_TOKEN_REASONS as WEB_PUSH_DEAD_TOKEN_REASONS,\n WebPushSender,\n} from '../webpush';\nimport { removeDevice } from './ingest';\nimport type { JourneyEnv } from './platform';\nimport { routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\nlet relayIgnoredLogged = false;\n\n/**\n * Where APNs requests go: the dev relay `APNS_RELAY_ORIGIN` names, except in production. The relay\n * sees the provider JWT, so a production deploy that inherited the variable (a copied `.dev.vars`,\n * a leftover `vars` entry) goes straight to APNs rather than hand the credential to whatever\n * answers there, and says so once.\n */\nfunction apnsRelayOrigin(env: JourneyEnv, mode: JourneyMode): string | undefined {\n const origin = env.APNS_RELAY_ORIGIN;\n if (origin === undefined || mode !== 'production') return origin;\n if (!relayIgnoredLogged) {\n relayIgnoredLogged = true;\n console.error(\n `[workflow] APNS_RELAY_ORIGIN (${origin}) is ignored in production: APNs is called directly. The relay is for wrangler dev only; unset it in this environment.`\n );\n }\n return undefined;\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/**\n * A sender that fails every message with the reason the channel cannot deliver — for good: the\n * configuration is the same on every attempt, so the step does not retry into it.\n */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new PermanentError(\n `cannot send '${message.template}' on ${message.channel}: ${reason}`\n );\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n // Missing on this attempt, missing on the next: the deployment has to change, not the step\n throw new PermanentError(\n `${name} is not set: the environment must hold it for the configured channel`\n );\n }\n return value;\n}\n\n/**\n * The provider credentials — `ApnsProviderToken`, `FcmAccessToken`, `VapidSigner` — for as long\n * as the host lives. `channelsFromConfig` runs once per workflow run (every wake of every\n * instance) and builds the senders anew each time; what must outlive the run is the credential\n * state they hold: the APNs provider JWT must be reused for at least 20 minutes (Apple answers a\n * sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`), the FCM access\n * token is good for an hour, the VAPID key import is wasted work twice.\n *\n * The cache is the host's (`createJourneyHost` makes one and hands it to every run), not the\n * run's `env`: a Lambda invocation builds a fresh env object for each run (its instance port is\n * in it), so a cache keyed on env would miss on every wake. Inside, the key names the provider,\n * the ids that identify the key and a digest of the secret — so a rotated secret or a reloaded\n * host mints a new credential instead of reusing the old, and the secret itself is in no key and\n * no log. Nothing that shapes the sender (topic, environment, relay, registry, store) is in it:\n * the sender is built per call around the shared credential.\n */\nexport interface ProviderCache {\n /** The credential under `key`, built on the first call and returned as is after that. */\n get<T>(key: string, build: () => T): T;\n}\n\nexport function createProviderCache(): ProviderCache {\n const credentials = new Map<string, unknown>();\n return {\n get<T>(key: string, build: () => T): T {\n if (!credentials.has(key)) credentials.set(key, build());\n return credentials.get(key) as T;\n },\n };\n}\n\n/** A number for each fetch implementation, so a credential minted through one is not reused with another. */\nconst identities = new WeakMap<object, number>();\nlet nextIdentity = 1;\nfunction identity(value: object | undefined): string {\n if (value === undefined) return 'global';\n let id = identities.get(value);\n if (id === undefined) {\n id = nextIdentity++;\n identities.set(value, id);\n }\n return String(id);\n}\n\n/** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */\nasync function secretDigest(secret: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));\n return Buffer.from(digest.slice(0, 4)).toString('hex');\n}\n\n/**\n * A sender built on its first message, then kept for this call. `build` reads the secrets (and\n * so may throw a `PermanentError` naming the missing variable): a platform's secret is only\n * required when something is actually sent there, so a journey that only emails must not fail\n * because the APNs key is missing from a local `.dev.vars`. A build that failed is not kept.\n */\nfunction lazy(build: () => Promise<MessageSender>): MessageSender {\n let sender: Promise<MessageSender> | undefined;\n return {\n async send(message) {\n sender ??= build().catch((error: unknown) => {\n sender = undefined;\n throw error;\n });\n return (await sender).send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n /** Where finished emails go — the host's choice, independent of where the workflow runs. */\n email?: EmailTransport | undefined;\n fetch?: typeof fetch;\n /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */\n apnsFetch?: typeof fetch | undefined;\n /**\n * Where the provider credentials live across runs (`createProviderCache`): the host's, made\n * once with it. Without one, this call's credentials are its own — nothing is reused.\n */\n providers?: ProviderCache | undefined;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n const providers = options.providers ?? createProviderCache();\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n const transport = options.email;\n if (transport === undefined) {\n return unavailable(\"no email transport is configured (the host's `email` option)\");\n }\n // Each template names its own sender: there is no global one to require\n return new EmailSender({ transport, render: registryRenderer(registry, config.email) });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n /**\n * A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n * waits that read devices, as a removal through the API does. Done here, per run, on the\n * settled result, so the credential cached across runs (see `ProviderCache`) never holds a\n * run's store.\n */\n const dropDeadTokens = (\n provider: MessageSender,\n deadTokenReasons: ReadonlySet<string>\n ): MessageSender => ({\n async send(message) {\n const result = await provider.send(message);\n if (\n result !== undefined &&\n deadTokenReasons.has(result.reason) &&\n message.device !== undefined &&\n options.store !== undefined\n ) {\n await removeDevice(env, message.device.token, options.store);\n }\n return result;\n },\n });\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n const apnsFetch = options.apnsFetch ?? options.fetch;\n const environment = apnsEnvironment(apns, mode);\n platforms.ios = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'APNS_PRIVATE_KEY_BASE64');\n const credentials = providers.get(\n JSON.stringify(['apns', apns.keyId, apns.teamId, await secretDigest(secret)]),\n () =>\n new ApnsProviderToken({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(secret),\n })\n );\n const origin = apnsRelayOrigin(env, mode);\n return new ApnsPushSender({\n credentials,\n topic: apns.topic,\n environment,\n render,\n ...(origin === undefined ? {} : { origin }),\n ...(apnsFetch === undefined ? {} : { fetch: apnsFetch }),\n });\n }),\n APNS_DEAD_TOKEN_REASONS\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64');\n const serviceAccount = serviceAccountFromBase64(secret);\n // The token endpoint is called through the fetch: another fetch is another credential\n const credentials = providers.get(\n JSON.stringify([\n 'fcm',\n serviceAccount.client_email,\n serviceAccount.project_id,\n identity(options.fetch),\n await secretDigest(secret),\n ]),\n () =>\n new FcmAccessToken({\n serviceAccount,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n return new FcmPushSender({\n credentials,\n render,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n FCM_DEAD_TOKEN_REASONS\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = dropDeadTokens(\n lazy(async () => {\n const publicKey = requireSecret(env, 'VAPID_PUBLIC_KEY');\n const privateKey = requireSecret(env, 'VAPID_PRIVATE_KEY');\n const credentials = providers.get(\n JSON.stringify(['vapid', webPush.subject, publicKey, await secretDigest(privateKey)]),\n () => new VapidSigner({ subject: webPush.subject, publicKey, privateKey })\n );\n return new WebPushSender({\n credentials,\n render,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n WEB_PUSH_DEAD_TOKEN_REASONS\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new PermanentError(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"channels.d.cts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;wBAmDgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;;;;;;;;;;;;;;;;iBAoDK;;EAEf,IAAI,GAAG,aAAa,aAAa,IAAI;;wBAGvB,uBAAuB;;YAiD3B,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;;EAER,QAAQ;EACR,eAAe;;EAEf,mBAAmB;;;;;EAKnB,YAAY;IAEb"}
1
+ {"version":3,"file":"channels.d.cts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;wBAmDgB,YAAY,KAAK,aAAa;;wBAyB9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;;;;;;;;;;;;;;;;iBAoDK;;EAEf,IAAI,GAAG,aAAa,aAAa,IAAI;;wBAGvB,uBAAuB;;YAiD3B,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;;EAER,QAAQ;EACR,eAAe;;EAEf,mBAAmB;;;;;EAKnB,YAAY;IAEb"}
@@ -1 +1 @@
1
- {"version":3,"file":"channels.d.mts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;wBAmDgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;;;;;;;;;;;;;;;;iBAoDK;;EAEf,IAAI,GAAG,aAAa,aAAa,IAAI;;wBAGvB,uBAAuB;;YAiD3B,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;;EAER,QAAQ;EACR,eAAe;;EAEf,mBAAmB;;;;;EAKnB,YAAY;IAEb"}
1
+ {"version":3,"file":"channels.d.mts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;wBAmDgB,YAAY,KAAK,aAAa;;wBAyB9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;;;;;;;;;;;;;;;;iBAoDK;;EAEf,IAAI,GAAG,aAAa,aAAa,IAAI;;wBAGvB,uBAAuB;;YAiD3B,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;;EAER,QAAQ;EACR,eAAe;;EAEf,mBAAmB;;;;;EAKnB,YAAY;IAEb"}
@@ -27,6 +27,21 @@ import { Buffer } from "node:buffer";
27
27
  function journeyMode(env) {
28
28
  return env.ENVIRONMENT ?? "production";
29
29
  }
30
+ let relayIgnoredLogged = false;
31
+ /**
32
+ * Where APNs requests go: the dev relay `APNS_RELAY_ORIGIN` names, except in production. The relay
33
+ * sees the provider JWT, so a production deploy that inherited the variable (a copied `.dev.vars`,
34
+ * a leftover `vars` entry) goes straight to APNs rather than hand the credential to whatever
35
+ * answers there, and says so once.
36
+ */
37
+ function apnsRelayOrigin(env, mode) {
38
+ const origin = env.APNS_RELAY_ORIGIN;
39
+ if (origin === void 0 || mode !== "production") return origin;
40
+ if (!relayIgnoredLogged) {
41
+ relayIgnoredLogged = true;
42
+ console.error(`[workflow] APNS_RELAY_ORIGIN (${origin}) is ignored in production: APNs is called directly. The relay is for wrangler dev only; unset it in this environment.`);
43
+ }
44
+ }
30
45
  /** The runtime options for this environment's mode. */
31
46
  function runtimeFromConfig(config, env) {
32
47
  return resolveRuntime(config?.runtime, journeyMode(env));
@@ -130,12 +145,13 @@ function channelsFromConfig(config, env, options = {}) {
130
145
  teamId: apns.teamId,
131
146
  privateKey: apnsKeyFromBase64(secret)
132
147
  }));
148
+ const origin = apnsRelayOrigin(env, mode);
133
149
  return new ApnsPushSender({
134
150
  credentials,
135
151
  topic: apns.topic,
136
152
  environment,
137
153
  render,
138
- ...env.APNS_RELAY_ORIGIN === void 0 ? {} : { origin: env.APNS_RELAY_ORIGIN },
154
+ ...origin === void 0 ? {} : { origin },
139
155
  ...apnsFetch === void 0 ? {} : { fetch: apnsFetch }
140
156
  });
141
157
  }), DEAD_TOKEN_REASONS);
@@ -1 +1 @@
1
- {"version":3,"file":"channels.mjs","names":["APNS_DEAD_TOKEN_REASONS","FCM_DEAD_TOKEN_REASONS","WEB_PUSH_DEAD_TOKEN_REASONS"],"sources":["../../src/host/channels.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport {\n DEAD_TOKEN_REASONS as APNS_DEAD_TOKEN_REASONS,\n ApnsProviderToken,\n ApnsPushSender,\n apnsKeyFromBase64,\n} from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport { EmailSender, type EmailTransport } from '../email-sender';\nimport type { MessageSender } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport {\n DEAD_TOKEN_REASONS as FCM_DEAD_TOKEN_REASONS,\n FcmAccessToken,\n FcmPushSender,\n serviceAccountFromBase64,\n} from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport {\n VapidSigner,\n DEAD_TOKEN_REASONS as WEB_PUSH_DEAD_TOKEN_REASONS,\n WebPushSender,\n} from '../webpush';\nimport { removeDevice } from './ingest';\nimport type { JourneyEnv } from './platform';\nimport { routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/**\n * A sender that fails every message with the reason the channel cannot deliver — for good: the\n * configuration is the same on every attempt, so the step does not retry into it.\n */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new PermanentError(\n `cannot send '${message.template}' on ${message.channel}: ${reason}`\n );\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n // Missing on this attempt, missing on the next: the deployment has to change, not the step\n throw new PermanentError(\n `${name} is not set: the environment must hold it for the configured channel`\n );\n }\n return value;\n}\n\n/**\n * The provider credentials — `ApnsProviderToken`, `FcmAccessToken`, `VapidSigner` — for as long\n * as the host lives. `channelsFromConfig` runs once per workflow run (every wake of every\n * instance) and builds the senders anew each time; what must outlive the run is the credential\n * state they hold: the APNs provider JWT must be reused for at least 20 minutes (Apple answers a\n * sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`), the FCM access\n * token is good for an hour, the VAPID key import is wasted work twice.\n *\n * The cache is the host's (`createJourneyHost` makes one and hands it to every run), not the\n * run's `env`: a Lambda invocation builds a fresh env object for each run (its instance port is\n * in it), so a cache keyed on env would miss on every wake. Inside, the key names the provider,\n * the ids that identify the key and a digest of the secret — so a rotated secret or a reloaded\n * host mints a new credential instead of reusing the old, and the secret itself is in no key and\n * no log. Nothing that shapes the sender (topic, environment, relay, registry, store) is in it:\n * the sender is built per call around the shared credential.\n */\nexport interface ProviderCache {\n /** The credential under `key`, built on the first call and returned as is after that. */\n get<T>(key: string, build: () => T): T;\n}\n\nexport function createProviderCache(): ProviderCache {\n const credentials = new Map<string, unknown>();\n return {\n get<T>(key: string, build: () => T): T {\n if (!credentials.has(key)) credentials.set(key, build());\n return credentials.get(key) as T;\n },\n };\n}\n\n/** A number for each fetch implementation, so a credential minted through one is not reused with another. */\nconst identities = new WeakMap<object, number>();\nlet nextIdentity = 1;\nfunction identity(value: object | undefined): string {\n if (value === undefined) return 'global';\n let id = identities.get(value);\n if (id === undefined) {\n id = nextIdentity++;\n identities.set(value, id);\n }\n return String(id);\n}\n\n/** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */\nasync function secretDigest(secret: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));\n return Buffer.from(digest.slice(0, 4)).toString('hex');\n}\n\n/**\n * A sender built on its first message, then kept for this call. `build` reads the secrets (and\n * so may throw a `PermanentError` naming the missing variable): a platform's secret is only\n * required when something is actually sent there, so a journey that only emails must not fail\n * because the APNs key is missing from a local `.dev.vars`. A build that failed is not kept.\n */\nfunction lazy(build: () => Promise<MessageSender>): MessageSender {\n let sender: Promise<MessageSender> | undefined;\n return {\n async send(message) {\n sender ??= build().catch((error: unknown) => {\n sender = undefined;\n throw error;\n });\n return (await sender).send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n /** Where finished emails go — the host's choice, independent of where the workflow runs. */\n email?: EmailTransport | undefined;\n fetch?: typeof fetch;\n /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */\n apnsFetch?: typeof fetch | undefined;\n /**\n * Where the provider credentials live across runs (`createProviderCache`): the host's, made\n * once with it. Without one, this call's credentials are its own — nothing is reused.\n */\n providers?: ProviderCache | undefined;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n const providers = options.providers ?? createProviderCache();\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n const transport = options.email;\n if (transport === undefined) {\n return unavailable(\"no email transport is configured (the host's `email` option)\");\n }\n // Each template names its own sender: there is no global one to require\n return new EmailSender({ transport, render: registryRenderer(registry, config.email) });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n /**\n * A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n * waits that read devices, as a removal through the API does. Done here, per run, on the\n * settled result, so the credential cached across runs (see `ProviderCache`) never holds a\n * run's store.\n */\n const dropDeadTokens = (\n provider: MessageSender,\n deadTokenReasons: ReadonlySet<string>\n ): MessageSender => ({\n async send(message) {\n const result = await provider.send(message);\n if (\n result !== undefined &&\n deadTokenReasons.has(result.reason) &&\n message.device !== undefined &&\n options.store !== undefined\n ) {\n await removeDevice(env, message.device.token, options.store);\n }\n return result;\n },\n });\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n const apnsFetch = options.apnsFetch ?? options.fetch;\n const environment = apnsEnvironment(apns, mode);\n platforms.ios = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'APNS_PRIVATE_KEY_BASE64');\n const credentials = providers.get(\n JSON.stringify(['apns', apns.keyId, apns.teamId, await secretDigest(secret)]),\n () =>\n new ApnsProviderToken({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(secret),\n })\n );\n return new ApnsPushSender({\n credentials,\n topic: apns.topic,\n environment,\n render,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...(apnsFetch === undefined ? {} : { fetch: apnsFetch }),\n });\n }),\n APNS_DEAD_TOKEN_REASONS\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64');\n const serviceAccount = serviceAccountFromBase64(secret);\n // The token endpoint is called through the fetch: another fetch is another credential\n const credentials = providers.get(\n JSON.stringify([\n 'fcm',\n serviceAccount.client_email,\n serviceAccount.project_id,\n identity(options.fetch),\n await secretDigest(secret),\n ]),\n () =>\n new FcmAccessToken({\n serviceAccount,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n return new FcmPushSender({\n credentials,\n render,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n FCM_DEAD_TOKEN_REASONS\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = dropDeadTokens(\n lazy(async () => {\n const publicKey = requireSecret(env, 'VAPID_PUBLIC_KEY');\n const privateKey = requireSecret(env, 'VAPID_PRIVATE_KEY');\n const credentials = providers.get(\n JSON.stringify(['vapid', webPush.subject, publicKey, await secretDigest(privateKey)]),\n () => new VapidSigner({ subject: webPush.subject, publicKey, privateKey })\n );\n return new WebPushSender({\n credentials,\n render,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n WEB_PUSH_DEAD_TOKEN_REASONS\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new PermanentError(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"channels.mjs","names":["APNS_DEAD_TOKEN_REASONS","FCM_DEAD_TOKEN_REASONS","WEB_PUSH_DEAD_TOKEN_REASONS"],"sources":["../../src/host/channels.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport {\n DEAD_TOKEN_REASONS as APNS_DEAD_TOKEN_REASONS,\n ApnsProviderToken,\n ApnsPushSender,\n apnsKeyFromBase64,\n} from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport { EmailSender, type EmailTransport } from '../email-sender';\nimport type { MessageSender } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport {\n DEAD_TOKEN_REASONS as FCM_DEAD_TOKEN_REASONS,\n FcmAccessToken,\n FcmPushSender,\n serviceAccountFromBase64,\n} from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport {\n VapidSigner,\n DEAD_TOKEN_REASONS as WEB_PUSH_DEAD_TOKEN_REASONS,\n WebPushSender,\n} from '../webpush';\nimport { removeDevice } from './ingest';\nimport type { JourneyEnv } from './platform';\nimport { routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\nlet relayIgnoredLogged = false;\n\n/**\n * Where APNs requests go: the dev relay `APNS_RELAY_ORIGIN` names, except in production. The relay\n * sees the provider JWT, so a production deploy that inherited the variable (a copied `.dev.vars`,\n * a leftover `vars` entry) goes straight to APNs rather than hand the credential to whatever\n * answers there, and says so once.\n */\nfunction apnsRelayOrigin(env: JourneyEnv, mode: JourneyMode): string | undefined {\n const origin = env.APNS_RELAY_ORIGIN;\n if (origin === undefined || mode !== 'production') return origin;\n if (!relayIgnoredLogged) {\n relayIgnoredLogged = true;\n console.error(\n `[workflow] APNS_RELAY_ORIGIN (${origin}) is ignored in production: APNs is called directly. The relay is for wrangler dev only; unset it in this environment.`\n );\n }\n return undefined;\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/**\n * A sender that fails every message with the reason the channel cannot deliver — for good: the\n * configuration is the same on every attempt, so the step does not retry into it.\n */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new PermanentError(\n `cannot send '${message.template}' on ${message.channel}: ${reason}`\n );\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n // Missing on this attempt, missing on the next: the deployment has to change, not the step\n throw new PermanentError(\n `${name} is not set: the environment must hold it for the configured channel`\n );\n }\n return value;\n}\n\n/**\n * The provider credentials — `ApnsProviderToken`, `FcmAccessToken`, `VapidSigner` — for as long\n * as the host lives. `channelsFromConfig` runs once per workflow run (every wake of every\n * instance) and builds the senders anew each time; what must outlive the run is the credential\n * state they hold: the APNs provider JWT must be reused for at least 20 minutes (Apple answers a\n * sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`), the FCM access\n * token is good for an hour, the VAPID key import is wasted work twice.\n *\n * The cache is the host's (`createJourneyHost` makes one and hands it to every run), not the\n * run's `env`: a Lambda invocation builds a fresh env object for each run (its instance port is\n * in it), so a cache keyed on env would miss on every wake. Inside, the key names the provider,\n * the ids that identify the key and a digest of the secret — so a rotated secret or a reloaded\n * host mints a new credential instead of reusing the old, and the secret itself is in no key and\n * no log. Nothing that shapes the sender (topic, environment, relay, registry, store) is in it:\n * the sender is built per call around the shared credential.\n */\nexport interface ProviderCache {\n /** The credential under `key`, built on the first call and returned as is after that. */\n get<T>(key: string, build: () => T): T;\n}\n\nexport function createProviderCache(): ProviderCache {\n const credentials = new Map<string, unknown>();\n return {\n get<T>(key: string, build: () => T): T {\n if (!credentials.has(key)) credentials.set(key, build());\n return credentials.get(key) as T;\n },\n };\n}\n\n/** A number for each fetch implementation, so a credential minted through one is not reused with another. */\nconst identities = new WeakMap<object, number>();\nlet nextIdentity = 1;\nfunction identity(value: object | undefined): string {\n if (value === undefined) return 'global';\n let id = identities.get(value);\n if (id === undefined) {\n id = nextIdentity++;\n identities.set(value, id);\n }\n return String(id);\n}\n\n/** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */\nasync function secretDigest(secret: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));\n return Buffer.from(digest.slice(0, 4)).toString('hex');\n}\n\n/**\n * A sender built on its first message, then kept for this call. `build` reads the secrets (and\n * so may throw a `PermanentError` naming the missing variable): a platform's secret is only\n * required when something is actually sent there, so a journey that only emails must not fail\n * because the APNs key is missing from a local `.dev.vars`. A build that failed is not kept.\n */\nfunction lazy(build: () => Promise<MessageSender>): MessageSender {\n let sender: Promise<MessageSender> | undefined;\n return {\n async send(message) {\n sender ??= build().catch((error: unknown) => {\n sender = undefined;\n throw error;\n });\n return (await sender).send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n /** Where finished emails go — the host's choice, independent of where the workflow runs. */\n email?: EmailTransport | undefined;\n fetch?: typeof fetch;\n /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */\n apnsFetch?: typeof fetch | undefined;\n /**\n * Where the provider credentials live across runs (`createProviderCache`): the host's, made\n * once with it. Without one, this call's credentials are its own — nothing is reused.\n */\n providers?: ProviderCache | undefined;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n const providers = options.providers ?? createProviderCache();\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n const transport = options.email;\n if (transport === undefined) {\n return unavailable(\"no email transport is configured (the host's `email` option)\");\n }\n // Each template names its own sender: there is no global one to require\n return new EmailSender({ transport, render: registryRenderer(registry, config.email) });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n /**\n * A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n * waits that read devices, as a removal through the API does. Done here, per run, on the\n * settled result, so the credential cached across runs (see `ProviderCache`) never holds a\n * run's store.\n */\n const dropDeadTokens = (\n provider: MessageSender,\n deadTokenReasons: ReadonlySet<string>\n ): MessageSender => ({\n async send(message) {\n const result = await provider.send(message);\n if (\n result !== undefined &&\n deadTokenReasons.has(result.reason) &&\n message.device !== undefined &&\n options.store !== undefined\n ) {\n await removeDevice(env, message.device.token, options.store);\n }\n return result;\n },\n });\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n const apnsFetch = options.apnsFetch ?? options.fetch;\n const environment = apnsEnvironment(apns, mode);\n platforms.ios = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'APNS_PRIVATE_KEY_BASE64');\n const credentials = providers.get(\n JSON.stringify(['apns', apns.keyId, apns.teamId, await secretDigest(secret)]),\n () =>\n new ApnsProviderToken({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(secret),\n })\n );\n const origin = apnsRelayOrigin(env, mode);\n return new ApnsPushSender({\n credentials,\n topic: apns.topic,\n environment,\n render,\n ...(origin === undefined ? {} : { origin }),\n ...(apnsFetch === undefined ? {} : { fetch: apnsFetch }),\n });\n }),\n APNS_DEAD_TOKEN_REASONS\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64');\n const serviceAccount = serviceAccountFromBase64(secret);\n // The token endpoint is called through the fetch: another fetch is another credential\n const credentials = providers.get(\n JSON.stringify([\n 'fcm',\n serviceAccount.client_email,\n serviceAccount.project_id,\n identity(options.fetch),\n await secretDigest(secret),\n ]),\n () =>\n new FcmAccessToken({\n serviceAccount,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n return new FcmPushSender({\n credentials,\n render,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n FCM_DEAD_TOKEN_REASONS\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = dropDeadTokens(\n lazy(async () => {\n const publicKey = requireSecret(env, 'VAPID_PUBLIC_KEY');\n const privateKey = requireSecret(env, 'VAPID_PRIVATE_KEY');\n const credentials = providers.get(\n JSON.stringify(['vapid', webPush.subject, publicKey, await secretDigest(privateKey)]),\n () => new VapidSigner({ subject: webPush.subject, publicKey, privateKey })\n );\n return new WebPushSender({\n credentials,\n render,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n WEB_PUSH_DEAD_TOKEN_REASONS\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new PermanentError(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
@@ -286,7 +286,7 @@ const routes = [
286
286
  auth: "api",
287
287
  async handle({ env, store, host }) {
288
288
  if (host.bundle === void 0) return notFound();
289
- return json(await require_host_ingest.deployBundle(env, host.bundle(), store));
289
+ return json(await require_host_ingest.deployBundle(env, host.bundle(), store, host.bundleVersion, true));
290
290
  }
291
291
  },
292
292
  {
@@ -366,7 +366,7 @@ function failure(error) {
366
366
  /** One request through the table: the production gate, the bearer, the route, the error mapping. */
367
367
  async function serve(request, ctx) {
368
368
  const url = new URL(request.url);
369
- if (ctx.host !== void 0 && refusesOpen(request, ctx, url.pathname)) return json({ error: "API_TOKEN is not set: production refuses to serve without it (set ENVIRONMENT=development to run open)" }, 503);
369
+ if (refusesOpen(request, ctx, url.pathname)) return json({ error: "API_TOKEN is not set: production refuses to serve without it (set ENVIRONMENT=development to run open)" }, 503);
370
370
  const candidates = routes.filter((route) => matches(route.path, url.pathname));
371
371
  if (candidates.length === 0) return notFound();
372
372
  if (candidates.some((route) => route.auth === "api")) {
@@ -1 +1 @@
1
- {"version":3,"file":"http.cjs","names":["handleTracking","checkEvent","ingestEvent","identifyUser","registerDevice","unbindDevice","removeDevice","recordPushOpen","getSubscriptions","parseSubscriptionsPatch","setSubscriptions","getSubscriptionsByMessage","setSubscriptionsByMessage","forgetUser","deployBundle","PREVIEW_MESSAGE_ID","journeyMode","authorized","z","BundleIntegrityError","TemplateValidationError","UserErasedError","SchemaMismatchError"],"sources":["../../src/host/http.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport { type Contract, type ContractResult, checkEvent } from '../contract';\nimport { PREVIEW_MESSAGE_ID } from '../email';\nimport type { EmailRenderer } from '../email-sender';\nimport type { BundleIR, ScalarIR } from '../ir';\nimport { type TrackingOptions, handleTracking, recordPushOpen } from '../reporting/tracking';\nimport {\n type JourneyStore,\n type ProfileProps,\n SchemaMismatchError,\n UserErasedError,\n} from '../store/index';\nimport { TemplateValidationError } from '../templates';\nimport { type Authorize, authorized } from './auth';\nimport { journeyMode } from './channels';\nimport {\n BundleIntegrityError,\n type SweepResult,\n deployBundle,\n forgetUser,\n getSubscriptions,\n getSubscriptionsByMessage,\n identifyUser,\n ingestEvent,\n parseSubscriptionsPatch,\n registerDevice,\n removeDevice,\n setSubscriptions,\n setSubscriptionsByMessage,\n unbindDevice,\n} from './ingest';\nimport type { JourneyEnv } from './platform';\n\n/**\n * The HTTP surface, as one route table: the bare ingest API (`handleRequest`) and the routes\n * the full host adds (`serve` with its parts — the bundle, previews, tracking, the sweep). A\n * route whose part is absent answers 404, so the two share the table, the bearer check, the\n * body validation and the error mapping.\n */\n\n/**\n * How the ingest API applies a contract: 'enforce' refuses an invalid event or\n * patch with a 400 before anything is stored, woken or started; 'report' logs\n * the issues (event id, name, field paths — never the payload) and accepts it,\n * for checking real producers before turning enforcement on.\n */\nexport interface IngestValidation {\n contract?: Contract | undefined;\n mode?: 'enforce' | 'report' | undefined;\n}\n\n/** What the full host serves beyond the ingest API. A route whose part is absent answers 404. */\nexport interface HostParts {\n /** The bundle compiled into the code: `GET /bundle` shows it, `POST /bundle/deploy` makes it live. */\n bundle?: (() => BundleIR) | undefined;\n /** `GET /preview/:id?props=<JSON>`: the email templates, rendered. */\n preview?: { has(id: string): boolean; render: EmailRenderer } | undefined;\n /** `/t/*`, what recipients open (no bearer): served on a store that keeps reporting. */\n tracking?: TrackingOptions | undefined;\n /** `POST /segments/sweep`. */\n sweep?: (() => Promise<SweepResult>) | undefined;\n}\n\nexport interface ServeContext {\n env: JourneyEnv;\n store: JourneyStore;\n validation?: IngestValidation | undefined;\n /**\n * The host's parts. Their presence is what makes this a deployed host rather than the bare\n * API: production then refuses to serve without `API_TOKEN` — unless the host authorizes\n * callers itself (`authorize`).\n */\n host?: HostParts | undefined;\n /** The host's own authorization for the API routes; the env's `API_TOKEN` bearer without one. */\n authorize?: Authorize<JourneyEnv> | undefined;\n}\n\ninterface RouteContext {\n request: Request;\n url: URL;\n env: JourneyEnv;\n store: JourneyStore;\n validation: IngestValidation;\n host: HostParts;\n}\n\ninterface Route {\n /** '*' takes every method (the tracking routes answer GET, HEAD and POST themselves). */\n method: 'GET' | 'POST' | 'PUT' | '*';\n path: string | RegExp | ((pathname: string) => boolean);\n /** `api`: the caller must be authorized (the host's `authorize`, or the bearer); `none`: public. */\n auth: 'none' | 'api';\n handle(ctx: RouteContext): Promise<Response>;\n}\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n });\n\nconst notFound = () => json({ error: 'not found' }, 404);\n\n/* --------------------------------- bodies ---------------------------------- */\n\n/** The request body is not JSON: the caller's to fix (a 400), not a server fault. */\nclass InvalidJsonError extends Error {\n override readonly name = 'InvalidJsonError';\n}\n\nasync function readJson(request: Request): Promise<unknown> {\n try {\n return await request.json();\n } catch (error) {\n throw new InvalidJsonError('invalid JSON body', { cause: error });\n }\n}\n\n/**\n * Request bodies are untrusted JSON. They are read as unknown fields and\n * checked, never cast into the shape they are supposed to have: a cast only\n * silences the compiler, and what arrives here reaches SQL parameters.\n */\nasync function readBody(request: Request): Promise<Record<string, unknown>> {\n const body = await readJson(request);\n return isPlainObject(body) ? body : {};\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.length > 0;\n}\n\n/** A profile value: a scalar or null (which removes the property). Nested values are not profile data. */\nfunction isScalarOrNull(value: unknown): value is string | number | boolean | null {\n return (\n value === null ||\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n );\n}\n\n/** How far ahead of the server's clock an event or device `ts` may be: clock skew, not the future. */\nconst TS_FUTURE_TOLERANCE_MS = 5 * 60_000;\nconst TS_ERROR = 'ts must be a non-negative integer no later than 5 minutes in the future';\n\n/**\n * A caller's timestamp: epoch milliseconds, whole, not negative, and not past the skew tolerance.\n * A far-future ts would put the event ahead of everything a window condition reads, and a\n * fractional or unsafe one does not survive the store.\n */\nfunction isValidTs(ts: unknown): ts is number {\n return (\n typeof ts === 'number' &&\n Number.isSafeInteger(ts) &&\n ts >= 0 &&\n ts <= Date.now() + TS_FUTURE_TOLERANCE_MS\n );\n}\n\n/** The contract's verdict: the value to use, or the response refusing the request. */\nfunction checked<T>(\n validation: IngestValidation,\n result: ContractResult<T>,\n original: T,\n what: string\n): { value: T } | { refused: Response } {\n if (result.ok) return { value: result.data };\n const issues = result.issues.map((i) => `${i.path || '(root)'}: ${i.message}`);\n if (validation.mode === 'report') {\n console.warn(`[workflow http] contract would refuse ${what}: ${issues.join('; ')}`);\n return { value: original };\n }\n return {\n refused: json({ error: `${what} refused by the contract`, issues: result.issues }, 400),\n };\n}\n\n/**\n * The topics the engine knows: the deployed bundle's list (compiled from the contract), or the\n * contract's when the bare API runs without a host — never the caller's.\n */\nfunction declaredTopics(ctx: Pick<RouteContext, 'host' | 'validation'>): readonly string[] {\n return ctx.host.bundle?.().subscriptions?.topics ?? ctx.validation.contract?.topics ?? [];\n}\n\nconst SUBSCRIPTIONS = /^\\/subscriptions\\/[^/]+$/;\nconst subscriptionsUser = (url: URL) =>\n decodeURIComponent(url.pathname.slice('/subscriptions/'.length));\n/**\n * The same record, reached by one of the user's copies instead of their id: for the app's\n * preference page opened from an email's `preferencesUrl?m=<message>` without a sign-in. The\n * message id is the capability (an unguessable UUID, as `/t/u` trusts it); the bearer still\n * guards the route, so the page's own API proxies it, and rate-limits it as it sees fit.\n */\nconst SUBSCRIPTIONS_BY_MESSAGE = /^\\/subscriptions\\/by-message\\/[^/]+$/;\nconst subscriptionsMessage = (url: URL) =>\n decodeURIComponent(url.pathname.slice('/subscriptions/by-message/'.length));\n\n/* --------------------------------- routes ---------------------------------- */\n\nconst routes: readonly Route[] = [\n { method: 'GET', path: '/health', auth: 'none', handle: async () => json({ ok: true }) },\n {\n // Opened by recipients, so no bearer: the routes only record, and redirect to recorded URLs\n method: '*',\n path: /^\\/t\\//,\n auth: 'none',\n async handle({ request, store, host }) {\n if (host.tracking === undefined) return notFound();\n return (await handleTracking(request, store.reporting, host.tracking)) ?? notFound();\n },\n },\n {\n method: 'POST',\n path: '/events',\n auth: 'api',\n async handle({ request, env, store, validation }) {\n const { id, userId, event, payload, ts } = await readBody(request);\n if (!isNonEmptyString(userId) || !isNonEmptyString(event)) {\n return json({ error: 'userId and event required' }, 400);\n }\n if (event.startsWith('$')) {\n return json({ error: 'event names starting with $ are reserved' }, 400);\n }\n // An empty id is rejected rather than treated as absent: a caller that\n // sent one meant to identify the occurrence, and silently minting a\n // different one would turn their retry into a duplicate.\n if (id !== undefined && !isNonEmptyString(id)) {\n return json({ error: 'id must be a non-empty string' }, 400);\n }\n if (ts !== undefined && !isValidTs(ts)) return json({ error: TS_ERROR }, 400);\n if (payload !== undefined && !isPlainObject(payload)) {\n return json({ error: 'payload must be an object' }, 400);\n }\n let accepted = payload;\n if (validation.contract !== undefined) {\n const verdict = checked(\n validation,\n checkEvent(validation.contract, event, payload ?? {}),\n accepted ?? {},\n `event '${event}'${typeof id === 'string' ? ` (${id})` : ''}`\n );\n if ('refused' in verdict) return verdict.refused;\n accepted = verdict.value;\n }\n return json(await ingestEvent(env, { id, userId, event, payload: accepted, ts }, store));\n },\n },\n {\n method: 'POST',\n path: '/identify',\n auth: 'api',\n async handle({ request, env, store, validation }) {\n const { userId, props } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isPlainObject(props)) return json({ error: 'props must be an object' }, 400);\n // Profiles hold scalars only (both stores merge at the top level; a nested object would\n // be merged differently by each): refused here, contract or not\n if (!Object.values(props).every(isScalarOrNull)) {\n return json(\n { error: 'props values must be scalars (string, number, boolean) or null' },\n 400\n );\n }\n let patch = props as ProfileProps;\n if (validation.contract !== undefined) {\n const verdict = checked(\n validation,\n validation.contract.parseProfilePatch(props),\n patch,\n 'identify'\n );\n if ('refused' in verdict) return verdict.refused;\n patch = verdict.value as ProfileProps;\n }\n return json(await identifyUser(env, userId, patch, store));\n },\n },\n {\n method: 'POST',\n path: '/devices',\n auth: 'api',\n async handle({ request, env, store }) {\n const { userId, token, platform, ts } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n if (platform !== 'ios' && platform !== 'android' && platform !== 'web') {\n return json({ error: \"platform must be 'ios', 'android' or 'web'\" }, 400);\n }\n if (ts !== undefined && !isValidTs(ts)) return json({ error: TS_ERROR }, 400);\n const device = { userId, token, platform, ...(ts === undefined ? {} : { ts }) };\n return json(await registerDevice(env, device, store));\n },\n },\n {\n method: 'POST',\n path: '/devices/unbind',\n auth: 'api',\n async handle({ request, env, store }) {\n const { token } = await readBody(request);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n return json(await unbindDevice(env, token, store));\n },\n },\n {\n method: 'POST',\n path: '/devices/remove',\n auth: 'api',\n async handle({ request, env, store }) {\n const { token } = await readBody(request);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n return json(await removeDevice(env, token, store));\n },\n },\n {\n method: 'POST',\n path: '/messages/opened',\n auth: 'api',\n async handle({ request, store }) {\n const { userId, messageId } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isNonEmptyString(messageId)) return json({ error: 'messageId required' }, 400);\n const recorded = await recordPushOpen(store.reporting, userId, messageId);\n return json({ ok: true, recorded });\n },\n },\n {\n method: 'GET',\n path: SUBSCRIPTIONS,\n auth: 'api',\n async handle(ctx) {\n const userId = subscriptionsUser(ctx.url);\n if (userId === '') return json({ error: 'userId required' }, 400);\n return json(await getSubscriptions(ctx.store, userId, declaredTopics(ctx)));\n },\n },\n {\n method: 'PUT',\n path: SUBSCRIPTIONS,\n auth: 'api',\n async handle(ctx) {\n const userId = subscriptionsUser(ctx.url);\n if (userId === '') return json({ error: 'userId required' }, 400);\n const topics = declaredTopics(ctx);\n const parsed = parseSubscriptionsPatch(await readJson(ctx.request), topics);\n if (!parsed.ok) return json({ error: 'subscriptions refused', issues: parsed.issues }, 400);\n return json(await setSubscriptions(ctx.env, ctx.store, userId, parsed.patch, topics));\n },\n },\n {\n method: 'GET',\n path: SUBSCRIPTIONS_BY_MESSAGE,\n auth: 'api',\n async handle(ctx) {\n const messageId = subscriptionsMessage(ctx.url);\n const found = await getSubscriptionsByMessage(ctx.store, messageId, declaredTopics(ctx));\n return found === undefined ? json({ error: 'unknown message' }, 404) : json(found);\n },\n },\n {\n method: 'PUT',\n path: SUBSCRIPTIONS_BY_MESSAGE,\n auth: 'api',\n async handle(ctx) {\n const messageId = subscriptionsMessage(ctx.url);\n const topics = declaredTopics(ctx);\n const parsed = parseSubscriptionsPatch(await readJson(ctx.request), topics);\n if (!parsed.ok) return json({ error: 'subscriptions refused', issues: parsed.issues }, 400);\n const found = await setSubscriptionsByMessage(\n ctx.env,\n ctx.store,\n messageId,\n parsed.patch,\n topics\n );\n return found === undefined ? json({ error: 'unknown message' }, 404) : json(found);\n },\n },\n {\n method: 'POST',\n path: '/users/forget',\n auth: 'api',\n async handle({ request, env, store }) {\n const { userId } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n return json(await forgetUser(env, userId, store));\n },\n },\n {\n method: 'POST',\n path: '/deploy',\n auth: 'api',\n async handle({ request, env, store }) {\n return json(await deployBundle(env, await readJson(request), store));\n },\n },\n {\n method: 'GET',\n path: '/bundle',\n auth: 'api',\n async handle({ host }) {\n return host.bundle === undefined ? notFound() : json(host.bundle());\n },\n },\n {\n method: 'POST',\n path: '/bundle/deploy',\n auth: 'api',\n async handle({ env, store, host }) {\n if (host.bundle === undefined) return notFound();\n return json(await deployBundle(env, host.bundle(), store));\n },\n },\n {\n method: 'POST',\n path: '/segments/sweep',\n auth: 'api',\n async handle({ host }) {\n return host.sweep === undefined ? notFound() : json(await host.sweep());\n },\n },\n {\n method: 'GET',\n path: /^\\/preview\\//,\n auth: 'api',\n async handle({ url, host }) {\n const { preview } = host;\n if (preview === undefined) return notFound();\n const key = url.pathname.slice('/preview/'.length);\n if (!preview.has(key)) return json({ error: `unknown email template: ${key}` }, 404);\n // `?props=<JSON object>`: JSON keeps numbers and booleans typed, which query strings cannot\n let props: unknown;\n try {\n props = JSON.parse(url.searchParams.get('props') ?? '{}');\n } catch {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n if (!isPlainObject(props)) return json({ error: 'props must be a JSON object' }, 400);\n // A preview is no copy, but it shows the footer a sent one has: the preferences link\n // renders with a placeholder message id\n const { subject, html } = await preview.render(key, props as Record<string, ScalarIR>, {\n messageId: PREVIEW_MESSAGE_ID,\n });\n // A header holds bytes, not text: a subject with a non-Latin-1 character would throw\n // here, so it travels percent-encoded (the reader `decodeURIComponent`s it)\n return new Response(html, {\n headers: {\n 'content-type': 'text/html; charset=utf-8',\n 'x-subject': encodeURIComponent(subject),\n },\n });\n },\n },\n];\n\nfunction matches(path: Route['path'], pathname: string): boolean {\n if (typeof path === 'string') return path === pathname;\n if (path instanceof RegExp) return path.test(pathname);\n return path(pathname);\n}\n\n/**\n * Production without a bearer token (and no authorizer of the host's own) is not an open\n * instance, it is a mistake: anyone could ingest, identify and deploy. Only what recipients open\n * (`/t/*`) and the health check stay up.\n */\nfunction refusesOpen(request: Request, ctx: ServeContext, pathname: string): boolean {\n return (\n ctx.authorize === undefined &&\n ctx.env.API_TOKEN === undefined &&\n journeyMode(ctx.env) === 'production' &&\n !pathname.startsWith('/t/') &&\n !(request.method === 'GET' && pathname === '/health')\n );\n}\n\n/**\n * Whether the caller may use an API route: the host's authorizer on a copy of the request (so a\n * body it verifies is still there for the route), or the env's `API_TOKEN` as a bearer. An\n * authorizer that throws is a failure, never an admission.\n */\nasync function allowed(request: Request, ctx: ServeContext): Promise<boolean> {\n if (ctx.authorize === undefined) return authorized(request, ctx.env);\n return ctx.authorize(request.clone(), ctx.env);\n}\n\n/**\n * What a failed request answers. Validation problems are the caller's to fix (a 400, saying\n * what); anything else stays out of the response body (internals are logged, not leaked).\n */\nfunction failure(error: unknown): Response {\n if (error instanceof InvalidJsonError) return json({ error: 'invalid JSON body' }, 400);\n if (error instanceof z.core.$ZodError) {\n return json({ error: `invalid payload: ${error.message}` }, 400);\n }\n // The caller sent a self-inconsistent bundle: their problem to fix, so say what is wrong\n if (error instanceof BundleIntegrityError) return json({ error: error.message }, 400);\n // The query string is the props: a refusal is the caller's to fix, with the field paths\n if (error instanceof TemplateValidationError) {\n return json({ error: error.message, issues: error.issues }, 400);\n }\n // The user has an erasure tombstone: gone, and nothing about them is taken, now or later\n if (error instanceof UserErasedError) return json({ error: 'user is erased' }, 410);\n // The operator's to fix (run the migrations), so the response says what is missing\n if (error instanceof SchemaMismatchError) {\n return json({ error: error.message, problems: error.problems }, 503);\n }\n console.error('[workflow http]', error);\n return json({ error: 'internal error' }, 500);\n}\n\n/** One request through the table: the production gate, the bearer, the route, the error mapping. */\nexport async function serve(request: Request, ctx: ServeContext): Promise<Response> {\n const url = new URL(request.url);\n if (ctx.host !== undefined && refusesOpen(request, ctx, url.pathname)) {\n return json(\n {\n error:\n 'API_TOKEN is not set: production refuses to serve without it (set ENVIRONMENT=development to run open)',\n },\n 503\n );\n }\n const candidates = routes.filter((route) => matches(route.path, url.pathname));\n if (candidates.length === 0) return notFound();\n // Every API route asks the authorizer; the health check and the recipients' routes never do.\n // An unset token and no authorizer means an open dev instance (see JourneyEnv.API_TOKEN);\n // production refuses to run that way\n if (candidates.some((route) => route.auth === 'api')) {\n let ok: boolean;\n try {\n ok = await allowed(request, ctx);\n } catch (error) {\n console.error('[workflow http] authorize failed', error);\n return json({ error: 'internal error' }, 500);\n }\n if (!ok) return json({ error: 'unauthorized' }, 401);\n }\n const route = candidates.find((r) => r.method === '*' || r.method === request.method);\n if (route === undefined) return json({ error: 'method not allowed' }, 405);\n try {\n return await route.handle({\n request,\n url,\n env: ctx.env,\n store: ctx.store,\n validation: ctx.validation ?? {},\n host: ctx.host ?? {},\n });\n } catch (error) {\n return failure(error);\n }\n}\n\n/** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/subscriptions/:userId`, `/subscriptions/by-message/:messageId`, `/users/forget`, `/deploy`. */\nexport function handleRequest(\n request: Request,\n env: JourneyEnv,\n store: JourneyStore,\n validation: IngestValidation = {}\n): Promise<Response> {\n return serve(request, { env, store, validation });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"http.cjs","names":["handleTracking","checkEvent","ingestEvent","identifyUser","registerDevice","unbindDevice","removeDevice","recordPushOpen","getSubscriptions","parseSubscriptionsPatch","setSubscriptions","getSubscriptionsByMessage","setSubscriptionsByMessage","forgetUser","deployBundle","PREVIEW_MESSAGE_ID","journeyMode","authorized","z","BundleIntegrityError","TemplateValidationError","UserErasedError","SchemaMismatchError"],"sources":["../../src/host/http.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport { type Contract, type ContractResult, checkEvent } from '../contract';\nimport { PREVIEW_MESSAGE_ID } from '../email';\nimport type { EmailRenderer } from '../email-sender';\nimport type { BundleIR, ScalarIR } from '../ir';\nimport { type TrackingOptions, handleTracking, recordPushOpen } from '../reporting/tracking';\nimport {\n type BundleVersion,\n type JourneyStore,\n type ProfileProps,\n SchemaMismatchError,\n UserErasedError,\n} from '../store/index';\nimport { TemplateValidationError } from '../templates';\nimport { type Authorize, authorized } from './auth';\nimport { journeyMode } from './channels';\nimport {\n BundleIntegrityError,\n type SweepResult,\n deployBundle,\n forgetUser,\n getSubscriptions,\n getSubscriptionsByMessage,\n identifyUser,\n ingestEvent,\n parseSubscriptionsPatch,\n registerDevice,\n removeDevice,\n setSubscriptions,\n setSubscriptionsByMessage,\n unbindDevice,\n} from './ingest';\nimport type { JourneyEnv } from './platform';\n\n/**\n * The HTTP surface, as one route table: the bare ingest API (`handleRequest`) and the routes\n * the full host adds (`serve` with its parts — the bundle, previews, tracking, the sweep). A\n * route whose part is absent answers 404, so the two share the table, the bearer check, the\n * body validation and the error mapping.\n */\n\n/**\n * How the ingest API applies a contract: 'enforce' refuses an invalid event or\n * patch with a 400 before anything is stored, woken or started; 'report' logs\n * the issues (event id, name, field paths — never the payload) and accepts it,\n * for checking real producers before turning enforcement on.\n */\nexport interface IngestValidation {\n contract?: Contract | undefined;\n mode?: 'enforce' | 'report' | undefined;\n}\n\n/** What the full host serves beyond the ingest API. A route whose part is absent answers 404. */\nexport interface HostParts {\n /** The bundle compiled into the code: `GET /bundle` shows it, `POST /bundle/deploy` makes it live. */\n bundle?: (() => BundleIR) | undefined;\n /** The code version serving this request, recorded as live by `POST /bundle/deploy`. */\n bundleVersion?: BundleVersion | undefined;\n /** `GET /preview/:id?props=<JSON>`: the email templates, rendered. */\n preview?: { has(id: string): boolean; render: EmailRenderer } | undefined;\n /** `/t/*`, what recipients open (no bearer): served on a store that keeps reporting. */\n tracking?: TrackingOptions | undefined;\n /** `POST /segments/sweep`. */\n sweep?: (() => Promise<SweepResult>) | undefined;\n}\n\nexport interface ServeContext {\n env: JourneyEnv;\n store: JourneyStore;\n validation?: IngestValidation | undefined;\n /**\n * The host's parts. Their presence is what makes this a deployed host rather than the bare\n * API: production then refuses to serve without `API_TOKEN` — unless the host authorizes\n * callers itself (`authorize`).\n */\n host?: HostParts | undefined;\n /** The host's own authorization for the API routes; the env's `API_TOKEN` bearer without one. */\n authorize?: Authorize<JourneyEnv> | undefined;\n}\n\ninterface RouteContext {\n request: Request;\n url: URL;\n env: JourneyEnv;\n store: JourneyStore;\n validation: IngestValidation;\n host: HostParts;\n}\n\ninterface Route {\n /** '*' takes every method (the tracking routes answer GET, HEAD and POST themselves). */\n method: 'GET' | 'POST' | 'PUT' | '*';\n path: string | RegExp | ((pathname: string) => boolean);\n /** `api`: the caller must be authorized (the host's `authorize`, or the bearer); `none`: public. */\n auth: 'none' | 'api';\n handle(ctx: RouteContext): Promise<Response>;\n}\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n });\n\nconst notFound = () => json({ error: 'not found' }, 404);\n\n/* --------------------------------- bodies ---------------------------------- */\n\n/** The request body is not JSON: the caller's to fix (a 400), not a server fault. */\nclass InvalidJsonError extends Error {\n override readonly name = 'InvalidJsonError';\n}\n\nasync function readJson(request: Request): Promise<unknown> {\n try {\n return await request.json();\n } catch (error) {\n throw new InvalidJsonError('invalid JSON body', { cause: error });\n }\n}\n\n/**\n * Request bodies are untrusted JSON. They are read as unknown fields and\n * checked, never cast into the shape they are supposed to have: a cast only\n * silences the compiler, and what arrives here reaches SQL parameters.\n */\nasync function readBody(request: Request): Promise<Record<string, unknown>> {\n const body = await readJson(request);\n return isPlainObject(body) ? body : {};\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.length > 0;\n}\n\n/** A profile value: a scalar or null (which removes the property). Nested values are not profile data. */\nfunction isScalarOrNull(value: unknown): value is string | number | boolean | null {\n return (\n value === null ||\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n );\n}\n\n/** How far ahead of the server's clock an event or device `ts` may be: clock skew, not the future. */\nconst TS_FUTURE_TOLERANCE_MS = 5 * 60_000;\nconst TS_ERROR = 'ts must be a non-negative integer no later than 5 minutes in the future';\n\n/**\n * A caller's timestamp: epoch milliseconds, whole, not negative, and not past the skew tolerance.\n * A far-future ts would put the event ahead of everything a window condition reads, and a\n * fractional or unsafe one does not survive the store.\n */\nfunction isValidTs(ts: unknown): ts is number {\n return (\n typeof ts === 'number' &&\n Number.isSafeInteger(ts) &&\n ts >= 0 &&\n ts <= Date.now() + TS_FUTURE_TOLERANCE_MS\n );\n}\n\n/** The contract's verdict: the value to use, or the response refusing the request. */\nfunction checked<T>(\n validation: IngestValidation,\n result: ContractResult<T>,\n original: T,\n what: string\n): { value: T } | { refused: Response } {\n if (result.ok) return { value: result.data };\n const issues = result.issues.map((i) => `${i.path || '(root)'}: ${i.message}`);\n if (validation.mode === 'report') {\n console.warn(`[workflow http] contract would refuse ${what}: ${issues.join('; ')}`);\n return { value: original };\n }\n return {\n refused: json({ error: `${what} refused by the contract`, issues: result.issues }, 400),\n };\n}\n\n/**\n * The topics the engine knows: the deployed bundle's list (compiled from the contract), or the\n * contract's when the bare API runs without a host — never the caller's.\n */\nfunction declaredTopics(ctx: Pick<RouteContext, 'host' | 'validation'>): readonly string[] {\n return ctx.host.bundle?.().subscriptions?.topics ?? ctx.validation.contract?.topics ?? [];\n}\n\nconst SUBSCRIPTIONS = /^\\/subscriptions\\/[^/]+$/;\nconst subscriptionsUser = (url: URL) =>\n decodeURIComponent(url.pathname.slice('/subscriptions/'.length));\n/**\n * The same record, reached by one of the user's copies instead of their id: for the app's\n * preference page opened from an email's `preferencesUrl?m=<message>` without a sign-in. The\n * message id is the capability (an unguessable UUID, as `/t/u` trusts it); the bearer still\n * guards the route, so the page's own API proxies it, and rate-limits it as it sees fit.\n */\nconst SUBSCRIPTIONS_BY_MESSAGE = /^\\/subscriptions\\/by-message\\/[^/]+$/;\nconst subscriptionsMessage = (url: URL) =>\n decodeURIComponent(url.pathname.slice('/subscriptions/by-message/'.length));\n\n/* --------------------------------- routes ---------------------------------- */\n\nconst routes: readonly Route[] = [\n { method: 'GET', path: '/health', auth: 'none', handle: async () => json({ ok: true }) },\n {\n // Opened by recipients, so no bearer: the routes only record, and redirect to recorded URLs\n method: '*',\n path: /^\\/t\\//,\n auth: 'none',\n async handle({ request, store, host }) {\n if (host.tracking === undefined) return notFound();\n return (await handleTracking(request, store.reporting, host.tracking)) ?? notFound();\n },\n },\n {\n method: 'POST',\n path: '/events',\n auth: 'api',\n async handle({ request, env, store, validation }) {\n const { id, userId, event, payload, ts } = await readBody(request);\n if (!isNonEmptyString(userId) || !isNonEmptyString(event)) {\n return json({ error: 'userId and event required' }, 400);\n }\n if (event.startsWith('$')) {\n return json({ error: 'event names starting with $ are reserved' }, 400);\n }\n // An empty id is rejected rather than treated as absent: a caller that\n // sent one meant to identify the occurrence, and silently minting a\n // different one would turn their retry into a duplicate.\n if (id !== undefined && !isNonEmptyString(id)) {\n return json({ error: 'id must be a non-empty string' }, 400);\n }\n if (ts !== undefined && !isValidTs(ts)) return json({ error: TS_ERROR }, 400);\n if (payload !== undefined && !isPlainObject(payload)) {\n return json({ error: 'payload must be an object' }, 400);\n }\n let accepted = payload;\n if (validation.contract !== undefined) {\n const verdict = checked(\n validation,\n checkEvent(validation.contract, event, payload ?? {}),\n accepted ?? {},\n `event '${event}'${typeof id === 'string' ? ` (${id})` : ''}`\n );\n if ('refused' in verdict) return verdict.refused;\n accepted = verdict.value;\n }\n return json(await ingestEvent(env, { id, userId, event, payload: accepted, ts }, store));\n },\n },\n {\n method: 'POST',\n path: '/identify',\n auth: 'api',\n async handle({ request, env, store, validation }) {\n const { userId, props } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isPlainObject(props)) return json({ error: 'props must be an object' }, 400);\n // Profiles hold scalars only (both stores merge at the top level; a nested object would\n // be merged differently by each): refused here, contract or not\n if (!Object.values(props).every(isScalarOrNull)) {\n return json(\n { error: 'props values must be scalars (string, number, boolean) or null' },\n 400\n );\n }\n let patch = props as ProfileProps;\n if (validation.contract !== undefined) {\n const verdict = checked(\n validation,\n validation.contract.parseProfilePatch(props),\n patch,\n 'identify'\n );\n if ('refused' in verdict) return verdict.refused;\n patch = verdict.value as ProfileProps;\n }\n return json(await identifyUser(env, userId, patch, store));\n },\n },\n {\n method: 'POST',\n path: '/devices',\n auth: 'api',\n async handle({ request, env, store }) {\n const { userId, token, platform, ts } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n if (platform !== 'ios' && platform !== 'android' && platform !== 'web') {\n return json({ error: \"platform must be 'ios', 'android' or 'web'\" }, 400);\n }\n if (ts !== undefined && !isValidTs(ts)) return json({ error: TS_ERROR }, 400);\n const device = { userId, token, platform, ...(ts === undefined ? {} : { ts }) };\n return json(await registerDevice(env, device, store));\n },\n },\n {\n method: 'POST',\n path: '/devices/unbind',\n auth: 'api',\n async handle({ request, env, store }) {\n const { token } = await readBody(request);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n return json(await unbindDevice(env, token, store));\n },\n },\n {\n method: 'POST',\n path: '/devices/remove',\n auth: 'api',\n async handle({ request, env, store }) {\n const { token } = await readBody(request);\n if (!isNonEmptyString(token)) return json({ error: 'token required' }, 400);\n return json(await removeDevice(env, token, store));\n },\n },\n {\n method: 'POST',\n path: '/messages/opened',\n auth: 'api',\n async handle({ request, store }) {\n const { userId, messageId } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n if (!isNonEmptyString(messageId)) return json({ error: 'messageId required' }, 400);\n const recorded = await recordPushOpen(store.reporting, userId, messageId);\n return json({ ok: true, recorded });\n },\n },\n {\n method: 'GET',\n path: SUBSCRIPTIONS,\n auth: 'api',\n async handle(ctx) {\n const userId = subscriptionsUser(ctx.url);\n if (userId === '') return json({ error: 'userId required' }, 400);\n return json(await getSubscriptions(ctx.store, userId, declaredTopics(ctx)));\n },\n },\n {\n method: 'PUT',\n path: SUBSCRIPTIONS,\n auth: 'api',\n async handle(ctx) {\n const userId = subscriptionsUser(ctx.url);\n if (userId === '') return json({ error: 'userId required' }, 400);\n const topics = declaredTopics(ctx);\n const parsed = parseSubscriptionsPatch(await readJson(ctx.request), topics);\n if (!parsed.ok) return json({ error: 'subscriptions refused', issues: parsed.issues }, 400);\n return json(await setSubscriptions(ctx.env, ctx.store, userId, parsed.patch, topics));\n },\n },\n {\n method: 'GET',\n path: SUBSCRIPTIONS_BY_MESSAGE,\n auth: 'api',\n async handle(ctx) {\n const messageId = subscriptionsMessage(ctx.url);\n const found = await getSubscriptionsByMessage(ctx.store, messageId, declaredTopics(ctx));\n return found === undefined ? json({ error: 'unknown message' }, 404) : json(found);\n },\n },\n {\n method: 'PUT',\n path: SUBSCRIPTIONS_BY_MESSAGE,\n auth: 'api',\n async handle(ctx) {\n const messageId = subscriptionsMessage(ctx.url);\n const topics = declaredTopics(ctx);\n const parsed = parseSubscriptionsPatch(await readJson(ctx.request), topics);\n if (!parsed.ok) return json({ error: 'subscriptions refused', issues: parsed.issues }, 400);\n const found = await setSubscriptionsByMessage(\n ctx.env,\n ctx.store,\n messageId,\n parsed.patch,\n topics\n );\n return found === undefined ? json({ error: 'unknown message' }, 404) : json(found);\n },\n },\n {\n method: 'POST',\n path: '/users/forget',\n auth: 'api',\n async handle({ request, env, store }) {\n const { userId } = await readBody(request);\n if (!isNonEmptyString(userId)) return json({ error: 'userId required' }, 400);\n return json(await forgetUser(env, userId, store));\n },\n },\n {\n method: 'POST',\n path: '/deploy',\n auth: 'api',\n async handle({ request, env, store }) {\n return json(await deployBundle(env, await readJson(request), store));\n },\n },\n {\n method: 'GET',\n path: '/bundle',\n auth: 'api',\n async handle({ host }) {\n return host.bundle === undefined ? notFound() : json(host.bundle());\n },\n },\n {\n method: 'POST',\n path: '/bundle/deploy',\n auth: 'api',\n async handle({ env, store, host }) {\n if (host.bundle === undefined) return notFound();\n // Forced, and recording the serving version: otherwise an older version still serving\n // during a rollout would put its routes live while the row kept naming the newer one —\n // which then never re-applies its own bundle\n return json(await deployBundle(env, host.bundle(), store, host.bundleVersion, true));\n },\n },\n {\n method: 'POST',\n path: '/segments/sweep',\n auth: 'api',\n async handle({ host }) {\n return host.sweep === undefined ? notFound() : json(await host.sweep());\n },\n },\n {\n method: 'GET',\n path: /^\\/preview\\//,\n auth: 'api',\n async handle({ url, host }) {\n const { preview } = host;\n if (preview === undefined) return notFound();\n const key = url.pathname.slice('/preview/'.length);\n if (!preview.has(key)) return json({ error: `unknown email template: ${key}` }, 404);\n // `?props=<JSON object>`: JSON keeps numbers and booleans typed, which query strings cannot\n let props: unknown;\n try {\n props = JSON.parse(url.searchParams.get('props') ?? '{}');\n } catch {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n if (!isPlainObject(props)) return json({ error: 'props must be a JSON object' }, 400);\n // A preview is no copy, but it shows the footer a sent one has: the preferences link\n // renders with a placeholder message id\n const { subject, html } = await preview.render(key, props as Record<string, ScalarIR>, {\n messageId: PREVIEW_MESSAGE_ID,\n });\n // A header holds bytes, not text: a subject with a non-Latin-1 character would throw\n // here, so it travels percent-encoded (the reader `decodeURIComponent`s it)\n return new Response(html, {\n headers: {\n 'content-type': 'text/html; charset=utf-8',\n 'x-subject': encodeURIComponent(subject),\n },\n });\n },\n },\n];\n\nfunction matches(path: Route['path'], pathname: string): boolean {\n if (typeof path === 'string') return path === pathname;\n if (path instanceof RegExp) return path.test(pathname);\n return path(pathname);\n}\n\n/**\n * Production without a bearer token (and no authorizer of the host's own) is not an open\n * instance, it is a mistake: anyone could ingest, identify and deploy. Only what recipients open\n * (`/t/*`) and the health check stay up.\n */\nfunction refusesOpen(request: Request, ctx: ServeContext, pathname: string): boolean {\n return (\n ctx.authorize === undefined &&\n ctx.env.API_TOKEN === undefined &&\n journeyMode(ctx.env) === 'production' &&\n !pathname.startsWith('/t/') &&\n !(request.method === 'GET' && pathname === '/health')\n );\n}\n\n/**\n * Whether the caller may use an API route: the host's authorizer on a copy of the request (so a\n * body it verifies is still there for the route), or the env's `API_TOKEN` as a bearer. An\n * authorizer that throws is a failure, never an admission.\n */\nasync function allowed(request: Request, ctx: ServeContext): Promise<boolean> {\n if (ctx.authorize === undefined) return authorized(request, ctx.env);\n return ctx.authorize(request.clone(), ctx.env);\n}\n\n/**\n * What a failed request answers. Validation problems are the caller's to fix (a 400, saying\n * what); anything else stays out of the response body (internals are logged, not leaked).\n */\nfunction failure(error: unknown): Response {\n if (error instanceof InvalidJsonError) return json({ error: 'invalid JSON body' }, 400);\n if (error instanceof z.core.$ZodError) {\n return json({ error: `invalid payload: ${error.message}` }, 400);\n }\n // The caller sent a self-inconsistent bundle: their problem to fix, so say what is wrong\n if (error instanceof BundleIntegrityError) return json({ error: error.message }, 400);\n // The query string is the props: a refusal is the caller's to fix, with the field paths\n if (error instanceof TemplateValidationError) {\n return json({ error: error.message, issues: error.issues }, 400);\n }\n // The user has an erasure tombstone: gone, and nothing about them is taken, now or later\n if (error instanceof UserErasedError) return json({ error: 'user is erased' }, 410);\n // The operator's to fix (run the migrations), so the response says what is missing\n if (error instanceof SchemaMismatchError) {\n return json({ error: error.message, problems: error.problems }, 503);\n }\n console.error('[workflow http]', error);\n return json({ error: 'internal error' }, 500);\n}\n\n/** One request through the table: the production gate, the bearer, the route, the error mapping. */\nexport async function serve(request: Request, ctx: ServeContext): Promise<Response> {\n const url = new URL(request.url);\n if (refusesOpen(request, ctx, url.pathname)) {\n return json(\n {\n error:\n 'API_TOKEN is not set: production refuses to serve without it (set ENVIRONMENT=development to run open)',\n },\n 503\n );\n }\n const candidates = routes.filter((route) => matches(route.path, url.pathname));\n if (candidates.length === 0) return notFound();\n // Every API route asks the authorizer; the health check and the recipients' routes never do.\n // An unset token and no authorizer means an open dev instance (see JourneyEnv.API_TOKEN);\n // production refuses to run that way\n if (candidates.some((route) => route.auth === 'api')) {\n let ok: boolean;\n try {\n ok = await allowed(request, ctx);\n } catch (error) {\n console.error('[workflow http] authorize failed', error);\n return json({ error: 'internal error' }, 500);\n }\n if (!ok) return json({ error: 'unauthorized' }, 401);\n }\n const route = candidates.find((r) => r.method === '*' || r.method === request.method);\n if (route === undefined) return json({ error: 'method not allowed' }, 405);\n try {\n return await route.handle({\n request,\n url,\n env: ctx.env,\n store: ctx.store,\n validation: ctx.validation ?? {},\n host: ctx.host ?? {},\n });\n } catch (error) {\n return failure(error);\n }\n}\n\n/** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/subscriptions/:userId`, `/subscriptions/by-message/:messageId`, `/users/forget`, `/deploy`. */\nexport function handleRequest(\n request: Request,\n env: JourneyEnv,\n store: JourneyStore,\n validation: IngestValidation = {}\n): Promise<Response> {\n return serve(request, { env, store, validation });\n}\n"],"mappings":";;;;;;;;;;;;;;AAkGA,MAAM,QAAQ,MAAe,SAAS,QACpC,IAAI,SAAS,KAAK,UAAU,IAAI,GAAG;CACjC;CACA,SAAS,EAAE,gBAAgB,mBAAmB;AAChD,CAAC;AAEH,MAAM,iBAAiB,KAAK,EAAE,OAAO,YAAY,GAAG,GAAG;;AAKvD,IAAM,mBAAN,cAA+B,MAAM;CACnC,OAAyB;AAC3B;AAEA,eAAe,SAAS,SAAoC;CAC1D,IAAI;EACF,OAAO,MAAM,QAAQ,KAAK;CAC5B,SAAS,OAAO;EACd,MAAM,IAAI,iBAAiB,qBAAqB,EAAE,OAAO,MAAM,CAAC;CAClE;AACF;;;;;;AAOA,eAAe,SAAS,SAAoD;CAC1E,MAAM,OAAO,MAAM,SAAS,OAAO;CACnC,OAAO,cAAc,IAAI,IAAI,OAAO,CAAC;AACvC;AAEA,SAAS,cAAc,OAAkD;CACvE,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEA,SAAS,iBAAiB,OAAiC;CACzD,OAAO,OAAO,UAAU,YAAY,MAAM,SAAS;AACrD;;AAGA,SAAS,eAAe,OAA2D;CACjF,OACE,UAAU,QACV,OAAO,UAAU,YACjB,OAAO,UAAU,YACjB,OAAO,UAAU;AAErB;;AAGA,MAAM,yBAAyB;AAC/B,MAAM,WAAW;;;;;;AAOjB,SAAS,UAAU,IAA2B;CAC5C,OACE,OAAO,OAAO,YACd,OAAO,cAAc,EAAE,KACvB,MAAM,KACN,MAAM,KAAK,IAAI,IAAI;AAEvB;;AAGA,SAAS,QACP,YACA,QACA,UACA,MACsC;CACtC,IAAI,OAAO,IAAI,OAAO,EAAE,OAAO,OAAO,KAAK;CAC3C,MAAM,SAAS,OAAO,OAAO,KAAK,MAAM,GAAG,EAAE,QAAQ,SAAS,IAAI,EAAE,SAAS;CAC7E,IAAI,WAAW,SAAS,UAAU;EAChC,QAAQ,KAAK,yCAAyC,KAAK,IAAI,OAAO,KAAK,IAAI,GAAG;EAClF,OAAO,EAAE,OAAO,SAAS;CAC3B;CACA,OAAO,EACL,SAAS,KAAK;EAAE,OAAO,GAAG,KAAK;EAA2B,QAAQ,OAAO;CAAO,GAAG,GAAG,EACxF;AACF;;;;;AAMA,SAAS,eAAe,KAAmE;CACzF,OAAO,IAAI,KAAK,SAAS,CAAC,CAAC,eAAe,UAAU,IAAI,WAAW,UAAU,UAAU,CAAC;AAC1F;AAEA,MAAM,gBAAgB;AACtB,MAAM,qBAAqB,QACzB,mBAAmB,IAAI,SAAS,MAAM,EAAwB,CAAC;;;;;;;AAOjE,MAAM,2BAA2B;AACjC,MAAM,wBAAwB,QAC5B,mBAAmB,IAAI,SAAS,MAAM,EAAmC,CAAC;AAI5E,MAAM,SAA2B;CAC/B;EAAE,QAAQ;EAAO,MAAM;EAAW,MAAM;EAAQ,QAAQ,YAAY,KAAK,EAAE,IAAI,KAAK,CAAC;CAAE;CACvF;EAEE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,SAAS,OAAO,QAAQ;GACrC,IAAI,KAAK,aAAa,KAAA,GAAW,OAAO,SAAS;GACjD,OAAQ,MAAMA,2BAAAA,eAAe,SAAS,MAAM,WAAW,KAAK,QAAQ,KAAM,SAAS;EACrF;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,SAAS,KAAK,OAAO,cAAc;GAChD,MAAM,EAAE,IAAI,QAAQ,OAAO,SAAS,OAAO,MAAM,SAAS,OAAO;GACjE,IAAI,CAAC,iBAAiB,MAAM,KAAK,CAAC,iBAAiB,KAAK,GACtD,OAAO,KAAK,EAAE,OAAO,4BAA4B,GAAG,GAAG;GAEzD,IAAI,MAAM,WAAW,GAAG,GACtB,OAAO,KAAK,EAAE,OAAO,2CAA2C,GAAG,GAAG;GAKxE,IAAI,OAAO,KAAA,KAAa,CAAC,iBAAiB,EAAE,GAC1C,OAAO,KAAK,EAAE,OAAO,gCAAgC,GAAG,GAAG;GAE7D,IAAI,OAAO,KAAA,KAAa,CAAC,UAAU,EAAE,GAAG,OAAO,KAAK,EAAE,OAAO,SAAS,GAAG,GAAG;GAC5E,IAAI,YAAY,KAAA,KAAa,CAAC,cAAc,OAAO,GACjD,OAAO,KAAK,EAAE,OAAO,4BAA4B,GAAG,GAAG;GAEzD,IAAI,WAAW;GACf,IAAI,WAAW,aAAa,KAAA,GAAW;IACrC,MAAM,UAAU,QACd,YACAC,iBAAAA,WAAW,WAAW,UAAU,OAAO,WAAW,CAAC,CAAC,GACpD,YAAY,CAAC,GACb,UAAU,MAAM,GAAG,OAAO,OAAO,WAAW,KAAK,GAAG,KAAK,IAC3D;IACA,IAAI,aAAa,SAAS,OAAO,QAAQ;IACzC,WAAW,QAAQ;GACrB;GACA,OAAO,KAAK,MAAMC,oBAAAA,YAAY,KAAK;IAAE;IAAI;IAAQ;IAAO,SAAS;IAAU;GAAG,GAAG,KAAK,CAAC;EACzF;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,SAAS,KAAK,OAAO,cAAc;GAChD,MAAM,EAAE,QAAQ,UAAU,MAAM,SAAS,OAAO;GAChD,IAAI,CAAC,iBAAiB,MAAM,GAAG,OAAO,KAAK,EAAE,OAAO,kBAAkB,GAAG,GAAG;GAC5E,IAAI,CAAC,cAAc,KAAK,GAAG,OAAO,KAAK,EAAE,OAAO,0BAA0B,GAAG,GAAG;GAGhF,IAAI,CAAC,OAAO,OAAO,KAAK,CAAC,CAAC,MAAM,cAAc,GAC5C,OAAO,KACL,EAAE,OAAO,iEAAiE,GAC1E,GACF;GAEF,IAAI,QAAQ;GACZ,IAAI,WAAW,aAAa,KAAA,GAAW;IACrC,MAAM,UAAU,QACd,YACA,WAAW,SAAS,kBAAkB,KAAK,GAC3C,OACA,UACF;IACA,IAAI,aAAa,SAAS,OAAO,QAAQ;IACzC,QAAQ,QAAQ;GAClB;GACA,OAAO,KAAK,MAAMC,oBAAAA,aAAa,KAAK,QAAQ,OAAO,KAAK,CAAC;EAC3D;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,SAAS,KAAK,SAAS;GACpC,MAAM,EAAE,QAAQ,OAAO,UAAU,OAAO,MAAM,SAAS,OAAO;GAC9D,IAAI,CAAC,iBAAiB,MAAM,GAAG,OAAO,KAAK,EAAE,OAAO,kBAAkB,GAAG,GAAG;GAC5E,IAAI,CAAC,iBAAiB,KAAK,GAAG,OAAO,KAAK,EAAE,OAAO,iBAAiB,GAAG,GAAG;GAC1E,IAAI,aAAa,SAAS,aAAa,aAAa,aAAa,OAC/D,OAAO,KAAK,EAAE,OAAO,6CAA6C,GAAG,GAAG;GAE1E,IAAI,OAAO,KAAA,KAAa,CAAC,UAAU,EAAE,GAAG,OAAO,KAAK,EAAE,OAAO,SAAS,GAAG,GAAG;GAC5E,MAAM,SAAS;IAAE;IAAQ;IAAO;IAAU,GAAI,OAAO,KAAA,IAAY,CAAC,IAAI,EAAE,GAAG;GAAG;GAC9E,OAAO,KAAK,MAAMC,oBAAAA,eAAe,KAAK,QAAQ,KAAK,CAAC;EACtD;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,SAAS,KAAK,SAAS;GACpC,MAAM,EAAE,UAAU,MAAM,SAAS,OAAO;GACxC,IAAI,CAAC,iBAAiB,KAAK,GAAG,OAAO,KAAK,EAAE,OAAO,iBAAiB,GAAG,GAAG;GAC1E,OAAO,KAAK,MAAMC,oBAAAA,aAAa,KAAK,OAAO,KAAK,CAAC;EACnD;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,SAAS,KAAK,SAAS;GACpC,MAAM,EAAE,UAAU,MAAM,SAAS,OAAO;GACxC,IAAI,CAAC,iBAAiB,KAAK,GAAG,OAAO,KAAK,EAAE,OAAO,iBAAiB,GAAG,GAAG;GAC1E,OAAO,KAAK,MAAMC,oBAAAA,aAAa,KAAK,OAAO,KAAK,CAAC;EACnD;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,SAAS,SAAS;GAC/B,MAAM,EAAE,QAAQ,cAAc,MAAM,SAAS,OAAO;GACpD,IAAI,CAAC,iBAAiB,MAAM,GAAG,OAAO,KAAK,EAAE,OAAO,kBAAkB,GAAG,GAAG;GAC5E,IAAI,CAAC,iBAAiB,SAAS,GAAG,OAAO,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;GAClF,MAAM,WAAW,MAAMC,2BAAAA,eAAe,MAAM,WAAW,QAAQ,SAAS;GACxE,OAAO,KAAK;IAAE,IAAI;IAAM;GAAS,CAAC;EACpC;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,KAAK;GAChB,MAAM,SAAS,kBAAkB,IAAI,GAAG;GACxC,IAAI,WAAW,IAAI,OAAO,KAAK,EAAE,OAAO,kBAAkB,GAAG,GAAG;GAChE,OAAO,KAAK,MAAMC,oBAAAA,iBAAiB,IAAI,OAAO,QAAQ,eAAe,GAAG,CAAC,CAAC;EAC5E;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,KAAK;GAChB,MAAM,SAAS,kBAAkB,IAAI,GAAG;GACxC,IAAI,WAAW,IAAI,OAAO,KAAK,EAAE,OAAO,kBAAkB,GAAG,GAAG;GAChE,MAAM,SAAS,eAAe,GAAG;GACjC,MAAM,SAASC,oBAAAA,wBAAwB,MAAM,SAAS,IAAI,OAAO,GAAG,MAAM;GAC1E,IAAI,CAAC,OAAO,IAAI,OAAO,KAAK;IAAE,OAAO;IAAyB,QAAQ,OAAO;GAAO,GAAG,GAAG;GAC1F,OAAO,KAAK,MAAMC,oBAAAA,iBAAiB,IAAI,KAAK,IAAI,OAAO,QAAQ,OAAO,OAAO,MAAM,CAAC;EACtF;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,KAAK;GAChB,MAAM,YAAY,qBAAqB,IAAI,GAAG;GAC9C,MAAM,QAAQ,MAAMC,oBAAAA,0BAA0B,IAAI,OAAO,WAAW,eAAe,GAAG,CAAC;GACvF,OAAO,UAAU,KAAA,IAAY,KAAK,EAAE,OAAO,kBAAkB,GAAG,GAAG,IAAI,KAAK,KAAK;EACnF;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,KAAK;GAChB,MAAM,YAAY,qBAAqB,IAAI,GAAG;GAC9C,MAAM,SAAS,eAAe,GAAG;GACjC,MAAM,SAASF,oBAAAA,wBAAwB,MAAM,SAAS,IAAI,OAAO,GAAG,MAAM;GAC1E,IAAI,CAAC,OAAO,IAAI,OAAO,KAAK;IAAE,OAAO;IAAyB,QAAQ,OAAO;GAAO,GAAG,GAAG;GAC1F,MAAM,QAAQ,MAAMG,oBAAAA,0BAClB,IAAI,KACJ,IAAI,OACJ,WACA,OAAO,OACP,MACF;GACA,OAAO,UAAU,KAAA,IAAY,KAAK,EAAE,OAAO,kBAAkB,GAAG,GAAG,IAAI,KAAK,KAAK;EACnF;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,SAAS,KAAK,SAAS;GACpC,MAAM,EAAE,WAAW,MAAM,SAAS,OAAO;GACzC,IAAI,CAAC,iBAAiB,MAAM,GAAG,OAAO,KAAK,EAAE,OAAO,kBAAkB,GAAG,GAAG;GAC5E,OAAO,KAAK,MAAMC,oBAAAA,WAAW,KAAK,QAAQ,KAAK,CAAC;EAClD;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,SAAS,KAAK,SAAS;GACpC,OAAO,KAAK,MAAMC,oBAAAA,aAAa,KAAK,MAAM,SAAS,OAAO,GAAG,KAAK,CAAC;EACrE;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,QAAQ;GACrB,OAAO,KAAK,WAAW,KAAA,IAAY,SAAS,IAAI,KAAK,KAAK,OAAO,CAAC;EACpE;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,KAAK,OAAO,QAAQ;GACjC,IAAI,KAAK,WAAW,KAAA,GAAW,OAAO,SAAS;GAI/C,OAAO,KAAK,MAAMA,oBAAAA,aAAa,KAAK,KAAK,OAAO,GAAG,OAAO,KAAK,eAAe,IAAI,CAAC;EACrF;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,QAAQ;GACrB,OAAO,KAAK,UAAU,KAAA,IAAY,SAAS,IAAI,KAAK,MAAM,KAAK,MAAM,CAAC;EACxE;CACF;CACA;EACE,QAAQ;EACR,MAAM;EACN,MAAM;EACN,MAAM,OAAO,EAAE,KAAK,QAAQ;GAC1B,MAAM,EAAE,YAAY;GACpB,IAAI,YAAY,KAAA,GAAW,OAAO,SAAS;GAC3C,MAAM,MAAM,IAAI,SAAS,MAAM,CAAkB;GACjD,IAAI,CAAC,QAAQ,IAAI,GAAG,GAAG,OAAO,KAAK,EAAE,OAAO,2BAA2B,MAAM,GAAG,GAAG;GAEnF,IAAI;GACJ,IAAI;IACF,QAAQ,KAAK,MAAM,IAAI,aAAa,IAAI,OAAO,KAAK,IAAI;GAC1D,QAAQ;IACN,OAAO,KAAK,EAAE,OAAO,8BAA8B,GAAG,GAAG;GAC3D;GACA,IAAI,CAAC,cAAc,KAAK,GAAG,OAAO,KAAK,EAAE,OAAO,8BAA8B,GAAG,GAAG;GAGpF,MAAM,EAAE,SAAS,SAAS,MAAM,QAAQ,OAAO,KAAK,OAAmC,EACrF,WAAWC,cAAAA,mBACb,CAAC;GAGD,OAAO,IAAI,SAAS,MAAM,EACxB,SAAS;IACP,gBAAgB;IAChB,aAAa,mBAAmB,OAAO;GACzC,EACF,CAAC;EACH;CACF;AACF;AAEA,SAAS,QAAQ,MAAqB,UAA2B;CAC/D,IAAI,OAAO,SAAS,UAAU,OAAO,SAAS;CAC9C,IAAI,gBAAgB,QAAQ,OAAO,KAAK,KAAK,QAAQ;CACrD,OAAO,KAAK,QAAQ;AACtB;;;;;;AAOA,SAAS,YAAY,SAAkB,KAAmB,UAA2B;CACnF,OACE,IAAI,cAAc,KAAA,KAClB,IAAI,IAAI,cAAc,KAAA,KACtBC,sBAAAA,YAAY,IAAI,GAAG,MAAM,gBACzB,CAAC,SAAS,WAAW,KAAK,KAC1B,EAAE,QAAQ,WAAW,SAAS,aAAa;AAE/C;;;;;;AAOA,eAAe,QAAQ,SAAkB,KAAqC;CAC5E,IAAI,IAAI,cAAc,KAAA,GAAW,OAAOC,kBAAAA,WAAW,SAAS,IAAI,GAAG;CACnE,OAAO,IAAI,UAAU,QAAQ,MAAM,GAAG,IAAI,GAAG;AAC/C;;;;;AAMA,SAAS,QAAQ,OAA0B;CACzC,IAAI,iBAAiB,kBAAkB,OAAO,KAAK,EAAE,OAAO,oBAAoB,GAAG,GAAG;CACtF,IAAI,iBAAiBC,SAAE,KAAK,WAC1B,OAAO,KAAK,EAAE,OAAO,oBAAoB,MAAM,UAAU,GAAG,GAAG;CAGjE,IAAI,iBAAiBC,oBAAAA,sBAAsB,OAAO,KAAK,EAAE,OAAO,MAAM,QAAQ,GAAG,GAAG;CAEpF,IAAI,iBAAiBC,kBAAAA,yBACnB,OAAO,KAAK;EAAE,OAAO,MAAM;EAAS,QAAQ,MAAM;CAAO,GAAG,GAAG;CAGjE,IAAI,iBAAiBC,sBAAAA,iBAAiB,OAAO,KAAK,EAAE,OAAO,iBAAiB,GAAG,GAAG;CAElF,IAAI,iBAAiBC,oBAAAA,qBACnB,OAAO,KAAK;EAAE,OAAO,MAAM;EAAS,UAAU,MAAM;CAAS,GAAG,GAAG;CAErE,QAAQ,MAAM,mBAAmB,KAAK;CACtC,OAAO,KAAK,EAAE,OAAO,iBAAiB,GAAG,GAAG;AAC9C;;AAGA,eAAsB,MAAM,SAAkB,KAAsC;CAClF,MAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;CAC/B,IAAI,YAAY,SAAS,KAAK,IAAI,QAAQ,GACxC,OAAO,KACL,EACE,OACE,yGACJ,GACA,GACF;CAEF,MAAM,aAAa,OAAO,QAAQ,UAAU,QAAQ,MAAM,MAAM,IAAI,QAAQ,CAAC;CAC7E,IAAI,WAAW,WAAW,GAAG,OAAO,SAAS;CAI7C,IAAI,WAAW,MAAM,UAAU,MAAM,SAAS,KAAK,GAAG;EACpD,IAAI;EACJ,IAAI;GACF,KAAK,MAAM,QAAQ,SAAS,GAAG;EACjC,SAAS,OAAO;GACd,QAAQ,MAAM,oCAAoC,KAAK;GACvD,OAAO,KAAK,EAAE,OAAO,iBAAiB,GAAG,GAAG;EAC9C;EACA,IAAI,CAAC,IAAI,OAAO,KAAK,EAAE,OAAO,eAAe,GAAG,GAAG;CACrD;CACA,MAAM,QAAQ,WAAW,MAAM,MAAM,EAAE,WAAW,OAAO,EAAE,WAAW,QAAQ,MAAM;CACpF,IAAI,UAAU,KAAA,GAAW,OAAO,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;CACzE,IAAI;EACF,OAAO,MAAM,MAAM,OAAO;GACxB;GACA;GACA,KAAK,IAAI;GACT,OAAO,IAAI;GACX,YAAY,IAAI,cAAc,CAAC;GAC/B,MAAM,IAAI,QAAQ,CAAC;EACrB,CAAC;CACH,SAAS,OAAO;EACd,OAAO,QAAQ,KAAK;CACtB;AACF;;AAGA,SAAgB,cACd,SACA,KACA,OACA,aAA+B,CAAC,GACb;CACnB,OAAO,MAAM,SAAS;EAAE;EAAK;EAAO;CAAW,CAAC;AAClD"}
@@ -1,6 +1,6 @@
1
1
  import { BundleIR } from "../ir.cjs";
2
2
  import { Contract } from "../contract.cjs";
3
- import { JourneyStore } from "../store/index.cjs";
3
+ import { BundleVersion, JourneyStore } from "../store/index.cjs";
4
4
  import { EmailRenderer } from "../email-sender.cjs";
5
5
  import { Authorize } from "./auth.cjs";
6
6
  import { JourneyEnv } from "./platform.cjs";
@@ -26,6 +26,8 @@ export interface IngestValidation {
26
26
  export interface HostParts {
27
27
  /** The bundle compiled into the code: `GET /bundle` shows it, `POST /bundle/deploy` makes it live. */
28
28
  bundle?: (() => BundleIR) | undefined;
29
+ /** The code version serving this request, recorded as live by `POST /bundle/deploy`. */
30
+ bundleVersion?: BundleVersion | undefined;
29
31
  /** `GET /preview/:id?props=<JSON>`: the email templates, rendered. */
30
32
  preview?: {
31
33
  has(id: string): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.cts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;iBA8CiB;EACf,WAAW;EACX;;;iBAIe;;EAEf,gBAAgB;;EAEhB;IAAY,IAAI;IAAsB,QAAQ;;;EAE9C,WAAW;;EAEX,eAAe,QAAQ;;iBAGR;EACf,KAAK;EACL,OAAO;EACP,aAAa;;;;;;EAMb,OAAO;;EAEP,YAAY,UAAU;;;wBA2bF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,OAAO,cACP,aAAY,mBACX,QAAQ"}
1
+ {"version":3,"file":"http.d.cts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;iBA+CiB;EACf,WAAW;EACX;;;iBAIe;;EAEf,gBAAgB;;EAEhB,gBAAgB;;EAEhB;IAAY,IAAI;IAAsB,QAAQ;;;EAE9C,WAAW;;EAEX,eAAe,QAAQ;;iBAGR;EACf,KAAK;EACL,OAAO;EACP,aAAa;;;;;;EAMb,OAAO;;EAEP,YAAY,UAAU;;;wBA8bF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,OAAO,cACP,aAAY,mBACX,QAAQ"}
@@ -1,6 +1,6 @@
1
1
  import { BundleIR } from "../ir.mjs";
2
2
  import { Contract } from "../contract.mjs";
3
- import { JourneyStore } from "../store/index.mjs";
3
+ import { BundleVersion, JourneyStore } from "../store/index.mjs";
4
4
  import { EmailRenderer } from "../email-sender.mjs";
5
5
  import { Authorize } from "./auth.mjs";
6
6
  import { JourneyEnv } from "./platform.mjs";
@@ -26,6 +26,8 @@ export interface IngestValidation {
26
26
  export interface HostParts {
27
27
  /** The bundle compiled into the code: `GET /bundle` shows it, `POST /bundle/deploy` makes it live. */
28
28
  bundle?: (() => BundleIR) | undefined;
29
+ /** The code version serving this request, recorded as live by `POST /bundle/deploy`. */
30
+ bundleVersion?: BundleVersion | undefined;
29
31
  /** `GET /preview/:id?props=<JSON>`: the email templates, rendered. */
30
32
  preview?: {
31
33
  has(id: string): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.mts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;iBA8CiB;EACf,WAAW;EACX;;;iBAIe;;EAEf,gBAAgB;;EAEhB;IAAY,IAAI;IAAsB,QAAQ;;;EAE9C,WAAW;;EAEX,eAAe,QAAQ;;iBAGR;EACf,KAAK;EACL,OAAO;EACP,aAAa;;;;;;EAMb,OAAO;;EAEP,YAAY,UAAU;;;wBA2bF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,OAAO,cACP,aAAY,mBACX,QAAQ"}
1
+ {"version":3,"file":"http.d.mts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;iBA+CiB;EACf,WAAW;EACX;;;iBAIe;;EAEf,gBAAgB;;EAEhB,gBAAgB;;EAEhB;IAAY,IAAI;IAAsB,QAAQ;;;EAE9C,WAAW;;EAEX,eAAe,QAAQ;;iBAGR;EACf,KAAK;EACL,OAAO;EACP,aAAa;;;;;;EAMb,OAAO;;EAEP,YAAY,UAAU;;;wBA8bF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,OAAO,cACP,aAAY,mBACX,QAAQ"}
@@ -283,7 +283,7 @@ const routes = [
283
283
  auth: "api",
284
284
  async handle({ env, store, host }) {
285
285
  if (host.bundle === void 0) return notFound();
286
- return json(await deployBundle(env, host.bundle(), store));
286
+ return json(await deployBundle(env, host.bundle(), store, host.bundleVersion, true));
287
287
  }
288
288
  },
289
289
  {
@@ -363,7 +363,7 @@ function failure(error) {
363
363
  /** One request through the table: the production gate, the bearer, the route, the error mapping. */
364
364
  async function serve(request, ctx) {
365
365
  const url = new URL(request.url);
366
- if (ctx.host !== void 0 && refusesOpen(request, ctx, url.pathname)) return json({ error: "API_TOKEN is not set: production refuses to serve without it (set ENVIRONMENT=development to run open)" }, 503);
366
+ if (refusesOpen(request, ctx, url.pathname)) return json({ error: "API_TOKEN is not set: production refuses to serve without it (set ENVIRONMENT=development to run open)" }, 503);
367
367
  const candidates = routes.filter((route) => matches(route.path, url.pathname));
368
368
  if (candidates.length === 0) return notFound();
369
369
  if (candidates.some((route) => route.auth === "api")) {