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
16 changes: 16 additions & 0 deletions .changeset/29067-push-device-registry-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': minor
---

feat(spec): the push channel's protocol face, declared ahead of its producer: the widened device registration request, the stored device shape, optional `INotificationService` device members, the push delivery data with its same-server `route`, and the APNs / FCM transport configuration by `credentialRef` (ADR-0012 §6)

Clause-②: yes (widening)
Metadata-protocol: added

Nothing here is served or read yet. Every new or touched schema carries a `[PLANNED — not yet served]` or `[PLANNED — not yet read]` marker in its description, and no route row is added: the device routes land in `DEFAULT_NOTIFICATION_ROUTES` together with their handlers, so the catalog never advertises a route that answers 404. Nothing a client or an operator writes today is refused.

- **`RegisterDeviceRequestSchema`** gains three optional members: `vendor` (`apns` | `fcm`; absent, the server derives it from `platform`, `ios` → `apns` and `android` → `fcm`, and a `web` registration names `apns` or `fcm`), `appId` and `locale` (a BCP 47 tag, the house `LocaleSchema`). `token`, `platform`, `deviceId` and `name` are unchanged, so every body a client sent before still parses as it did. The describes now say that re-registering a `deviceId` refreshes that device, and that a request without one gets a server-minted id the client keeps. A `vendor` outside `apns` / `fcm` is refused; since the member is new, no body sent before carries one. `RegisterDeviceResponseSchema` and `UnregisterDevice*` keep their members and gain only their description.
- **`UserDeviceSchema`** (new, `system/push-device.zod.ts`) is the stored device: `deviceId`, `userId`, `vendor`, `token`, `platform`, `enabled`, and optional `appId`, `locale`, `name`, `invalidatedAt` and `lastSeenAt`. A device belongs to its user alone (no organization member), a token has one live row (the same user's registration without a `deviceId` refreshes the row that holds it, and a second user's registration takes it over), and a token the vendor reports dead keeps its row with `enabled: false` and `invalidatedAt`.
- **`INotificationService`** gains four optional members: `registerDevice(userId, request)`, `unregisterDevice(userId, deviceId)`, `listDevices(userId)` and `invalidateDeviceToken(token)`. Their docs state the semantics an implementation owes. Absence means the capability is unavailable, so every existing implementation stays valid.
- **`PushDeliveryDataSchema`** (new, `api/push-delivery.zod.ts`) is the data a push carries to the shell: `notificationId` and an optional `route`. `PushRouteSchema` admits only a same-server relative path (one leading `/`; no scheme, host, backslash, whitespace or control character; no dot segment and no percent-encoded `/`, `\`, `.` or `%` in its path). The route is derived, never authored: it is the notification's existing `actionUrl` (app-relative `/OBJECT/ID` or an `/apps/…` route) when that passes the schema, and absent otherwise. The shell resolves it on tap as the inbox does, never verbatim. FCM carries this data in `message.data`; APNs at the top level of the payload, beside `aps`. No `notify` key is added.
- **`PushServiceConfigSchema`** (new, `system/push-config.zod.ts`, beside the email transport configuration): an `apns` block (token auth: `teamId`, `keyId`, `topic`, `environment: sandbox | production`, the `.p8` key by `credentialRef`) and an `fcm` block (HTTP v1: `projectId`, the service-account JSON by `credentialRef`). Secrets are references only (ADR-0143 D3). An inline secret is refused by name under the keys an operator would reach for, and refused as the content of a reference (a PEM block or a JSON document). `PushVendorSchema` names the two vendors. It is not a stack-definition key until its reader ships.
3 changes: 2 additions & 1 deletion content/docs/references/api/index.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: API Protocol — complete schema reference
navTitle: API Protocol
description: "The ObjectStack API Protocol in 34 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
description: "The ObjectStack API Protocol in 35 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand Down Expand Up @@ -33,6 +33,7 @@ This section contains all protocol schemas for the api layer of ObjectStack.
<Card href="/docs/references/api/package-lifecycle" title="Package Lifecycle" description="Source: packages/spec/src/api/package-lifecycle.zod.ts" />
<Card href="/docs/references/api/plugin-rest-api" title="Plugin Rest Api" description="Source: packages/spec/src/api/plugin-rest-api.zod.ts" />
<Card href="/docs/references/api/protocol" title="Protocol" description="Source: packages/spec/src/api/protocol.zod.ts" />
<Card href="/docs/references/api/push-delivery" title="Push Delivery" description="Source: packages/spec/src/api/push-delivery.zod.ts" />
<Card href="/docs/references/api/query-adapter" title="Query Adapter" description="Source: packages/spec/src/api/query-adapter.zod.ts" />
<Card href="/docs/references/api/realtime" title="Realtime" description="Source: packages/spec/src/api/realtime.zod.ts" />
<Card href="/docs/references/api/realtime-shared" title="Realtime Shared" description="Source: packages/spec/src/api/realtime-shared.zod.ts" />
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"misc",
"package-api-assembled",
"package-lifecycle",
"push-delivery",
"record-page",
"sortability"
]
Expand Down
13 changes: 12 additions & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2761,20 +2761,27 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'

## RegisterDeviceRequest

[PLANNED — not yet served] Register or refresh a push device for the signed-in user. No route serves it yet; the device routes land together with their handlers

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **token** | `string` | ✅ | Device push notification token |
| **platform** | `Enum<'ios' \| 'android' \| 'web'>` | ✅ | Device platform |
| **deviceId** | `string` | optional | Unique device identifier |
| **deviceId** | `string` | optional | Client-held device id, stable per app install. Re-registering the same deviceId refreshes that device; absent, the server mints one and answers it, and the client keeps it for later registrations and unregister |
| **name** | `string` | optional | Device friendly name |
| **vendor** | `Enum<'apns' \| 'fcm'>` | optional | Push vendor the token belongs to: apns or fcm. Absent, the server derives it from platform: ios means apns, android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused |
| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID |
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in |


---

## RegisterDeviceResponse

[PLANNED — not yet served] Answer to a device registration: the device id the client keeps

### Properties

| Property | Type | Required | Description |
Expand Down Expand Up @@ -3019,6 +3026,8 @@ Uninstall package response

## UnregisterDeviceRequest

[PLANNED — not yet served] Unregister one of the signed-in user's push devices

### Properties

| Property | Type | Required | Description |
Expand All @@ -3030,6 +3039,8 @@ Uninstall package response

## UnregisterDeviceResponse

[PLANNED — not yet served] Answer to a device unregistration

### Properties

| Property | Type | Required | Description |
Expand Down
92 changes: 92 additions & 0 deletions content/docs/references/api/push-delivery.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
title: Push Delivery schema — API Protocol reference
navTitle: Push Delivery
description: "Push delivery data — what a push carries to the shell. Reference for PushDeliveryData, PushRoute: every property with its type and default."
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

Push delivery data — what a push carries to the shell.

The shell reads it when the user taps the push: `notificationId` names the
notification (the same id the inbox lists and `markRead` takes), and
`route` is the notification's `actionUrl`, the link the inbox serves for it.

**Where it sits in the vendor payload.** ADR-0012 §8 names it the `data`
member of the push renderer's output; that is the renderer's shape, not the
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.

**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:
an explicit `url` / `actionUrl` in the notification's payload, else
`/OBJECT/ID` built from the notification's source record, else none. So it
is either app-relative (`/opportunity/123`) or already a Console route
(`/apps/crm/opportunity/123`). When that value passes
`PushRouteSchema` the push carries it as `route`; when it does not
(an absolute URL to another site, say) or there is none, the push carries
no `route` and the tap opens the shell's default screen. The delivery is
never failed for it.

**The shell never navigates it verbatim.** On tap it resolves `route`
exactly as the inbox bell and Home do for the same notification: through
`resolveNotificationTarget` (`ui/packages/app-shell/src/utils/appRoute.ts`)
with its host app segment, which hosts an app-relative path under
`/apps/APP/` and passes an `/apps/…` route through. One resolver, so a
push and the inbox row open the same screen.

A shell with no app list of its own (a native shell that has not loaded
the Console's catalog, say) does not guess either. It hands `route` to the
Console to resolve, or hosts it under the segment the Console would answer
(`resolveHostAppSegment` in the same file: the preferred app the user last
had open, re-checked against the active apps, then the first active app,
then `setup`). It never re-implements the resolver and never opens `route`
verbatim.

Not to be confused with the `PushNotificationSchema` that `system/` once
declared and then removed (ADR-0049): that was an authoring template shape;
this is the wire data of a delivery, produced by the platform.

<Callout type="info">
**Source:** `packages/spec/src/api/push-delivery.zod.ts`
</Callout>

## TypeScript Usage

```typescript
import { PushDeliveryDataSchema, PushRouteSchema } from '@objectstack/spec/api';
import type { PushDeliveryData, PushRoute } from '@objectstack/spec/api';

// Validate data
const result = PushDeliveryDataSchema.parse(data);
```

---

## PushDeliveryData

[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and its actionUrl as a same-server route. No push channel delivers yet

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **notificationId** | `string` | ✅ | Notification id — the one the inbox lists and markRead takes |
| **route** | `string` | optional | The notification actionUrl, the value the inbox serves, when it is a same-server relative path; absent otherwise. The shell resolves it on tap as the inbox bell and Home do, never verbatim |


---

## PushRoute

The notification actionUrl as a same-server relative path: app-relative (/opportunity/123) or a Console route (/apps/crm/opportunity/123). The shell resolves it on tap as the inbox does, never verbatim; never an absolute or off-server URL


---

Loading
Loading