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
32 changes: 32 additions & 0 deletions .changeset/29820-push-capability-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@objectstack/spec': minor
'@objectstack/core': minor
'@objectstack/service-push': minor
'@objectstack/cli': minor
'@objectstack/verify': minor
---

feat(push): `requires: ['push']` and the stack's `push` key turn mobile push on under `objectstack serve`, and a relay configuration is refused at boot

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up owed — #29969 (skills/objectstack-platform/evals/config-plugins-ops.json: no eval asks for `requires: ['push']` or the `push` key); this diff teaches both in `skills/objectstack-platform/SKILL.md` (the top-level key enumeration and the `requires` table) and in the new `content/docs/kernel/runtime-services/push-service.mdx`

A deployment that runs `objectstack serve` from a stack file can now turn on mobile push without host code. Before this, the only way to register a push service was a host that composed `PushServicePlugin` in code, so a `notify` node naming `push` had nothing to deliver through on a served stack.

- **`@objectstack/spec`: the `push` capability token and the `push` stack key.** `push` joins `PLATFORM_CAPABILITY_TOKENS`, provided by `@objectstack/service-push` in the open edition. It is requested, never on the always-on slate. The stack definition gains a top-level `push` key beside `email` and `sms`: it is `PushServiceConfigSchema`, composed as a single value (`COMPOSE_KEY_DISPOSITIONS`). Authoring accepts a direct block, a relay block and absence; it refuses an inline secret, by key name (`key`, `privateKey`, `serviceAccount`, …) with `credentialRef` as the remedy, or pasted into a `credentialRef` (ADR-0143 D3). `PushServiceConfigSchema`'s docblock now names the stack key and `objectstack serve` as its reader; `PushRelayTransportConfigSchema` keeps its `[PLANNED — not yet served]` marker.
- **`@objectstack/core`: the `push` provider row and its argument.** `CAPABILITY_PROVIDERS.push` names `PushServicePlugin`, and `resolveCapabilityArgument('push', …)` builds its argument from the stack's `push` key through `resolvePushCapabilityArg`, read off the provider module as the `email` and `sms` readers are. Both `objectstack serve` and `@objectstack/verify`'s `bootStack` read this table, so an app's tests get the push service its server gets.
- **`@objectstack/service-push`: `resolvePushCapabilityArg`.** It parses the `push` key with `PushServiceConfigSchema` and refuses, before the kernel boots, two configurations: the relay transport (an absent key and `push: {}` read as it), whose sender has not shipped, with a message that says so and prescribes `transport: 'direct'` with an `apns` and/or `fcm` block; and anything the schema refuses, an inline secret first among them, naming each path and the schema's guidance and never the value. On a stack that declares `requires: ['push']` either refusal fails the boot as `Capability "push" (@objectstack/service-push) failed to start: …`.
- **`@objectstack/cli` and `@objectstack/verify`** depend on `@objectstack/service-push`, so the provider the token names resolves out of the box, as the open edition promises.

To turn push on, add `requires: ['push']` and a direct block naming each vendor's secret by reference:

```ts
push: {
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' },
},
```

In the open tier each `credentialRef` is the name of an environment variable holding the secret. A `push` key on a stack whose `requires` does not name `push` is not read, and `objectstack serve` logs one boot warning saying so. The runtime-services chapter has a new `services.push` page.
5 changes: 3 additions & 2 deletions content/docs/getting-started/quick-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,11 @@ schema page is the authority until one lands.
**Not in the table, and why.** `viewItems` and `runtimeModule` are not authorable
at all (`viewItems` is `z.never()`, the machine-assembled channel for
runtime-assembled manifests; `runtimeModule` is written by `objectstack build`);
`manifest`, `i18n`, `api`, `server`, `appName`, `email`, `sms`, `requires`,
`manifest`, `i18n`, `api`, `server`, `appName`, `email`, `sms`, `push`, `requires`,
`tiers`, `plugins`, `devPlugins`, `devHint`, `devLogins` and `demoStaff` configure
the stack rather than declare metadata items (`email` and `sms` carry no
credentials — those stay in the environment or in Settings; `demoStaff` names the
credentials — those stay in the environment or in Settings; `push` names each
vendor secret by `credentialRef` and is read only when `requires` names `push`; `demoStaff` names the
demo accounts a local `os dev` boot creates, and `os build` leaves it out of
`objectstack.json`); and
`functions` and `onEnable` are code the runtime calls, not metadata it stores.
Expand Down
1 change: 1 addition & 0 deletions content/docs/kernel/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The kernel is ObjectStack's runtime: it loads your metadata artifact, hosts plug
| [`services.sharing`](/docs/kernel/runtime-services/sharing-service) | stable | `buildReadFilter`, `canEdit`, `canDelete`, `canManageShares`, `grant`/`revoke`, `listShares` |
| [`services.email`](/docs/kernel/runtime-services/email-service) | stable | `send`, `sendTemplate` |
| [`services.sms`](/docs/kernel/runtime-services/sms-service) | stable | Outbound SMS delivery through pluggable providers (Aliyun SMS, Twilio) |
| [`services.push`](/docs/kernel/runtime-services/push-service) | experimental | Mobile push to APNs and FCM, turned on by `requires: ['push']` |
| [`services.queue`](/docs/kernel/runtime-services/queue-service) | stable | Background work and queues |
| [`services.settings`](/docs/kernel/runtime-services/settings-service) | stable | App/environment settings |
| [`services.storage`](/docs/kernel/runtime-services/storage-service) | stable | File storage |
Expand Down
2 changes: 2 additions & 0 deletions content/docs/kernel/runtime-services/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ This chapter documents the runtime `services.*` APIs used in hook/action/flow/pl
- `services.queue`
- `services.email`
- `services.sms`
- `services.push`
- `services.settings`
- `services.storage`

Expand All @@ -56,6 +57,7 @@ Each page links the canonical TypeScript source used to derive signatures.
- Queue: `packages/spec/src/contracts/queue-service.ts`
- Email: `packages/spec/src/contracts/email-service.ts`
- SMS: `packages/spec/src/contracts/sms-service.ts`
- Push: `packages/services/service-push/src/push-service.ts`
- Storage: `packages/spec/src/contracts/storage-service.ts`
- Settings: `packages/services/service-settings/src/settings-service.ts`
- Audit: `packages/plugins/plugin-audit/src/auth-event-audit.ts`
Expand Down
1 change: 1 addition & 0 deletions content/docs/kernel/runtime-services/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"queue-service",
"email-service",
"sms-service",
"push-service",
"settings-service",
"storage-service",
"examples",
Expand Down
180 changes: 180 additions & 0 deletions content/docs/kernel/runtime-services/push-service.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
---
title: services.push — the mobile push service API
navTitle: services.push
description: Mobile push delivery to APNs and FCM, configured from the stack's push key with credentials by reference.
---

- **Stability:** `experimental`
- **Canonical source:** `packages/services/service-push/src/push-service.ts`
- **Registry slot:** `push` — resolve with `ctx.getService('push')`.

## Methods

```ts
services.push.vendors(): PushVendor[]
services.push.send(message: PushMessage): Promise<PushSendResult>
```

`PushMessage` is one push to one device: the `vendor` the token belongs to
(`apns` or `fcm`), the device `token`, a plain-text `title` and `body`, and
`data`, the delivery data the app shell reads (string values only, because FCM
accepts only strings there).

`vendors()` lists the vendors this deployment has a transport for. An empty list
means push delivers nothing.

Application code rarely calls this service directly. A `notify` flow node or a
`NotificationService.emit` that names the `push` channel goes through the
messaging service, whose `push` channel reads the recipient's devices and sends
through this service.

## Returns

`PushSendResult`, one of:

- `{ status: 'sent', messageId? }`: the vendor accepted the push. `messageId`
is APNs' `apns-id` or FCM's message `name`.
- `{ status: 'invalid_token', reason }`: the vendor reported the token dead
(APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`).
- `{ status: 'failed', error, retryable }`: anything else. `retryable` is true
for a timeout, a 429 or a 5xx, and false for a refused credential or payload.

`send()` never throws: a transport error is answered `failed`.

## Turning it on

Push is a requested capability, never on by default. A stack turns it on with
`requires: ['push']` and configures it with the top-level `push` key, which is
`PushServiceConfigSchema`:

```ts
import { defineStack } from '@objectstack/spec';

export default defineStack({
manifest: { /* … */ },
requires: ['push'],
push: {
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' },
},
});
```

A `push` key without `push` in `requires` is not read: `objectstack serve` logs
one boot warning naming the key and the fix. With the token, `objectstack serve`
mounts `PushServicePlugin` from
`@objectstack/service-push`, which the CLI ships with. `@objectstack/verify`'s
`bootStack` mounts it the same way, so an app's tests run with the same push
service its server gets.

### The two vendors

- **APNs** (Apple Push Notification service), token-based authentication: the
Apple Developer `teamId`, the auth key's `keyId`, the app bundle ID as
`topic`, and the `environment`, `sandbox` for development builds or
`production` for TestFlight and App Store builds.
- **FCM** (Firebase Cloud Messaging) over the HTTP v1 API: the Firebase
`projectId`, authenticated by a service account.

Each vendor block is optional, and at least one is required. A vendor with no
block is a vendor this deployment does not push to; devices registered on it are
skipped. Other Android vendors (HMS, Xiaomi, OPPO, vivo) are not transports of
this service: they plug in as separate channel implementations.

### Credential references

Each vendor has one secret, the APNs `.p8` signing key and the FCM
service-account JSON, and the stack names it by `credentialRef`, never inline.
In the open tier a `credentialRef` is the name of an environment variable, read
when the transport first signs a request. For the example above, set
`APNS_SIGNING_KEY` to the contents of the `.p8` file and `FCM_SERVICE_ACCOUNT` to
the service-account JSON.

The stack definition is compiled into the published artifact, so an inline
secret is refused twice:

- at authoring, by `defineStack` and `os validate`. A key an operator would reach
for (`key`, `privateKey`, `serviceAccount`, …) is refused by name with
`credentialRef` as the remedy, and a PEM block or JSON document pasted into
`credentialRef` itself is refused, because a reference never contains
whitespace or braces;
- at boot, because a compiled artifact or a plain config object can reach
`objectstack serve` without passing `defineStack`. The `push` capability fails
to start, naming each refused path and its remedy. The refusal never repeats
the value.

A `credentialRef` that names an unset variable is not a boot error: the push to
that vendor fails with `credential_unresolved`, naming the reference.

### The hosted relay: not served yet

`PushServiceConfigSchema` has a second transport, `transport: 'relay'`, and it is
the schema's default: a `push` block with no vendor block, `push: {}` and an
absent `push` key all read as the relay. The relay delivers to the
store-published ObjectOS app, whose APNs key and Firebase project stay with
ObjectStack, and authenticates the server by its cloud binding credential.

**Its sender has not shipped.** Until it does, a stack that requires `push` with
a relay configuration (including no `push` key) fails at boot:

```
Capability "push" (@objectstack/service-push) failed to start: 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. …
```

The fix is the direct transport: `transport: 'direct'` with an `apns` and/or
`fcm` block for an app signed with your own APNs key and Firebase project. The
boot refuses rather than serving the relay because a served relay would mount
the push channel and answer every push as sent while nothing left the server.
Authoring still accepts a relay block, so a stack written for the relay does not
need to change when the sender ships.

## Devices

The push channel addresses the recipient's devices in the device registry,
`sys_user_device`, owned by the messaging service. A signed-in app registers
its device and removes it through two routes:

- `POST /api/v1/notifications/devices` registers the current user's device, or
refreshes it when its `deviceId` is already registered
(`RegisterDeviceRequestSchema`: `token`, `platform`, optional `deviceId`,
`vendor`, `appId`, `locale`, `origin`).
- `DELETE /api/v1/notifications/devices/:deviceId` removes one of the current
user's devices. A device the user does not hold answers 404.

### Dead-token retirement

When a vendor answers `invalid_token`, the push channel retires that device: the
row is kept, disabled, with the time it was retired
(`INotificationService.invalidateDeviceToken`). The channel does it, not the
dispatcher, because one notification fans out to every device of the recipient
and only the channel knows which token died. The recipient's other devices
still receive the push.

## How a delivery is graded

The push channel grades one delivery, one recipient user, by what all of that
user's devices answered:

| What happened | Outcome |
|:--|:--|
| at least one device accepted it | delivered; a device that failed beside it is logged, never retried, so the devices that received it are not pushed twice |
| every addressed device was reported dead | `invalid_recipient`: suppressed, not dead-lettered |
| the user has no enabled device | `invalid_recipient` |
| devices exist, but none on a vendor this deployment has a transport for, 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`, as the email and SMS channels answer it |

## Consumers

- **Messaging `push` channel**: `notify(channels: ['push'])` renders the
notification in each recipient's language and pushes it to their enabled
devices. It is mounted while a `push` service is registered.
1 change: 1 addition & 0 deletions content/docs/kernel/runtime-services/versioning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ When a runtime service API changes in a breaking way:
| `services.queue` | `stable` |
| `services.email` | `stable` |
| `services.sms` | `stable` |
| `services.push` | `experimental` |
| `services.settings` | `stable` |
| `services.storage` | `stable` |
35 changes: 22 additions & 13 deletions content/docs/references/system/push-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ vendors.
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.
registers. Under `objectstack serve` the value is the stack definition's
`push` key, read when the stack declares `requires: ['push']` (below).

**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 @@ -44,11 +45,13 @@ 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.
`@objectstack/service-push` (ADR-0012, amended 2026-11-03, #29041), and
`objectstack serve` reads it from the stack's `push` key. The relay
transport is not yet: the relay sender is follow-up card #29920, and until
it ships the boot refuses a relay block, the default `{}` included (the
`push` capability's reader before the kernel boots, and `PushServicePlugin`
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.
Expand All @@ -65,13 +68,19 @@ refused two ways: written under a key an operator would reach for (`key`,
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.** `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.
**What reads it: the stack's `push` key under `objectstack serve`.** It is
the stack definition's top-level `push` key, beside `email` and `sms`. A
stack that declares `requires: ['push']` has `objectstack serve` (and
`@objectstack/verify`'s `bootStack`) mount `PushServicePlugin`, built by
`resolvePushCapabilityArg` (`@objectstack/service-push`) from that key.
The reader parses the key with this schema again at boot, because a
compiled artifact may not have passed `defineStack`, and it refuses two
things there, failing the boot: a relay block (the default `{}` and an
absent key included), because nothing sends it yet, and an inline secret.
A host that composes the plugin in code hands it the value through the
constructor instead. No `OS_PUSH_*` environment variable carries any of
it: the vendor blocks hold no secret to override, and each `credentialRef`
already names one.

Out of scope here: the other vendors ADR-0012 §6 routes to alternate
channel plugins (HMS, Xiaomi, OPPO, vivo).
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"@objectstack/service-mailbox": "workspace:*",
"@objectstack/service-messaging": "workspace:*",
"@objectstack/service-package": "workspace:*",
"@objectstack/service-push": "workspace:*",
"@objectstack/service-queue": "workspace:*",
"@objectstack/service-realtime": "workspace:*",
"@objectstack/service-settings": "workspace:*",
Expand Down
Loading
Loading