Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/29041-push-notification-channel.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 9 additions & 2 deletions content/docs/plugins/packages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/api/push-delivery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
49 changes: 29 additions & 20 deletions content/docs/references/system/push-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,28 +43,35 @@ 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.

**Secrets are references, never values (ADR-0143 D3, ADR-0097 §3).** Each
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).
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -162,15 +171,15 @@ 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

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

Expand All @@ -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

Expand Down
5 changes: 3 additions & 2 deletions docs/adr/0012-notification-platform.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-automation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading