@shware/workflow 4.0.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +141 -13
  2. package/dist/apns.cjs +3 -2
  3. package/dist/apns.cjs.map +1 -1
  4. package/dist/apns.d.cts.map +1 -1
  5. package/dist/apns.d.mts.map +1 -1
  6. package/dist/apns.mjs +3 -2
  7. package/dist/apns.mjs.map +1 -1
  8. package/dist/aws/function-url.cjs +3 -11
  9. package/dist/aws/function-url.cjs.map +1 -1
  10. package/dist/aws/function-url.d.cts.map +1 -1
  11. package/dist/aws/function-url.d.mts.map +1 -1
  12. package/dist/aws/function-url.mjs +3 -11
  13. package/dist/aws/function-url.mjs.map +1 -1
  14. package/dist/aws/index.cjs +3 -0
  15. package/dist/aws/index.cjs.map +1 -1
  16. package/dist/aws/index.d.cts +2 -1
  17. package/dist/aws/index.d.cts.map +1 -1
  18. package/dist/aws/index.d.mts +2 -1
  19. package/dist/aws/index.d.mts.map +1 -1
  20. package/dist/aws/index.mjs +2 -1
  21. package/dist/aws/index.mjs.map +1 -1
  22. package/dist/cloudflare/index.cjs +1 -0
  23. package/dist/cloudflare/index.d.cts +2 -2
  24. package/dist/cloudflare/index.d.mts +2 -2
  25. package/dist/cloudflare/index.mjs +2 -2
  26. package/dist/fcm.cjs +2 -1
  27. package/dist/fcm.cjs.map +1 -1
  28. package/dist/fcm.d.cts.map +1 -1
  29. package/dist/fcm.d.mts.map +1 -1
  30. package/dist/fcm.mjs +2 -1
  31. package/dist/fcm.mjs.map +1 -1
  32. package/dist/host/auth.cjs +15 -15
  33. package/dist/host/auth.cjs.map +1 -1
  34. package/dist/host/auth.d.cts +16 -2
  35. package/dist/host/auth.d.cts.map +1 -1
  36. package/dist/host/auth.d.mts +16 -2
  37. package/dist/host/auth.d.mts.map +1 -1
  38. package/dist/host/auth.mjs +15 -16
  39. package/dist/host/auth.mjs.map +1 -1
  40. package/dist/host/channels.cjs +2 -1
  41. package/dist/host/channels.cjs.map +1 -1
  42. package/dist/host/channels.d.cts.map +1 -1
  43. package/dist/host/channels.d.mts.map +1 -1
  44. package/dist/host/channels.mjs +2 -1
  45. package/dist/host/channels.mjs.map +1 -1
  46. package/dist/host/http.cjs +37 -18
  47. package/dist/host/http.cjs.map +1 -1
  48. package/dist/host/http.d.cts +5 -1
  49. package/dist/host/http.d.cts.map +1 -1
  50. package/dist/host/http.d.mts +5 -1
  51. package/dist/host/http.d.mts.map +1 -1
  52. package/dist/host/http.mjs +37 -18
  53. package/dist/host/http.mjs.map +1 -1
  54. package/dist/host/journey-host.cjs +1 -0
  55. package/dist/host/journey-host.cjs.map +1 -1
  56. package/dist/host/journey-host.d.cts +12 -0
  57. package/dist/host/journey-host.d.cts.map +1 -1
  58. package/dist/host/journey-host.d.mts +12 -0
  59. package/dist/host/journey-host.d.mts.map +1 -1
  60. package/dist/host/journey-host.mjs +1 -0
  61. package/dist/host/journey-host.mjs.map +1 -1
  62. package/dist/jwt.cjs +5 -9
  63. package/dist/jwt.cjs.map +1 -1
  64. package/dist/jwt.d.cts +1 -1
  65. package/dist/jwt.d.cts.map +1 -1
  66. package/dist/jwt.d.mts +1 -1
  67. package/dist/jwt.d.mts.map +1 -1
  68. package/dist/jwt.mjs +5 -9
  69. package/dist/jwt.mjs.map +1 -1
  70. package/dist/store/sql.cjs +2 -1
  71. package/dist/store/sql.cjs.map +1 -1
  72. package/dist/store/sql.d.cts.map +1 -1
  73. package/dist/store/sql.d.mts.map +1 -1
  74. package/dist/store/sql.mjs +2 -1
  75. package/dist/store/sql.mjs.map +1 -1
  76. package/dist/transports/sigv4.cjs +2 -3
  77. package/dist/transports/sigv4.cjs.map +1 -1
  78. package/dist/transports/sigv4.d.cts.map +1 -1
  79. package/dist/transports/sigv4.d.mts.map +1 -1
  80. package/dist/transports/sigv4.mjs +2 -3
  81. package/dist/transports/sigv4.mjs.map +1 -1
  82. package/dist/webpush.cjs +11 -20
  83. package/dist/webpush.cjs.map +1 -1
  84. package/dist/webpush.d.cts.map +1 -1
  85. package/dist/webpush.d.mts.map +1 -1
  86. package/dist/webpush.mjs +11 -20
  87. package/dist/webpush.mjs.map +1 -1
  88. package/package.json +1 -1
package/README.md CHANGED
@@ -666,8 +666,10 @@ Secrets never go in the config; the Worker reads them from its environment:
666
666
  | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | The Web Push key pair |
667
667
  | `APNS_RELAY_ORIGIN` | Development only: the local APNs relay |
668
668
 
