diff --git a/.changeset/29041-push-notification-channel.md b/.changeset/29041-push-notification-channel.md new file mode 100644 index 00000000000..67fc4e16a93 --- /dev/null +++ b/.changeset/29041-push-notification-channel.md @@ -0,0 +1,14 @@ +--- +'@objectstack/service-push': patch +'@objectstack/service-messaging': patch +'@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. 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`, `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/.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 42146340a7c..362724b432d 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: @@ -81,7 +81,7 @@ const result = PushDeliveryDataSchema.parse(data); ## PushDeliveryData -[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id, its actionUrl as a same-server route, and the origin of the server that sent it. No push channel delivers yet +The data a push delivery carries to the shell: the notification id, its actionUrl as a same-server route, and the origin of the server that sent it ### Properties diff --git a/content/docs/references/system/push-config.mdx b/content/docs/references/system/push-config.mdx index 4784f1e77e1..9667b92fc9b 100644 --- a/content/docs/references/system/push-config.mdx +++ b/content/docs/references/system/push-config.mdx @@ -9,10 +9,12 @@ description: "Push Service Configuration Protocol — how the push channel reach Push Service Configuration Protocol — how the push channel reaches the vendors. -**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: +for the direct transport it builds one vendor transport per block, and it +refuses the relay transport, not yet served (#29920). The `push` channel in +`@objectstack/service-messaging` delivers through the push service it +registers. **Two transports, and the hosted relay is the default.** The store-published ObjectOS app is signed with ObjectStack's own APNs key and Firebase project, @@ -41,6 +43,13 @@ 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 +`@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 `{}` +included, when it initialises, so a server is never left believing it +pushes when nothing would deliver. + It sits beside `EmailServiceConfigSchema` (`email-config.zod.ts`), the declaration of the email channel's transport selection. @@ -48,21 +57,21 @@ declaration of the email channel's transport selection. direct vendor block 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: the other vendors ADR-0012 §6 routes to alternate channel plugins (HMS, Xiaomi, OPPO, vivo). @@ -85,7 +94,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 @@ -102,7 +111,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 @@ -116,7 +125,7 @@ const result = ApnsTransportConfigSchema.parse(data); ## PushDirectTransportConfig -[PLANNED — not yet read] Direct push transport for a white-label build: one block per vendor (apns, fcm), at least one, each naming its credential by reference +Direct push transport for a white-label build: one block per vendor (apns, fcm), at least one, each naming its credential by reference; read by the push service ### Properties @@ -148,7 +157,7 @@ const result = ApnsTransportConfigSchema.parse(data); ## PushRelayTransportConfig -[PLANNED — not yet read] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none +[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 @@ -162,7 +171,7 @@ const result = ApnsTransportConfigSchema.parse(data); ## PushServiceConfig -[PLANNED — not yet read] Push channel transport configuration: the hosted relay (the default) or direct delivery to apns and fcm for a white-label build. Nothing reads it until the push channel plugin ships +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 @@ -170,7 +179,7 @@ This schema accepts one of the following structures: #### Option 1 -[PLANNED — not yet read] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none +[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 @@ -183,7 +192,7 @@ This schema accepts one of the following structures: #### Option 2 -[PLANNED — not yet read] Direct push transport for a white-label build: one block per vendor (apns, fcm), at least one, each naming its credential by reference +Direct push transport for a white-label build: one block per vendor (apns, fcm), at least one, each naming its credential by reference; read by the push service ### Properties diff --git a/docs/adr/0012-notification-platform.md b/docs/adr/0012-notification-platform.md index db64800ed7b..5124056b3f4 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 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` | `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 | 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,6 +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`. `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** (`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-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..c5c1437a536 --- /dev/null +++ b/packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts @@ -0,0 +1,272 @@ +// 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 { + 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'; + +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; 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 {}; + }, + }; +} + +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-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/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..5e3b607ace5 --- /dev/null +++ b/packages/services/service-messaging/src/push-channel.test.ts @@ -0,0 +1,287 @@ +// 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'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, + type EngineFindOneQueryInput, +} from '@objectstack/metadata-core'; + +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('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' }); + 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; 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(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; + const 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..f83c66bc963 --- /dev/null +++ b/packages/services/service-messaging/src/push-channel.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { PushDeliveryDataSchema, 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, 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` + * 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: withOrigin(data, device.origin), + }); + 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; +} + +/** + * 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(); +} + +/** 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..33ba84f13f1 --- /dev/null +++ b/packages/services/service-push/README.md @@ -0,0 +1,17 @@ +# @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`. + +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/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..31fdc20f3c1 --- /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, PUSH_RELAY_NOT_SERVED_MESSAGE, 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..6e08b8ea75e --- /dev/null +++ b/packages/services/service-push/src/push-plugin.test.ts @@ -0,0 +1,113 @@ +// 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, PUSH_RELAY_NOT_SERVED_MESSAGE } 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('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 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'); + }); + + 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..c19951194f7 --- /dev/null +++ b/packages/services/service-push/src/push-plugin.ts @@ -0,0 +1,116 @@ +// 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 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; + /** + * 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>; +} + +/** + * 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 + * 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. 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). + * + * @example + * ```ts + * 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' }, + * }, + * })); + * ``` + */ +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 ?? {}); + 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 }); + 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..4c49dc2450f --- /dev/null +++ b/packages/services/service-push/vitest.config.ts @@ -0,0 +1,24 @@ +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). + // Mechanism + measured costs: examples/app-showcase/vitest.config.ts. + // 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 bfcf63000d3..d85e5268767 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/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 6fdd51dace0..43de66d79ea 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1973,7 +1973,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 0352926e6b7..4a6349a31b3 100644 --- a/packages/spec/src/api/push-delivery.zod.ts +++ b/packages/spec/src/api/push-delivery.zod.ts @@ -17,10 +17,10 @@ import { PUSH_SERVER_ORIGIN_MESSAGE, PUSH_SERVER_ORIGIN_PATTERN } from '../syste * 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: @@ -97,7 +97,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( @@ -111,7 +110,7 @@ export const PushDeliveryDataSchema = lazySchema(() => z.object({ + 'when the device was registered without one', ), }).describe( - '[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id, its actionUrl as a ' - + 'same-server route, and the origin of the server that sent it. No push channel delivers yet', + 'The data a push delivery carries to the shell: the notification id, its actionUrl as a same-server route, and the ' + + 'origin of the server that sent it', )); 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 fbdf618daf9..38672280fa7 100644 --- a/packages/spec/src/system/push-config.test.ts +++ b/packages/spec/src/system/push-config.test.ts @@ -199,16 +199,22 @@ describe('PushServiceConfigSchema — direct vendor blocks', () => { }); }); -// 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): the direct +// transport and its vendor blocks are served, so they claim nothing unread +// (ADR-0049: a marker states a fact). The relay is refused at boot until its +// sender ships (#29920), so its block keeps a marker that says so. +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/); + }); + it.each([ ['PushServiceConfigSchema', PushServiceConfigSchema], - ['PushRelayTransportConfigSchema', PushRelayTransportConfigSchema], ['PushDirectTransportConfigSchema', PushDirectTransportConfigSchema], ['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 6f5368357e0..01efcd456b0 100644 --- a/packages/spec/src/system/push-config.zod.ts +++ b/packages/spec/src/system/push-config.zod.ts @@ -9,10 +9,12 @@ import { strictObject } from '../shared/strict-object'; * Push Service Configuration Protocol — how the push channel reaches the * vendors. * - * **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: + * for the direct transport it builds one vendor transport per block, and it + * refuses the relay transport, not yet served (#29920). The `push` channel in + * `@objectstack/service-messaging` delivers through the push service it + * registers. * * **Two transports, and the hosted relay is the default.** The store-published * ObjectOS app is signed with ObjectStack's own APNs key and Firebase project, @@ -41,6 +43,13 @@ 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 + * `@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 `{}` + * included, when it initialises, so a server is never left believing it + * pushes when nothing would deliver. + * * It sits beside `EmailServiceConfigSchema` (`email-config.zod.ts`), the * declaration of the email channel's transport selection. * @@ -48,21 +57,21 @@ import { strictObject } from '../shared/strict-object'; * direct vendor block 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: the other vendors ADR-0012 §6 routes to alternate * channel plugins (HMS, Xiaomi, OPPO, vivo). @@ -135,7 +144,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. */ @@ -160,7 +169,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; const RELAY_CREDENTIAL_GUIDANCE = @@ -210,7 +219,8 @@ export const PushRelayTransportConfigSchema = lazySchema(() => strictObject( + 'carries the server binding credential', ), }, -).describe('[PLANNED — not yet read] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none')); +).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')); export type PushRelayTransportConfig = z.input; /** Post-parse shape of {@link PushRelayTransportConfig} — `transport` filled in (ADR-0122). */ export type PushRelayTransportConfigParsed = z.infer; @@ -235,7 +245,7 @@ export const PushDirectTransportConfigSchema = lazySchema(() => strictObject( fcm: FcmTransportConfigSchema.optional().describe('FCM HTTP v1 transport. Absent: no delivery to fcm devices'), }, ).refine(requiredOneOf(['apns', 'fcm']), { message: PUSH_DIRECT_TRANSPORT_EMPTY_MESSAGE }) - .describe('[PLANNED — not yet read] Direct push transport for a white-label build: one block per vendor (apns, fcm), at least one, each naming its credential by reference')); + .describe('Direct push transport for a white-label build: one block per vendor (apns, fcm), at least one, each naming its credential by reference; read by the push service')); export type PushDirectTransportConfig = z.input; /** Post-parse shape of {@link PushDirectTransportConfig} — `transport` filled in (ADR-0122). */ export type PushDirectTransportConfigParsed = z.infer; @@ -254,7 +264,8 @@ export const PushServiceConfigSchema = lazySchema(() => z.discriminatedUnion( // Both members default `transport`, so neither owns its absence: an absent // tag falls back to trying the members in order, relay first. { unionFallback: true }, -).describe('[PLANNED — not yet read] Push channel transport configuration: the hosted relay (the default) or direct delivery to apns and fcm for a white-label build. Nothing reads it until the push channel plugin ships')); +).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')); export type PushServiceConfig = z.input; /** Post-parse shape of {@link PushServiceConfig} — `transport` filled in (ADR-0122). */ export type PushServiceConfigParsed = z.infer; diff --git a/packages/spec/src/system/push-device.zod.ts b/packages/spec/src/system/push-device.zod.ts index dac832b122e..136e6f7ddcf 100644 --- a/packages/spec/src/system/push-device.zod.ts +++ b/packages/spec/src/system/push-device.zod.ts @@ -17,9 +17,12 @@ import { PUSH_SERVER_ORIGIN_MESSAGE, PUSH_SERVER_ORIGIN_PATTERN } from './push-s * 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/packages/spec/src/system/push-server-origin.test.ts b/packages/spec/src/system/push-server-origin.test.ts index 200cb5eb42d..08fa6e6f031 100644 --- a/packages/spec/src/system/push-server-origin.test.ts +++ b/packages/spec/src/system/push-server-origin.test.ts @@ -106,7 +106,7 @@ describe('the three origin members are one contract', () => { it('marks the device-side members PLANNED until the server stores the origin', () => { expect(RegisterDeviceRequestSchema.shape.origin.description).toMatch(/^\[PLANNED — not yet stored\] /); expect(UserDeviceSchema.shape.origin.description).toMatch(/^\[PLANNED — not yet stored\] /); - // The push data carries its marker on the whole schema until a push channel produces it. - expect(PushDeliveryDataSchema.description).toMatch(/^\[PLANNED — not yet served\] /); + // The push data is served: the push channel produces it and copies a stored origin (#29041). + expect(PushDeliveryDataSchema.description).not.toMatch(/PLANNED/); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2578af28370..617bfa66a88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2756,6 +2756,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 @@ -3110,6 +3113,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': 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",