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
29 changes: 29 additions & 0 deletions .changeset/29920-push-relay-cloud-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/service-push': patch
'@objectstack/cloud-connection': patch
'@objectstack/service-messaging': patch
'@objectstack/spec': patch
'@objectstack/core': patch
---

feat(service-push)!: push sends through the hosted push relay, authenticated by the server's cloud binding

Clause-②: yes (narrowing) — the relay transport (`transport: 'relay'`, and the default `{}`), refused at init until now, is served; the relay wire narrows: `PushRelayPayloadSchema.data` refuses a key `PushDeliveryDataSchema` does not declare instead of stripping it, and a `RATE_LIMIT_EXCEEDED` relay error must not answer `retryable: false`; `PUSH_RELAY_NOT_SERVED_MESSAGE` leaves `@objectstack/service-push`.
Metadata-protocol: none

<!-- adr-0087: not-required (runtime-export-retired @objectstack/service-push#PUSH_RELAY_NOT_SERVED_MESSAGE) the retired export is the text of an init refusal that no longer happens; no packages/spec schema, registered node type, ADR-0087 conversion surface or stored shape names it. The two wire narrowings are on the server-to-relay request and the relay's error answer, which no metadata author writes and nothing stores, so there is nothing for objectstack migrate meta to rewrite -->

**The hosted push relay is served.** A stack that requires `push` with a relay configuration (`transport: 'relay'`, `push: {}`, or no `push` key) now boots and sends each push to the cloud's push relay at `/api/v1/cloud/push/send` under the control-plane URL (`OS_CLOUD_URL`, else `https://cloud.objectos.ai`); a relay block's `endpoint` overrides it. Each request carries one ID-only delivery (the notification id, its same-server route, the server's origin and one generic line in the recipient's language); the title and body never leave the server.

- **Authentication.** `@objectstack/cloud-connection` registers a new kernel service, `cloud-binding` (`ICloudBindingService` in `@objectstack/spec/contracts`: `credential()`, `bound()`, `controlPlaneUrl()`), and its own control-plane routes read the credential through it. The push service reads it on every send and puts it in the `Authorization` header only.
- **An unbound server boots.** It logs one warning at `kernel:ready`; until the server is bound on the Cloud connection setup page, the push service lists no vendor, so the push channel is suppressed as `transport_not_configured` and notifications reach users in the in-app inbox. The bind takes effect without a restart.
- **A deployment with no control plane** (`OS_CLOUD_URL=off`, `none`, `local` or `disabled`) refuses a relay configuration at boot, naming the in-app inbox and the direct transport as the two ways out. The direct transport is unchanged.
- **Answers.** `ok` is sent, `invalid` retires the device row, `retry` is retryable; a 401 fails the push without a retry, naming the lapsed binding; a 429 rate limit is retryable and a used-up quota is not.
- **The notice line.** The push channel picks the generic line by the recipient's language (`sys_user.locale`, then the deployment default, then English) and hands it to the push service as `PushMessage.notice`.
- `isControlPlaneDeclined` and the "no control plane" spellings move to `@objectstack/core`; `@objectstack/cloud-connection` re-exports `isControlPlaneDeclined` unchanged.

**BREAKING — what narrows, and what to do:**

- FROM `PUSH_RELAY_NOT_SERVED_MESSAGE` TO `PUSH_RELAY_AIR_GAPPED_MESSAGE` (both exported by `@objectstack/service-push`): the relay is no longer refused for being unserved, only on a deployment that declared it has no control plane. A test that matched the old text matches the new constant, or asserts that the relay boots.
- The relay payload's `data` is judged strictly: a sender puts only `notificationId`, `route` and `origin` in it. A content key such as `title` was stripped before and is now refused with the ID-only guidance.
- A relay's `RATE_LIMIT_EXCEEDED` error answers `retryable: true`, or leaves `retryable` out (it defaults to `true`). A relay that answered `retryable: false` on a rate limit is now refused by the wire schema.
77 changes: 53 additions & 24 deletions content/docs/kernel/runtime-services/push-service.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
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.
description: Mobile push delivery through the hosted push relay or straight to APNs and FCM, configured from the stack's push key.
---

- **Stability:** `experimental`
Expand All @@ -16,12 +16,14 @@ 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).
(`apns` or `fcm`), the device `token`, a plain-text `title` and `body`, `data`,
the delivery data the app shell reads (string values only, because FCM accepts
only strings there), and `notice`, the generic line a relay delivery shows in
place of the title and body.

