|
| 1 | +--- |
| 2 | +title: services.push — the mobile push service API |
| 3 | +navTitle: services.push |
| 4 | +description: Mobile push delivery to APNs and FCM, configured from the stack's push key with credentials by reference. |
| 5 | +--- |
| 6 | + |
| 7 | +- **Stability:** `experimental` |
| 8 | +- **Canonical source:** `packages/services/service-push/src/push-service.ts` |
| 9 | +- **Registry slot:** `push` — resolve with `ctx.getService('push')`. |
| 10 | + |
| 11 | +## Methods |
| 12 | + |
| 13 | +```ts |
| 14 | +services.push.vendors(): PushVendor[] |
| 15 | +services.push.send(message: PushMessage): Promise<PushSendResult> |
| 16 | +``` |
| 17 | + |
| 18 | +`PushMessage` is one push to one device: the `vendor` the token belongs to |
| 19 | +(`apns` or `fcm`), the device `token`, a plain-text `title` and `body`, and |
| 20 | +`data`, the delivery data the app shell reads (string values only, because FCM |
| 21 | +accepts only strings there). |
| 22 | + |
| 23 | +`vendors()` lists the vendors this deployment has a transport for. An empty list |
| 24 | +means push delivers nothing. |
| 25 | + |
| 26 | +Application code rarely calls this service directly. A `notify` flow node or a |
| 27 | +`NotificationService.emit` that names the `push` channel goes through the |
| 28 | +messaging service, whose `push` channel reads the recipient's devices and sends |
| 29 | +through this service. |
| 30 | + |
| 31 | +## Returns |
| 32 | + |
| 33 | +`PushSendResult`, one of: |
| 34 | + |
| 35 | +- `{ status: 'sent', messageId? }`: the vendor accepted the push. `messageId` |
| 36 | + is APNs' `apns-id` or FCM's message `name`. |
| 37 | +- `{ status: 'invalid_token', reason }`: the vendor reported the token dead |
| 38 | + (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`). |
| 39 | +- `{ status: 'failed', error, retryable }`: anything else. `retryable` is true |
| 40 | + for a timeout, a 429 or a 5xx, and false for a refused credential or payload. |
| 41 | + |
| 42 | +`send()` never throws: a transport error is answered `failed`. |
| 43 | + |
| 44 | +## Turning it on |
| 45 | + |
| 46 | +Push is a requested capability, never on by default. A stack turns it on with |
| 47 | +`requires: ['push']` and configures it with the top-level `push` key, which is |
| 48 | +`PushServiceConfigSchema`: |
| 49 | + |
| 50 | +```ts |
| 51 | +import { defineStack } from '@objectstack/spec'; |
| 52 | + |
| 53 | +export default defineStack({ |
| 54 | + manifest: { /* … */ }, |
| 55 | + requires: ['push'], |
| 56 | + push: { |
| 57 | + transport: 'direct', |
| 58 | + apns: { |
| 59 | + teamId: 'ABCDE12345', |
| 60 | + keyId: 'KEY1234567', |
| 61 | + topic: 'com.example.app', |
| 62 | + environment: 'production', |
| 63 | + credentialRef: 'APNS_SIGNING_KEY', |
| 64 | + }, |
| 65 | + fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' }, |
| 66 | + }, |
| 67 | +}); |
| 68 | +``` |
| 69 | + |
| 70 | +A `push` key without `push` in `requires` is not read: `objectstack serve` logs |
| 71 | +one boot warning naming the key and the fix. With the token, `objectstack serve` |
| 72 | +mounts `PushServicePlugin` from |
| 73 | +`@objectstack/service-push`, which the CLI ships with. `@objectstack/verify`'s |
| 74 | +`bootStack` mounts it the same way, so an app's tests run with the same push |
| 75 | +service its server gets. |
| 76 | + |
| 77 | +### The two vendors |
| 78 | + |
| 79 | +- **APNs** (Apple Push Notification service), token-based authentication: the |
| 80 | + Apple Developer `teamId`, the auth key's `keyId`, the app bundle ID as |
| 81 | + `topic`, and the `environment`, `sandbox` for development builds or |
| 82 | + `production` for TestFlight and App Store builds. |
| 83 | +- **FCM** (Firebase Cloud Messaging) over the HTTP v1 API: the Firebase |
| 84 | + `projectId`, authenticated by a service account. |
| 85 | + |
| 86 | +Each vendor block is optional, and at least one is required. A vendor with no |
| 87 | +block is a vendor this deployment does not push to; devices registered on it are |
| 88 | +skipped. Other Android vendors (HMS, Xiaomi, OPPO, vivo) are not transports of |
| 89 | +this service: they plug in as separate channel implementations. |
| 90 | + |
| 91 | +### Credential references |
| 92 | + |
| 93 | +Each vendor has one secret, the APNs `.p8` signing key and the FCM |
| 94 | +service-account JSON, and the stack names it by `credentialRef`, never inline. |
| 95 | +In the open tier a `credentialRef` is the name of an environment variable, read |
| 96 | +when the transport first signs a request. For the example above, set |
| 97 | +`APNS_SIGNING_KEY` to the contents of the `.p8` file and `FCM_SERVICE_ACCOUNT` to |
| 98 | +the service-account JSON. |
| 99 | + |
| 100 | +The stack definition is compiled into the published artifact, so an inline |
| 101 | +secret is refused twice: |
| 102 | + |
| 103 | +- at authoring, by `defineStack` and `os validate`. A key an operator would reach |
| 104 | + for (`key`, `privateKey`, `serviceAccount`, …) is refused by name with |
| 105 | + `credentialRef` as the remedy, and a PEM block or JSON document pasted into |
| 106 | + `credentialRef` itself is refused, because a reference never contains |
| 107 | + whitespace or braces; |
| 108 | +- at boot, because a compiled artifact or a plain config object can reach |
| 109 | + `objectstack serve` without passing `defineStack`. The `push` capability fails |
| 110 | + to start, naming each refused path and its remedy. The refusal never repeats |
| 111 | + the value. |
| 112 | + |
| 113 | +A `credentialRef` that names an unset variable is not a boot error: the push to |
| 114 | +that vendor fails with `credential_unresolved`, naming the reference. |
| 115 | + |
| 116 | +### The hosted relay: not served yet |
| 117 | + |
| 118 | +`PushServiceConfigSchema` has a second transport, `transport: 'relay'`, and it is |
| 119 | +the schema's default: a `push` block with no vendor block, `push: {}` and an |
| 120 | +absent `push` key all read as the relay. The relay delivers to the |
| 121 | +store-published ObjectOS app, whose APNs key and Firebase project stay with |
| 122 | +ObjectStack, and authenticates the server by its cloud binding credential. |
| 123 | + |
| 124 | +**Its sender has not shipped.** Until it does, a stack that requires `push` with |
| 125 | +a relay configuration (including no `push` key) fails at boot: |
| 126 | + |
| 127 | +``` |
| 128 | +Capability "push" (@objectstack/service-push) failed to start: PushServicePlugin: the hosted push relay |
| 129 | +(transport: 'relay', which is also what an empty push config reads as) is not served yet: its sender has |
| 130 | +not shipped, and nothing would deliver through it. … |
| 131 | +``` |
| 132 | + |
| 133 | +The fix is the direct transport: `transport: 'direct'` with an `apns` and/or |
| 134 | +`fcm` block for an app signed with your own APNs key and Firebase project. The |
| 135 | +boot refuses rather than serving the relay because a served relay would mount |
| 136 | +the push channel and answer every push as sent while nothing left the server. |
| 137 | +Authoring still accepts a relay block, so a stack written for the relay does not |
| 138 | +need to change when the sender ships. |
| 139 | + |
| 140 | +## Devices |
| 141 | + |
| 142 | +The push channel addresses the recipient's devices in the device registry, |
| 143 | +`sys_user_device`, owned by the messaging service. A signed-in app registers |
| 144 | +its device and removes it through two routes: |
| 145 | + |
| 146 | +- `POST /api/v1/notifications/devices` registers the current user's device, or |
| 147 | + refreshes it when its `deviceId` is already registered |
| 148 | + (`RegisterDeviceRequestSchema`: `token`, `platform`, optional `deviceId`, |
| 149 | + `vendor`, `appId`, `locale`, `origin`). |
| 150 | +- `DELETE /api/v1/notifications/devices/:deviceId` removes one of the current |
| 151 | + user's devices. A device the user does not hold answers 404. |
| 152 | + |
| 153 | +### Dead-token retirement |
| 154 | + |
| 155 | +When a vendor answers `invalid_token`, the push channel retires that device: the |
| 156 | +row is kept, disabled, with the time it was retired |
| 157 | +(`INotificationService.invalidateDeviceToken`). The channel does it, not the |
| 158 | +dispatcher, because one notification fans out to every device of the recipient |
| 159 | +and only the channel knows which token died. The recipient's other devices |
| 160 | +still receive the push. |
| 161 | + |
| 162 | +## How a delivery is graded |
| 163 | + |
| 164 | +The push channel grades one delivery, one recipient user, by what all of that |
| 165 | +user's devices answered: |
| 166 | + |
| 167 | +| What happened | Outcome | |
| 168 | +|:--|:--| |
| 169 | +| 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 | |
| 170 | +| every addressed device was reported dead | `invalid_recipient`: suppressed, not dead-lettered | |
| 171 | +| the user has no enabled device | `invalid_recipient` | |
| 172 | +| 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` | |
| 173 | +| a timeout, a 429 or a 5xx with nothing delivered | `retryable` | |
| 174 | +| no push service registered | refused `transport_not_configured`, `permanent`, as the email and SMS channels answer it | |
| 175 | + |
| 176 | +## Consumers |
| 177 | + |
| 178 | +- **Messaging `push` channel**: `notify(channels: ['push'])` renders the |
| 179 | + notification in each recipient's language and pushes it to their enabled |
| 180 | + devices. It is mounted while a `push` service is registered. |
0 commit comments