@shware/workflow 4.1.1 → 5.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 (118) hide show
  1. package/README.md +16 -38
  2. package/dist/aws/index.cjs +2 -3
  3. package/dist/aws/index.cjs.map +1 -1
  4. package/dist/aws/index.d.cts +1 -1
  5. package/dist/aws/index.d.mts +1 -1
  6. package/dist/aws/index.mjs +2 -3
  7. package/dist/aws/index.mjs.map +1 -1
  8. package/dist/aws/instances.cjs +1 -1
  9. package/dist/aws/instances.cjs.map +1 -1
  10. package/dist/aws/instances.d.cts +1 -1
  11. package/dist/aws/instances.d.mts +1 -1
  12. package/dist/aws/instances.mjs +1 -1
  13. package/dist/aws/instances.mjs.map +1 -1
  14. package/dist/cloudflare/bindings.cjs.map +1 -1
  15. package/dist/cloudflare/bindings.d.cts +0 -19
  16. package/dist/cloudflare/bindings.d.cts.map +1 -1
  17. package/dist/cloudflare/bindings.d.mts +0 -19
  18. package/dist/cloudflare/bindings.d.mts.map +1 -1
  19. package/dist/cloudflare/bindings.mjs.map +1 -1
  20. package/dist/cloudflare/index.cjs +0 -5
  21. package/dist/cloudflare/index.d.cts +4 -6
  22. package/dist/cloudflare/index.d.mts +4 -6
  23. package/dist/cloudflare/index.mjs +2 -4
  24. package/dist/cloudflare/runner.cjs +3 -4
  25. package/dist/cloudflare/runner.cjs.map +1 -1
  26. package/dist/cloudflare/runner.d.cts +3 -3
  27. package/dist/cloudflare/runner.d.cts.map +1 -1
  28. package/dist/cloudflare/runner.d.mts +3 -3
  29. package/dist/cloudflare/runner.d.mts.map +1 -1
  30. package/dist/cloudflare/runner.mjs +3 -4
  31. package/dist/cloudflare/runner.mjs.map +1 -1
  32. package/dist/config.cjs.map +1 -1
  33. package/dist/config.d.cts +1 -1
  34. package/dist/config.d.mts +1 -1
  35. package/dist/config.mjs.map +1 -1
  36. package/dist/engine/condition.cjs +2 -2
  37. package/dist/engine/condition.cjs.map +1 -1
  38. package/dist/engine/condition.d.cts +1 -1
  39. package/dist/engine/condition.d.mts +1 -1
  40. package/dist/engine/condition.mjs +2 -2
  41. package/dist/engine/condition.mjs.map +1 -1
  42. package/dist/engine/ports.d.cts +1 -1
  43. package/dist/engine/ports.d.mts +1 -1
  44. package/dist/host/channels.d.cts +1 -1
  45. package/dist/host/channels.d.mts +1 -1
  46. package/dist/host/execute.d.cts +1 -1
  47. package/dist/host/execute.d.mts +1 -1
  48. package/dist/host/http.cjs +3 -4
  49. package/dist/host/http.cjs.map +1 -1
  50. package/dist/host/http.d.cts +2 -2
  51. package/dist/host/http.d.cts.map +1 -1
  52. package/dist/host/http.d.mts +2 -2
  53. package/dist/host/http.d.mts.map +1 -1
  54. package/dist/host/http.mjs +3 -4
  55. package/dist/host/http.mjs.map +1 -1
  56. package/dist/host/ingest.cjs +11 -22
  57. package/dist/host/ingest.cjs.map +1 -1
  58. package/dist/host/ingest.d.cts +8 -13
  59. package/dist/host/ingest.d.cts.map +1 -1
  60. package/dist/host/ingest.d.mts +8 -13
  61. package/dist/host/ingest.d.mts.map +1 -1
  62. package/dist/host/ingest.mjs +12 -22
  63. package/dist/host/ingest.mjs.map +1 -1
  64. package/dist/host/journey-host.cjs +2 -4
  65. package/dist/host/journey-host.cjs.map +1 -1
  66. package/dist/host/journey-host.d.cts +6 -6
  67. package/dist/host/journey-host.d.cts.map +1 -1
  68. package/dist/host/journey-host.d.mts +6 -6
  69. package/dist/host/journey-host.d.mts.map +1 -1
  70. package/dist/host/journey-host.mjs +2 -4
  71. package/dist/host/journey-host.mjs.map +1 -1
  72. package/dist/host/platform.cjs.map +1 -1
  73. package/dist/host/platform.d.cts +1 -7
  74. package/dist/host/platform.d.cts.map +1 -1
  75. package/dist/host/platform.d.mts +1 -7
  76. package/dist/host/platform.d.mts.map +1 -1
  77. package/dist/host/platform.mjs.map +1 -1
  78. package/dist/store/facts.cjs +1 -1
  79. package/dist/store/facts.cjs.map +1 -1
  80. package/dist/store/facts.mjs +1 -1
  81. package/dist/store/facts.mjs.map +1 -1
  82. package/dist/store/index.cjs +0 -2
  83. package/dist/store/index.cjs.map +1 -1
  84. package/dist/store/index.d.cts +21 -26
  85. package/dist/store/index.d.cts.map +1 -1
  86. package/dist/store/index.d.mts +21 -26
  87. package/dist/store/index.d.mts.map +1 -1
  88. package/dist/store/index.mjs +1 -2
  89. package/dist/store/index.mjs.map +1 -1
  90. package/dist/store/postgres.cjs +2 -3
  91. package/dist/store/postgres.cjs.map +1 -1
  92. package/dist/store/postgres.d.cts +2 -3
  93. package/dist/store/postgres.d.cts.map +1 -1
  94. package/dist/store/postgres.d.mts +2 -3
  95. package/dist/store/postgres.d.mts.map +1 -1
  96. package/dist/store/postgres.mjs +2 -3
  97. package/dist/store/postgres.mjs.map +1 -1
  98. package/dist/transports/cloudflare-email.d.cts +1 -1
  99. package/dist/transports/cloudflare-email.d.mts +1 -1
  100. package/package.json +1 -3
  101. package/dist/cloudflare/d1-env.cjs +0 -17
  102. package/dist/cloudflare/d1-env.cjs.map +0 -1
  103. package/dist/cloudflare/d1-env.d.cts +0 -12
  104. package/dist/cloudflare/d1-env.d.cts.map +0 -1
  105. package/dist/cloudflare/d1-env.d.mts +0 -12
  106. package/dist/cloudflare/d1-env.d.mts.map +0 -1
  107. package/dist/cloudflare/d1-env.mjs +0 -16
  108. package/dist/cloudflare/d1-env.mjs.map +0 -1
  109. package/dist/cloudflare/schema.sql +0 -148
  110. package/dist/store/d1.cjs +0 -245
  111. package/dist/store/d1.cjs.map +0 -1
  112. package/dist/store/d1.d.cts +0 -73
  113. package/dist/store/d1.d.cts.map +0 -1
  114. package/dist/store/d1.d.mts +0 -73
  115. package/dist/store/d1.d.mts.map +0 -1
  116. package/dist/store/d1.mjs +0 -244
  117. package/dist/store/d1.mjs.map +0 -1
  118. package/src/cloudflare/schema.sql +0 -148