`vendors()` lists the vendors this deployment has a transport for. An empty list
means push delivers nothing.
`vendors()` lists the vendors this deployment can push to right now. An empty
list means push delivers nothing: no vendor is configured, or the relay is
configured and the server is not bound to the cloud yet.

Application code rarely calls this service directly. A `notify` flow node or a
`NotificationService.emit` that names the `push` channel goes through the
Expand All @@ -37,7 +39,8 @@ through this service.
- `{ 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.
for a timeout, a 429 rate limit or a 5xx, and false for a refused credential,
a refused payload, a lapsed cloud binding or a used-up relay quota.

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

Expand Down Expand Up @@ -88,7 +91,7 @@ block is a vendor this deployment does not push to; devices registered on it are
skipped. Only `apns` and `fcm` ship today. The other Android vendors (HMS /
Honor / Xiaomi / OPPO / vivo) are planned for a later phase behind the hosted
relay (below), as further values of `vendor`, the token type the sender routes
on (this service's transports today, the relay once it is served), and not as
on (the direct transports and the relay alike), and not as
plugins or channels of their own.

### Credential references
Expand Down Expand Up @@ -116,29 +119,55 @@ secret is refused twice:
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
### The hosted relay

`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.
ObjectStack.

**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. …
```ts
export default defineStack({
manifest: { /* … */ },
requires: ['push'],
// push: {} — or leave the key out: both read as the relay.
});
```

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.
- **Authentication.** The server authenticates by its cloud binding
credential: the `oscc_` bearer a self-hosted server receives when it is bound
on the Cloud connection setup page, or the service key the cloud gives the
servers it hosts. The relay block carries no credential of its own. The push
service reads the credential from the `cloud-binding` service
(`@objectstack/cloud-connection`) on every push, and puts it in the request's
`Authorization` header and nowhere else.
- **Address.** The relay is served by the cloud's control plane at
`/api/v1/cloud/push/send`, under the control-plane URL (`OS_CLOUD_URL`, else
`https://cloud.objectos.ai`). A relay block's `endpoint` overrides it, for a
staging relay.
- **ID-only.** A relay delivery carries the notification id, its same-server
route, the server's origin and one generic line ("You have a new
notification", in the recipient's language). The title and body never leave
the server; the app reads the notification from the server after the tap.

**Before the server is bound.** A fresh self-hosted server is not bound on its
first boot, and it still boots: the boot logs one warning that the relay
delivers nothing until the server is bound. Until then the push channel reports
no transport (`transport_not_configured`), so notifications are recorded with
push suppressed and reach users in the in-app inbox. Binding takes effect
without a restart.

**When the deployment has no control plane.** A deployment that declares it has
none (`OS_CLOUD_URL=off`, `none`, `local` or `disabled`) cannot reach the relay,
so a stack that requires `push` with a relay configuration fails at boot,
naming the two ways out: the in-app inbox, which needs no push service, or the
direct transport for an app signed with your own APNs key and Firebase project.

**What the relay answers.** `ok` is a delivered push; `invalid` is a dead token,
which retires the device as below; `retry` is a retryable failure. A 401 means
the cloud binding was revoked or is not recognised: the push fails without a
retry, and the fix is to bind the server again.

## Devices

Expand Down Expand Up @@ -174,7 +203,7 @@ user's devices answered:
| 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 |
| no push service registered, or one with no vendor available (an unbound relay) | suppressed at fan-out as `transport_not_configured`, recorded on the notification; a delivery already queued is refused `transport_not_configured`, `permanent`, as the email and SMS channels answer it |

## Consumers

Expand Down
Loading
Loading