@shware/workflow 6.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +78 -30
  2. package/dist/client.cjs +34 -11
  3. package/dist/client.cjs.map +1 -1
  4. package/dist/client.d.cts +16 -2
  5. package/dist/client.d.cts.map +1 -1
  6. package/dist/client.d.mts +16 -2
  7. package/dist/client.d.mts.map +1 -1
  8. package/dist/client.mjs +34 -11
  9. package/dist/client.mjs.map +1 -1
  10. package/dist/cloudflare/index.cjs +2 -2
  11. package/dist/cloudflare/index.mjs +2 -2
  12. package/dist/email-sender.cjs +2 -1
  13. package/dist/email-sender.cjs.map +1 -1
  14. package/dist/email-sender.d.cts +4 -3
  15. package/dist/email-sender.d.cts.map +1 -1
  16. package/dist/email-sender.d.mts +4 -3
  17. package/dist/email-sender.d.mts.map +1 -1
  18. package/dist/email-sender.mjs +2 -1
  19. package/dist/email-sender.mjs.map +1 -1
  20. package/dist/email.cjs +38 -10
  21. package/dist/email.cjs.map +1 -1
  22. package/dist/email.d.cts +43 -12
  23. package/dist/email.d.cts.map +1 -1
  24. package/dist/email.d.mts +43 -12
  25. package/dist/email.d.mts.map +1 -1
  26. package/dist/email.mjs +36 -11
  27. package/dist/email.mjs.map +1 -1
  28. package/dist/engine/ports.d.cts +6 -0
  29. package/dist/engine/ports.d.cts.map +1 -1
  30. package/dist/engine/ports.d.mts +6 -0
  31. package/dist/engine/ports.d.mts.map +1 -1
  32. package/dist/host/http.cjs +38 -3
  33. package/dist/host/http.cjs.map +1 -1
  34. package/dist/host/http.d.cts +1 -1
  35. package/dist/host/http.d.cts.map +1 -1
  36. package/dist/host/http.d.mts +1 -1
  37. package/dist/host/http.d.mts.map +1 -1
  38. package/dist/host/http.mjs +39 -4
  39. package/dist/host/http.mjs.map +1 -1
  40. package/dist/host/ingest.cjs +45 -0
  41. package/dist/host/ingest.cjs.map +1 -1
  42. package/dist/host/ingest.d.cts +28 -0
  43. package/dist/host/ingest.d.cts.map +1 -1
  44. package/dist/host/ingest.d.mts +28 -0
  45. package/dist/host/ingest.d.mts.map +1 -1
  46. package/dist/host/ingest.mjs +43 -1
  47. package/dist/host/ingest.mjs.map +1 -1
  48. package/dist/index.cjs +3 -0
  49. package/dist/index.d.cts +2 -2
  50. package/dist/index.d.mts +2 -2
  51. package/dist/index.mjs +2 -2
  52. package/dist/react-email.cjs +6 -5
  53. package/dist/react-email.cjs.map +1 -1
  54. package/dist/react-email.d.cts.map +1 -1
  55. package/dist/react-email.d.mts.map +1 -1
  56. package/dist/react-email.mjs +6 -5
  57. package/dist/react-email.mjs.map +1 -1
  58. package/dist/reporting/observer.cjs +3 -1
  59. package/dist/reporting/observer.cjs.map +1 -1
  60. package/dist/reporting/observer.d.cts +2 -1
  61. package/dist/reporting/observer.d.cts.map +1 -1
  62. package/dist/reporting/observer.d.mts +2 -1
  63. package/dist/reporting/observer.d.mts.map +1 -1
  64. package/dist/reporting/observer.mjs +3 -1
  65. package/dist/reporting/observer.mjs.map +1 -1
  66. package/dist/reporting/tracking.cjs +3 -0
  67. package/dist/reporting/tracking.cjs.map +1 -1
  68. package/dist/reporting/tracking.d.cts +2 -0
  69. package/dist/reporting/tracking.d.cts.map +1 -1
  70. package/dist/reporting/tracking.d.mts +2 -0
  71. package/dist/reporting/tracking.d.mts.map +1 -1
  72. package/dist/reporting/tracking.mjs +3 -1
  73. package/dist/reporting/tracking.mjs.map +1 -1
  74. package/package.json +1 -1
package/README.md CHANGED
@@ -525,15 +525,20 @@ the engine does before and after the send:
525
525
 
526
526
  There is no global sender: share addresses through a module of your own. A
527
527
  topic email's unsubscribe link is the template's `unsubscribeUrl`, else the
528
- config's `email.unsubscribeUrl`, else none; a transactional email never has one
529
- (`resolveEmail` is the rule). A layout reads it from the render, never from a
530
- constant, so a template's own URL reaches its HTML; the resolved `topic` is
528
+ config's `email.unsubscribeUrl`, else none; its preferences link is the config's
529
+ `email.preferencesUrl` with the copy's message id, when the copy has one; a
530
+ transactional email never has either (`resolveEmail` is the rule). A layout
531
+ reads them from the render, never from a constant, so a template's own URL
532
+ reaches its HTML and each copy's link names that copy; the resolved `topic` is
531
533
  there too, for a line like "you get these because you subscribed to tips":
532
534
 
533
535
  ```tsx
534
536
  import { useEmailContext } from '@shware/workflow/email-context';
535
537
 
536
- const { unsubscribeUrl } = useEmailContext();
538
+ const { unsubscribeUrl, preferencesUrl } = useEmailContext();
539
+ {
540
+ preferencesUrl !== undefined && <Link href={preferencesUrl}>Email preferences</Link>;
541
+ }
537
542
  {
538
543
  unsubscribeUrl !== undefined && <Link href={unsubscribeUrl}>Unsubscribe</Link>;
539
544
  }
@@ -543,6 +548,7 @@ const { unsubscribeUrl } = useEmailContext();
543
548
  // workflow.config.ts
544
549
  email: {
545
550
  unsubscribeUrl: 'https://acme.test/unsubscribe', // templates without their own
551
+ preferencesUrl: 'https://acme.test/settings/email', // the app's preference page
546
552
  tracking: { quickClickMs: 1_000 }, // the default
547
553
  },
548
554
  ```
@@ -575,6 +581,26 @@ two buttons: the email's topic, and all email. Without reporting, the header nam
575
581
  template's own unsubscribe link, with no one-click claim. Only https URLs are named, so a local
576
582
  `http://` origin sends no header.
577
583
 
584
+ So a topic email carries two ways out, for two readers. The **header** is for the mail client:
585
+ `List-Unsubscribe` points at the Worker's own `/t/u/<message>`, which a one-click POST or the
586
+ Worker's plain confirmation page settles without the app. The **footer** is for the person:
587
+ `preferencesUrl` is the app's own page, branded, signed in or not, reached as
588
+ `https://acme.test/settings/email?m=<message id>` (the page's own query is kept, `m` set beside
589
+ it). The message id in `m` is how the page knows the recipient without a login: it is the
590
+ capability, an unguessable UUID that only ever reached that mailbox, the same trust `/t/u` runs
591
+ on. The page's API calls `GET /subscriptions/by-message/<m>` with the bearer
592
+ (`JourneyClient.getSubscriptionsByMessage`), gets the recipient's record plus
593
+ `message: { channel, topic }` to highlight the topic that email belonged to, and writes with
594
+ `PUT /subscriptions/by-message/<m>` (`setSubscriptionsByMessage`), the same patch as the user
595
+ endpoint. An id that names no copy (never sent, or erased with its user by `/users/forget`) or a
596
+ transactional one answers 404, the rule `/t/u` applies: show the link as expired. The Worker
597
+ does not rate-limit these routes; the app's proxy does, as it sees fit. The link is tracked as
598
+ an ordinary click (`/t/l/...`), never as an unsubscribe: `preferencesUrl` must not share its
599
+ origin and path with `unsubscribeUrl` (or a template's own), and the config refuses one that
600
+ does. Without reporting (no Postgres reporting or no `WORKFLOW_ORIGIN`) there is no message id,
601
+ so `preferencesUrl` is `undefined` in the render and the footer shows nothing; a preview
602
+ (`GET /preview` and the studio) renders it with a placeholder id, `PREVIEW_MESSAGE_ID`.
603
+
578
604
  What a user opted out of is the engine's record, per topic and channel: a topic email is
579
605
  skipped while its topic (on email) or all email is opted out, a transactional email never is,
580
606
  and `reachable('email')` reads the all-email opt-out only — a user unsubscribed from one topic
@@ -704,20 +730,20 @@ Discord; supply anything else with `journeyWorker({ messages })`.
704
730
 
705
731
  `defineConfig` takes:
706
732
 
707
- | Field | Holds |
708
- | ----------- | ----------------------------------------------------------------------------------- |
709
- | `title` | The Studio title |
710
- | `contract` | The `defineContract(...)` every `/events` and `/identify` is validated against |
711
- | `workflows` | The workflows to deploy |
712
- | `segments` | Named segments |
713
- | `actions` | Custom actions `.run()` uses |
714
- | `templates` | The registries: `{ email?, push?, discord?, slack? }`; topics typed by the contract |
715
- | `email` | `{ unsubscribeUrl?, tracking? }`: what every topic email template shares |
716
- | `runtime` | Per mode (`development`, `production`, …): `{ timeScale?, logMessages? }` |
717
- | `apns` | `{ topic, keyId, teamId, environment? }` |
718
- | `fcm` | `{ androidChannelId? }` |
719
- | `webPush` | `{ subject, openedEvent? }` |
720
- | `database` | `{ schema? }`: the Postgres schema, `workflow` by default |
733
+ | Field | Holds |
734
+ | ----------- | ----------------------------------------------------------------------------------------- |
735
+ | `title` | The Studio title |
736
+ | `contract` | The `defineContract(...)` every `/events` and `/identify` is validated against |
737
+ | `workflows` | The workflows to deploy |
738
+ | `segments` | Named segments |
739
+ | `actions` | Custom actions `.run()` uses |
740
+ | `templates` | The registries: `{ email?, push?, discord?, slack? }`; topics typed by the contract |
741
+ | `email` | `{ unsubscribeUrl?, preferencesUrl?, tracking? }`: what every topic email template shares |
742
+ | `runtime` | Per mode (`development`, `production`, …): `{ timeScale?, logMessages? }` |
743
+ | `apns` | `{ topic, keyId, teamId, environment? }` |
744
+ | `fcm` | `{ androidChannelId? }` |
745
+ | `webPush` | `{ subject, openedEvent? }` |
746
+ | `database` | `{ schema? }`: the Postgres schema, `workflow` by default |
721
747
 
722
748
  The `ENVIRONMENT` var picks the `runtime` entry (production when unset).
723
749
  `timeScale` fast-forwards every duration: 600 turns "1 day" into 2.4 minutes.
@@ -749,18 +775,20 @@ throws `JourneyClientError` with its status and body.
749
775
  const journeys = new JourneyClient({ contract, origin, token });
750
776
  ```
751
777
 
752
- | Method | Endpoint | Does |
753
- | --------------------------------------------- | ----------------------- | -------------------------------------------------------- |
754
- | `identify(userId, props)` | `POST /identify` | Merge profile properties; `null` removes one |
755
- | `track(userId, event, payload, { id?, ts? })` | `POST /events` | Record an event; starts and wakes journeys |
756
- | `registerDevice(userId, { token, platform })` | `POST /devices` | Register a push token (`ios`, `android` or `web`) |
757
- | `unbindDevice(token)` | `POST /devices/unbind` | Signed out: the token stays, bound to nobody |
758
- | `removeDevice(token)` | `POST /devices/remove` | Forget a token for good |
759
- | `messageOpened(userId, messageId)` | `POST /messages/opened` | A journey push tapped open (the `message_id` it carried) |
760
- | `getSubscriptions(userId)` | `GET /subscriptions/…` | `{ email, push }`, each `{ all, topics }`, `true` = on |
761
- | `setSubscriptions(userId, patch)` | `PUT /subscriptions/…` | Change what the patch names; answers the new state |
762
- | `forgetUser(userId)` | `POST /users/forget` | Stop the user's journeys and erase their data |
763
- | `deploy(bundle)` | `POST /deploy` | Deploy a compiled bundle |
778
+ | Method | Endpoint | Does |
779
+ | --------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------- |
780
+ | `identify(userId, props)` | `POST /identify` | Merge profile properties; `null` removes one |
781
+ | `track(userId, event, payload, { id?, ts? })` | `POST /events` | Record an event; starts and wakes journeys |
782
+ | `registerDevice(userId, { token, platform })` | `POST /devices` | Register a push token (`ios`, `android` or `web`) |
783
+ | `unbindDevice(token)` | `POST /devices/unbind` | Signed out: the token stays, bound to nobody |
784
+ | `removeDevice(token)` | `POST /devices/remove` | Forget a token for good |
785
+ | `messageOpened(userId, messageId)` | `POST /messages/opened` | A journey push tapped open (the `message_id` it carried) |
786
+ | `getSubscriptions(userId)` | `GET /subscriptions/…` | `{ email, push }`, each `{ all, topics }`, `true` = on |
787
+ | `setSubscriptions(userId, patch)` | `PUT /subscriptions/…` | Change what the patch names; answers the new state |
788
+ | `getSubscriptionsByMessage(messageId)` | `GET /subscriptions/by-message/…` | The recipient's record by a copy's id, plus `message: { channel, topic }` |
789
+ | `setSubscriptionsByMessage(messageId, patch)` | `PUT /subscriptions/by-message/…` | The same patch, for the copy's recipient; answers as the GET does |
790
+ | `forgetUser(userId)` | `POST /users/forget` | Stop the user's journeys and erase their data |
791
+ | `deploy(bundle)` | `POST /deploy` | Deploy a compiled bundle |
764
792
 
765
793
  Pass an `id` to `track` whenever a caller may retry; see
766
794
  [Give each event an `id`](#give-each-event-an-id).
@@ -790,6 +818,17 @@ A "subscribe to all" button sends `all: true` plus every topic `true`; an
790
818
  "unsubscribe from all" sends `all: false` and leaves the topics as they are, so
791
819
  turning `all` back on restores the user's own choices.
792
820
 
821
+ The same page opened from an email's footer, with no session, reads the `m` query parameter the
822
+ `preferencesUrl` link carries and goes by the copy instead of the user (see
823
+ [Email](#email)); a 404 (`JourneyClientError` with `status` 404) is an expired link:
824
+
825
+ ```ts
826
+ // GET /api/email-preferences?m=<message id>, proxied by the app's API, rate-limited there
827
+ const { email, push, message } = await journeys.getSubscriptionsByMessage(m);
828
+ // message: { channel: 'email', topic: 'promotions' } — the email this page was opened from
829
+ await journeys.setSubscriptionsByMessage(m, { email: { topics: { [message.topic]: false } } });
830
+ ```
831
+
793
832
  ## Compiling and planning
794
833
 
795
834
  `compileConfig(config)` returns the `BundleIR` the Worker deploys. It is
@@ -1206,6 +1245,15 @@ On Postgres the engine also keeps:
1206
1245
  non-boolean answers 400 with the field paths; an erased user 410. Changing
1207
1246
  `email.all` wakes the waits on `reachable('email')`, `push.all` those on
1208
1247
  `reachable('push')`.
1248
+ - `GET /subscriptions/by-message/:messageId` answers the same record for the
1249
+ recipient of that copy, plus `message: { channel, topic }` (`channel` is
1250
+ `email` or `push`, the channel the copy went out on); `PUT` takes the same
1251
+ patch as `/subscriptions/:userId` and answers as the GET does. The message id
1252
+ is the capability (the `m` of an email's `preferencesUrl` link), so the app
1253
+ proxies and rate-limits these routes behind its own API; the bearer still
1254
+ guards them. An id that names no copy — never sent, not a UUID, or erased
1255
+ with its user — or a transactional copy (`topic` null) answers 404, the rule
1256
+ `/t/u` applies; a user whose erasure is pending answers 410, as above.
1209
1257
  - `POST /users/forget` takes `{ userId }`; the erasure takes the user's opt-outs
1210
1258
  with the rest, since nothing is ever sent to that id again.
1211
1259
  - `POST /deploy` takes a compiled bundle and swaps the routing table atomically.
package/dist/client.cjs CHANGED
@@ -126,19 +126,41 @@ var JourneyClient = class {
126
126
  * here, before the request; the engine refuses it too.
127
127
  */
128
128
  setSubscriptions(userId, patch) {
129
+ const refused = this.undeclaredTopics(patch);
130
+ if (refused !== void 0) return Promise.reject(refused);
131
+ return this.request("PUT", subscriptionsPath(userId), patch);
132
+ }
133
+ /**
134
+ * `getSubscriptions` for the recipient of one copy, by the message id an email's preferences
135
+ * link carries (`email.preferencesUrl?m=<message id>`): how a preference page works without a
136
+ * sign-in. The answer adds `message: { channel, topic }`, the copy the page was opened from,
137
+ * so it can highlight that topic. Rejects with a 404 for an id that names no copy — never
138
+ * sent, or erased with its user — and for a transactional one; the page should show the link
139
+ * as expired. The message id is the capability, so the app proxies this behind its own API
140
+ * and rate-limits it.
141
+ */
142
+ getSubscriptionsByMessage(messageId) {
143
+ return this.request("GET", subscriptionsByMessagePath(messageId));
144
+ }
145
+ /** `setSubscriptions` for the recipient of one copy; the patch and the answer are those of `getSubscriptionsByMessage`. */
146
+ setSubscriptionsByMessage(messageId, patch) {
147
+ const refused = this.undeclaredTopics(patch);
148
+ if (refused !== void 0) return Promise.reject(refused);
149
+ return this.request("PUT", subscriptionsByMessagePath(messageId), patch);
150
+ }
151
+ /** With a contract, the refusal of a patch naming a topic it does not declare; undefined when the patch is fine. */
152
+ undeclaredTopics(patch) {
129
153
  const contract = this.options.contract;
130
- if (contract !== void 0) {
131
- const issues = [];
132
- for (const channel of require_host_ingest.SUBSCRIPTION_CHANNELS) for (const topic of Object.keys(patch[channel]?.topics ?? {})) {
133
- if (contract.topics.includes(topic)) continue;
134
- issues.push({
135
- path: `${channel}.topics.${topic}`,
136
- message: "not a declared topic"
137
- });
138
- }
139
- if (issues.length > 0) return Promise.reject(new JourneyValidationError("/subscriptions", void 0, issues));
154
+ if (contract === void 0) return void 0;
155
+ const issues = [];
156
+ for (const channel of require_host_ingest.SUBSCRIPTION_CHANNELS) for (const topic of Object.keys(patch[channel]?.topics ?? {})) {
157
+ if (contract.topics.includes(topic)) continue;
158
+ issues.push({
159
+ path: `${channel}.topics.${topic}`,
160
+ message: "not a declared topic"
161
+ });
140
162
  }
141
- return this.request("PUT", subscriptionsPath(userId), patch);
163
+ return issues.length > 0 ? new JourneyValidationError("/subscriptions", void 0, issues) : void 0;
142
164
  }
143
165
  /** Deploy a compiled bundle (BundleIR). */
144
166
  deploy(bundle) {
@@ -161,6 +183,7 @@ var JourneyClient = class {
161
183
  }
162
184
  };
163
185
  const subscriptionsPath = (userId) => `/subscriptions/${encodeURIComponent(userId)}`;
186
+ const subscriptionsByMessagePath = (messageId) => `/subscriptions/by-message/${encodeURIComponent(messageId)}`;
164
187
  exports.JourneyClient = JourneyClient;
165
188
  exports.JourneyClientError = JourneyClientError;
166
189
  exports.JourneyValidationError = JourneyValidationError;
@@ -1 +1 @@
1
- {"version":3,"file":"client.cjs","names":["SUBSCRIPTION_CHANNELS"],"sources":["../src/client.ts"],"sourcesContent":["import type * as core from 'zod/v4/core';\nimport type { Contract, ContractIssue, ProfilePatch, Topic } from './contract';\nimport {\n type DeployResult,\n type IdentifyResult,\n type IngestResult,\n SUBSCRIPTION_CHANNELS,\n type Subscriptions,\n type SubscriptionsPatch,\n} from './host/ingest';\nimport type { ProfileProps } from './store';\n\n/**\n * `@shware/workflow/client` — the producer side of the ingest API, for the\n * service that knows what users do (an api server, a webhook handler). Plain\n * fetch, no dependencies; errors carry the status and body so a caller can\n * log them without failing the request that triggered the report.\n */\n\nexport interface JourneyClientOptions<C extends Contract | undefined = undefined> {\n /**\n * The shared `defineContract(...)`: types `track` (the event name picks the\n * payload type) and `identify`, and checks both before anything is sent.\n */\n contract?: C;\n /** The journey Worker's origin, e.g. `https://workflow.example.com`. */\n origin: string;\n /** Its API_TOKEN, sent as a bearer; omit against an open local instance. */\n token?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport class JourneyClientError extends Error {\n constructor(\n readonly path: string,\n readonly status: number,\n readonly body: string\n ) {\n super(`journey ${path} failed: ${status} ${body}`);\n this.name = 'JourneyClientError';\n }\n}\n\n/** A payload or profile patch the contract refuses — raised before the request, with the field paths. */\nexport class JourneyValidationError extends Error {\n constructor(\n readonly path: string,\n readonly event: string | undefined,\n readonly issues: ContractIssue[]\n ) {\n super(\n `journey ${path}${event === undefined ? '' : ` '${event}'`} refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n this.name = 'JourneyValidationError';\n }\n}\n\ntype EventsOf<C> = C extends Contract<infer E> ? E : never;\n/** Event names: the contract's, or any string without one. */\nexport type ClientEventName<C> = [C] extends [undefined] ? string : keyof EventsOf<C> & string;\ntype TrackArgs<C, K> = [C] extends [undefined]\n ? [payload?: Record<string, unknown>, options?: TrackOptions]\n : K extends keyof EventsOf<C>\n ? [payload: core.input<EventsOf<C>[K]>, options?: TrackOptions]\n : never;\ntype IdentifyProps<C> = [C] extends [undefined]\n ? ProfileProps\n : C extends Contract<Record<string, core.$ZodObject>, infer P>\n ? ProfilePatch<P>\n : never;\n\n/** The topics the client's contract declares; any string without a contract. */\nexport type ClientTopic<C> = [C] extends [undefined] ? string : Topic<C>;\n\nexport type {\n ChannelSubscriptions,\n ChannelSubscriptionsPatch,\n SubscriptionChannel,\n Subscriptions,\n SubscriptionsPatch,\n} from './host/ingest';\n\nexport interface TrackOptions {\n /** The occurrence's identity: a retry with the same id is recorded once. */\n id?: string;\n /** When it happened (epoch ms); the Worker's clock when omitted. */\n ts?: number;\n}\n\nexport class JourneyClient<C extends Contract | undefined = undefined> {\n constructor(private readonly options: JourneyClientOptions<C>) {}\n\n /** Merge profile properties (`null` clears one). Every property a journey reads must be identified before the event that starts it. */\n identify(userId: string, props: IdentifyProps<C>): Promise<IdentifyResult> {\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseProfilePatch(props);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/identify', undefined, result.issues));\n }\n return this.post<IdentifyResult>('/identify', { userId, props });\n }\n\n /**\n * Record an event; the Worker starts every journey whose trigger matches it\n * and wakes waits on it. A caller that retries should pass the same `id`\n * each time: the Worker stores one occurrence per id, so a retry after a\n * timeout cannot double-count the event. `ts` defaults to the Worker's clock.\n */\n track<K extends ClientEventName<C>>(\n userId: string,\n event: K,\n ...args: TrackArgs<C, K>\n ): Promise<IngestResult> {\n const [payload = {}, options = {}] = args as [Record<string, unknown>?, TrackOptions?];\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseEvent(event, payload);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/events', event, result.issues));\n }\n return this.post<IngestResult>('/events', {\n userId,\n event,\n payload,\n ...(options.id === undefined ? {} : { id: options.id }),\n ...(options.ts === undefined ? {} : { ts: options.ts }),\n });\n }\n\n /**\n * A push token the app registered for the user (call it on every registration: a token moves\n * to the latest user). The engine keeps devices itself — pushes go to them, a token APNs / FCM\n * reports as gone is dropped — and journeys waiting on `reachable('push')` wake.\n */\n registerDevice(\n userId: string,\n device: { token: string; platform: 'ios' | 'android' | 'web'; ts?: number }\n ): Promise<{ ok: true; woke: number }> {\n const { token, platform, ts } = device;\n return this.post('/devices', {\n userId,\n token,\n platform,\n ...(ts === undefined ? {} : { ts }),\n });\n }\n\n /**\n * A journey push tapped open: the `message_id` the push carried, reported for the user who\n * tapped it. Recorded as a human open on the copy (an email's opens are tracked by the Worker).\n */\n messageOpened(userId: string, messageId: string): Promise<{ ok: true; recorded: boolean }> {\n return this.post('/messages/opened', { userId, messageId });\n }\n\n /**\n * A deleted account: the engine records the erasure, stops the user's running journeys, then\n * erases everything it keeps about them (profile, devices, events, reporting). Once this\n * resolves the request is durable: `erased: false` means an instance would not stop yet, and\n * the Worker's scheduled sweep finishes the job.\n */\n forgetUser(userId: string): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n return this.post('/users/forget', { userId });\n }\n\n /** The user signed out on that device: the token stays registered, bound to nobody. */\n unbindDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/unbind', { token });\n }\n\n /** Forget a push token for good (an uninstall the app learned of, a token it replaced). */\n removeDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/remove', { token });\n }\n\n /**\n * Where the user stands, topic × channel: for `email` and for `push`, `all` (false once they\n * opted out of the whole channel) and each declared topic (false once they opted out of it on\n * that channel) — the engine's record, which a preference page shows as it is. The two are\n * independent flags: a topic answers its own opt-out even while `all` is false, so `all: false`\n * masks every topic however they read (nothing is sent on the channel until it is true again).\n * Rejects with a 410 for an erased user.\n */\n getSubscriptions(userId: string): Promise<Subscriptions<ClientTopic<C>>> {\n return this.request('GET', subscriptionsPath(userId));\n }\n\n /**\n * Set subscriptions: only what the patch names changes, per channel (`{ email: { topics: {\n * newsletter: false } } }` opts out of one topic's emails, `{ push: { all: false } }` of every\n * push — the in-app opt-out App Store Review Guideline 4.5.4 asks for). `all: true` clears the\n * channel-wide opt-out only: a topic opted out on its own stays off until the patch names it,\n * so a preference page's \"subscribe to all\" sends `all: true` plus every topic `true`. Answers\n * the subscriptions as they now stand. With a contract, a topic it does not declare is refused\n * here, before the request; the engine refuses it too.\n */\n setSubscriptions(\n userId: string,\n patch: SubscriptionsPatch<ClientTopic<C>>\n ): Promise<Subscriptions<ClientTopic<C>>> {\n const contract = this.options.contract;\n if (contract !== undefined) {\n const issues: ContractIssue[] = [];\n for (const channel of SUBSCRIPTION_CHANNELS) {\n for (const topic of Object.keys(patch[channel]?.topics ?? {})) {\n // `includes`, not the predicate: narrowing `string` by a `topic is string` leaves `never`\n if (contract.topics.includes(topic)) continue;\n issues.push({ path: `${channel}.topics.${topic}`, message: 'not a declared topic' });\n }\n }\n if (issues.length > 0) {\n return Promise.reject(new JourneyValidationError('/subscriptions', undefined, issues));\n }\n }\n return this.request('PUT', subscriptionsPath(userId), patch);\n }\n\n /** Deploy a compiled bundle (BundleIR). */\n deploy(bundle: unknown): Promise<DeployResult> {\n return this.post<DeployResult>('/deploy', bundle);\n }\n\n private post<T>(path: string, body: unknown): Promise<T> {\n return this.request<T>('POST', path, body);\n }\n\n private async request<T>(\n method: 'GET' | 'POST' | 'PUT',\n path: string,\n body?: unknown\n ): Promise<T> {\n const headers: Record<string, string> = {};\n if (body !== undefined) headers['content-type'] = 'application/json';\n if (this.options.token !== undefined) headers.authorization = `Bearer ${this.options.token}`;\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(`${this.options.origin.replace(/\\/$/, '')}${path}`, {\n method,\n headers,\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n });\n if (!response.ok) throw new JourneyClientError(path, response.status, await response.text());\n return (await response.json()) as T;\n }\n}\n\nconst subscriptionsPath = (userId: string) => `/subscriptions/${encodeURIComponent(userId)}`;\n"],"mappings":"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"}
1
+ {"version":3,"file":"client.cjs","names":["SUBSCRIPTION_CHANNELS"],"sources":["../src/client.ts"],"sourcesContent":["import type * as core from 'zod/v4/core';\nimport type { Contract, ContractIssue, ProfilePatch, Topic } from './contract';\nimport {\n type DeployResult,\n type IdentifyResult,\n type IngestResult,\n type MessageSubscriptions,\n SUBSCRIPTION_CHANNELS,\n type Subscriptions,\n type SubscriptionsPatch,\n} from './host/ingest';\nimport type { ProfileProps } from './store';\n\n/**\n * `@shware/workflow/client` — the producer side of the ingest API, for the\n * service that knows what users do (an api server, a webhook handler). Plain\n * fetch, no dependencies; errors carry the status and body so a caller can\n * log them without failing the request that triggered the report.\n */\n\nexport interface JourneyClientOptions<C extends Contract | undefined = undefined> {\n /**\n * The shared `defineContract(...)`: types `track` (the event name picks the\n * payload type) and `identify`, and checks both before anything is sent.\n */\n contract?: C;\n /** The journey Worker's origin, e.g. `https://workflow.example.com`. */\n origin: string;\n /** Its API_TOKEN, sent as a bearer; omit against an open local instance. */\n token?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport class JourneyClientError extends Error {\n constructor(\n readonly path: string,\n readonly status: number,\n readonly body: string\n ) {\n super(`journey ${path} failed: ${status} ${body}`);\n this.name = 'JourneyClientError';\n }\n}\n\n/** A payload or profile patch the contract refuses — raised before the request, with the field paths. */\nexport class JourneyValidationError extends Error {\n constructor(\n readonly path: string,\n readonly event: string | undefined,\n readonly issues: ContractIssue[]\n ) {\n super(\n `journey ${path}${event === undefined ? '' : ` '${event}'`} refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n this.name = 'JourneyValidationError';\n }\n}\n\ntype EventsOf<C> = C extends Contract<infer E> ? E : never;\n/** Event names: the contract's, or any string without one. */\nexport type ClientEventName<C> = [C] extends [undefined] ? string : keyof EventsOf<C> & string;\ntype TrackArgs<C, K> = [C] extends [undefined]\n ? [payload?: Record<string, unknown>, options?: TrackOptions]\n : K extends keyof EventsOf<C>\n ? [payload: core.input<EventsOf<C>[K]>, options?: TrackOptions]\n : never;\ntype IdentifyProps<C> = [C] extends [undefined]\n ? ProfileProps\n : C extends Contract<Record<string, core.$ZodObject>, infer P>\n ? ProfilePatch<P>\n : never;\n\n/** The topics the client's contract declares; any string without a contract. */\nexport type ClientTopic<C> = [C] extends [undefined] ? string : Topic<C>;\n\nexport type {\n ChannelSubscriptions,\n ChannelSubscriptionsPatch,\n MessageOrigin,\n MessageSubscriptions,\n SubscriptionChannel,\n Subscriptions,\n SubscriptionsPatch,\n} from './host/ingest';\n\nexport interface TrackOptions {\n /** The occurrence's identity: a retry with the same id is recorded once. */\n id?: string;\n /** When it happened (epoch ms); the Worker's clock when omitted. */\n ts?: number;\n}\n\nexport class JourneyClient<C extends Contract | undefined = undefined> {\n constructor(private readonly options: JourneyClientOptions<C>) {}\n\n /** Merge profile properties (`null` clears one). Every property a journey reads must be identified before the event that starts it. */\n identify(userId: string, props: IdentifyProps<C>): Promise<IdentifyResult> {\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseProfilePatch(props);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/identify', undefined, result.issues));\n }\n return this.post<IdentifyResult>('/identify', { userId, props });\n }\n\n /**\n * Record an event; the Worker starts every journey whose trigger matches it\n * and wakes waits on it. A caller that retries should pass the same `id`\n * each time: the Worker stores one occurrence per id, so a retry after a\n * timeout cannot double-count the event. `ts` defaults to the Worker's clock.\n */\n track<K extends ClientEventName<C>>(\n userId: string,\n event: K,\n ...args: TrackArgs<C, K>\n ): Promise<IngestResult> {\n const [payload = {}, options = {}] = args as [Record<string, unknown>?, TrackOptions?];\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseEvent(event, payload);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/events', event, result.issues));\n }\n return this.post<IngestResult>('/events', {\n userId,\n event,\n payload,\n ...(options.id === undefined ? {} : { id: options.id }),\n ...(options.ts === undefined ? {} : { ts: options.ts }),\n });\n }\n\n /**\n * A push token the app registered for the user (call it on every registration: a token moves\n * to the latest user). The engine keeps devices itself — pushes go to them, a token APNs / FCM\n * reports as gone is dropped — and journeys waiting on `reachable('push')` wake.\n */\n registerDevice(\n userId: string,\n device: { token: string; platform: 'ios' | 'android' | 'web'; ts?: number }\n ): Promise<{ ok: true; woke: number }> {\n const { token, platform, ts } = device;\n return this.post('/devices', {\n userId,\n token,\n platform,\n ...(ts === undefined ? {} : { ts }),\n });\n }\n\n /**\n * A journey push tapped open: the `message_id` the push carried, reported for the user who\n * tapped it. Recorded as a human open on the copy (an email's opens are tracked by the Worker).\n */\n messageOpened(userId: string, messageId: string): Promise<{ ok: true; recorded: boolean }> {\n return this.post('/messages/opened', { userId, messageId });\n }\n\n /**\n * A deleted account: the engine records the erasure, stops the user's running journeys, then\n * erases everything it keeps about them (profile, devices, events, reporting). Once this\n * resolves the request is durable: `erased: false` means an instance would not stop yet, and\n * the Worker's scheduled sweep finishes the job.\n */\n forgetUser(userId: string): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n return this.post('/users/forget', { userId });\n }\n\n /** The user signed out on that device: the token stays registered, bound to nobody. */\n unbindDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/unbind', { token });\n }\n\n /** Forget a push token for good (an uninstall the app learned of, a token it replaced). */\n removeDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/remove', { token });\n }\n\n /**\n * Where the user stands, topic × channel: for `email` and for `push`, `all` (false once they\n * opted out of the whole channel) and each declared topic (false once they opted out of it on\n * that channel) — the engine's record, which a preference page shows as it is. The two are\n * independent flags: a topic answers its own opt-out even while `all` is false, so `all: false`\n * masks every topic however they read (nothing is sent on the channel until it is true again).\n * Rejects with a 410 for an erased user.\n */\n getSubscriptions(userId: string): Promise<Subscriptions<ClientTopic<C>>> {\n return this.request('GET', subscriptionsPath(userId));\n }\n\n /**\n * Set subscriptions: only what the patch names changes, per channel (`{ email: { topics: {\n * newsletter: false } } }` opts out of one topic's emails, `{ push: { all: false } }` of every\n * push — the in-app opt-out App Store Review Guideline 4.5.4 asks for). `all: true` clears the\n * channel-wide opt-out only: a topic opted out on its own stays off until the patch names it,\n * so a preference page's \"subscribe to all\" sends `all: true` plus every topic `true`. Answers\n * the subscriptions as they now stand. With a contract, a topic it does not declare is refused\n * here, before the request; the engine refuses it too.\n */\n setSubscriptions(\n userId: string,\n patch: SubscriptionsPatch<ClientTopic<C>>\n ): Promise<Subscriptions<ClientTopic<C>>> {\n const refused = this.undeclaredTopics(patch);\n if (refused !== undefined) return Promise.reject(refused);\n return this.request('PUT', subscriptionsPath(userId), patch);\n }\n\n /**\n * `getSubscriptions` for the recipient of one copy, by the message id an email's preferences\n * link carries (`email.preferencesUrl?m=<message id>`): how a preference page works without a\n * sign-in. The answer adds `message: { channel, topic }`, the copy the page was opened from,\n * so it can highlight that topic. Rejects with a 404 for an id that names no copy — never\n * sent, or erased with its user — and for a transactional one; the page should show the link\n * as expired. The message id is the capability, so the app proxies this behind its own API\n * and rate-limits it.\n */\n getSubscriptionsByMessage(messageId: string): Promise<MessageSubscriptions<ClientTopic<C>>> {\n return this.request('GET', subscriptionsByMessagePath(messageId));\n }\n\n /** `setSubscriptions` for the recipient of one copy; the patch and the answer are those of `getSubscriptionsByMessage`. */\n setSubscriptionsByMessage(\n messageId: string,\n patch: SubscriptionsPatch<ClientTopic<C>>\n ): Promise<MessageSubscriptions<ClientTopic<C>>> {\n const refused = this.undeclaredTopics(patch);\n if (refused !== undefined) return Promise.reject(refused);\n return this.request('PUT', subscriptionsByMessagePath(messageId), patch);\n }\n\n /** With a contract, the refusal of a patch naming a topic it does not declare; undefined when the patch is fine. */\n private undeclaredTopics(\n patch: SubscriptionsPatch<ClientTopic<C>>\n ): JourneyValidationError | undefined {\n const contract = this.options.contract;\n if (contract === undefined) return undefined;\n const issues: ContractIssue[] = [];\n for (const channel of SUBSCRIPTION_CHANNELS) {\n for (const topic of Object.keys(patch[channel]?.topics ?? {})) {\n // `includes`, not the predicate: narrowing `string` by a `topic is string` leaves `never`\n if (contract.topics.includes(topic)) continue;\n issues.push({ path: `${channel}.topics.${topic}`, message: 'not a declared topic' });\n }\n }\n return issues.length > 0\n ? new JourneyValidationError('/subscriptions', undefined, issues)\n : undefined;\n }\n\n /** Deploy a compiled bundle (BundleIR). */\n deploy(bundle: unknown): Promise<DeployResult> {\n return this.post<DeployResult>('/deploy', bundle);\n }\n\n private post<T>(path: string, body: unknown): Promise<T> {\n return this.request<T>('POST', path, body);\n }\n\n private async request<T>(\n method: 'GET' | 'POST' | 'PUT',\n path: string,\n body?: unknown\n ): Promise<T> {\n const headers: Record<string, string> = {};\n if (body !== undefined) headers['content-type'] = 'application/json';\n if (this.options.token !== undefined) headers.authorization = `Bearer ${this.options.token}`;\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(`${this.options.origin.replace(/\\/$/, '')}${path}`, {\n method,\n headers,\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n });\n if (!response.ok) throw new JourneyClientError(path, response.status, await response.text());\n return (await response.json()) as T;\n }\n}\n\nconst subscriptionsPath = (userId: string) => `/subscriptions/${encodeURIComponent(userId)}`;\nconst subscriptionsByMessagePath = (messageId: string) =>\n `/subscriptions/by-message/${encodeURIComponent(messageId)}`;\n"],"mappings":"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"}
package/dist/client.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Contract, ContractIssue, ProfilePatch, Topic } from "./contract.cjs";
2
2
  import { ProfileProps } from "./store/index.cjs";
3
- import { ChannelSubscriptions, ChannelSubscriptionsPatch, DeployResult, IdentifyResult, IngestResult, SubscriptionChannel, Subscriptions, SubscriptionsPatch } from "./host/ingest.cjs";
3
+ import { ChannelSubscriptions, ChannelSubscriptionsPatch, DeployResult, IdentifyResult, IngestResult, MessageOrigin, MessageSubscriptions, SubscriptionChannel, Subscriptions, SubscriptionsPatch } from "./host/ingest.cjs";
4
4
  import * as core from "zod/v4/core";
5
5
  /**
6
6
  * `@shware/workflow/client` — the producer side of the ingest API, for the
@@ -120,10 +120,24 @@ export declare class JourneyClient<C extends Contract | undefined = undefined> {
120
120
  * here, before the request; the engine refuses it too.
121
121
  */
122
122
  setSubscriptions(userId: string, patch: SubscriptionsPatch<ClientTopic<C>>): Promise<Subscriptions<ClientTopic<C>>>;
123
+ /**
124
+ * `getSubscriptions` for the recipient of one copy, by the message id an email's preferences
125
+ * link carries (`email.preferencesUrl?m=<message id>`): how a preference page works without a
126
+ * sign-in. The answer adds `message: { channel, topic }`, the copy the page was opened from,
127
+ * so it can highlight that topic. Rejects with a 404 for an id that names no copy — never
128
+ * sent, or erased with its user — and for a transactional one; the page should show the link
129
+ * as expired. The message id is the capability, so the app proxies this behind its own API
130
+ * and rate-limits it.
131
+ */
132
+ getSubscriptionsByMessage(messageId: string): Promise<MessageSubscriptions<ClientTopic<C>>>;
133
+ /** `setSubscriptions` for the recipient of one copy; the patch and the answer are those of `getSubscriptionsByMessage`. */
134
+ setSubscriptionsByMessage(messageId: string, patch: SubscriptionsPatch<ClientTopic<C>>): Promise<MessageSubscriptions<ClientTopic<C>>>;
135
+ /** With a contract, the refusal of a patch naming a topic it does not declare; undefined when the patch is fine. */
136
+ private undeclaredTopics;
123
137
  /** Deploy a compiled bundle (BundleIR). */
