@shware/workflow 3.3.0 → 4.1.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.
- package/README.md +230 -40
- package/dist/apns.cjs +113 -26
- package/dist/apns.cjs.map +1 -1
- package/dist/apns.d.cts +52 -6
- package/dist/apns.d.cts.map +1 -1
- package/dist/apns.d.mts +52 -6
- package/dist/apns.d.mts.map +1 -1
- package/dist/apns.mjs +111 -27
- package/dist/apns.mjs.map +1 -1
- package/dist/aws/engine-step.cjs +5 -5
- package/dist/aws/engine-step.cjs.map +1 -1
- package/dist/aws/engine-step.d.cts +8 -2
- package/dist/aws/engine-step.d.cts.map +1 -1
- package/dist/aws/engine-step.d.mts +8 -2
- package/dist/aws/engine-step.d.mts.map +1 -1
- package/dist/aws/engine-step.mjs +5 -5
- package/dist/aws/engine-step.mjs.map +1 -1
- package/dist/aws/index.cjs +50 -6
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.d.cts +4 -3
- package/dist/aws/index.d.cts.map +1 -1
- package/dist/aws/index.d.mts +4 -3
- package/dist/aws/index.d.mts.map +1 -1
- package/dist/aws/index.mjs +49 -7
- package/dist/aws/index.mjs.map +1 -1
- package/dist/aws/instances.cjs +29 -6
- package/dist/aws/instances.cjs.map +1 -1
- package/dist/aws/instances.d.cts +11 -1
- package/dist/aws/instances.d.cts.map +1 -1
- package/dist/aws/instances.d.mts +11 -1
- package/dist/aws/instances.d.mts.map +1 -1
- package/dist/aws/instances.mjs +27 -4
- package/dist/aws/instances.mjs.map +1 -1
- package/dist/client.cjs +4 -1
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +2 -2
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +2 -2
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +4 -1
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/bindings.cjs +28 -25
- package/dist/cloudflare/bindings.cjs.map +1 -1
- package/dist/cloudflare/bindings.d.cts +15 -129
- package/dist/cloudflare/bindings.d.cts.map +1 -1
- package/dist/cloudflare/bindings.d.mts +15 -129
- package/dist/cloudflare/bindings.d.mts.map +1 -1
- package/dist/cloudflare/bindings.mjs +28 -23
- package/dist/cloudflare/bindings.mjs.map +1 -1
- package/dist/cloudflare/d1-env.cjs +6 -6
- package/dist/cloudflare/d1-env.cjs.map +1 -1
- package/dist/cloudflare/d1-env.d.cts +5 -5
- package/dist/cloudflare/d1-env.d.mts +5 -5
- package/dist/cloudflare/d1-env.mjs +6 -6
- package/dist/cloudflare/d1-env.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +29 -18
- package/dist/cloudflare/index.d.cts +8 -4
- package/dist/cloudflare/index.d.mts +8 -4
- package/dist/cloudflare/index.mjs +9 -5
- package/dist/cloudflare/runner.cjs +29 -14
- package/dist/cloudflare/runner.cjs.map +1 -1
- package/dist/cloudflare/runner.d.cts +9 -2
- package/dist/cloudflare/runner.d.cts.map +1 -1
- package/dist/cloudflare/runner.d.mts +9 -2
- package/dist/cloudflare/runner.d.mts.map +1 -1
- package/dist/cloudflare/runner.mjs +28 -13
- package/dist/cloudflare/runner.mjs.map +1 -1
- package/dist/cloudflare/schema.sql +27 -6
- package/dist/cloudflare/worker.cjs +19 -9
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts +2 -2
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts +2 -2
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +19 -9
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/config.cjs +21 -5
- package/dist/config.cjs.map +1 -1
- package/dist/config.mjs +21 -5
- package/dist/config.mjs.map +1 -1
- package/dist/dsl/action.cjs +3 -6
- package/dist/dsl/action.cjs.map +1 -1
- package/dist/dsl/action.d.cts +12 -11
- package/dist/dsl/action.d.cts.map +1 -1
- package/dist/dsl/action.d.mts +12 -11
- package/dist/dsl/action.d.mts.map +1 -1
- package/dist/dsl/action.mjs +3 -6
- package/dist/dsl/action.mjs.map +1 -1
- package/dist/dsl/bundle.cjs +35 -31
- package/dist/dsl/bundle.cjs.map +1 -1
- package/dist/dsl/bundle.d.cts.map +1 -1
- package/dist/dsl/bundle.d.mts.map +1 -1
- package/dist/dsl/bundle.mjs +35 -31
- package/dist/dsl/bundle.mjs.map +1 -1
- package/dist/dsl/condition.cjs +13 -1
- package/dist/dsl/condition.cjs.map +1 -1
- package/dist/dsl/condition.d.cts +9 -1
- package/dist/dsl/condition.d.cts.map +1 -1
- package/dist/dsl/condition.d.mts +9 -1
- package/dist/dsl/condition.d.mts.map +1 -1
- package/dist/dsl/condition.mjs +13 -2
- package/dist/dsl/condition.mjs.map +1 -1
- package/dist/dsl/flow.cjs +33 -11
- package/dist/dsl/flow.cjs.map +1 -1
- package/dist/dsl/flow.d.cts +9 -1
- package/dist/dsl/flow.d.cts.map +1 -1
- package/dist/dsl/flow.d.mts +9 -1
- package/dist/dsl/flow.d.mts.map +1 -1
- package/dist/dsl/flow.mjs +34 -13
- package/dist/dsl/flow.mjs.map +1 -1
- package/dist/dsl/workflow.cjs +2 -3
- package/dist/dsl/workflow.cjs.map +1 -1
- package/dist/dsl/workflow.d.cts.map +1 -1
- package/dist/dsl/workflow.d.mts.map +1 -1
- package/dist/dsl/workflow.mjs +3 -4
- package/dist/dsl/workflow.mjs.map +1 -1
- package/dist/engine/actions.cjs +1 -8
- package/dist/engine/actions.cjs.map +1 -1
- package/dist/engine/actions.d.cts +1 -12
- package/dist/engine/actions.d.cts.map +1 -1
- package/dist/engine/actions.d.mts +1 -12
- package/dist/engine/actions.d.mts.map +1 -1
- package/dist/engine/actions.mjs +1 -8
- package/dist/engine/actions.mjs.map +1 -1
- package/dist/engine/condition.cjs +25 -5
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts +12 -3
- package/dist/engine/condition.d.cts.map +1 -1
- package/dist/engine/condition.d.mts +12 -3
- package/dist/engine/condition.d.mts.map +1 -1
- package/dist/engine/condition.mjs +25 -6
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/index.cjs +3 -0
- package/dist/engine/index.d.cts +5 -5
- package/dist/engine/index.d.mts +5 -5
- package/dist/engine/index.mjs +4 -4
- package/dist/engine/interpreter.cjs +27 -20
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.d.cts.map +1 -1
- package/dist/engine/interpreter.d.mts.map +1 -1
- package/dist/engine/interpreter.mjs +27 -20
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +15 -6
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +15 -6
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/engine/retry.cjs +13 -0
- package/dist/engine/retry.cjs.map +1 -1
- package/dist/engine/retry.d.cts +12 -0
- package/dist/engine/retry.d.cts.map +1 -1
- package/dist/engine/retry.d.mts +12 -0
- package/dist/engine/retry.d.mts.map +1 -1
- package/dist/engine/retry.mjs +13 -1
- package/dist/engine/retry.mjs.map +1 -1
- package/dist/engine/time-scale.cjs +90 -15
- package/dist/engine/time-scale.cjs.map +1 -1
- package/dist/engine/time-scale.d.cts +9 -6
- package/dist/engine/time-scale.d.cts.map +1 -1
- package/dist/engine/time-scale.d.mts +9 -6
- package/dist/engine/time-scale.d.mts.map +1 -1
- package/dist/engine/time-scale.mjs +90 -16
- package/dist/engine/time-scale.mjs.map +1 -1
- package/dist/fcm.cjs +111 -38
- package/dist/fcm.cjs.map +1 -1
- package/dist/fcm.d.cts +45 -5
- package/dist/fcm.d.cts.map +1 -1
- package/dist/fcm.d.mts +45 -5
- package/dist/fcm.d.mts.map +1 -1
- package/dist/fcm.mjs +110 -39
- package/dist/fcm.mjs.map +1 -1
- package/dist/hash.cjs +6 -6
- package/dist/hash.cjs.map +1 -1
- package/dist/hash.mjs +6 -6
- package/dist/hash.mjs.map +1 -1
- package/dist/host/auth.cjs +49 -0
- package/dist/host/auth.cjs.map +1 -0
- package/dist/host/auth.d.cts +35 -0
- package/dist/host/auth.d.cts.map +1 -0
- package/dist/host/auth.d.mts +35 -0
- package/dist/host/auth.d.mts.map +1 -0
- package/dist/host/auth.mjs +46 -0
- package/dist/host/auth.mjs.map +1 -0
- package/dist/host/channels.cjs +118 -40
- package/dist/host/channels.cjs.map +1 -1
- package/dist/host/channels.d.cts +27 -1
- package/dist/host/channels.d.cts.map +1 -1
- package/dist/host/channels.d.mts +27 -1
- package/dist/host/channels.d.mts.map +1 -1
- package/dist/host/channels.mjs +121 -44
- package/dist/host/channels.mjs.map +1 -1
- package/dist/host/execute.cjs +14 -2
- package/dist/host/execute.cjs.map +1 -1
- package/dist/host/execute.d.cts +4 -4
- package/dist/host/execute.d.cts.map +1 -1
- package/dist/host/execute.d.mts +4 -4
- package/dist/host/execute.d.mts.map +1 -1
- package/dist/host/execute.mjs +13 -1
- package/dist/host/execute.mjs.map +1 -1
- package/dist/host/fresh-read.cjs +30 -0
- package/dist/host/fresh-read.cjs.map +1 -0
- package/dist/host/fresh-read.d.cts +18 -0
- package/dist/host/fresh-read.d.cts.map +1 -0
- package/dist/host/fresh-read.d.mts +18 -0
- package/dist/host/fresh-read.d.mts.map +1 -0
- package/dist/host/fresh-read.mjs +29 -0
- package/dist/host/fresh-read.mjs.map +1 -0
- package/dist/host/http.cjs +340 -0
- package/dist/host/http.cjs.map +1 -0
- package/dist/host/http.d.cts +58 -0
- package/dist/host/http.d.cts.map +1 -0
- package/dist/host/http.d.mts +58 -0
- package/dist/host/http.d.mts.map +1 -0
- package/dist/host/http.mjs +336 -0
- package/dist/host/http.mjs.map +1 -0
- package/dist/host/{router.cjs → ingest.cjs} +147 -175
- package/dist/host/ingest.cjs.map +1 -0
- package/dist/host/{router.d.cts → ingest.d.cts} +48 -30
- package/dist/host/ingest.d.cts.map +1 -0
- package/dist/host/{router.d.mts → ingest.d.mts} +48 -30
- package/dist/host/ingest.d.mts.map +1 -0
- package/dist/host/{router.mjs → ingest.mjs} +144 -173
- package/dist/host/ingest.mjs.map +1 -0
- package/dist/host/journey-host.cjs +66 -93
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +31 -26
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +31 -26
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +66 -93
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/host/platform.cjs +21 -0
- package/dist/host/platform.cjs.map +1 -0
- package/dist/host/platform.d.cts +126 -0
- package/dist/host/platform.d.cts.map +1 -0
- package/dist/host/platform.d.mts +126 -0
- package/dist/host/platform.d.mts.map +1 -0
- package/dist/host/platform.mjs +19 -0
- package/dist/host/platform.mjs.map +1 -0
- package/dist/host/senders.cjs +18 -9
- package/dist/host/senders.cjs.map +1 -1
- package/dist/host/senders.d.cts +9 -4
- package/dist/host/senders.d.cts.map +1 -1
- package/dist/host/senders.d.mts +9 -4
- package/dist/host/senders.d.mts.map +1 -1
- package/dist/host/senders.mjs +18 -9
- package/dist/host/senders.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/ir.cjs +5 -7
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +3 -12
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +3 -12
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +5 -7
- package/dist/ir.mjs.map +1 -1
- package/dist/plan.cjs +27 -33
- package/dist/plan.cjs.map +1 -1
- package/dist/plan.d.cts +1 -1
- package/dist/plan.d.cts.map +1 -1
- package/dist/plan.d.mts +1 -1
- package/dist/plan.d.mts.map +1 -1
- package/dist/plan.mjs +27 -33
- package/dist/plan.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +14 -4
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +10 -10
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +10 -10
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +14 -4
- package/dist/reporting/tracking.mjs.map +1 -1
- package/dist/store/d1.cjs +88 -29
- package/dist/store/d1.cjs.map +1 -1
- package/dist/store/d1.d.cts +29 -11
- package/dist/store/d1.d.cts.map +1 -1
- package/dist/store/d1.d.mts +29 -11
- package/dist/store/d1.d.mts.map +1 -1
- package/dist/store/d1.mjs +88 -29
- package/dist/store/d1.mjs.map +1 -1
- package/dist/store/erasure.cjs +22 -0
- package/dist/store/erasure.cjs.map +1 -0
- package/dist/store/erasure.d.cts +18 -0
- package/dist/store/erasure.d.cts.map +1 -0
- package/dist/store/erasure.d.mts +18 -0
- package/dist/store/erasure.d.mts.map +1 -0
- package/dist/store/erasure.mjs +21 -0
- package/dist/store/erasure.mjs.map +1 -0
- package/dist/store/index.cjs +2 -0
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +67 -13
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +67 -13
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +2 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +125 -62
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +30 -2
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +30 -2
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +125 -62
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/postgres.cjs +179 -60
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +44 -7
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +44 -7
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +179 -60
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +7 -5
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +7 -5
- package/dist/store/tables.mjs.map +1 -1
- package/dist/store/user-lock.cjs +43 -0
- package/dist/store/user-lock.cjs.map +1 -0
- package/dist/store/user-lock.d.cts +32 -0
- package/dist/store/user-lock.d.cts.map +1 -0
- package/dist/store/user-lock.d.mts +32 -0
- package/dist/store/user-lock.d.mts.map +1 -0
- package/dist/store/user-lock.mjs +40 -0
- package/dist/store/user-lock.mjs.map +1 -0
- package/dist/transports/cloudflare-email.cjs +1 -1
- package/dist/transports/cloudflare-email.mjs +1 -1
- package/dist/transports/ses.cjs +47 -6
- package/dist/transports/ses.cjs.map +1 -1
- package/dist/transports/ses.d.cts +9 -1
- package/dist/transports/ses.d.cts.map +1 -1
- package/dist/transports/ses.d.mts +9 -1
- package/dist/transports/ses.d.mts.map +1 -1
- package/dist/transports/ses.mjs +47 -7
- package/dist/transports/ses.mjs.map +1 -1
- package/dist/webpush.cjs +133 -47
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts +49 -8
- package/dist/webpush.d.cts.map +1 -1
- package/dist/webpush.d.mts +49 -8
- package/dist/webpush.d.mts.map +1 -1
- package/dist/webpush.mjs +130 -47
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -1
- package/src/cloudflare/schema.sql +27 -6
- package/dist/host/router.cjs.map +0 -1
- package/dist/host/router.d.cts.map +0 -1
- package/dist/host/router.d.mts.map +0 -1
- package/dist/host/router.mjs.map +0 -1
package/dist/fcm.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fcm.mjs","names":[],"sources":["../src/fcm.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/fcm` — push delivery through Firebase Cloud Messaging's\n * HTTP v1 API, from a service account. No dependencies: the OAuth2 access\n * token is minted by signing a JWT with WebCrypto (Workers, Node 20+, Deno,\n * Bun), so this runs wherever the engine runs.\n *\n * The recipient is an FCM registration token the app registered for the user\n * (`registerDevice`); the engine sends one message per device.\n */\n\n/** The fields of a Google service-account JSON this sender needs. */\nexport interface FcmServiceAccount {\n project_id: string;\n client_email: string;\n /** PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8). */\n private_key: string;\n token_uri?: string;\n}\n\nexport interface FcmPushOptions {\n serviceAccount: FcmServiceAccount;\n render: PushRenderer;\n /** Android-specific delivery options, e.g. `{ channel_id: 'default' }` (FCM v1 `android.notification`). */\n android?: Record<string, unknown>;\n /** APNs-specific payload options merged into `aps`, e.g. `{ sound: 'default' }`. */\n apns?: Record<string, unknown>;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n /** Called for a token FCM reports as gone (`UNREGISTERED`); `channelsFromConfig` removes the device. */\n onUnregistered?: (message: OutboundMessage) => Promise<void> | void;\n}\n\n/** Errors FCM reports for a token that will never deliver again — not worth a retry. */\nconst DEAD_TOKEN_CODES = new Set(['UNREGISTERED', 'INVALID_ARGUMENT', 'SENDER_ID_MISMATCH']);\n\nconst SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';\n\nexport class FcmPushSender implements MessageSender {\n private token: { value: string; expiresAt: number } | undefined;\n\n constructor(private readonly options: FcmPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`FcmPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`FcmPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n const { serviceAccount, android, apns } = this.options;\n const doFetch = this.options.fetch ?? fetch;\n\n const response = await doFetch(\n `https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${await this.accessToken()}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n message: {\n token: message.recipient,\n notification: { title, body, ...(image === undefined ? {} : { image }) },\n // The template key travels as data so the app can route a tap; the\n // idempotency key lets the app de-duplicate a redelivered push.\n data: {\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n },\n ...(android === undefined ? {} : { android: { notification: android } }),\n apns: { payload: { aps: { sound: 'default', ...apns } } },\n },\n }),\n }\n );\n if (response.ok) return;\n\n const text = await response.text();\n if (DEAD_TOKEN_CODES.has(fcmErrorCode(text))) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[fcm] token gone for user ${message.userId}: ${fcmErrorCode(text)}`);\n await this.options.onUnregistered?.(message);\n return { delivered: false, reason: fcmErrorCode(text) };\n }\n throw new Error(`FCM send failed: ${response.status} ${text}`);\n }\n\n private async accessToken(): Promise<string> {\n const now = Date.now();\n if (this.token !== undefined && this.token.expiresAt - 60_000 > now) return this.token.value;\n\n const { serviceAccount } = this.options;\n const tokenUri = serviceAccount.token_uri ?? 'https://oauth2.googleapis.com/token';\n const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(tokenUri, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion,\n }),\n });\n if (!response.ok) {\n throw new Error(`FCM token exchange failed: ${response.status} ${await response.text()}`);\n }\n const json = (await response.json()) as { access_token: string; expires_in: number };\n this.token = { value: json.access_token, expiresAt: now + json.expires_in * 1000 };\n return json.access_token;\n }\n}\n\n/** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */\nexport function fcmErrorCode(body: string): string {\n try {\n const parsed = JSON.parse(body) as {\n error?: { status?: string; details?: { errorCode?: string }[] };\n };\n const detail = parsed.error?.details?.find((d) => d.errorCode !== undefined)?.errorCode;\n return detail ?? parsed.error?.status ?? '';\n } catch {\n return '';\n }\n}\n\n/** RS256-sign the OAuth2 JWT assertion for a service account. */\nexport async function signJwt(\n account: FcmServiceAccount,\n audience: string,\n scope: string,\n nowMs = Date.now()\n): Promise<string> {\n const iat = Math.floor(nowMs / 1000);\n const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));\n const claims = base64url(\n JSON.stringify({ iss: account.client_email, scope, aud: audience, iat, exp: iat + 3600 })\n );\n const key = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(account.private_key),\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign']\n );\n const signature = await crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** Parse the service-account JSON as it is usually shipped: base64 of the file, in one secret. */\nexport function serviceAccountFromBase64(value: string): FcmServiceAccount {\n const json = JSON.parse(atob(value)) as Partial<FcmServiceAccount>;\n if (!json.project_id || !json.client_email || !json.private_key) {\n throw new Error('service account JSON is missing project_id, client_email or private_key');\n }\n return json as FcmServiceAccount;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"fcm.mjs","names":[],"sources":["../src/fcm.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/fcm` — push delivery through Firebase Cloud Messaging's\n * HTTP v1 API, from a service account. No dependencies: the OAuth2 access\n * token is minted by signing a JWT with WebCrypto (Workers, Node 20+, Deno,\n * Bun), so this runs wherever the engine runs.\n *\n * The recipient is an FCM registration token the app registered for the user\n * (`registerDevice`); the engine sends one message per device.\n */\n\n/** The fields of a Google service-account JSON this sender needs. */\nexport interface FcmServiceAccount {\n project_id: string;\n client_email: string;\n /** PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8). */\n private_key: string;\n token_uri?: string;\n}\n\nexport interface FcmAccessTokenOptions {\n serviceAccount: FcmServiceAccount;\n /** Fetch implementation for the token exchange (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport interface FcmPushOptions {\n /** The access token the requests carry — shared by every sender on the same service account. */\n credentials: FcmAccessToken;\n render: PushRenderer;\n /** Android-specific delivery options, e.g. `{ channel_id: 'default' }` (FCM v1 `android.notification`). */\n android?: Record<string, unknown>;\n /** APNs-specific payload options merged into `aps`, e.g. `{ sound: 'default' }`. */\n apns?: Record<string, unknown>;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\n/**\n * How an FCM v1 answer is classified, after the API's error codes\n * (https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode):\n *\n * - dead token: 404 `UNREGISTERED` (the app was uninstalled, or the token expired) — also a 404\n * whose only code is the gRPC status `NOT_FOUND`. The copy settles undelivered and the host\n * forgets the token.\n * - undelivered, device kept: 400 `INVALID_ARGUMENT`. FCM gives it for a malformed token but\n * just as well for a payload problem (a message too big, a bad data key, a bad TTL), which\n * would hit every device of the template at once; the copy settles with the reason, so the\n * report shows it, and nothing is deleted on a guess.\n * - configuration: 403 `SENDER_ID_MISMATCH` (the token belongs to another Firebase project) and\n * 401 `THIRD_PARTY_AUTH_ERROR` (the APNs or web push credentials in the Firebase project):\n * `PermanentError`, devices kept.\n * - transient: 401 `UNAUTHENTICATED` (the cached access token is dropped first), 429\n * `QUOTA_EXCEEDED`, 503 `UNAVAILABLE`, 500 `INTERNAL` and any other 429 / 5xx.\n * - everything else (another 4xx) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['UNREGISTERED', 'NOT_FOUND']);\n\nconst CONFIGURATION_CODES: ReadonlySet<string> = new Set([\n 'SENDER_ID_MISMATCH',\n 'THIRD_PARTY_AUTH_ERROR',\n]);\nconst TRANSIENT_CODES: ReadonlySet<string> = new Set(['QUOTA_EXCEEDED', 'UNAVAILABLE', 'INTERNAL']);\n\nconst SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';\n\n/**\n * The OAuth2 access token for one service account, exchanged once and reused until a minute\n * before it expires. Concurrent callers without a usable token share the one exchange in flight,\n * and an exchange that failed is not kept, so the next call tries again. One instance serves every\n * sender built on the account — a host keeps it across runs, and builds the senders per run.\n */\nexport class FcmAccessToken {\n /** The Firebase project the account belongs to — the `messages:send` URL names it. */\n readonly projectId: string;\n private minted: { value: string; expiresAt: number } | undefined;\n private minting: Promise<string> | undefined;\n\n constructor(private readonly options: FcmAccessTokenOptions) {\n this.projectId = options.serviceAccount.project_id;\n }\n\n /** The current token, exchanged now if there is none or it is about to expire. */\n async token(now = Date.now()): Promise<string> {\n if (this.minted !== undefined && this.minted.expiresAt - 60_000 > now) {\n return this.minted.value;\n }\n this.minting ??= this.exchange(now)\n .then((minted) => {\n this.minted = minted;\n return minted.value;\n })\n .finally(() => {\n this.minting = undefined;\n });\n return this.minting;\n }\n\n /** Forget the token: FCM refused it (401), so the next call exchanges a new one. */\n invalidate(): void {\n this.minted = undefined;\n }\n\n private async exchange(now: number): Promise<{ value: string; expiresAt: number }> {\n const { serviceAccount } = this.options;\n const tokenUri = serviceAccount.token_uri ?? 'https://oauth2.googleapis.com/token';\n const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(tokenUri, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion,\n }),\n });\n if (!response.ok) {\n const detail = `${response.status} ${await response.text()}`;\n // A 4xx from the token endpoint (`invalid_grant`: the key was revoked, `invalid_client`)\n // is the service account itself; only a throttled or failing endpoint is worth a retry\n if (isRetryableStatus(response.status)) {\n throw new Error(`FCM token exchange failed: ${detail}`);\n }\n throw new PermanentError(\n `FCM token exchange refused for '${serviceAccount.client_email}': ${detail}`\n );\n }\n const json = (await response.json()) as { access_token: string; expires_in: number };\n return { value: json.access_token, expiresAt: now + json.expires_in * 1000 };\n }\n}\n\nexport class FcmPushSender implements MessageSender {\n constructor(private readonly options: FcmPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`FcmPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`FcmPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n const { credentials, android, apns } = this.options;\n const doFetch = this.options.fetch ?? fetch;\n\n const response = await doFetch(\n `https://fcm.googleapis.com/v1/projects/${credentials.projectId}/messages:send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${await credentials.token()}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n message: {\n token: message.recipient,\n notification: { title, body, ...(image === undefined ? {} : { image }) },\n // The template key travels as data so the app can route a tap; the\n // idempotency key lets the app de-duplicate a redelivered push.\n data: {\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n },\n ...(android === undefined ? {} : { android: { notification: android } }),\n apns: { payload: { aps: { sound: 'default', ...apns } } },\n },\n }),\n }\n );\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n const code = fcmErrorCode(text);\n if (code === 'UNREGISTERED' || (status === 404 && DEAD_TOKEN_REASONS.has(code))) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[fcm] token gone for user ${message.userId}: ${code}`);\n return { delivered: false, reason: code };\n }\n if (code === 'INVALID_ARGUMENT') {\n // The token or our payload: settled so the report says which device, nothing deleted\n console.warn(`[fcm] refused for user ${message.userId}: ${status} ${text}`);\n return { delivered: false, reason: code };\n }\n if (CONFIGURATION_CODES.has(code)) {\n throw new PermanentError(\n `FCM refused the request for project '${credentials.projectId}': ${status} ${code}`\n );\n }\n if (status === 401 || code === 'UNAUTHENTICATED') {\n // The access token was revoked before it expired: mint a new one on the retry\n credentials.invalidate();\n throw new Error(\n `FCM send failed: ${status} ${code || text} (access token renewed for the retry)`\n );\n }\n if (TRANSIENT_CODES.has(code) || isRetryableStatus(status)) {\n throw new Error(`FCM send failed: ${status} ${code || text}`);\n }\n throw new PermanentError(`FCM refused the request: ${status} ${code || text}`);\n }\n}\n\n/** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */\nexport function fcmErrorCode(body: string): string {\n try {\n const parsed = JSON.parse(body) as {\n error?: { status?: string; details?: { errorCode?: string }[] };\n };\n const detail = parsed.error?.details?.find((d) => d.errorCode !== undefined)?.errorCode;\n return detail ?? parsed.error?.status ?? '';\n } catch {\n return '';\n }\n}\n\n/** RS256-sign the OAuth2 JWT assertion for a service account. */\nexport async function signJwt(\n account: FcmServiceAccount,\n audience: string,\n scope: string,\n nowMs = Date.now()\n): Promise<string> {\n const iat = Math.floor(nowMs / 1000);\n const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));\n const claims = base64url(\n JSON.stringify({ iss: account.client_email, scope, aud: audience, iat, exp: iat + 3600 })\n );\n const key = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(account.private_key),\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign']\n );\n const signature = await crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** Parse the service-account JSON as it is usually shipped: base64 of the file, in one secret. */\nexport function serviceAccountFromBase64(value: string): FcmServiceAccount {\n let json: Partial<FcmServiceAccount>;\n try {\n json = JSON.parse(atob(value)) as Partial<FcmServiceAccount>;\n } catch (error) {\n // A secret that is not base64 JSON is the same on every attempt: not worth a retry\n throw new PermanentError('service account is not base64-encoded JSON', { cause: error });\n }\n if (!json.project_id || !json.client_email || !json.private_key) {\n throw new PermanentError(\n 'service account JSON is missing project_id, client_email or private_key'\n );\n }\n return json as FcmServiceAccount;\n}\n"],"mappings":"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"}
|
package/dist/hash.cjs
CHANGED
|
@@ -49,12 +49,12 @@ const PROVENANCE_KEYS = /* @__PURE__ */ new Set(["loc"]);
|
|
|
49
49
|
* send_event payloads and action args. Everything inside them is execution
|
|
50
50
|
* data and is hashed whole.
|
|
51
51
|
*
|
|
52
|
-
* This is what keeps the exclusion above positional
|
|
53
|
-
* depth
|
|
54
|
-
*
|
|
55
|
-
* carrying different values
|
|
56
|
-
* as metadata-only, and the deploy
|
|
57
|
-
* key that in-flight journeys are pinned to.
|
|
52
|
+
* This is what keeps the exclusion above positional: stripping by name at every
|
|
53
|
+
* depth would reach inside these maps, and a prop or payload field that happens
|
|
54
|
+
* to be called `label` (or `meta`) would vanish from the hash — two sends
|
|
55
|
+
* carrying different values would hash identically, `plan` would report a real
|
|
56
|
+
* change as metadata-only, and the deploy would write different content under
|
|
57
|
+
* the same key that in-flight journeys are pinned to.
|
|
58
58
|
*
|
|
59
59
|
* These three are the only `z.record` fields in the IR (see ir.ts). A new one
|
|
60
60
|
* has to be added here, or its contents become strippable by name again.
|
package/dist/hash.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hash.cjs","names":[],"sources":["../src/hash.ts"],"sourcesContent":["/**\n * Content hashing — contentHash covers execution semantics only.\n *\n * Human-facing metadata (name / description, node labels) is excluded,\n * because it cannot change which path a user takes:\n * - rewording a description must not invalidate the version an in-flight\n * journey is pinned to;\n * - nor should it surface as a change in `plan`.\n *\n * Storage, by contrast, always keeps the full IR (including the latest\n * description). contentHash carries exactly two responsibilities: deciding\n * whether semantics moved (plan / migration), and pinning a version for\n * in-flight instances. So after a description edit: the UI shows the new text,\n * plan reports no change, and in-flight users are untouched.\n */\n\n/**\n * Keys excluded from hashing.\n * - meta: metadata at any level — workflow (name / description) and\n * node/segment provenance (`loc`, the callsite source map)\n * - label: node and branch-arm names (UI titles / observability, never routing)\n * - reason: the exit / filter audit string. It is written into the audit log\n * and shown in the studio, but no user takes a different path because of it —\n * the same test as a description, so rewording one must not repin in-flight\n * journeys or report as a semantic change in plan.\n * - contentHash: the hash itself\n *\n * Note that a cohort arm's `name` IS hashed: it becomes part of the node id\n * (`{id}.{armName}.{j}`), and node ids are durable step names — renaming an arm\n * changes execution identity.\n */\nconst UNHASHED_KEYS = new Set(['meta', 'label', 'reason', 'contentHash']);\n\n/**\n * Provenance only. `loc` is a source map, not content: it moves with the\n * machine and with the directory the compile ran from, so plan compares full\n * content with it removed (see plan.ts) — otherwise CI, compiling from the\n * workspace root, reports every definition as metadata_only against a deploy\n * compiled from a package directory.\n */\nconst PROVENANCE_KEYS = new Set(['loc']);\n\n/**\n * Fields whose values are maps the *author* keys, not the IR: message props,\n * send_event payloads and action args. Everything inside them is execution\n * data and is hashed whole.\n *\n * This is what keeps the exclusion above positional. Stripping by name at every\n * depth used to reach inside these maps, so a prop or payload field that\n * happened to be called `label` (or `meta`) vanished from the hash: two sends\n * carrying different values hashed identically, `plan` reported a real change\n * as metadata-only, and the deploy wrote different content under the same KV\n * key that in-flight journeys are pinned to.\n *\n * These three are the only `z.record` fields in the IR (see ir.ts). A new one\n * has to be added here, or its contents become strippable by name again.\n *\n * It matters for every key above: a send_event payload may well carry a field\n * called `reason` (subscription_cancelled does), and dropping it would hash two\n * different sends the same.\n */\nconst AUTHOR_KEYED_FIELDS = new Set(['props', 'payload', 'args']);\n\n/** Drop `keys` at the positions the IR defines them, never inside an author-keyed map. */\nfunction stripKeys(value: unknown, keys: ReadonlySet<string>): unknown {\n if (Array.isArray(value)) return value.map((item) => stripKeys(item, keys));\n if (value !== null && typeof value === 'object') {\n const out: Record<string, unknown> = {};\n for (const [key, item] of Object.entries(value as Record<string, unknown>)) {\n if (keys.has(key)) continue;\n // Author-keyed: hashed verbatim, never walked into\n out[key] = AUTHOR_KEYED_FIELDS.has(key) ? item : stripKeys(item, keys);\n }\n return out;\n }\n return value;\n}\n\n/** Drop metadata at the positions the IR defines it, leaving execution semantics. */\nexport function stripMeta(value: unknown): unknown {\n return stripKeys(value, UNHASHED_KEYS);\n}\n\n/** Drop provenance, keeping the rest of the metadata — what plan compares. */\nexport function stripProvenance(value: unknown): unknown {\n return stripKeys(value, PROVENANCE_KEYS);\n}\n\n/** Canonical JSON: sorted keys, no whitespace — a stable hash input. */\nexport function canonicalJSON(value: unknown): string {\n // JSON.stringify(undefined) returns undefined, not a string; absent fields become null\n if (value === undefined) return 'null';\n if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(',')}]`;\n if (value !== null && typeof value === 'object') {\n const entries = Object.keys(value as Record<string, unknown>)\n .sort()\n .map(\n (key) => `${JSON.stringify(key)}:${canonicalJSON((value as Record<string, unknown>)[key])}`\n );\n return `{${entries.join(',')}}`;\n }\n return JSON.stringify(value);\n}\n\n/*\n * SHA-256, hand-written and synchronous: crypto.subtle is async (and absent in\n * some embedders), node:crypto is Node-only — the compiler must stay sync and\n * portable. Like the Murmur3 bucketing hash, the known-answer tests pin this\n * implementation to the reference vectors. contentHash is a permanent contract\n * (it addresses stored versions and pins in-flight journeys), so the algorithm\n * must never change once production data exists.\n */\n\n// prettier-ignore\nconst K = new Uint32Array([\n 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,\n 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,\n 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,\n 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,\n 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,\n 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,\n 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,\n 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,\n]);\n\nconst rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));\n\nconst utf8 = new TextEncoder();\n\n/** SHA-256 over the UTF-8 bytes of `input`, as 64 hex chars. */\nexport function sha256Hex(input: string): string {\n const data = utf8.encode(input);\n const bitLenLo = (data.length << 3) >>> 0;\n const bitLenHi = Math.floor(data.length / 0x20000000);\n\n // Pad to a multiple of 64 bytes: 0x80, zeros, then the 64-bit big-endian bit length\n const padded = new Uint8Array((((data.length + 8) >> 6) + 1) << 6);\n padded.set(data);\n padded[data.length] = 0x80;\n const view = new DataView(padded.buffer);\n view.setUint32(padded.length - 8, bitLenHi);\n view.setUint32(padded.length - 4, bitLenLo);\n\n // prettier-ignore\n const state = new Uint32Array([\n 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,\n ]);\n const w = new Uint32Array(64);\n\n for (let offset = 0; offset < padded.length; offset += 64) {\n for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);\n for (let i = 16; i < 64; i++) {\n const w15 = w[i - 15];\n const w2 = w[i - 2];\n const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3);\n const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10);\n w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;\n }\n\n let [a, b, c, d, e, f, g, h] = state as unknown as [\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n ];\n for (let i = 0; i < 64; i++) {\n const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);\n const ch = (e & f) ^ (~e & g);\n const t1 = (h + s1 + ch + K[i] + w[i]) >>> 0;\n const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);\n const maj = (a & b) ^ (a & c) ^ (b & c);\n const t2 = (s0 + maj) >>> 0;\n h = g;\n g = f;\n f = e;\n e = (d + t1) >>> 0;\n d = c;\n c = b;\n b = a;\n a = (t1 + t2) >>> 0;\n }\n state[0] = (state[0] + a) >>> 0;\n state[1] = (state[1] + b) >>> 0;\n state[2] = (state[2] + c) >>> 0;\n state[3] = (state[3] + d) >>> 0;\n state[4] = (state[4] + e) >>> 0;\n state[5] = (state[5] + f) >>> 0;\n state[6] = (state[6] + g) >>> 0;\n state[7] = (state[7] + h) >>> 0;\n }\n\n return [...state].map((word) => word.toString(16).padStart(8, '0')).join('');\n}\n\n/**\n * Truncate to 128 bits (32 hex chars): collision-safe for content addressing at\n * any realistic definition count, and short enough for KV keys and instance ids.\n */\nconst contentHashOf = (input: string): string => sha256Hex(input).slice(0, 32);\n\n/** Content hash of execution semantics (strip metadata → canonical JSON → truncated SHA-256). */\nexport function semanticHash(value: unknown): string {\n return contentHashOf(canonicalJSON(stripMeta(value)));\n}\n\n/** Canonical hash of the full content — used to tell \"semantics unchanged, metadata edited\" apart. */\nexport function fullHash(value: unknown): string {\n return contentHashOf(canonicalJSON(value));\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"hash.cjs","names":[],"sources":["../src/hash.ts"],"sourcesContent":["/**\n * Content hashing — contentHash covers execution semantics only.\n *\n * Human-facing metadata (name / description, node labels) is excluded,\n * because it cannot change which path a user takes:\n * - rewording a description must not invalidate the version an in-flight\n * journey is pinned to;\n * - nor should it surface as a change in `plan`.\n *\n * Storage, by contrast, always keeps the full IR (including the latest\n * description). contentHash carries exactly two responsibilities: deciding\n * whether semantics moved (plan / migration), and pinning a version for\n * in-flight instances. So after a description edit: the UI shows the new text,\n * plan reports no change, and in-flight users are untouched.\n */\n\n/**\n * Keys excluded from hashing.\n * - meta: metadata at any level — workflow (name / description) and\n * node/segment provenance (`loc`, the callsite source map)\n * - label: node and branch-arm names (UI titles / observability, never routing)\n * - reason: the exit / filter audit string. It is written into the audit log\n * and shown in the studio, but no user takes a different path because of it —\n * the same test as a description, so rewording one must not repin in-flight\n * journeys or report as a semantic change in plan.\n * - contentHash: the hash itself\n *\n * Note that a cohort arm's `name` IS hashed: it becomes part of the node id\n * (`{id}.{armName}.{j}`), and node ids are durable step names — renaming an arm\n * changes execution identity.\n */\nconst UNHASHED_KEYS = new Set(['meta', 'label', 'reason', 'contentHash']);\n\n/**\n * Provenance only. `loc` is a source map, not content: it moves with the\n * machine and with the directory the compile ran from, so plan compares full\n * content with it removed (see plan.ts) — otherwise CI, compiling from the\n * workspace root, reports every definition as metadata_only against a deploy\n * compiled from a package directory.\n */\nconst PROVENANCE_KEYS = new Set(['loc']);\n\n/**\n * Fields whose values are maps the *author* keys, not the IR: message props,\n * send_event payloads and action args. Everything inside them is execution\n * data and is hashed whole.\n *\n * This is what keeps the exclusion above positional: stripping by name at every\n * depth would reach inside these maps, and a prop or payload field that happens\n * to be called `label` (or `meta`) would vanish from the hash — two sends\n * carrying different values would hash identically, `plan` would report a real\n * change as metadata-only, and the deploy would write different content under\n * the same key that in-flight journeys are pinned to.\n *\n * These three are the only `z.record` fields in the IR (see ir.ts). A new one\n * has to be added here, or its contents become strippable by name again.\n *\n * It matters for every key above: a send_event payload may well carry a field\n * called `reason` (subscription_cancelled does), and dropping it would hash two\n * different sends the same.\n */\nconst AUTHOR_KEYED_FIELDS = new Set(['props', 'payload', 'args']);\n\n/** Drop `keys` at the positions the IR defines them, never inside an author-keyed map. */\nfunction stripKeys(value: unknown, keys: ReadonlySet<string>): unknown {\n if (Array.isArray(value)) return value.map((item) => stripKeys(item, keys));\n if (value !== null && typeof value === 'object') {\n const out: Record<string, unknown> = {};\n for (const [key, item] of Object.entries(value as Record<string, unknown>)) {\n if (keys.has(key)) continue;\n // Author-keyed: hashed verbatim, never walked into\n out[key] = AUTHOR_KEYED_FIELDS.has(key) ? item : stripKeys(item, keys);\n }\n return out;\n }\n return value;\n}\n\n/** Drop metadata at the positions the IR defines it, leaving execution semantics. */\nexport function stripMeta(value: unknown): unknown {\n return stripKeys(value, UNHASHED_KEYS);\n}\n\n/** Drop provenance, keeping the rest of the metadata — what plan compares. */\nexport function stripProvenance(value: unknown): unknown {\n return stripKeys(value, PROVENANCE_KEYS);\n}\n\n/** Canonical JSON: sorted keys, no whitespace — a stable hash input. */\nexport function canonicalJSON(value: unknown): string {\n // JSON.stringify(undefined) returns undefined, not a string; absent fields become null\n if (value === undefined) return 'null';\n if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(',')}]`;\n if (value !== null && typeof value === 'object') {\n const entries = Object.keys(value as Record<string, unknown>)\n .sort()\n .map(\n (key) => `${JSON.stringify(key)}:${canonicalJSON((value as Record<string, unknown>)[key])}`\n );\n return `{${entries.join(',')}}`;\n }\n return JSON.stringify(value);\n}\n\n/*\n * SHA-256, hand-written and synchronous: crypto.subtle is async (and absent in\n * some embedders), node:crypto is Node-only — the compiler must stay sync and\n * portable. Like the Murmur3 bucketing hash, the known-answer tests pin this\n * implementation to the reference vectors. contentHash is a permanent contract\n * (it addresses stored versions and pins in-flight journeys), so the algorithm\n * must never change once production data exists.\n */\n\n// prettier-ignore\nconst K = new Uint32Array([\n 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,\n 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,\n 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,\n 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,\n 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,\n 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,\n 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,\n 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,\n]);\n\nconst rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));\n\nconst utf8 = new TextEncoder();\n\n/** SHA-256 over the UTF-8 bytes of `input`, as 64 hex chars. */\nexport function sha256Hex(input: string): string {\n const data = utf8.encode(input);\n const bitLenLo = (data.length << 3) >>> 0;\n const bitLenHi = Math.floor(data.length / 0x20000000);\n\n // Pad to a multiple of 64 bytes: 0x80, zeros, then the 64-bit big-endian bit length\n const padded = new Uint8Array((((data.length + 8) >> 6) + 1) << 6);\n padded.set(data);\n padded[data.length] = 0x80;\n const view = new DataView(padded.buffer);\n view.setUint32(padded.length - 8, bitLenHi);\n view.setUint32(padded.length - 4, bitLenLo);\n\n // prettier-ignore\n const state = new Uint32Array([\n 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,\n ]);\n const w = new Uint32Array(64);\n\n for (let offset = 0; offset < padded.length; offset += 64) {\n for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);\n for (let i = 16; i < 64; i++) {\n const w15 = w[i - 15];\n const w2 = w[i - 2];\n const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3);\n const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10);\n w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;\n }\n\n let [a, b, c, d, e, f, g, h] = state as unknown as [\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n ];\n for (let i = 0; i < 64; i++) {\n const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);\n const ch = (e & f) ^ (~e & g);\n const t1 = (h + s1 + ch + K[i] + w[i]) >>> 0;\n const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);\n const maj = (a & b) ^ (a & c) ^ (b & c);\n const t2 = (s0 + maj) >>> 0;\n h = g;\n g = f;\n f = e;\n e = (d + t1) >>> 0;\n d = c;\n c = b;\n b = a;\n a = (t1 + t2) >>> 0;\n }\n state[0] = (state[0] + a) >>> 0;\n state[1] = (state[1] + b) >>> 0;\n state[2] = (state[2] + c) >>> 0;\n state[3] = (state[3] + d) >>> 0;\n state[4] = (state[4] + e) >>> 0;\n state[5] = (state[5] + f) >>> 0;\n state[6] = (state[6] + g) >>> 0;\n state[7] = (state[7] + h) >>> 0;\n }\n\n return [...state].map((word) => word.toString(16).padStart(8, '0')).join('');\n}\n\n/**\n * Truncate to 128 bits (32 hex chars): collision-safe for content addressing at\n * any realistic definition count, and short enough for KV keys and instance ids.\n */\nconst contentHashOf = (input: string): string => sha256Hex(input).slice(0, 32);\n\n/** Content hash of execution semantics (strip metadata → canonical JSON → truncated SHA-256). */\nexport function semanticHash(value: unknown): string {\n return contentHashOf(canonicalJSON(stripMeta(value)));\n}\n\n/** Canonical hash of the full content — used to tell \"semantics unchanged, metadata edited\" apart. */\nexport function fullHash(value: unknown): string {\n return contentHashOf(canonicalJSON(value));\n}\n"],"mappings":"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"}
|
package/dist/hash.mjs
CHANGED
|
@@ -48,12 +48,12 @@ const PROVENANCE_KEYS = /* @__PURE__ */ new Set(["loc"]);
|
|
|
48
48
|
* send_event payloads and action args. Everything inside them is execution
|
|
49
49
|
* data and is hashed whole.
|
|
50
50
|
*
|
|
51
|
-
* This is what keeps the exclusion above positional
|
|
52
|
-
* depth
|
|
53
|
-
*
|
|
54
|
-
* carrying different values
|
|
55
|
-
* as metadata-only, and the deploy
|
|
56
|
-
* key that in-flight journeys are pinned to.
|
|
51
|
+
* This is what keeps the exclusion above positional: stripping by name at every
|
|
52
|
+
* depth would reach inside these maps, and a prop or payload field that happens
|
|
53
|
+
* to be called `label` (or `meta`) would vanish from the hash — two sends
|
|
54
|
+
* carrying different values would hash identically, `plan` would report a real
|
|
55
|
+
* change as metadata-only, and the deploy would write different content under
|
|
56
|
+
* the same key that in-flight journeys are pinned to.
|
|
57
57
|
*
|
|
58
58
|
* These three are the only `z.record` fields in the IR (see ir.ts). A new one
|
|
59
59
|
* has to be added here, or its contents become strippable by name again.
|
package/dist/hash.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hash.mjs","names":[],"sources":["../src/hash.ts"],"sourcesContent":["/**\n * Content hashing — contentHash covers execution semantics only.\n *\n * Human-facing metadata (name / description, node labels) is excluded,\n * because it cannot change which path a user takes:\n * - rewording a description must not invalidate the version an in-flight\n * journey is pinned to;\n * - nor should it surface as a change in `plan`.\n *\n * Storage, by contrast, always keeps the full IR (including the latest\n * description). contentHash carries exactly two responsibilities: deciding\n * whether semantics moved (plan / migration), and pinning a version for\n * in-flight instances. So after a description edit: the UI shows the new text,\n * plan reports no change, and in-flight users are untouched.\n */\n\n/**\n * Keys excluded from hashing.\n * - meta: metadata at any level — workflow (name / description) and\n * node/segment provenance (`loc`, the callsite source map)\n * - label: node and branch-arm names (UI titles / observability, never routing)\n * - reason: the exit / filter audit string. It is written into the audit log\n * and shown in the studio, but no user takes a different path because of it —\n * the same test as a description, so rewording one must not repin in-flight\n * journeys or report as a semantic change in plan.\n * - contentHash: the hash itself\n *\n * Note that a cohort arm's `name` IS hashed: it becomes part of the node id\n * (`{id}.{armName}.{j}`), and node ids are durable step names — renaming an arm\n * changes execution identity.\n */\nconst UNHASHED_KEYS = new Set(['meta', 'label', 'reason', 'contentHash']);\n\n/**\n * Provenance only. `loc` is a source map, not content: it moves with the\n * machine and with the directory the compile ran from, so plan compares full\n * content with it removed (see plan.ts) — otherwise CI, compiling from the\n * workspace root, reports every definition as metadata_only against a deploy\n * compiled from a package directory.\n */\nconst PROVENANCE_KEYS = new Set(['loc']);\n\n/**\n * Fields whose values are maps the *author* keys, not the IR: message props,\n * send_event payloads and action args. Everything inside them is execution\n * data and is hashed whole.\n *\n * This is what keeps the exclusion above positional. Stripping by name at every\n * depth used to reach inside these maps, so a prop or payload field that\n * happened to be called `label` (or `meta`) vanished from the hash: two sends\n * carrying different values hashed identically, `plan` reported a real change\n * as metadata-only, and the deploy wrote different content under the same KV\n * key that in-flight journeys are pinned to.\n *\n * These three are the only `z.record` fields in the IR (see ir.ts). A new one\n * has to be added here, or its contents become strippable by name again.\n *\n * It matters for every key above: a send_event payload may well carry a field\n * called `reason` (subscription_cancelled does), and dropping it would hash two\n * different sends the same.\n */\nconst AUTHOR_KEYED_FIELDS = new Set(['props', 'payload', 'args']);\n\n/** Drop `keys` at the positions the IR defines them, never inside an author-keyed map. */\nfunction stripKeys(value: unknown, keys: ReadonlySet<string>): unknown {\n if (Array.isArray(value)) return value.map((item) => stripKeys(item, keys));\n if (value !== null && typeof value === 'object') {\n const out: Record<string, unknown> = {};\n for (const [key, item] of Object.entries(value as Record<string, unknown>)) {\n if (keys.has(key)) continue;\n // Author-keyed: hashed verbatim, never walked into\n out[key] = AUTHOR_KEYED_FIELDS.has(key) ? item : stripKeys(item, keys);\n }\n return out;\n }\n return value;\n}\n\n/** Drop metadata at the positions the IR defines it, leaving execution semantics. */\nexport function stripMeta(value: unknown): unknown {\n return stripKeys(value, UNHASHED_KEYS);\n}\n\n/** Drop provenance, keeping the rest of the metadata — what plan compares. */\nexport function stripProvenance(value: unknown): unknown {\n return stripKeys(value, PROVENANCE_KEYS);\n}\n\n/** Canonical JSON: sorted keys, no whitespace — a stable hash input. */\nexport function canonicalJSON(value: unknown): string {\n // JSON.stringify(undefined) returns undefined, not a string; absent fields become null\n if (value === undefined) return 'null';\n if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(',')}]`;\n if (value !== null && typeof value === 'object') {\n const entries = Object.keys(value as Record<string, unknown>)\n .sort()\n .map(\n (key) => `${JSON.stringify(key)}:${canonicalJSON((value as Record<string, unknown>)[key])}`\n );\n return `{${entries.join(',')}}`;\n }\n return JSON.stringify(value);\n}\n\n/*\n * SHA-256, hand-written and synchronous: crypto.subtle is async (and absent in\n * some embedders), node:crypto is Node-only — the compiler must stay sync and\n * portable. Like the Murmur3 bucketing hash, the known-answer tests pin this\n * implementation to the reference vectors. contentHash is a permanent contract\n * (it addresses stored versions and pins in-flight journeys), so the algorithm\n * must never change once production data exists.\n */\n\n// prettier-ignore\nconst K = new Uint32Array([\n 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,\n 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,\n 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,\n 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,\n 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,\n 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,\n 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,\n 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,\n]);\n\nconst rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));\n\nconst utf8 = new TextEncoder();\n\n/** SHA-256 over the UTF-8 bytes of `input`, as 64 hex chars. */\nexport function sha256Hex(input: string): string {\n const data = utf8.encode(input);\n const bitLenLo = (data.length << 3) >>> 0;\n const bitLenHi = Math.floor(data.length / 0x20000000);\n\n // Pad to a multiple of 64 bytes: 0x80, zeros, then the 64-bit big-endian bit length\n const padded = new Uint8Array((((data.length + 8) >> 6) + 1) << 6);\n padded.set(data);\n padded[data.length] = 0x80;\n const view = new DataView(padded.buffer);\n view.setUint32(padded.length - 8, bitLenHi);\n view.setUint32(padded.length - 4, bitLenLo);\n\n // prettier-ignore\n const state = new Uint32Array([\n 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,\n ]);\n const w = new Uint32Array(64);\n\n for (let offset = 0; offset < padded.length; offset += 64) {\n for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);\n for (let i = 16; i < 64; i++) {\n const w15 = w[i - 15];\n const w2 = w[i - 2];\n const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3);\n const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10);\n w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;\n }\n\n let [a, b, c, d, e, f, g, h] = state as unknown as [\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n ];\n for (let i = 0; i < 64; i++) {\n const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);\n const ch = (e & f) ^ (~e & g);\n const t1 = (h + s1 + ch + K[i] + w[i]) >>> 0;\n const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);\n const maj = (a & b) ^ (a & c) ^ (b & c);\n const t2 = (s0 + maj) >>> 0;\n h = g;\n g = f;\n f = e;\n e = (d + t1) >>> 0;\n d = c;\n c = b;\n b = a;\n a = (t1 + t2) >>> 0;\n }\n state[0] = (state[0] + a) >>> 0;\n state[1] = (state[1] + b) >>> 0;\n state[2] = (state[2] + c) >>> 0;\n state[3] = (state[3] + d) >>> 0;\n state[4] = (state[4] + e) >>> 0;\n state[5] = (state[5] + f) >>> 0;\n state[6] = (state[6] + g) >>> 0;\n state[7] = (state[7] + h) >>> 0;\n }\n\n return [...state].map((word) => word.toString(16).padStart(8, '0')).join('');\n}\n\n/**\n * Truncate to 128 bits (32 hex chars): collision-safe for content addressing at\n * any realistic definition count, and short enough for KV keys and instance ids.\n */\nconst contentHashOf = (input: string): string => sha256Hex(input).slice(0, 32);\n\n/** Content hash of execution semantics (strip metadata → canonical JSON → truncated SHA-256). */\nexport function semanticHash(value: unknown): string {\n return contentHashOf(canonicalJSON(stripMeta(value)));\n}\n\n/** Canonical hash of the full content — used to tell \"semantics unchanged, metadata edited\" apart. */\nexport function fullHash(value: unknown): string {\n return contentHashOf(canonicalJSON(value));\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"hash.mjs","names":[],"sources":["../src/hash.ts"],"sourcesContent":["/**\n * Content hashing — contentHash covers execution semantics only.\n *\n * Human-facing metadata (name / description, node labels) is excluded,\n * because it cannot change which path a user takes:\n * - rewording a description must not invalidate the version an in-flight\n * journey is pinned to;\n * - nor should it surface as a change in `plan`.\n *\n * Storage, by contrast, always keeps the full IR (including the latest\n * description). contentHash carries exactly two responsibilities: deciding\n * whether semantics moved (plan / migration), and pinning a version for\n * in-flight instances. So after a description edit: the UI shows the new text,\n * plan reports no change, and in-flight users are untouched.\n */\n\n/**\n * Keys excluded from hashing.\n * - meta: metadata at any level — workflow (name / description) and\n * node/segment provenance (`loc`, the callsite source map)\n * - label: node and branch-arm names (UI titles / observability, never routing)\n * - reason: the exit / filter audit string. It is written into the audit log\n * and shown in the studio, but no user takes a different path because of it —\n * the same test as a description, so rewording one must not repin in-flight\n * journeys or report as a semantic change in plan.\n * - contentHash: the hash itself\n *\n * Note that a cohort arm's `name` IS hashed: it becomes part of the node id\n * (`{id}.{armName}.{j}`), and node ids are durable step names — renaming an arm\n * changes execution identity.\n */\nconst UNHASHED_KEYS = new Set(['meta', 'label', 'reason', 'contentHash']);\n\n/**\n * Provenance only. `loc` is a source map, not content: it moves with the\n * machine and with the directory the compile ran from, so plan compares full\n * content with it removed (see plan.ts) — otherwise CI, compiling from the\n * workspace root, reports every definition as metadata_only against a deploy\n * compiled from a package directory.\n */\nconst PROVENANCE_KEYS = new Set(['loc']);\n\n/**\n * Fields whose values are maps the *author* keys, not the IR: message props,\n * send_event payloads and action args. Everything inside them is execution\n * data and is hashed whole.\n *\n * This is what keeps the exclusion above positional: stripping by name at every\n * depth would reach inside these maps, and a prop or payload field that happens\n * to be called `label` (or `meta`) would vanish from the hash — two sends\n * carrying different values would hash identically, `plan` would report a real\n * change as metadata-only, and the deploy would write different content under\n * the same key that in-flight journeys are pinned to.\n *\n * These three are the only `z.record` fields in the IR (see ir.ts). A new one\n * has to be added here, or its contents become strippable by name again.\n *\n * It matters for every key above: a send_event payload may well carry a field\n * called `reason` (subscription_cancelled does), and dropping it would hash two\n * different sends the same.\n */\nconst AUTHOR_KEYED_FIELDS = new Set(['props', 'payload', 'args']);\n\n/** Drop `keys` at the positions the IR defines them, never inside an author-keyed map. */\nfunction stripKeys(value: unknown, keys: ReadonlySet<string>): unknown {\n if (Array.isArray(value)) return value.map((item) => stripKeys(item, keys));\n if (value !== null && typeof value === 'object') {\n const out: Record<string, unknown> = {};\n for (const [key, item] of Object.entries(value as Record<string, unknown>)) {\n if (keys.has(key)) continue;\n // Author-keyed: hashed verbatim, never walked into\n out[key] = AUTHOR_KEYED_FIELDS.has(key) ? item : stripKeys(item, keys);\n }\n return out;\n }\n return value;\n}\n\n/** Drop metadata at the positions the IR defines it, leaving execution semantics. */\nexport function stripMeta(value: unknown): unknown {\n return stripKeys(value, UNHASHED_KEYS);\n}\n\n/** Drop provenance, keeping the rest of the metadata — what plan compares. */\nexport function stripProvenance(value: unknown): unknown {\n return stripKeys(value, PROVENANCE_KEYS);\n}\n\n/** Canonical JSON: sorted keys, no whitespace — a stable hash input. */\nexport function canonicalJSON(value: unknown): string {\n // JSON.stringify(undefined) returns undefined, not a string; absent fields become null\n if (value === undefined) return 'null';\n if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(',')}]`;\n if (value !== null && typeof value === 'object') {\n const entries = Object.keys(value as Record<string, unknown>)\n .sort()\n .map(\n (key) => `${JSON.stringify(key)}:${canonicalJSON((value as Record<string, unknown>)[key])}`\n );\n return `{${entries.join(',')}}`;\n }\n return JSON.stringify(value);\n}\n\n/*\n * SHA-256, hand-written and synchronous: crypto.subtle is async (and absent in\n * some embedders), node:crypto is Node-only — the compiler must stay sync and\n * portable. Like the Murmur3 bucketing hash, the known-answer tests pin this\n * implementation to the reference vectors. contentHash is a permanent contract\n * (it addresses stored versions and pins in-flight journeys), so the algorithm\n * must never change once production data exists.\n */\n\n// prettier-ignore\nconst K = new Uint32Array([\n 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,\n 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,\n 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,\n 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,\n 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,\n 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,\n 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,\n 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,\n]);\n\nconst rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));\n\nconst utf8 = new TextEncoder();\n\n/** SHA-256 over the UTF-8 bytes of `input`, as 64 hex chars. */\nexport function sha256Hex(input: string): string {\n const data = utf8.encode(input);\n const bitLenLo = (data.length << 3) >>> 0;\n const bitLenHi = Math.floor(data.length / 0x20000000);\n\n // Pad to a multiple of 64 bytes: 0x80, zeros, then the 64-bit big-endian bit length\n const padded = new Uint8Array((((data.length + 8) >> 6) + 1) << 6);\n padded.set(data);\n padded[data.length] = 0x80;\n const view = new DataView(padded.buffer);\n view.setUint32(padded.length - 8, bitLenHi);\n view.setUint32(padded.length - 4, bitLenLo);\n\n // prettier-ignore\n const state = new Uint32Array([\n 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,\n ]);\n const w = new Uint32Array(64);\n\n for (let offset = 0; offset < padded.length; offset += 64) {\n for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);\n for (let i = 16; i < 64; i++) {\n const w15 = w[i - 15];\n const w2 = w[i - 2];\n const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3);\n const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10);\n w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;\n }\n\n let [a, b, c, d, e, f, g, h] = state as unknown as [\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n ];\n for (let i = 0; i < 64; i++) {\n const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);\n const ch = (e & f) ^ (~e & g);\n const t1 = (h + s1 + ch + K[i] + w[i]) >>> 0;\n const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);\n const maj = (a & b) ^ (a & c) ^ (b & c);\n const t2 = (s0 + maj) >>> 0;\n h = g;\n g = f;\n f = e;\n e = (d + t1) >>> 0;\n d = c;\n c = b;\n b = a;\n a = (t1 + t2) >>> 0;\n }\n state[0] = (state[0] + a) >>> 0;\n state[1] = (state[1] + b) >>> 0;\n state[2] = (state[2] + c) >>> 0;\n state[3] = (state[3] + d) >>> 0;\n state[4] = (state[4] + e) >>> 0;\n state[5] = (state[5] + f) >>> 0;\n state[6] = (state[6] + g) >>> 0;\n state[7] = (state[7] + h) >>> 0;\n }\n\n return [...state].map((word) => word.toString(16).padStart(8, '0')).join('');\n}\n\n/**\n * Truncate to 128 bits (32 hex chars): collision-safe for content addressing at\n * any realistic definition count, and short enough for KV keys and instance ids.\n */\nconst contentHashOf = (input: string): string => sha256Hex(input).slice(0, 32);\n\n/** Content hash of execution semantics (strip metadata → canonical JSON → truncated SHA-256). */\nexport function semanticHash(value: unknown): string {\n return contentHashOf(canonicalJSON(stripMeta(value)));\n}\n\n/** Canonical hash of the full content — used to tell \"semantics unchanged, metadata edited\" apart. */\nexport function fullHash(value: unknown): string {\n return contentHashOf(canonicalJSON(value));\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_crypto = require("node:crypto");
|
|
3
|
+
//#region src/host/auth.ts
|
|
4
|
+
const utf8 = new TextEncoder();
|
|
5
|
+
/**
|
|
6
|
+
* `node:crypto`'s constant-time compare — on Node, Lambda, and a Worker with `nodejs_compat`
|
|
7
|
+
* (which the host needs anyway). It demands equal lengths, so callers hash first or check the
|
|
8
|
+
* length; a mismatch on length answers at once, which reveals the length and nothing else.
|
|
9
|
+
*/
|
|
10
|
+
function constantTimeEqual(a, b) {
|
|
11
|
+
return a.length === b.length && (0, node_crypto.timingSafeEqual)(a, b);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Whether two secrets are equal, in time that does not depend on where they differ: both are
|
|
15
|
+
* hashed first, so the comparison runs over equal-length digests whatever their lengths — for an
|
|
16
|
+
* authorizer comparing a signature or a token of its own.
|
|
17
|
+
*/
|
|
18
|
+
async function timingSafeEqual(a, b) {
|
|
19
|
+
const [x, y] = await Promise.all([crypto.subtle.digest("SHA-256", utf8.encode(a)), crypto.subtle.digest("SHA-256", utf8.encode(b))]);
|
|
20
|
+
return constantTimeEqual(new Uint8Array(x), new Uint8Array(y));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).
|
|
24
|
+
* The scheme is matched case-insensitively, as RFC 9110 says schemes are; the token is compared
|
|
25
|
+
* in constant time.
|
|
26
|
+
*/
|
|
27
|
+
function bearerMatches(request, token) {
|
|
28
|
+
const header = request.headers.get("authorization");
|
|
29
|
+
if (header === null) return false;
|
|
30
|
+
const space = header.indexOf(" ");
|
|
31
|
+
if (space === -1) return false;
|
|
32
|
+
if (header.slice(0, space).toLowerCase() !== "bearer") return false;
|
|
33
|
+
const presented = header.slice(space + 1).trim();
|
|
34
|
+
return constantTimeEqual(utf8.encode(presented), utf8.encode(token));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether the request may use the API: always when no token is configured (an open development
|
|
38
|
+
* instance — production refuses to run that way, see `createJourneyHost`), otherwise only with
|
|
39
|
+
* the matching bearer.
|
|
40
|
+
*/
|
|
41
|
+
function authorized(request, env) {
|
|
42
|
+
return env.API_TOKEN === void 0 || bearerMatches(request, env.API_TOKEN);
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
exports.authorized = authorized;
|
|
46
|
+
exports.bearerMatches = bearerMatches;
|
|
47
|
+
exports.timingSafeEqual = timingSafeEqual;
|
|
48
|
+
|
|
49
|
+
//# sourceMappingURL=auth.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.cjs","names":["timingSafeEqualBytes"],"sources":["../../src/host/auth.ts"],"sourcesContent":["import { timingSafeEqual as timingSafeEqualBytes } from 'node:crypto';\n\n/**\n * Who may call the host's protected routes. By default the env's `API_TOKEN` as a bearer; a host\n * replaces it with its own `Authorize` (an HMAC over the body, a JWT, a header its proxy adds).\n * The helpers here are what such an authorizer builds on.\n */\n\n/**\n * The host's authorization: receives a copy of the request (so it may read the body) and the\n * env. True lets the request through, false answers 401, a throw answers 500 and is logged — an\n * authorizer that fails never lets a request in. The tracking routes (`/t/...`) and `/health`\n * are public and never reach it.\n */\nexport type Authorize<Env> = (request: Request, env: Env) => boolean | Promise<boolean>;\n\nconst utf8 = new TextEncoder();\n\n/**\n * `node:crypto`'s constant-time compare — on Node, Lambda, and a Worker with `nodejs_compat`\n * (which the host needs anyway). It demands equal lengths, so callers hash first or check the\n * length; a mismatch on length answers at once, which reveals the length and nothing else.\n */\nfunction constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {\n return a.length === b.length && timingSafeEqualBytes(a, b);\n}\n\n/**\n * Whether two secrets are equal, in time that does not depend on where they differ: both are\n * hashed first, so the comparison runs over equal-length digests whatever their lengths — for an\n * authorizer comparing a signature or a token of its own.\n */\nexport async function timingSafeEqual(a: string, b: string): Promise<boolean> {\n const [x, y] = await Promise.all([\n crypto.subtle.digest('SHA-256', utf8.encode(a)),\n crypto.subtle.digest('SHA-256', utf8.encode(b)),\n ]);\n return constantTimeEqual(new Uint8Array(x), new Uint8Array(y));\n}\n\n/**\n * Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).\n * The scheme is matched case-insensitively, as RFC 9110 says schemes are; the token is compared\n * in constant time.\n */\nexport function bearerMatches(request: Request, token: string): boolean {\n const header = request.headers.get('authorization');\n if (header === null) return false;\n const space = header.indexOf(' ');\n if (space === -1) return false;\n if (header.slice(0, space).toLowerCase() !== 'bearer') return false;\n const presented = header.slice(space + 1).trim();\n return constantTimeEqual(utf8.encode(presented), utf8.encode(token));\n}\n\n/**\n * Whether the request may use the API: always when no token is configured (an open development\n * instance — production refuses to run that way, see `createJourneyHost`), otherwise only with\n * the matching bearer.\n */\nexport function authorized(request: Request, env: { API_TOKEN?: string | undefined }): boolean {\n return env.API_TOKEN === undefined || bearerMatches(request, env.API_TOKEN);\n}\n"],"mappings":";;;AAgBA,MAAM,OAAO,IAAI,YAAY;;;;;;AAO7B,SAAS,kBAAkB,GAAe,GAAwB;CAChE,OAAO,EAAE,WAAW,EAAE,WAAA,GAAUA,YAAAA,gBAAAA,CAAqB,GAAG,CAAC;AAC3D;;;;;;AAOA,eAAsB,gBAAgB,GAAW,GAA6B;CAC5E,MAAM,CAAC,GAAG,KAAK,MAAM,QAAQ,IAAI,CAC/B,OAAO,OAAO,OAAO,WAAW,KAAK,OAAO,CAAC,CAAC,GAC9C,OAAO,OAAO,OAAO,WAAW,KAAK,OAAO,CAAC,CAAC,CAChD,CAAC;CACD,OAAO,kBAAkB,IAAI,WAAW,CAAC,GAAG,IAAI,WAAW,CAAC,CAAC;AAC/D;;;;;;AAOA,SAAgB,cAAc,SAAkB,OAAwB;CACtE,MAAM,SAAS,QAAQ,QAAQ,IAAI,eAAe;CAClD,IAAI,WAAW,MAAM,OAAO;CAC5B,MAAM,QAAQ,OAAO,QAAQ,GAAG;CAChC,IAAI,UAAU,IAAI,OAAO;CACzB,IAAI,OAAO,MAAM,GAAG,KAAK,CAAC,CAAC,YAAY,MAAM,UAAU,OAAO;CAC9D,MAAM,YAAY,OAAO,MAAM,QAAQ,CAAC,CAAC,CAAC,KAAK;CAC/C,OAAO,kBAAkB,KAAK,OAAO,SAAS,GAAG,KAAK,OAAO,KAAK,CAAC;AACrE;;;;;;AAOA,SAAgB,WAAW,SAAkB,KAAkD;CAC7F,OAAO,IAAI,cAAc,KAAA,KAAa,cAAc,SAAS,IAAI,SAAS;AAC5E"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/host/auth.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Who may call the host's protected routes. By default the env's `API_TOKEN` as a bearer; a host
|
|
4
|
+
* replaces it with its own `Authorize` (an HMAC over the body, a JWT, a header its proxy adds).
|
|
5
|
+
* The helpers here are what such an authorizer builds on.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The host's authorization: receives a copy of the request (so it may read the body) and the
|
|
9
|
+
* env. True lets the request through, false answers 401, a throw answers 500 and is logged — an
|
|
10
|
+
* authorizer that fails never lets a request in. The tracking routes (`/t/...`) and `/health`
|
|
11
|
+
* are public and never reach it.
|
|
12
|
+
*/
|
|
13
|
+
export type Authorize<Env> = (request: Request, env: Env) => boolean | Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Whether two secrets are equal, in time that does not depend on where they differ: both are
|
|
16
|
+
* hashed first, so the comparison runs over equal-length digests whatever their lengths — for an
|
|
17
|
+
* authorizer comparing a signature or a token of its own.
|
|
18
|
+
*/
|
|
19
|
+
export declare function timingSafeEqual(a: string, b: string): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).
|
|
22
|
+
* The scheme is matched case-insensitively, as RFC 9110 says schemes are; the token is compared
|
|
23
|
+
* in constant time.
|
|
24
|
+
*/
|
|
25
|
+
export declare function bearerMatches(request: Request, token: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether the request may use the API: always when no token is configured (an open development
|
|
28
|
+
* instance — production refuses to run that way, see `createJourneyHost`), otherwise only with
|
|
29
|
+
* the matching bearer.
|
|
30
|
+
*/
|
|
31
|
+
export declare function authorized(request: Request, env: {
|
|
32
|
+
API_TOKEN?: string | undefined;
|
|
33
|
+
}): boolean;
|
|
34
|
+
//#endregion
|
|
35
|
+
//# sourceMappingURL=auth.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.cts","names":[],"sources":["../../src/host/auth.ts"],"mappings":";;;;;;;;;;;;YAcY,UAAU,QAAQ,SAAS,SAAS,KAAK,kBAAkB;;;;;;wBAkBjD,gBAAgB,WAAW,YAAY;;;;;;wBAa7C,cAAc,SAAS,SAAS;;;;;;wBAehC,WAAW,SAAS,SAAS;EAAO"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/host/auth.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Who may call the host's protected routes. By default the env's `API_TOKEN` as a bearer; a host
|
|
4
|
+
* replaces it with its own `Authorize` (an HMAC over the body, a JWT, a header its proxy adds).
|
|
5
|
+
* The helpers here are what such an authorizer builds on.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The host's authorization: receives a copy of the request (so it may read the body) and the
|
|
9
|
+
* env. True lets the request through, false answers 401, a throw answers 500 and is logged — an
|
|
10
|
+
* authorizer that fails never lets a request in. The tracking routes (`/t/...`) and `/health`
|
|
11
|
+
* are public and never reach it.
|
|
12
|
+
*/
|
|
13
|
+
export type Authorize<Env> = (request: Request, env: Env) => boolean | Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Whether two secrets are equal, in time that does not depend on where they differ: both are
|
|
16
|
+
* hashed first, so the comparison runs over equal-length digests whatever their lengths — for an
|
|
17
|
+
* authorizer comparing a signature or a token of its own.
|
|
18
|
+
*/
|
|
19
|
+
export declare function timingSafeEqual(a: string, b: string): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).
|
|
22
|
+
* The scheme is matched case-insensitively, as RFC 9110 says schemes are; the token is compared
|
|
23
|
+
* in constant time.
|
|
24
|
+
*/
|
|
25
|
+
export declare function bearerMatches(request: Request, token: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether the request may use the API: always when no token is configured (an open development
|
|
28
|
+
* instance — production refuses to run that way, see `createJourneyHost`), otherwise only with
|
|
29
|
+
* the matching bearer.
|
|
30
|
+
*/
|
|
31
|
+
export declare function authorized(request: Request, env: {
|
|
32
|
+
API_TOKEN?: string | undefined;
|
|
33
|
+
}): boolean;
|
|
34
|
+
//#endregion
|
|
35
|
+
//# sourceMappingURL=auth.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.mts","names":[],"sources":["../../src/host/auth.ts"],"mappings":";;;;;;;;;;;;YAcY,UAAU,QAAQ,SAAS,SAAS,KAAK,kBAAkB;;;;;;wBAkBjD,gBAAgB,WAAW,YAAY;;;;;;wBAa7C,cAAc,SAAS,SAAS;;;;;;wBAehC,WAAW,SAAS,SAAS;EAAO"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { timingSafeEqual as timingSafeEqual$1 } from "node:crypto";
|
|
2
|
+
//#region src/host/auth.ts
|
|
3
|
+
const utf8 = new TextEncoder();
|
|
4
|
+
/**
|
|
5
|
+
* `node:crypto`'s constant-time compare — on Node, Lambda, and a Worker with `nodejs_compat`
|
|
6
|
+
* (which the host needs anyway). It demands equal lengths, so callers hash first or check the
|
|
7
|
+
* length; a mismatch on length answers at once, which reveals the length and nothing else.
|
|
8
|
+
*/
|
|
9
|
+
function constantTimeEqual(a, b) {
|
|
10
|
+
return a.length === b.length && timingSafeEqual$1(a, b);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Whether two secrets are equal, in time that does not depend on where they differ: both are
|
|
14
|
+
* hashed first, so the comparison runs over equal-length digests whatever their lengths — for an
|
|
15
|
+
* authorizer comparing a signature or a token of its own.
|
|
16
|
+
*/
|
|
17
|
+
async function timingSafeEqual(a, b) {
|
|
18
|
+
const [x, y] = await Promise.all([crypto.subtle.digest("SHA-256", utf8.encode(a)), crypto.subtle.digest("SHA-256", utf8.encode(b))]);
|
|
19
|
+
return constantTimeEqual(new Uint8Array(x), new Uint8Array(y));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).
|
|
23
|
+
* The scheme is matched case-insensitively, as RFC 9110 says schemes are; the token is compared
|
|
24
|
+
* in constant time.
|
|
25
|
+
*/
|
|
26
|
+
function bearerMatches(request, token) {
|
|
27
|
+
const header = request.headers.get("authorization");
|
|
28
|
+
if (header === null) return false;
|
|
29
|
+
const space = header.indexOf(" ");
|
|
30
|
+
if (space === -1) return false;
|
|
31
|
+
if (header.slice(0, space).toLowerCase() !== "bearer") return false;
|
|
32
|
+
const presented = header.slice(space + 1).trim();
|
|
33
|
+
return constantTimeEqual(utf8.encode(presented), utf8.encode(token));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether the request may use the API: always when no token is configured (an open development
|
|
37
|
+
* instance — production refuses to run that way, see `createJourneyHost`), otherwise only with
|
|
38
|
+
* the matching bearer.
|
|
39
|
+
*/
|
|
40
|
+
function authorized(request, env) {
|
|
41
|
+
return env.API_TOKEN === void 0 || bearerMatches(request, env.API_TOKEN);
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
export { authorized, bearerMatches, timingSafeEqual };
|
|
45
|
+
|
|
46
|
+
//# sourceMappingURL=auth.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.mjs","names":["timingSafeEqualBytes"],"sources":["../../src/host/auth.ts"],"sourcesContent":["import { timingSafeEqual as timingSafeEqualBytes } from 'node:crypto';\n\n/**\n * Who may call the host's protected routes. By default the env's `API_TOKEN` as a bearer; a host\n * replaces it with its own `Authorize` (an HMAC over the body, a JWT, a header its proxy adds).\n * The helpers here are what such an authorizer builds on.\n */\n\n/**\n * The host's authorization: receives a copy of the request (so it may read the body) and the\n * env. True lets the request through, false answers 401, a throw answers 500 and is logged — an\n * authorizer that fails never lets a request in. The tracking routes (`/t/...`) and `/health`\n * are public and never reach it.\n */\nexport type Authorize<Env> = (request: Request, env: Env) => boolean | Promise<boolean>;\n\nconst utf8 = new TextEncoder();\n\n/**\n * `node:crypto`'s constant-time compare — on Node, Lambda, and a Worker with `nodejs_compat`\n * (which the host needs anyway). It demands equal lengths, so callers hash first or check the\n * length; a mismatch on length answers at once, which reveals the length and nothing else.\n */\nfunction constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {\n return a.length === b.length && timingSafeEqualBytes(a, b);\n}\n\n/**\n * Whether two secrets are equal, in time that does not depend on where they differ: both are\n * hashed first, so the comparison runs over equal-length digests whatever their lengths — for an\n * authorizer comparing a signature or a token of its own.\n */\nexport async function timingSafeEqual(a: string, b: string): Promise<boolean> {\n const [x, y] = await Promise.all([\n crypto.subtle.digest('SHA-256', utf8.encode(a)),\n crypto.subtle.digest('SHA-256', utf8.encode(b)),\n ]);\n return constantTimeEqual(new Uint8Array(x), new Uint8Array(y));\n}\n\n/**\n * Whether the request carries `token` as a bearer credential (`Authorization: Bearer <token>`).\n * The scheme is matched case-insensitively, as RFC 9110 says schemes are; the token is compared\n * in constant time.\n */\nexport function bearerMatches(request: Request, token: string): boolean {\n const header = request.headers.get('authorization');\n if (header === null) return false;\n const space = header.indexOf(' ');\n if (space === -1) return false;\n if (header.slice(0, space).toLowerCase() !== 'bearer') return false;\n const presented = header.slice(space + 1).trim();\n return constantTimeEqual(utf8.encode(presented), utf8.encode(token));\n}\n\n/**\n * Whether the request may use the API: always when no token is configured (an open development\n * instance — production refuses to run that way, see `createJourneyHost`), otherwise only with\n * the matching bearer.\n */\nexport function authorized(request: Request, env: { API_TOKEN?: string | undefined }): boolean {\n return env.API_TOKEN === undefined || bearerMatches(request, env.API_TOKEN);\n}\n"],"mappings":";;AAgBA,MAAM,OAAO,IAAI,YAAY;;;;;;AAO7B,SAAS,kBAAkB,GAAe,GAAwB;CAChE,OAAO,EAAE,WAAW,EAAE,UAAUA,kBAAqB,GAAG,CAAC;AAC3D;;;;;;AAOA,eAAsB,gBAAgB,GAAW,GAA6B;CAC5E,MAAM,CAAC,GAAG,KAAK,MAAM,QAAQ,IAAI,CAC/B,OAAO,OAAO,OAAO,WAAW,KAAK,OAAO,CAAC,CAAC,GAC9C,OAAO,OAAO,OAAO,WAAW,KAAK,OAAO,CAAC,CAAC,CAChD,CAAC;CACD,OAAO,kBAAkB,IAAI,WAAW,CAAC,GAAG,IAAI,WAAW,CAAC,CAAC;AAC/D;;;;;;AAOA,SAAgB,cAAc,SAAkB,OAAwB;CACtE,MAAM,SAAS,QAAQ,QAAQ,IAAI,eAAe;CAClD,IAAI,WAAW,MAAM,OAAO;CAC5B,MAAM,QAAQ,OAAO,QAAQ,GAAG;CAChC,IAAI,UAAU,IAAI,OAAO;CACzB,IAAI,OAAO,MAAM,GAAG,KAAK,CAAC,CAAC,YAAY,MAAM,UAAU,OAAO;CAC9D,MAAM,YAAY,OAAO,MAAM,QAAQ,CAAC,CAAC,CAAC,KAAK;CAC/C,OAAO,kBAAkB,KAAK,OAAO,SAAS,GAAG,KAAK,OAAO,KAAK,CAAC;AACrE;;;;;;AAOA,SAAgB,WAAW,SAAkB,KAAkD;CAC7F,OAAO,IAAI,cAAc,KAAA,KAAa,cAAc,SAAS,IAAI,SAAS;AAC5E"}
|