From 42a548830b321651b0712a2faba7d25c5d898d91 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:06:40 +0000 Subject: [PATCH 1/9] =?UTF-8?q?feat(notifications):=20the=20push=20channel?= =?UTF-8?q?=20=E2=80=94=20service-push=20(APNs=20+=20FCM)=20and=20its=20ch?= =?UTF-8?q?annel=20shell=20in=20service-messaging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A notify node naming the push channel now reaches the recipient's registered devices through APNs (HTTP/2, token auth) and FCM (HTTP v1); a token the vendor reports dead is retired. Credentials only through credentialRef. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JQwxbWDV359nY4JGrtUq4P --- .changeset/29041-push-notification-channel.md | 14 + .changeset/config.json | 1 + CONTRIBUTING.md | 1 + content/docs/plugins/packages.mdx | 11 +- content/docs/references/api/push-delivery.mdx | 10 +- .../docs/references/system/push-config.mdx | 38 +-- docs/adr/0012-notification-platform.md | 7 +- .../services/service-automation/package.json | 1 + .../notify-push-delivery.integration.test.ts | 262 ++++++++++++++++++ .../services/service-messaging/src/index.ts | 2 + .../src/messaging-service-plugin.ts | 20 ++ .../src/objects/user-device.object.ts | 10 +- .../src/push-channel.test.ts | 255 +++++++++++++++++ .../service-messaging/src/push-channel.ts | 250 +++++++++++++++++ packages/services/service-push/LICENSE | 202 ++++++++++++++ packages/services/service-push/README.md | 14 + packages/services/service-push/package.json | 62 +++++ .../services/service-push/src/credential.ts | 51 ++++ packages/services/service-push/src/index.ts | 19 ++ .../service-push/src/push-plugin.test.ts | 94 +++++++ .../services/service-push/src/push-plugin.ts | 93 +++++++ .../services/service-push/src/push-service.ts | 117 ++++++++ .../service-push/src/transports/apns.test.ts | 160 +++++++++++ .../service-push/src/transports/apns.ts | 216 +++++++++++++++ .../service-push/src/transports/fcm.test.ts | 158 +++++++++++ .../service-push/src/transports/fcm.ts | 201 ++++++++++++++ packages/services/service-push/tsconfig.json | 17 ++ .../services/service-push/vitest.config.ts | 10 + .../spec/src/api/error-code-ledger.zod.ts | 4 +- packages/spec/src/api/push-delivery.test.ts | 6 +- packages/spec/src/api/push-delivery.zod.ts | 12 +- .../src/contracts/notification-service.ts | 9 +- packages/spec/src/system/notification.zod.ts | 18 +- packages/spec/src/system/push-config.test.ts | 10 +- packages/spec/src/system/push-config.zod.ts | 36 +-- packages/spec/src/system/push-device.zod.ts | 9 +- pnpm-lock.yaml | 22 ++ 37 files changed, 2344 insertions(+), 78 deletions(-) create mode 100644 .changeset/29041-push-notification-channel.md create mode 100644 packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts create mode 100644 packages/services/service-messaging/src/push-channel.test.ts create mode 100644 packages/services/service-messaging/src/push-channel.ts create mode 100644 packages/services/service-push/LICENSE create mode 100644 packages/services/service-push/README.md create mode 100644 packages/services/service-push/package.json create mode 100644 packages/services/service-push/src/credential.ts create mode 100644 packages/services/service-push/src/index.ts create mode 100644 packages/services/service-push/src/push-plugin.test.ts create mode 100644 packages/services/service-push/src/push-plugin.ts create mode 100644 packages/services/service-push/src/push-service.ts create mode 100644 packages/services/service-push/src/transports/apns.test.ts create mode 100644 packages/services/service-push/src/transports/apns.ts create mode 100644 packages/services/service-push/src/transports/fcm.test.ts create mode 100644 packages/services/service-push/src/transports/fcm.ts create mode 100644 packages/services/service-push/tsconfig.json create mode 100644 packages/services/service-push/vitest.config.ts diff --git a/.changeset/29041-push-notification-channel.md b/.changeset/29041-push-notification-channel.md new file mode 100644 index 00000000000..2e2310588ed --- /dev/null +++ b/.changeset/29041-push-notification-channel.md @@ -0,0 +1,14 @@ +--- +'@objectstack/service-push': minor +'@objectstack/service-messaging': minor +'@objectstack/spec': patch +--- + +feat(notifications): the `push` channel — a `notify` node naming `push` reaches the recipient's phones through APNs and FCM + +Clause-②: yes (widening) +Metadata-protocol: none + +- **New package `@objectstack/service-push`.** `PushServicePlugin` registers the kernel's `push` service: APNs over HTTP/2 with token-based auth (an ES256 provider token signed with the `.p8` key, reused for fifty minutes) and FCM over the HTTP v1 API (an OAuth access token from the service account). It reads `PushServiceConfigSchema` from its `config` option and refuses an inline secret at boot with the schema's guidance. Each secret is named by `credentialRef` and resolved when the transport first signs; in the open tier the reference is the name of an environment variable (ADR-0143 D3). +- **`@objectstack/service-messaging`: the `push` channel.** Mounted while a `push` service is registered, as the sms channel is while an `sms` service is. One delivery reaches every enabled device the recipient registered, on each vendor the push service has a transport for. The push carries the notification id and, when it is a same-server path (`PushRouteSchema`), the notification's `actionUrl` as `route`; any other `actionUrl` is left out and the delivery still goes. A token the vendor reports dead (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`) is retired through `invalidateDeviceToken`: the device row is kept, disabled, and the user's other devices still get the push. A recipient with no enabled device, or whose every device was reported dead, is graded `invalid_recipient`; no push service is refused `transport_not_configured`, as the sms and email channels refuse it. +- **`@objectstack/spec`: description and comment text only.** The `[PLANNED]` markers come off `PushServiceConfigSchema`, `ApnsTransportConfigSchema`, `FcmTransportConfigSchema` and `PushDeliveryDataSchema`, which now have their reader and producer. `NotificationChannelSchema`'s JSDoc lists `push` among the registered channels, the docblocks name `@objectstack/service-push` where they named `plugin-notification-push`, and the `NOT_FOUND` synonym waiver's reason names service-messaging's own provenance row. No key, schema shape or export changes. diff --git a/.changeset/config.json b/.changeset/config.json index 6a18140829c..252e5999292 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -49,6 +49,7 @@ "@objectstack/plugin-security", "@objectstack/plugin-sharing", "@objectstack/service-sms", + "@objectstack/service-push", "@objectstack/plugin-webhooks", "@objectstack/trigger-record-change", "@objectstack/trigger-api", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 756b271d389..aa6111250aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -416,6 +416,7 @@ start when you add a component. | [`@objectstack/service-i18n`](packages/services/service-i18n) | Internationalization | | [`@objectstack/service-messaging`](packages/services/service-messaging) | Outbound notification dispatch across channels | | [`@objectstack/service-sms`](packages/services/service-sms) | SMS delivery (Aliyun, Twilio, log) | +| [`@objectstack/service-push`](packages/services/service-push) | Mobile push delivery (APNs, FCM) | | [`@objectstack/service-knowledge`](packages/services/service-knowledge) | Knowledge / RAG orchestration over pluggable adapters | | [`@objectstack/service-package`](packages/services/service-package) | Package registry — publish, install, manage metadata packages | | [`@objectstack/cloud-connection`](packages/cloud-connection) | Runtime-side client for an ObjectStack cloud control plane | diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index d1814340be0..7eb149c29e1 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -15,7 +15,7 @@ ObjectStack is organized into package manifests across multiple categories. This | **Framework adapters** | 1 | `hono` (other frameworks: build a thin adapter on `HttpDispatcher` — see below) | | **Drivers** | 5 | `driver-memory`, `driver-sql`, `driver-sqlite-wasm`, `driver-mongodb`, `driver-turso` | | **Plugins** | 17 | `plugin-auth`, `plugin-security`, `plugin-audit`, `plugin-approvals`, `plugin-sharing`, `plugin-email`, `plugin-webhooks`, `plugin-hono-server`, `plugin-dev`, `plugin-pinyin-search`, `mcp`, trigger plugins (`trigger-api`, `trigger-record-change`, `trigger-schedule`), and knowledge/embedder plugins (`knowledge-memory`, `knowledge-ragflow`, `embedder-openai`) | -| **Platform services** | 17 | `service-analytics`, `service-automation`, `service-cache`, `service-cluster`, `service-cluster-redis`, `service-datasource`, `service-i18n`, `service-job`, `service-knowledge`, `service-messaging`, `service-package`, `service-print`, `service-queue`, `service-realtime`, `service-settings`, `service-sms`, `service-storage` | +| **Platform services** | 18 | `service-analytics`, `service-automation`, `service-cache`, `service-cluster`, `service-cluster-redis`, `service-datasource`, `service-i18n`, `service-job`, `service-knowledge`, `service-messaging`, `service-package`, `service-print`, `service-push`, `service-queue`, `service-realtime`, `service-settings`, `service-sms`, `service-storage` | ## Core Packages @@ -240,7 +240,7 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern **Messaging Service** — Outbound notification dispatch (ADR-0012). -- **Features**: `MessagingChannel` registry, `emit()` fan-out, always-on inbox channel; email/webhook/push/IM channels plug in +- **Features**: `MessagingChannel` registry, `emit()` fan-out, always-on inbox channel; the email, sms and push channels mount while their transport service is registered; the push device registry (`sys_user_device`, `/api/v1/notifications/devices`); webhook/IM channels plug in - **When to use**: Notifying users across channels from flows, hooks, and plugins ### @objectstack/service-sms @@ -250,6 +250,13 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern - **Features**: Provider adapters (Aliyun SMS, Twilio), `sms` settings namespace with live rebind, backs phone-number OTP sign-in/reset and the messaging `sms` channel - **When to use**: Phone OTP first-login / self-service reset, SMS notifications +### @objectstack/service-push + +**Push Service** — Outbound mobile push through APNs and FCM (`push` service). + +- **Features**: APNs over HTTP/2 with token-based auth (`.p8` signing key) and FCM over the HTTP v1 API (service account), configured by `PushServiceConfigSchema` with each secret named by `credentialRef`, never inline; backs the messaging `push` channel, which delivers to the recipient's registered devices, carries the notification's `route`, and retires a token the vendor reports dead +- **When to use**: Phone notifications from `notify` flow nodes and `emit()` — compose `new PushServicePlugin({ config })` beside `MessagingServicePlugin` + ### @objectstack/service-print **Print Service** — Prints a record's print page to a PDF in headless Chromium, as the requesting user, and archives it as a file attached to the record (`print` service). diff --git a/content/docs/references/api/push-delivery.mdx b/content/docs/references/api/push-delivery.mdx index edfb78e9492..97e284daf02 100644 --- a/content/docs/references/api/push-delivery.mdx +++ b/content/docs/references/api/push-delivery.mdx @@ -18,10 +18,10 @@ vendor payload. On FCM (HTTP v1) these members go in `message.data`, a map of string values. On APNs they sit at the top level of the payload, beside `aps`, and never under a `data` key. -**Declared ahead of its producer.** No push channel delivers yet, so -nothing produces this data today; `PushDeliveryDataSchema` carries a -`[PLANNED — not yet served]` marker in its `.describe()` until the push -channel ships. The route rule below is the contract that channel owes. +**Produced by the `push` channel** (`push-channel.ts` in +`@objectstack/service-messaging`), which builds it per delivery and hands it +to the push service's transports (`@objectstack/service-push`). The route +rule below is the contract that channel keeps. **What `route` is — the inbox's `actionUrl`, derived, never authored.** There is no `notify` key for it. It is the value the inbox already serves: @@ -71,7 +71,7 @@ const result = PushDeliveryDataSchema.parse(data); ## PushDeliveryData -[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and its actionUrl as a same-server route. No push channel delivers yet +The data a push delivery carries to the shell: the notification id and its actionUrl as a same-server route ### Properties diff --git a/content/docs/references/system/push-config.mdx b/content/docs/references/system/push-config.mdx index 20b31ca09c7..40805c25619 100644 --- a/content/docs/references/system/push-config.mdx +++ b/content/docs/references/system/push-config.mdx @@ -1,21 +1,23 @@ --- title: Push Config schema — System Protocol reference navTitle: Push Config -description: "Push Service Configuration Protocol — the push channel's transports. Declared ahead of its reader." +description: "Push Service Configuration Protocol — the push channel's transports. Read by @objectstack/service-push." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} Push Service Configuration Protocol — the push channel's transports. -**Declared ahead of its reader.** Every schema here carries a -`[PLANNED — not yet read]` marker in its `.describe()`: nothing reads this -shape until the push channel plugin lands, so a value written to it today -changes nothing. The marker comes off with the change that wires the reader. +**Read by `@objectstack/service-push`.** `PushServicePlugin` parses its +`config` option with `PushServiceConfigSchema` when it initialises and +builds one transport per block; the `push` channel in +`@objectstack/service-messaging` delivers through the push service it +registers. Operator-facing configuration for the `push` channel ADR-0012 §4 ships in core: Apple Push Notification service with token-based auth, and Firebase -Cloud Messaging over its HTTP v1 API. It sits beside +Cloud Messaging over its HTTP v1 API (ADR-0012, amended 2026-11-03: the +transports ship as `@objectstack/service-push`). It sits beside `EmailServiceConfigSchema` (`email-config.zod.ts`), the declaration of the email channel's transport selection. @@ -23,21 +25,21 @@ the email channel's transport selection. transport has one secret — the APNs `.p8` signing key and the FCM service-account JSON — and each is named by a `credentialRef`, the same key every other secret an author names is written under (ADR-0143 D3), which -the push channel resolves through the secrets layer when it sends (in the -open tier, the name of an environment variable). An inline secret is +the push transport resolves through the secrets layer when it first signs +(in the open tier, the name of an environment variable). An inline secret is refused two ways: written under a key an operator would reach for (`key`, `privateKey`, `serviceAccount`, …), it is refused by name with `credentialRef` as the prescription; written into the reference itself, it is refused because a reference is one token — a PEM block carries whitespace and a JSON document carries braces, and a name carries neither. -**What reads it, and why it is not a stack key yet.** The push channel -plugin (`plugin-notification-push`, ADR-0012 §11) is its one reader, and it -is a later change than this declaration. The email transport selection it -sits beside is read from `config.email` of the stack definition -(`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape gets its own -configuration door in the change that ships the reader, not here, so a key -is never authorable before something honours it. +**What reads it, and why it is not a stack key.** `PushServicePlugin` +(`@objectstack/service-push`) is its one reader, through the plugin's +constructor: the host that composes the plugin hands it the value. The email +transport selection it sits beside is read from `config.email` of the stack +definition (`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape has no +stack-definition key and no `os serve` capability yet, so it is authorable +only where a host composes the plugin. Out of scope here: a hosted (cloud-side) configuration surface, and the other vendors ADR-0012 §6 routes to alternate channel plugins (HMS, @@ -61,7 +63,7 @@ const result = ApnsTransportConfigSchema.parse(data); ## ApnsTransportConfig -[PLANNED — not yet read] APNs transport with token-based auth (.p8 signing key by reference) +APNs transport with token-based auth (.p8 signing key by reference) ### Properties @@ -78,7 +80,7 @@ const result = ApnsTransportConfigSchema.parse(data); ## FcmTransportConfig -[PLANNED — not yet read] FCM HTTP v1 transport (service-account JSON by reference) +FCM HTTP v1 transport (service-account JSON by reference) ### Properties @@ -92,7 +94,7 @@ const result = ApnsTransportConfigSchema.parse(data); ## PushServiceConfig -[PLANNED — not yet read] Push channel transport configuration: one optional block per vendor (apns, fcm). Nothing reads it until the push channel plugin ships +Push channel transport configuration: one optional block per vendor (apns, fcm), read by the push service ### Properties diff --git a/docs/adr/0012-notification-platform.md b/docs/adr/0012-notification-platform.md index db64800ed7b..d35b263be6c 100644 --- a/docs/adr/0012-notification-platform.md +++ b/docs/adr/0012-notification-platform.md @@ -1,6 +1,6 @@ # ADR-0012: Messaging Platform — Outbound Notifications on a Generalized Outbox -**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) · **Superseded by [ADR-0030](./0030-notification-platform-convergence.md)** (2026-06-01) — the M1 implementation drifted (two conflated inbox objects); ADR-0030 records the correction and realizes this layered model. +**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) · **Superseded by [ADR-0030](./0030-notification-platform-convergence.md)** (2026-06-01) — the M1 implementation drifted (two conflated inbox objects); ADR-0030 records the correction and realizes this layered model. · **Amended (2026-11-03, #29041)**: push ships as `@objectstack/service-push` (the APNs and FCM transports, the kernel's `push` service) with its channel shell `push-channel.ts` in `@objectstack/service-messaging`, which also owns `sys_user_device`; every `plugin-notification-push` below reads that way. ADR-0030 is unchanged. **Authors**: Platform team — surfaced from GitHub Issue #1292 ("[P0] notification: no outbound notification channel") **Consumers**: `@objectstack/spec` (new `messaging/` domain), `@objectstack/service-messaging` (new), `@objectstack/plugin-notification-inbox` (new), `@objectstack/plugin-notification-email` (replaces `plugin-email`), `@objectstack/plugin-notification-webhook` (extracted from `plugin-webhooks`), `@objectstack/plugin-notification-push` (new), every flow that has a `notify` node, every template that ships notification rules **Sibling**: [ADR-0013 — Bidirectional Messaging & Conversational Channels](./0013-bidirectional-messaging.md) @@ -306,7 +306,7 @@ These cost ~5 columns and one empty table in M1. They are non-negotiable: introd | `inbox` | `plugin-notification-inbox` | Writes to `sys_inbox_message`; fires `service-realtime` event for online users | **MUST ship M1** | | `email` | `plugin-notification-email` (replaces `plugin-email`) | SMTP + transport plugins (SendGrid baseline) | **MUST ship M1** | | `webhook` | `plugin-notification-webhook` (extracted from `plugin-webhooks`) | HTTP POST with HMAC | **MUST ship M1** (port existing) | -| `push` | `plugin-notification-push` | APNs (HTTP/2) + FCM (HTTP v1) | **MUST ship M1** | +| `push` | `service-push` (transports) + `push-channel.ts` in `service-messaging` (channel); amended 2026-11-03, #29041 | APNs (HTTP/2, token auth) + FCM (HTTP v1) | Shipped (#29041) | All four bind into `channel-registry` during `onEnable`. @@ -375,7 +375,8 @@ Lifecycle hooks: * Client SDK calls `POST /api/v1/notifications/devices` on login → `device-registry.upsert(...)`. * APNs / FCM `BadDeviceToken` / `Unregistered` response → channel returns `classifyError = 'invalid_recipient'` → dispatcher calls `device-registry.invalidate(token)` (sets `invalidated_at`, `enabled=false`) without dead-lettering the notification (it just routes around the dead token). -* Domestic vendors (HMS / Xiaomi / OPPO / VIVO) plug in as **alternate channel implementations** (`plugin-notification-push-hms` etc.), not as transports — their on-device SDKs and quotas are too different to share a code path. + > Amended (2026-11-03, #29041): the push channel (`push-channel.ts` in service-messaging) retires the dead token itself, through `INotificationService.invalidateDeviceToken`, because one delivery fans out to all of the recipient's devices and only the channel knows which token died; the other devices still get the push. The delivery is graded `invalid_recipient` (suppressed, not dead-lettered) only when every device was reported dead. The shipped device shape is `UserDeviceSchema` in `packages/spec/src/system/push-device.zod.ts`, narrowed to `apns` | `fcm` and `ios` | `android` | `web`. +* Domestic vendors (HMS / Xiaomi / OPPO / VIVO) plug in as **alternate channel implementations** (one package each, named by kind like `service-push`), not as transports — their on-device SDKs and quotas are too different to share a code path. ### 7. Topic + Preference matrix diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 0a3b725569a..6fe34a56f67 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -45,6 +45,7 @@ "@objectstack/refd-timer-testkit": "workspace:*", "@objectstack/service-job": "workspace:*", "@objectstack/service-messaging": "workspace:*", + "@objectstack/service-push": "workspace:*", "@types/node": "^26.6.5", "tsx": "^4.23.15", "typescript": "^6.0.3", diff --git a/packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts b/packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts new file mode 100644 index 00000000000..af6f1d4bdbb --- /dev/null +++ b/packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29041] A `notify` flow node naming the `push` channel delivers a push to + * the devices the recipient registered, through mock APNs and FCM endpoints, + * and a token the vendor reports dead is retired. + * + * Before #29041 the same flow succeeded with `acted: 0`: no `push` channel was + * registered, so fan-out refused the delivery and no request left the box. + * + * Wired through the REAL stack below the node: `MessagingService` (inline + * fan-out) with its device registry, the `push` channel from + * service-messaging, and `PushService` from service-push with its real APNs + * (HTTP/2, ES256 provider token) and FCM (HTTP v1, OAuth service account) + * transports, pointed at local servers. Credentials reach the transports only + * through `credentialRef`, resolved by the resolver the test hands in. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import http from 'node:http'; +import http2 from 'node:http2'; +import { generateKeyPairSync } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import { MessagingService, createInboxChannel, createPushChannel } from '@objectstack/service-messaging'; +import { ApnsPushTransport, FcmPushTransport, PushService } from '@objectstack/service-push'; +import { AutomationEngine } from '../engine.js'; +import { registerNotifyNode } from './notify-node.js'; + +function silentLogger(): any { + const l: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; + l.child = () => l; + return l; +} + +/** A data engine over in-memory tables: equality `where` on plain fields, which is all this path reads. */ +function memoryEngine() { + const tables: Record>> = {}; + const matches = (row: Record, where: Record = {}) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$') || (typeof v === 'object' && v !== null)) { + throw new Error(`this double matches equality on plain fields only: ${JSON.stringify(where)}`); + } + return row[k] === v; + }); + let seq = 0; + return { + tables, + async insert(object: string, row: Record) { + const stored = { ...row, id: row.id != null ? String(row.id) : `row_${++seq}` }; + (tables[object] ??= []).push(stored); + return { ...stored }; + }, + async find(object: string, q: { where?: Record } = {}) { + return (tables[object] ?? []).filter((r) => matches(r, q.where)).map((r) => ({ ...r })); + }, + async findOne(object: string, q: { where?: Record } = {}) { + const hit = (tables[object] ?? []).find((r) => matches(r, q.where)); + return hit ? { ...hit } : null; + }, + async update(object: string, values: Record, opts: { where?: Record }) { + for (const r of tables[object] ?? []) if (matches(r, opts.where)) Object.assign(r, values); + return {}; + }, + async delete(object: string, opts: { where?: Record }) { + tables[object] = (tables[object] ?? []).filter((r) => !matches(r, opts.where)); + return {}; + }, + }; +} + +const closers: Array<() => Promise> = []; +afterEach(async () => { + // Last opened, first closed: a transport's held session goes before the server it holds. + for (const close of closers.splice(0).reverse()) await close(); +}); + +/** Mock APNs gateway (HTTP/2): answers 410 Unregistered for the tokens in `dead`, else 200. */ +async function mockApns(dead: Set) { + const pushes: Array<{ token: string; payload: any }> = []; + const server = http2.createServer(); + server.on('stream', (stream: http2.ServerHttp2Stream, headers) => { + let body = ''; + stream.setEncoding('utf8'); + stream.on('data', (c: string) => { body += c; }); + stream.on('end', () => { + const token = String(headers[':path']).replace('/3/device/', ''); + pushes.push({ token, payload: JSON.parse(body) }); + if (dead.has(token)) { + stream.respond({ ':status': 410 }); + stream.end(JSON.stringify({ reason: 'Unregistered' })); + } else { + stream.respond({ ':status': 200, 'apns-id': `apns-${pushes.length}` }); + stream.end(); + } + }); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + closers.push(() => new Promise((r) => server.close(() => r()))); + return { origin: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, pushes }; +} + +/** Mock Google: the OAuth token endpoint and the FCM v1 send endpoint. */ +async function mockFcm() { + const pushes: Array<{ url: string; message: any }> = []; + const server = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (c: string) => { body += c; }); + req.on('end', () => { + res.writeHead(200, { 'content-type': 'application/json' }); + if (req.url === '/token') { + res.end(JSON.stringify({ access_token: 'at', expires_in: 3600 })); + return; + } + pushes.push({ url: String(req.url), message: JSON.parse(body).message }); + res.end(JSON.stringify({ name: `projects/p/messages/${pushes.length}` })); + }); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + closers.push(() => new Promise((r) => { server.close(() => r()); server.closeAllConnections(); })); + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + return { origin, tokenUri: `${origin}/token`, pushes }; +} + +const APNS_KEY = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey + .export({ type: 'pkcs8', format: 'pem' }).toString(); +const FCM_ACCOUNT = JSON.stringify({ + client_email: 'push@p.iam.gserviceaccount.com', + private_key: generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), +}); +/** The secrets layer of this test: each credentialRef names one secret. */ +const SECRETS: Record = { APNS_SIGNING_KEY: APNS_KEY, FCM_SERVICE_ACCOUNT: FCM_ACCOUNT }; + +const IOS_TOKEN = 'a1'.repeat(32); +const DEAD_IOS_TOKEN = 'd0'.repeat(32); +const ANDROID_TOKEN = 'fcm-android-token-1'; + +async function boot(dead: Set = new Set()) { + const apns = await mockApns(dead); + const fcm = await mockFcm(); + const data = memoryEngine(); + const getData = () => data as any; + const resolveCredential = (ref: string) => SECRETS[ref]; + const apnsTransport = new ApnsPushTransport({ + config: { teamId: 'TEAM123456', keyId: 'KEY1234567', topic: 'com.example.crm', environment: 'sandbox', credentialRef: 'APNS_SIGNING_KEY' }, + resolveCredential, + origin: apns.origin, + }); + closers.push(() => apnsTransport.close()); + const push = new PushService({ + transports: { + apns: apnsTransport, + fcm: new FcmPushTransport({ + config: { projectId: 'p', credentialRef: 'FCM_SERVICE_ACCOUNT' }, + resolveCredential, + origin: fcm.origin, + tokenUri: fcm.tokenUri, + }), + }, + }); + + const messaging = new MessagingService({ logger: silentLogger(), getData }); + messaging.registerChannel(createInboxChannel({ getData })); + messaging.registerChannel(createPushChannel({ getPush: () => push, devices: messaging })); + + const engine = new AutomationEngine(silentLogger()); + registerNotifyNode(engine, { + logger: silentLogger(), + getService: (name: string) => (name === 'messaging' ? messaging : name === 'data' ? getData() : undefined), + } as any); + return { engine, messaging, apns, fcm }; +} + +async function runNotify(stack: Awaited>, config: Record, record?: Record) { + stack.engine.registerFlow('deal_won', { + name: 'deal_won', + label: 'Deal won', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'tell_owner', type: 'notify', label: 'Tell owner', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'tell_owner' }, + { id: 'e2', source: 'tell_owner', target: 'end' }, + ], + } as any); + const context = record ? { tenantId: 'org_1', object: 'opportunity', record } : { tenantId: 'org_1' }; + return stack.engine.execute('deal_won', context as any); +} + +describe('#29041 — a notify node naming the push channel', () => { + it('delivers to the devices the signed-in user registered, through mock APNs and FCM, carrying route', async () => { + const stack = await boot(); + // What `POST /api/v1/notifications/devices` does for the session user. + await stack.messaging.registerDevice('usr_owner', { token: IOS_TOKEN, platform: 'ios', deviceId: 'iphone' }); + await stack.messaging.registerDevice('usr_owner', { token: ANDROID_TOKEN, platform: 'android', deviceId: 'pixel' }); + + const run = await runNotify(stack, { + title: 'Deal won', + message: 'Acme signed', + recipients: ['usr_owner'], + channels: ['push'], + actionUrl: '/apps/crm/opportunity/123', + }); + + expect(run.success).toBe(true); + expect(run.summary).toMatchObject({ selected: 1, acted: 1 }); + + expect(stack.apns.pushes).toHaveLength(1); + const apns = stack.apns.pushes[0]; + expect(apns.token).toBe(IOS_TOKEN); + expect(apns.payload.aps.alert).toEqual({ title: 'Deal won', body: 'Acme signed' }); + expect(apns.payload.route).toBe('/apps/crm/opportunity/123'); + expect(typeof apns.payload.notificationId).toBe('string'); + + expect(stack.fcm.pushes).toHaveLength(1); + const fcm = stack.fcm.pushes[0]; + expect(fcm.url).toBe('/v1/projects/p/messages:send'); + expect(fcm.message).toMatchObject({ + token: ANDROID_TOKEN, + notification: { title: 'Deal won', body: 'Acme signed' }, + data: { route: '/apps/crm/opportunity/123', notificationId: apns.payload.notificationId }, + }); + }); + + it('carries the source record\'s app-relative path as route when the payload names no url', async () => { + const stack = await boot(); + await stack.messaging.registerDevice('usr_owner', { token: IOS_TOKEN, platform: 'ios' }); + const run = await stack.messaging.emit({ + topic: 'deal.won', + audience: ['usr_owner'], + channels: ['push'], + source: { object: 'opportunity', id: '123' }, + payload: { title: 'Deal won' }, + } as any); + expect(run.delivered).toBe(1); + expect(stack.apns.pushes[0].payload.route).toBe('/opportunity/123'); + }); + + it('retires a token APNs reports Unregistered, and the user\'s other device still gets the push', async () => { + const stack = await boot(new Set([DEAD_IOS_TOKEN])); + await stack.messaging.registerDevice('usr_owner', { token: DEAD_IOS_TOKEN, platform: 'ios', deviceId: 'old-iphone' }); + await stack.messaging.registerDevice('usr_owner', { token: ANDROID_TOKEN, platform: 'android', deviceId: 'pixel' }); + + const run = await runNotify(stack, { title: 'Deal won', recipients: ['usr_owner'], channels: ['push'] }); + expect(run.success).toBe(true); + expect(stack.fcm.pushes).toHaveLength(1); + + const devices = await stack.messaging.listDevices('usr_owner'); + const old = devices.find((d) => d.deviceId === 'old-iphone'); + expect(old).toMatchObject({ enabled: false }); + expect(typeof old?.invalidatedAt).toBe('string'); + expect(devices.find((d) => d.deviceId === 'pixel')).toMatchObject({ enabled: true }); + + // The retired device is not pushed again. + await runNotify(stack, { title: 'Second deal', recipients: ['usr_owner'], channels: ['push'] }); + expect(stack.apns.pushes.map((p) => p.token)).toEqual([DEAD_IOS_TOKEN]); + expect(stack.fcm.pushes).toHaveLength(2); + }); +}); diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts index 626f4c329f0..33f2f886001 100644 --- a/packages/services/service-messaging/src/index.ts +++ b/packages/services/service-messaging/src/index.ts @@ -70,6 +70,8 @@ export { resolveRecipientLocale, } from './recipient-locale.js'; export type { SmsChannelOptions, SmsSenderSurface } from './sms-channel.js'; +export { createPushChannel } from './push-channel.js'; +export type { PushChannelOptions, PushSenderSurface, PushDeviceSurface } from './push-channel.js'; // [#24978] Keyed notification text — `EmitInput.keyedText`, rendered per // recipient at delivery from the `i18n` service's catalog. diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index c1753da8d5e..5c71ea0674f 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -13,6 +13,7 @@ import { DEFAULT_MAX_IDLE_INTERVAL_MS, NotificationDispatcher, type DispatchClus import { HttpDispatcher } from './http-dispatcher.js'; import { createEmailChannel } from './email-channel.js'; import { createSmsChannel } from './sms-channel.js'; +import { createPushChannel } from './push-channel.js'; import { NotificationTemplateStore, TEMPLATE_DECLARED_FALLBACK_LOCALE, @@ -509,6 +510,25 @@ export class MessagingServicePlugin implements Plugin { logger: ctx.logger, })); }); + + // Push channel (#29041): same pattern — mounted while a `push` + // service (`@objectstack/service-push`) is resolvable. The devices + // it addresses are this service's own registry (`sys_user_device`). + const getPush = () => { + try { + return ctx.getService('push'); + } catch { + return undefined; + } + }; + ctx.hook('kernel:ready', async () => { + service.registerChannelProvider('push', lazyChannelMount({ + resolveTransport: getPush, + create: () => createPushChannel({ getPush, devices: service }), + announce: '[messaging] push channel registered (delivers to the recipient\'s enabled sys_user_device rows through the push service)', + logger: ctx.logger, + })); + }); } // Reliable delivery (P1): wire the outbox + dispatcher once the engine diff --git a/packages/services/service-messaging/src/objects/user-device.object.ts b/packages/services/service-messaging/src/objects/user-device.object.ts index 071a1c1ab99..22275146cd5 100644 --- a/packages/services/service-messaging/src/objects/user-device.object.ts +++ b/packages/services/service-messaging/src/objects/user-device.object.ts @@ -7,10 +7,12 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * (user, app install), the stored form of spec's `UserDeviceSchema` * (`system/push-device.zod.ts`). * - * Belongs to `service-messaging` because no push channel plugin exists yet and - * this service owns the `notification` slot whose `INotificationService` - * device members write it (`../device-registry.ts`). When the push channel - * lands it reads this table; it does not own it. + * Owned by `service-messaging`: this service fills the `notification` slot + * whose `INotificationService` device members write it + * (`../device-registry.ts`), and the `push` channel (`../push-channel.ts`, + * also this package) reads it through `listDevices` and retires dead tokens + * through `invalidateDeviceToken`. `@objectstack/service-push` holds only the + * APNs and FCM transports; it never reads or writes this table. * * The three rules spec states for the shape, and where each is kept: * diff --git a/packages/services/service-messaging/src/push-channel.test.ts b/packages/services/service-messaging/src/push-channel.test.ts new file mode 100644 index 00000000000..cf3cfcfb245 --- /dev/null +++ b/packages/services/service-messaging/src/push-channel.test.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `push` channel (#29041): which devices one delivery reaches, the data it + * carries (`PushDeliveryDataSchema`), what it does with a token the vendor + * reports dead, how each outcome is graded, and its mount in + * `MessagingServicePlugin` (present exactly while a `push` service is). + */ + +import { describe, it, expect } from 'vitest'; +import type { UserDevice } from '@objectstack/spec/system'; +import { PushDeliveryDataSchema } from '@objectstack/spec/api'; +import { createPushChannel, deliveryData, truncateUtf8, type PushSenderSurface } from './push-channel.js'; +import { MessagingServicePlugin } from './messaging-service-plugin.js'; +import type { MessagingService } from './messaging-service.js'; +import type { Delivery } from './channel.js'; + +const ctx = { logger: { info() {}, warn() {}, error() {} } }; + +function device(over: Partial & { deviceId: string; token: string }): UserDevice { + return { userId: 'usr_1', vendor: 'apns', platform: 'ios', enabled: true, ...over }; +} + +type Answer = Awaited>; + +/** A push service double: answers per token, records every send. */ +function pushDouble(answers: Record = {}, vendors: string[] = ['apns', 'fcm']) { + const sent: Array[0]> = []; + const push: PushSenderSurface = { + vendors: () => vendors, + async send(m) { + sent.push(m); + return answers[m.token] ?? { status: 'sent', messageId: `id-${m.token}` }; + }, + }; + return { push, sent }; +} + +function registry(devices: UserDevice[]) { + const retired: string[] = []; + return { + retired, + devices: { + async listDevices(userId: string) { return devices.filter((d) => d.userId === userId); }, + async invalidateDeviceToken(token: string) { retired.push(token); }, + }, + }; +} + +function delivery(over: Partial = {}): Delivery { + return { + channel: 'push', + recipient: 'usr_1', + notification: { + notificationId: 'ntf_1', + title: 'Deal won', + body: 'Acme signed', + recipients: ['usr_1'], + actionUrl: '/opportunity/123', + ...over, + }, + }; +} + +describe('createPushChannel — send', () => { + it('pushes every enabled device of the recipient, with the notification id and the actionUrl as route', async () => { + const { push, sent } = pushDouble(); + const reg = registry([ + device({ deviceId: 'd1', token: 'tok-ios' }), + device({ deviceId: 'd2', token: 'tok-android', vendor: 'fcm', platform: 'android' }), + device({ deviceId: 'd3', token: 'tok-off', enabled: false }), + device({ deviceId: 'd4', token: 'tok-other-user', userId: 'usr_2' }), + ]); + const channel = createPushChannel({ getPush: () => push, devices: reg.devices }); + const result = await channel.send(ctx, delivery()); + + expect(result).toEqual({ ok: true, externalId: 'id-tok-ios,id-tok-android' }); + expect(sent.map((m) => [m.vendor, m.token])).toEqual([['apns', 'tok-ios'], ['fcm', 'tok-android']]); + expect(sent[0]).toMatchObject({ title: 'Deal won', body: 'Acme signed' }); + expect(sent[0].data).toEqual({ notificationId: 'ntf_1', route: '/opportunity/123' }); + // The data is the declared delivery data. + expect(PushDeliveryDataSchema.safeParse(sent[0].data).success).toBe(true); + }); + + it('leaves out a route that is not a same-server path, and still delivers', async () => { + for (const actionUrl of ['https://evil.example/x', '//evil.example/x', '/a/../../api/v1/me', undefined]) { + const { push, sent } = pushDouble(); + const channel = createPushChannel({ getPush: () => push, devices: registry([device({ deviceId: 'd1', token: 't1' })]).devices }); + expect((await channel.send(ctx, delivery({ actionUrl }))).ok).toBe(true); + expect(sent[0].data).toEqual({ notificationId: 'ntf_1' }); + } + }); + + it('retires a token the vendor reports dead, and the recipient\'s other device still gets the push', async () => { + const { push, sent } = pushDouble({ 'tok-dead': { status: 'invalid_token', reason: 'Unregistered' } }); + const reg = registry([device({ deviceId: 'd1', token: 'tok-dead' }), device({ deviceId: 'd2', token: 'tok-live' })]); + const channel = createPushChannel({ getPush: () => push, devices: reg.devices }); + const result = await channel.send(ctx, delivery()); + + expect(result.ok).toBe(true); + expect(sent.map((m) => m.token)).toEqual(['tok-dead', 'tok-live']); + expect(reg.retired).toEqual(['tok-dead']); + }); + + it('grades a delivery whose every device was reported dead invalid_recipient, retiring each', async () => { + const dead: Answer = { status: 'invalid_token', reason: 'BadDeviceToken' }; + const { push } = pushDouble({ a: dead, b: dead }); + const reg = registry([device({ deviceId: 'd1', token: 'a' }), device({ deviceId: 'd2', token: 'b' })]); + const channel = createPushChannel({ getPush: () => push, devices: reg.devices }); + const result = await channel.send(ctx, delivery()); + + expect(result.ok).toBe(false); + expect(channel.classifyError!(result.error)).toBe('invalid_recipient'); + expect(reg.retired).toEqual(['a', 'b']); + }); + + it('grades a recipient with no enabled device invalid_recipient without sending', async () => { + const { push, sent } = pushDouble(); + const channel = createPushChannel({ + getPush: () => push, + devices: registry([device({ deviceId: 'd1', token: 't', enabled: false })]).devices, + }); + const result = await channel.send(ctx, delivery()); + expect(result.ok).toBe(false); + expect(channel.classifyError!(result.error)).toBe('invalid_recipient'); + expect(sent).toHaveLength(0); + }); + + it('grades devices only on a vendor no transport is configured for as permanent, without sending', async () => { + const { push, sent } = pushDouble({}, ['apns']); + const channel = createPushChannel({ + getPush: () => push, + devices: registry([device({ deviceId: 'd1', token: 't', vendor: 'fcm', platform: 'android' })]).devices, + }); + const result = await channel.send(ctx, delivery()); + expect(result.ok).toBe(false); + expect(result.error).toContain('fcm'); + expect(channel.classifyError!(result.error)).toBe('permanent'); + expect(sent).toHaveLength(0); + }); + + it('grades a retryable vendor failure retryable, and a refusal no retry repairs permanent', async () => { + const reg = registry([device({ deviceId: 'd1', token: 't' })]); + const retry = createPushChannel({ + getPush: () => pushDouble({ t: { status: 'failed', error: 'APNs answered 503 ServiceUnavailable', retryable: true } }).push, + devices: reg.devices, + }); + const r1 = await retry.send(ctx, delivery()); + expect(r1.ok).toBe(false); + expect(retry.classifyError!(r1.error)).toBe('retryable'); + + const refuse = createPushChannel({ + getPush: () => pushDouble({ t: { status: 'failed', error: 'APNs answered 413 PayloadTooLarge', retryable: false } }).push, + devices: reg.devices, + }); + const r2 = await refuse.send(ctx, delivery()); + expect(r2.ok).toBe(false); + expect(refuse.classifyError!(r2.error)).toBe('permanent'); + }); + + it('counts a delivery with one device pushed as delivered, so a retry never pushes that device twice', async () => { + const { push } = pushDouble({ b: { status: 'failed', error: 'timeout', retryable: true } }); + const channel = createPushChannel({ + getPush: () => push, + devices: registry([device({ deviceId: 'd1', token: 'a' }), device({ deviceId: 'd2', token: 'b' })]).devices, + }); + expect(await channel.send(ctx, delivery())).toEqual({ ok: true, externalId: 'id-a' }); + }); + + it('shortens a long title and body to the byte ceilings on a character boundary', async () => { + const { push, sent } = pushDouble(); + const channel = createPushChannel({ getPush: () => push, devices: registry([device({ deviceId: 'd1', token: 't' })]).devices }); + await channel.send(ctx, delivery({ title: '标'.repeat(200), body: '题\n'.repeat(2000) })); + expect(Buffer.byteLength(sent[0].title, 'utf8')).toBeLessThanOrEqual(256); + expect(Buffer.byteLength(sent[0].body, 'utf8')).toBeLessThanOrEqual(2048); + expect(sent[0].title.endsWith('…')).toBe(true); + expect(sent[0].body).not.toContain('\n'); + }); +}); + +describe('createPushChannel — no transport', () => { + it('refuses with transport_not_configured, permanent, and answers unavailable, when no push service is registered', async () => { + const channel = createPushChannel({ getPush: () => undefined, devices: registry([]).devices }); + const result = await channel.send(ctx, delivery()); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/^transport_not_configured: /); + expect(channel.classifyError!(result.error)).toBe('permanent'); + expect(await channel.isAvailable!(ctx, {})).toEqual({ available: false, reason: 'transport_not_configured' }); + }); + + it('answers unavailable for a push service with no vendor, available for one with a vendor', async () => { + const none = createPushChannel({ getPush: () => pushDouble({}, []).push, devices: registry([]).devices }); + expect(await none.isAvailable!(ctx, {})).toEqual({ available: false, reason: 'transport_not_configured' }); + const one = createPushChannel({ getPush: () => pushDouble({}, ['fcm']).push, devices: registry([]).devices }); + expect(await one.isAvailable!(ctx, {})).toEqual({ available: true }); + }); +}); + +describe('deliveryData / truncateUtf8', () => { + it('admits both inbox actionUrl shapes as route', () => { + expect(deliveryData('n', '/opportunity/1')).toEqual({ notificationId: 'n', route: '/opportunity/1' }); + expect(deliveryData('n', '/apps/crm/opportunity/1')).toEqual({ notificationId: 'n', route: '/apps/crm/opportunity/1' }); + }); + + it('leaves a short string as it is', () => { + expect(truncateUtf8('abc', 10)).toBe('abc'); + }); +}); + +describe('MessagingServicePlugin mounts the push channel while a push service is registered', () => { + function kernelCtx() { + const services = new Map(); + const ready: Array<() => Promise> = []; + const devices = [{ id: 'r1', user_id: 'usr_1', device_id: 'd1', vendor: 'apns', platform: 'ios', token: 'tok-1', enabled: true }]; + services.set('manifest', { register() {} }); + services.set('data', { + async insert(_o: string, row: Record) { return { id: 'evt_1', ...row }; }, + async find(object: string, q: { where?: Record } = {}) { + return object === 'sys_user_device' ? devices.filter((d) => d.user_id === q.where?.user_id) : []; + }, + async findOne() { return null; }, + async update() { return {}; }, + async delete() { return {}; }, + }); + const ctx = { + logger: { info() {}, warn() {}, error() {}, debug() {}, child: () => ctx.logger }, + registerService: (name: string, svc: unknown) => services.set(name, svc), + getService: (name: string): T => { + if (!services.has(name)) throw new Error(`service '${name}' not found`); + return services.get(name) as T; + }, + hook: (event: string, fn: () => Promise) => { if (event === 'kernel:ready') ready.push(fn); }, + } as never; + return { ctx, services, ready }; + } + + it('refuses push before a push service exists, and delivers through it once one is registered', async () => { + const { ctx, services, ready } = kernelCtx(); + await new MessagingServicePlugin({ reliableDelivery: false }).init(ctx); + for (const fn of ready) await fn(); + const messaging = services.get('messaging') as MessagingService; + const emit = { topic: 'deal.won', audience: ['usr_1'], channels: ['push'], payload: { title: 'Deal won', url: '/opportunity/9' } }; + + const before = await messaging.emit(emit); + expect(before.deliveries).toEqual([ + expect.objectContaining({ channel: 'push', recipient: 'usr_1', ok: false, error: "channel 'push' not registered" }), + ]); + + const { push, sent } = pushDouble(); + services.set('push', push); + const after = await messaging.emit(emit); + expect(after.deliveries).toEqual([expect.objectContaining({ channel: 'push', recipient: 'usr_1', ok: true })]); + expect(sent).toEqual([expect.objectContaining({ vendor: 'apns', token: 'tok-1', title: 'Deal won', data: { notificationId: 'evt_1', route: '/opportunity/9' } })]); + }); +}); diff --git a/packages/services/service-messaging/src/push-channel.ts b/packages/services/service-messaging/src/push-channel.ts new file mode 100644 index 00000000000..edfc1086214 --- /dev/null +++ b/packages/services/service-messaging/src/push-channel.ts @@ -0,0 +1,250 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { PushRouteSchema } from '@objectstack/spec/api'; +import type { UserDevice } from '@objectstack/spec/system'; +import type { + ChannelAvailability, + ChannelAvailabilityQuery, + ChannelUnavailableReason, + Delivery, + ErrorClass, + MessagingChannel, + MessagingChannelContext, + SendResult, +} from './channel.js'; + +/** + * Structural view of the push service (`@objectstack/service-push`'s + * `IPushService`), declared locally so service-messaging takes no runtime + * dependency on it: the channel resolves whatever is registered under the + * `push` service and sends through this shape (as `SmsSenderSurface` does in + * sms-channel.ts). + */ +export interface PushSenderSurface { + /** The vendors the service has a transport for. */ + vendors(): readonly string[]; + send(message: { + vendor: UserDevice['vendor']; + token: string; + title: string; + body: string; + data: Record; + }): Promise< + | { status: 'sent'; messageId?: string } + | { status: 'invalid_token'; reason: string } + | { status: 'failed'; error: string; retryable: boolean } + >; +} + +/** The two device-registry members the channel uses (`INotificationService`, implemented by `MessagingService`). */ +export interface PushDeviceSurface { + listDevices(userId: string): Promise; + invalidateDeviceToken(token: string): Promise; +} + +export interface PushChannelOptions { + /** + * Resolve the push service; `undefined` ⇒ there is no transport, which + * {@link MessagingChannel.send} refuses with the declared + * `transport_not_configured` reason, as the sms and email channels do. + */ + getPush(): PushSenderSurface | undefined; + /** The device registry: the recipient's devices, and the dead-token retirement. */ + devices: PushDeviceSurface; +} + +/** The one token for "there is no transport", shared with `isAvailable()` (see sms-channel.ts). */ +const TRANSPORT_NOT_CONFIGURED: ChannelUnavailableReason = 'transport_not_configured'; + +/** + * Error-string prefixes, read back by {@link classify}. The dispatcher hands + * `classifyError` the `SendResult.error` string, so the grade travels as its + * leading token, the convention sms-channel.ts uses. + */ +const NO_PUSH_DEVICE = 'no_push_device'; +const ALL_TOKENS_DEAD = 'invalid_recipient'; +const PUSH_REFUSED = 'push_refused'; + +/** + * Byte ceilings for the visible text. APNs and FCM both refuse a payload over + * 4 KB; the title, the body and the data together stay well under it, so a + * long notification is shortened rather than refused. + */ +const TITLE_MAX_BYTES = 256; +const BODY_MAX_BYTES = 2048; +/** A route longer than this is left out (the tap then opens the shell's default screen). */ +const ROUTE_MAX_BYTES = 1024; + +/** + * The `push` channel (ADR-0012 §4, §6; #29041) — delivers a notification to + * the recipient's phones through the registered `push` service + * (`@objectstack/service-push`: APNs and FCM). + * + * For one delivery (one recipient user): read the user's devices from the + * device registry (`sys_user_device`, which this package owns), keep the + * enabled ones whose vendor the push service has a transport for, and send + * each one the notification's title and body (already in the recipient's + * language: fan-out renders keyed text per recipient) plus its delivery data + * (`PushDeliveryDataSchema`): the notification id and, when it passes + * `PushRouteSchema`, its `actionUrl` as `route`. An `actionUrl` that is not a + * same-server path is left out, never sent; the delivery is not failed for it. + * + * **Dead tokens are retired here.** A device the vendor answers `invalid_token` + * for (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`) is retired + * through `invalidateDeviceToken`: the row is kept, disabled, with its time + * (`UserDeviceSchema` rule c). The channel does it, not the dispatcher, because + * one delivery fans out to several devices and only the channel knows which + * token died; the rest of the user's devices still receive the push. + * + * Outcome of one delivery: + * + * - at least one device accepted it ⇒ delivered. A device that failed beside + * it is logged, not retried: a retry would push the devices that already + * received it a second time. + * - every addressed device was reported dead ⇒ `invalid_recipient` + * (suppressed, not dead-lettered: the recipient, not the transport, is gone). + * - the user has no enabled device ⇒ `invalid_recipient` too: there is + * nothing to deliver to, and no retry changes that. + * - devices exist but none on a vendor the service can reach, or the vendor + * refused the push for a reason no retry repairs ⇒ `permanent`. + * - a timeout, a 429 or a 5xx with nothing delivered ⇒ `retryable`. + * - no push service registered ⇒ refused `transport_not_configured`, + * `permanent`, exactly as the sms and email channels answer it. + */ +export function createPushChannel(opts: PushChannelOptions): MessagingChannel { + return { + id: 'push', + + /** + * Can this deployment push at all? A registered push service with at + * least one vendor transport. A service-registry call and a property + * read: no I/O, no cache (the sms channel's reasoning applies as is). + */ + isAvailable(_ctx: MessagingChannelContext, _query: ChannelAvailabilityQuery): ChannelAvailability { + const push = opts.getPush(); + return push && push.vendors().length > 0 + ? { available: true } + : { available: false, reason: TRANSPORT_NOT_CONFIGURED }; + }, + + async send(ctx: MessagingChannelContext, delivery: Delivery): Promise { + const push = opts.getPush(); + const vendors = push?.vendors() ?? []; + if (!push || vendors.length === 0) { + ctx.logger.warn(`[push] no push transport is configured; '${delivery.recipient}' was NOT pushed`); + return { + ok: false, + error: `${TRANSPORT_NOT_CONFIGURED}: no 'push' service with a configured vendor is registered; ` + + `nothing was sent to '${delivery.recipient}'`, + }; + } + + const devices = (await opts.devices.listDevices(delivery.recipient)).filter((d) => d.enabled); + if (devices.length === 0) { + return { ok: false, error: `${NO_PUSH_DEVICE}: recipient '${delivery.recipient}' has no enabled push device` }; + } + const reachable = devices.filter((d) => vendors.includes(d.vendor)); + if (reachable.length === 0) { + return { + ok: false, + error: `${PUSH_REFUSED}: recipient '${delivery.recipient}' has devices only on ` + + `${[...new Set(devices.map((d) => d.vendor))].join(', ')}, which no push transport is configured for`, + }; + } + + const n = delivery.notification; + const title = truncateUtf8(plain(n.title), TITLE_MAX_BYTES); + const body = truncateUtf8(plain(n.body), BODY_MAX_BYTES); + const data = deliveryData(n.notificationId, n.actionUrl); + + const sent: string[] = []; + const dead: string[] = []; + const failures: Array<{ error: string; retryable: boolean }> = []; + for (const device of reachable) { + const result = await push.send({ vendor: device.vendor, token: device.token, title, body, data }); + if (result.status === 'sent') { + sent.push(result.messageId ?? ''); + } else if (result.status === 'invalid_token') { + dead.push(device.deviceId); + try { + await opts.devices.invalidateDeviceToken(device.token); + } catch (err) { + ctx.logger.warn( + `[push] device '${device.deviceId}' of '${delivery.recipient}' was reported dead (${result.reason}) ` + + `but could not be retired: ${(err as Error).message}`, + ); + } + } else { + failures.push({ error: result.error, retryable: result.retryable }); + } + } + + if (sent.length > 0) { + if (failures.length > 0) { + ctx.logger.warn( + `[push] '${delivery.recipient}': ${sent.length} device(s) pushed, ${failures.length} failed ` + + `(${failures.map((f) => f.error).join('; ')}); not retried, so the pushed devices are not pushed twice`, + ); + } + const ids = sent.filter(Boolean); + return { ok: true, externalId: ids.length > 0 ? ids.join(',') : undefined }; + } + if (failures.length === 0) { + return { + ok: false, + error: `${ALL_TOKENS_DEAD}: every push device of '${delivery.recipient}' was reported dead ` + + `and retired (${dead.join(', ')})`, + }; + } + const detail = failures.map((f) => f.error).join('; '); + return failures.some((f) => f.retryable) + ? { ok: false, error: `push send failed: ${detail}` } + : { ok: false, error: `${PUSH_REFUSED}: ${detail}` }; + }, + + classifyError(err: unknown): ErrorClass { + return classify(err instanceof Error ? err.message : String(err ?? '')); + }, + }; +} + +function classify(text: string): ErrorClass { + if (text.startsWith(`${TRANSPORT_NOT_CONFIGURED}:`)) return 'permanent'; + if (text.startsWith(`${NO_PUSH_DEVICE}:`) || text.startsWith(`${ALL_TOKENS_DEAD}:`)) return 'invalid_recipient'; + if (text.startsWith(`${PUSH_REFUSED}:`)) return 'permanent'; + return 'retryable'; +} + +/** + * The delivery data (`PushDeliveryDataSchema`): the notification id, plus + * `route` when the `actionUrl` is a same-server path `PushRouteSchema` admits. + */ +export function deliveryData(notificationId: string | undefined, actionUrl: string | undefined): Record { + const data: Record = {}; + if (notificationId) data.notificationId = notificationId; + if (actionUrl && Buffer.byteLength(actionUrl, 'utf8') <= ROUTE_MAX_BYTES && PushRouteSchema.safeParse(actionUrl).success) { + data.route = actionUrl; + } + return data; +} + +/** Collapse whitespace runs: a push shows one plain line of text per member. */ +function plain(text: string | undefined): string { + return String(text ?? '').replace(/\s+/g, ' ').trim(); +} + +/** Cut `text` to at most `maxBytes` UTF-8 bytes on a character boundary, marking the cut with an ellipsis. */ +export function truncateUtf8(text: string, maxBytes: number): string { + if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text; + const ellipsis = '…'; + const budget = maxBytes - Buffer.byteLength(ellipsis, 'utf8'); + let out = ''; + let used = 0; + for (const ch of text) { + const size = Buffer.byteLength(ch, 'utf8'); + if (used + size > budget) break; + out += ch; + used += size; + } + return out + ellipsis; +} diff --git a/packages/services/service-push/LICENSE b/packages/services/service-push/LICENSE new file mode 100644 index 00000000000..16bc23f4043 --- /dev/null +++ b/packages/services/service-push/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute + must include a readable copy of the attribution notices + contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, + in at least one of the following places: within a NOTICE + text file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with + the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the + License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 ObjectStack + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/services/service-push/README.md b/packages/services/service-push/README.md new file mode 100644 index 00000000000..d7799163008 --- /dev/null +++ b/packages/services/service-push/README.md @@ -0,0 +1,14 @@ +# @objectstack/service-push + +The push service: `IPushService` over the APNs (HTTP/2, token-based auth) and FCM (HTTP v1) transports, configured by `PushServiceConfigSchema` from `@objectstack/spec/system`. Each secret, the APNs `.p8` signing key and the FCM service-account JSON, is named by a `credentialRef` and resolved when the transport first signs (in the open tier, the name of an environment variable). `PushServicePlugin` registers the kernel's `push` service, and the `push` channel in `@objectstack/service-messaging` delivers through it to the devices users register under `/api/v1/notifications/devices`. + +```typescript +import { PushServicePlugin } from '@objectstack/service-push'; + +kernel.use(new PushServicePlugin({ + config: { + apns: { teamId: 'ABCDE12345', keyId: 'KEY1234567', topic: 'com.example.app', environment: 'production', credentialRef: 'APNS_SIGNING_KEY' }, + fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' }, + }, +})); +``` diff --git a/packages/services/service-push/package.json b/packages/services/service-push/package.json new file mode 100644 index 00000000000..ffeaad0e15c --- /dev/null +++ b/packages/services/service-push/package.json @@ -0,0 +1,62 @@ +{ + "name": "@objectstack/service-push", + "version": "18.2.0", + "license": "Apache-2.0", + "description": "Push service for ObjectStack — IPushService over the APNs (token auth) and FCM HTTP v1 transports, configured by PushServiceConfigSchema with credentials by reference.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts && node ../../../scripts/check-dts-emitted.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "@types/node": "^26.6.5", + "typescript": "^6.0.3", + "vitest": "^4.1.11" + }, + "keywords": [ + "objectstack", + "service", + "push", + "apns", + "fcm" + ], + "author": "ObjectStack", + "repository": { + "type": "git", + "url": "git+https://github.com/objectstack-ai/objectstack.git", + "directory": "packages/services/service-push" + }, + "homepage": "https://objectstack.ai/docs", + "bugs": "https://github.com/objectstack-ai/objectstack/issues", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "engines": { + "node": ">=22.0.0" + } +} diff --git a/packages/services/service-push/src/credential.ts b/packages/services/service-push/src/credential.ts new file mode 100644 index 00000000000..6421c91aa74 --- /dev/null +++ b/packages/services/service-push/src/credential.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Resolves a `credentialRef` to the secret it names (ADR-0143 D3, ADR-0097 §3). + * + * The push transports never hold a secret in configuration: the APNs `.p8` + * signing key and the FCM service-account JSON are each named by a + * `credentialRef` (`PushServiceConfigSchema`), and the transport asks this + * resolver for the value when it first needs to sign. May be async, so an + * enterprise resolver can read a vault. Answers `undefined` (or an empty + * string) when the reference names nothing. + */ +export type PushCredentialResolver = (ref: string) => string | undefined | Promise; + +/** + * The open-tier resolver: a reference is the name of an environment variable. + * The same rule as service-automation's `defaultEnvCredentialResolver` for a + * connector's `credentialRef`. + */ +export const envPushCredentialResolver: PushCredentialResolver = (ref) => + typeof process !== 'undefined' ? process.env?.[ref] : undefined; + +/** + * Resolve `ref`, refusing an empty answer with an error that names the + * reference and never a value. + */ +export async function resolveRequiredCredential( + resolve: PushCredentialResolver, + ref: string, + what: string, +): Promise { + const value = await resolve(ref); + if (typeof value !== 'string' || value.trim() === '') { + throw new PushCredentialError( + `credential_unresolved: the ${what} credentialRef '${ref}' names no secret ` + + '(in the open tier, set the environment variable of that name)', + ); + } + return value; +} + +/** + * A credential that is missing or unusable. No retry can repair it, so the + * transports answer it as a non-retryable failure. + */ +export class PushCredentialError extends Error { + constructor(message: string) { + super(message); + this.name = 'PushCredentialError'; + } +} diff --git a/packages/services/service-push/src/index.ts b/packages/services/service-push/src/index.ts new file mode 100644 index 00000000000..7ff6026b514 --- /dev/null +++ b/packages/services/service-push/src/index.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export { + PushService, + maskToken, + type IPushService, + type IPushTransport, + type PushMessage, + type PushSendResult, + type PushServiceOptions, +} from './push-service.js'; +export { PushServicePlugin, type PushServicePluginOptions } from './push-plugin.js'; +export { + envPushCredentialResolver, + PushCredentialError, + type PushCredentialResolver, +} from './credential.js'; +export { ApnsPushTransport, APNS_ORIGINS, type ApnsPushTransportOptions } from './transports/apns.js'; +export { FcmPushTransport, FCM_ORIGIN, GOOGLE_TOKEN_URI, type FcmPushTransportOptions } from './transports/fcm.js'; diff --git a/packages/services/service-push/src/push-plugin.test.ts b/packages/services/service-push/src/push-plugin.test.ts new file mode 100644 index 00000000000..901b194efe2 --- /dev/null +++ b/packages/services/service-push/src/push-plugin.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `PushServicePlugin` reads `PushServiceConfigSchema` (`@objectstack/spec/system`) + * and registers the kernel's `push` service; `PushService` routes by vendor. + */ + +import { describe, it, expect } from 'vitest'; +import { PushServicePlugin } from './push-plugin.js'; +import { PushService, type IPushTransport, type PushMessage } from './push-service.js'; +import { envPushCredentialResolver } from './credential.js'; + +function context() { + const services = new Map(); + const lines: string[] = []; + const logger = { info: (m: string) => lines.push(m), warn: (m: string) => lines.push(m), error() {}, debug() {} }; + return { + services, + lines, + ctx: { registerService: (name: string, s: unknown) => services.set(name, s), logger } as never, + }; +} + +const APNS = { teamId: 'TEAM123456', keyId: 'KEY1234567', topic: 'com.example.app', environment: 'production' as const, credentialRef: 'APNS_KEY' }; +const FCM = { projectId: 'example-app', credentialRef: 'FCM_ACCOUNT' }; +const message: PushMessage = { vendor: 'fcm', token: 't', title: 'x', body: 'y', data: {} }; + +describe('PushServicePlugin', () => { + it('registers the push service with one transport per configured vendor', async () => { + const { ctx, services } = context(); + await new PushServicePlugin({ config: { apns: APNS, fcm: FCM } }).init(ctx); + const push = services.get('push') as PushService; + expect(push).toBeInstanceOf(PushService); + expect(push.vendors().sort()).toEqual(['apns', 'fcm']); + }); + + it('registers a push service with no vendor when nothing is configured, and says so', async () => { + const { ctx, services, lines } = context(); + await new PushServicePlugin().init(ctx); + expect((services.get('push') as PushService).vendors()).toEqual([]); + expect(lines.some((l) => l.includes('no vendor configured'))).toBe(true); + }); + + it('refuses an inline secret at init with the schema\'s guidance, registering nothing', async () => { + const { ctx, services } = context(); + const plugin = new PushServicePlugin({ config: { apns: { ...APNS, privateKey: '-----BEGIN PRIVATE KEY-----' } } as never }); + await expect(plugin.init(ctx)).rejects.toThrow(/credentialRef/); + expect(services.has('push')).toBe(false); + }); + + it('lets a host transport win over that vendor\'s config block', async () => { + const { ctx, services } = context(); + const sent: PushMessage[] = []; + const fcm: IPushTransport = { vendor: 'fcm', async send(m) { sent.push(m); return { status: 'sent', messageId: 'host' }; } }; + await new PushServicePlugin({ config: { fcm: FCM }, transports: { fcm } }).init(ctx); + expect(await (services.get('push') as PushService).send(message)).toEqual({ status: 'sent', messageId: 'host' }); + expect(sent).toHaveLength(1); + }); +}); + +describe('PushService', () => { + it('answers a vendor with no transport as a non-retryable failure', async () => { + const push = new PushService({ transports: {} }); + expect(await push.send(message)).toEqual({ + status: 'failed', + error: 'vendor_not_configured: no fcm transport is configured', + retryable: false, + }); + }); + + it('answers a transport that throws as a retryable failure, logging a masked token', async () => { + const warned: string[] = []; + const push = new PushService({ + transports: { fcm: { vendor: 'fcm', async send() { throw new Error('socket hang up'); } } }, + logger: { warn: (m) => warned.push(m) }, + }); + const result = await push.send({ ...message, token: 'secret-device-token-123456' }); + expect(result).toMatchObject({ status: 'failed', retryable: true }); + expect(warned[0]).toContain('***123456'); + expect(warned[0]).not.toContain('secret-device-token'); + }); +}); + +describe('envPushCredentialResolver', () => { + it('reads the environment variable the reference names', () => { + process.env.PUSH_TEST_REF_29041 = 'value'; + try { + expect(envPushCredentialResolver('PUSH_TEST_REF_29041')).toBe('value'); + expect(envPushCredentialResolver('PUSH_TEST_REF_29041_ABSENT')).toBeUndefined(); + } finally { + delete process.env.PUSH_TEST_REF_29041; + } + }); +}); diff --git a/packages/services/service-push/src/push-plugin.ts b/packages/services/service-push/src/push-plugin.ts new file mode 100644 index 00000000000..13f294dc468 --- /dev/null +++ b/packages/services/service-push/src/push-plugin.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { PushServiceConfigSchema, type PushServiceConfig, type PushVendor } from '@objectstack/spec/system'; +import { PushService, type IPushTransport } from './push-service.js'; +import { envPushCredentialResolver, type PushCredentialResolver } from './credential.js'; +import { ApnsPushTransport } from './transports/apns.js'; +import { FcmPushTransport } from './transports/fcm.js'; + +export interface PushServicePluginOptions { + /** + * The push transports: one optional block per vendor, parsed by + * `PushServiceConfigSchema` (`@objectstack/spec/system`) when the plugin + * initialises, so an inline secret or a misspelt key fails the boot with + * the schema's own guidance. A vendor with no block is not delivered to. + */ + config?: PushServiceConfig; + /** + * Resolves each block's `credentialRef` to its secret when the transport + * first signs. Default: the open tier's rule, the name of an environment + * variable ({@link envPushCredentialResolver}). + */ + resolveCredential?: PushCredentialResolver; + /** + * Transports supplied by the host, per vendor; each wins over that + * vendor's `config` block. For a vendor endpoint the built-in transports + * do not reach, and for tests. + */ + transports?: Partial>; +} + +/** + * PushServicePlugin — registers the kernel's `push` service, the transport + * half of the `push` notification channel (ADR-0012 §4, §6; amended + * 2026-11-03: the transports ship here, the channel in service-messaging). + * + * The `push` channel itself lives in `@objectstack/service-messaging` + * (`push-channel.ts`): it is mounted while a `push` service is registered, + * reads the recipient's devices from the device registry (`sys_user_device`, + * owned by service-messaging), sends through this service, and retires a + * token this service reports dead. This package holds no table and reads no + * user. + * + * Configuration is the constructor's `config`, the operator's + * `PushServiceConfigSchema` value; secrets are named by `credentialRef` and + * resolved through {@link PushServicePluginOptions.resolveCredential}, never + * written inline (ADR-0143 D3). + * + * @example + * ```ts + * kernel.use(new MessagingServicePlugin()); + * kernel.use(new PushServicePlugin({ + * config: { + * apns: { teamId: 'ABCDE12345', keyId: 'KEY1234567', topic: 'com.example.app', + * environment: 'production', credentialRef: 'APNS_SIGNING_KEY' }, + * fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' }, + * }, + * })); + * ``` + */ +export class PushServicePlugin implements Plugin { + name = 'com.objectstack.service.push'; + /** Services init() registers on every path (ADR-0116). */ + providesServices = ['push']; + version = '1.0.0'; + type = 'standard' as const; + + private service?: PushService; + + constructor(private readonly options: PushServicePluginOptions = {}) {} + + async init(ctx: PluginContext): Promise { + const config = PushServiceConfigSchema.parse(this.options.config ?? {}); + const resolveCredential = this.options.resolveCredential ?? envPushCredentialResolver; + const transports: Partial> = {}; + if (config.apns) transports.apns = new ApnsPushTransport({ config: config.apns, resolveCredential }); + if (config.fcm) transports.fcm = new FcmPushTransport({ config: config.fcm, resolveCredential }); + Object.assign(transports, this.options.transports ?? {}); + + this.service = new PushService({ transports, logger: ctx.logger }); + ctx.registerService('push', this.service); + const vendors = this.service.vendors(); + ctx.logger.info( + vendors.length > 0 + ? `PushServicePlugin: push service registered (${vendors.join(', ')})` + : 'PushServicePlugin: push service registered with no vendor configured — the push channel delivers nothing', + ); + } + + async destroy(): Promise { + await this.service?.close(); + } +} diff --git a/packages/services/service-push/src/push-service.ts b/packages/services/service-push/src/push-service.ts new file mode 100644 index 00000000000..5092a4e237f --- /dev/null +++ b/packages/services/service-push/src/push-service.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { PushVendor } from '@objectstack/spec/system'; + +/** + * One push to one device, as the push channel hands it over. + * + * `data` is the push's delivery data (`PushDeliveryDataSchema`, + * `@objectstack/spec/api`): the notification id and, when it is a same-server + * path, its `route`. Every value is a string, because FCM's `message.data` + * takes only strings. Each transport places it where its vendor reads it: FCM + * in `message.data`, APNs at the top level of the payload beside `aps`. + */ +export interface PushMessage { + /** The vendor the token belongs to: the device row's `vendor`. */ + readonly vendor: PushVendor; + /** The vendor push token, as the device registered it. */ + readonly token: string; + /** Plain-text title (the notification's title, in the recipient's language). */ + readonly title: string; + /** Plain-text body. */ + readonly body: string; + /** Delivery data for the shell: string values only. */ + readonly data: Readonly>; +} + +/** + * What one push attempt answered. + * + * - `sent`: the vendor accepted it (`messageId` is APNs' `apns-id` or FCM's + * message `name`). + * - `invalid_token`: the vendor said the token is dead (APNs `BadDeviceToken` + * / `Unregistered`, FCM `UNREGISTERED`). The caller retires the device; the + * notification itself is not at fault. + * - `failed`: anything else. `retryable` says whether the same push may + * succeed later (a timeout, a 429 or a 5xx) or never will as configured (a + * rejected credential, a refused payload, a vendor with no transport). + */ +export type PushSendResult = + | { readonly status: 'sent'; readonly messageId?: string } + | { readonly status: 'invalid_token'; readonly reason: string } + | { readonly status: 'failed'; readonly error: string; readonly retryable: boolean }; + +/** A vendor transport: APNs or FCM. */ +export interface IPushTransport { + readonly vendor: PushVendor; + send(message: PushMessage): Promise; + /** Release held connections (the APNs HTTP/2 session). */ + close?(): Promise | void; +} + +/** + * The push service the `push` channel delivers through, registered as the + * kernel's `push` service by `PushServicePlugin`. + * + * It sends one push to one device and reports what the vendor answered. It + * does not know users or devices: the channel (`push-channel.ts` in + * `@objectstack/service-messaging`) reads the recipient's devices from the + * device registry and retires a token this service reports `invalid_token`. + */ +export interface IPushService { + /** The vendors this deployment has a transport for. Empty: push delivers nothing. */ + vendors(): PushVendor[]; + /** Send one push. Never throws: a transport error is answered `failed`. */ + send(message: PushMessage): Promise; +} + +export interface PushServiceOptions { + /** One transport per configured vendor. A vendor without one is not delivered to. */ + transports: Partial>; + logger?: { warn(msg: string): void }; +} + +/** + * The last six characters of a token, for log lines. A push token addresses + * one phone; logs never carry it whole. + */ +export function maskToken(token: string): string { + const t = String(token ?? ''); + return t.length <= 6 ? '***' : `***${t.slice(-6)}`; +} + +/** {@link IPushService} over one transport per vendor. */ +export class PushService implements IPushService { + constructor(private readonly opts: PushServiceOptions) {} + + vendors(): PushVendor[] { + return (Object.keys(this.opts.transports) as PushVendor[]).filter((v) => this.opts.transports[v]); + } + + async send(message: PushMessage): Promise { + const transport = this.opts.transports[message.vendor]; + if (!transport) { + return { + status: 'failed', + error: `vendor_not_configured: no ${message.vendor} transport is configured`, + retryable: false, + }; + } + try { + return await transport.send(message); + } catch (err) { + // A transport that throws (a dropped connection, a timeout) has + // not been answered by the vendor, so the same push may succeed. + const text = (err as Error)?.message ?? String(err); + this.opts.logger?.warn(`[push] ${message.vendor} send to ${maskToken(message.token)} threw: ${text}`); + return { status: 'failed', error: `${message.vendor} transport error: ${text}`, retryable: true }; + } + } + + /** Close every transport's held connections. */ + async close(): Promise { + for (const transport of Object.values(this.opts.transports)) { + await transport?.close?.(); + } + } +} diff --git a/packages/services/service-push/src/transports/apns.test.ts b/packages/services/service-push/src/transports/apns.test.ts new file mode 100644 index 00000000000..f915b829f84 --- /dev/null +++ b/packages/services/service-push/src/transports/apns.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The APNs transport against a local HTTP/2 server standing in for the APNs + * gateway: the request it makes (path, headers, a provider token the public + * key verifies, the payload with the delivery data beside `aps`), and how it + * reads each answer APNs gives. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import http2 from 'node:http2'; +import { generateKeyPairSync, verify } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import { ApnsPushTransport } from './apns.js'; + +const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); +const P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); +const TOKEN = 'ab'.repeat(32); + +interface Seen { headers: http2.IncomingHttpHeaders; body: string } +type Answer = { status: number; body?: unknown; apnsId?: string }; + +const servers: http2.Http2Server[] = []; +const transports: ApnsPushTransport[] = []; + +afterEach(async () => { + for (const t of transports.splice(0)) await t.close(); + for (const s of servers.splice(0)) await new Promise((r) => s.close(() => r())); +}); + +/** A gateway that answers each request with the next queued answer (the last one repeats). */ +async function gateway(answers: Answer[]) { + const seen: Seen[] = []; + const server = http2.createServer(); + server.on('stream', (stream: http2.ServerHttp2Stream, headers) => { + let body = ''; + stream.setEncoding('utf8'); + stream.on('data', (c: string) => { body += c; }); + stream.on('end', () => { + seen.push({ headers, body }); + const a = answers[Math.min(seen.length - 1, answers.length - 1)]; + stream.respond({ ':status': a.status, ...(a.apnsId ? { 'apns-id': a.apnsId } : {}) }); + stream.end(a.body === undefined ? '' : JSON.stringify(a.body)); + }); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + servers.push(server); + return { origin: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, seen }; +} + +function transport(origin: string, opts: { resolve?: (ref: string) => string | undefined; now?: () => number } = {}) { + const t = new ApnsPushTransport({ + config: { teamId: 'TEAM123456', keyId: 'KEY1234567', topic: 'com.example.app', environment: 'sandbox', credentialRef: 'APNS_KEY' }, + resolveCredential: opts.resolve ?? ((ref) => (ref === 'APNS_KEY' ? P8 : undefined)), + origin, + now: opts.now, + }); + transports.push(t); + return t; +} + +const message = { vendor: 'apns' as const, token: TOKEN, title: 'Deal won', body: 'Acme signed', data: { notificationId: 'ntf_1', route: '/apps/crm/opportunity/123' } }; + +function verifiesEs256(jwt: string): boolean { + const [h, c, s] = jwt.split('.'); + return verify('sha256', Buffer.from(`${h}.${c}`), { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(s, 'base64url')); +} + +describe('ApnsPushTransport', () => { + it('posts the alert to /3/device/TOKEN with a provider token the key verifies, the data beside aps', async () => { + const gw = await gateway([{ status: 200, apnsId: 'apns-id-1' }]); + const result = await transport(gw.origin).send(message); + + expect(result).toEqual({ status: 'sent', messageId: 'apns-id-1' }); + expect(gw.seen).toHaveLength(1); + const { headers, body } = gw.seen[0]; + expect(headers[':method']).toBe('POST'); + expect(headers[':path']).toBe(`/3/device/${TOKEN}`); + expect(headers['apns-topic']).toBe('com.example.app'); + expect(headers['apns-push-type']).toBe('alert'); + const jwt = String(headers.authorization).replace(/^bearer /, ''); + expect(verifiesEs256(jwt)).toBe(true); + const [h, c] = jwt.split('.').slice(0, 2).map((p) => JSON.parse(Buffer.from(p, 'base64url').toString())); + expect(h).toEqual({ alg: 'ES256', kid: 'KEY1234567' }); + expect(c.iss).toBe('TEAM123456'); + expect(JSON.parse(body)).toEqual({ + notificationId: 'ntf_1', + route: '/apps/crm/opportunity/123', + aps: { alert: { title: 'Deal won', body: 'Acme signed' }, sound: 'default' }, + }); + }); + + it.each([ + [400, 'BadDeviceToken'], + [410, 'Unregistered'], + ])('answers %i %s as invalid_token', async (status, reason) => { + const gw = await gateway([{ status, body: { reason } }]); + expect(await transport(gw.origin).send(message)).toEqual({ status: 'invalid_token', reason }); + }); + + it('re-signs once on ExpiredProviderToken and delivers', async () => { + let t = 1_000_000; + const gw = await gateway([{ status: 403, body: { reason: 'ExpiredProviderToken' } }, { status: 200, apnsId: 'x' }]); + const apns = transport(gw.origin, { now: () => (t += 1000) }); + expect(await apns.send(message)).toEqual({ status: 'sent', messageId: 'x' }); + expect(gw.seen).toHaveLength(2); + expect(gw.seen[0].headers.authorization).not.toBe(gw.seen[1].headers.authorization); + }); + + it('reuses one provider token for fifty minutes, then mints a new one', async () => { + let t = 1_000_000_000; + const gw = await gateway([{ status: 200 }]); + const apns = transport(gw.origin, { now: () => t }); + await apns.send(message); + t += 49 * 60 * 1000; + await apns.send(message); + t += 2 * 60 * 1000; + await apns.send(message); + const auth = gw.seen.map((s) => s.headers.authorization); + expect(auth[0]).toBe(auth[1]); + expect(auth[2]).not.toBe(auth[1]); + }); + + it.each([ + [429, 'TooManyRequests', true], + [503, 'ServiceUnavailable', true], + [500, 'InternalServerError', true], + [413, 'PayloadTooLarge', false], + [403, 'InvalidProviderToken', false], + ])('answers %i %s as failed, retryable %s', async (status, reason, retryable) => { + const gw = await gateway([{ status, body: { reason } }]); + const result = await transport(gw.origin).send(message); + expect(result).toMatchObject({ status: 'failed', retryable }); + expect((result as { error: string }).error).toContain(reason); + }); + + it('refuses a credentialRef that names nothing without sending, naming the reference and no value', async () => { + const gw = await gateway([{ status: 200 }]); + const result = await transport(gw.origin, { resolve: () => undefined }).send(message); + expect(result).toMatchObject({ status: 'failed', retryable: false }); + expect((result as { error: string }).error).toMatch(/^credential_unresolved: .*'APNS_KEY'/); + expect(gw.seen).toHaveLength(0); + }); + + it('refuses a reference that names something other than a signing key, never echoing it', async () => { + const gw = await gateway([{ status: 200 }]); + const result = await transport(gw.origin, { resolve: () => 'not-a-key-value' }).send(message); + expect(result).toMatchObject({ status: 'failed', retryable: false }); + expect((result as { error: string }).error).toMatch(/^credential_unusable: /); + expect((result as { error: string }).error).not.toContain('not-a-key-value'); + expect(gw.seen).toHaveLength(0); + }); + + it('answers a token that is not hex as invalid_token and never puts it in a request path', async () => { + const gw = await gateway([{ status: 200 }]); + const result = await transport(gw.origin).send({ ...message, token: '../../3/device/x' }); + expect(result).toMatchObject({ status: 'invalid_token' }); + expect(gw.seen).toHaveLength(0); + }); +}); diff --git a/packages/services/service-push/src/transports/apns.ts b/packages/services/service-push/src/transports/apns.ts new file mode 100644 index 00000000000..87b22769586 --- /dev/null +++ b/packages/services/service-push/src/transports/apns.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import http2 from 'node:http2'; +import { createPrivateKey, sign } from 'node:crypto'; +import type { ApnsTransportConfig } from '@objectstack/spec/system'; +import type { IPushTransport, PushMessage, PushSendResult } from '../push-service.js'; +import { PushCredentialError, resolveRequiredCredential, type PushCredentialResolver } from '../credential.js'; + +/** The two APNs gateways, by `environment`. */ +export const APNS_ORIGINS = { + production: 'https://api.push.apple.com', + sandbox: 'https://api.sandbox.push.apple.com', +} as const; + +/** + * How long one provider token is reused. Apple refuses a token older than an + * hour (`ExpiredProviderToken`) and throttles one refreshed more often than + * every twenty minutes (`TooManyProviderTokenUpdates`), so it is minted once + * and kept for fifty. + */ +const PROVIDER_TOKEN_TTL_MS = 50 * 60 * 1000; + +/** The reasons APNs gives for a token that will never deliver again. */ +const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered']); + +/** An APNs device token is hex. Anything else is not one, and never reaches the request path. */ +const APNS_TOKEN_SHAPE = /^[0-9a-fA-F]{1,512}$/; + +export interface ApnsPushTransportOptions { + /** The parsed `apns` block of `PushServiceConfigSchema`. */ + config: ApnsTransportConfig; + /** Resolves `config.credentialRef` to the `.p8` signing key (PEM). */ + resolveCredential: PushCredentialResolver; + /** + * The gateway origin. Defaults to the one `config.environment` names; a + * test points it at a local HTTP/2 server. Not authorable configuration. + */ + origin?: string; + /** Per-request timeout. Default 10 s. */ + timeoutMs?: number; + /** Clock, in epoch milliseconds. */ + now?: () => number; +} + +interface ApnsResponse { + status: number; + apnsId?: string; + body: string; +} + +/** + * APNs with token-based authentication, over HTTP/2 (APNs accepts nothing + * else). + * + * Signs an ES256 provider token with the `.p8` key the `credentialRef` names, + * reuses it for fifty minutes, and posts each push to + * `/3/device/TOKEN` on one held HTTP/2 session. The payload is the `aps` + * alert plus the delivery data at the top level beside it (never under a + * `data` key; `PushDeliveryDataSchema`). + * + * Answers: `200` is sent; `BadDeviceToken` / `Unregistered` (or `410`) is + * `invalid_token`; an `ExpiredProviderToken` re-signs once and retries; `429` + * and `5xx` are retryable; every other refusal is not. + */ +export class ApnsPushTransport implements IPushTransport { + readonly vendor = 'apns' as const; + private readonly origin: string; + private readonly timeoutMs: number; + private readonly now: () => number; + private client?: http2.ClientHttp2Session; + private providerToken?: { value: string; mintedAt: number }; + + constructor(private readonly opts: ApnsPushTransportOptions) { + this.origin = opts.origin ?? APNS_ORIGINS[opts.config.environment]; + this.timeoutMs = opts.timeoutMs ?? 10_000; + this.now = opts.now ?? Date.now; + } + + async send(message: PushMessage): Promise { + if (!APNS_TOKEN_SHAPE.test(message.token)) { + return { status: 'invalid_token', reason: 'BadDeviceToken: not a hex APNs device token' }; + } + const payload = JSON.stringify({ + ...message.data, + aps: { alert: { title: message.title, body: message.body }, sound: 'default' }, + }); + + for (let attempt = 1; ; attempt += 1) { + let jwt: string; + try { + jwt = await this.signedProviderToken(); + } catch (err) { + return { status: 'failed', error: credentialFailure(err, 'APNs'), retryable: false }; + } + const res = await this.post(`/3/device/${message.token}`, { + authorization: `bearer ${jwt}`, + 'apns-topic': this.opts.config.topic, + 'apns-push-type': 'alert', + 'apns-priority': '10', + 'content-type': 'application/json', + }, payload); + + if (res.status === 200) return { status: 'sent', ...(res.apnsId ? { messageId: res.apnsId } : {}) }; + const reason = readReason(res.body) ?? `HTTP ${res.status}`; + if (DEAD_TOKEN_REASONS.has(reason) || res.status === 410) { + return { status: 'invalid_token', reason }; + } + if (res.status === 403 && reason === 'ExpiredProviderToken' && attempt === 1) { + this.providerToken = undefined; + continue; + } + return { + status: 'failed', + error: `APNs answered ${res.status} ${reason}`, + retryable: res.status === 429 || res.status >= 500, + }; + } + } + + async close(): Promise { + const client = this.client; + this.client = undefined; + if (client && !client.closed) await new Promise((resolve) => client.close(() => resolve())); + } + + /** The ES256 provider token, minted from the `.p8` key and reused for {@link PROVIDER_TOKEN_TTL_MS}. */ + private async signedProviderToken(): Promise { + const now = this.now(); + if (this.providerToken && now - this.providerToken.mintedAt < PROVIDER_TOKEN_TTL_MS) { + return this.providerToken.value; + } + const pem = await resolveRequiredCredential(this.opts.resolveCredential, this.opts.config.credentialRef, 'APNs'); + let key; + try { + key = createPrivateKey(pem); + } catch { + throw new PushCredentialError( + `credential_unusable: the APNs credentialRef '${this.opts.config.credentialRef}' does not name a .p8 signing key`, + ); + } + const header = base64url(JSON.stringify({ alg: 'ES256', kid: this.opts.config.keyId })); + const claims = base64url(JSON.stringify({ iss: this.opts.config.teamId, iat: Math.floor(now / 1000) })); + const input = `${header}.${claims}`; + let signature: Buffer; + try { + signature = sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' }); + } catch { + throw new PushCredentialError( + `credential_unusable: the APNs credentialRef '${this.opts.config.credentialRef}' does not name an ES256 (P-256) key`, + ); + } + const value = `${input}.${base64url(signature)}`; + this.providerToken = { value, mintedAt: now }; + return value; + } + + private session(): http2.ClientHttp2Session { + if (this.client && !this.client.closed && !this.client.destroyed) return this.client; + const client = http2.connect(this.origin); + // A session-level error is reported by the request it fails; the + // listener only keeps it from becoming an unhandled 'error' event. + client.on('error', () => {}); + client.on('close', () => { + if (this.client === client) this.client = undefined; + }); + client.on('goaway', () => { + if (this.client === client) this.client = undefined; + }); + // The held session never keeps the process alive on its own. + client.unref(); + this.client = client; + return client; + } + + private post(path: string, headers: Record, body: string): Promise { + return new Promise((resolve, reject) => { + const req = this.session().request({ ':method': 'POST', ':path': path, ...headers }); + let status = 0; + let apnsId: string | undefined; + let data = ''; + req.setEncoding('utf8'); + req.on('response', (h) => { + status = Number(h[':status']); + const id = h['apns-id']; + apnsId = typeof id === 'string' ? id : undefined; + }); + req.on('data', (chunk: string) => { data += chunk; }); + req.on('end', () => resolve({ status, apnsId, body: data })); + req.on('error', reject); + req.setTimeout(this.timeoutMs, () => { + req.close(http2.constants.NGHTTP2_CANCEL); + reject(new Error(`APNs did not answer within ${this.timeoutMs} ms`)); + }); + req.end(body); + }); + } +} + +function base64url(value: string | Buffer): string { + return Buffer.from(value).toString('base64url'); +} + +function readReason(body: string): string | undefined { + try { + const reason = (JSON.parse(body) as { reason?: unknown })?.reason; + return typeof reason === 'string' && reason ? reason : undefined; + } catch { + return undefined; + } +} + +/** A credential failure's message; any other error is reported as unusable without its text. */ +export function credentialFailure(err: unknown, vendor: string): string { + if (err instanceof PushCredentialError) return err.message; + return `credential_unusable: the ${vendor} credential could not be used`; +} diff --git a/packages/services/service-push/src/transports/fcm.test.ts b/packages/services/service-push/src/transports/fcm.test.ts new file mode 100644 index 00000000000..d67e62d2d90 --- /dev/null +++ b/packages/services/service-push/src/transports/fcm.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The FCM HTTP v1 transport against a local HTTP server standing in for both + * Google's OAuth token endpoint and the FCM send endpoint: the signed + * assertion it exchanges, the message it posts (the delivery data in + * `message.data`), and how it reads each answer FCM gives. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import http from 'node:http'; +import { generateKeyPairSync, verify } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import { FcmPushTransport } from './fcm.js'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const SERVICE_ACCOUNT = JSON.stringify({ + type: 'service_account', + client_email: 'push@example-app.iam.gserviceaccount.com', + private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), +}); + +interface Seen { url: string; headers: http.IncomingHttpHeaders; body: string } +type Answer = { status: number; body?: unknown }; + +const servers: http.Server[] = []; +afterEach(async () => { + for (const s of servers.splice(0)) await new Promise((r) => s.close(() => r())); +}); + +/** One server: `/token` answers with `tokenAnswers`, everything else with `sendAnswers` (the last answer repeats). */ +async function google(sendAnswers: Answer[], tokenAnswers: Answer[] = [{ status: 200, body: { access_token: 'at-1', expires_in: 3600 } }]) { + const tokens: Seen[] = []; + const sends: Seen[] = []; + const server = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (c: string) => { body += c; }); + req.on('end', () => { + const isToken = req.url === '/token'; + const log = isToken ? tokens : sends; + const queue = isToken ? tokenAnswers : sendAnswers; + log.push({ url: String(req.url), headers: req.headers, body }); + const a = queue[Math.min(log.length - 1, queue.length - 1)]; + res.writeHead(a.status, { 'content-type': 'application/json' }); + res.end(a.body === undefined ? '' : JSON.stringify(a.body)); + }); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + servers.push(server); + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + return { origin, tokenUri: `${origin}/token`, tokens, sends }; +} + +function transport(g: { origin: string; tokenUri: string }, opts: { resolve?: (ref: string) => string | undefined; now?: () => number } = {}) { + return new FcmPushTransport({ + config: { projectId: 'example-app', credentialRef: 'FCM_ACCOUNT' }, + resolveCredential: opts.resolve ?? ((ref) => (ref === 'FCM_ACCOUNT' ? SERVICE_ACCOUNT : undefined)), + origin: g.origin, + tokenUri: g.tokenUri, + now: opts.now, + }); +} + +const message = { vendor: 'fcm' as const, token: 'fcm-token-1', title: 'Deal won', body: 'Acme signed', data: { notificationId: 'ntf_1', route: '/opportunity/123' } }; + +const unregistered = { + status: 404, + body: { error: { code: 404, status: 'NOT_FOUND', message: 'Requested entity was not found.', details: [{ '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', errorCode: 'UNREGISTERED' }] } }, +}; + +describe('FcmPushTransport', () => { + it('exchanges a signed RS256 assertion, then posts the message with the data in message.data', async () => { + const g = await google([{ status: 200, body: { name: 'projects/example-app/messages/0:1' } }]); + const result = await transport(g).send(message); + + expect(result).toEqual({ status: 'sent', messageId: 'projects/example-app/messages/0:1' }); + expect(g.tokens).toHaveLength(1); + const form = new URLSearchParams(g.tokens[0].body); + expect(form.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + const [h, c, s] = String(form.get('assertion')).split('.'); + expect(verify('RSA-SHA256', Buffer.from(`${h}.${c}`), publicKey, Buffer.from(s, 'base64url'))).toBe(true); + const claims = JSON.parse(Buffer.from(c, 'base64url').toString()); + expect(claims).toMatchObject({ + iss: 'push@example-app.iam.gserviceaccount.com', + scope: 'https://www.googleapis.com/auth/firebase.messaging', + aud: g.tokenUri, + }); + + expect(g.sends).toHaveLength(1); + expect(g.sends[0].url).toBe('/v1/projects/example-app/messages:send'); + expect(g.sends[0].headers.authorization).toBe('Bearer at-1'); + expect(JSON.parse(g.sends[0].body)).toEqual({ + message: { + token: 'fcm-token-1', + notification: { title: 'Deal won', body: 'Acme signed' }, + data: { notificationId: 'ntf_1', route: '/opportunity/123' }, + }, + }); + }); + + it('answers UNREGISTERED as invalid_token', async () => { + const g = await google([unregistered]); + expect(await transport(g).send(message)).toEqual({ status: 'invalid_token', reason: 'UNREGISTERED' }); + }); + + it('keeps the access token across sends until it nears expiry', async () => { + let t = 1_000_000_000; + const g = await google([{ status: 200, body: { name: 'm' } }]); + const fcm = transport(g, { now: () => t }); + await fcm.send(message); + t += 30 * 60 * 1000; + await fcm.send(message); + expect(g.tokens).toHaveLength(1); + t += 30 * 60 * 1000; + await fcm.send(message); + expect(g.tokens).toHaveLength(2); + }); + + it('refreshes the access token once on a 401 and delivers', async () => { + const g = await google( + [{ status: 401, body: { error: { code: 401, status: 'UNAUTHENTICATED' } } }, { status: 200, body: { name: 'm' } }], + [{ status: 200, body: { access_token: 'at-1', expires_in: 3600 } }, { status: 200, body: { access_token: 'at-2', expires_in: 3600 } }], + ); + expect(await transport(g).send(message)).toEqual({ status: 'sent', messageId: 'm' }); + expect(g.sends.map((s) => s.headers.authorization)).toEqual(['Bearer at-1', 'Bearer at-2']); + }); + + it.each([ + [429, 'QUOTA_EXCEEDED', true], + [503, 'UNAVAILABLE', true], + [400, 'INVALID_ARGUMENT', false], + [403, 'SENDER_ID_MISMATCH', false], + ])('answers %i %s as failed, retryable %s', async (status, errorCode, retryable) => { + const g = await google([{ status, body: { error: { code: status, status: errorCode, details: [{ errorCode }] } } }]); + const result = await transport(g).send(message); + expect(result).toMatchObject({ status: 'failed', retryable }); + expect((result as { error: string }).error).toContain(errorCode); + }); + + it('refuses a reference that names no service-account JSON, without echoing it or calling Google', async () => { + const g = await google([{ status: 200, body: { name: 'm' } }]); + const result = await transport(g, { resolve: () => 'plainly-not-json' }).send(message); + expect(result).toMatchObject({ status: 'failed', retryable: false }); + expect((result as { error: string }).error).toMatch(/^credential_unusable: .*'FCM_ACCOUNT'/); + expect((result as { error: string }).error).not.toContain('plainly-not-json'); + expect(g.tokens).toHaveLength(0); + expect(g.sends).toHaveLength(0); + }); + + it('answers a token endpoint that refuses the service account as a non-retryable credential failure', async () => { + const g = await google([{ status: 200 }], [{ status: 400, body: { error: 'invalid_grant' } }]); + const result = await transport(g).send(message); + expect(result).toMatchObject({ status: 'failed', retryable: false }); + expect((result as { error: string }).error).toMatch(/^credential_refused: /); + expect(g.sends).toHaveLength(0); + }); +}); diff --git a/packages/services/service-push/src/transports/fcm.ts b/packages/services/service-push/src/transports/fcm.ts new file mode 100644 index 00000000000..a99651fcd46 --- /dev/null +++ b/packages/services/service-push/src/transports/fcm.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { createPrivateKey, sign } from 'node:crypto'; +import type { FcmTransportConfig } from '@objectstack/spec/system'; +import type { IPushTransport, PushMessage, PushSendResult } from '../push-service.js'; +import { PushCredentialError, resolveRequiredCredential, type PushCredentialResolver } from '../credential.js'; +import { credentialFailure } from './apns.js'; + +/** The FCM HTTP v1 origin. */ +export const FCM_ORIGIN = 'https://fcm.googleapis.com'; + +/** Google's OAuth 2.0 token endpoint, used when the service account names none. */ +export const GOOGLE_TOKEN_URI = 'https://oauth2.googleapis.com/token'; + +/** The one scope the HTTP v1 send needs. */ +const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'; + +/** An access token is refreshed this long before Google says it expires. */ +const ACCESS_TOKEN_MARGIN_MS = 60 * 1000; + +export interface FcmPushTransportOptions { + /** The parsed `fcm` block of `PushServiceConfigSchema`. */ + config: FcmTransportConfig; + /** Resolves `config.credentialRef` to the service-account JSON. */ + resolveCredential: PushCredentialResolver; + /** The FCM origin. Defaults to {@link FCM_ORIGIN}; a test points it at a local server. Not authorable. */ + origin?: string; + /** + * The OAuth token endpoint. Defaults to the service account's `token_uri`, + * else {@link GOOGLE_TOKEN_URI}; a test points it at a local server. Not authorable. + */ + tokenUri?: string; + /** Per-request timeout. Default 10 s. */ + timeoutMs?: number; + /** Clock, in epoch milliseconds. */ + now?: () => number; + /** `fetch`, injectable for a test. */ + fetch?: typeof fetch; +} + +interface ServiceAccount { + clientEmail: string; + privateKey: string; + tokenUri: string; +} + +interface FcmError { + code?: number; + status?: string; + message?: string; + details?: Array<{ '@type'?: string; errorCode?: string }>; +} + +/** + * FCM over the HTTP v1 API, authenticated by a service account. + * + * Signs an RS256 assertion with the service account's private key (the JSON + * the `credentialRef` names), exchanges it for an OAuth access token, keeps + * that token until a minute before it expires, and posts each push to + * `/v1/projects/PROJECT/messages:send` with the delivery data in + * `message.data` (`PushDeliveryDataSchema`). + * + * Answers: `200` is sent; `UNREGISTERED` (or `404 NOT_FOUND`) is + * `invalid_token`; a `401` refreshes the access token once and retries; + * `429` and `5xx` are retryable; every other refusal is not. + */ +export class FcmPushTransport implements IPushTransport { + readonly vendor = 'fcm' as const; + private readonly origin: string; + private readonly timeoutMs: number; + private readonly now: () => number; + private readonly fetch: typeof fetch; + private accessToken?: { value: string; expiresAt: number }; + + constructor(private readonly opts: FcmPushTransportOptions) { + this.origin = (opts.origin ?? FCM_ORIGIN).replace(/\/+$/, ''); + this.timeoutMs = opts.timeoutMs ?? 10_000; + this.now = opts.now ?? Date.now; + this.fetch = opts.fetch ?? fetch; + } + + async send(message: PushMessage): Promise { + const url = `${this.origin}/v1/projects/${encodeURIComponent(this.opts.config.projectId)}/messages:send`; + const body = JSON.stringify({ + message: { + token: message.token, + notification: { title: message.title, body: message.body }, + data: { ...message.data }, + }, + }); + + for (let attempt = 1; ; attempt += 1) { + let token: string; + try { + token = await this.oauthAccessToken(); + } catch (err) { + if (err instanceof PushCredentialError) return { status: 'failed', error: err.message, retryable: false }; + throw err; + } + const res = await this.fetch(url, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body, + signal: AbortSignal.timeout(this.timeoutMs), + }); + const text = await res.text(); + if (res.status === 200) { + const name = readJson<{ name?: unknown }>(text)?.name; + return { status: 'sent', ...(typeof name === 'string' && name ? { messageId: name } : {}) }; + } + const error = readJson<{ error?: FcmError }>(text)?.error ?? {}; + const errorCode = error.details?.find((d) => typeof d?.errorCode === 'string')?.errorCode; + if (errorCode === 'UNREGISTERED' || (res.status === 404 && error.status === 'NOT_FOUND')) { + return { status: 'invalid_token', reason: errorCode ?? 'NOT_FOUND' }; + } + if (res.status === 401 && attempt === 1) { + this.accessToken = undefined; + continue; + } + return { + status: 'failed', + error: `FCM answered ${res.status} ${errorCode ?? error.status ?? ''}`.trim(), + retryable: res.status === 429 || res.status >= 500, + }; + } + } + + /** The OAuth access token, exchanged for a signed assertion and kept until it nears expiry. */ + private async oauthAccessToken(): Promise { + const now = this.now(); + if (this.accessToken && now < this.accessToken.expiresAt) return this.accessToken.value; + + const account = await this.serviceAccount(); + const tokenUri = this.opts.tokenUri ?? account.tokenUri; + const iat = Math.floor(now / 1000); + const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); + const claims = base64url(JSON.stringify({ iss: account.clientEmail, scope: FCM_SCOPE, aud: tokenUri, iat, exp: iat + 3600 })); + const input = `${header}.${claims}`; + let signature: Buffer; + try { + signature = sign('RSA-SHA256', Buffer.from(input), createPrivateKey(account.privateKey)); + } catch (err) { + throw new PushCredentialError(credentialFailure(err, 'FCM service-account private key')); + } + + const res = await this.fetch(tokenUri, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: `${input}.${base64url(signature)}`, + }).toString(), + signal: AbortSignal.timeout(this.timeoutMs), + }); + const answer = readJson<{ access_token?: unknown; expires_in?: unknown }>(await res.text()); + if (!res.ok || typeof answer?.access_token !== 'string') { + if (res.status === 400 || res.status === 401 || res.status === 403) { + throw new PushCredentialError( + `credential_refused: the FCM token endpoint refused the service account (HTTP ${res.status})`, + ); + } + throw new Error(`the FCM token endpoint answered HTTP ${res.status}`); + } + const expiresIn = typeof answer.expires_in === 'number' ? answer.expires_in : 3600; + this.accessToken = { + value: answer.access_token, + expiresAt: now + expiresIn * 1000 - ACCESS_TOKEN_MARGIN_MS, + }; + return this.accessToken.value; + } + + private async serviceAccount(): Promise { + const ref = this.opts.config.credentialRef; + const raw = await resolveRequiredCredential(this.opts.resolveCredential, ref, 'FCM'); + const parsed = readJson>(raw); + const clientEmail = parsed?.client_email; + const privateKey = parsed?.private_key; + if (typeof clientEmail !== 'string' || typeof privateKey !== 'string') { + throw new PushCredentialError( + `credential_unusable: the FCM credentialRef '${ref}' does not name a service-account JSON ` + + '(client_email and private_key)', + ); + } + const tokenUri = typeof parsed?.token_uri === 'string' && parsed.token_uri.startsWith('https://') + ? parsed.token_uri + : GOOGLE_TOKEN_URI; + return { clientEmail, privateKey, tokenUri }; + } +} + +function base64url(value: string | Buffer): string { + return Buffer.from(value).toString('base64url'); +} + +function readJson(text: string): T | undefined { + try { + return JSON.parse(text) as T; + } catch { + return undefined; + } +} diff --git a/packages/services/service-push/tsconfig.json b/packages/services/service-push/tsconfig.json new file mode 100644 index 00000000000..5c03d935aaa --- /dev/null +++ b/packages/services/service-push/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": [ + "node" + ] + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/services/service-push/vitest.config.ts b/packages/services/service-push/vitest.config.ts new file mode 100644 index 00000000000..758e8b268d8 --- /dev/null +++ b/packages/services/service-push/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // A late console.* must not redden a green suite (#10374). + // Mechanism + measured costs: examples/app-showcase/vitest.config.ts. + // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. + disableConsoleIntercept: true, + }, +}); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 7fa06605d96..6a22745c89b 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1957,7 +1957,9 @@ export const STANDARD_SYNONYM_WAIVERS: readonly StandardSynonymWaiver[] = [ { code: 'NOT_FOUND', shadows: 'RESOURCE_NOT_FOUND', - reason: 'Pre-gate synonym on the wire from @objectstack/rest and plugin-sharing. ' + + reason: 'Pre-gate synonym on the wire from @objectstack/rest and plugin-sharing; ' + + 'service-messaging lists the same pre-existing wire value under its own provenance row ' + + '(its device-registry unregister 404). ' + 'Wire value kept; consolidation deferred until it has a measured victim.', }, { diff --git a/packages/spec/src/api/push-delivery.test.ts b/packages/spec/src/api/push-delivery.test.ts index 929b6a47b3c..004336f9f65 100644 --- a/packages/spec/src/api/push-delivery.test.ts +++ b/packages/spec/src/api/push-delivery.test.ts @@ -74,8 +74,8 @@ describe('PushDeliveryDataSchema', () => { }); }); -describe('PushDeliveryDataSchema is declared, not yet served', () => { - it('carries the PLANNED marker until a push channel produces it', () => { - expect(PushDeliveryDataSchema.description).toMatch(/^\[PLANNED — /); +describe('PushDeliveryDataSchema is served', () => { + it('carries no PLANNED marker: the push channel produces it (#29041)', () => { + expect(PushDeliveryDataSchema.description).not.toMatch(/PLANNED/); }); }); diff --git a/packages/spec/src/api/push-delivery.zod.ts b/packages/spec/src/api/push-delivery.zod.ts index f69f8a90678..afbae020a3e 100644 --- a/packages/spec/src/api/push-delivery.zod.ts +++ b/packages/spec/src/api/push-delivery.zod.ts @@ -16,10 +16,10 @@ import { lazySchema } from '../shared/lazy-schema'; * of string values. On APNs they sit at the top level of the payload, beside * `aps`, and never under a `data` key. * - * **Declared ahead of its producer.** No push channel delivers yet, so - * nothing produces this data today; {@link PushDeliveryDataSchema} carries a - * `[PLANNED — not yet served]` marker in its `.describe()` until the push - * channel ships. The route rule below is the contract that channel owes. + * **Produced by the `push` channel** (`push-channel.ts` in + * `@objectstack/service-messaging`), which builds it per delivery and hands it + * to the push service's transports (`@objectstack/service-push`). The route + * rule below is the contract that channel keeps. * * **What `route` is — the inbox's `actionUrl`, derived, never authored.** * There is no `notify` key for it. It is the value the inbox already serves: @@ -86,7 +86,6 @@ export const PushRouteSchema = z.string() export type PushRoute = z.input; /** The data a push delivery carries to the shell. */ -// The marker comes off when the push channel (#29041) produces this data. export const PushDeliveryDataSchema = lazySchema(() => z.object({ notificationId: z.string().min(1).describe('Notification id — the one the inbox lists and markRead takes'), route: PushRouteSchema.optional().describe( @@ -94,7 +93,6 @@ export const PushDeliveryDataSchema = lazySchema(() => z.object({ + 'The shell resolves it on tap as the inbox bell and Home do, never verbatim', ), }).describe( - '[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and its actionUrl as a ' - + 'same-server route. No push channel delivers yet', + 'The data a push delivery carries to the shell: the notification id and its actionUrl as a same-server route', )); export type PushDeliveryData = z.input; diff --git a/packages/spec/src/contracts/notification-service.ts b/packages/spec/src/contracts/notification-service.ts index 041ed494530..6fce5dd2257 100644 --- a/packages/spec/src/contracts/notification-service.ts +++ b/packages/spec/src/contracts/notification-service.ts @@ -228,8 +228,9 @@ export interface INotificationService { * Implemented by `@objectstack/service-messaging` (`MessagingService`, * over `sys_user_device`); `registerDevice` and `unregisterDevice` are * served by `POST` / `DELETE /api/v1/notifications/devices`. - * `listDevices` has no route, and `invalidateDeviceToken` is called by - * the push channel's invalid-recipient path. + * `listDevices` has no route: the `push` channel (`push-channel.ts`, + * also service-messaging) reads it to address the recipient's phones, and + * calls `invalidateDeviceToken` for a token the vendor reports dead. */ /** @@ -286,8 +287,8 @@ export interface INotificationService { /** * Retire a token the vendor reported dead (APNs `BadDeviceToken` / - * `Unregistered`, FCM `UNREGISTERED`), called by the push channel's - * invalid-recipient path (ADR-0012 §6, §12). + * `Unregistered`, FCM `UNREGISTERED`), called by the `push` channel when + * a send to that token is answered dead (ADR-0012 §6, §12). * * The device row is KEPT, disabled, with the time of invalidation, so * "why did this phone stop getting pushes" stays answerable and a later diff --git a/packages/spec/src/system/notification.zod.ts b/packages/spec/src/system/notification.zod.ts index 89ee2c30f46..9951dca3cc5 100644 --- a/packages/spec/src/system/notification.zod.ts +++ b/packages/spec/src/system/notification.zod.ts @@ -9,9 +9,11 @@ import { lazySchema } from '../shared/lazy-schema'; * Supported notification delivery channels. * * ⚠️ PARTIALLY ENFORCED — the delivery channels actually registered by - * `service-messaging` are `inbox`, `email`, and `sms` (#3197). `push`, - * `slack`, `teams`, and `webhook` have no delivery implementation, and the - * dispatcher dead-letters any message addressed to an unregistered channel. + * `service-messaging` are `inbox`, `email`, `sms` (#3197) and `push` (#29041), + * each but the inbox mounted while its transport service is registered (`push`: + * `@objectstack/service-push`). `slack`, `teams`, and `webhook` have no + * delivery implementation, and fan-out refuses any message addressed to an + * unregistered channel. * Note also the naming drift: this enum says `in-app` while the implemented * channel registers as `inbox` (which this enum does not contain) — * reconcile before wiring this enum into the runtime. @@ -60,9 +62,13 @@ export const NotificationChannelSchema = lazySchema(() => z.enum([ // `template-renderer.ts`; the provider-side template is Aliyun's // pre-registered `TemplateCode` in `service-sms` (`aliyun.ts`), a vendor // API shape, not a spec constant. -// - push / in-app — no delivery implementation at all (#3197): the -// dispatcher dead-letters them. Declaring their payload shapes here -// advertised a capability nothing delivers. +// - push — no template: the `push` channel (`push-channel.ts` in +// service-messaging, #29041) sends the notification's own title and body, +// already in the recipient's language, with `PushDeliveryDataSchema` +// (`api/push-delivery.zod.ts`) as its data, through `@objectstack/service-push`. +// - in-app — no delivery implementation under that name (#3197); the inbox +// channel is `inbox`. Declaring payload shapes here advertised a +// capability nothing delivered. // // The channel vocabulary above stays: `NotificationChannel` is re-exported by // `@objectstack/spec/contracts` and consumed by `service-messaging`. diff --git a/packages/spec/src/system/push-config.test.ts b/packages/spec/src/system/push-config.test.ts index 327e6066ab0..3e38fa05851 100644 --- a/packages/spec/src/system/push-config.test.ts +++ b/packages/spec/src/system/push-config.test.ts @@ -108,14 +108,14 @@ describe('PushServiceConfigSchema', () => { }); }); -// Declared ahead of its reader (ADR-0049): nothing reads this shape until the -// push channel plugin ships, and each block says so in its description. -describe('push transport configuration is declared, not yet read', () => { +// Read by `PushServicePlugin` (`@objectstack/service-push`, #29041), so no +// block claims it is unread any more (ADR-0049: a marker states a fact). +describe('push transport configuration is read', () => { it.each([ ['PushServiceConfigSchema', PushServiceConfigSchema], ['ApnsTransportConfigSchema', ApnsTransportConfigSchema], ['FcmTransportConfigSchema', FcmTransportConfigSchema], - ] as const)('%s carries the PLANNED marker', (_name, schema) => { - expect(schema.description).toMatch(/^\[PLANNED — /); + ] as const)('%s carries no PLANNED marker', (_name, schema) => { + expect(schema.description).not.toMatch(/PLANNED/); }); }); diff --git a/packages/spec/src/system/push-config.zod.ts b/packages/spec/src/system/push-config.zod.ts index 6b45637ae9c..3c7d8ab9067 100644 --- a/packages/spec/src/system/push-config.zod.ts +++ b/packages/spec/src/system/push-config.zod.ts @@ -7,14 +7,16 @@ import { strictObject } from '../shared/strict-object'; /** * Push Service Configuration Protocol — the push channel's transports. * - * **Declared ahead of its reader.** Every schema here carries a - * `[PLANNED — not yet read]` marker in its `.describe()`: nothing reads this - * shape until the push channel plugin lands, so a value written to it today - * changes nothing. The marker comes off with the change that wires the reader. + * **Read by `@objectstack/service-push`.** `PushServicePlugin` parses its + * `config` option with {@link PushServiceConfigSchema} when it initialises and + * builds one transport per block; the `push` channel in + * `@objectstack/service-messaging` delivers through the push service it + * registers. * * Operator-facing configuration for the `push` channel ADR-0012 §4 ships in * core: Apple Push Notification service with token-based auth, and Firebase - * Cloud Messaging over its HTTP v1 API. It sits beside + * Cloud Messaging over its HTTP v1 API (ADR-0012, amended 2026-11-03: the + * transports ship as `@objectstack/service-push`). It sits beside * `EmailServiceConfigSchema` (`email-config.zod.ts`), the declaration of * the email channel's transport selection. * @@ -22,21 +24,21 @@ import { strictObject } from '../shared/strict-object'; * transport has one secret — the APNs `.p8` signing key and the FCM * service-account JSON — and each is named by a `credentialRef`, the same key * every other secret an author names is written under (ADR-0143 D3), which - * the push channel resolves through the secrets layer when it sends (in the - * open tier, the name of an environment variable). An inline secret is + * the push transport resolves through the secrets layer when it first signs + * (in the open tier, the name of an environment variable). An inline secret is * refused two ways: written under a key an operator would reach for (`key`, * `privateKey`, `serviceAccount`, …), it is refused by name with * `credentialRef` as the prescription; written into the reference itself, it * is refused because a reference is one token — a PEM block carries * whitespace and a JSON document carries braces, and a name carries neither. * - * **What reads it, and why it is not a stack key yet.** The push channel - * plugin (`plugin-notification-push`, ADR-0012 §11) is its one reader, and it - * is a later change than this declaration. The email transport selection it - * sits beside is read from `config.email` of the stack definition - * (`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape gets its own - * configuration door in the change that ships the reader, not here, so a key - * is never authorable before something honours it. + * **What reads it, and why it is not a stack key.** `PushServicePlugin` + * (`@objectstack/service-push`) is its one reader, through the plugin's + * constructor: the host that composes the plugin hands it the value. The email + * transport selection it sits beside is read from `config.email` of the stack + * definition (`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape has no + * stack-definition key and no `os serve` capability yet, so it is authorable + * only where a host composes the plugin. * * Out of scope here: a hosted (cloud-side) configuration surface, and the * other vendors ADR-0012 §6 routes to alternate channel plugins (HMS, @@ -110,7 +112,7 @@ export const ApnsTransportConfigSchema = lazySchema(() => strictObject( credentialRef: secretReference('APNs .p8 signing key') .describe('Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself'), }, -).describe('[PLANNED — not yet read] APNs transport with token-based auth (.p8 signing key by reference)')); +).describe('APNs transport with token-based auth (.p8 signing key by reference)')); export type ApnsTransportConfig = z.input; /** FCM over the HTTP v1 API, authenticated by a service account. */ @@ -135,7 +137,7 @@ export const FcmTransportConfigSchema = lazySchema(() => strictObject( credentialRef: secretReference('FCM service-account JSON') .describe('Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself'), }, -).describe('[PLANNED — not yet read] FCM HTTP v1 transport (service-account JSON by reference)')); +).describe('FCM HTTP v1 transport (service-account JSON by reference)')); export type FcmTransportConfig = z.input; /** @@ -152,5 +154,5 @@ export const PushServiceConfigSchema = lazySchema(() => strictObject( apns: ApnsTransportConfigSchema.optional().describe('APNs transport (token-based auth). Absent: no delivery to apns devices'), fcm: FcmTransportConfigSchema.optional().describe('FCM HTTP v1 transport. Absent: no delivery to fcm devices'), }, -).describe('[PLANNED — not yet read] Push channel transport configuration: one optional block per vendor (apns, fcm). Nothing reads it until the push channel plugin ships')); +).describe('Push channel transport configuration: one optional block per vendor (apns, fcm), read by the push service')); export type PushServiceConfig = z.input; diff --git a/packages/spec/src/system/push-device.zod.ts b/packages/spec/src/system/push-device.zod.ts index bacdd09e5b9..497d76eb9b0 100644 --- a/packages/spec/src/system/push-device.zod.ts +++ b/packages/spec/src/system/push-device.zod.ts @@ -16,9 +16,12 @@ import { PUSH_DEVICE_PLATFORMS } from './push-device-platform'; * answers (`INotificationService.listDevices`). * * Stored in `sys_user_device`, the device registry's table (ADR-0012 §11), - * which `@objectstack/service-messaging` owns while no push channel plugin - * exists: its `INotificationService` device members write it, and the device - * routes under `/api/v1/notifications/devices` are the only door to it. + * which `@objectstack/service-messaging` owns: its `INotificationService` + * device members write it, the device routes under + * `/api/v1/notifications/devices` are the only door to it from outside, and + * its `push` channel reads it to address a phone and retires dead tokens in + * it. `@objectstack/service-push` holds only the vendor transports and never + * touches the table. * * Three rules the registry owes, stated here because they decide the shape: * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e51617f1d1d..a4b32ff8911 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2731,6 +2731,9 @@ importers: '@objectstack/service-messaging': specifier: workspace:* version: link:../service-messaging + '@objectstack/service-push': + specifier: workspace:* + version: link:../service-push '@types/node': specifier: ^26.6.5 version: 26.6.5 @@ -3085,6 +3088,25 @@ importers: specifier: ^4.1.11 version: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.10)(happy-dom@21.0.4)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.6.5)(typescript@6.0.3))(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + packages/services/service-push: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@types/node': + specifier: ^26.6.5 + version: 26.6.5 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.11 + version: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.10)(happy-dom@21.0.4)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.6.5)(typescript@6.0.3))(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + packages/services/service-queue: dependencies: '@objectstack/core': From 391ab42bda904dc9d82fd451b5ceac933b58b78a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:10:27 +0000 Subject: [PATCH 2/9] test(notifications): type the plugin-wiring context double in push-channel.test.ts Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JQwxbWDV359nY4JGrtUq4P --- packages/services/service-messaging/src/push-channel.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/services/service-messaging/src/push-channel.test.ts b/packages/services/service-messaging/src/push-channel.test.ts index cf3cfcfb245..eff1a39c369 100644 --- a/packages/services/service-messaging/src/push-channel.test.ts +++ b/packages/services/service-messaging/src/push-channel.test.ts @@ -222,8 +222,10 @@ describe('MessagingServicePlugin mounts the push channel while a push service is async update() { return {}; }, async delete() { return {}; }, }); + const logger: Record = { info() {}, warn() {}, error() {}, debug() {} }; + logger.child = () => logger; const ctx = { - logger: { info() {}, warn() {}, error() {}, debug() {}, child: () => ctx.logger }, + logger, registerService: (name: string, svc: unknown) => services.set(name, svc), getService: (name: string): T => { if (!services.has(name)) throw new Error(`service '${name}' not found`); From 1ebbde26bcf5669af20bae6e802d7d959878d923 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:49:56 +0000 Subject: [PATCH 3/9] test(notifications): hold the push test doubles to the engine contracts, alias service-push and spec/system to source, and count service-push in spec's llms.txt The engine doubles in push-channel.test.ts and notify-push-delivery.integration.test.ts route findOne/update/delete through the metadata-core dispatch predicates and hold the caller's find limit (pinned ledger rows written by check-engine-double-contract --write). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JQwxbWDV359nY4JGrtUq4P --- .../notify-push-delivery.integration.test.ts | 14 +++++++-- .../service-automation/vitest.config.ts | 8 +++++ .../src/push-channel.test.ts | 19 ++++++++---- .../services/service-push/vitest.config.ts | 14 +++++++++ packages/spec/llms.txt | 4 +-- scripts/engine-double-contract.pinned.json | 30 +++++++++++++++++++ 6 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts b/packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts index af6f1d4bdbb..c5c1437a536 100644 --- a/packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts +++ b/packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts @@ -22,6 +22,12 @@ import http2 from 'node:http2'; import { generateKeyPairSync } from 'node:crypto'; import type { AddressInfo } from 'node:net'; import { MessagingService, createInboxChannel, createPushChannel } from '@objectstack/service-messaging'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, + type EngineFindOneQueryInput, +} from '@objectstack/metadata-core'; import { ApnsPushTransport, FcmPushTransport, PushService } from '@objectstack/service-push'; import { AutomationEngine } from '../engine.js'; import { registerNotifyNode } from './notify-node.js'; @@ -50,18 +56,22 @@ function memoryEngine() { (tables[object] ??= []).push(stored); return { ...stored }; }, - async find(object: string, q: { where?: Record } = {}) { - return (tables[object] ?? []).filter((r) => matches(r, q.where)).map((r) => ({ ...r })); + async find(object: string, q: { where?: Record; limit?: number } = {}) { + const rows = (tables[object] ?? []).filter((r) => matches(r, q.where)); + return (typeof q.limit === 'number' ? rows.slice(0, q.limit) : rows).map((r) => ({ ...r })); }, async findOne(object: string, q: { where?: Record } = {}) { + assertEngineFindOnePredicate(object, q as EngineFindOneQueryInput); const hit = (tables[object] ?? []).find((r) => matches(r, q.where)); return hit ? { ...hit } : null; }, async update(object: string, values: Record, opts: { where?: Record }) { + assertEngineUpdateDispatch(values as never, opts as never); for (const r of tables[object] ?? []) if (matches(r, opts.where)) Object.assign(r, values); return {}; }, async delete(object: string, opts: { where?: Record }) { + assertEngineDeleteDispatch(opts as never); tables[object] = (tables[object] ?? []).filter((r) => !matches(r, opts.where)); return {}; }, diff --git a/packages/services/service-automation/vitest.config.ts b/packages/services/service-automation/vitest.config.ts index 8827ab3306c..758369285f1 100644 --- a/packages/services/service-automation/vitest.config.ts +++ b/packages/services/service-automation/vitest.config.ts @@ -107,6 +107,14 @@ export default defineConfig({ find: /^@objectstack\/plugin-sharing$/, replacement: path.resolve(__dirname, '../../plugins/plugin-sharing/src/index.ts'), }, + { + // [#29041] `notify-push-delivery.integration.test.ts` delivers a + // `notify` node through the real APNs and FCM transports of + // `@objectstack/service-push` against local mock endpoints. Read from + // source for the reason the entries above give. Same anchored-regex rule. + find: /^@objectstack\/service-push$/, + replacement: path.resolve(__dirname, '../service-push/src/index.ts'), + }, { // The same suite reads the `sys_audit_log` rows the post-decision writes // leave (`AuditPlugin`'s writers), so the attribution it pins is the diff --git a/packages/services/service-messaging/src/push-channel.test.ts b/packages/services/service-messaging/src/push-channel.test.ts index eff1a39c369..0acd763f3ba 100644 --- a/packages/services/service-messaging/src/push-channel.test.ts +++ b/packages/services/service-messaging/src/push-channel.test.ts @@ -14,6 +14,12 @@ import { createPushChannel, deliveryData, truncateUtf8, type PushSenderSurface } import { MessagingServicePlugin } from './messaging-service-plugin.js'; import type { MessagingService } from './messaging-service.js'; import type { Delivery } from './channel.js'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, + type EngineFindOneQueryInput, +} from '@objectstack/metadata-core'; const ctx = { logger: { info() {}, warn() {}, error() {} } }; @@ -215,12 +221,15 @@ describe('MessagingServicePlugin mounts the push channel while a push service is services.set('manifest', { register() {} }); services.set('data', { async insert(_o: string, row: Record) { return { id: 'evt_1', ...row }; }, - async find(object: string, q: { where?: Record } = {}) { - return object === 'sys_user_device' ? devices.filter((d) => d.user_id === q.where?.user_id) : []; + async find(object: string, q: { where?: Record; limit?: number } = {}) { + if (object !== 'sys_user_device') return []; + const rows = devices.filter((d) => Object.entries(q.where ?? {}) + .every(([k, v]) => (d as Record)[k] === v)); + return typeof q.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - async findOne() { return null; }, - async update() { return {}; }, - async delete() { return {}; }, + async findOne(object: string, q?: EngineFindOneQueryInput) { assertEngineFindOnePredicate(object, q); return null; }, + async update(_o: string, values: unknown, opts: unknown) { assertEngineUpdateDispatch(values as never, opts as never); return {}; }, + async delete(_o: string, opts: unknown) { assertEngineDeleteDispatch(opts as never); return {}; }, }); const logger: Record = { info() {}, warn() {}, error() {}, debug() {} }; logger.child = () => logger; diff --git a/packages/services/service-push/vitest.config.ts b/packages/services/service-push/vitest.config.ts index 758e8b268d8..4c49dc2450f 100644 --- a/packages/services/service-push/vitest.config.ts +++ b/packages/services/service-push/vitest.config.ts @@ -1,5 +1,14 @@ import { defineConfig } from 'vitest/config'; +import path from 'node:path'; +/** + * The transports read their configuration types and the plugin parses its + * `config` with `PushServiceConfigSchema`, both from `@objectstack/spec/system`. + * Unaliased, the workspace link resolves that to the spec package's last + * build, so a test about the source next to it would report on build state + * instead (`pnpm check:test-source-alias` is the gate). Anchored regex, array + * form: a bare string `find` matches by PREFIX. + */ export default defineConfig({ test: { // A late console.* must not redden a green suite (#10374). @@ -7,4 +16,9 @@ export default defineConfig({ // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. disableConsoleIntercept: true, }, + resolve: { + alias: [ + { find: /^@objectstack\/spec\/system$/, replacement: path.resolve(__dirname, '../../spec/src/system/index.ts') }, + ], + }, }); diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index d69713fcb3d..a42d38b8d0a 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -178,9 +178,9 @@ function registerObject(rawConfig: unknown) { --- -## 7. Package Ecosystem (73 packages) +## 7. Package Ecosystem (74 packages) -The workspace publishes 73 packages under the `@objectstack` scope. The table +The workspace publishes 74 packages under the `@objectstack` scope. The table below is a curated entry-point list, not the full set — drivers, connectors, triggers, plugins and kernel-managed services each form their own family. diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 7089aa86e14..a9ce4336f5d 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -5051,6 +5051,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-automation/src/builtin/notify-written-address-channels.integration.test.ts", "verb": "findOne", @@ -5276,6 +5291,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/services/service-messaging/src/push-channel.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/services/service-messaging/src/push-channel.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/services/service-messaging/src/push-channel.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-messaging/src/read-receipt-organization.test.ts", "verb": "findOne", From b6f8119b09c40c39c14bd3630f5ff0264e2793a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:26:35 +0000 Subject: [PATCH 4/9] =?UTF-8?q?chore(changeset):=20grade=20the=20push=20ch?= =?UTF-8?q?annel=20entries=20patch=20=E2=80=94=20Metadata-protocol=20none?= =?UTF-8?q?=20takes=20patch=20(ruling=20A=20on=20#25105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JQwxbWDV359nY4JGrtUq4P --- .changeset/29041-push-notification-channel.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/29041-push-notification-channel.md b/.changeset/29041-push-notification-channel.md index 2e2310588ed..a61c7e102e9 100644 --- a/.changeset/29041-push-notification-channel.md +++ b/.changeset/29041-push-notification-channel.md @@ -1,6 +1,6 @@ --- -'@objectstack/service-push': minor -'@objectstack/service-messaging': minor +'@objectstack/service-push': patch +'@objectstack/service-messaging': patch '@objectstack/spec': patch --- From 04c9e75da60412ba24066cedf6244b2b2b5005c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:12:30 +0000 Subject: [PATCH 5/9] feat(service-messaging): the push channel copies a device row's origin into the push data PushDeliveryDataSchema.origin (from main) says the push channel copies it from the device row. Each device's send now carries its row's origin when the schema admits it; a row without one, or with a malformed one, sends the data without it. The registry does not store origin yet (UserDeviceSchema.origin stays PLANNED), so today no push carries it. Co-Authored-By: Claude --- .../src/push-channel.test.ts | 21 +++++++++++++++ .../service-messaging/src/push-channel.ts | 26 ++++++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/services/service-messaging/src/push-channel.test.ts b/packages/services/service-messaging/src/push-channel.test.ts index 0acd763f3ba..5e3b607ace5 100644 --- a/packages/services/service-messaging/src/push-channel.test.ts +++ b/packages/services/service-messaging/src/push-channel.test.ts @@ -202,6 +202,27 @@ describe('createPushChannel — no transport', () => { }); }); +describe('createPushChannel — the sending server (origin)', () => { + it('copies each device row\'s origin into that device\'s data, and sends none for a row without one', async () => { + const { push, sent } = pushDouble(); + const reg = registry([ + device({ deviceId: 'd1', token: 'tok-a', origin: 'https://crm.example.com' }), + device({ deviceId: 'd2', token: 'tok-b', origin: 'http://localhost:3000' }), + device({ deviceId: 'd3', token: 'tok-c' }), + device({ deviceId: 'd4', token: 'tok-d', origin: 'https://crm.example.com/path' }), + ]); + const channel = createPushChannel({ getPush: () => push, devices: reg.devices }); + expect((await channel.send(ctx, delivery())).ok).toBe(true); + const byToken = Object.fromEntries(sent.map((m) => [m.token, m.data])); + expect(byToken['tok-a']).toEqual({ notificationId: 'ntf_1', route: '/opportunity/123', origin: 'https://crm.example.com' }); + expect(byToken['tok-b']).toEqual({ notificationId: 'ntf_1', route: '/opportunity/123', origin: 'http://localhost:3000' }); + // No stored origin, or one the schema refuses: the data goes without it. + expect(byToken['tok-c']).toEqual({ notificationId: 'ntf_1', route: '/opportunity/123' }); + expect(byToken['tok-d']).toEqual({ notificationId: 'ntf_1', route: '/opportunity/123' }); + for (const m of sent) expect(PushDeliveryDataSchema.safeParse(m.data).success).toBe(true); + }); +}); + describe('deliveryData / truncateUtf8', () => { it('admits both inbox actionUrl shapes as route', () => { expect(deliveryData('n', '/opportunity/1')).toEqual({ notificationId: 'n', route: '/opportunity/1' }); diff --git a/packages/services/service-messaging/src/push-channel.ts b/packages/services/service-messaging/src/push-channel.ts index edfc1086214..f83c66bc963 100644 --- a/packages/services/service-messaging/src/push-channel.ts +++ b/packages/services/service-messaging/src/push-channel.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { PushRouteSchema } from '@objectstack/spec/api'; +import { PushDeliveryDataSchema, PushRouteSchema } from '@objectstack/spec/api'; import type { UserDevice } from '@objectstack/spec/system'; import type { ChannelAvailability, @@ -85,8 +85,9 @@ const ROUTE_MAX_BYTES = 1024; * enabled ones whose vendor the push service has a transport for, and send * each one the notification's title and body (already in the recipient's * language: fan-out renders keyed text per recipient) plus its delivery data - * (`PushDeliveryDataSchema`): the notification id and, when it passes - * `PushRouteSchema`, its `actionUrl` as `route`. An `actionUrl` that is not a + * (`PushDeliveryDataSchema`): the notification id, when it passes + * `PushRouteSchema` its `actionUrl` as `route`, and the device row's `origin` + * when it holds one. An `actionUrl` that is not a * same-server path is left out, never sent; the delivery is not failed for it. * * **Dead tokens are retired here.** A device the vendor answers `invalid_token` @@ -161,7 +162,13 @@ export function createPushChannel(opts: PushChannelOptions): MessagingChannel { const dead: string[] = []; const failures: Array<{ error: string; retryable: boolean }> = []; for (const device of reachable) { - const result = await push.send({ vendor: device.vendor, token: device.token, title, body, data }); + const result = await push.send({ + vendor: device.vendor, + token: device.token, + title, + body, + data: withOrigin(data, device.origin), + }); if (result.status === 'sent') { sent.push(result.messageId ?? ''); } else if (result.status === 'invalid_token') { @@ -228,6 +235,17 @@ export function deliveryData(notificationId: string | undefined, actionUrl: stri return data; } +/** + * The device's `origin` added to the delivery data (`PushDeliveryDataSchema.origin`), + * when the row holds one the schema admits: the server the Console registered + * from, so a shell with several saved servers opens the tap on this one. A row + * without one, or with one the schema refuses, sends the data as is. + */ +export function withOrigin(data: Record, origin: string | undefined): Record { + if (!origin || !PushDeliveryDataSchema.shape.origin.safeParse(origin).success) return data; + return { ...data, origin }; +} + /** Collapse whitespace runs: a push shows one plain line of text per member. */ function plain(text: string | undefined): string { return String(text ?? '').replace(/\s+/g, ' ').trim(); From 3aac0c5938b09d8b7a6cb7ffa88507dca5ec7cfb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:49:16 +0000 Subject: [PATCH 6/9] chore(changeset): name the stored origin the push channel copies Co-Authored-By: Claude --- .changeset/29041-push-notification-channel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/29041-push-notification-channel.md b/.changeset/29041-push-notification-channel.md index a61c7e102e9..126915fa0c5 100644 --- a/.changeset/29041-push-notification-channel.md +++ b/.changeset/29041-push-notification-channel.md @@ -10,5 +10,5 @@ Clause-②: yes (widening) Metadata-protocol: none - **New package `@objectstack/service-push`.** `PushServicePlugin` registers the kernel's `push` service: APNs over HTTP/2 with token-based auth (an ES256 provider token signed with the `.p8` key, reused for fifty minutes) and FCM over the HTTP v1 API (an OAuth access token from the service account). It reads `PushServiceConfigSchema` from its `config` option and refuses an inline secret at boot with the schema's guidance. Each secret is named by `credentialRef` and resolved when the transport first signs; in the open tier the reference is the name of an environment variable (ADR-0143 D3). -- **`@objectstack/service-messaging`: the `push` channel.** Mounted while a `push` service is registered, as the sms channel is while an `sms` service is. One delivery reaches every enabled device the recipient registered, on each vendor the push service has a transport for. The push carries the notification id and, when it is a same-server path (`PushRouteSchema`), the notification's `actionUrl` as `route`; any other `actionUrl` is left out and the delivery still goes. A token the vendor reports dead (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`) is retired through `invalidateDeviceToken`: the device row is kept, disabled, and the user's other devices still get the push. A recipient with no enabled device, or whose every device was reported dead, is graded `invalid_recipient`; no push service is refused `transport_not_configured`, as the sms and email channels refuse it. +- **`@objectstack/service-messaging`: the `push` channel.** Mounted while a `push` service is registered, as the sms channel is while an `sms` service is. One delivery reaches every enabled device the recipient registered, on each vendor the push service has a transport for. The push carries the notification id and, when it is a same-server path (`PushRouteSchema`), the notification's `actionUrl` as `route`; any other `actionUrl` is left out and the delivery still goes. Each device's push also carries the origin stored on its row (`PushDeliveryDataSchema.origin`) when the row holds one; the registry does not store it yet, so today none does. A token the vendor reports dead (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`) is retired through `invalidateDeviceToken`: the device row is kept, disabled, and the user's other devices still get the push. A recipient with no enabled device, or whose every device was reported dead, is graded `invalid_recipient`; no push service is refused `transport_not_configured`, as the sms and email channels refuse it. - **`@objectstack/spec`: description and comment text only.** The `[PLANNED]` markers come off `PushServiceConfigSchema`, `ApnsTransportConfigSchema`, `FcmTransportConfigSchema` and `PushDeliveryDataSchema`, which now have their reader and producer. `NotificationChannelSchema`'s JSDoc lists `push` among the registered channels, the docblocks name `@objectstack/service-push` where they named `plugin-notification-push`, and the `NOT_FOUND` synonym waiver's reason names service-messaging's own provenance row. No key, schema shape or export changes. From 42eef7ad2cc43d30970f44d121da585205834d9e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:57:51 +0000 Subject: [PATCH 7/9] feat(service-push): serve the direct push transport and refuse the relay at init PushServicePlugin parses the relay | direct union. transport: 'direct' (or a vendor block with no transport) drives the APNs and FCM transports unchanged. transport: 'relay', and the empty config that reads as it, is refused at init with PUSH_RELAY_NOT_SERVED_MESSAGE, naming follow-up card #29920 and the direct transport for a white-label build; nothing is registered. Pinned: direct boots named and inferred, relay is refused four ways. ADR-0012: the Amended status line, the section 4 push row and the section 6 note say service-push ships the direct transport and the hosted relay, the default per cloud#3519, is not yet served (#29920). README and changeset say the same. Co-Authored-By: Claude --- .changeset/29041-push-notification-channel.md | 4 +-- docs/adr/0012-notification-platform.md | 6 ++-- packages/services/service-push/README.md | 3 ++ packages/services/service-push/src/index.ts | 2 +- .../service-push/src/push-plugin.test.ts | 31 +++++++++++++++---- .../services/service-push/src/push-plugin.ts | 31 ++++++++++++++++--- 6 files changed, 60 insertions(+), 17 deletions(-) diff --git a/.changeset/29041-push-notification-channel.md b/.changeset/29041-push-notification-channel.md index 126915fa0c5..67fc4e16a93 100644 --- a/.changeset/29041-push-notification-channel.md +++ b/.changeset/29041-push-notification-channel.md @@ -9,6 +9,6 @@ feat(notifications): the `push` channel — a `notify` node naming `push` reache Clause-②: yes (widening) Metadata-protocol: none -- **New package `@objectstack/service-push`.** `PushServicePlugin` registers the kernel's `push` service: APNs over HTTP/2 with token-based auth (an ES256 provider token signed with the `.p8` key, reused for fifty minutes) and FCM over the HTTP v1 API (an OAuth access token from the service account). It reads `PushServiceConfigSchema` from its `config` option and refuses an inline secret at boot with the schema's guidance. Each secret is named by `credentialRef` and resolved when the transport first signs; in the open tier the reference is the name of an environment variable (ADR-0143 D3). +- **New package `@objectstack/service-push`.** `PushServicePlugin` registers the kernel's `push` service: APNs over HTTP/2 with token-based auth (an ES256 provider token signed with the `.p8` key, reused for fifty minutes) and FCM over the HTTP v1 API (an OAuth access token from the service account). It reads `PushServiceConfigSchema` from its `config` option and refuses an inline secret at boot with the schema's guidance. It serves the direct transport (`transport: 'direct'`, the operator's own APNs key and Firebase project, for a white-label build); the hosted relay, the schema's default and so also an empty config, is refused at init until its sender ships (#29920). Each secret is named by `credentialRef` and resolved when the transport first signs; in the open tier the reference is the name of an environment variable (ADR-0143 D3). - **`@objectstack/service-messaging`: the `push` channel.** Mounted while a `push` service is registered, as the sms channel is while an `sms` service is. One delivery reaches every enabled device the recipient registered, on each vendor the push service has a transport for. The push carries the notification id and, when it is a same-server path (`PushRouteSchema`), the notification's `actionUrl` as `route`; any other `actionUrl` is left out and the delivery still goes. Each device's push also carries the origin stored on its row (`PushDeliveryDataSchema.origin`) when the row holds one; the registry does not store it yet, so today none does. A token the vendor reports dead (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`) is retired through `invalidateDeviceToken`: the device row is kept, disabled, and the user's other devices still get the push. A recipient with no enabled device, or whose every device was reported dead, is graded `invalid_recipient`; no push service is refused `transport_not_configured`, as the sms and email channels refuse it. -- **`@objectstack/spec`: description and comment text only.** The `[PLANNED]` markers come off `PushServiceConfigSchema`, `ApnsTransportConfigSchema`, `FcmTransportConfigSchema` and `PushDeliveryDataSchema`, which now have their reader and producer. `NotificationChannelSchema`'s JSDoc lists `push` among the registered channels, the docblocks name `@objectstack/service-push` where they named `plugin-notification-push`, and the `NOT_FOUND` synonym waiver's reason names service-messaging's own provenance row. No key, schema shape or export changes. +- **`@objectstack/spec`: description and comment text only.** The `[PLANNED]` markers come off `PushServiceConfigSchema`, `PushDirectTransportConfigSchema`, `ApnsTransportConfigSchema`, `FcmTransportConfigSchema` and `PushDeliveryDataSchema`, which now have their reader and producer; `PushRelayTransportConfigSchema` keeps `[PLANNED — not yet served]`, now saying the push service refuses it at boot (#29920). `NotificationChannelSchema`'s JSDoc lists `push` among the registered channels, the docblocks name `@objectstack/service-push` where they named `plugin-notification-push`, and the `NOT_FOUND` synonym waiver's reason names service-messaging's own provenance row. No key, schema shape or export changes. diff --git a/docs/adr/0012-notification-platform.md b/docs/adr/0012-notification-platform.md index d35b263be6c..b34646ee169 100644 --- a/docs/adr/0012-notification-platform.md +++ b/docs/adr/0012-notification-platform.md @@ -1,6 +1,6 @@ # ADR-0012: Messaging Platform — Outbound Notifications on a Generalized Outbox -**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) · **Superseded by [ADR-0030](./0030-notification-platform-convergence.md)** (2026-06-01) — the M1 implementation drifted (two conflated inbox objects); ADR-0030 records the correction and realizes this layered model. · **Amended (2026-11-03, #29041)**: push ships as `@objectstack/service-push` (the APNs and FCM transports, the kernel's `push` service) with its channel shell `push-channel.ts` in `@objectstack/service-messaging`, which also owns `sys_user_device`; every `plugin-notification-push` below reads that way. ADR-0030 is unchanged. +**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) · **Superseded by [ADR-0030](./0030-notification-platform-convergence.md)** (2026-06-01) — the M1 implementation drifted (two conflated inbox objects); ADR-0030 records the correction and realizes this layered model. · **Amended (2026-11-03, #29041)**: push ships as `@objectstack/service-push` (the direct APNs and FCM transports, the kernel's `push` service; the hosted relay, the default transport per cloud#3519, is not yet served, #29920) with its channel shell `push-channel.ts` in `@objectstack/service-messaging`, which also owns `sys_user_device`; every `plugin-notification-push` below reads that way. ADR-0030 is unchanged. **Authors**: Platform team — surfaced from GitHub Issue #1292 ("[P0] notification: no outbound notification channel") **Consumers**: `@objectstack/spec` (new `messaging/` domain), `@objectstack/service-messaging` (new), `@objectstack/plugin-notification-inbox` (new), `@objectstack/plugin-notification-email` (replaces `plugin-email`), `@objectstack/plugin-notification-webhook` (extracted from `plugin-webhooks`), `@objectstack/plugin-notification-push` (new), every flow that has a `notify` node, every template that ships notification rules **Sibling**: [ADR-0013 — Bidirectional Messaging & Conversational Channels](./0013-bidirectional-messaging.md) @@ -306,7 +306,7 @@ These cost ~5 columns and one empty table in M1. They are non-negotiable: introd | `inbox` | `plugin-notification-inbox` | Writes to `sys_inbox_message`; fires `service-realtime` event for online users | **MUST ship M1** | | `email` | `plugin-notification-email` (replaces `plugin-email`) | SMTP + transport plugins (SendGrid baseline) | **MUST ship M1** | | `webhook` | `plugin-notification-webhook` (extracted from `plugin-webhooks`) | HTTP POST with HMAC | **MUST ship M1** (port existing) | -| `push` | `service-push` (transports) + `push-channel.ts` in `service-messaging` (channel); amended 2026-11-03, #29041 | APNs (HTTP/2, token auth) + FCM (HTTP v1) | Shipped (#29041) | +| `push` | `service-push` (transports) + `push-channel.ts` in `service-messaging` (channel); amended 2026-11-03, #29041 | Direct: APNs (HTTP/2, token auth) + FCM (HTTP v1) with the operator's own credentials, for a white-label build. The hosted relay is the default transport (cloud#3519) and is not yet served (#29920) | Direct shipped (#29041); relay not yet served (#29920) | All four bind into `channel-registry` during `onEnable`. @@ -375,7 +375,7 @@ Lifecycle hooks: * Client SDK calls `POST /api/v1/notifications/devices` on login → `device-registry.upsert(...)`. * APNs / FCM `BadDeviceToken` / `Unregistered` response → channel returns `classifyError = 'invalid_recipient'` → dispatcher calls `device-registry.invalidate(token)` (sets `invalidated_at`, `enabled=false`) without dead-lettering the notification (it just routes around the dead token). - > Amended (2026-11-03, #29041): the push channel (`push-channel.ts` in service-messaging) retires the dead token itself, through `INotificationService.invalidateDeviceToken`, because one delivery fans out to all of the recipient's devices and only the channel knows which token died; the other devices still get the push. The delivery is graded `invalid_recipient` (suppressed, not dead-lettered) only when every device was reported dead. The shipped device shape is `UserDeviceSchema` in `packages/spec/src/system/push-device.zod.ts`, narrowed to `apns` | `fcm` and `ios` | `android` | `web`. + > Amended (2026-11-03, #29041): the push channel (`push-channel.ts` in service-messaging) retires the dead token itself, through `INotificationService.invalidateDeviceToken`, because one delivery fans out to all of the recipient's devices and only the channel knows which token died; the other devices still get the push. The delivery is graded `invalid_recipient` (suppressed, not dead-lettered) only when every device was reported dead. The shipped device shape is `UserDeviceSchema` in `packages/spec/src/system/push-device.zod.ts`, narrowed to `apns` | `fcm` and `ios` | `android` | `web`. `service-push` ships the direct transport; the hosted relay, the default per cloud#3519, is not yet served (#29920), and `PushServicePlugin` refuses a relay configuration at init until it is. * Domestic vendors (HMS / Xiaomi / OPPO / VIVO) plug in as **alternate channel implementations** (one package each, named by kind like `service-push`), not as transports — their on-device SDKs and quotas are too different to share a code path. ### 7. Topic + Preference matrix diff --git a/packages/services/service-push/README.md b/packages/services/service-push/README.md index d7799163008..33ba84f13f1 100644 --- a/packages/services/service-push/README.md +++ b/packages/services/service-push/README.md @@ -2,11 +2,14 @@ The push service: `IPushService` over the APNs (HTTP/2, token-based auth) and FCM (HTTP v1) transports, configured by `PushServiceConfigSchema` from `@objectstack/spec/system`. Each secret, the APNs `.p8` signing key and the FCM service-account JSON, is named by a `credentialRef` and resolved when the transport first signs (in the open tier, the name of an environment variable). `PushServicePlugin` registers the kernel's `push` service, and the `push` channel in `@objectstack/service-messaging` delivers through it to the devices users register under `/api/v1/notifications/devices`. +The direct transport is the one served: `transport: 'direct'` with your own APNs key and Firebase project, for a white-label build. The hosted relay (`transport: 'relay'`, the schema's default, so also an empty config) is refused at init until its sender ships (#29920). + ```typescript import { PushServicePlugin } from '@objectstack/service-push'; kernel.use(new PushServicePlugin({ config: { + transport: 'direct', apns: { teamId: 'ABCDE12345', keyId: 'KEY1234567', topic: 'com.example.app', environment: 'production', credentialRef: 'APNS_SIGNING_KEY' }, fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' }, }, diff --git a/packages/services/service-push/src/index.ts b/packages/services/service-push/src/index.ts index 7ff6026b514..31fdc20f3c1 100644 --- a/packages/services/service-push/src/index.ts +++ b/packages/services/service-push/src/index.ts @@ -9,7 +9,7 @@ export { type PushSendResult, type PushServiceOptions, } from './push-service.js'; -export { PushServicePlugin, type PushServicePluginOptions } from './push-plugin.js'; +export { PushServicePlugin, PUSH_RELAY_NOT_SERVED_MESSAGE, type PushServicePluginOptions } from './push-plugin.js'; export { envPushCredentialResolver, PushCredentialError, diff --git a/packages/services/service-push/src/push-plugin.test.ts b/packages/services/service-push/src/push-plugin.test.ts index 901b194efe2..7e63407394d 100644 --- a/packages/services/service-push/src/push-plugin.test.ts +++ b/packages/services/service-push/src/push-plugin.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect } from 'vitest'; -import { PushServicePlugin } from './push-plugin.js'; +import { PushServicePlugin, PUSH_RELAY_NOT_SERVED_MESSAGE } from './push-plugin.js'; import { PushService, type IPushTransport, type PushMessage } from './push-service.js'; import { envPushCredentialResolver } from './credential.js'; @@ -34,11 +34,30 @@ describe('PushServicePlugin', () => { expect(push.vendors().sort()).toEqual(['apns', 'fcm']); }); - it('registers a push service with no vendor when nothing is configured, and says so', async () => { - const { ctx, services, lines } = context(); - await new PushServicePlugin().init(ctx); - expect((services.get('push') as PushService).vendors()).toEqual([]); - expect(lines.some((l) => l.includes('no vendor configured'))).toBe(true); + it('boots the direct transport, named or read from a vendor block', async () => { + for (const config of [{ transport: 'direct' as const, apns: APNS }, { apns: APNS }]) { + const { ctx, services } = context(); + await new PushServicePlugin({ config }).init(ctx); + expect((services.get('push') as PushService).vendors()).toEqual(['apns']); + } + }); + + it.each([ + ['no config', undefined], + ['the empty config', {}], + ["transport: 'relay'", { transport: 'relay' as const }], + ['a staging relay endpoint', { transport: 'relay' as const, endpoint: 'https://relay.staging.example.com' }], + ])('refuses the relay transport at init (%s), registering nothing', async (_name, config) => { + const { ctx, services } = context(); + const init = new PushServicePlugin({ config }).init(ctx); + await expect(init).rejects.toThrow(PUSH_RELAY_NOT_SERVED_MESSAGE); + expect(services.has('push')).toBe(false); + }); + + it('names the follow-up card and the direct transport in the relay refusal', () => { + expect(PUSH_RELAY_NOT_SERVED_MESSAGE).toContain('#29920'); + expect(PUSH_RELAY_NOT_SERVED_MESSAGE).toContain("transport: 'direct'"); + expect(PUSH_RELAY_NOT_SERVED_MESSAGE).toContain('credentialRef'); }); it('refuses an inline secret at init with the schema\'s guidance, registering nothing', async () => { diff --git a/packages/services/service-push/src/push-plugin.ts b/packages/services/service-push/src/push-plugin.ts index 13f294dc468..bf1a3fbdea0 100644 --- a/packages/services/service-push/src/push-plugin.ts +++ b/packages/services/service-push/src/push-plugin.ts @@ -9,10 +9,16 @@ import { FcmPushTransport } from './transports/fcm.js'; export interface PushServicePluginOptions { /** - * The push transports: one optional block per vendor, parsed by - * `PushServiceConfigSchema` (`@objectstack/spec/system`) when the plugin - * initialises, so an inline secret or a misspelt key fails the boot with - * the schema's own guidance. A vendor with no block is not delivered to. + * The push transport, parsed by `PushServiceConfigSchema` + * (`@objectstack/spec/system`) when the plugin initialises, so an inline + * secret or a misspelt key fails the boot with the schema's own guidance. + * + * Only the direct transport is served: `transport: 'direct'` (or a block + * with an `apns` / `fcm` block and no `transport`), one vendor transport per + * block; a vendor with no block is not delivered to. The hosted relay + * (`transport: 'relay'`, and the default `{}` that reads as it) is refused + * at init until its sender ships (#29920): see + * {@link PUSH_RELAY_NOT_SERVED_MESSAGE}. */ config?: PushServiceConfig; /** @@ -42,7 +48,8 @@ export interface PushServicePluginOptions { * user. * * Configuration is the constructor's `config`, the operator's - * `PushServiceConfigSchema` value; secrets are named by `credentialRef` and + * `PushServiceConfigSchema` value. The direct transport is served; the hosted + * relay, the schema's default, is refused at init until #29920 ships it. Secrets are named by `credentialRef` and * resolved through {@link PushServicePluginOptions.resolveCredential}, never * written inline (ADR-0143 D3). * @@ -51,6 +58,7 @@ export interface PushServicePluginOptions { * kernel.use(new MessagingServicePlugin()); * kernel.use(new PushServicePlugin({ * config: { + * transport: 'direct', * apns: { teamId: 'ABCDE12345', keyId: 'KEY1234567', topic: 'com.example.app', * environment: 'production', credentialRef: 'APNS_SIGNING_KEY' }, * fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' }, @@ -58,6 +66,18 @@ export interface PushServicePluginOptions { * })); * ``` */ +/** + * The refusal a relay configuration meets at init. The relay is the schema's + * default (cloud#3519), but its sender is not served yet (#29920): booting a + * `push` service that would deliver nothing is worse than refusing, because + * the channel would mount and every push would look sent. + */ +export const PUSH_RELAY_NOT_SERVED_MESSAGE = + "PushServicePlugin: the hosted push relay (transport: 'relay', which is also what an empty push config reads as) " + + 'is not served yet: its sender is follow-up card #29920, and nothing would deliver through it. For a white-label ' + + "build signed with your own APNs key or Firebase project, write transport: 'direct' with an apns and/or fcm block, " + + 'each naming its secret by credentialRef.'; + export class PushServicePlugin implements Plugin { name = 'com.objectstack.service.push'; /** Services init() registers on every path (ADR-0116). */ @@ -71,6 +91,7 @@ export class PushServicePlugin implements Plugin { async init(ctx: PluginContext): Promise { const config = PushServiceConfigSchema.parse(this.options.config ?? {}); + if (config.transport !== 'direct') throw new Error(PUSH_RELAY_NOT_SERVED_MESSAGE); const resolveCredential = this.options.resolveCredential ?? envPushCredentialResolver; const transports: Partial> = {}; if (config.apns) transports.apns = new ApnsPushTransport({ config: config.apns, resolveCredential }); From b0503c1db66b33e7b6f38c425fdcd65776e6a369 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:36:07 +0000 Subject: [PATCH 8/9] fix(push): name no tracker id in operator-facing push text (check:doc-authoring) The relay refusal message and the relay and union describes drop #29920; the card stays named in the code comments, the ADR, the README and the changeset. push-config.mdx regenerated. Co-Authored-By: Claude --- content/docs/references/system/push-config.mdx | 6 +++--- packages/services/service-push/src/push-plugin.test.ts | 4 ++-- packages/services/service-push/src/push-plugin.ts | 6 ++++-- packages/spec/src/system/push-config.test.ts | 1 - packages/spec/src/system/push-config.zod.ts | 4 ++-- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/content/docs/references/system/push-config.mdx b/content/docs/references/system/push-config.mdx index 6d64b1c2919..204b6b54426 100644 --- a/content/docs/references/system/push-config.mdx +++ b/content/docs/references/system/push-config.mdx @@ -157,7 +157,7 @@ Direct push transport for a white-label build: one block per vendor (apns, fcm), ## PushRelayTransportConfig -[PLANNED — not yet served] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none. The push service refuses it at boot until the relay sender ships (#29920) +[PLANNED — not yet served] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none. The push service refuses it at boot until the relay sender ships ### Properties @@ -171,7 +171,7 @@ Direct push transport for a white-label build: one block per vendor (apns, fcm), ## PushServiceConfig -Push channel transport configuration: the hosted relay (the default) or direct delivery to apns and fcm for a white-label build. The push service reads it: direct drives the vendor transports, and relay is refused at boot until the relay sender ships (#29920) +Push channel transport configuration: the hosted relay (the default) or direct delivery to apns and fcm for a white-label build. The push service reads it: direct drives the vendor transports, and relay is refused at boot until the relay sender ships ### Union Options @@ -179,7 +179,7 @@ This schema accepts one of the following structures: #### Option 1 -[PLANNED — not yet served] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none. The push service refuses it at boot until the relay sender ships (#29920) +[PLANNED — not yet served] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none. The push service refuses it at boot until the relay sender ships ### Properties diff --git a/packages/services/service-push/src/push-plugin.test.ts b/packages/services/service-push/src/push-plugin.test.ts index 7e63407394d..6e08b8ea75e 100644 --- a/packages/services/service-push/src/push-plugin.test.ts +++ b/packages/services/service-push/src/push-plugin.test.ts @@ -54,8 +54,8 @@ describe('PushServicePlugin', () => { expect(services.has('push')).toBe(false); }); - it('names the follow-up card and the direct transport in the relay refusal', () => { - expect(PUSH_RELAY_NOT_SERVED_MESSAGE).toContain('#29920'); + it('names the direct transport, and the relay as not yet served, in the relay refusal', () => { + expect(PUSH_RELAY_NOT_SERVED_MESSAGE).toContain('is not served yet'); expect(PUSH_RELAY_NOT_SERVED_MESSAGE).toContain("transport: 'direct'"); expect(PUSH_RELAY_NOT_SERVED_MESSAGE).toContain('credentialRef'); }); diff --git a/packages/services/service-push/src/push-plugin.ts b/packages/services/service-push/src/push-plugin.ts index bf1a3fbdea0..71c459e0cf4 100644 --- a/packages/services/service-push/src/push-plugin.ts +++ b/packages/services/service-push/src/push-plugin.ts @@ -68,13 +68,15 @@ export interface PushServicePluginOptions { */ /** * The refusal a relay configuration meets at init. The relay is the schema's - * default (cloud#3519), but its sender is not served yet (#29920): booting a + * default (cloud#3519), but its sender is not served yet (follow-up card + * #29920; the text below is printed at the operator, so it names no tracker + * id, per check:doc-authoring): booting a * `push` service that would deliver nothing is worse than refusing, because * the channel would mount and every push would look sent. */ export const PUSH_RELAY_NOT_SERVED_MESSAGE = "PushServicePlugin: the hosted push relay (transport: 'relay', which is also what an empty push config reads as) " - + 'is not served yet: its sender is follow-up card #29920, and nothing would deliver through it. For a white-label ' + + 'is not served yet: its sender has not shipped, and nothing would deliver through it. For a white-label ' + "build signed with your own APNs key or Firebase project, write transport: 'direct' with an apns and/or fcm block, " + 'each naming its secret by credentialRef.'; diff --git a/packages/spec/src/system/push-config.test.ts b/packages/spec/src/system/push-config.test.ts index e055ed88a6a..38672280fa7 100644 --- a/packages/spec/src/system/push-config.test.ts +++ b/packages/spec/src/system/push-config.test.ts @@ -207,7 +207,6 @@ describe('push transport configuration is read', () => { it('marks the relay block not yet served, and says the push service refuses it', () => { expect(PushRelayTransportConfigSchema.description).toMatch(/^\[PLANNED — not yet served\] /); expect(PushRelayTransportConfigSchema.description).toMatch(/refuses it at boot/); - expect(PushRelayTransportConfigSchema.description).toMatch(/#29920/); }); it.each([ diff --git a/packages/spec/src/system/push-config.zod.ts b/packages/spec/src/system/push-config.zod.ts index 4915c65c366..971c1cb1f39 100644 --- a/packages/spec/src/system/push-config.zod.ts +++ b/packages/spec/src/system/push-config.zod.ts @@ -220,7 +220,7 @@ export const PushRelayTransportConfigSchema = lazySchema(() => strictObject( ), }, ).describe('[PLANNED — not yet served] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none. ' - + 'The push service refuses it at boot until the relay sender ships (#29920)')); + + 'The push service refuses it at boot until the relay sender ships')); export type PushRelayTransportConfig = z.input; /** Post-parse shape of {@link PushRelayTransportConfig} — `transport` filled in (ADR-0122). */ export type PushRelayTransportConfigParsed = z.infer; @@ -265,7 +265,7 @@ export const PushServiceConfigSchema = lazySchema(() => z.discriminatedUnion( // tag falls back to trying the members in order, relay first. { unionFallback: true }, ).describe('Push channel transport configuration: the hosted relay (the default) or direct delivery to apns and fcm for a white-label build. ' - + 'The push service reads it: direct drives the vendor transports, and relay is refused at boot until the relay sender ships (#29920)')); + + 'The push service reads it: direct drives the vendor transports, and relay is refused at boot until the relay sender ships')); export type PushServiceConfig = z.input; /** Post-parse shape of {@link PushServiceConfig} — `transport` filled in (ADR-0122). */ export type PushServiceConfigParsed = z.infer; From 5b7095572f3f1836f18a0ef62bd072485353edcd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 14:38:35 +0000 Subject: [PATCH 9/9] =?UTF-8?q?fix(push):=20seat=20review=20round=203b=20?= =?UTF-8?q?=E2=80=94=20ADR-0012=20domestic-vendor=20line=20as=20main=20has?= =?UTF-8?q?=20it,=20docblock=20gutter,=20JSDoc=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0012 §6: the domestic-vendor sentence reads exactly as on origin/main (#29817 rewrites it). push-config.zod.ts: the served-today paragraph gains its ' * ' gutter (push-config.mdx regenerated). push-plugin.ts: the relay refusal constant and its docblock sit above the class docblock, so each JSDoc attaches to its own declaration. Co-Authored-By: Claude --- .../docs/references/system/push-config.mdx | 2 +- docs/adr/0012-notification-platform.md | 2 +- .../services/service-push/src/push-plugin.ts | 28 +++++++++---------- packages/spec/src/system/push-config.zod.ts | 2 +- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/content/docs/references/system/push-config.mdx b/content/docs/references/system/push-config.mdx index 204b6b54426..9667b92fc9b 100644 --- a/content/docs/references/system/push-config.mdx +++ b/content/docs/references/system/push-config.mdx @@ -43,7 +43,7 @@ credential (a vendor block, or a key an operator would reach for), and a direct block with no vendor block, which names no `credentialRef` and so could deliver to nothing. -*What is served today.** The direct transport is: it ships as +**What is served today.** The direct transport is: it ships as `@objectstack/service-push` (ADR-0012, amended 2026-11-03, #29041). The relay transport is not yet: the relay sender is follow-up card #29920, and until it ships `PushServicePlugin` refuses a relay block, the default `{}` diff --git a/docs/adr/0012-notification-platform.md b/docs/adr/0012-notification-platform.md index b34646ee169..5124056b3f4 100644 --- a/docs/adr/0012-notification-platform.md +++ b/docs/adr/0012-notification-platform.md @@ -376,7 +376,7 @@ Lifecycle hooks: * Client SDK calls `POST /api/v1/notifications/devices` on login → `device-registry.upsert(...)`. * APNs / FCM `BadDeviceToken` / `Unregistered` response → channel returns `classifyError = 'invalid_recipient'` → dispatcher calls `device-registry.invalidate(token)` (sets `invalidated_at`, `enabled=false`) without dead-lettering the notification (it just routes around the dead token). > Amended (2026-11-03, #29041): the push channel (`push-channel.ts` in service-messaging) retires the dead token itself, through `INotificationService.invalidateDeviceToken`, because one delivery fans out to all of the recipient's devices and only the channel knows which token died; the other devices still get the push. The delivery is graded `invalid_recipient` (suppressed, not dead-lettered) only when every device was reported dead. The shipped device shape is `UserDeviceSchema` in `packages/spec/src/system/push-device.zod.ts`, narrowed to `apns` | `fcm` and `ios` | `android` | `web`. `service-push` ships the direct transport; the hosted relay, the default per cloud#3519, is not yet served (#29920), and `PushServicePlugin` refuses a relay configuration at init until it is. -* Domestic vendors (HMS / Xiaomi / OPPO / VIVO) plug in as **alternate channel implementations** (one package each, named by kind like `service-push`), not as transports — their on-device SDKs and quotas are too different to share a code path. +* Domestic vendors (HMS / Xiaomi / OPPO / VIVO) plug in as **alternate channel implementations** (`plugin-notification-push-hms` etc.), not as transports — their on-device SDKs and quotas are too different to share a code path. ### 7. Topic + Preference matrix diff --git a/packages/services/service-push/src/push-plugin.ts b/packages/services/service-push/src/push-plugin.ts index 71c459e0cf4..c19951194f7 100644 --- a/packages/services/service-push/src/push-plugin.ts +++ b/packages/services/service-push/src/push-plugin.ts @@ -35,6 +35,20 @@ export interface PushServicePluginOptions { transports?: Partial>; } +/** + * The refusal a relay configuration meets at init. The relay is the schema's + * default (cloud#3519), but its sender is not served yet (follow-up card + * #29920; the text below is printed at the operator, so it names no tracker + * id, per check:doc-authoring): booting a + * `push` service that would deliver nothing is worse than refusing, because + * the channel would mount and every push would look sent. + */ +export const PUSH_RELAY_NOT_SERVED_MESSAGE = + "PushServicePlugin: the hosted push relay (transport: 'relay', which is also what an empty push config reads as) " + + 'is not served yet: its sender has not shipped, and nothing would deliver through it. For a white-label ' + + "build signed with your own APNs key or Firebase project, write transport: 'direct' with an apns and/or fcm block, " + + 'each naming its secret by credentialRef.'; + /** * PushServicePlugin — registers the kernel's `push` service, the transport * half of the `push` notification channel (ADR-0012 §4, §6; amended @@ -66,20 +80,6 @@ export interface PushServicePluginOptions { * })); * ``` */ -/** - * The refusal a relay configuration meets at init. The relay is the schema's - * default (cloud#3519), but its sender is not served yet (follow-up card - * #29920; the text below is printed at the operator, so it names no tracker - * id, per check:doc-authoring): booting a - * `push` service that would deliver nothing is worse than refusing, because - * the channel would mount and every push would look sent. - */ -export const PUSH_RELAY_NOT_SERVED_MESSAGE = - "PushServicePlugin: the hosted push relay (transport: 'relay', which is also what an empty push config reads as) " - + 'is not served yet: its sender has not shipped, and nothing would deliver through it. For a white-label ' - + "build signed with your own APNs key or Firebase project, write transport: 'direct' with an apns and/or fcm block, " - + 'each naming its secret by credentialRef.'; - export class PushServicePlugin implements Plugin { name = 'com.objectstack.service.push'; /** Services init() registers on every path (ADR-0116). */ diff --git a/packages/spec/src/system/push-config.zod.ts b/packages/spec/src/system/push-config.zod.ts index 971c1cb1f39..01efcd456b0 100644 --- a/packages/spec/src/system/push-config.zod.ts +++ b/packages/spec/src/system/push-config.zod.ts @@ -43,7 +43,7 @@ import { strictObject } from '../shared/strict-object'; * direct block with no vendor block, which names no `credentialRef` and so * could deliver to nothing. * - **What is served today.** The direct transport is: it ships as + * **What is served today.** The direct transport is: it ships as * `@objectstack/service-push` (ADR-0012, amended 2026-11-03, #29041). The * relay transport is not yet: the relay sender is follow-up card #29920, and * until it ships `PushServicePlugin` refuses a relay block, the default `{}`