124
138
  deploy(bundle: unknown): Promise<DeployResult>;
125
139
  private post;
126
140
  private request;
127
141
  }
128
- export type { ChannelSubscriptions, ChannelSubscriptionsPatch, SubscriptionChannel, Subscriptions, SubscriptionsPatch };
142
+ export type { ChannelSubscriptions, ChannelSubscriptionsPatch, MessageOrigin, MessageSubscriptions, SubscriptionChannel, Subscriptions, SubscriptionsPatch };
129
143
  //# sourceMappingURL=client.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.cts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;;;;;;iBAmBiB,qBAAqB,UAAU;;;;;EAK9C,WAAW;;EAEX;;EAEA;;EAEA,eAAe;;qBAGJ,2BAA2B;WAE3B;WACA;WACA;EAHX,YACW,cACA,gBACA;;;qBAQA,+BAA+B;WAE/B;WACA;WACA,QAAQ;EAHnB,YACW,cACA,2BACA,QAAQ;;KAWhB,SAAS,KAAK,UAAU,eAAe,KAAK;;YAErC,gBAAgB,MAAM,wCAAwC,SAAS;KAC9E,UAAU,GAAG,MAAM,0BACnB,UAAU,yBAAyB,UAAU,gBAC9C,gBAAgB,SAAS,MACtB,SAAS,KAAK,MAAM,SAAS,GAAG,KAAK,UAAU;KAEjD,cAAc,MAAM,yBACrB,eACA,UAAU,SAAS,eAAe,KAAK,mBAAmB,KACxD,aAAa;;YAIP,YAAY,MAAM,kCAAkC,MAAM;iBAUrD;;EAEf;;EAEA;;qBAGW,cAAc,UAAU;mBACN;EAA7B,YAA6B,SAAS,qBAAqB;;EAG3D,SAAS,gBAAgB,OAAO,cAAc,KAAK,QAAQ;;;;;;;EAgB3D,MAAM,UAAU,gBAAgB,IAC9B,gBACA,OAAO,MACJ,MAAM,UAAU,GAAG,KACrB,QAAQ;;;;;;EAsBX,eACE,gBACA;IAAU;IAAe;IAAqC;MAC7D;IAAU;IAAU;;;;;;EAcvB,cAAc,gBAAgB,oBAAoB;IAAU;IAAU;;;;;;;;EAUtE,WAAW,iBAAiB;IAAU;IAAU;IAAiB;;;EAKjE,aAAa,gBAAgB;IAAU;IAAU;;;EAKjD,aAAa,gBAAgB;IAAU;IAAU;;;;;;;;;;EAYjD,iBAAiB,iBAAiB,QAAQ,cAAc,YAAY;;;;;;;;;;EAapE,iBACE,gBACA,OAAO,mBAAmB,YAAY,MACrC,QAAQ,cAAc,YAAY;;EAmBrC,OAAO,kBAAkB,QAAQ;UAIzB;UAIM"}