669
- `journeyWorker` also takes `observer`, `messages` and `runtime` overrides, and
670
- `contractMode: 'report'` to log contract refusals instead of answering 400.
669
+ `journeyWorker` (and `journeyLambda`) also take `observer`, `messages` and `runtime` overrides,
670
+ `contractMode: 'report'` to log contract refusals instead of answering 400, and `authorize` to
671
+ replace the `API_TOKEN` bearer with a check of your own (see
672
+ [AWS deployment notes](#aws-deployment-notes)).
671
673
 
672
674
  ## `JourneyClient`
673
675
 
@@ -751,7 +753,8 @@ The compiled IR lives in D1 with the routes, never in a KV namespace: KV is even
751
753
  while the routes in D1 are not, so an instance started in another colo right after a deploy could
752
754
  miss its IR.
753
755
 
754
- A Worker on Postgres needs only `JOURNEY`. Add the `EMAIL` send_email binding
756
+ The Worker needs the `nodejs_compat` compatibility flag (the bearer check uses
757
+ `node:crypto`'s `timingSafeEqual`). A Worker on Postgres needs only `JOURNEY`. Add the `EMAIL` send_email binding
755
758
  when it sends through Cloudflare Email (`cloudflareEmailTransport({ binding: env.EMAIL })`), and a `triggers.crons` entry with `scheduled`
756
759
  exported to run the segment sweep.
757
760
 
@@ -796,6 +799,30 @@ There are no migrations, and re-applying the file is not one: every statement is
796
799
  the first insert needing a new column fails. Recreate it instead. The demo has
797
800
  `pnpm db:reset`. The `events` key is `(user_id, id)`, and `workflow_ir` holds the compiled IR.
798
801
 
802
+ ### Cloudflare deployment notes
803
+
804
+ - **Email transport.** Pass `email` explicitly: `cloudflareEmailTransport({ binding: env.EMAIL })`
805
+ for Cloudflare Email, or `sesEmailTransport({ region, credentials })` with credentials from the
806
+ Worker's env, since a Worker has no execution role. A Worker whose workflows email without one
807
+ fails at startup.
808
+ - **List-Unsubscribe.** Cloudflare Email rejects a `List-Unsubscribe` header with a non-TLS URI, so
809
+ the one-click header is only sent with an https `WORKFLOW_ORIGIN`. `wrangler dev` on
810
+ `http://localhost` sends none.
811
+ - **Workflows limits** (paid plan):
812
+ - An instance's wall time is unlimited.
813
+ - A single `step.sleep` lasts at most 365 days.
814
+ - 10,000 steps per instance by default, configurable up to 25,000. Sleeps do not count, but every
815
+ wait attempt costs a few steps.
816
+ - A step's result is at most 1 MiB.
817
+ - A completed instance's state is kept 30 days.
818
+ - **Authorization.** Set `API_TOKEN` with `wrangler secret put`, or pass `authorize` (below).
819
+ The tracking routes (`/t/...`) stay public by design.
820
+ - **Deploying.** With the `CF_VERSION_METADATA` binding and a Postgres store, each `wrangler deploy`
821
+ makes its own bundle live (see [Postgres](#postgres)). On D1, call `POST /bundle/deploy`.
822
+ - **APNs in development.** `wrangler dev` (workerd) cannot make HTTP/2 subrequests: run
823
+ `@shware/workflow/apns-relay` and point `APNS_RELAY_ORIGIN` at it. A deployed Worker needs no
824
+ relay.
825
+
799
826
  ## AWS Lambda
800
827
 
801
828
  The same config runs as one Lambda durable function behind a Function URL, on
@@ -879,14 +906,114 @@ Workflow:
879
906
  FunctionUrlConfig: { AuthType: NONE, InvokeMode: BUFFERED }
880
907
  ```
881
908
 
882
- The execution role needs the durable checkpoint permissions,
883
- `lambda:InvokeFunction` on its own versions, `lambda:SendDurableExecutionCallbackSuccess`,
884
- `lambda:GetDurableExecution`, `lambda:ListDurableExecutionsByFunction` and
885
- `lambda:StopDurableExecution`, plus `ses:SendEmail` when it sends through SES.
886
- With `AuthType: NONE`, scope the public `lambda:InvokeFunction` grant to
887
- `lambda:InvokedViaFunctionUrl = true` so the URL cannot be used to invoke the
888
- function directly. APNs goes over Node's HTTP/2 client; a custom domain needs
889
- CloudFront (or another HTTPS proxy) in front of the Function URL.
909
+ ### AWS deployment notes
910
+
911
+ Checked against a real durable Lambda (Node 24, us-east-1).
912
+
913
+ **Function URL: `AuthType: NONE`, with the API protected in the app.** Not every caller can
914
+ sign a request:
915
+
916
+ | Caller | Routes |
917
+ | ------------------------------------------ | -------------------------------------------------------- |
918
+ | Your backend | `/events`, `/identify`, `/devices`, `/users/forget`, ... |
919
+ | Recipients' mail clients and browsers | `/t/o`, `/t/l`, `/t/u` |
920
+ | Gmail / Yahoo one-click unsubscribe | `POST /t/u/<message>` (RFC 8058) |
921
+ | iOS notification extension, service worker | `POST /t/d`, `POST /t/p` |
922
+
923
+ An alias has one URL and one auth type, and the tracking and one-click routes must stay public:
924
+
925
+ - `AWS_IAM` would break those routes. SigV4 also takes the `Authorization` header, so the bearer
926
+ could not travel with it.
927
+ - CloudFront's origin access control (OAC) does not help either: with OAC, every POST must carry
928
+ the body's SHA-256 in `x-amz-content-sha256`, and a mail provider's one-click POST does not.
929
+
930
+ Keep the routes public and protect the API in the app: the `API_TOKEN` bearer by default, or
931
+ your own `authorize`. Production refuses to serve without one of the two.
932
+
933
+ **Your own authorization.** `authorize(request, env)` replaces the bearer check on every API
934
+ route, in any format you choose: an HMAC over the body, a JWT, a header your proxy adds. It
935
+ receives a copy of the request, so it can read the body, and the env the host validated. True
936
+ lets the request through, false answers 401, and a throw answers 500 rather than letting the
937
+ request in. The tracking routes and `/health` never ask it. `bearerMatches` and
938
+ `timingSafeEqual` are exported for building one:
939
+
940
+ ```ts
941
+ import { journeyLambda, timingSafeEqual } from '@shware/workflow/aws';
942
+
943
+ journeyLambda({
944
+ // ...
945
+ authorize: async (request, env) =>
946
+ timingSafeEqual(
947
+ request.headers.get('x-signature') ?? '',
948
+ await hmac(env.SECRET, await request.text())
949
+ ),
950
+ });
951
+ ```
952
+
953
+ **Resource policy.** Grant `lambda:InvokeFunctionUrl` to everyone (`FunctionUrlAuthType: NONE`)
954
+ and `lambda:InvokeFunction` only with `lambda:InvokedViaFunctionUrl = true`, so the URL cannot
955
+ be used to invoke the function directly.
956
+
957
+ **Put CloudFront and WAF in front, without OAC.** A custom domain needs CloudFront (or another
958
+ HTTPS proxy) anyway; Route 53 alone cannot terminate TLS for a Function URL. Add a rate-based WAF
959
+ rule there: every request that reaches the function starts a durable execution and is billed,
960
+ even one the app answers 401. Reserved concurrency caps what a flood can cost.
961
+
962
+ **What a request costs.** Every Function URL request is a durable execution of its own:
963
+
964
+ | Execution | Durable ops | Per million |
965
+ | ------------------------------------------- | ----------- | ----------- |
966
+ | `GET` (pixel, redirect, `/health`), no step | 1 | about $9 |
967
+ | `POST` (an event, an identify), one step | 2 | about $17 |
968
+ | A plain, non-durable Lambda for comparison | - | about $1 |
969
+ | A journey: email, delay, one wake-up, email | 24 | about $190 |
970
+
971
+ Durable operations cost $8 per million, on top of Lambda's request and duration charges. That is
972
+ negligible at thousands of requests a month and worth a second, non-durable function for HTTP at
973
+ tens of millions. Server-side latency stays small: `/health` takes about 6 ms, a write 40–300 ms.
974
+
975
+ **Quotas.**
976
+
977
+ - Starting durable executions: 3,000 per second (6,000 in us-east-1, us-west-2 and eu-west-1).
978
+ HTTP requests and journey starts share it.
979
+ - 30,000 operations per execution, which cannot be raised. A wait attempt costs about five: the
980
+ callback plus four steps.
981
+ - 1 MB per operation's payload. Reads (`GET`, `HEAD`) are answered outside a step so their bodies
982
+ are not checkpointed.
983
+
984
+ **Timeouts and retention.**
985
+
986
+ - Set `DurableConfig.ExecutionTimeout` to the longest journey (waits, delays and the goal
987
+ window), up to 366 days, and pass the same value as `journeyLambda({ executionTimeoutMs })`: a
988
+ workflow that could outlive it fails at startup instead of being stopped midway.
989
+ - `RetentionPeriodInDays` (1–90) only keeps history after an execution ends. Keep `startRetryMs`
990
+ (12 hours) well under it.
991
+ - Keep `stopDrainMs` (15 minutes) at least the function's `Timeout`: a stop does not interrupt a
992
+ running invocation.
993
+
994
+ **Execution role.**
995
+
996
+ - `AWSLambdaBasicDurableExecutionRolePolicy` (checkpoints, state, logs).
997
+ - On `arn:...:function:<name>` and `arn:...:function:<name>:*`: `lambda:InvokeFunction`,
998
+ `lambda:SendDurableExecutionCallbackSuccess`, `lambda:GetDurableExecution`,
999
+ `lambda:StopDurableExecution` and `lambda:ListDurableExecutionsByFunction`. Durable execution
1000
+ ARNs carry the version, so an unqualified ARN alone matches nothing.
1001
+ - `ses:SendEmail` when sending through SES.
1002
+
1003
+ **Versions.**
1004
+
1005
+ - Publish numbered versions behind the `live` alias. `$LATEST` lets a paused execution resume on
1006
+ changed code.
1007
+ - Do not delete a version while executions are pinned to it.
1008
+ - Rolling the alias back does not roll the routes back: the newer bundle stays live until
1009
+ `POST /bundle/deploy` from the restored version.
1010
+
1011
+ **Emails.** The one-click unsubscribe header needs an https `WORKFLOW_ORIGIN`; over http no
1012
+ `List-Unsubscribe` is sent. Mailbox providers honour the header only when DKIM signs it, so check
1013
+ one real message for `dkim=pass` with `list-unsubscribe` in `h=`.
1014
+
1015
+ **Memory.** Size it for the app; the store is remote. APNs goes over the package's Node HTTP/2
1016
+ client, because Node 24's `fetch` speaks HTTP/1.1 only (see `src/http2-client.ts`).
890
1017
 
891
1018
  ## Postgres
892
1019
 
@@ -963,8 +1090,9 @@ On Postgres the engine also keeps:
963
1090
 
964
1091
  `handleRequest` serves the ingest API:
965
1092
 
966
- - `GET /health` is the only unauthenticated one. The bearer scheme is matched
967
- case-insensitively and compared in constant time.
1093
+ - `GET /health` is the only unauthenticated one. Every other route asks the
1094
+ host's `authorize`, or without one the `API_TOKEN` bearer: its scheme is
1095
+ matched case-insensitively and the token compared in constant time.
968
1096
  - `POST /events` takes `{ userId, event, payload?, ts?, id? }` and answers with
969
1097
  the id the occurrence was recorded under. Event names beginning with `$` are
970
1098
  reserved for internal use. `ts` is epoch milliseconds: a non-negative integer
package/dist/apns.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_engine_retry = require("./engine/retry.cjs");
3
3
  const require_jwt = require("./jwt.cjs");
4
+ let node_buffer = require("node:buffer");
4
5
  //#region src/apns.ts
5
6
  const APNS_HOSTS = {
6
7
  sandbox: "https://api.sandbox.push.apple.com",
@@ -49,7 +50,7 @@ async function apnsIdFromKey(idempotencyKey) {
49
50
  const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(idempotencyKey))).slice(0, 16);
50
51
  bytes[6] = bytes[6] & 15 | 128;
51
52
  bytes[8] = bytes[8] & 63 | 128;
52
- const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
53
+ const hex = node_buffer.Buffer.from(bytes).toString("hex");
53
54
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
54
55
  }
55
56
  /** Apple: reuse a provider token for at least 20 minutes, never past 60. */
@@ -188,7 +189,7 @@ async function signApnsJwt(key, nowMs = Date.now()) {
188
189
  function apnsKeyFromBase64(value) {
189
190
  let pem;
190
191
  try {
191
- pem = atob(value);
192
+ pem = node_buffer.Buffer.from(value, "base64").toString();
192
193
  } catch (error) {
193
194
  throw new require_engine_retry.PermanentError("APNs key is not base64 (expected the .p8 file, base64-encoded)", { cause: error });
194
195
  }
package/dist/apns.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"apns.cjs","names":["PermanentError","isRetryableStatus","base64url","pemToDer"],"sources":["../src/apns.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/apns` — iOS push delivery straight to the Apple Push\n * Notification service, token-based (an APNs auth key, the `.p8` file from the\n * developer portal). No Firebase in between: the app registers the APNs device\n * token in its own device table, and the host's DeviceDirectory hands it to the\n * engine, which sends one message per device.\n * No dependencies: the provider JWT is ES256-signed with WebCrypto.\n *\n * APNs speaks HTTP/2 only. A deployed Worker's `fetch` negotiates it, but\n * workerd under `wrangler dev` does not (the connection is dropped), so a local\n * host points `origin` at the Node relay in `@shware/workflow/apns-relay`.\n */\n\n/** The APNs auth key — the `.p8` file from the developer portal, and the ids that name it. */\nexport interface ApnsProviderTokenOptions {\n /** The key's 10-character id, shown next to it in the developer portal. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */\n privateKey: string;\n}\n\nexport interface ApnsPushOptions {\n /** The provider token the requests are signed with — shared by every sender on the same key. */\n credentials: ApnsProviderToken;\n /** The app's bundle id — APNs's `apns-topic`. */\n topic: string;\n /**\n * Which APNs environment the device tokens come from: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. A token from one is\n * `BadDeviceToken` on the other.\n */\n environment: 'sandbox' | 'production';\n render: PushRenderer;\n /**\n * POST here instead of Apple's host — the dev relay, which forwards over\n * HTTP/2. The real host still travels along (`apns-relay-upstream`), so the\n * relay needs no configuration of its own.\n */\n origin?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport const APNS_HOSTS = {\n sandbox: 'https://api.sandbox.push.apple.com',\n production: 'https://api.push.apple.com',\n} as const;\n\n/**\n * How an APNs answer is classified, after Apple's response table\n * (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):\n *\n * - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the\n * topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles\n * undelivered and the host forgets the token.\n * - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but\n * just as well for a token of the other environment (a sandbox build pushed through the\n * production host), which would answer for every device at once; the copy settles with the\n * reason, so the report shows it, and nothing is deleted on a guess.\n * - configuration: the topic, certificate or provider key is wrong. Every device on this app\n * would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,\n * the deployment is not.\n * - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,\n * `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.\n * - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,\n * 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['Unregistered', 'ExpiredToken']);\n\n/** The app's topic, certificate or provider key is wrong: the request is refused for every device. */\nconst CONFIGURATION_REASONS: ReadonlySet<string> = new Set([\n 'DeviceTokenNotForTopic',\n 'TopicDisallowed',\n 'BadTopic',\n 'MissingTopic',\n 'BadCertificate',\n 'BadCertificateEnvironment',\n 'Forbidden',\n 'InvalidProviderToken',\n 'MissingProviderToken',\n]);\n\n/**\n * The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the\n * first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the\n * same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —\n * collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose\n * the node id at its end, and two nodes of one instance would collapse into one notification.\n */\nexport async function apnsIdFromKey(idempotencyKey: string): Promise<string> {\n const digest = new Uint8Array(\n await crypto.subtle.digest('SHA-256', new TextEncoder().encode(idempotencyKey))\n );\n const bytes = digest.slice(0, 16);\n bytes[6] = (bytes[6] & 0x0f) | 0x80; // version 8: custom, deterministic\n bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\n/** Apple: reuse a provider token for at least 20 minutes, never past 60. */\nconst TOKEN_TTL_MS = 50 * 60_000;\n\n/**\n * The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple\n * answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent\n * callers on a fresh or aged token share the one signing in flight, and a signing that failed is\n * not kept, so the next call tries again. One instance serves every sender built on the key — a\n * host keeps it across runs, and builds the senders per run.\n */\nexport class ApnsProviderToken {\n private signed: { value: string; issuedAt: number } | undefined;\n private signing: Promise<string> | undefined;\n\n constructor(private readonly options: ApnsProviderTokenOptions) {}\n\n /** The current token, signed now if there is none or it has aged out. */\n async token(now = Date.now()): Promise<string> {\n if (this.signed !== undefined && now - this.signed.issuedAt < TOKEN_TTL_MS) {\n return this.signed.value;\n }\n this.signing ??= signApnsJwt(this.options, now)\n .then((value) => {\n this.signed = { value, issuedAt: now };\n return value;\n })\n .finally(() => {\n this.signing = undefined;\n });\n return this.signing;\n }\n\n /** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */\n invalidate(): void {\n this.signed = undefined;\n }\n}\n\nexport class ApnsPushSender implements MessageSender {\n constructor(private readonly options: ApnsPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`ApnsPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`ApnsPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n // A notification service extension runs only for a mutable push: it attaches the image and\n // reports the device delivery (`delivered_url`)\n const mutable = image !== undefined || message.tracking?.data?.delivered_url !== undefined;\n const upstream = APNS_HOSTS[this.options.environment];\n const origin = this.options.origin ?? upstream;\n const doFetch = this.options.fetch ?? fetch;\n\n const copyId = await apnsIdFromKey(message.idempotencyKey);\n const response = await doFetch(`${origin}/3/device/${message.recipient}`, {\n method: 'POST',\n headers: {\n authorization: `bearer ${await this.options.credentials.token()}`,\n 'apns-topic': this.options.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': '10',\n // A resend of the same copy (an at-least-once retry after APNs accepted the first attempt)\n // carries the same id and collapses onto the first notification instead of showing twice\n 'apns-id': copyId,\n 'apns-collapse-id': copyId,\n 'content-type': 'application/json',\n ...(this.options.origin === undefined ? {} : { 'apns-relay-upstream': upstream }),\n },\n body: JSON.stringify({\n aps: {\n alert: { title, body },\n sound: 'default',\n ...(mutable ? { 'mutable-content': 1 } : {}),\n },\n // The template key travels so the app can route a tap; the idempotency\n // key lets the app de-duplicate a redelivered push.\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(image === undefined ? {} : { image }),\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n }),\n });\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n // A 410 without a body is still \"unregistered\": settle it under that reason\n const reason = apnsReason(text) || (status === 410 ? 'Unregistered' : '');\n if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {\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(`[apns] token gone for user ${message.userId}: ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'BadDeviceToken') {\n // The token or the environment: settled so the report says which device, nothing deleted\n console.warn(`[apns] refused for user ${message.userId}: ${status} ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'ExpiredProviderToken') {\n // The token aged out (clock skew, a long-lived isolate): sign a new one on the retry\n this.options.credentials.invalidate();\n throw new Error(\n `APNs send failed: ${status} ${reason} (provider token renewed for the retry)`\n );\n }\n if (CONFIGURATION_REASONS.has(reason)) {\n throw new PermanentError(\n `APNs refused the request for topic '${this.options.topic}' (${this.options.environment}): ${status} ${reason}`\n );\n }\n if (isRetryableStatus(status)) {\n throw new Error(`APNs send failed: ${status} ${reason || text}`);\n }\n throw new PermanentError(`APNs refused the request: ${status} ${reason || text}`);\n }\n}\n\n/** APNs error bodies are `{ \"reason\": \"…\" }`. */\nexport function apnsReason(body: string): string {\n try {\n const parsed = JSON.parse(body) as { reason?: string };\n return parsed.reason ?? '';\n } catch {\n return '';\n }\n}\n\n/** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */\nexport async function signApnsJwt(\n key: ApnsProviderTokenOptions,\n nowMs = Date.now()\n): Promise<string> {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: key.keyId }));\n const claims = base64url(JSON.stringify({ iss: key.teamId, iat: Math.floor(nowMs / 1000) }));\n const cryptoKey = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(key.privateKey),\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n );\n // WebCrypto's ECDSA signature is the raw r‖s pair, which is exactly JWS's ES256 encoding.\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n cryptoKey,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** The `.p8` as it is usually shipped in a secret: base64 of the file. */\nexport function apnsKeyFromBase64(value: string): string {\n let pem: string;\n try {\n pem = atob(value);\n } catch (error) {\n // A secret that is not base64 is the same on every attempt: not worth a retry\n throw new PermanentError('APNs key is not base64 (expected the .p8 file, base64-encoded)', {\n cause: error,\n });\n }\n if (!pem.includes('-----BEGIN PRIVATE KEY-----')) {\n throw new PermanentError('APNs key is not a PKCS #8 PEM (.p8) file');\n }\n return pem;\n}\n"],"mappings":";;;;AAmDA,MAAa,aAAa;CACxB,SAAS;CACT,YAAY;AACd;;;;;;;;;;;;;;;;;;;;AAqBA,MAAa,qCAA0C,IAAI,IAAI,CAAC,gBAAgB,cAAc,CAAC;;AAG/F,MAAM,wCAA6C,IAAI,IAAI;CACzD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;;;AASD,eAAsB,cAAc,gBAAyC;CAI3E,MAAM,QAAQ,IAHK,WACjB,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI,YAAY,CAAC,CAAC,OAAO,cAAc,CAAC,CAE7D,CAAC,CAAC,MAAM,GAAG,EAAE;CAChC,MAAM,KAAM,MAAM,KAAK,KAAQ;CAC/B,MAAM,KAAM,MAAM,KAAK,KAAQ;CAC/B,MAAM,MAAM,MAAM,KAAK,QAAQ,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;CAC7E,OAAO,GAAG,IAAI,MAAM,GAAG,CAAC,EAAE,GAAG,IAAI,MAAM,GAAG,EAAE,EAAE,GAAG,IAAI,MAAM,IAAI,EAAE,EAAE,GAAG,IAAI,MAAM,IAAI,EAAE,EAAE,GAAG,IAAI,MAAM,EAAE;AACzG;;AAGA,MAAM,eAAe;;;;;;;;AASrB,IAAa,oBAAb,MAA+B;CAIA;CAH7B;CACA;CAEA,YAAY,SAAoD;EAAnC,KAAA,UAAA;CAAoC;;CAGjE,MAAM,MAAM,MAAM,KAAK,IAAI,GAAoB;EAC7C,IAAI,KAAK,WAAW,KAAA,KAAa,MAAM,KAAK,OAAO,WAAW,cAC5D,OAAO,KAAK,OAAO;EAErB,KAAK,YAAY,YAAY,KAAK,SAAS,GAAG,CAAC,CAC5C,MAAM,UAAU;GACf,KAAK,SAAS;IAAE;IAAO,UAAU;GAAI;GACrC,OAAO;EACT,CAAC,CAAC,CACD,cAAc;GACb,KAAK,UAAU,KAAA;EACjB,CAAC;EACH,OAAO,KAAK;CACd;;CAGA,aAAmB;EACjB,KAAK,SAAS,KAAA;CAChB;AACF;AAEA,IAAa,iBAAb,MAAqD;CACtB;CAA7B,YAAY,SAA2C;EAA1B,KAAA,UAAA;CAA2B;CAExD,MAAM,KAAK,SAAsD;EAC/D,IAAI,QAAQ,YAAY,QACtB,MAAM,IAAI,MAAM,wCAAwC,QAAQ,QAAQ,EAAE;EAE5E,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,2CAA2C,QAAQ,OAAO,EAAE;EAE9E,MAAM,EAAE,OAAO,MAAM,OAAO,QAAQ,MAAM,KAAK,QAAQ,OAAO,QAAQ,UAAU,QAAQ,KAAK;EAG7F,MAAM,UAAU,UAAU,KAAA,KAAa,QAAQ,UAAU,MAAM,kBAAkB,KAAA;EACjF,MAAM,WAAW,WAAW,KAAK,QAAQ;EACzC,MAAM,SAAS,KAAK,QAAQ,UAAU;EACtC,MAAM,UAAU,KAAK,QAAQ,SAAS;EAEtC,MAAM,SAAS,MAAM,cAAc,QAAQ,cAAc;EACzD,MAAM,WAAW,MAAM,QAAQ,GAAG,OAAO,YAAY,QAAQ,aAAa;GACxE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU,MAAM,KAAK,QAAQ,YAAY,MAAM;IAC9D,cAAc,KAAK,QAAQ;IAC3B,kBAAkB;IAClB,iBAAiB;IAGjB,WAAW;IACX,oBAAoB;IACpB,gBAAgB;IAChB,GAAI,KAAK,QAAQ,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,uBAAuB,SAAS;GACjF;GACA,MAAM,KAAK,UAAU;IACnB,KAAK;KACH,OAAO;MAAE;MAAO;KAAK;KACrB,OAAO;KACP,GAAI,UAAU,EAAE,mBAAmB,EAAE,IAAI,CAAC;IAC5C;IAGA,UAAU,QAAQ;IAClB,gBAAgB,QAAQ;IACxB,GAAI,UAAU,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM;IACvC,GAAI,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,IAAI;IACnC,GAAG,QAAQ,UAAU;GACvB,CAAC;EACH,CAAC;EACD,IAAI,SAAS,IAAI;EAEjB,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,MAAM,EAAE,WAAW;EAEnB,MAAM,SAAS,WAAW,IAAI,MAAM,WAAW,MAAM,iBAAiB;EACtE,IAAI,WAAW,OAAO,mBAAmB,IAAI,MAAM,GAAG;GAGpD,QAAQ,KAAK,8BAA8B,QAAQ,OAAO,IAAI,QAAQ;GACtE,OAAO;IAAE,WAAW;IAAO;GAAO;EACpC;EACA,IAAI,WAAW,kBAAkB;GAE/B,QAAQ,KAAK,2BAA2B,QAAQ,OAAO,IAAI,OAAO,GAAG,QAAQ;GAC7E,OAAO;IAAE,WAAW;IAAO;GAAO;EACpC;EACA,IAAI,WAAW,wBAAwB;GAErC,KAAK,QAAQ,YAAY,WAAW;GACpC,MAAM,IAAI,MACR,qBAAqB,OAAO,GAAG,OAAO,wCACxC;EACF;EACA,IAAI,sBAAsB,IAAI,MAAM,GAClC,MAAM,IAAIA,qBAAAA,eACR,uCAAuC,KAAK,QAAQ,MAAM,KAAK,KAAK,QAAQ,YAAY,KAAK,OAAO,GAAG,QACzG;EAEF,IAAIC,qBAAAA,kBAAkB,MAAM,GAC1B,MAAM,IAAI,MAAM,qBAAqB,OAAO,GAAG,UAAU,MAAM;EAEjE,MAAM,IAAID,qBAAAA,eAAe,6BAA6B,OAAO,GAAG,UAAU,MAAM;CAClF;AACF;;AAGA,SAAgB,WAAW,MAAsB;CAC/C,IAAI;EAEF,OADe,KAAK,MAAM,IACd,CAAC,CAAC,UAAU;CAC1B,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,eAAsB,YACpB,KACA,QAAQ,KAAK,IAAI,GACA;CACjB,MAAM,SAASE,YAAAA,UAAU,KAAK,UAAU;EAAE,KAAK;EAAS,KAAK,IAAI;CAAM,CAAC,CAAC;CACzE,MAAM,SAASA,YAAAA,UAAU,KAAK,UAAU;EAAE,KAAK,IAAI;EAAQ,KAAK,KAAK,MAAM,QAAQ,GAAI;CAAE,CAAC,CAAC;CAC3F,MAAM,YAAY,MAAM,OAAO,OAAO,UACpC,SACAC,YAAAA,SAAS,IAAI,UAAU,GACvB;EAAE,MAAM;EAAS,YAAY;CAAQ,GACrC,OACA,CAAC,MAAM,CACT;CAEA,MAAM,YAAY,MAAM,OAAO,OAAO,KACpC;EAAE,MAAM;EAAS,MAAM;CAAU,GACjC,WACA,IAAI,YAAY,CAAC,CAAC,OAAO,GAAG,OAAO,GAAG,QAAQ,CAChD;CACA,OAAO,GAAG,OAAO,GAAG,OAAO,GAAGD,YAAAA,UAAU,IAAI,WAAW,SAAS,CAAC;AACnE;;AAGA,SAAgB,kBAAkB,OAAuB;CACvD,IAAI;CACJ,IAAI;EACF,MAAM,KAAK,KAAK;CAClB,SAAS,OAAO;EAEd,MAAM,IAAIF,qBAAAA,eAAe,kEAAkE,EACzF,OAAO,MACT,CAAC;CACH;CACA,IAAI,CAAC,IAAI,SAAS,6BAA6B,GAC7C,MAAM,IAAIA,qBAAAA,eAAe,0CAA0C;CAErE,OAAO;AACT"}
1
+ {"version":3,"file":"apns.cjs","names":["Buffer","PermanentError","isRetryableStatus","base64url","pemToDer"],"sources":["../src/apns.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport 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/apns` — iOS push delivery straight to the Apple Push\n * Notification service, token-based (an APNs auth key, the `.p8` file from the\n * developer portal). No Firebase in between: the app registers the APNs device\n * token in its own device table, and the host's DeviceDirectory hands it to the\n * engine, which sends one message per device.\n * No dependencies: the provider JWT is ES256-signed with WebCrypto.\n *\n * APNs speaks HTTP/2 only. A deployed Worker's `fetch` negotiates it, but\n * workerd under `wrangler dev` does not (the connection is dropped), so a local\n * host points `origin` at the Node relay in `@shware/workflow/apns-relay`.\n */\n\n/** The APNs auth key — the `.p8` file from the developer portal, and the ids that name it. */\nexport interface ApnsProviderTokenOptions {\n /** The key's 10-character id, shown next to it in the developer portal. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */\n privateKey: string;\n}\n\nexport interface ApnsPushOptions {\n /** The provider token the requests are signed with — shared by every sender on the same key. */\n credentials: ApnsProviderToken;\n /** The app's bundle id — APNs's `apns-topic`. */\n topic: string;\n /**\n * Which APNs environment the device tokens come from: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. A token from one is\n * `BadDeviceToken` on the other.\n */\n environment: 'sandbox' | 'production';\n render: PushRenderer;\n /**\n * POST here instead of Apple's host — the dev relay, which forwards over\n * HTTP/2. The real host still travels along (`apns-relay-upstream`), so the\n * relay needs no configuration of its own.\n */\n origin?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport const APNS_HOSTS = {\n sandbox: 'https://api.sandbox.push.apple.com',\n production: 'https://api.push.apple.com',\n} as const;\n\n/**\n * How an APNs answer is classified, after Apple's response table\n * (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):\n *\n * - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the\n * topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles\n * undelivered and the host forgets the token.\n * - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but\n * just as well for a token of the other environment (a sandbox build pushed through the\n * production host), which would answer for every device at once; the copy settles with the\n * reason, so the report shows it, and nothing is deleted on a guess.\n * - configuration: the topic, certificate or provider key is wrong. Every device on this app\n * would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,\n * the deployment is not.\n * - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,\n * `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.\n * - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,\n * 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['Unregistered', 'ExpiredToken']);\n\n/** The app's topic, certificate or provider key is wrong: the request is refused for every device. */\nconst CONFIGURATION_REASONS: ReadonlySet<string> = new Set([\n 'DeviceTokenNotForTopic',\n 'TopicDisallowed',\n 'BadTopic',\n 'MissingTopic',\n 'BadCertificate',\n 'BadCertificateEnvironment',\n 'Forbidden',\n 'InvalidProviderToken',\n 'MissingProviderToken',\n]);\n\n/**\n * The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the\n * first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the\n * same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —\n * collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose\n * the node id at its end, and two nodes of one instance would collapse into one notification.\n */\nexport async function apnsIdFromKey(idempotencyKey: string): Promise<string> {\n const digest = new Uint8Array(\n await crypto.subtle.digest('SHA-256', new TextEncoder().encode(idempotencyKey))\n );\n const bytes = digest.slice(0, 16);\n bytes[6] = (bytes[6] & 0x0f) | 0x80; // version 8: custom, deterministic\n bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant\n const hex = Buffer.from(bytes).toString('hex');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\n/** Apple: reuse a provider token for at least 20 minutes, never past 60. */\nconst TOKEN_TTL_MS = 50 * 60_000;\n\n/**\n * The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple\n * answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent\n * callers on a fresh or aged token share the one signing in flight, and a signing that failed is\n * not kept, so the next call tries again. One instance serves every sender built on the key — a\n * host keeps it across runs, and builds the senders per run.\n */\nexport class ApnsProviderToken {\n private signed: { value: string; issuedAt: number } | undefined;\n private signing: Promise<string> | undefined;\n\n constructor(private readonly options: ApnsProviderTokenOptions) {}\n\n /** The current token, signed now if there is none or it has aged out. */\n async token(now = Date.now()): Promise<string> {\n if (this.signed !== undefined && now - this.signed.issuedAt < TOKEN_TTL_MS) {\n return this.signed.value;\n }\n this.signing ??= signApnsJwt(this.options, now)\n .then((value) => {\n this.signed = { value, issuedAt: now };\n return value;\n })\n .finally(() => {\n this.signing = undefined;\n });\n return this.signing;\n }\n\n /** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */\n invalidate(): void {\n this.signed = undefined;\n }\n}\n\nexport class ApnsPushSender implements MessageSender {\n constructor(private readonly options: ApnsPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`ApnsPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`ApnsPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n // A notification service extension runs only for a mutable push: it attaches the image and\n // reports the device delivery (`delivered_url`)\n const mutable = image !== undefined || message.tracking?.data?.delivered_url !== undefined;\n const upstream = APNS_HOSTS[this.options.environment];\n const origin = this.options.origin ?? upstream;\n const doFetch = this.options.fetch ?? fetch;\n\n const copyId = await apnsIdFromKey(message.idempotencyKey);\n const response = await doFetch(`${origin}/3/device/${message.recipient}`, {\n method: 'POST',\n headers: {\n authorization: `bearer ${await this.options.credentials.token()}`,\n 'apns-topic': this.options.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': '10',\n // A resend of the same copy (an at-least-once retry after APNs accepted the first attempt)\n // carries the same id and collapses onto the first notification instead of showing twice\n 'apns-id': copyId,\n 'apns-collapse-id': copyId,\n 'content-type': 'application/json',\n ...(this.options.origin === undefined ? {} : { 'apns-relay-upstream': upstream }),\n },\n body: JSON.stringify({\n aps: {\n alert: { title, body },\n sound: 'default',\n ...(mutable ? { 'mutable-content': 1 } : {}),\n },\n // The template key travels so the app can route a tap; the idempotency\n // key lets the app de-duplicate a redelivered push.\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(image === undefined ? {} : { image }),\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n }),\n });\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n // A 410 without a body is still \"unregistered\": settle it under that reason\n const reason = apnsReason(text) || (status === 410 ? 'Unregistered' : '');\n if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {\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(`[apns] token gone for user ${message.userId}: ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'BadDeviceToken') {\n // The token or the environment: settled so the report says which device, nothing deleted\n console.warn(`[apns] refused for user ${message.userId}: ${status} ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'ExpiredProviderToken') {\n // The token aged out (clock skew, a long-lived isolate): sign a new one on the retry\n this.options.credentials.invalidate();\n throw new Error(\n `APNs send failed: ${status} ${reason} (provider token renewed for the retry)`\n );\n }\n if (CONFIGURATION_REASONS.has(reason)) {\n throw new PermanentError(\n `APNs refused the request for topic '${this.options.topic}' (${this.options.environment}): ${status} ${reason}`\n );\n }\n if (isRetryableStatus(status)) {\n throw new Error(`APNs send failed: ${status} ${reason || text}`);\n }\n throw new PermanentError(`APNs refused the request: ${status} ${reason || text}`);\n }\n}\n\n/** APNs error bodies are `{ \"reason\": \"…\" }`. */\nexport function apnsReason(body: string): string {\n try {\n const parsed = JSON.parse(body) as { reason?: string };\n return parsed.reason ?? '';\n } catch {\n return '';\n }\n}\n\n/** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */\nexport async function signApnsJwt(\n key: ApnsProviderTokenOptions,\n nowMs = Date.now()\n): Promise<string> {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: key.keyId }));\n const claims = base64url(JSON.stringify({ iss: key.teamId, iat: Math.floor(nowMs / 1000) }));\n const cryptoKey = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(key.privateKey),\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n );\n // WebCrypto's ECDSA signature is the raw r‖s pair, which is exactly JWS's ES256 encoding.\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n cryptoKey,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** The `.p8` as it is usually shipped in a secret: base64 of the file. */\nexport function apnsKeyFromBase64(value: string): string {\n let pem: string;\n try {\n pem = Buffer.from(value, 'base64').toString();\n } catch (error) {\n // A secret that is not base64 is the same on every attempt: not worth a retry\n throw new PermanentError('APNs key is not base64 (expected the .p8 file, base64-encoded)', {\n cause: error,\n });\n }\n if (!pem.includes('-----BEGIN PRIVATE KEY-----')) {\n throw new PermanentError('APNs key is not a PKCS #8 PEM (.p8) file');\n }\n return pem;\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"apns.d.cts","names":[],"sources":["../src/apns.ts"],"mappings":";;;;;;;;;;;;;;;;;iBAmBiB;;EAEf;;EAEA;;EAEA;;iBAGe;;EAEf,aAAa;;EAEb;;;;;;;EAOA;EACA,QAAQ;;;;;;EAMR;;EAEA,eAAe;;qBAGJ;WACX;WACA;;;;;;;;;;;;;;;;;;;;;qBAsBW,oBAAoB;;;;;;;;wBAsBX,cAAc,yBAAyB;;;;;;;;qBAqBhD;mBAIkB;UAHrB;UACA;EAER,YAA6B,SAAS;;EAGhC,MAAM,eAAmB;;EAgB/B;;qBAKW,0BAA0B;mBACR;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;wBAkFvC,WAAW;;wBAUL,YACpB,KAAK,0BACL,iBACC;;wBAoBa,kBAAkB"}
1
+ {"version":3,"file":"apns.d.cts","names":[],"sources":["../src/apns.ts"],"mappings":";;;;;;;;;;;;;;;;;iBAoBiB;;EAEf;;EAEA;;EAEA;;iBAGe;;EAEf,aAAa;;EAEb;;;;;;;EAOA;EACA,QAAQ;;;;;;EAMR;;EAEA,eAAe;;qBAGJ;WACX;WACA;;;;;;;;;;;;;;;;;;;;;qBAsBW,oBAAoB;;;;;;;;wBAsBX,cAAc,yBAAyB;;;;;;;;qBAqBhD;mBAIkB;UAHrB;UACA;EAER,YAA6B,SAAS;;EAGhC,MAAM,eAAmB;;EAgB/B;;qBAKW,0BAA0B;mBACR;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;wBAkFvC,WAAW;;wBAUL,YACpB,KAAK,0BACL,iBACC;;wBAoBa,kBAAkB"}
@@ -1 +1 @@
1
- {"version":3,"file":"apns.d.mts","names":[],"sources":["../src/apns.ts"],"mappings":";;;;;;;;;;;;;;;;;iBAmBiB;;EAEf;;EAEA;;EAEA;;iBAGe;;EAEf,aAAa;;EAEb;;;;;;;EAOA;EACA,QAAQ;;;;;;EAMR;;EAEA,eAAe;;qBAGJ;WACX;WACA;;;;;;;;;;;;;;;;;;;;;qBAsBW,oBAAoB;;;;;;;;wBAsBX,cAAc,yBAAyB;;;;;;;;qBAqBhD;mBAIkB;UAHrB;UACA;EAER,YAA6B,SAAS;;EAGhC,MAAM,eAAmB;;EAgB/B;;qBAKW,0BAA0B;mBACR;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;wBAkFvC,WAAW;;wBAUL,YACpB,KAAK,0BACL,iBACC;;wBAoBa,kBAAkB"}
1
+ {"version":3,"file":"apns.d.mts","names":[],"sources":["../src/apns.ts"],"mappings":";;;;;;;;;;;;;;;;;iBAoBiB;;EAEf;;EAEA;;EAEA;;iBAGe;;EAEf,aAAa;;EAEb;;;;;;;EAOA;EACA,QAAQ;;;;;;EAMR;;EAEA,eAAe;;qBAGJ;WACX;WACA;;;;;;;;;;;;;;;;;;;;;qBAsBW,oBAAoB;;;;;;;;wBAsBX,cAAc,yBAAyB;;;;;;;;qBAqBhD;mBAIkB;UAHrB;UACA;EAER,YAA6B,SAAS;;EAGhC,MAAM,eAAmB;;EAgB/B;;qBAKW,0BAA0B;mBACR;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;wBAkFvC,WAAW;;wBAUL,YACpB,KAAK,0BACL,iBACC;;wBAoBa,kBAAkB"}
package/dist/apns.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { PermanentError, isRetryableStatus } from "./engine/retry.mjs";
2
2
  import { base64url, pemToDer } from "./jwt.mjs";
3
+ import { Buffer } from "node:buffer";
3
4
  //#region src/apns.ts
4
5
  const APNS_HOSTS = {
5
6
  sandbox: "https://api.sandbox.push.apple.com",
@@ -48,7 +49,7 @@ async function apnsIdFromKey(idempotencyKey) {
48
49
  const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(idempotencyKey))).slice(0, 16);
49
50
  bytes[6] = bytes[6] & 15 | 128;
50
51
  bytes[8] = bytes[8] & 63 | 128;
51
- const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
52
+ const hex = Buffer.from(bytes).toString("hex");
52
53
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
53
54
  }
54
55
  /** Apple: reuse a provider token for at least 20 minutes, never past 60. */
@@ -187,7 +188,7 @@ async function signApnsJwt(key, nowMs = Date.now()) {
187
188
  function apnsKeyFromBase64(value) {
188
189
  let pem;
189
190
  try {
190
- pem = atob(value);
191
+ pem = Buffer.from(value, "base64").toString();
191
192
  } catch (error) {
192
193
  throw new PermanentError("APNs key is not base64 (expected the .p8 file, base64-encoded)", { cause: error });
193
194
  }
package/dist/apns.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"apns.mjs","names":[],"sources":["../src/apns.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/apns` — iOS push delivery straight to the Apple Push\n * Notification service, token-based (an APNs auth key, the `.p8` file from the\n * developer portal). No Firebase in between: the app registers the APNs device\n * token in its own device table, and the host's DeviceDirectory hands it to the\n * engine, which sends one message per device.\n * No dependencies: the provider JWT is ES256-signed with WebCrypto.\n *\n * APNs speaks HTTP/2 only. A deployed Worker's `fetch` negotiates it, but\n * workerd under `wrangler dev` does not (the connection is dropped), so a local\n * host points `origin` at the Node relay in `@shware/workflow/apns-relay`.\n */\n\n/** The APNs auth key — the `.p8` file from the developer portal, and the ids that name it. */\nexport interface ApnsProviderTokenOptions {\n /** The key's 10-character id, shown next to it in the developer portal. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */\n privateKey: string;\n}\n\nexport interface ApnsPushOptions {\n /** The provider token the requests are signed with — shared by every sender on the same key. */\n credentials: ApnsProviderToken;\n /** The app's bundle id — APNs's `apns-topic`. */\n topic: string;\n /**\n * Which APNs environment the device tokens come from: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. A token from one is\n * `BadDeviceToken` on the other.\n */\n environment: 'sandbox' | 'production';\n render: PushRenderer;\n /**\n * POST here instead of Apple's host — the dev relay, which forwards over\n * HTTP/2. The real host still travels along (`apns-relay-upstream`), so the\n * relay needs no configuration of its own.\n */\n origin?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport const APNS_HOSTS = {\n sandbox: 'https://api.sandbox.push.apple.com',\n production: 'https://api.push.apple.com',\n} as const;\n\n/**\n * How an APNs answer is classified, after Apple's response table\n * (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):\n *\n * - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the\n * topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles\n * undelivered and the host forgets the token.\n * - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but\n * just as well for a token of the other environment (a sandbox build pushed through the\n * production host), which would answer for every device at once; the copy settles with the\n * reason, so the report shows it, and nothing is deleted on a guess.\n * - configuration: the topic, certificate or provider key is wrong. Every device on this app\n * would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,\n * the deployment is not.\n * - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,\n * `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.\n * - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,\n * 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['Unregistered', 'ExpiredToken']);\n\n/** The app's topic, certificate or provider key is wrong: the request is refused for every device. */\nconst CONFIGURATION_REASONS: ReadonlySet<string> = new Set([\n 'DeviceTokenNotForTopic',\n 'TopicDisallowed',\n 'BadTopic',\n 'MissingTopic',\n 'BadCertificate',\n 'BadCertificateEnvironment',\n 'Forbidden',\n 'InvalidProviderToken',\n 'MissingProviderToken',\n]);\n\n/**\n * The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the\n * first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the\n * same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —\n * collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose\n * the node id at its end, and two nodes of one instance would collapse into one notification.\n */\nexport async function apnsIdFromKey(idempotencyKey: string): Promise<string> {\n const digest = new Uint8Array(\n await crypto.subtle.digest('SHA-256', new TextEncoder().encode(idempotencyKey))\n );\n const bytes = digest.slice(0, 16);\n bytes[6] = (bytes[6] & 0x0f) | 0x80; // version 8: custom, deterministic\n bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\n/** Apple: reuse a provider token for at least 20 minutes, never past 60. */\nconst TOKEN_TTL_MS = 50 * 60_000;\n\n/**\n * The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple\n * answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent\n * callers on a fresh or aged token share the one signing in flight, and a signing that failed is\n * not kept, so the next call tries again. One instance serves every sender built on the key — a\n * host keeps it across runs, and builds the senders per run.\n */\nexport class ApnsProviderToken {\n private signed: { value: string; issuedAt: number } | undefined;\n private signing: Promise<string> | undefined;\n\n constructor(private readonly options: ApnsProviderTokenOptions) {}\n\n /** The current token, signed now if there is none or it has aged out. */\n async token(now = Date.now()): Promise<string> {\n if (this.signed !== undefined && now - this.signed.issuedAt < TOKEN_TTL_MS) {\n return this.signed.value;\n }\n this.signing ??= signApnsJwt(this.options, now)\n .then((value) => {\n this.signed = { value, issuedAt: now };\n return value;\n })\n .finally(() => {\n this.signing = undefined;\n });\n return this.signing;\n }\n\n /** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */\n invalidate(): void {\n this.signed = undefined;\n }\n}\n\nexport class ApnsPushSender implements MessageSender {\n constructor(private readonly options: ApnsPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`ApnsPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`ApnsPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n // A notification service extension runs only for a mutable push: it attaches the image and\n // reports the device delivery (`delivered_url`)\n const mutable = image !== undefined || message.tracking?.data?.delivered_url !== undefined;\n const upstream = APNS_HOSTS[this.options.environment];\n const origin = this.options.origin ?? upstream;\n const doFetch = this.options.fetch ?? fetch;\n\n const copyId = await apnsIdFromKey(message.idempotencyKey);\n const response = await doFetch(`${origin}/3/device/${message.recipient}`, {\n method: 'POST',\n headers: {\n authorization: `bearer ${await this.options.credentials.token()}`,\n 'apns-topic': this.options.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': '10',\n // A resend of the same copy (an at-least-once retry after APNs accepted the first attempt)\n // carries the same id and collapses onto the first notification instead of showing twice\n 'apns-id': copyId,\n 'apns-collapse-id': copyId,\n 'content-type': 'application/json',\n ...(this.options.origin === undefined ? {} : { 'apns-relay-upstream': upstream }),\n },\n body: JSON.stringify({\n aps: {\n alert: { title, body },\n sound: 'default',\n ...(mutable ? { 'mutable-content': 1 } : {}),\n },\n // The template key travels so the app can route a tap; the idempotency\n // key lets the app de-duplicate a redelivered push.\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(image === undefined ? {} : { image }),\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n }),\n });\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n // A 410 without a body is still \"unregistered\": settle it under that reason\n const reason = apnsReason(text) || (status === 410 ? 'Unregistered' : '');\n if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {\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(`[apns] token gone for user ${message.userId}: ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'BadDeviceToken') {\n // The token or the environment: settled so the report says which device, nothing deleted\n console.warn(`[apns] refused for user ${message.userId}: ${status} ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'ExpiredProviderToken') {\n // The token aged out (clock skew, a long-lived isolate): sign a new one on the retry\n this.options.credentials.invalidate();\n throw new Error(\n `APNs send failed: ${status} ${reason} (provider token renewed for the retry)`\n );\n }\n if (CONFIGURATION_REASONS.has(reason)) {\n throw new PermanentError(\n `APNs refused the request for topic '${this.options.topic}' (${this.options.environment}): ${status} ${reason}`\n );\n }\n if (isRetryableStatus(status)) {\n throw new Error(`APNs send failed: ${status} ${reason || text}`);\n }\n throw new PermanentError(`APNs refused the request: ${status} ${reason || text}`);\n }\n}\n\n/** APNs error bodies are `{ \"reason\": \"…\" }`. */\nexport function apnsReason(body: string): string {\n try {\n const parsed = JSON.parse(body) as { reason?: string };\n return parsed.reason ?? '';\n } catch {\n return '';\n }\n}\n\n/** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */\nexport async function signApnsJwt(\n key: ApnsProviderTokenOptions,\n nowMs = Date.now()\n): Promise<string> {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: key.keyId }));\n const claims = base64url(JSON.stringify({ iss: key.teamId, iat: Math.floor(nowMs / 1000) }));\n const cryptoKey = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(key.privateKey),\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n );\n // WebCrypto's ECDSA signature is the raw r‖s pair, which is exactly JWS's ES256 encoding.\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n cryptoKey,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** The `.p8` as it is usually shipped in a secret: base64 of the file. */\nexport function apnsKeyFromBase64(value: string): string {\n let pem: string;\n try {\n pem = atob(value);\n } catch (error) {\n // A secret that is not base64 is the same on every attempt: not worth a retry\n throw new PermanentError('APNs key is not base64 (expected the .p8 file, base64-encoded)', {\n cause: error,\n });\n }\n if (!pem.includes('-----BEGIN PRIVATE KEY-----')) {\n throw new PermanentError('APNs key is not a PKCS #8 PEM (.p8) file');\n }\n return pem;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"apns.mjs","names":[],"sources":["../src/apns.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport 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/apns` — iOS push delivery straight to the Apple Push\n * Notification service, token-based (an APNs auth key, the `.p8` file from the\n * developer portal). No Firebase in between: the app registers the APNs device\n * token in its own device table, and the host's DeviceDirectory hands it to the\n * engine, which sends one message per device.\n * No dependencies: the provider JWT is ES256-signed with WebCrypto.\n *\n * APNs speaks HTTP/2 only. A deployed Worker's `fetch` negotiates it, but\n * workerd under `wrangler dev` does not (the connection is dropped), so a local\n * host points `origin` at the Node relay in `@shware/workflow/apns-relay`.\n */\n\n/** The APNs auth key — the `.p8` file from the developer portal, and the ids that name it. */\nexport interface ApnsProviderTokenOptions {\n /** The key's 10-character id, shown next to it in the developer portal. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */\n privateKey: string;\n}\n\nexport interface ApnsPushOptions {\n /** The provider token the requests are signed with — shared by every sender on the same key. */\n credentials: ApnsProviderToken;\n /** The app's bundle id — APNs's `apns-topic`. */\n topic: string;\n /**\n * Which APNs environment the device tokens come from: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. A token from one is\n * `BadDeviceToken` on the other.\n */\n environment: 'sandbox' | 'production';\n render: PushRenderer;\n /**\n * POST here instead of Apple's host — the dev relay, which forwards over\n * HTTP/2. The real host still travels along (`apns-relay-upstream`), so the\n * relay needs no configuration of its own.\n */\n origin?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport const APNS_HOSTS = {\n sandbox: 'https://api.sandbox.push.apple.com',\n production: 'https://api.push.apple.com',\n} as const;\n\n/**\n * How an APNs answer is classified, after Apple's response table\n * (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):\n *\n * - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the\n * topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles\n * undelivered and the host forgets the token.\n * - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but\n * just as well for a token of the other environment (a sandbox build pushed through the\n * production host), which would answer for every device at once; the copy settles with the\n * reason, so the report shows it, and nothing is deleted on a guess.\n * - configuration: the topic, certificate or provider key is wrong. Every device on this app\n * would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,\n * the deployment is not.\n * - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,\n * `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.\n * - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,\n * 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['Unregistered', 'ExpiredToken']);\n\n/** The app's topic, certificate or provider key is wrong: the request is refused for every device. */\nconst CONFIGURATION_REASONS: ReadonlySet<string> = new Set([\n 'DeviceTokenNotForTopic',\n 'TopicDisallowed',\n 'BadTopic',\n 'MissingTopic',\n 'BadCertificate',\n 'BadCertificateEnvironment',\n 'Forbidden',\n 'InvalidProviderToken',\n 'MissingProviderToken',\n]);\n\n/**\n * The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the\n * first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the\n * same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —\n * collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose\n * the node id at its end, and two nodes of one instance would collapse into one notification.\n */\nexport async function apnsIdFromKey(idempotencyKey: string): Promise<string> {\n const digest = new Uint8Array(\n await crypto.subtle.digest('SHA-256', new TextEncoder().encode(idempotencyKey))\n );\n const bytes = digest.slice(0, 16);\n bytes[6] = (bytes[6] & 0x0f) | 0x80; // version 8: custom, deterministic\n bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant\n const hex = Buffer.from(bytes).toString('hex');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\n/** Apple: reuse a provider token for at least 20 minutes, never past 60. */\nconst TOKEN_TTL_MS = 50 * 60_000;\n\n/**\n * The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple\n * answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent\n * callers on a fresh or aged token share the one signing in flight, and a signing that failed is\n * not kept, so the next call tries again. One instance serves every sender built on the key — a\n * host keeps it across runs, and builds the senders per run.\n */\nexport class ApnsProviderToken {\n private signed: { value: string; issuedAt: number } | undefined;\n private signing: Promise<string> | undefined;\n\n constructor(private readonly options: ApnsProviderTokenOptions) {}\n\n /** The current token, signed now if there is none or it has aged out. */\n async token(now = Date.now()): Promise<string> {\n if (this.signed !== undefined && now - this.signed.issuedAt < TOKEN_TTL_MS) {\n return this.signed.value;\n }\n this.signing ??= signApnsJwt(this.options, now)\n .then((value) => {\n this.signed = { value, issuedAt: now };\n return value;\n })\n .finally(() => {\n this.signing = undefined;\n });\n return this.signing;\n }\n\n /** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */\n invalidate(): void {\n this.signed = undefined;\n }\n}\n\nexport class ApnsPushSender implements MessageSender {\n constructor(private readonly options: ApnsPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`ApnsPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`ApnsPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n // A notification service extension runs only for a mutable push: it attaches the image and\n // reports the device delivery (`delivered_url`)\n const mutable = image !== undefined || message.tracking?.data?.delivered_url !== undefined;\n const upstream = APNS_HOSTS[this.options.environment];\n const origin = this.options.origin ?? upstream;\n const doFetch = this.options.fetch ?? fetch;\n\n const copyId = await apnsIdFromKey(message.idempotencyKey);\n const response = await doFetch(`${origin}/3/device/${message.recipient}`, {\n method: 'POST',\n headers: {\n authorization: `bearer ${await this.options.credentials.token()}`,\n 'apns-topic': this.options.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': '10',\n // A resend of the same copy (an at-least-once retry after APNs accepted the first attempt)\n // carries the same id and collapses onto the first notification instead of showing twice\n 'apns-id': copyId,\n 'apns-collapse-id': copyId,\n 'content-type': 'application/json',\n ...(this.options.origin === undefined ? {} : { 'apns-relay-upstream': upstream }),\n },\n body: JSON.stringify({\n aps: {\n alert: { title, body },\n sound: 'default',\n ...(mutable ? { 'mutable-content': 1 } : {}),\n },\n // The template key travels so the app can route a tap; the idempotency\n // key lets the app de-duplicate a redelivered push.\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(image === undefined ? {} : { image }),\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n }),\n });\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n // A 410 without a body is still \"unregistered\": settle it under that reason\n const reason = apnsReason(text) || (status === 410 ? 'Unregistered' : '');\n if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {\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(`[apns] token gone for user ${message.userId}: ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'BadDeviceToken') {\n // The token or the environment: settled so the report says which device, nothing deleted\n console.warn(`[apns] refused for user ${message.userId}: ${status} ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'ExpiredProviderToken') {\n // The token aged out (clock skew, a long-lived isolate): sign a new one on the retry\n this.options.credentials.invalidate();\n throw new Error(\n `APNs send failed: ${status} ${reason} (provider token renewed for the retry)`\n );\n }\n if (CONFIGURATION_REASONS.has(reason)) {\n throw new PermanentError(\n `APNs refused the request for topic '${this.options.topic}' (${this.options.environment}): ${status} ${reason}`\n );\n }\n if (isRetryableStatus(status)) {\n throw new Error(`APNs send failed: ${status} ${reason || text}`);\n }\n throw new PermanentError(`APNs refused the request: ${status} ${reason || text}`);\n }\n}\n\n/** APNs error bodies are `{ \"reason\": \"…\" }`. */\nexport function apnsReason(body: string): string {\n try {\n const parsed = JSON.parse(body) as { reason?: string };\n return parsed.reason ?? '';\n } catch {\n return '';\n }\n}\n\n/** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */\nexport async function signApnsJwt(\n key: ApnsProviderTokenOptions,\n nowMs = Date.now()\n): Promise<string> {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: key.keyId }));\n const claims = base64url(JSON.stringify({ iss: key.teamId, iat: Math.floor(nowMs / 1000) }));\n const cryptoKey = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(key.privateKey),\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n );\n // WebCrypto's ECDSA signature is the raw r‖s pair, which is exactly JWS's ES256 encoding.\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n cryptoKey,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** The `.p8` as it is usually shipped in a secret: base64 of the file. */\nexport function apnsKeyFromBase64(value: string): string {\n let pem: string;\n try {\n pem = Buffer.from(value, 'base64').toString();\n } catch (error) {\n // A secret that is not base64 is the same on every attempt: not worth a retry\n throw new PermanentError('APNs key is not base64 (expected the .p8 file, base64-encoded)', {\n cause: error,\n });\n }\n if (!pem.includes('-----BEGIN PRIVATE KEY-----')) {\n throw new PermanentError('APNs key is not a PKCS #8 PEM (.p8) file');\n }\n return pem;\n}\n"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_buffer = require("node:buffer");
2
3
  //#region src/aws/function-url.ts
3
4
  /**
4
5
  * An HTTP request, told apart by the event's own top-level shape — the Function URL wraps
@@ -9,17 +10,8 @@ function isFunctionUrlEvent(event) {
9
10
  const { requestContext, rawPath } = event;
10
11
  return typeof rawPath === "string" && typeof requestContext === "object" && requestContext !== null && typeof requestContext.http === "object" && requestContext.http !== null && typeof requestContext.http.method === "string";
11
12
  }
12
- function fromBase64(value) {
13
- const binary = atob(value);
14
- const bytes = new Uint8Array(binary.length);
15
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
16
- return bytes;
17
- }
18
- function toBase64(bytes) {
19
- let binary = "";
20
- for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
21
- return btoa(binary);
22
- }
13
+ const fromBase64 = (value) => new Uint8Array(node_buffer.Buffer.from(value, "base64"));
14
+ const toBase64 = (bytes) => node_buffer.Buffer.from(bytes).toString("base64");
23
15
  function toRequest(event) {
24
16
  const method = event.requestContext.http.method.toUpperCase();
25
17
  const headers = new Headers();
@@ -1 +1 @@
1
- {"version":3,"file":"function-url.cjs","names":[],"sources":["../../src/aws/function-url.ts"],"sourcesContent":["/**\n * Lambda Function URL events (payload format 2.0) as fetch `Request`s, and `Response`s back as\n * the result Lambda turns into the HTTP response — so the host's HTTP API runs unchanged.\n */\n\nexport interface FunctionUrlEvent {\n version?: string;\n rawPath: string;\n rawQueryString?: string;\n headers?: Record<string, string | undefined>;\n cookies?: string[];\n body?: string;\n isBase64Encoded?: boolean;\n requestContext: {\n domainName?: string;\n http: { method: string; sourceIp?: string; userAgent?: string };\n };\n}\n\nexport interface FunctionUrlResult {\n statusCode: number;\n headers: Record<string, string>;\n cookies?: string[];\n body: string;\n isBase64Encoded: boolean;\n}\n\n/**\n * An HTTP request, told apart by the event's own top-level shape — the Function URL wraps\n * every request in `requestContext.http`, so no request body can pass for anything else.\n */\nexport function isFunctionUrlEvent(event: unknown): event is FunctionUrlEvent {\n if (typeof event !== 'object' || event === null) return false;\n // Untrusted until checked: every level may be missing\n const { requestContext, rawPath } = event as {\n requestContext?: { http?: { method?: unknown } | null } | null;\n rawPath?: unknown;\n };\n return (\n typeof rawPath === 'string' &&\n typeof requestContext === 'object' &&\n requestContext !== null &&\n typeof requestContext.http === 'object' &&\n requestContext.http !== null &&\n typeof requestContext.http.method === 'string'\n );\n}\n\nfunction fromBase64(value: string): Uint8Array {\n const binary = atob(value);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);\n return bytes;\n}\n\nfunction toBase64(bytes: Uint8Array): string {\n let binary = '';\n for (let i = 0; i < bytes.length; i += 0x8000) {\n binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));\n }\n return btoa(binary);\n}\n\nexport function toRequest(event: FunctionUrlEvent): Request {\n const method = event.requestContext.http.method.toUpperCase();\n const headers = new Headers();\n for (const [name, value] of Object.entries(event.headers ?? {})) {\n if (value !== undefined) headers.set(name, value);\n }\n if (event.cookies !== undefined && event.cookies.length > 0) {\n headers.set('cookie', event.cookies.join('; '));\n }\n const host = event.requestContext.domainName ?? headers.get('host') ?? 'localhost';\n const query =\n event.rawQueryString === undefined || event.rawQueryString === ''\n ? ''\n : `?${event.rawQueryString}`;\n const hasBody = method !== 'GET' && method !== 'HEAD' && event.body !== undefined;\n return new Request(`https://${host}${event.rawPath}${query}`, {\n method,\n headers,\n ...(hasBody\n ? { body: event.isBase64Encoded === true ? fromBase64(event.body ?? '') : event.body }\n : {}),\n });\n}\n\n/** Content types sent as text; everything else (a tracking pixel) goes back base64. */\nconst TEXTUAL =\n /^(text\\/|application\\/(json|javascript|xml|x-www-form-urlencoded)|image\\/svg\\+xml)/i;\n\nexport async function fromResponse(response: Response, method: string): Promise<FunctionUrlResult> {\n const headers: Record<string, string> = {};\n const cookies: string[] = [];\n response.headers.forEach((value, name) => {\n if (name === 'set-cookie') cookies.push(value);\n else headers[name] = value;\n });\n const contentType = response.headers.get('content-type') ?? '';\n const head = method.toUpperCase() === 'HEAD';\n const bytes = head ? new Uint8Array() : new Uint8Array(await response.arrayBuffer());\n const textual = bytes.length === 0 || TEXTUAL.test(contentType);\n return {\n statusCode: response.status,\n headers,\n ...(cookies.length === 0 ? {} : { cookies }),\n body: textual ? new TextDecoder().decode(bytes) : toBase64(bytes),\n isBase64Encoded: !textual,\n };\n}\n"],"mappings":";;;;;;AA+BA,SAAgB,mBAAmB,OAA2C;CAC5E,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CAExD,MAAM,EAAE,gBAAgB,YAAY;CAIpC,OACE,OAAO,YAAY,YACnB,OAAO,mBAAmB,YAC1B,mBAAmB,QACnB,OAAO,eAAe,SAAS,YAC/B,eAAe,SAAS,QACxB,OAAO,eAAe,KAAK,WAAW;AAE1C;AAEA,SAAS,WAAW,OAA2B;CAC7C,MAAM,SAAS,KAAK,KAAK;CACzB,MAAM,QAAQ,IAAI,WAAW,OAAO,MAAM;CAC1C,KAAK,IAAI,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK,MAAM,KAAK,OAAO,WAAW,CAAC;CACtE,OAAO;AACT;AAEA,SAAS,SAAS,OAA2B;CAC3C,IAAI,SAAS;CACb,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,OACrC,UAAU,OAAO,aAAa,GAAG,MAAM,SAAS,GAAG,IAAI,KAAM,CAAC;CAEhE,OAAO,KAAK,MAAM;AACpB;AAEA,SAAgB,UAAU,OAAkC;CAC1D,MAAM,SAAS,MAAM,eAAe,KAAK,OAAO,YAAY;CAC5D,MAAM,UAAU,IAAI,QAAQ;CAC5B,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,MAAM,WAAW,CAAC,CAAC,GAC5D,IAAI,UAAU,KAAA,GAAW,QAAQ,IAAI,MAAM,KAAK;CAElD,IAAI,MAAM,YAAY,KAAA,KAAa,MAAM,QAAQ,SAAS,GACxD,QAAQ,IAAI,UAAU,MAAM,QAAQ,KAAK,IAAI,CAAC;CAEhD,MAAM,OAAO,MAAM,eAAe,cAAc,QAAQ,IAAI,MAAM,KAAK;CACvE,MAAM,QACJ,MAAM,mBAAmB,KAAA,KAAa,MAAM,mBAAmB,KAC3D,KACA,IAAI,MAAM;CAChB,MAAM,UAAU,WAAW,SAAS,WAAW,UAAU,MAAM,SAAS,KAAA;CACxE,OAAO,IAAI,QAAQ,WAAW,OAAO,MAAM,UAAU,SAAS;EAC5D;EACA;EACA,GAAI,UACA,EAAE,MAAM,MAAM,oBAAoB,OAAO,WAAW,MAAM,QAAQ,EAAE,IAAI,MAAM,KAAK,IACnF,CAAC;CACP,CAAC;AACH;;AAGA,MAAM,UACJ;AAEF,eAAsB,aAAa,UAAoB,QAA4C;CACjG,MAAM,UAAkC,CAAC;CACzC,MAAM,UAAoB,CAAC;CAC3B,SAAS,QAAQ,SAAS,OAAO,SAAS;EACxC,IAAI,SAAS,cAAc,QAAQ,KAAK,KAAK;OACxC,QAAQ,QAAQ;CACvB,CAAC;CACD,MAAM,cAAc,SAAS,QAAQ,IAAI,cAAc,KAAK;CAE5D,MAAM,QADO,OAAO,YAAY,MAAM,yBACjB,IAAI,WAAW,IAAI,IAAI,WAAW,MAAM,SAAS,YAAY,CAAC;CACnF,MAAM,UAAU,MAAM,WAAW,KAAK,QAAQ,KAAK,WAAW;CAC9D,OAAO;EACL,YAAY,SAAS;EACrB;EACA,GAAI,QAAQ,WAAW,IAAI,CAAC,IAAI,EAAE,QAAQ;EAC1C,MAAM,UAAU,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,IAAI,SAAS,KAAK;EAChE,iBAAiB,CAAC;CACpB;AACF"}
1
+ {"version":3,"file":"function-url.cjs","names":["Buffer"],"sources":["../../src/aws/function-url.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\n/**\n * Lambda Function URL events (payload format 2.0) as fetch `Request`s, and `Response`s back as\n * the result Lambda turns into the HTTP response — so the host's HTTP API runs unchanged.\n */\n\nexport interface FunctionUrlEvent {\n version?: string;\n rawPath: string;\n rawQueryString?: string;\n headers?: Record<string, string | undefined>;\n cookies?: string[];\n body?: string;\n isBase64Encoded?: boolean;\n requestContext: {\n domainName?: string;\n http: { method: string; sourceIp?: string; userAgent?: string };\n };\n}\n\nexport interface FunctionUrlResult {\n statusCode: number;\n headers: Record<string, string>;\n cookies?: string[];\n body: string;\n isBase64Encoded: boolean;\n}\n\n/**\n * An HTTP request, told apart by the event's own top-level shape — the Function URL wraps\n * every request in `requestContext.http`, so no request body can pass for anything else.\n */\nexport function isFunctionUrlEvent(event: unknown): event is FunctionUrlEvent {\n if (typeof event !== 'object' || event === null) return false;\n // Untrusted until checked: every level may be missing\n const { requestContext, rawPath } = event as {\n requestContext?: { http?: { method?: unknown } | null } | null;\n rawPath?: unknown;\n };\n return (\n typeof rawPath === 'string' &&\n typeof requestContext === 'object' &&\n requestContext !== null &&\n typeof requestContext.http === 'object' &&\n requestContext.http !== null &&\n typeof requestContext.http.method === 'string'\n );\n}\n\nconst fromBase64 = (value: string): Uint8Array => new Uint8Array(Buffer.from(value, 'base64'));\nconst toBase64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64');\n\nexport function toRequest(event: FunctionUrlEvent): Request {\n const method = event.requestContext.http.method.toUpperCase();\n const headers = new Headers();\n for (const [name, value] of Object.entries(event.headers ?? {})) {\n if (value !== undefined) headers.set(name, value);\n }\n if (event.cookies !== undefined && event.cookies.length > 0) {\n headers.set('cookie', event.cookies.join('; '));\n }\n const host = event.requestContext.domainName ?? headers.get('host') ?? 'localhost';\n const query =\n event.rawQueryString === undefined || event.rawQueryString === ''\n ? ''\n : `?${event.rawQueryString}`;\n const hasBody = method !== 'GET' && method !== 'HEAD' && event.body !== undefined;\n return new Request(`https://${host}${event.rawPath}${query}`, {\n method,\n headers,\n ...(hasBody\n ? { body: event.isBase64Encoded === true ? fromBase64(event.body ?? '') : event.body }\n : {}),\n });\n}\n\n/** Content types sent as text; everything else (a tracking pixel) goes back base64. */\nconst TEXTUAL =\n /^(text\\/|application\\/(json|javascript|xml|x-www-form-urlencoded)|image\\/svg\\+xml)/i;\n\nexport async function fromResponse(response: Response, method: string): Promise<FunctionUrlResult> {\n const headers: Record<string, string> = {};\n const cookies: string[] = [];\n response.headers.forEach((value, name) => {\n if (name === 'set-cookie') cookies.push(value);\n else headers[name] = value;\n });\n const contentType = response.headers.get('content-type') ?? '';\n const head = method.toUpperCase() === 'HEAD';\n const bytes = head ? new Uint8Array() : new Uint8Array(await response.arrayBuffer());\n const textual = bytes.length === 0 || TEXTUAL.test(contentType);\n return {\n statusCode: response.status,\n headers,\n ...(cookies.length === 0 ? {} : { cookies }),\n body: textual ? new TextDecoder().decode(bytes) : toBase64(bytes),\n isBase64Encoded: !textual,\n };\n}\n"],"mappings":";;;;;;;AAgCA,SAAgB,mBAAmB,OAA2C;CAC5E,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CAExD,MAAM,EAAE,gBAAgB,YAAY;CAIpC,OACE,OAAO,YAAY,YACnB,OAAO,mBAAmB,YAC1B,mBAAmB,QACnB,OAAO,eAAe,SAAS,YAC/B,eAAe,SAAS,QACxB,OAAO,eAAe,KAAK,WAAW;AAE1C;AAEA,MAAM,cAAc,UAA8B,IAAI,WAAWA,YAAAA,OAAO,KAAK,OAAO,QAAQ,CAAC;AAC7F,MAAM,YAAY,UAA8BA,YAAAA,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,QAAQ;AAEpF,SAAgB,UAAU,OAAkC;CAC1D,MAAM,SAAS,MAAM,eAAe,KAAK,OAAO,YAAY;CAC5D,MAAM,UAAU,IAAI,QAAQ;CAC5B,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,MAAM,WAAW,CAAC,CAAC,GAC5D,IAAI,UAAU,KAAA,GAAW,QAAQ,IAAI,MAAM,KAAK;CAElD,IAAI,MAAM,YAAY,KAAA,KAAa,MAAM,QAAQ,SAAS,GACxD,QAAQ,IAAI,UAAU,MAAM,QAAQ,KAAK,IAAI,CAAC;CAEhD,MAAM,OAAO,MAAM,eAAe,cAAc,QAAQ,IAAI,MAAM,KAAK;CACvE,MAAM,QACJ,MAAM,mBAAmB,KAAA,KAAa,MAAM,mBAAmB,KAC3D,KACA,IAAI,MAAM;CAChB,MAAM,UAAU,WAAW,SAAS,WAAW,UAAU,MAAM,SAAS,KAAA;CACxE,OAAO,IAAI,QAAQ,WAAW,OAAO,MAAM,UAAU,SAAS;EAC5D;EACA;EACA,GAAI,UACA,EAAE,MAAM,MAAM,oBAAoB,OAAO,WAAW,MAAM,QAAQ,EAAE,IAAI,MAAM,KAAK,IACnF,CAAC;CACP,CAAC;AACH;;AAGA,MAAM,UACJ;AAEF,eAAsB,aAAa,UAAoB,QAA4C;CACjG,MAAM,UAAkC,CAAC;CACzC,MAAM,UAAoB,CAAC;CAC3B,SAAS,QAAQ,SAAS,OAAO,SAAS;EACxC,IAAI,SAAS,cAAc,QAAQ,KAAK,KAAK;OACxC,QAAQ,QAAQ;CACvB,CAAC;CACD,MAAM,cAAc,SAAS,QAAQ,IAAI,cAAc,KAAK;CAE5D,MAAM,QADO,OAAO,YAAY,MAAM,yBACjB,IAAI,WAAW,IAAI,IAAI,WAAW,MAAM,SAAS,YAAY,CAAC;CACnF,MAAM,UAAU,MAAM,WAAW,KAAK,QAAQ,KAAK,WAAW;CAC9D,OAAO;EACL,YAAY,SAAS;EACrB;EACA,GAAI,QAAQ,WAAW,IAAI,CAAC,IAAI,EAAE,QAAQ;EAC1C,MAAM,UAAU,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,IAAI,SAAS,KAAK;EAChE,iBAAiB,CAAC;CACpB;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"function-url.d.cts","names":[],"sources":["../../src/aws/function-url.ts"],"mappings":";;;;;iBAKiB;EACf;EACA;EACA;EACA,UAAU;EACV;EACA;EACA;EACA;IACE;IACA;MAAQ;MAAgB;MAAmB;;;;iBAI9B;EACf;EACA,SAAS;EACT;EACA;EACA;;;;;;wBAOc,mBAAmB,iBAAiB,SAAS;wBAgC7C,UAAU,OAAO,mBAAmB;wBA4B9B,aAAa,UAAU,UAAU,iBAAiB,QAAQ"}
1
+ {"version":3,"file":"function-url.d.cts","names":[],"sources":["../../src/aws/function-url.ts"],"mappings":";;;;;iBAMiB;EACf;EACA;EACA;EACA,UAAU;EACV;EACA;EACA;EACA;IACE;IACA;MAAQ;MAAgB;MAAmB;;;;iBAI9B;EACf;EACA,SAAS;EACT;EACA;EACA;;;;;;wBAOc,mBAAmB,iBAAiB,SAAS;wBAoB7C,UAAU,OAAO,mBAAmB;wBA4B9B,aAAa,UAAU,UAAU,iBAAiB,QAAQ"}
@@ -1 +1 @@
1
- {"version":3,"file":"function-url.d.mts","names":[],"sources":["../../src/aws/function-url.ts"],"mappings":";;;;;iBAKiB;EACf;EACA;EACA;EACA,UAAU;EACV;EACA;EACA;EACA;IACE;IACA;MAAQ;MAAgB;MAAmB;;;;iBAI9B;EACf;EACA,SAAS;EACT;EACA;EACA;;;;;;wBAOc,mBAAmB,iBAAiB,SAAS;wBAgC7C,UAAU,OAAO,mBAAmB;wBA4B9B,aAAa,UAAU,UAAU,iBAAiB,QAAQ"}
1
+ {"version":3,"file":"function-url.d.mts","names":[],"sources":["../../src/aws/function-url.ts"],"mappings":";;;;;iBAMiB;EACf;EACA;EACA;EACA,UAAU;EACV;EACA;EACA;EACA;IACE;IACA;MAAQ;MAAgB;MAAmB;;;;iBAI9B;EACf;EACA,SAAS;EACT;EACA;EACA;;;;;;wBAOc,mBAAmB,iBAAiB,SAAS;wBAoB7C,UAAU,OAAO,mBAAmB;wBA4B9B,aAAa,UAAU,UAAU,iBAAiB,QAAQ"}
@@ -1,3 +1,4 @@
1
+ import { Buffer } from "node:buffer";
1
2
  //#region src/aws/function-url.ts
2
3
  /**
3
4
  * An HTTP request, told apart by the event's own top-level shape — the Function URL wraps
@@ -8,17 +9,8 @@ function isFunctionUrlEvent(event) {
8
9
  const { requestContext, rawPath } = event;
9
10
  return typeof rawPath === "string" && typeof requestContext === "object" && requestContext !== null && typeof requestContext.http === "object" && requestContext.http !== null && typeof requestContext.http.method === "string";
10
11
  }
11
- function fromBase64(value) {
12
- const binary = atob(value);
13
- const bytes = new Uint8Array(binary.length);
14
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
15
- return bytes;
16
- }
17
- function toBase64(bytes) {
18
- let binary = "";
19
- for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
20
- return btoa(binary);
21
- }
12
+ const fromBase64 = (value) => new Uint8Array(Buffer.from(value, "base64"));
13
+ const toBase64 = (bytes) => Buffer.from(bytes).toString("base64");
22
14
  function toRequest(event) {
23
15
  const method = event.requestContext.http.method.toUpperCase();
24
16
  const headers = new Headers();
@@ -1 +1 @@
1
- {"version":3,"file":"function-url.mjs","names":[],"sources":["../../src/aws/function-url.ts"],"sourcesContent":["/**\n * Lambda Function URL events (payload format 2.0) as fetch `Request`s, and `Response`s back as\n * the result Lambda turns into the HTTP response — so the host's HTTP API runs unchanged.\n */\n\nexport interface FunctionUrlEvent {\n version?: string;\n rawPath: string;\n rawQueryString?: string;\n headers?: Record<string, string | undefined>;\n cookies?: string[];\n body?: string;\n isBase64Encoded?: boolean;\n requestContext: {\n domainName?: string;\n http: { method: string; sourceIp?: string; userAgent?: string };\n };\n}\n\nexport interface FunctionUrlResult {\n statusCode: number;\n headers: Record<string, string>;\n cookies?: string[];\n body: string;\n isBase64Encoded: boolean;\n}\n\n/**\n * An HTTP request, told apart by the event's own top-level shape — the Function URL wraps\n * every request in `requestContext.http`, so no request body can pass for anything else.\n */\nexport function isFunctionUrlEvent(event: unknown): event is FunctionUrlEvent {\n if (typeof event !== 'object' || event === null) return false;\n // Untrusted until checked: every level may be missing\n const { requestContext, rawPath } = event as {\n requestContext?: { http?: { method?: unknown } | null } | null;\n rawPath?: unknown;\n };\n return (\n typeof rawPath === 'string' &&\n typeof requestContext === 'object' &&\n requestContext !== null &&\n typeof requestContext.http === 'object' &&\n requestContext.http !== null &&\n typeof requestContext.http.method === 'string'\n );\n}\n\nfunction fromBase64(value: string): Uint8Array {\n const binary = atob(value);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);\n return bytes;\n}\n\nfunction toBase64(bytes: Uint8Array): string {\n let binary = '';\n for (let i = 0; i < bytes.length; i += 0x8000) {\n binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));\n }\n return btoa(binary);\n}\n\nexport function toRequest(event: FunctionUrlEvent): Request {\n const method = event.requestContext.http.method.toUpperCase();\n const headers = new Headers();\n for (const [name, value] of Object.entries(event.headers ?? {})) {\n if (value !== undefined) headers.set(name, value);\n }\n if (event.cookies !== undefined && event.cookies.length > 0) {\n headers.set('cookie', event.cookies.join('; '));\n }\n const host = event.requestContext.domainName ?? headers.get('host') ?? 'localhost';\n const query =\n event.rawQueryString === undefined || event.rawQueryString === ''\n ? ''\n : `?${event.rawQueryString}`;\n const hasBody = method !== 'GET' && method !== 'HEAD' && event.body !== undefined;\n return new Request(`https://${host}${event.rawPath}${query}`, {\n method,\n headers,\n ...(hasBody\n ? { body: event.isBase64Encoded === true ? fromBase64(event.body ?? '') : event.body }\n : {}),\n });\n}\n\n/** Content types sent as text; everything else (a tracking pixel) goes back base64. */\nconst TEXTUAL =\n /^(text\\/|application\\/(json|javascript|xml|x-www-form-urlencoded)|image\\/svg\\+xml)/i;\n\nexport async function fromResponse(response: Response, method: string): Promise<FunctionUrlResult> {\n const headers: Record<string, string> = {};\n const cookies: string[] = [];\n response.headers.forEach((value, name) => {\n if (name === 'set-cookie') cookies.push(value);\n else headers[name] = value;\n });\n const contentType = response.headers.get('content-type') ?? '';\n const head = method.toUpperCase() === 'HEAD';\n const bytes = head ? new Uint8Array() : new Uint8Array(await response.arrayBuffer());\n const textual = bytes.length === 0 || TEXTUAL.test(contentType);\n return {\n statusCode: response.status,\n headers,\n ...(cookies.length === 0 ? {} : { cookies }),\n body: textual ? new TextDecoder().decode(bytes) : toBase64(bytes),\n isBase64Encoded: !textual,\n };\n}\n"],"mappings":";;;;;AA+BA,SAAgB,mBAAmB,OAA2C;CAC5E,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CAExD,MAAM,EAAE,gBAAgB,YAAY;CAIpC,OACE,OAAO,YAAY,YACnB,OAAO,mBAAmB,YAC1B,mBAAmB,QACnB,OAAO,eAAe,SAAS,YAC/B,eAAe,SAAS,QACxB,OAAO,eAAe,KAAK,WAAW;AAE1C;AAEA,SAAS,WAAW,OAA2B;CAC7C,MAAM,SAAS,KAAK,KAAK;CACzB,MAAM,QAAQ,IAAI,WAAW,OAAO,MAAM;CAC1C,KAAK,IAAI,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK,MAAM,KAAK,OAAO,WAAW,CAAC;CACtE,OAAO;AACT;AAEA,SAAS,SAAS,OAA2B;CAC3C,IAAI,SAAS;CACb,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,OACrC,UAAU,OAAO,aAAa,GAAG,MAAM,SAAS,GAAG,IAAI,KAAM,CAAC;CAEhE,OAAO,KAAK,MAAM;AACpB;AAEA,SAAgB,UAAU,OAAkC;CAC1D,MAAM,SAAS,MAAM,eAAe,KAAK,OAAO,YAAY;CAC5D,MAAM,UAAU,IAAI,QAAQ;CAC5B,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,MAAM,WAAW,CAAC,CAAC,GAC5D,IAAI,UAAU,KAAA,GAAW,QAAQ,IAAI,MAAM,KAAK;CAElD,IAAI,MAAM,YAAY,KAAA,KAAa,MAAM,QAAQ,SAAS,GACxD,QAAQ,IAAI,UAAU,MAAM,QAAQ,KAAK,IAAI,CAAC;CAEhD,MAAM,OAAO,MAAM,eAAe,cAAc,QAAQ,IAAI,MAAM,KAAK;CACvE,MAAM,QACJ,MAAM,mBAAmB,KAAA,KAAa,MAAM,mBAAmB,KAC3D,KACA,IAAI,MAAM;CAChB,MAAM,UAAU,WAAW,SAAS,WAAW,UAAU,MAAM,SAAS,KAAA;CACxE,OAAO,IAAI,QAAQ,WAAW,OAAO,MAAM,UAAU,SAAS;EAC5D;EACA;EACA,GAAI,UACA,EAAE,MAAM,MAAM,oBAAoB,OAAO,WAAW,MAAM,QAAQ,EAAE,IAAI,MAAM,KAAK,IACnF,CAAC;CACP,CAAC;AACH;;AAGA,MAAM,UACJ;AAEF,eAAsB,aAAa,UAAoB,QAA4C;CACjG,MAAM,UAAkC,CAAC;CACzC,MAAM,UAAoB,CAAC;CAC3B,SAAS,QAAQ,SAAS,OAAO,SAAS;EACxC,IAAI,SAAS,cAAc,QAAQ,KAAK,KAAK;OACxC,QAAQ,QAAQ;CACvB,CAAC;CACD,MAAM,cAAc,SAAS,QAAQ,IAAI,cAAc,KAAK;CAE5D,MAAM,QADO,OAAO,YAAY,MAAM,yBACjB,IAAI,WAAW,IAAI,IAAI,WAAW,MAAM,SAAS,YAAY,CAAC;CACnF,MAAM,UAAU,MAAM,WAAW,KAAK,QAAQ,KAAK,WAAW;CAC9D,OAAO;EACL,YAAY,SAAS;EACrB;EACA,GAAI,QAAQ,WAAW,IAAI,CAAC,IAAI,EAAE,QAAQ;EAC1C,MAAM,UAAU,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,IAAI,SAAS,KAAK;EAChE,iBAAiB,CAAC;CACpB;AACF"}
1
+ {"version":3,"file":"function-url.mjs","names":[],"sources":["../../src/aws/function-url.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\n/**\n * Lambda Function URL events (payload format 2.0) as fetch `Request`s, and `Response`s back as\n * the result Lambda turns into the HTTP response — so the host's HTTP API runs unchanged.\n */\n\nexport interface FunctionUrlEvent {\n version?: string;\n rawPath: string;\n rawQueryString?: string;\n headers?: Record<string, string | undefined>;\n cookies?: string[];\n body?: string;\n isBase64Encoded?: boolean;\n requestContext: {\n domainName?: string;\n http: { method: string; sourceIp?: string; userAgent?: string };\n };\n}\n\nexport interface FunctionUrlResult {\n statusCode: number;\n headers: Record<string, string>;\n cookies?: string[];\n body: string;\n isBase64Encoded: boolean;\n}\n\n/**\n * An HTTP request, told apart by the event's own top-level shape — the Function URL wraps\n * every request in `requestContext.http`, so no request body can pass for anything else.\n */\nexport function isFunctionUrlEvent(event: unknown): event is FunctionUrlEvent {\n if (typeof event !== 'object' || event === null) return false;\n // Untrusted until checked: every level may be missing\n const { requestContext, rawPath } = event as {\n requestContext?: { http?: { method?: unknown } | null } | null;\n rawPath?: unknown;\n };\n return (\n typeof rawPath === 'string' &&\n typeof requestContext === 'object' &&\n requestContext !== null &&\n typeof requestContext.http === 'object' &&\n requestContext.http !== null &&\n typeof requestContext.http.method === 'string'\n );\n}\n\nconst fromBase64 = (value: string): Uint8Array => new Uint8Array(Buffer.from(value, 'base64'));\nconst toBase64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64');\n\nexport function toRequest(event: FunctionUrlEvent): Request {\n const method = event.requestContext.http.method.toUpperCase();\n const headers = new Headers();\n for (const [name, value] of Object.entries(event.headers ?? {})) {\n if (value !== undefined) headers.set(name, value);\n }\n if (event.cookies !== undefined && event.cookies.length > 0) {\n headers.set('cookie', event.cookies.join('; '));\n }\n const host = event.requestContext.domainName ?? headers.get('host') ?? 'localhost';\n const query =\n event.rawQueryString === undefined || event.rawQueryString === ''\n ? ''\n : `?${event.rawQueryString}`;\n const hasBody = method !== 'GET' && method !== 'HEAD' && event.body !== undefined;\n return new Request(`https://${host}${event.rawPath}${query}`, {\n method,\n headers,\n ...(hasBody\n ? { body: event.isBase64Encoded === true ? fromBase64(event.body ?? '') : event.body }\n : {}),\n });\n}\n\n/** Content types sent as text; everything else (a tracking pixel) goes back base64. */\nconst TEXTUAL =\n /^(text\\/|application\\/(json|javascript|xml|x-www-form-urlencoded)|image\\/svg\\+xml)/i;\n\nexport async function fromResponse(response: Response, method: string): Promise<FunctionUrlResult> {\n const headers: Record<string, string> = {};\n const cookies: string[] = [];\n response.headers.forEach((value, name) => {\n if (name === 'set-cookie') cookies.push(value);\n else headers[name] = value;\n });\n const contentType = response.headers.get('content-type') ?? '';\n const head = method.toUpperCase() === 'HEAD';\n const bytes = head ? new Uint8Array() : new Uint8Array(await response.arrayBuffer());\n const textual = bytes.length === 0 || TEXTUAL.test(contentType);\n return {\n statusCode: response.status,\n headers,\n ...(cookies.length === 0 ? {} : { cookies }),\n body: textual ? new TextDecoder().decode(bytes) : toBase64(bytes),\n isBase64Encoded: !textual,\n };\n}\n"],"mappings":";;;;;;AAgCA,SAAgB,mBAAmB,OAA2C;CAC5E,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CAExD,MAAM,EAAE,gBAAgB,YAAY;CAIpC,OACE,OAAO,YAAY,YACnB,OAAO,mBAAmB,YAC1B,mBAAmB,QACnB,OAAO,eAAe,SAAS,YAC/B,eAAe,SAAS,QACxB,OAAO,eAAe,KAAK,WAAW;AAE1C;AAEA,MAAM,cAAc,UAA8B,IAAI,WAAW,OAAO,KAAK,OAAO,QAAQ,CAAC;AAC7F,MAAM,YAAY,UAA8B,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,QAAQ;AAEpF,SAAgB,UAAU,OAAkC;CAC1D,MAAM,SAAS,MAAM,eAAe,KAAK,OAAO,YAAY;CAC5D,MAAM,UAAU,IAAI,QAAQ;CAC5B,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,MAAM,WAAW,CAAC,CAAC,GAC5D,IAAI,UAAU,KAAA,GAAW,QAAQ,IAAI,MAAM,KAAK;CAElD,IAAI,MAAM,YAAY,KAAA,KAAa,MAAM,QAAQ,SAAS,GACxD,QAAQ,IAAI,UAAU,MAAM,QAAQ,KAAK,IAAI,CAAC;CAEhD,MAAM,OAAO,MAAM,eAAe,cAAc,QAAQ,IAAI,MAAM,KAAK;CACvE,MAAM,QACJ,MAAM,mBAAmB,KAAA,KAAa,MAAM,mBAAmB,KAC3D,KACA,IAAI,MAAM;CAChB,MAAM,UAAU,WAAW,SAAS,WAAW,UAAU,MAAM,SAAS,KAAA;CACxE,OAAO,IAAI,QAAQ,WAAW,OAAO,MAAM,UAAU,SAAS;EAC5D;EACA;EACA,GAAI,UACA,EAAE,MAAM,MAAM,oBAAoB,OAAO,WAAW,MAAM,QAAQ,EAAE,IAAI,MAAM,KAAK,IACnF,CAAC;CACP,CAAC;AACH;;AAGA,MAAM,UACJ;AAEF,eAAsB,aAAa,UAAoB,QAA4C;CACjG,MAAM,UAAkC,CAAC;CACzC,MAAM,UAAoB,CAAC;CAC3B,SAAS,QAAQ,SAAS,OAAO,SAAS;EACxC,IAAI,SAAS,cAAc,QAAQ,KAAK,KAAK;OACxC,QAAQ,QAAQ;CACvB,CAAC;CACD,MAAM,cAAc,SAAS,QAAQ,IAAI,cAAc,KAAK;CAE5D,MAAM,QADO,OAAO,YAAY,MAAM,yBACjB,IAAI,WAAW,IAAI,IAAI,WAAW,MAAM,SAAS,YAAY,CAAC;CACnF,MAAM,UAAU,MAAM,WAAW,KAAK,QAAQ,KAAK,WAAW;CAC9D,OAAO;EACL,YAAY,SAAS;EACrB;EACA,GAAI,QAAQ,WAAW,IAAI,CAAC,IAAI,EAAE,QAAQ;EAC1C,MAAM,UAAU,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,IAAI,SAAS,KAAK;EAChE,iBAAiB,CAAC;CACpB;AACF"}
@@ -7,6 +7,7 @@ const require_engine_duration = require("../engine/duration.cjs");
7
7
  const require_host_fresh_read = require("../host/fresh-read.cjs");
8
8
  const require_host_ingest = require("../host/ingest.cjs");
9
9
  const require_host_execute = require("../host/execute.cjs");
10
+ const require_host_auth = require("../host/auth.cjs");
10
11
  const require_host_journey_host = require("../host/journey-host.cjs");
11
12
  const require_aws_instances = require("./instances.cjs");
12
13
  const require_aws_lambda_api = require("./lambda-api.cjs");
@@ -197,6 +198,7 @@ exports.AwsEngineStep = require_aws_engine_step.AwsEngineStep;
197
198
  exports.AwsInstances = require_aws_instances.AwsInstances;
198
199
  exports.RUN_EVENT_TYPE = require_aws_instances.RUN_EVENT_TYPE;
199
200
  exports.SWEEP_EVENT_TYPE = SWEEP_EVENT_TYPE;
201
+ exports.bearerMatches = require_host_auth.bearerMatches;
200
202
  exports.executionName = require_aws_instances.executionName;
201
203
  exports.fromResponse = require_aws_function_url.fromResponse;
202
204
  exports.isFunctionUrlEvent = require_aws_function_url.isFunctionUrlEvent;
@@ -204,6 +206,7 @@ exports.isRunInput = require_aws_instances.isRunInput;
204
206
  exports.journeyLambda = journeyLambda;
205
207
  exports.sdkLambdaApi = require_aws_lambda_api.sdkLambdaApi;
206
208
  exports.stepRetry = require_aws_engine_step.stepRetry;
209
+ exports.timingSafeEqual = require_host_auth.timingSafeEqual;
207
210
  exports.toRequest = require_aws_function_url.toRequest;
208
211
 
209
212
  //# sourceMappingURL=index.cjs.map