@@ -1 +1 @@
1
- {"version":3,"file":"http.cjs","names":["handleTracking","checkEvent","ingestEvent","identifyUser","registerDevice","unbindDevice","removeDevice","recordPushOpen","forgetUser","deployBundle","journeyMode","authorized","z","BundleIntegrityError","TemplateValidationError","UserErasedError","SchemaMismatchError","d1Store"],"sources":["../../src/host/http.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport { d1Store } from '../cloudflare/d1-env';\nimport { type Contract, type ContractResult, checkEvent } from '../contract';\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 identifyUser,\n ingestEvent,\n registerDevice,\n removeDevice,\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' | '*';\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/* --------------------------------- 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 || store.reporting === 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 =\n store.reporting !== undefined && (await recordPushOpen(store.reporting, userId, messageId));\n return json({ ok: true, recorded });\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 const { subject, html } = await preview.render(key, props as Record<string, ScalarIR>);\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`, `/users/forget`, `/deploy`. */\nexport function handleRequest(\n request: Request,\n env: JourneyEnv,\n store: JourneyStore = d1Store(env),\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","forgetUser","deployBundle","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 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 identifyUser,\n ingestEvent,\n registerDevice,\n removeDevice,\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' | '*';\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/* --------------------------------- 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: '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 const { subject, html } = await preview.render(key, props as Record<string, ScalarIR>);\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`, `/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,9 +1,9 @@
1
1
  import { BundleIR } from "../ir.cjs";
2
- import { JourneyEnv } from "./platform.cjs";
3
2
  import { JourneyStore } from "../store/index.cjs";
4
3
  import { Contract } from "../contract.cjs";
5
4
  import { EmailRenderer } from "../email-sender.cjs";
6
5
  import { Authorize } from "./auth.cjs";
6
+ import { JourneyEnv } from "./platform.cjs";
7
7
  import { SweepResult } from "./ingest.cjs";
8
8
  import { TrackingOptions } from "../reporting/tracking.cjs";
9
9
  //#region src/host/http.d.ts
@@ -53,6 +53,6 @@ export interface ServeContext {
53
53
  /** One request through the table: the production gate, the bearer, the route, the error mapping. */
54
54
  export declare function serve(request: Request, ctx: ServeContext): Promise<Response>;
55
55
  /** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/users/forget`, `/deploy`. */
56
- export declare function handleRequest(request: Request, env: JourneyEnv, store?: JourneyStore, validation?: IngestValidation): Promise<Response>;
56
+ export declare function handleRequest(request: Request, env: JourneyEnv, store: JourneyStore, validation?: IngestValidation): Promise<Response>;
57
57
  //#endregion
58
58
  //# sourceMappingURL=http.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.cts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAyCiB;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;;;wBA+WF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,QAAO,cACP,aAAY,mBACX,QAAQ"}
1
+ {"version":3,"file":"http.d.cts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAwCiB;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;;;wBA8WF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,OAAO,cACP,aAAY,mBACX,QAAQ"}
@@ -1,9 +1,9 @@
1
1
  import { BundleIR } from "../ir.mjs";
2
- import { JourneyEnv } from "./platform.mjs";
3
2
  import { JourneyStore } from "../store/index.mjs";
4
3
  import { Contract } from "../contract.mjs";
5
4
  import { EmailRenderer } from "../email-sender.mjs";
6
5
  import { Authorize } from "./auth.mjs";
6
+ import { JourneyEnv } from "./platform.mjs";
7
7
  import { SweepResult } from "./ingest.mjs";
8
8
  import { TrackingOptions } from "../reporting/tracking.mjs";
9
9
  //#region src/host/http.d.ts
@@ -53,6 +53,6 @@ export interface ServeContext {
53
53
  /** One request through the table: the production gate, the bearer, the route, the error mapping. */
54
54
  export declare function serve(request: Request, ctx: ServeContext): Promise<Response>;
55
55
  /** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/users/forget`, `/deploy`. */
56
- export declare function handleRequest(request: Request, env: JourneyEnv, store?: JourneyStore, validation?: IngestValidation): Promise<Response>;
56
+ export declare function handleRequest(request: Request, env: JourneyEnv, store: JourneyStore, validation?: IngestValidation): Promise<Response>;
57
57
  //#endregion
58
58
  //# sourceMappingURL=http.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.mts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAyCiB;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;;;wBA+WF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,QAAO,cACP,aAAY,mBACX,QAAQ"}
1
+ {"version":3,"file":"http.d.mts","names":[],"sources":["../../src/host/http.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAwCiB;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;;;wBA8WF,MAAM,SAAS,SAAS,KAAK,eAAe,QAAQ;;wBA2C1D,cACd,SAAS,SACT,KAAK,YACL,OAAO,cACP,aAAY,mBACX,QAAQ"}
@@ -1,7 +1,6 @@
1
1
  import { TemplateValidationError } from "../templates.mjs";
2
2
  import { checkEvent } from "../contract.mjs";
3
3
  import { UserErasedError } from "../store/erasure.mjs";
4
- import { d1Store } from "../cloudflare/d1-env.mjs";
5
4
  import { SchemaMismatchError } from "../store/index.mjs";
6
5
  import { BundleIntegrityError, deployBundle, forgetUser, identifyUser, ingestEvent, registerDevice, removeDevice, unbindDevice } from "./ingest.mjs";
7
6
  import { journeyMode } from "./channels.mjs";
@@ -80,7 +79,7 @@ const routes = [
80
79
  path: /^\/t\//,
81
80
  auth: "none",
82
81
  async handle({ request, store, host }) {
83
- if (host.tracking === void 0 || store.reporting === void 0) return notFound();
82
+ if (host.tracking === void 0) return notFound();
84
83
  return await handleTracking(request, store.reporting, host.tracking) ?? notFound();
85
84
  }
86
85
  },
@@ -175,7 +174,7 @@ const routes = [
175
174
  const { userId, messageId } = await readBody(request);
176
175
  if (!isNonEmptyString(userId)) return json({ error: "userId required" }, 400);
177
176
  if (!isNonEmptyString(messageId)) return json({ error: "messageId required" }, 400);
178
- const recorded = store.reporting !== void 0 && await recordPushOpen(store.reporting, userId, messageId);
177
+ const recorded = await recordPushOpen(store.reporting, userId, messageId);
179
178
  return json({
180
179
  ok: true,
181
180
  recorded
@@ -323,7 +322,7 @@ async function serve(request, ctx) {
323
322
  }
324
323
  }
325
324
  /** The bare ingest API over one store: `/health`, `/events`, `/identify`, `/devices*`, `/messages/opened`, `/users/forget`, `/deploy`. */
326
- function handleRequest(request, env, store = d1Store(env), validation = {}) {
325
+ function handleRequest(request, env, store, validation = {}) {
327
326
  return serve(request, {
328
327
  env,
329
328
  store,
@@ -1 +1 @@
1
- {"version":3,"file":"http.mjs","names":[],"sources":["../../src/host/http.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport { d1Store } from '../cloudflare/d1-env';\nimport { type Contract, type ContractResult, checkEvent } from '../contract';\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 identifyUser,\n ingestEvent,\n registerDevice,\n removeDevice,\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' | '*';\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/* --------------------------------- 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 || store.reporting === 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 =\n store.reporting !== undefined && (await recordPushOpen(store.reporting, userId, messageId));\n return json({ ok: true, recorded });\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 const { subject, html } = await preview.render(key, props as Record<string, ScalarIR>);\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`, `/users/forget`, `/deploy`. */\nexport function handleRequest(\n request: Request,\n env: JourneyEnv,\n store: JourneyStore = d1Store(env),\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.mjs","names":[],"sources":["../../src/host/http.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport { type Contract, type ContractResult, checkEvent } from '../contract';\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 identifyUser,\n ingestEvent,\n registerDevice,\n removeDevice,\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' | '*';\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/* --------------------------------- 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: '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 const { subject, html } = await preview.render(key, props as Record<string, ScalarIR>);\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`, `/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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"}
@@ -5,7 +5,6 @@ const require_store_erasure = require("../store/erasure.cjs");
5
5
  const require_engine_bucket = require("../engine/bucket.cjs");
6
6
  const require_engine_condition = require("../engine/condition.cjs");
7
7
  const require_store_facts = require("../store/facts.cjs");
8
- const require_cloudflare_d1_env = require("../cloudflare/d1-env.cjs");
9
8
  const require_store_index = require("../store/index.cjs");
10
9
  const require_host_platform = require("./platform.cjs");
11
10
  //#region src/host/ingest.ts
@@ -16,8 +15,7 @@ const require_host_platform = require("./platform.cjs");
16
15
  * wakes the waits on the properties it patched and re-evaluates segment triggers too. Plus
17
16
  * devices, erasure, the segment sweep and bundle deploys.
18
17
  *
19
- * Storage goes through a JourneyStore; the `store` argument defaults to the D1 store built from
20
- * the env's binding, so a Worker that only has that calls these functions as they are.
18
+ * Storage goes through a JourneyStore; every entry point takes the one the host opened.
21
19
  */
22
20
  /**
23
21
  * The fast answer for a user with an erasure tombstone (being erased, or erased for good): every
@@ -27,7 +25,7 @@ const require_host_platform = require("./platform.cjs");
27
25
  async function refuseIfErased(store, userId) {
28
26
  if (await store.erasureState(userId) !== "none") throw new require_store_erasure.UserErasedError(userId);
29
27
  }
30
- async function ingestEvent(env, input, store = require_cloudflare_d1_env.d1Store(env)) {
28
+ async function ingestEvent(env, input, store) {
31
29
  if (input.event.startsWith("$")) throw new Error(`event name '${input.event}' is reserved ('$' prefix is internal)`);
32
30
  await refuseIfErased(store, input.userId);
33
31
  const ts = input.ts ?? Date.now();
@@ -236,7 +234,7 @@ async function sweepSegments(env, store, now = Date.now()) {
236
234
  if (!page.more || page.lastUserId === void 0) break;
237
235
  after = page.lastUserId;
238
236
  }
239
- const sizes = await store.reporting?.snapshotSegments(now) ?? [];
237
+ const sizes = await store.reporting.snapshotSegments(now);
240
238
  return {
241
239
  users,
242
240
  started,
@@ -307,7 +305,7 @@ function buildInstanceId(workflow, hash, userId, ts) {
307
305
  * and re-evaluate segment triggers. The merge is one statement in the store, so
308
306
  * two identifies landing together cannot lose each other's fields.
309
307
  */
310
- async function identifyUser(env, userId, props, store = require_cloudflare_d1_env.d1Store(env)) {
308
+ async function identifyUser(env, userId, props, store) {
311
309
  await refuseIfErased(store, userId);
312
310
  const ts = Date.now();
313
311
  return {
@@ -322,7 +320,7 @@ async function identifyUser(env, userId, props, store = require_cloudflare_d1_en
322
320
  * to one user at a time). Wakes the waits that read devices (`reachable('push')` inside
323
321
  * `waitUntil`), for the new owner and the previous one.
324
322
  */
325
- async function registerDevice(env, device, store = require_cloudflare_d1_env.d1Store(env)) {
323
+ async function registerDevice(env, device, store) {
326
324
  await refuseIfErased(store, device.userId);
327
325
  const previous = await store.removeDevice(device.token);
328
326
  await store.upsertDevice({
@@ -418,7 +416,7 @@ async function eraseUser(env, userId, store, now) {
418
416
  * leaves it pending — nothing erased yet — and the scheduled sweep retries it; `erased: false`
419
417
  * says so. A user already erased answers `erased: true` again, with nothing to do.
420
418
  */
421
- async function forgetUser(env, userId, store = require_cloudflare_d1_env.d1Store(env), now = Date.now()) {
419
+ async function forgetUser(env, userId, store, now = Date.now()) {
422
420
  await store.requestErasure(userId, now);
423
421
  return {
424
422
  ok: true,
@@ -430,7 +428,7 @@ async function forgetUser(env, userId, store = require_cloudflare_d1_env.d1Store
430
428
  * sign-in only has to bind it again. Wakes the former owner's device waits (`reachable('push')`
431
429
  * may no longer hold).
432
430
  */
433
- async function unbindDevice(env, token, store = require_cloudflare_d1_env.d1Store(env)) {
431
+ async function unbindDevice(env, token, store) {
434
432
  const owner = await store.unbindDevice(token);
435
433
  return {
436
434
  ok: true,
@@ -438,20 +436,13 @@ async function unbindDevice(env, token, store = require_cloudflare_d1_env.d1Stor
438
436
  };
439
437
  }
440
438
  /** A push token forgotten for good (the push service reports it gone); wakes its owner's device waits. */
441
- async function removeDevice(env, token, store = require_cloudflare_d1_env.d1Store(env)) {
439
+ async function removeDevice(env, token, store) {
442
440
  const owner = await store.removeDevice(token);
443
441
  return {
444
442
  ok: true,
445
443
  woke: owner === void 0 ? 0 : await wakeSubscribers(env, store, owner, [require_engine_condition.DEVICES_UPDATED_EVENT])
446
444
  };
447
445
  }
448
- /** A versioned deploy on a store that cannot tell which version is live (D1). */
449
- var UnversionedStoreError = class extends Error {
450
- name = "UnversionedStoreError";
451
- constructor() {
452
- super("this store does not record which version is live: deploy with POST /bundle/deploy");
453
- }
454
- };
455
446
  /** A bundle whose declared hashes do not describe its contents — rejected before anything is written. */
456
447
  var BundleIntegrityError = class extends Error {
457
448
  constructor(message) {
@@ -486,16 +477,15 @@ function verifyBundleHashes(parsed) {
486
477
  * only over an older live version (`replaceRoutesIfNewer`) — how journeyWorker applies the bundle
487
478
  * compiled into it; without one it is forced, whatever is live.
488
479
  */
489
- async function deployBundle(env, bundle, store = require_cloudflare_d1_env.d1Store(env), version) {
490
- if (version !== void 0 && store.replaceRoutesIfNewer === void 0) throw new UnversionedStoreError();
480
+ async function deployBundle(env, bundle, store, version) {
491
481
  const parsed = require_ir.BundleIR.parse(bundle);
492
482
  verifyBundleHashes(parsed);
493
- const problems = await store.checkSchema?.() ?? [];
483
+ const problems = await store.checkSchema();
494
484
  if (problems.length > 0) throw new require_store_index.SchemaMismatchError(problems);
495
485
  for (const workflow of parsed.workflows) await store.putWorkflowIR(workflow);
496
486
  let applied = true;
497
487
  if (version === void 0) await store.replaceRoutes(parsed);
498
- else applied = await store.replaceRoutesIfNewer?.(parsed, version) ?? false;
488
+ else applied = await store.replaceRoutesIfNewer(parsed, version);
499
489
  const unrouted = parsed.workflows.filter((workflow) => workflow.trigger.type === "date" || workflow.trigger.type === "webhook").map((workflow) => ({
500
490
  workflow: workflow.id,
501
491
  trigger: workflow.trigger.type
@@ -509,7 +499,6 @@ async function deployBundle(env, bundle, store = require_cloudflare_d1_env.d1Sto
509
499
  //#endregion
510
500
  exports.BundleIntegrityError = BundleIntegrityError;
511
501
  exports.SWEEP_PAGE_SIZE = SWEEP_PAGE_SIZE;
512
- exports.UnversionedStoreError = UnversionedStoreError;
513
502
  exports.deployBundle = deployBundle;
514
503
  exports.forgetUser = forgetUser;
515
504
  exports.identifyUser = identifyUser;