1
+ {"version":3,"file":"client.d.cts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;;;;;;iBAoBiB,qBAAqB,UAAU;;;;;EAK9C,WAAW;;EAEX;;EAEA;;EAEA,eAAe;;qBAGJ,2BAA2B;WAE3B;WACA;WACA;EAHX,YACW,cACA,gBACA;;;qBAQA,+BAA+B;WAE/B;WACA;WACA,QAAQ;EAHnB,YACW,cACA,2BACA,QAAQ;;KAWhB,SAAS,KAAK,UAAU,eAAe,KAAK;;YAErC,gBAAgB,MAAM,wCAAwC,SAAS;KAC9E,UAAU,GAAG,MAAM,0BACnB,UAAU,yBAAyB,UAAU,gBAC9C,gBAAgB,SAAS,MACtB,SAAS,KAAK,MAAM,SAAS,GAAG,KAAK,UAAU;KAEjD,cAAc,MAAM,yBACrB,eACA,UAAU,SAAS,eAAe,KAAK,mBAAmB,KACxD,aAAa;;YAIP,YAAY,MAAM,kCAAkC,MAAM;iBAYrD;;EAEf;;EAEA;;qBAGW,cAAc,UAAU;mBACN;EAA7B,YAA6B,SAAS,qBAAqB;;EAG3D,SAAS,gBAAgB,OAAO,cAAc,KAAK,QAAQ;;;;;;;EAgB3D,MAAM,UAAU,gBAAgB,IAC9B,gBACA,OAAO,MACJ,MAAM,UAAU,GAAG,KACrB,QAAQ;;;;;;EAsBX,eACE,gBACA;IAAU;IAAe;IAAqC;MAC7D;IAAU;IAAU;;;;;;EAcvB,cAAc,gBAAgB,oBAAoB;IAAU;IAAU;;;;;;;;EAUtE,WAAW,iBAAiB;IAAU;IAAU;IAAiB;;;EAKjE,aAAa,gBAAgB;IAAU;IAAU;;;EAKjD,aAAa,gBAAgB;IAAU;IAAU;;;;;;;;;;EAYjD,iBAAiB,iBAAiB,QAAQ,cAAc,YAAY;;;;;;;;;;EAapE,iBACE,gBACA,OAAO,mBAAmB,YAAY,MACrC,QAAQ,cAAc,YAAY;;;;;;;;;;EAerC,0BAA0B,oBAAoB,QAAQ,qBAAqB,YAAY;;EAKvF,0BACE,mBACA,OAAO,mBAAmB,YAAY,MACrC,QAAQ,qBAAqB,YAAY;;UAOpC;;EAmBR,OAAO,kBAAkB,QAAQ;UAIzB;UAIM"}
package/dist/client.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Contract, ContractIssue, ProfilePatch, Topic } from "./contract.mjs";
2
2
  import { ProfileProps } from "./store/index.mjs";
3
- import { ChannelSubscriptions, ChannelSubscriptionsPatch, DeployResult, IdentifyResult, IngestResult, SubscriptionChannel, Subscriptions, SubscriptionsPatch } from "./host/ingest.mjs";
3
+ import { ChannelSubscriptions, ChannelSubscriptionsPatch, DeployResult, IdentifyResult, IngestResult, MessageOrigin, MessageSubscriptions, SubscriptionChannel, Subscriptions, SubscriptionsPatch } from "./host/ingest.mjs";
4
4
  import * as core from "zod/v4/core";
5
5
  /**
6
6
  * `@shware/workflow/client` — the producer side of the ingest API, for the
@@ -120,10 +120,24 @@ export declare class JourneyClient<C extends Contract | undefined = undefined> {
120
120
  * here, before the request; the engine refuses it too.
121
121
  */
122
122
  setSubscriptions(userId: string, patch: SubscriptionsPatch<ClientTopic<C>>): Promise<Subscriptions<ClientTopic<C>>>;
123
+ /**
124
+ * `getSubscriptions` for the recipient of one copy, by the message id an email's preferences
125
+ * link carries (`email.preferencesUrl?m=<message id>`): how a preference page works without a
126
+ * sign-in. The answer adds `message: { channel, topic }`, the copy the page was opened from,
127
+ * so it can highlight that topic. Rejects with a 404 for an id that names no copy — never
128
+ * sent, or erased with its user — and for a transactional one; the page should show the link
129
+ * as expired. The message id is the capability, so the app proxies this behind its own API
130
+ * and rate-limits it.
131
+ */
132
+ getSubscriptionsByMessage(messageId: string): Promise<MessageSubscriptions<ClientTopic<C>>>;
133
+ /** `setSubscriptions` for the recipient of one copy; the patch and the answer are those of `getSubscriptionsByMessage`. */
134
+ setSubscriptionsByMessage(messageId: string, patch: SubscriptionsPatch<ClientTopic<C>>): Promise<MessageSubscriptions<ClientTopic<C>>>;
135
+ /** With a contract, the refusal of a patch naming a topic it does not declare; undefined when the patch is fine. */
136
+ private undeclaredTopics;
123
137
  /** Deploy a compiled bundle (BundleIR). */
124
138
  deploy(bundle: unknown): Promise<DeployResult>;
125
139
  private post;
126
140
  private request;
127
141
  }
128
- export type { ChannelSubscriptions, ChannelSubscriptionsPatch, SubscriptionChannel, Subscriptions, SubscriptionsPatch };
142
+ export type { ChannelSubscriptions, ChannelSubscriptionsPatch, MessageOrigin, MessageSubscriptions, SubscriptionChannel, Subscriptions, SubscriptionsPatch };
129
143
  //# sourceMappingURL=client.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.mts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;;;;;;iBAmBiB,qBAAqB,UAAU;;;;;EAK9C,WAAW;;EAEX;;EAEA;;EAEA,eAAe;;qBAGJ,2BAA2B;WAE3B;WACA;WACA;EAHX,YACW,cACA,gBACA;;;qBAQA,+BAA+B;WAE/B;WACA;WACA,QAAQ;EAHnB,YACW,cACA,2BACA,QAAQ;;KAWhB,SAAS,KAAK,UAAU,eAAe,KAAK;;YAErC,gBAAgB,MAAM,wCAAwC,SAAS;KAC9E,UAAU,GAAG,MAAM,0BACnB,UAAU,yBAAyB,UAAU,gBAC9C,gBAAgB,SAAS,MACtB,SAAS,KAAK,MAAM,SAAS,GAAG,KAAK,UAAU;KAEjD,cAAc,MAAM,yBACrB,eACA,UAAU,SAAS,eAAe,KAAK,mBAAmB,KACxD,aAAa;;YAIP,YAAY,MAAM,kCAAkC,MAAM;iBAUrD;;EAEf;;EAEA;;qBAGW,cAAc,UAAU;mBACN;EAA7B,YAA6B,SAAS,qBAAqB;;EAG3D,SAAS,gBAAgB,OAAO,cAAc,KAAK,QAAQ;;;;;;;EAgB3D,MAAM,UAAU,gBAAgB,IAC9B,gBACA,OAAO,MACJ,MAAM,UAAU,GAAG,KACrB,QAAQ;;;;;;EAsBX,eACE,gBACA;IAAU;IAAe;IAAqC;MAC7D;IAAU;IAAU;;;;;;EAcvB,cAAc,gBAAgB,oBAAoB;IAAU;IAAU;;;;;;;;EAUtE,WAAW,iBAAiB;IAAU;IAAU;IAAiB;;;EAKjE,aAAa,gBAAgB;IAAU;IAAU;;;EAKjD,aAAa,gBAAgB;IAAU;IAAU;;;;;;;;;;EAYjD,iBAAiB,iBAAiB,QAAQ,cAAc,YAAY;;;;;;;;;;EAapE,iBACE,gBACA,OAAO,mBAAmB,YAAY,MACrC,QAAQ,cAAc,YAAY;;EAmBrC,OAAO,kBAAkB,QAAQ;UAIzB;UAIM"}
1
+ {"version":3,"file":"client.d.mts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;;;;;;iBAoBiB,qBAAqB,UAAU;;;;;EAK9C,WAAW;;EAEX;;EAEA;;EAEA,eAAe;;qBAGJ,2BAA2B;WAE3B;WACA;WACA;EAHX,YACW,cACA,gBACA;;;qBAQA,+BAA+B;WAE/B;WACA;WACA,QAAQ;EAHnB,YACW,cACA,2BACA,QAAQ;;KAWhB,SAAS,KAAK,UAAU,eAAe,KAAK;;YAErC,gBAAgB,MAAM,wCAAwC,SAAS;KAC9E,UAAU,GAAG,MAAM,0BACnB,UAAU,yBAAyB,UAAU,gBAC9C,gBAAgB,SAAS,MACtB,SAAS,KAAK,MAAM,SAAS,GAAG,KAAK,UAAU;KAEjD,cAAc,MAAM,yBACrB,eACA,UAAU,SAAS,eAAe,KAAK,mBAAmB,KACxD,aAAa;;YAIP,YAAY,MAAM,kCAAkC,MAAM;iBAYrD;;EAEf;;EAEA;;qBAGW,cAAc,UAAU;mBACN;EAA7B,YAA6B,SAAS,qBAAqB;;EAG3D,SAAS,gBAAgB,OAAO,cAAc,KAAK,QAAQ;;;;;;;EAgB3D,MAAM,UAAU,gBAAgB,IAC9B,gBACA,OAAO,MACJ,MAAM,UAAU,GAAG,KACrB,QAAQ;;;;;;EAsBX,eACE,gBACA;IAAU;IAAe;IAAqC;MAC7D;IAAU;IAAU;;;;;;EAcvB,cAAc,gBAAgB,oBAAoB;IAAU;IAAU;;;;;;;;EAUtE,WAAW,iBAAiB;IAAU;IAAU;IAAiB;;;EAKjE,aAAa,gBAAgB;IAAU;IAAU;;;EAKjD,aAAa,gBAAgB;IAAU;IAAU;;;;;;;;;;EAYjD,iBAAiB,iBAAiB,QAAQ,cAAc,YAAY;;;;;;;;;;EAapE,iBACE,gBACA,OAAO,mBAAmB,YAAY,MACrC,QAAQ,cAAc,YAAY;;;;;;;;;;EAerC,0BAA0B,oBAAoB,QAAQ,qBAAqB,YAAY;;EAKvF,0BACE,mBACA,OAAO,mBAAmB,YAAY,MACrC,QAAQ,qBAAqB,YAAY;;UAOpC;;EAmBR,OAAO,kBAAkB,QAAQ;UAIzB;UAIM"}
package/dist/client.mjs CHANGED
@@ -125,19 +125,41 @@ var JourneyClient = class {
125
125
  * here, before the request; the engine refuses it too.
126
126
  */
127
127
  setSubscriptions(userId, patch) {
128
+ const refused = this.undeclaredTopics(patch);
129
+ if (refused !== void 0) return Promise.reject(refused);
130
+ return this.request("PUT", subscriptionsPath(userId), patch);
131
+ }
132
+ /**
133
+ * `getSubscriptions` for the recipient of one copy, by the message id an email's preferences
134
+ * link carries (`email.preferencesUrl?m=<message id>`): how a preference page works without a
135
+ * sign-in. The answer adds `message: { channel, topic }`, the copy the page was opened from,
136
+ * so it can highlight that topic. Rejects with a 404 for an id that names no copy — never
137
+ * sent, or erased with its user — and for a transactional one; the page should show the link
138
+ * as expired. The message id is the capability, so the app proxies this behind its own API
139
+ * and rate-limits it.
140
+ */
141
+ getSubscriptionsByMessage(messageId) {
142
+ return this.request("GET", subscriptionsByMessagePath(messageId));
143
+ }
144
+ /** `setSubscriptions` for the recipient of one copy; the patch and the answer are those of `getSubscriptionsByMessage`. */
145
+ setSubscriptionsByMessage(messageId, patch) {
146
+ const refused = this.undeclaredTopics(patch);
147
+ if (refused !== void 0) return Promise.reject(refused);
148
+ return this.request("PUT", subscriptionsByMessagePath(messageId), patch);
149
+ }
150
+ /** With a contract, the refusal of a patch naming a topic it does not declare; undefined when the patch is fine. */
151
+ undeclaredTopics(patch) {
128
152
  const contract = this.options.contract;
129
- if (contract !== void 0) {
130
- const issues = [];
131
- for (const channel of SUBSCRIPTION_CHANNELS) for (const topic of Object.keys(patch[channel]?.topics ?? {})) {
132
- if (contract.topics.includes(topic)) continue;
133
- issues.push({
134
- path: `${channel}.topics.${topic}`,
135
- message: "not a declared topic"
136
- });
137
- }
138
- if (issues.length > 0) return Promise.reject(new JourneyValidationError("/subscriptions", void 0, issues));
153
+ if (contract === void 0) return void 0;
154
+ const issues = [];
155
+ for (const channel of SUBSCRIPTION_CHANNELS) for (const topic of Object.keys(patch[channel]?.topics ?? {})) {
156
+ if (contract.topics.includes(topic)) continue;
157
+ issues.push({
158
+ path: `${channel}.topics.${topic}`,
159
+ message: "not a declared topic"
160
+ });
139
161
  }
140
- return this.request("PUT", subscriptionsPath(userId), patch);
162
+ return issues.length > 0 ? new JourneyValidationError("/subscriptions", void 0, issues) : void 0;
141
163
  }
142
164
  /** Deploy a compiled bundle (BundleIR). */
143
165
  deploy(bundle) {
@@ -160,6 +182,7 @@ var JourneyClient = class {
160
182
  }
161
183
  };
162
184
  const subscriptionsPath = (userId) => `/subscriptions/${encodeURIComponent(userId)}`;
185
+ const subscriptionsByMessagePath = (messageId) => `/subscriptions/by-message/${encodeURIComponent(messageId)}`;
163
186
  export { JourneyClient, JourneyClientError, JourneyValidationError };
164
187
 
165
188
  //# sourceMappingURL=client.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.mjs","names":[],"sources":["../src/client.ts"],"sourcesContent":["import type * as core from 'zod/v4/core';\nimport type { Contract, ContractIssue, ProfilePatch, Topic } from './contract';\nimport {\n type DeployResult,\n type IdentifyResult,\n type IngestResult,\n SUBSCRIPTION_CHANNELS,\n type Subscriptions,\n type SubscriptionsPatch,\n} from './host/ingest';\nimport type { ProfileProps } from './store';\n\n/**\n * `@shware/workflow/client` — the producer side of the ingest API, for the\n * service that knows what users do (an api server, a webhook handler). Plain\n * fetch, no dependencies; errors carry the status and body so a caller can\n * log them without failing the request that triggered the report.\n */\n\nexport interface JourneyClientOptions<C extends Contract | undefined = undefined> {\n /**\n * The shared `defineContract(...)`: types `track` (the event name picks the\n * payload type) and `identify`, and checks both before anything is sent.\n */\n contract?: C;\n /** The journey Worker's origin, e.g. `https://workflow.example.com`. */\n origin: string;\n /** Its API_TOKEN, sent as a bearer; omit against an open local instance. */\n token?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport class JourneyClientError extends Error {\n constructor(\n readonly path: string,\n readonly status: number,\n readonly body: string\n ) {\n super(`journey ${path} failed: ${status} ${body}`);\n this.name = 'JourneyClientError';\n }\n}\n\n/** A payload or profile patch the contract refuses — raised before the request, with the field paths. */\nexport class JourneyValidationError extends Error {\n constructor(\n readonly path: string,\n readonly event: string | undefined,\n readonly issues: ContractIssue[]\n ) {\n super(\n `journey ${path}${event === undefined ? '' : ` '${event}'`} refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n this.name = 'JourneyValidationError';\n }\n}\n\ntype EventsOf<C> = C extends Contract<infer E> ? E : never;\n/** Event names: the contract's, or any string without one. */\nexport type ClientEventName<C> = [C] extends [undefined] ? string : keyof EventsOf<C> & string;\ntype TrackArgs<C, K> = [C] extends [undefined]\n ? [payload?: Record<string, unknown>, options?: TrackOptions]\n : K extends keyof EventsOf<C>\n ? [payload: core.input<EventsOf<C>[K]>, options?: TrackOptions]\n : never;\ntype IdentifyProps<C> = [C] extends [undefined]\n ? ProfileProps\n : C extends Contract<Record<string, core.$ZodObject>, infer P>\n ? ProfilePatch<P>\n : never;\n\n/** The topics the client's contract declares; any string without a contract. */\nexport type ClientTopic<C> = [C] extends [undefined] ? string : Topic<C>;\n\nexport type {\n ChannelSubscriptions,\n ChannelSubscriptionsPatch,\n SubscriptionChannel,\n Subscriptions,\n SubscriptionsPatch,\n} from './host/ingest';\n\nexport interface TrackOptions {\n /** The occurrence's identity: a retry with the same id is recorded once. */\n id?: string;\n /** When it happened (epoch ms); the Worker's clock when omitted. */\n ts?: number;\n}\n\nexport class JourneyClient<C extends Contract | undefined = undefined> {\n constructor(private readonly options: JourneyClientOptions<C>) {}\n\n /** Merge profile properties (`null` clears one). Every property a journey reads must be identified before the event that starts it. */\n identify(userId: string, props: IdentifyProps<C>): Promise<IdentifyResult> {\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseProfilePatch(props);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/identify', undefined, result.issues));\n }\n return this.post<IdentifyResult>('/identify', { userId, props });\n }\n\n /**\n * Record an event; the Worker starts every journey whose trigger matches it\n * and wakes waits on it. A caller that retries should pass the same `id`\n * each time: the Worker stores one occurrence per id, so a retry after a\n * timeout cannot double-count the event. `ts` defaults to the Worker's clock.\n */\n track<K extends ClientEventName<C>>(\n userId: string,\n event: K,\n ...args: TrackArgs<C, K>\n ): Promise<IngestResult> {\n const [payload = {}, options = {}] = args as [Record<string, unknown>?, TrackOptions?];\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseEvent(event, payload);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/events', event, result.issues));\n }\n return this.post<IngestResult>('/events', {\n userId,\n event,\n payload,\n ...(options.id === undefined ? {} : { id: options.id }),\n ...(options.ts === undefined ? {} : { ts: options.ts }),\n });\n }\n\n /**\n * A push token the app registered for the user (call it on every registration: a token moves\n * to the latest user). The engine keeps devices itself — pushes go to them, a token APNs / FCM\n * reports as gone is dropped — and journeys waiting on `reachable('push')` wake.\n */\n registerDevice(\n userId: string,\n device: { token: string; platform: 'ios' | 'android' | 'web'; ts?: number }\n ): Promise<{ ok: true; woke: number }> {\n const { token, platform, ts } = device;\n return this.post('/devices', {\n userId,\n token,\n platform,\n ...(ts === undefined ? {} : { ts }),\n });\n }\n\n /**\n * A journey push tapped open: the `message_id` the push carried, reported for the user who\n * tapped it. Recorded as a human open on the copy (an email's opens are tracked by the Worker).\n */\n messageOpened(userId: string, messageId: string): Promise<{ ok: true; recorded: boolean }> {\n return this.post('/messages/opened', { userId, messageId });\n }\n\n /**\n * A deleted account: the engine records the erasure, stops the user's running journeys, then\n * erases everything it keeps about them (profile, devices, events, reporting). Once this\n * resolves the request is durable: `erased: false` means an instance would not stop yet, and\n * the Worker's scheduled sweep finishes the job.\n */\n forgetUser(userId: string): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n return this.post('/users/forget', { userId });\n }\n\n /** The user signed out on that device: the token stays registered, bound to nobody. */\n unbindDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/unbind', { token });\n }\n\n /** Forget a push token for good (an uninstall the app learned of, a token it replaced). */\n removeDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/remove', { token });\n }\n\n /**\n * Where the user stands, topic × channel: for `email` and for `push`, `all` (false once they\n * opted out of the whole channel) and each declared topic (false once they opted out of it on\n * that channel) — the engine's record, which a preference page shows as it is. The two are\n * independent flags: a topic answers its own opt-out even while `all` is false, so `all: false`\n * masks every topic however they read (nothing is sent on the channel until it is true again).\n * Rejects with a 410 for an erased user.\n */\n getSubscriptions(userId: string): Promise<Subscriptions<ClientTopic<C>>> {\n return this.request('GET', subscriptionsPath(userId));\n }\n\n /**\n * Set subscriptions: only what the patch names changes, per channel (`{ email: { topics: {\n * newsletter: false } } }` opts out of one topic's emails, `{ push: { all: false } }` of every\n * push — the in-app opt-out App Store Review Guideline 4.5.4 asks for). `all: true` clears the\n * channel-wide opt-out only: a topic opted out on its own stays off until the patch names it,\n * so a preference page's \"subscribe to all\" sends `all: true` plus every topic `true`. Answers\n * the subscriptions as they now stand. With a contract, a topic it does not declare is refused\n * here, before the request; the engine refuses it too.\n */\n setSubscriptions(\n userId: string,\n patch: SubscriptionsPatch<ClientTopic<C>>\n ): Promise<Subscriptions<ClientTopic<C>>> {\n const contract = this.options.contract;\n if (contract !== undefined) {\n const issues: ContractIssue[] = [];\n for (const channel of SUBSCRIPTION_CHANNELS) {\n for (const topic of Object.keys(patch[channel]?.topics ?? {})) {\n // `includes`, not the predicate: narrowing `string` by a `topic is string` leaves `never`\n if (contract.topics.includes(topic)) continue;\n issues.push({ path: `${channel}.topics.${topic}`, message: 'not a declared topic' });\n }\n }\n if (issues.length > 0) {\n return Promise.reject(new JourneyValidationError('/subscriptions', undefined, issues));\n }\n }\n return this.request('PUT', subscriptionsPath(userId), patch);\n }\n\n /** Deploy a compiled bundle (BundleIR). */\n deploy(bundle: unknown): Promise<DeployResult> {\n return this.post<DeployResult>('/deploy', bundle);\n }\n\n private post<T>(path: string, body: unknown): Promise<T> {\n return this.request<T>('POST', path, body);\n }\n\n private async request<T>(\n method: 'GET' | 'POST' | 'PUT',\n path: string,\n body?: unknown\n ): Promise<T> {\n const headers: Record<string, string> = {};\n if (body !== undefined) headers['content-type'] = 'application/json';\n if (this.options.token !== undefined) headers.authorization = `Bearer ${this.options.token}`;\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(`${this.options.origin.replace(/\\/$/, '')}${path}`, {\n method,\n headers,\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n });\n if (!response.ok) throw new JourneyClientError(path, response.status, await response.text());\n return (await response.json()) as T;\n }\n}\n\nconst subscriptionsPath = (userId: string) => `/subscriptions/${encodeURIComponent(userId)}`;\n"],"mappings":"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"}
1
+ {"version":3,"file":"client.mjs","names":[],"sources":["../src/client.ts"],"sourcesContent":["import type * as core from 'zod/v4/core';\nimport type { Contract, ContractIssue, ProfilePatch, Topic } from './contract';\nimport {\n type DeployResult,\n type IdentifyResult,\n type IngestResult,\n type MessageSubscriptions,\n SUBSCRIPTION_CHANNELS,\n type Subscriptions,\n type SubscriptionsPatch,\n} from './host/ingest';\nimport type { ProfileProps } from './store';\n\n/**\n * `@shware/workflow/client` — the producer side of the ingest API, for the\n * service that knows what users do (an api server, a webhook handler). Plain\n * fetch, no dependencies; errors carry the status and body so a caller can\n * log them without failing the request that triggered the report.\n */\n\nexport interface JourneyClientOptions<C extends Contract | undefined = undefined> {\n /**\n * The shared `defineContract(...)`: types `track` (the event name picks the\n * payload type) and `identify`, and checks both before anything is sent.\n */\n contract?: C;\n /** The journey Worker's origin, e.g. `https://workflow.example.com`. */\n origin: string;\n /** Its API_TOKEN, sent as a bearer; omit against an open local instance. */\n token?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport class JourneyClientError extends Error {\n constructor(\n readonly path: string,\n readonly status: number,\n readonly body: string\n ) {\n super(`journey ${path} failed: ${status} ${body}`);\n this.name = 'JourneyClientError';\n }\n}\n\n/** A payload or profile patch the contract refuses — raised before the request, with the field paths. */\nexport class JourneyValidationError extends Error {\n constructor(\n readonly path: string,\n readonly event: string | undefined,\n readonly issues: ContractIssue[]\n ) {\n super(\n `journey ${path}${event === undefined ? '' : ` '${event}'`} refused by the contract: ${issues\n .map((i) => `${i.path || '(root)'} ${i.message}`)\n .join('; ')}`\n );\n this.name = 'JourneyValidationError';\n }\n}\n\ntype EventsOf<C> = C extends Contract<infer E> ? E : never;\n/** Event names: the contract's, or any string without one. */\nexport type ClientEventName<C> = [C] extends [undefined] ? string : keyof EventsOf<C> & string;\ntype TrackArgs<C, K> = [C] extends [undefined]\n ? [payload?: Record<string, unknown>, options?: TrackOptions]\n : K extends keyof EventsOf<C>\n ? [payload: core.input<EventsOf<C>[K]>, options?: TrackOptions]\n : never;\ntype IdentifyProps<C> = [C] extends [undefined]\n ? ProfileProps\n : C extends Contract<Record<string, core.$ZodObject>, infer P>\n ? ProfilePatch<P>\n : never;\n\n/** The topics the client's contract declares; any string without a contract. */\nexport type ClientTopic<C> = [C] extends [undefined] ? string : Topic<C>;\n\nexport type {\n ChannelSubscriptions,\n ChannelSubscriptionsPatch,\n MessageOrigin,\n MessageSubscriptions,\n SubscriptionChannel,\n Subscriptions,\n SubscriptionsPatch,\n} from './host/ingest';\n\nexport interface TrackOptions {\n /** The occurrence's identity: a retry with the same id is recorded once. */\n id?: string;\n /** When it happened (epoch ms); the Worker's clock when omitted. */\n ts?: number;\n}\n\nexport class JourneyClient<C extends Contract | undefined = undefined> {\n constructor(private readonly options: JourneyClientOptions<C>) {}\n\n /** Merge profile properties (`null` clears one). Every property a journey reads must be identified before the event that starts it. */\n identify(userId: string, props: IdentifyProps<C>): Promise<IdentifyResult> {\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseProfilePatch(props);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/identify', undefined, result.issues));\n }\n return this.post<IdentifyResult>('/identify', { userId, props });\n }\n\n /**\n * Record an event; the Worker starts every journey whose trigger matches it\n * and wakes waits on it. A caller that retries should pass the same `id`\n * each time: the Worker stores one occurrence per id, so a retry after a\n * timeout cannot double-count the event. `ts` defaults to the Worker's clock.\n */\n track<K extends ClientEventName<C>>(\n userId: string,\n event: K,\n ...args: TrackArgs<C, K>\n ): Promise<IngestResult> {\n const [payload = {}, options = {}] = args as [Record<string, unknown>?, TrackOptions?];\n const contract = this.options.contract;\n if (contract !== undefined) {\n const result = contract.parseEvent(event, payload);\n if (!result.ok)\n return Promise.reject(new JourneyValidationError('/events', event, result.issues));\n }\n return this.post<IngestResult>('/events', {\n userId,\n event,\n payload,\n ...(options.id === undefined ? {} : { id: options.id }),\n ...(options.ts === undefined ? {} : { ts: options.ts }),\n });\n }\n\n /**\n * A push token the app registered for the user (call it on every registration: a token moves\n * to the latest user). The engine keeps devices itself — pushes go to them, a token APNs / FCM\n * reports as gone is dropped — and journeys waiting on `reachable('push')` wake.\n */\n registerDevice(\n userId: string,\n device: { token: string; platform: 'ios' | 'android' | 'web'; ts?: number }\n ): Promise<{ ok: true; woke: number }> {\n const { token, platform, ts } = device;\n return this.post('/devices', {\n userId,\n token,\n platform,\n ...(ts === undefined ? {} : { ts }),\n });\n }\n\n /**\n * A journey push tapped open: the `message_id` the push carried, reported for the user who\n * tapped it. Recorded as a human open on the copy (an email's opens are tracked by the Worker).\n */\n messageOpened(userId: string, messageId: string): Promise<{ ok: true; recorded: boolean }> {\n return this.post('/messages/opened', { userId, messageId });\n }\n\n /**\n * A deleted account: the engine records the erasure, stops the user's running journeys, then\n * erases everything it keeps about them (profile, devices, events, reporting). Once this\n * resolves the request is durable: `erased: false` means an instance would not stop yet, and\n * the Worker's scheduled sweep finishes the job.\n */\n forgetUser(userId: string): Promise<{ ok: true; erased: boolean; pending: string[] }> {\n return this.post('/users/forget', { userId });\n }\n\n /** The user signed out on that device: the token stays registered, bound to nobody. */\n unbindDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/unbind', { token });\n }\n\n /** Forget a push token for good (an uninstall the app learned of, a token it replaced). */\n removeDevice(token: string): Promise<{ ok: true; woke: number }> {\n return this.post('/devices/remove', { token });\n }\n\n /**\n * Where the user stands, topic × channel: for `email` and for `push`, `all` (false once they\n * opted out of the whole channel) and each declared topic (false once they opted out of it on\n * that channel) — the engine's record, which a preference page shows as it is. The two are\n * independent flags: a topic answers its own opt-out even while `all` is false, so `all: false`\n * masks every topic however they read (nothing is sent on the channel until it is true again).\n * Rejects with a 410 for an erased user.\n */\n getSubscriptions(userId: string): Promise<Subscriptions<ClientTopic<C>>> {\n return this.request('GET', subscriptionsPath(userId));\n }\n\n /**\n * Set subscriptions: only what the patch names changes, per channel (`{ email: { topics: {\n * newsletter: false } } }` opts out of one topic's emails, `{ push: { all: false } }` of every\n * push — the in-app opt-out App Store Review Guideline 4.5.4 asks for). `all: true` clears the\n * channel-wide opt-out only: a topic opted out on its own stays off until the patch names it,\n * so a preference page's \"subscribe to all\" sends `all: true` plus every topic `true`. Answers\n * the subscriptions as they now stand. With a contract, a topic it does not declare is refused\n * here, before the request; the engine refuses it too.\n */\n setSubscriptions(\n userId: string,\n patch: SubscriptionsPatch<ClientTopic<C>>\n ): Promise<Subscriptions<ClientTopic<C>>> {\n const refused = this.undeclaredTopics(patch);\n if (refused !== undefined) return Promise.reject(refused);\n return this.request('PUT', subscriptionsPath(userId), patch);\n }\n\n /**\n * `getSubscriptions` for the recipient of one copy, by the message id an email's preferences\n * link carries (`email.preferencesUrl?m=<message id>`): how a preference page works without a\n * sign-in. The answer adds `message: { channel, topic }`, the copy the page was opened from,\n * so it can highlight that topic. Rejects with a 404 for an id that names no copy — never\n * sent, or erased with its user — and for a transactional one; the page should show the link\n * as expired. The message id is the capability, so the app proxies this behind its own API\n * and rate-limits it.\n */\n getSubscriptionsByMessage(messageId: string): Promise<MessageSubscriptions<ClientTopic<C>>> {\n return this.request('GET', subscriptionsByMessagePath(messageId));\n }\n\n /** `setSubscriptions` for the recipient of one copy; the patch and the answer are those of `getSubscriptionsByMessage`. */\n setSubscriptionsByMessage(\n messageId: string,\n patch: SubscriptionsPatch<ClientTopic<C>>\n ): Promise<MessageSubscriptions<ClientTopic<C>>> {\n const refused = this.undeclaredTopics(patch);\n if (refused !== undefined) return Promise.reject(refused);\n return this.request('PUT', subscriptionsByMessagePath(messageId), patch);\n }\n\n /** With a contract, the refusal of a patch naming a topic it does not declare; undefined when the patch is fine. */\n private undeclaredTopics(\n patch: SubscriptionsPatch<ClientTopic<C>>\n ): JourneyValidationError | undefined {\n const contract = this.options.contract;\n if (contract === undefined) return undefined;\n const issues: ContractIssue[] = [];\n for (const channel of SUBSCRIPTION_CHANNELS) {\n for (const topic of Object.keys(patch[channel]?.topics ?? {})) {\n // `includes`, not the predicate: narrowing `string` by a `topic is string` leaves `never`\n if (contract.topics.includes(topic)) continue;\n issues.push({ path: `${channel}.topics.${topic}`, message: 'not a declared topic' });\n }\n }\n return issues.length > 0\n ? new JourneyValidationError('/subscriptions', undefined, issues)\n : undefined;\n }\n\n /** Deploy a compiled bundle (BundleIR). */\n deploy(bundle: unknown): Promise<DeployResult> {\n return this.post<DeployResult>('/deploy', bundle);\n }\n\n private post<T>(path: string, body: unknown): Promise<T> {\n return this.request<T>('POST', path, body);\n }\n\n private async request<T>(\n method: 'GET' | 'POST' | 'PUT',\n path: string,\n body?: unknown\n ): Promise<T> {\n const headers: Record<string, string> = {};\n if (body !== undefined) headers['content-type'] = 'application/json';\n if (this.options.token !== undefined) headers.authorization = `Bearer ${this.options.token}`;\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(`${this.options.origin.replace(/\\/$/, '')}${path}`, {\n method,\n headers,\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n });\n if (!response.ok) throw new JourneyClientError(path, response.status, await response.text());\n return (await response.json()) as T;\n }\n}\n\nconst subscriptionsPath = (userId: string) => `/subscriptions/${encodeURIComponent(userId)}`;\nconst subscriptionsByMessagePath = (messageId: string) =>\n `/subscriptions/by-message/${encodeURIComponent(messageId)}`;\n"],"mappings":"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"}
@@ -1,4 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_reporting_classify = require("../reporting/classify.cjs");
3
+ const require_reporting_tracking = require("../reporting/tracking.cjs");
2
4
  const require_store_facts = require("../store/facts.cjs");
3
5
  const require_store_erasure = require("../store/erasure.cjs");
4
6
  const require_store_postgres_reporting = require("../store/postgres-reporting.cjs");
@@ -10,8 +12,6 @@ const require_email_sender = require("../email-sender.cjs");
10
12
  const require_reporting_observer = require("../reporting/observer.cjs");
11
13
  const require_host_senders = require("../host/senders.cjs");
12
14
  const require_host_channels = require("../host/channels.cjs");
13
- const require_reporting_classify = require("../reporting/classify.cjs");
14
- const require_reporting_tracking = require("../reporting/tracking.cjs");
15
15
  const require_host_auth = require("../host/auth.cjs");
16
16
  const require_host_http = require("../host/http.cjs");
17
17
  const require_host_journey_host = require("../host/journey-host.cjs");
@@ -1,3 +1,5 @@
1
+ import { BURST_WINDOW_MS, DEFAULT_QUICK_MS, classifyClick, classifyOpen } from "../reporting/classify.mjs";
2
+ import { handleTracking, recordPushOpen } from "../reporting/tracking.mjs";
1
3
  import { JourneyFactSource } from "../store/facts.mjs";
2
4
  import { UserErasedError } from "../store/erasure.mjs";
3
5
  import { PostgresReporting } from "../store/postgres-reporting.mjs";
@@ -9,8 +11,6 @@ import { EmailSender, trackEmail } from "../email-sender.mjs";
9
11
  import { reportingObserver } from "../reporting/observer.mjs";
10
12
  import { LogMessageSender, WebhookMessageSender, routeByChannel, routeByPlatform } from "../host/senders.mjs";
11
13
  import { channelsFromConfig, journeyMode, runtimeFromConfig } from "../host/channels.mjs";
12
- import { BURST_WINDOW_MS, DEFAULT_QUICK_MS, classifyClick, classifyOpen } from "../reporting/classify.mjs";
13
- import { handleTracking, recordPushOpen } from "../reporting/tracking.mjs";
14
14
  import { authorized, bearerMatches, timingSafeEqual } from "../host/auth.mjs";
15
15
  import { handleRequest } from "../host/http.mjs";
16
16
  import { EnvValidationError } from "../host/journey-host.mjs";
@@ -14,7 +14,8 @@ var EmailSender = class {
14
14
  async send(message) {
15
15
  if (message.channel !== "email") throw new Error(`EmailSender: unsupported channel '${message.channel}'`);
16
16
  if (message.recipient === void 0) throw new Error(`EmailSender: no recipient for user '${message.userId}'`);
17
- const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props);
17
+ const messageId = message.tracking?.messageId;
18
+ const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props, messageId === void 0 ? {} : { messageId });
18
19
  const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
19
20
  await message.tracking?.commit?.();
20
21
  const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);