From 5e8dce747200163ec1b1de6e0bade96219f3a020 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:48:07 +0000 Subject: [PATCH 1/9] wip(cloud-connection): register the cloud-binding service; declined spellings move to core Claude-Session: https://claude.ai/code/session_01Utp6CZX77joBRgcruRQSMN Co-authored-by: Claude --- .../cloud-connection/src/cloud-binding.ts | 52 +++++++++++++++++++ .../src/cloud-connection-plugin.ts | 29 +++++++++-- packages/cloud-connection/src/cloud-url.ts | 32 +++++------- packages/core/src/stack-auth.ts | 27 ++++++++++ .../src/contracts/cloud-binding-service.ts | 45 ++++++++++++++++ packages/spec/src/contracts/index.ts | 3 ++ 6 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 packages/cloud-connection/src/cloud-binding.ts create mode 100644 packages/spec/src/contracts/cloud-binding-service.ts diff --git a/packages/cloud-connection/src/cloud-binding.ts b/packages/cloud-connection/src/cloud-binding.ts new file mode 100644 index 00000000000..918402fc12a --- /dev/null +++ b/packages/cloud-connection/src/cloud-binding.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `cloud-binding` service — this server's binding to the cloud control + * plane (cloud ADR-0008), as `ICloudBindingService` + * (`@objectstack/spec/contracts`) declares it. + * + * `CloudConnectionPlugin` registers it in `init()` and its own control-plane + * routes read the credential through it, so the rule below has one owner: + * the hosted push relay sender (`@objectstack/service-push`) asks this + * service and never re-spells it (ruling on #29920, Q1 A). + * + * Every member reads at call time. A self-hosted server binds while it runs, + * through the Setup page, and the bind takes effect without a restart. + */ + +import { resolveControlPlaneApiKey } from '@objectstack/core'; +import type { ICloudBindingService } from '@objectstack/spec/contracts'; +import { resolveCloudUrl } from './cloud-url.js'; +import type { ConnectionCredentialStore } from './connection-credential-store.js'; + +/** The kernel service name {@link createCloudBinding}'s answer is registered under. */ +export const CLOUD_BINDING_SERVICE = 'cloud-binding'; + +export interface CloudBindingOptions { + /** The plugin's `controlPlaneApiKey` option (the env→cloud service key); `OS_CLOUD_API_KEY` when absent. */ + controlPlaneApiKey?: string; + /** The plugin's `controlPlaneUrl` option; `OS_CLOUD_URL`, then the default, when absent. */ + controlPlaneUrl?: string; + /** Where a self-hosted bind persisted its `oscc_` bearer. */ + store: Pick; +} + +/** + * Build the binding service over the plugin's options and credential store. + * + * The credential is the env→cloud service key when one is set (a runtime the + * cloud hosts, whose connection the platform manages), else the `oscc_` + * bearer the device-code bind stored; `''` when neither. The same + * derivation `isCloudManaged` reads in `@objectstack/core` + * (`resolveControlPlaneApiKey`), so "platform-managed" and "which bearer" + * cannot disagree. + */ +export function createCloudBinding(options: CloudBindingOptions): ICloudBindingService { + const credential = (): string => + resolveControlPlaneApiKey(options.controlPlaneApiKey) || options.store.read()?.runtimeToken || ''; + return { + credential, + bound: () => credential() !== '', + controlPlaneUrl: () => resolveCloudUrl(options.controlPlaneUrl), + }; +} diff --git a/packages/cloud-connection/src/cloud-connection-plugin.ts b/packages/cloud-connection/src/cloud-connection-plugin.ts index 742a5c5fa4e..8fcd5d514ed 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.ts @@ -101,6 +101,8 @@ interface PluginContext { hook(event: string, handler: (...args: any[]) => any): void; getService(name: string): T; + /** Registers the `cloud-binding` service in `init()` (#29920). */ + registerService?(name: string, service: unknown): void; /** * The host kernel — read only for its ASYNC registry, which is what tells a * `tenancy` service that was never registered apart from one that broke @@ -147,6 +149,8 @@ import { import type { AuthSessionApi, IAuthService, IObjectQLEngine } from '@objectstack/spec/contracts'; import { inProcessSessionReadInput } from '@objectstack/types'; import { ConnectionCredentialStore } from './connection-credential-store.js'; +import { CLOUD_BINDING_SERVICE, createCloudBinding } from './cloud-binding.js'; +import type { ICloudBindingService } from '@objectstack/spec/contracts'; import { CLOUD_CONNECTION_CAPABILITY, CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js'; import { readJsonRequestBody } from './request-json-body.js'; @@ -292,15 +296,32 @@ export class CloudConnectionPlugin implements Plugin { readonly name = 'com.objectstack.cloud.connection'; readonly version = '0.3.0'; + /** Services init() registers on every path (ADR-0116). */ + readonly providesServices = [CLOUD_BINDING_SERVICE]; + private readonly cfg: CloudConnectionPluginConfig; private readonly store: ConnectionCredentialStore; + /** + * [#29920] This server's cloud binding: the ONE reader of the effective + * control-plane credential. Registered as the `cloud-binding` service, and + * the routes below read the same instance, never a copy of its rule. + */ + private readonly binding: ICloudBindingService; constructor(config: CloudConnectionPluginConfig = {}) { this.cfg = config; this.store = new ConnectionCredentialStore(config.credentialPath); + this.binding = createCloudBinding({ + controlPlaneApiKey: config.controlPlaneApiKey, + controlPlaneUrl: config.controlPlaneUrl, + store: this.store, + }); } - init = async (_ctx: PluginContext): Promise => { /* HTTP wiring on kernel:ready */ }; + /** Registers the `cloud-binding` service; the HTTP wiring waits for kernel:ready. */ + init = async (ctx: PluginContext): Promise => { + ctx.registerService?.(CLOUD_BINDING_SERVICE, this.binding); + }; start = async (ctx: PluginContext): Promise => { ctx.hook('kernel:ready', async () => { @@ -339,8 +360,10 @@ export class CloudConnectionPlugin implements Plugin { // cloud-hosted runtimes carry the env→cloud service key; a // self-hosted runtime presents the `oscc_…` bearer the bind flow // persisted. Lazy so a bind that completes while the runtime is - // up takes effect without a restart. - const credential = (): string => (platformManaged ? cloudApiKey : this.store.read()?.runtimeToken || ''); + // up takes effect without a restart. [#29920] Read through the + // `cloud-binding` service this plugin registers, the same instance + // the push relay sender reads, so the rule has one owner. + const credential = (): string => this.binding.credential(); const authHeaders = (): Record => { const cred = credential(); return cred ? { Authorization: `Bearer ${cred}` } : {}; diff --git a/packages/cloud-connection/src/cloud-url.ts b/packages/cloud-connection/src/cloud-url.ts index b1821cbd37b..bda002d7951 100644 --- a/packages/cloud-connection/src/cloud-url.ts +++ b/packages/cloud-connection/src/cloud-url.ts @@ -1,5 +1,10 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { + isControlPlaneDeclined as isControlPlaneDeclinedInCore, + isControlPlaneDeclinedSpelling as isDeclinedSpelling, +} from '@objectstack/core'; + /** * Shared marketplace / cloud control-plane defaults. * @@ -11,23 +16,14 @@ */ export const DEFAULT_CLOUD_URL = 'https://cloud.objectos.ai'; -/** - * The spellings by which a deployment declares "this runtime has no control - * plane" — the repo's one existing, documented, network-posture declaration. - * - * ONE definition, read in two directions: `resolveCloudUrl()` turns it into an - * empty URL, and {@link isControlPlaneDeclined} answers whether it was said at - * all. They were a single inline list until the telemetry posture (#10805) - * needed the second question; keeping two copies would let "what counts as - * off" drift between "no cloud calls" and "no client telemetry", which is - * precisely the pair that must never disagree. - */ -const CLOUD_DECLINED_SPELLINGS: readonly string[] = ['off', 'none', 'local', 'disabled']; - -/** Is this raw declaration one of the documented "no control plane" spellings? */ -function isDeclinedSpelling(raw: string): boolean { - return CLOUD_DECLINED_SPELLINGS.includes(raw.trim().toLowerCase()); -} +// The "no control plane" spellings live in `@objectstack/core` +// (`CLOUD_DECLINED_SPELLINGS`), beside `resolveControlPlaneApiKey`, so a +// package that cannot depend on this one (the push relay sender, #29920) reads +// the same definition. ONE definition, read in two directions here: +// `resolveCloudUrl()` turns it into an empty URL, and +// {@link isControlPlaneDeclined} answers whether it was said at all. Keeping +// two copies would let "what counts as off" drift between "no cloud calls" and +// "no client telemetry", which is precisely the pair that must never disagree. /** * Resolve the effective control-plane URL from an explicit constructor @@ -73,5 +69,5 @@ export function resolveCloudUrl(explicit?: string | null): string { * ask twice — see `RuntimeConfigPlugin.declinesControlPlane()`. */ export function isControlPlaneDeclined(explicit?: string | null): boolean { - return isDeclinedSpelling(explicit ?? process.env.OS_CLOUD_URL ?? ''); + return isControlPlaneDeclinedInCore(explicit); } diff --git a/packages/core/src/stack-auth.ts b/packages/core/src/stack-auth.ts index 655493786dd..0559b531222 100644 --- a/packages/core/src/stack-auth.ts +++ b/packages/core/src/stack-auth.ts @@ -134,6 +134,33 @@ export function resolveControlPlaneApiKey(configured?: string): string { return (configured ?? getEnv('OS_CLOUD_API_KEY') ?? '').trim(); } +/** + * The spellings by which a deployment declares "this runtime has no control + * plane" (`OS_CLOUD_URL=off`, …) — the repo's one documented network-posture + * declaration. ONE definition: `@objectstack/cloud-connection`'s + * `resolveCloudUrl` and `isControlPlaneDeclined` read it from here, and so + * does the hosted push relay sender (`@objectstack/service-push`, #29920), + * which cannot depend on cloud-connection. + */ +export const CLOUD_DECLINED_SPELLINGS: readonly string[] = ['off', 'none', 'local', 'disabled']; + +/** Is this raw declaration one of the documented "no control plane" spellings? */ +export function isControlPlaneDeclinedSpelling(raw: string): boolean { + return CLOUD_DECLINED_SPELLINGS.includes(raw.trim().toLowerCase()); +} + +/** + * Did this deployment DECLARE that it has no control plane? Pass the host's + * explicit argument to ask about that argument; pass nothing to ask about + * `OS_CLOUD_URL`. An empty or unset value is not a declaration. The reasoning + * for why this is not "the resolved URL is empty" lives on + * `isControlPlaneDeclined` in `@objectstack/cloud-connection`, which re-exports + * this function. + */ +export function isControlPlaneDeclined(explicit?: string | null): boolean { + return isControlPlaneDeclinedSpelling(explicit ?? getEnv('OS_CLOUD_URL') ?? ''); +} + /** * The memoized answer of {@link isCloudManaged} — its refusal included, so a * boot the fuse refused stays refused on every later read in the process. diff --git a/packages/spec/src/contracts/cloud-binding-service.ts b/packages/spec/src/contracts/cloud-binding-service.ts new file mode 100644 index 00000000000..401c87bb668 --- /dev/null +++ b/packages/spec/src/contracts/cloud-binding-service.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ICloudBindingService — this server's binding to the cloud control plane + * (cloud ADR-0008), as one read point for every service that presents it. + * + * Registered as the runtime service `cloud-binding` by + * `@objectstack/cloud-connection` (`CloudConnectionPlugin.init`). Not a + * `CoreServiceName` member: a server with no cloud connection composes no + * occupant, and a reader treats an absent service as "not bound". + * + * **One rule, one owner.** The effective credential is the env→cloud service + * key (`OS_CLOUD_API_KEY`, which the cloud injects into the runtimes it + * hosts) when one is set, else the `oscc_` runtime bearer a self-hosted + * server received from the device-code bind. That rule lives in + * cloud-connection only; its own control-plane routes read it through this + * same service, and a reader elsewhere (the hosted push relay sender in + * `@objectstack/service-push`) asks here and never re-spells it. + * + * **Read on every call.** A self-hosted server binds while it runs (the + * Setup page), so the answer can change without a restart. A reader asks at + * the moment it presents the credential, and never caches a verdict taken at + * boot. + * + * ⛔ The credential is a secret: a reader puts it in an `Authorization` + * header and nowhere else — not in a log line, an error text or a test + * snapshot. + */ +export interface ICloudBindingService { + /** + * The bearer this server presents to the cloud: the env→cloud service key + * when the platform manages the connection, else the bound `oscc_` runtime + * token. `''` when the server is not bound. + */ + credential(): string; + /** Whether {@link credential} answers a bearer right now. */ + bound(): boolean; + /** + * The control-plane base URL the binding is made against, without a + * trailing slash: the configured value, else `OS_CLOUD_URL`, else the + * ObjectStack-operated default. `''` when the deployment declared that it + * has no control plane (`OS_CLOUD_URL=off`, `none`, `local`, `disabled`). + */ + controlPlaneUrl(): string; +} diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index e158e09748c..cddb15ae562 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -88,3 +88,6 @@ export * from './seed-loader-service.js'; export * from './seed-settlement.js'; export * from './crypto-provider.js'; export * from './cluster-service.js'; +// This server's cloud binding (cloud ADR-0008), registered as `cloud-binding` +// by cloud-connection and read by the hosted push relay sender (#29920). +export * from './cloud-binding-service.js'; From c4b114999ee6ba2709c444698fb2a765848e0be9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:52:07 +0000 Subject: [PATCH 2/9] wip(service-push): send through the hosted relay with the cloud binding credential Claude-Session: https://claude.ai/code/session_01Utp6CZX77joBRgcruRQSMN Co-authored-by: Claude --- .../src/messaging-service-plugin.ts | 2 +- .../service-messaging/src/push-channel.ts | 72 ++++++ .../service-push/src/capability-arg.test.ts | 61 +++-- .../service-push/src/capability-arg.ts | 25 +- packages/services/service-push/src/index.ts | 13 +- .../services/service-push/src/push-plugin.ts | 120 +++++++--- .../services/service-push/src/push-service.ts | 24 +- .../service-push/src/transports/relay.ts | 217 ++++++++++++++++++ packages/spec/src/api/push-relay.zod.ts | 56 +++-- packages/spec/src/stack-email-sms.test.ts | 5 +- packages/spec/src/stack.zod.ts | 9 +- packages/spec/src/system/push-config.zod.ts | 31 +-- 12 files changed, 538 insertions(+), 97 deletions(-) create mode 100644 packages/services/service-push/src/transports/relay.ts diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 27c09da1777..383cf48a5cd 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -541,7 +541,7 @@ export class MessagingServicePlugin implements Plugin { ctx.hook('kernel:ready', async () => { service.registerChannelProvider('push', lazyChannelMount({ resolveTransport: getPush, - create: () => createPushChannel({ getPush, devices: service }), + create: () => createPushChannel({ getPush, devices: service, getData, getDefaultTemplateLocale }), announce: '[messaging] push channel registered (delivers to the recipient\'s enabled sys_user_device rows through the push service)', logger: ctx.logger, })); diff --git a/packages/services/service-messaging/src/push-channel.ts b/packages/services/service-messaging/src/push-channel.ts index f83c66bc963..381e8a938ba 100644 --- a/packages/services/service-messaging/src/push-channel.ts +++ b/packages/services/service-messaging/src/push-channel.ts @@ -1,7 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { PushDeliveryDataSchema, PushRouteSchema } from '@objectstack/spec/api'; +import type { IDataEngine } from '@objectstack/spec/contracts'; import type { UserDevice } from '@objectstack/spec/system'; +import { RECIPIENT_LOCALE_FIELD, USER_OBJECT, resolveRecipientLocale } from './recipient-locale.js'; +import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js'; import type { ChannelAvailability, ChannelAvailabilityQuery, @@ -29,6 +32,8 @@ export interface PushSenderSurface { title: string; body: string; data: Record; + /** The generic visible line a hosted relay delivery shows instead of the content, in the recipient's language. */ + notice?: string; }): Promise< | { status: 'sent'; messageId?: string } | { status: 'invalid_token'; reason: string } @@ -51,6 +56,43 @@ export interface PushChannelOptions { getPush(): PushSenderSurface | undefined; /** The device registry: the recipient's devices, and the dead-token retirement. */ devices: PushDeviceSurface; + /** + * The data engine the recipient's `sys_user.locale` is read from, for the + * generic notice line ({@link pushNoticeLine}). Absent, or a read that + * fails: the deployment default, then English. Never costs the delivery. + */ + getData?(): IDataEngine | undefined; + /** The deployment default locale (`II18nService.getDefaultLocale()`), probed per delivery. */ + getDefaultTemplateLocale?(): string | undefined; +} + +/** + * The generic visible line a hosted relay delivery shows, per locale. The + * relay is a third party to the notification, so its payload carries this + * line instead of the title and body (`PushRelayPayloadSchema.text`), in the + * recipient's language: the same chain every channel reads + * (`resolveRecipientLocale`, ruling on #29920 Q4 C). A locale with no line + * here takes its language's line, else English. + */ +export const PUSH_NOTICE_LINES: Readonly> = { + en: 'You have a new notification', + 'zh-CN': '您有一条新通知', + 'ja-JP': '新しい通知があります', + 'es-ES': 'Tienes una notificación nueva', +}; + +/** The notice line for `locale`: its own line, else its language's, else English. */ +export function pushNoticeLine(locale: string | undefined): string { + if (locale) { + const wanted = locale.toLowerCase(); + const keys = Object.keys(PUSH_NOTICE_LINES); + const exact = keys.find((k) => k.toLowerCase() === wanted); + if (exact) return PUSH_NOTICE_LINES[exact]!; + const language = wanted.split('-')[0]; + const sameLanguage = keys.find((k) => k.toLowerCase().split('-')[0] === language); + if (sameLanguage) return PUSH_NOTICE_LINES[sameLanguage]!; + } + return PUSH_NOTICE_LINES.en!; } /** The one token for "there is no transport", shared with `isAvailable()` (see sms-channel.ts). */ @@ -113,6 +155,34 @@ const ROUTE_MAX_BYTES = 1024; * `permanent`, exactly as the sms and email channels answer it. */ export function createPushChannel(opts: PushChannelOptions): MessagingChannel { + /** + * The recipient's language (#13881's chain: `sys_user.locale`, then the + * deployment default), read once per delivery for the notice line. A + * failed read falls to the deployment default: it never costs the push. + */ + async function recipientLocale(ctx: MessagingChannelContext, recipient: string): Promise { + const deploymentLocale = () => opts.getDefaultTemplateLocale?.(); + const data = opts.getData?.(); + let own: unknown; + if (data) { + try { + // The explicit system opt-in — see FAN_OUT_SYSTEM_CONTEXT: the + // recipient's locale, read for the delivery only. + const user = await data.findOne( + USER_OBJECT, + { where: { id: recipient }, fields: [RECIPIENT_LOCALE_FIELD] }, + { context: FAN_OUT_SYSTEM_CONTEXT }, + ); + own = (user as Record | null | undefined)?.[RECIPIENT_LOCALE_FIELD]; + } catch (err) { + ctx.logger.warn( + `[push] recipient locale lookup for '${recipient}' failed (${(err as Error).message}); the notice line takes the deployment default`, + ); + } + } + return resolveRecipientLocale(own, deploymentLocale); + } + return { id: 'push', @@ -157,6 +227,7 @@ export function createPushChannel(opts: PushChannelOptions): MessagingChannel { const title = truncateUtf8(plain(n.title), TITLE_MAX_BYTES); const body = truncateUtf8(plain(n.body), BODY_MAX_BYTES); const data = deliveryData(n.notificationId, n.actionUrl); + const notice = pushNoticeLine(await recipientLocale(ctx, delivery.recipient)); const sent: string[] = []; const dead: string[] = []; @@ -168,6 +239,7 @@ export function createPushChannel(opts: PushChannelOptions): MessagingChannel { title, body, data: withOrigin(data, device.origin), + notice, }); if (result.status === 'sent') { sent.push(result.messageId ?? ''); diff --git a/packages/services/service-push/src/capability-arg.test.ts b/packages/services/service-push/src/capability-arg.test.ts index 6c808f2fb25..655a57d087c 100644 --- a/packages/services/service-push/src/capability-arg.test.ts +++ b/packages/services/service-push/src/capability-arg.test.ts @@ -6,15 +6,17 @@ * the stack's `push` key, parsed, or a refusal before the kernel boots. * * The refusals are the point. A relay configuration (an absent key and `{}` - * included) has no sender yet, so serving it would mount the push channel and - * answer every push as if it were sent; and a compiled artifact can carry an - * inline secret that `defineStack` would have refused, so the boot judges it - * again. Each refusal names the remedy, and none echoes a secret value. + * included) on a deployment that declared it has no control plane can never + * deliver, so serving it would mount the push channel and answer every push as + * if it were sent; and a compiled artifact can carry an inline secret that + * `defineStack` would have refused, so the boot judges it again. Each refusal + * names the remedy, and none echoes a secret value. Anywhere else the relay + * boots: an unbound server binds while it runs. */ -import { describe, it, expect } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { resolvePushCapabilityArg } from './capability-arg.js'; -import { PushServicePlugin, PUSH_RELAY_NOT_SERVED_MESSAGE } from './push-plugin.js'; +import { PushServicePlugin, PUSH_RELAY_AIR_GAPPED_MESSAGE } from './push-plugin.js'; import { PushService } from './push-service.js'; const APNS = { teamId: 'TEAM123456', keyId: 'KEY1234567', topic: 'com.example.app', environment: 'production' as const, credentialRef: 'APNS_KEY' }; @@ -39,21 +41,48 @@ describe('resolvePushCapabilityArg — the direct transport is built', () => { }); }); -describe('resolvePushCapabilityArg — the relay is refused at boot', () => { - it.each([ - ['an absent push key', undefined], - ['null', null], - ['the empty block', {}], - ["transport: 'relay'", { transport: 'relay' }], - ['a staging relay endpoint', { transport: 'relay', endpoint: 'https://relay.staging.example.com' }], - ])('%s is refused, naming the missing sender and the direct transport', (_name, push) => { +const RELAY_CASES = [ + ['an absent push key', undefined], + ['null', null], + ['the empty block', {}], + ["transport: 'relay'", { transport: 'relay' }], + ['a staging relay endpoint', { transport: 'relay', endpoint: 'https://relay.staging.example.com' }], +] as const; + +describe('resolvePushCapabilityArg — the relay is built', () => { + beforeEach(() => vi.stubEnv('OS_CLOUD_URL', '')); + afterEach(() => vi.unstubAllEnvs()); + + it.each(RELAY_CASES)('%s hands the plugin the relay block, bound or not', (_name, push) => { + expect(resolvePushCapabilityArg(push).options.config.transport).toBe('relay'); + }); + + it('keeps an explicit relay endpoint', () => { + expect(resolvePushCapabilityArg({ endpoint: 'https://relay.staging.example.com' }).options.config).toEqual({ + transport: 'relay', + endpoint: 'https://relay.staging.example.com', + }); + }); +}); + +describe('resolvePushCapabilityArg — the relay is refused on a deployment with no control plane', () => { + afterEach(() => vi.unstubAllEnvs()); + + it.each(RELAY_CASES)('%s is refused under OS_CLOUD_URL=off, naming the air gap, the inbox and the direct transport', (_name, push) => { + vi.stubEnv('OS_CLOUD_URL', 'off'); const read = () => resolvePushCapabilityArg(push); - expect(read).toThrow(PUSH_RELAY_NOT_SERVED_MESSAGE); - expect(read).toThrow(/its sender has not shipped/); + expect(read).toThrow(PUSH_RELAY_AIR_GAPPED_MESSAGE); + expect(read).toThrow(/declared it has no control plane/); + expect(read).toThrow(/in-app inbox/); expect(read).toThrow(/transport: 'direct'/); // Where the operator writes it on this boot path. expect(read).toThrow(/stack definition's push key/); }); + + it('a direct block still boots under OS_CLOUD_URL=off: it needs no control plane', () => { + vi.stubEnv('OS_CLOUD_URL', 'off'); + expect(resolvePushCapabilityArg({ apns: APNS }).options.config.transport).toBe('direct'); + }); }); describe('resolvePushCapabilityArg — a configuration the schema refuses fails the boot', () => { diff --git a/packages/services/service-push/src/capability-arg.ts b/packages/services/service-push/src/capability-arg.ts index 8638e2857a5..b89a9586f30 100644 --- a/packages/services/service-push/src/capability-arg.ts +++ b/packages/services/service-push/src/capability-arg.ts @@ -3,7 +3,7 @@ /** * What `PushServicePlugin` is constructed with on a served boot: the stack * definition's `push` key, parsed by `PushServiceConfigSchema` — and refused, - * before the kernel boots, when this package cannot deliver through it. + * before the kernel boots, when this package can never deliver through it. * * ## Why this lives here, and not in `@objectstack/cli` or `@objectstack/core` * @@ -23,10 +23,13 @@ * operator meets the refusal as the `push` capability failing to start, with * the reason, before any service is registered: * - * - **The relay transport**, which is also what an absent `push` key and an - * empty one read as. Its sender has not shipped, so nothing would deliver - * through it; booting would mount the `push` channel and every push would - * look sent. ({@link PUSH_RELAY_NOT_SERVED_MESSAGE}.) + * - **The relay transport on a deployment that declared it has no control + * plane** (`OS_CLOUD_URL=off`, …), the relay being also what an absent + * `push` key and an empty one read as. The relay is hosted by the cloud, so + * nothing could ever deliver through it there; booting would mount the + * `push` channel and every push would look sent. + * ({@link PUSH_RELAY_AIR_GAPPED_MESSAGE}.) Anywhere else the relay boots, + * bound or not: an unbound server binds while it runs. * - **A configuration the schema refuses**, an inline secret first among * them. `defineStack` refuses it at authoring, but a compiled artifact or a * plain config object reaches the boot without passing it, so the boot @@ -38,8 +41,9 @@ * transport reads when it first signs. */ +import { isControlPlaneDeclined } from '@objectstack/core'; import { PushServiceConfigSchema, type PushServiceConfigParsed } from '@objectstack/spec/system'; -import { PUSH_RELAY_NOT_SERVED_MESSAGE } from './push-plugin.js'; +import { PUSH_RELAY_AIR_GAPPED_MESSAGE } from './push-plugin.js'; /** Constructor options for `PushServicePlugin`, as the capability loop builds them. */ export interface PushCapabilityArg { @@ -48,8 +52,9 @@ export interface PushCapabilityArg { /** * Resolve `PushServicePlugin` constructor options from the stack definition's - * `push` key. Throws on a relay configuration (absent and `{}` included) and on - * any configuration `PushServiceConfigSchema` refuses. + * `push` key. Throws on a relay configuration (absent and `{}` included) when + * the deployment declared it has no control plane, and on any configuration + * `PushServiceConfigSchema` refuses. */ export function resolvePushCapabilityArg(cfgPush: unknown = {}): PushCapabilityArg { const raw = cfgPush ?? {}; @@ -63,9 +68,9 @@ export function resolvePushCapabilityArg(cfgPush: unknown = {}): PushCapabilityA ); } const config = verdict.data; - if (config.transport !== 'direct') { + if (config.transport === 'relay' && isControlPlaneDeclined()) { throw new Error( - `${PUSH_RELAY_NOT_SERVED_MESSAGE} On objectstack serve this is the stack definition's push key, and an absent ` + `${PUSH_RELAY_AIR_GAPPED_MESSAGE} On objectstack serve this is the stack definition's push key, and an absent ` + 'key reads as the relay too.', ); } diff --git a/packages/services/service-push/src/index.ts b/packages/services/service-push/src/index.ts index cb90c0bb2c6..ab449ed657a 100644 --- a/packages/services/service-push/src/index.ts +++ b/packages/services/service-push/src/index.ts @@ -9,7 +9,7 @@ export { type PushSendResult, type PushServiceOptions, } from './push-service.js'; -export { PushServicePlugin, PUSH_RELAY_NOT_SERVED_MESSAGE, type PushServicePluginOptions } from './push-plugin.js'; +export { PushServicePlugin, PUSH_RELAY_AIR_GAPPED_MESSAGE, type PushServicePluginOptions } from './push-plugin.js'; export { resolvePushCapabilityArg, type PushCapabilityArg } from './capability-arg.js'; export { envPushCredentialResolver, @@ -18,3 +18,14 @@ export { } from './credential.js'; export { ApnsPushTransport, APNS_ORIGINS, type ApnsPushTransportOptions } from './transports/apns.js'; export { FcmPushTransport, FCM_ORIGIN, GOOGLE_TOKEN_URI, type FcmPushTransportOptions } from './transports/fcm.js'; +export { + RelayPushSender, + relayTransport, + PUSH_RELAY_PATH, + CLOUD_BINDING_SERVICE, + DEFAULT_PUSH_NOTICE, + RELAY_NOT_BOUND, + RELAY_ADDRESS_NOT_PUBLISHED, + RELAY_BINDING_REFUSED, + type RelayPushSenderOptions, +} from './transports/relay.js'; diff --git a/packages/services/service-push/src/push-plugin.ts b/packages/services/service-push/src/push-plugin.ts index c19951194f7..822eb53a2e2 100644 --- a/packages/services/service-push/src/push-plugin.ts +++ b/packages/services/service-push/src/push-plugin.ts @@ -1,11 +1,14 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; +import { isControlPlaneDeclined } from '@objectstack/core'; +import type { ICloudBindingService } from '@objectstack/spec/contracts'; import { PushServiceConfigSchema, type PushServiceConfig, type PushVendor } from '@objectstack/spec/system'; import { PushService, type IPushTransport } from './push-service.js'; import { envPushCredentialResolver, type PushCredentialResolver } from './credential.js'; import { ApnsPushTransport } from './transports/apns.js'; import { FcmPushTransport } from './transports/fcm.js'; +import { CLOUD_BINDING_SERVICE, RelayPushSender, relayTransport } from './transports/relay.js'; export interface PushServicePluginOptions { /** @@ -13,45 +16,55 @@ export interface PushServicePluginOptions { * (`@objectstack/spec/system`) when the plugin initialises, so an inline * secret or a misspelt key fails the boot with the schema's own guidance. * - * Only the direct transport is served: `transport: 'direct'` (or a block - * with an `apns` / `fcm` block and no `transport`), one vendor transport per - * block; a vendor with no block is not delivered to. The hosted relay - * (`transport: 'relay'`, and the default `{}` that reads as it) is refused - * at init until its sender ships (#29920): see - * {@link PUSH_RELAY_NOT_SERVED_MESSAGE}. + * Both transports are served. The hosted relay (`transport: 'relay'`, and + * the default `{}` that reads as it) sends through the cloud's push relay, + * authenticated by this server's cloud binding (the `cloud-binding` + * service, `@objectstack/cloud-connection`); it is refused at init only on + * a deployment that declared it has no control plane + * ({@link PUSH_RELAY_AIR_GAPPED_MESSAGE}). The direct transport + * (`transport: 'direct'`, or a block with an `apns` / `fcm` block and no + * `transport`) builds one vendor transport per block; a vendor with no + * block is not delivered to. */ config?: PushServiceConfig; /** - * Resolves each block's `credentialRef` to its secret when the transport - * first signs. Default: the open tier's rule, the name of an environment - * variable ({@link envPushCredentialResolver}). + * Resolves each direct block's `credentialRef` to its secret when the + * transport first signs. Default: the open tier's rule, the name of an + * environment variable ({@link envPushCredentialResolver}). The relay + * names no secret: its bearer is the cloud binding credential. */ resolveCredential?: PushCredentialResolver; /** * Transports supplied by the host, per vendor; each wins over that - * vendor's `config` block. For a vendor endpoint the built-in transports - * do not reach, and for tests. + * vendor's `config` block or the relay. For a vendor endpoint the built-in + * transports do not reach, and for tests. */ transports?: Partial>; + /** `fetch` for the relay sender, injectable for a test. Not authorable. */ + relayFetch?: typeof fetch; } /** - * The refusal a relay configuration meets at init. The relay is the schema's - * default (cloud#3519), but its sender is not served yet (follow-up card - * #29920; the text below is printed at the operator, so it names no tracker - * id, per check:doc-authoring): booting a - * `push` service that would deliver nothing is worse than refusing, because - * the channel would mount and every push would look sent. + * The refusal a relay configuration meets at init on a deployment that + * declared it has no control plane (`OS_CLOUD_URL=off`, `none`, `local` or + * `disabled`). The relay is hosted by the cloud and there is no self-hosted + * relay, so such a deployment has no system push (ADR-0012 §6.1, the air-gap + * rule): booting a `push` service that can never deliver is worse than + * refusing, because the channel would mount and every push would look sent. + * The text is printed at the operator, so it names no tracker id + * (check:doc-authoring). */ -export const PUSH_RELAY_NOT_SERVED_MESSAGE = +export const PUSH_RELAY_AIR_GAPPED_MESSAGE = "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. For a white-label ' - + "build signed with your own APNs key or Firebase project, write transport: 'direct' with an apns and/or fcm block, " - + 'each naming its secret by credentialRef.'; + + 'cannot be reached from a deployment that declared it has no control plane (OS_CLOUD_URL is off, none, local ' + + 'or disabled): the relay is hosted by the cloud, so an air-gapped deployment has no system push. Users read ' + + 'their notifications in the in-app inbox, which needs no push service; leave push out of the stack\'s requires. ' + + "For a white-label build signed with your own APNs key or Firebase project, write transport: 'direct' with an " + + 'apns and/or fcm block, each naming its secret by credentialRef.'; /** * PushServicePlugin — registers the kernel's `push` service, the transport - * half of the `push` notification channel (ADR-0012 §4, §6; amended + * half of the `push` notification channel (ADR-0012 §4, §6, §6.1; amended * 2026-11-03: the transports ship here, the channel in service-messaging). * * The `push` channel itself lives in `@objectstack/service-messaging` @@ -62,10 +75,20 @@ export const PUSH_RELAY_NOT_SERVED_MESSAGE = * user. * * Configuration is the constructor's `config`, the operator's - * `PushServiceConfigSchema` value. The direct transport is served; the hosted - * relay, the schema's default, is refused at init until #29920 ships it. Secrets are named by `credentialRef` and - * resolved through {@link PushServicePluginOptions.resolveCredential}, never - * written inline (ADR-0143 D3). + * `PushServiceConfigSchema` value. + * + * - **The hosted relay** (the default) sends each push to the cloud's push + * relay as an ID-only `PushRelayRequestSchema`, with this server's cloud + * binding credential as the bearer, read from the `cloud-binding` service + * on every send (ruling on #29920: Q1 A, Q2 A, Q3 A). While the server is + * not bound the relay transport is unavailable: the service lists no + * vendor, the push channel reports `transport_not_configured`, and the boot + * says so once at `warn`. A bind done while the server runs takes effect + * without a restart. + * - **The direct transport** sends with the operator's own APNs key and + * Firebase project. Secrets are named by `credentialRef` and resolved + * through {@link PushServicePluginOptions.resolveCredential}, never written + * inline (ADR-0143 D3). * * @example * ```ts @@ -88,20 +111,39 @@ export class PushServicePlugin implements Plugin { type = 'standard' as const; private service?: PushService; + private relay?: RelayPushSender; constructor(private readonly options: PushServicePluginOptions = {}) {} async init(ctx: PluginContext): Promise { const config = PushServiceConfigSchema.parse(this.options.config ?? {}); - if (config.transport !== 'direct') throw new Error(PUSH_RELAY_NOT_SERVED_MESSAGE); - const resolveCredential = this.options.resolveCredential ?? envPushCredentialResolver; const transports: Partial> = {}; - if (config.apns) transports.apns = new ApnsPushTransport({ config: config.apns, resolveCredential }); - if (config.fcm) transports.fcm = new FcmPushTransport({ config: config.fcm, resolveCredential }); + if (config.transport === 'relay') { + if (isControlPlaneDeclined()) throw new Error(PUSH_RELAY_AIR_GAPPED_MESSAGE); + const relay = new RelayPushSender({ + config, + // Resolved on every send, never at init: the binding service is + // registered by another plugin, and the bind itself happens + // while the server runs. + binding: () => ctx.getService(CLOUD_BINDING_SERVICE), + fetch: this.options.relayFetch, + }); + this.relay = relay; + transports.apns = relayTransport('apns', relay); + transports.fcm = relayTransport('fcm', relay); + } else { + const resolveCredential = this.options.resolveCredential ?? envPushCredentialResolver; + if (config.apns) transports.apns = new ApnsPushTransport({ config: config.apns, resolveCredential }); + if (config.fcm) transports.fcm = new FcmPushTransport({ config: config.fcm, resolveCredential }); + } Object.assign(transports, this.options.transports ?? {}); this.service = new PushService({ transports, logger: ctx.logger }); ctx.registerService('push', this.service); + if (this.relay) { + ctx.logger.info('PushServicePlugin: push service registered (apns, fcm through the hosted push relay)'); + return; + } const vendors = this.service.vendors(); ctx.logger.info( vendors.length > 0 @@ -110,6 +152,24 @@ export class PushServicePlugin implements Plugin { ); } + /** + * With the relay, says once, when every plugin has registered, whether the + * server is bound: an unbound server keeps booting, and its pushes wait for + * the bind. + */ + async start(ctx: PluginContext): Promise { + const relay = this.relay; + if (!relay || typeof ctx.hook !== 'function') return; + ctx.hook('kernel:ready', async () => { + if (relay.bound()) return; + ctx.logger.warn( + 'PushServicePlugin: this server is not bound to the cloud, so the hosted push relay delivers nothing until ' + + 'it is. Bind it on the Cloud connection setup page; the bind takes effect without a restart. Until then ' + + 'the push channel reports transport_not_configured, and notifications reach users in the in-app inbox.', + ); + }); + } + async destroy(): Promise { await this.service?.close(); } diff --git a/packages/services/service-push/src/push-service.ts b/packages/services/service-push/src/push-service.ts index 5092a4e237f..c553d7a9087 100644 --- a/packages/services/service-push/src/push-service.ts +++ b/packages/services/service-push/src/push-service.ts @@ -22,6 +22,14 @@ export interface PushMessage { readonly body: string; /** Delivery data for the shell: string values only. */ readonly data: Readonly>; + /** + * The generic visible line a hosted relay delivery shows instead of the + * title and body, in the recipient's language (the `push` channel picks it + * by `resolveRecipientLocale`). The relay transport sends it in place of + * the content, which never leaves the server through the relay; the direct + * transports ignore it. Absent: the English line. + */ + readonly notice?: string; } /** @@ -41,9 +49,16 @@ export type PushSendResult = | { readonly status: 'invalid_token'; readonly reason: string } | { readonly status: 'failed'; readonly error: string; readonly retryable: boolean }; -/** A vendor transport: APNs or FCM. */ +/** A vendor transport: APNs or FCM, direct or through the hosted relay. */ export interface IPushTransport { readonly vendor: PushVendor; + /** + * Whether the transport can deliver right now, read on every call. Absent: + * always. The relay transport answers `false` while the server is not + * bound to the cloud, so {@link IPushService.vendors} leaves its vendor + * out and the push channel reports the deployment unable to push. + */ + available?(): boolean; send(message: PushMessage): Promise; /** Release held connections (the APNs HTTP/2 session). */ close?(): Promise | void; @@ -59,7 +74,7 @@ export interface IPushTransport { * device registry and retires a token this service reports `invalid_token`. */ export interface IPushService { - /** The vendors this deployment has a transport for. Empty: push delivers nothing. */ + /** The vendors this deployment has an available transport for, read on every call. Empty: push delivers nothing. */ vendors(): PushVendor[]; /** Send one push. Never throws: a transport error is answered `failed`. */ send(message: PushMessage): Promise; @@ -85,7 +100,10 @@ export class PushService implements IPushService { constructor(private readonly opts: PushServiceOptions) {} vendors(): PushVendor[] { - return (Object.keys(this.opts.transports) as PushVendor[]).filter((v) => this.opts.transports[v]); + return (Object.keys(this.opts.transports) as PushVendor[]).filter((v) => { + const transport = this.opts.transports[v]; + return transport !== undefined && (transport.available?.() ?? true); + }); } async send(message: PushMessage): Promise { diff --git a/packages/services/service-push/src/transports/relay.ts b/packages/services/service-push/src/transports/relay.ts new file mode 100644 index 00000000000..e8864ce45fb --- /dev/null +++ b/packages/services/service-push/src/transports/relay.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { + PushRelayErrorResponseSchema, + PushRelayRequestSchema, + PushRelayResponseSchema, + type PushRelayPayload, + type PushRelayRequest, +} from '@objectstack/spec/api'; +import type { ICloudBindingService } from '@objectstack/spec/contracts'; +import type { PushRelayTransportConfigParsed, PushVendor } from '@objectstack/spec/system'; +import type { IPushTransport, PushMessage, PushSendResult } from '../push-service.js'; + +/** + * The path the hosted push relay is served at, under the control-plane base + * URL (`ICloudBindingService.controlPlaneUrl()`). The relay is served by the + * cloud's control plane (objectstack-ai/cloud#3519, ruling 7B), at + * `POST /api/v1/cloud/push/send`, behind the same runtime-credential gate as + * the cloud's other runtime routes. The maintainer's ruling on #29920 (Q3 A) + * puts the constant here and not in `packages/spec`: ADR-0012 §6.1 keeps the + * production relay address out of the spec. + */ +export const PUSH_RELAY_PATH = '/api/v1/cloud/push/send'; + +/** The kernel service the binding credential is read from (`ICloudBindingService`, `@objectstack/cloud-connection`). */ +export const CLOUD_BINDING_SERVICE = 'cloud-binding'; + +/** + * The visible line a relay delivery shows when the caller names none: the + * English one. The `push` channel picks the line in the recipient's language + * (`PushMessage.notice`); this is the floor for any other caller. + */ +export const DEFAULT_PUSH_NOTICE = 'You have a new notification'; + +/** The error-text prefixes a relay send answers with, so a log or a test reads the cause, not the prose. */ +export const RELAY_NOT_BOUND = 'relay_not_bound'; +export const RELAY_ADDRESS_NOT_PUBLISHED = 'relay_address_not_published'; +export const RELAY_BINDING_REFUSED = 'relay_binding_refused'; + +export interface RelayPushSenderOptions { + /** The parsed relay block of `PushServiceConfigSchema`. */ + config: PushRelayTransportConfigParsed; + /** + * The binding service, resolved on every call: `undefined` when no + * `cloud-binding` service is registered, which reads as "not bound". + */ + binding: () => ICloudBindingService | undefined; + /** Per-request timeout. Default 10 s. */ + timeoutMs?: number; + /** `fetch`, injectable for a test. */ + fetch?: typeof fetch; +} + +/** + * The hosted push relay sender (ADR-0012 §6.1, cloud#3519): one + * `PushRelayRequestSchema` POST per push, with one delivery for the push's + * device token, authenticated by this server's cloud binding credential. + * + * **The credential.** Read from the `cloud-binding` service on every send, + * never held, so a bind done while the server runs takes effect at once. It + * goes into the `Authorization` header and nowhere else: no log line and no + * error text this sender writes carries it. + * + * **ID-only.** The delivery's payload is the push delivery data + * (`PushMessage.data`) and one generic visible line (`PushMessage.notice`); + * the notification's title and body never leave the server. The request is + * parsed with `PushRelayRequestSchema` before it is sent, so a content key in + * the data is refused here rather than carried. + * + * **Answers.** A 200 answers each delivery: `ok` is sent, `invalid` is a dead + * token (the channel retires the device row), `retry` is a retryable failure. + * A 401 refuses the whole request: not retryable, naming the lapsed binding. + * A 429 is retryable when the relay says so (`retryable`, pinned `true` on + * `RATE_LIMIT_EXCEEDED`); a 5xx is retryable; any other refusal is not. + */ +export class RelayPushSender { + private readonly timeoutMs: number; + private readonly fetch: typeof fetch; + + constructor(private readonly opts: RelayPushSenderOptions) { + this.timeoutMs = opts.timeoutMs ?? 10_000; + this.fetch = opts.fetch ?? fetch; + } + + /** Whether the server is bound to the cloud right now. Read on every call. */ + bound(): boolean { + return this.credential() !== ''; + } + + /** The relay URL: the configured `endpoint`, else the control plane's relay path; `''` when neither is known. */ + endpoint(): string { + if (this.opts.config.endpoint) return this.opts.config.endpoint; + const base = (this.binding()?.controlPlaneUrl() ?? '').replace(/\/+$/, ''); + return base ? `${base}${PUSH_RELAY_PATH}` : ''; + } + + async send(message: PushMessage): Promise { + const credential = this.credential(); + if (!credential) { + return { + status: 'failed', + error: `${RELAY_NOT_BOUND}: the server is not bound to cloud, so the hosted push relay cannot be reached; ` + + 'bind it on the Cloud connection setup page', + retryable: false, + }; + } + const url = this.endpoint(); + if (!url) { + return { + status: 'failed', + error: `${RELAY_ADDRESS_NOT_PUBLISHED}: the push relay address is not published for this deployment ` + + '(no control-plane URL); write the relay block\'s endpoint', + retryable: false, + }; + } + + const request: PushRelayRequest = { + deliveries: [{ + token: message.token, + vendor: message.vendor, + payload: { data: { ...message.data } as PushRelayPayload['data'], text: message.notice?.trim() || DEFAULT_PUSH_NOTICE }, + priority: 'high', + }], + }; + const parsed = PushRelayRequestSchema.safeParse(request); + if (!parsed.success) { + const where = parsed.error.issues + .map((i) => `${i.path.map(String).join('.') || '(root)'}: ${i.message}`) + .join('; '); + return { status: 'failed', error: `relay_payload_refused: ${where}`, retryable: false }; + } + + const res = await this.fetch(url, { + method: 'POST', + headers: { authorization: `Bearer ${credential}`, 'content-type': 'application/json' }, + body: JSON.stringify(parsed.data), + signal: AbortSignal.timeout(this.timeoutMs), + }); + const text = await res.text(); + + if (res.status === 200) { + const answer = PushRelayResponseSchema.safeParse(readJson(text)); + const result = answer.success + ? answer.data.results.find((r) => r.token === message.token) ?? answer.data.results[0] + : undefined; + if (!result) { + return { status: 'failed', error: 'relay_answer_unreadable: the push relay answered 200 with no result for the delivery', retryable: false }; + } + if (result.status === 'ok') return { status: 'sent' }; + if (result.status === 'invalid') return { status: 'invalid_token', reason: 'relay: invalid' }; + return { status: 'failed', error: 'relay_retry: the push relay reported a transient vendor failure', retryable: true }; + } + + const refusal = PushRelayErrorResponseSchema.safeParse(readJson(text)); + const error = refusal.success ? refusal.data.error : undefined; + if (res.status === 401) { + const reason = error?.code === 'UNAUTHENTICATED' ? ` (${error.details.reason})` : ''; + return { + status: 'failed', + error: `${RELAY_BINDING_REFUSED}: the push relay refused this server's cloud binding${reason}; ` + + 'the binding has lapsed, bind the server to the cloud again', + retryable: false, + }; + } + if (res.status === 429) { + const code = error?.code ?? 'HTTP 429'; + return { + status: 'failed', + error: `the push relay answered 429 ${code}`, + retryable: error ? error.retryable : true, + }; + } + return { + status: 'failed', + error: `the push relay answered HTTP ${res.status}`, + retryable: res.status >= 500, + }; + } + + private binding(): ICloudBindingService | undefined { + try { + return this.opts.binding(); + } catch { + return undefined; + } + } + + private credential(): string { + try { + return this.binding()?.credential() ?? ''; + } catch { + return ''; + } + } +} + +/** + * One vendor's transport over the shared relay sender: the push service + * keeps one transport per vendor, and the relay delivers to both. Available + * only while the server is bound, so the push channel reports the deployment + * unable to push (`transport_not_configured`) until the bind is done. + */ +export function relayTransport(vendor: PushVendor, sender: RelayPushSender): IPushTransport { + return { + vendor, + available: () => sender.bound(), + send: (message) => sender.send(message), + }; +} + +function readJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} diff --git a/packages/spec/src/api/push-relay.zod.ts b/packages/spec/src/api/push-relay.zod.ts index f3ab4011573..362709bc91c 100644 --- a/packages/spec/src/api/push-relay.zod.ts +++ b/packages/spec/src/api/push-relay.zod.ts @@ -35,10 +35,12 @@ import { PushDeliveryDataSchema } from './push-delivery.zod'; * from the server after the tap, by its id. To the relay the payload is * opaque: it puts it in the vendor message unchanged. * - * **Declared ahead of its producer.** No push channel delivers yet, so - * nothing sends this request today; each schema here carries a - * `[PLANNED — not yet served]` marker in its `.describe()` until the push - * channel ships. + * **Served.** `@objectstack/service-push` sends this request: its relay + * transport (`RelayPushSender`) builds one delivery per device token, parses + * the request with {@link PushRelayRequestSchema} before it leaves the + * server, and reads the answer with {@link PushRelayResponseSchema} and + * {@link PushRelayErrorResponseSchema}. The relay that receives it is served + * by the cloud's control plane (objectstack-ai/cloud#3519). */ /** Guidance for a notification-content key written into the ID-only payload. */ @@ -56,6 +58,27 @@ export const PushRelayPrioritySchema = z.enum(['high', 'normal']) .describe('Vendor delivery priority: high delivers at once and wakes the device (APNs 10, FCM high); normal may wait for the device power state (APNs 5, FCM normal)'); export type PushRelayPriority = z.input; +/** + * The push delivery data as the relay payload carries it: the members of + * `PushDeliveryDataSchema`, judged strictly. The relay forwards the payload + * unchanged, so a key the data does not declare (a notification `title`, say) + * is refused here rather than stripped: a stripped key would read as carried. + */ +const PushRelayPayloadDataSchema = lazySchema(() => strictObject( + { + surface: 'the push relay payload data', + history: 'Judged strictly from the first relay sender on; before it, no relay payload was produced.', + guidance: { + title: PAYLOAD_CONTENT_GUIDANCE, + body: PAYLOAD_CONTENT_GUIDANCE, + subtitle: PAYLOAD_CONTENT_GUIDANCE, + message: PAYLOAD_CONTENT_GUIDANCE, + content: PAYLOAD_CONTENT_GUIDANCE, + }, + }, + PushDeliveryDataSchema.shape, +)); + /** The payload of one delivery: the push delivery data and one generic visible line. */ export const PushRelayPayloadSchema = lazySchema(() => strictObject( { @@ -70,16 +93,16 @@ export const PushRelayPayloadSchema = lazySchema(() => strictObject( }, }, { - data: PushDeliveryDataSchema.describe( - 'The push delivery data: the notification id, its same-server route and the origin of the sending server. ' - + 'The relay puts it in the vendor message unchanged', + data: PushRelayPayloadDataSchema.describe( + 'The push delivery data: the notification id, its same-server route and the origin of the sending server, and ' + + 'no other key. The relay puts it in the vendor message unchanged', ), text: z.string().min(1).describe( - 'One generic visible line the device shows (for example "You have a new notification"), in the device locale. ' - + 'Never the notification title or body', + 'One generic visible line the device shows (for example "You have a new notification"), in the recipient\'s ' + + 'language. Never the notification title or body', ), }, -).describe('[PLANNED — not yet served] The ID-only payload of one relay delivery: the push delivery data and one generic visible line, with no title or body. Opaque to the relay')); +).describe('The ID-only payload of one relay delivery: the push delivery data and one generic visible line, with no title or body. Opaque to the relay')); export type PushRelayPayload = z.input; /** One delivery: a device token, its vendor, the payload and the priority. */ @@ -94,7 +117,7 @@ export const PushRelayDeliverySchema = lazySchema(() => strictObject( payload: PushRelayPayloadSchema.describe('The ID-only payload'), priority: PushRelayPrioritySchema.describe('Vendor delivery priority'), }, -).describe('[PLANNED — not yet served] One relay delivery: a device token, its vendor, the ID-only payload and the delivery priority')); +).describe('One relay delivery: a device token, its vendor, the ID-only payload and the delivery priority')); export type PushRelayDelivery = z.input; /** @@ -111,7 +134,7 @@ export const PushRelayRequestSchema = lazySchema(() => strictObject( 'The deliveries, one per device token. The response answers each one, in the same order', ), }, -).describe('[PLANNED — not yet served] The request a server sends the hosted push relay: one delivery per device token. Authenticated by the server cloud binding credential as its bearer token')); +).describe('The request a server sends the hosted push relay: one delivery per device token. Authenticated by the server cloud binding credential as its bearer token')); export type PushRelayRequest = z.input; /** @@ -132,7 +155,7 @@ export type PushRelayResultStatus = z.input; export const PushRelayResultSchema = lazySchema(() => z.object({ token: z.string().min(1).describe('The device token of the delivery this result answers'), status: PushRelayResultStatusSchema.describe('What happened to the delivery'), -}).describe('[PLANNED — not yet served] The relay answer for one delivery: its device token and its outcome')); +}).describe('The relay answer for one delivery: its device token and its outcome')); export type PushRelayResult = z.input; /** @@ -144,7 +167,7 @@ export const PushRelayResponseSchema = lazySchema(() => z.object({ results: z.array(PushRelayResultSchema).describe( 'One result per delivery, in the order of the request deliveries', ), -}).describe('[PLANNED — not yet served] The push relay answer: one result per delivery token, ok, invalid or retry')); +}).describe('The push relay answer: one result per delivery token, ok, invalid or retry')); export type PushRelayResponse = z.input; /** @@ -180,6 +203,7 @@ function rateLimitError() { return EnhancedApiErrorSchema.extend({ code: z.literal('RATE_LIMIT_EXCEEDED').describe('The server sent the relay too many requests'), httpStatus: z.literal(429).describe('HTTP status code'), + retryable: z.literal(true).default(true).describe('Always true: the same request succeeds once retryAfterSeconds has passed'), retryAfterSeconds: z.number().int().positive().describe('Seconds to wait before sending again'), helpText: z.string().min(1).describe('The next step: send again after retryAfterSeconds'), }); @@ -210,7 +234,7 @@ export const PushRelayErrorSchema = lazySchema(() => z.discriminatedUnion('code' unauthenticatedError(), rateLimitError(), quotaError(), -]).describe('[PLANNED — not yet served] A push relay error: UNAUTHENTICATED (401, bad or revoked binding), RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED (429), each with its next step in helpText')); +]).describe('A push relay error: UNAUTHENTICATED (401, bad or revoked binding), RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED (429), each with its next step in helpText')); export type PushRelayError = z.input; /** Post-parse shape of {@link PushRelayError} — defaults applied (ADR-0122). */ export type PushRelayErrorParsed = z.infer; @@ -218,7 +242,7 @@ export type PushRelayErrorParsed = z.infer; /** The relay's error response: the house `ErrorResponseSchema` envelope around a {@link PushRelayErrorSchema}. */ export const PushRelayErrorResponseSchema = lazySchema(() => ErrorResponseSchema.extend({ error: PushRelayErrorSchema.describe('Error details'), -}).describe('[PLANNED — not yet served] The push relay error response: the house error envelope, for 401 (bad or revoked binding) and 429 (rate limit or quota)')); +}).describe('The push relay error response: the house error envelope, for 401 (bad or revoked binding) and 429 (rate limit or quota)')); export type PushRelayErrorResponse = z.input; /** Post-parse shape of {@link PushRelayErrorResponse} — defaults applied (ADR-0122). */ export type PushRelayErrorResponseParsed = z.infer; diff --git a/packages/spec/src/stack-email-sms.test.ts b/packages/spec/src/stack-email-sms.test.ts index 9764a93fd72..7d22a697523 100644 --- a/packages/spec/src/stack-email-sms.test.ts +++ b/packages/spec/src/stack-email-sms.test.ts @@ -250,8 +250,9 @@ describe('compose — an identical configuration passes, a different one is name // The top-level `push` key is `PushServiceConfigSchema` itself, read by // `resolvePushCapabilityArg` (`@objectstack/service-push`) when the stack // declares `requires: ['push']`. Authoring accepts both transports and the -// absence: refusing the not-yet-served relay is the boot's job, which names the -// missing sender (pinned in the CLI and in `@objectstack/verify`). What +// absence: refusing the relay where it can never deliver (a deployment that +// declared no control plane) is the boot's job (pinned in the CLI and in +// `@objectstack/verify`). What // authoring refuses is an inline secret, by key name or inside a // `credentialRef`, because the stack is compiled into the published artifact. diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index e249bb9759f..35eb80259ae 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1139,14 +1139,15 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { * mount the `push` capability (`requires: ['push']`); nothing reads it * without that token. Secrets are named by `credentialRef` (ADR-0143 D3), * never written inline: the stack definition is compiled into the published - * artifact. Absent reads as `{}`, the hosted relay, which the boot refuses - * until the relay sender ships; a direct block is what is served. + * artifact. Absent reads as `{}`, the hosted relay, which sends once the + * server is bound to the cloud; the boot refuses it only on a deployment that + * declared it has no control plane (`OS_CLOUD_URL=off`). */ push: PushServiceConfigSchema.optional() .describe( 'Mobile push configuration, read when requires names push: transport direct with apns and/or fcm blocks, each ' - + 'naming its secret by credentialRef (never inline). Absent or transport relay is the hosted relay, which the ' - + 'boot refuses until the relay sender ships', + + 'naming its secret by credentialRef (never inline). Absent or transport relay is the hosted relay, which sends ' + + 'once the server is bound to the cloud and is refused at boot only when OS_CLOUD_URL declares no control plane', ), /** diff --git a/packages/spec/src/system/push-config.zod.ts b/packages/spec/src/system/push-config.zod.ts index cbda375977c..dea5a8f1bb6 100644 --- a/packages/spec/src/system/push-config.zod.ts +++ b/packages/spec/src/system/push-config.zod.ts @@ -11,8 +11,9 @@ import { strictObject } from '../shared/strict-object'; * * **Read by `@objectstack/service-push`.** `PushServicePlugin` parses its * `config` option with {@link 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 + * for the relay transport it sends through the hosted push relay with the + * server's cloud binding credential, and for the direct transport it builds + * one vendor transport per block. The `push` channel in * `@objectstack/service-messaging` delivers through the push service it * registers. Under `objectstack serve` the value is the stack definition's * `push` key, read when the stack declares `requires: ['push']` (below). @@ -44,14 +45,15 @@ import { strictObject } from '../shared/strict-object'; * 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), 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. + * **What is served.** Both transports, in `@objectstack/service-push` + * (ADR-0012, amended 2026-11-03, #29041), read by `objectstack serve` from + * the stack's `push` key. The relay sender (#29920) reads the binding + * credential from the `cloud-binding` service on every send, so a server + * that is not bound yet still boots: its push channel reports no transport + * until the bind is done, and the bind takes effect without a restart. The + * boot refuses a relay block, the default `{}` included, only on a deployment + * that declared it has no control plane (`OS_CLOUD_URL=off`), where nothing + * could ever deliver through the relay (ADR-0012 §6.1, the air-gap rule). * * It sits beside `EmailServiceConfigSchema` (`email-config.zod.ts`), the * declaration of the email channel's transport selection. @@ -76,7 +78,8 @@ import { strictObject } from '../shared/strict-object'; * 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. + * absent key included) on a deployment that declared it has no control + * plane, 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` @@ -234,8 +237,8 @@ export const PushRelayTransportConfigSchema = lazySchema(() => strictObject( + 'carries the server binding credential', ), }, -).describe('[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')); +).describe('The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none. ' + + 'Read by the push service, which sends each push to the relay once the server is bound to the cloud')); export type PushRelayTransportConfig = z.input; /** Post-parse shape of {@link PushRelayTransportConfig} — `transport` filled in (ADR-0122). */ export type PushRelayTransportConfigParsed = z.infer; @@ -280,7 +283,7 @@ export const PushServiceConfigSchema = lazySchema(() => z.discriminatedUnion( // tag falls back to trying the members in order, relay first. { unionFallback: true }, ).describe('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')); + + 'The push service reads it: relay sends through the hosted relay with the server cloud binding, and direct drives the vendor transports')); export type PushServiceConfig = z.input; /** Post-parse shape of {@link PushServiceConfig} — `transport` filled in (ADR-0122). */ export type PushServiceConfigParsed = z.infer; From 78f8a3fdb5801ac6f1efced0a5b8d384e8442c3b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:55:06 +0000 Subject: [PATCH 3/9] wip(service-push): relay pins, channel notice line, docs and changeset Claude-Session: https://claude.ai/code/session_01Utp6CZX77joBRgcruRQSMN Co-authored-by: Claude --- .changeset/29920-push-relay-cloud-binding.md | 29 +++ .../kernel/runtime-services/push-service.mdx | 77 ++++-- ...erve-stack-email-sms-door.contract.test.ts | 17 +- .../src/push-channel.test.ts | 82 ++++++- packages/services/service-push/README.md | 4 +- packages/services/service-push/package.json | 2 +- .../service-push/src/push-plugin.test.ts | 122 ++++++++-- .../service-push/src/transports/relay.test.ts | 219 ++++++++++++++++++ .../src/harness.served-composition.test.ts | 18 +- 9 files changed, 511 insertions(+), 59 deletions(-) create mode 100644 .changeset/29920-push-relay-cloud-binding.md create mode 100644 packages/services/service-push/src/transports/relay.test.ts diff --git a/.changeset/29920-push-relay-cloud-binding.md b/.changeset/29920-push-relay-cloud-binding.md new file mode 100644 index 00000000000..9b1111bd08d --- /dev/null +++ b/.changeset/29920-push-relay-cloud-binding.md @@ -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 + + + +**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` (`@objectstack/service-push`) TO `PUSH_RELAY_AIR_GAPPED_MESSAGE`: 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. +- FROM a relay payload `data` with extra keys (stripped) TO refused: send only `notificationId`, `route` and `origin` in `data`; a content key such as `title` is refused with the ID-only guidance. +- FROM a `RATE_LIMIT_EXCEEDED` relay error with any `retryable` TO `retryable: true` (the default when absent): a relay that answered `retryable: false` on a rate limit answers `true` or leaves it out. diff --git a/content/docs/kernel/runtime-services/push-service.mdx b/content/docs/kernel/runtime-services/push-service.mdx index c2833d010bd..f64cda1fed9 100644 --- a/content/docs/kernel/runtime-services/push-service.mdx +++ b/content/docs/kernel/runtime-services/push-service.mdx @@ -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` @@ -16,12 +16,14 @@ services.push.send(message: PushMessage): Promise ``` `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 @@ -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`. @@ -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 @@ -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 @@ -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 diff --git a/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts b/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts index 836b30d34af..d07227d8e16 100644 --- a/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts +++ b/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts @@ -139,12 +139,21 @@ describe("a defineStack-built push block reaches the PushServicePlugin serve con ['no push key', {}], ['push: {}', { push: {} }], ["push: { transport: 'relay' }", { push: { transport: 'relay' as const } }], - ])('%s is refused at boot, naming the missing relay sender and the direct transport', (_name, extra) => { + ])('%s reaches the plugin as the relay; under OS_CLOUD_URL=off it is refused at boot, naming the air gap and the direct transport', (_name, extra) => { const stack = defineStack({ manifest, requires: ['push'], ...extra }); const boot = () => argumentFor('push', stack, {}); - expect(boot).toThrow(servicePush.PUSH_RELAY_NOT_SERVED_MESSAGE); - expect(boot).toThrow(/its sender has not shipped/); - expect(boot).toThrow(/transport: 'direct'/); + const saved = process.env.OS_CLOUD_URL; + try { + delete process.env.OS_CLOUD_URL; + expect(boot()).toEqual({ config: { transport: 'relay' } }); + process.env.OS_CLOUD_URL = 'off'; + expect(boot).toThrow(servicePush.PUSH_RELAY_AIR_GAPPED_MESSAGE); + expect(boot).toThrow(/declared it has no control plane/); + expect(boot).toThrow(/transport: 'direct'/); + } finally { + if (saved === undefined) delete process.env.OS_CLOUD_URL; + else process.env.OS_CLOUD_URL = saved; + } }); it('an inline secret in a configuration defineStack never built is refused at boot, by name, value not echoed', () => { diff --git a/packages/services/service-messaging/src/push-channel.test.ts b/packages/services/service-messaging/src/push-channel.test.ts index 5e3b607ace5..5c8ea3ab660 100644 --- a/packages/services/service-messaging/src/push-channel.test.ts +++ b/packages/services/service-messaging/src/push-channel.test.ts @@ -10,9 +10,10 @@ import { describe, it, expect } from 'vitest'; import type { UserDevice } from '@objectstack/spec/system'; import { PushDeliveryDataSchema } from '@objectstack/spec/api'; -import { createPushChannel, deliveryData, truncateUtf8, type PushSenderSurface } from './push-channel.js'; +import { createPushChannel, deliveryData, pushNoticeLine, truncateUtf8, PUSH_NOTICE_LINES, type PushSenderSurface } from './push-channel.js'; import { MessagingServicePlugin } from './messaging-service-plugin.js'; -import type { MessagingService } from './messaging-service.js'; +import { MessagingService } from './messaging-service.js'; +import { MemoryNotificationOutbox } from './memory-outbox.js'; import type { Delivery } from './channel.js'; import { assertEngineDeleteDispatch, @@ -285,3 +286,80 @@ describe('MessagingServicePlugin mounts the push channel while a push service is expect(sent).toEqual([expect.objectContaining({ vendor: 'apns', token: 'tok-1', title: 'Deal won', data: { notificationId: 'evt_1', route: '/opportunity/9' } })]); }); }); + +describe('createPushChannel — the notice line, in the recipient\'s language (#29920, Q4 C)', () => { + function userEngine(locale: unknown, opts: { fail?: boolean } = {}) { + const reads: Array<{ object: string; query: unknown }> = []; + return { + reads, + engine: { + async findOne(object: string, query: unknown) { + reads.push({ object, query }); + if (opts.fail) throw new Error('engine down'); + return { id: 'usr_1', locale }; + }, + } as never, + }; + } + + it('sends the zh-CN line to a recipient whose sys_user.locale is zh-CN', async () => { + const { push, sent } = pushDouble(); + const data = userEngine('zh-CN'); + const channel = createPushChannel({ + getPush: () => push, + devices: registry([device({ deviceId: 'd1', token: 'tok-a' })]).devices, + getData: () => data.engine, + getDefaultTemplateLocale: () => 'en-US', + }); + expect((await channel.send(ctx, delivery())).ok).toBe(true); + expect(sent[0]!.notice).toBe(PUSH_NOTICE_LINES['zh-CN']); + expect(data.reads[0]).toMatchObject({ object: 'sys_user', query: { where: { id: 'usr_1' }, fields: ['locale'] } }); + }); + + it('falls to the deployment default, then English; a failed read never costs the push', async () => { + const cases: Array<[unknown, string | undefined, boolean, string]> = [ + [undefined, 'ja-JP', false, PUSH_NOTICE_LINES['ja-JP']!], + ['undefined', undefined, false, PUSH_NOTICE_LINES.en!], + ['fr-FR', undefined, false, PUSH_NOTICE_LINES.en!], + ['zh-CN', 'es-ES', true, PUSH_NOTICE_LINES['es-ES']!], + ]; + for (const [own, deployment, fail, expected] of cases) { + const { push, sent } = pushDouble(); + const channel = createPushChannel({ + getPush: () => push, + devices: registry([device({ deviceId: 'd1', token: 'tok-a' })]).devices, + getData: () => userEngine(own, { fail }).engine, + getDefaultTemplateLocale: () => deployment, + }); + expect((await channel.send(ctx, delivery())).ok).toBe(true); + expect(sent[0]!.notice).toBe(expected); + } + }); + + it('a locale with no line of its own takes its language\'s line', () => { + expect(pushNoticeLine('zh-Hans-CN')).toBe(PUSH_NOTICE_LINES['zh-CN']); + expect(pushNoticeLine('es-419')).toBe(PUSH_NOTICE_LINES['es-ES']); + expect(pushNoticeLine(undefined)).toBe(PUSH_NOTICE_LINES.en); + }); +}); + +describe('createPushChannel — a push service with no available vendor (an unbound relay)', () => { + it('is suppressed at fan-out as transport_not_configured: recorded on the notification, no delivery row to dead-letter', async () => { + const outbox = new MemoryNotificationOutbox(1); + const inserts: Array<{ object: string; row: Record }> = []; + const engine = { + async insert(object: string, row: Record) { inserts.push({ object, row }); return { id: 'evt_1', ...row }; }, + async find() { return []; }, + } as never; + const service = new MessagingService({ logger: ctx.logger, outbox, getData: () => engine }); + const { push, sent } = pushDouble({}, []); + service.registerChannel(createPushChannel({ getPush: () => push, devices: registry([device({ deviceId: 'd1', token: 'tok-a' })]).devices })); + + const result = await service.emit({ topic: 'deal.won', audience: ['usr_1'], channels: ['push'], organizationId: 'org_1', payload: { title: 'Deal won' } }); + + expect(await outbox.list()).toHaveLength(0); + expect(sent).toHaveLength(0); + expect(inserts[0]!.row.suppressed_channels).toEqual([{ channel: 'push', reason: 'transport_not_configured' }]); + expect(result.suppressed).toEqual([{ channel: 'push', reason: 'transport_not_configured' }]); + }); +}); diff --git a/packages/services/service-push/README.md b/packages/services/service-push/README.md index 33ba84f13f1..f8ebcf17aff 100644 --- a/packages/services/service-push/README.md +++ b/packages/services/service-push/README.md @@ -1,8 +1,8 @@ # @objectstack/service-push -The push service: `IPushService` over the APNs (HTTP/2, token-based auth) and FCM (HTTP v1) transports, configured by `PushServiceConfigSchema` from `@objectstack/spec/system`. Each secret, the APNs `.p8` signing key and the FCM service-account JSON, is named by a `credentialRef` and resolved when the transport first signs (in the open tier, the name of an environment variable). `PushServicePlugin` registers the kernel's `push` service, and the `push` channel in `@objectstack/service-messaging` delivers through it to the devices users register under `/api/v1/notifications/devices`. +The push service: `IPushService` over the hosted push relay and the APNs (HTTP/2, token-based auth) and FCM (HTTP v1) transports, configured by `PushServiceConfigSchema` from `@objectstack/spec/system`. Each secret, the APNs `.p8` signing key and the FCM service-account JSON, is named by a `credentialRef` and resolved when the transport first signs (in the open tier, the name of an environment variable). `PushServicePlugin` registers the kernel's `push` service, and the `push` channel in `@objectstack/service-messaging` delivers through it to the devices users register under `/api/v1/notifications/devices`. -The direct transport is the one served: `transport: 'direct'` with your own APNs key and Firebase project, for a white-label build. The hosted relay (`transport: 'relay'`, the schema's default, so also an empty config) is refused at init until its sender ships (#29920). +Two transports are served. The hosted relay (`transport: 'relay'`, the schema's default, so also an empty config) sends each push to the cloud's push relay as an ID-only request, authenticated by this server's cloud binding credential, which it reads from the `cloud-binding` service (`@objectstack/cloud-connection`) on every send: an unbound server boots, and pushes once it is bound. A deployment that declared it has no control plane (`OS_CLOUD_URL=off`) refuses the relay at init. The direct transport, `transport: 'direct'` with your own APNs key and Firebase project, is for a white-label build. ```typescript import { PushServicePlugin } from '@objectstack/service-push'; diff --git a/packages/services/service-push/package.json b/packages/services/service-push/package.json index ffeaad0e15c..bc15f77a9ff 100644 --- a/packages/services/service-push/package.json +++ b/packages/services/service-push/package.json @@ -2,7 +2,7 @@ "name": "@objectstack/service-push", "version": "18.2.0", "license": "Apache-2.0", - "description": "Push service for ObjectStack — IPushService over the APNs (token auth) and FCM HTTP v1 transports, configured by PushServiceConfigSchema with credentials by reference.", + "description": "Push service for ObjectStack — IPushService over the hosted push relay (cloud binding credential) and the APNs (token auth) and FCM HTTP v1 transports, configured by PushServiceConfigSchema with credentials by reference.", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/services/service-push/src/push-plugin.test.ts b/packages/services/service-push/src/push-plugin.test.ts index 6e08b8ea75e..711d041aa0c 100644 --- a/packages/services/service-push/src/push-plugin.test.ts +++ b/packages/services/service-push/src/push-plugin.test.ts @@ -5,22 +5,40 @@ * and registers the kernel's `push` service; `PushService` routes by vendor. */ -import { describe, it, expect } from 'vitest'; -import { PushServicePlugin, PUSH_RELAY_NOT_SERVED_MESSAGE } from './push-plugin.js'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import type { ICloudBindingService } from '@objectstack/spec/contracts'; +import { PushServicePlugin, PUSH_RELAY_AIR_GAPPED_MESSAGE } from './push-plugin.js'; import { PushService, type IPushTransport, type PushMessage } from './push-service.js'; import { envPushCredentialResolver } from './credential.js'; function context() { const services = new Map(); const lines: string[] = []; - const logger = { info: (m: string) => lines.push(m), warn: (m: string) => lines.push(m), error() {}, debug() {} }; + const hooks = new Map Promise | void>>(); + const logger = { info: (m: string) => lines.push(m), warn: (m: string) => lines.push(m), error: (m: string) => lines.push(m), debug() {} }; return { services, lines, - ctx: { registerService: (name: string, s: unknown) => services.set(name, s), logger } as never, + /** Runs the handlers registered for `event`, as the kernel does. */ + async fire(event: string) { for (const h of hooks.get(event) ?? []) await h(); }, + ctx: { + registerService: (name: string, s: unknown) => services.set(name, s), + getService: (name: string) => { + if (!services.has(name)) throw new Error(`service '${name}' is not registered`); + return services.get(name); + }, + hook: (event: string, h: () => Promise | void) => hooks.set(event, [...(hooks.get(event) ?? []), h]), + logger, + } as never, }; } +const SECRET = 'oscc_secret-binding-credential-29920'; + +function cloudBinding(credential: () => string): ICloudBindingService { + return { credential, bound: () => credential() !== '', controlPlaneUrl: () => 'https://cloud.example.com' }; +} + const APNS = { teamId: 'TEAM123456', keyId: 'KEY1234567', topic: 'com.example.app', environment: 'production' as const, credentialRef: 'APNS_KEY' }; const FCM = { projectId: 'example-app', credentialRef: 'FCM_ACCOUNT' }; const message: PushMessage = { vendor: 'fcm', token: 't', title: 'x', body: 'y', data: {} }; @@ -42,23 +60,6 @@ describe('PushServicePlugin', () => { } }); - it.each([ - ['no config', undefined], - ['the empty config', {}], - ["transport: 'relay'", { transport: 'relay' as const }], - ['a staging relay endpoint', { transport: 'relay' as const, endpoint: 'https://relay.staging.example.com' }], - ])('refuses the relay transport at init (%s), registering nothing', async (_name, config) => { - const { ctx, services } = context(); - const init = new PushServicePlugin({ config }).init(ctx); - await expect(init).rejects.toThrow(PUSH_RELAY_NOT_SERVED_MESSAGE); - expect(services.has('push')).toBe(false); - }); - - it('names the direct transport, and the relay as not yet served, in the relay refusal', () => { - expect(PUSH_RELAY_NOT_SERVED_MESSAGE).toContain('is not served yet'); - expect(PUSH_RELAY_NOT_SERVED_MESSAGE).toContain("transport: 'direct'"); - expect(PUSH_RELAY_NOT_SERVED_MESSAGE).toContain('credentialRef'); - }); it('refuses an inline secret at init with the schema\'s guidance, registering nothing', async () => { const { ctx, services } = context(); @@ -77,6 +78,85 @@ describe('PushServicePlugin', () => { }); }); +const RELAY_CONFIGS = [ + ['no config', undefined], + ['the empty config', {}], + ["transport: 'relay'", { transport: 'relay' as const }], + ['a staging relay endpoint', { transport: 'relay' as const, endpoint: 'https://relay.staging.example.com' }], +] as const; + +describe('PushServicePlugin — the hosted relay', () => { + beforeEach(() => vi.stubEnv('OS_CLOUD_URL', '')); + afterEach(() => vi.unstubAllEnvs()); + + it.each(RELAY_CONFIGS)('boots the relay transport (%s), unbound: the push service lists no vendor', async (_name, config) => { + const { ctx, services } = context(); + await new PushServicePlugin({ config }).init(ctx); + expect(services.get('push')).toBeInstanceOf(PushService); + expect((services.get('push') as PushService).vendors()).toEqual([]); + }); + + it('bound: lists both vendors and sends one POST carrying the binding credential, which no log line carries', async () => { + const { ctx, services, lines, fire } = context(); + services.set('cloud-binding', cloudBinding(() => SECRET)); + const calls: Array<{ url: string; headers: Record }> = []; + const relayFetch = (async (url: string, init: RequestInit) => { + calls.push({ url, headers: init.headers as Record }); + const body = JSON.parse(String(init.body)); + return new Response(JSON.stringify({ results: [{ token: body.deliveries[0].token, status: 'ok' }] }), { status: 200 }); + }) as unknown as typeof fetch; + const plugin = new PushServicePlugin({ config: {}, relayFetch }); + await plugin.init(ctx); + await plugin.start(ctx); + await fire('kernel:ready'); + const push = services.get('push') as PushService; + expect(push.vendors().sort()).toEqual(['apns', 'fcm']); + expect(await push.send(message)).toEqual({ status: 'sent' }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('https://cloud.example.com/api/v1/cloud/push/send'); + expect(calls[0]!.headers.authorization).toBe(`Bearer ${SECRET}`); + expect(lines.join('\n')).not.toContain(SECRET); + expect(lines.some((l) => /not bound to the cloud/.test(l))).toBe(false); + }); + + it('unbound at kernel:ready: warns once, and the bind takes effect without a restart', async () => { + const { ctx, services, lines, fire } = context(); + let credential = ''; + services.set('cloud-binding', cloudBinding(() => credential)); + const plugin = new PushServicePlugin({ config: {} }); + await plugin.init(ctx); + await plugin.start(ctx); + await fire('kernel:ready'); + const warned = lines.filter((l) => /not bound to the cloud/.test(l)); + expect(warned).toHaveLength(1); + expect(warned[0]).toMatch(/transport_not_configured/); + const push = services.get('push') as PushService; + expect(push.vendors()).toEqual([]); + credential = SECRET; + expect(push.vendors().sort()).toEqual(['apns', 'fcm']); + }); + + it.each(RELAY_CONFIGS)('refuses the relay at init under OS_CLOUD_URL=off (%s), registering nothing', async (_name, config) => { + vi.stubEnv('OS_CLOUD_URL', 'off'); + const { ctx, services } = context(); + await expect(new PushServicePlugin({ config }).init(ctx)).rejects.toThrow(PUSH_RELAY_AIR_GAPPED_MESSAGE); + expect(services.has('push')).toBe(false); + }); + + it('names the air gap and both ways out (the inbox, the direct transport) in the refusal', () => { + expect(PUSH_RELAY_AIR_GAPPED_MESSAGE).toContain('declared it has no control plane'); + expect(PUSH_RELAY_AIR_GAPPED_MESSAGE).toContain('in-app inbox'); + expect(PUSH_RELAY_AIR_GAPPED_MESSAGE).toContain("transport: 'direct'"); + }); + + it('a direct block still boots under OS_CLOUD_URL=off', async () => { + vi.stubEnv('OS_CLOUD_URL', 'off'); + const { ctx, services } = context(); + await new PushServicePlugin({ config: { fcm: FCM } }).init(ctx); + expect((services.get('push') as PushService).vendors()).toEqual(['fcm']); + }); +}); + describe('PushService', () => { it('answers a vendor with no transport as a non-retryable failure', async () => { const push = new PushService({ transports: {} }); diff --git a/packages/services/service-push/src/transports/relay.test.ts b/packages/services/service-push/src/transports/relay.test.ts new file mode 100644 index 00000000000..00076838eec --- /dev/null +++ b/packages/services/service-push/src/transports/relay.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The hosted push relay sender (#29920): one `PushRelayRequestSchema` POST per + * push, authenticated by the `cloud-binding` service's credential, read on + * every send. Each arm the maintainer ruled is pinned here: bound, unbound, + * the default address and an explicit endpoint, no address at all, a 401, and + * the per-delivery `ok` / `invalid` / `retry` answers. + */ + +import { describe, it, expect } from 'vitest'; +import { PushRelayRequestSchema } from '@objectstack/spec/api'; +import type { ICloudBindingService } from '@objectstack/spec/contracts'; +import type { PushMessage } from '../push-service.js'; +import { PushService } from '../push-service.js'; +import { + DEFAULT_PUSH_NOTICE, + PUSH_RELAY_PATH, + RELAY_ADDRESS_NOT_PUBLISHED, + RELAY_BINDING_REFUSED, + RELAY_NOT_BOUND, + RelayPushSender, + relayTransport, +} from './relay.js'; + +const SECRET = 'oscc_secret-binding-credential-29920'; + +function binding(over: Partial<{ credential: string; controlPlaneUrl: string }> = {}): ICloudBindingService { + const credential = over.credential ?? SECRET; + return { + credential: () => credential, + bound: () => credential !== '', + controlPlaneUrl: () => over.controlPlaneUrl ?? 'https://cloud.example.com', + }; +} + +interface Call { url: string; headers: Record; body: unknown } + +/** A relay double: records each request and answers with the given status and body. */ +function relay(status = 200, answer?: (body: { deliveries: Array<{ token: string }> }) => unknown) { + const calls: Call[] = []; + const fetchImpl = (async (url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)); + calls.push({ url, headers: init.headers as Record, body }); + const payload = answer + ? answer(body) + : { results: body.deliveries.map((d: { token: string }) => ({ token: d.token, status: 'ok' })) }; + return new Response(JSON.stringify(payload), { status }); + }) as unknown as typeof fetch; + return { calls, fetch: fetchImpl }; +} + +const message: PushMessage = { + vendor: 'apns', + token: 'device-token-abcdef', + title: 'Deal won', + body: 'Acme signed the contract', + data: { notificationId: 'ntf_1', route: '/opportunity/1' }, + notice: '您有一条新通知', +}; + +describe('RelayPushSender — a bound server', () => { + it('POSTs one ID-only delivery to the control plane relay path, with the binding credential as bearer', async () => { + const r = relay(); + const sender = new RelayPushSender({ config: { transport: 'relay' }, binding: () => binding(), fetch: r.fetch }); + expect(await sender.send(message)).toEqual({ status: 'sent' }); + + expect(r.calls).toHaveLength(1); + const [call] = r.calls; + expect(call!.url).toBe(`https://cloud.example.com${PUSH_RELAY_PATH}`); + expect(PUSH_RELAY_PATH).toBe('/api/v1/cloud/push/send'); + expect(call!.headers.authorization).toBe(`Bearer ${SECRET}`); + // The wire the relay pins: it parses, and it carries no content. + expect(PushRelayRequestSchema.safeParse(call!.body).success).toBe(true); + expect(call!.body).toEqual({ + deliveries: [{ + token: 'device-token-abcdef', + vendor: 'apns', + payload: { data: { notificationId: 'ntf_1', route: '/opportunity/1' }, text: '您有一条新通知' }, + priority: 'high', + }], + }); + expect(JSON.stringify(call!.body)).not.toContain('Deal won'); + expect(JSON.stringify(call!.body)).not.toContain('Acme signed'); + }); + + it('sends to an explicit relay endpoint, which wins over the control plane', async () => { + const r = relay(); + const sender = new RelayPushSender({ + config: { transport: 'relay', endpoint: 'https://relay.staging.example.com/send' }, + binding: () => binding(), + fetch: r.fetch, + }); + await sender.send(message); + expect(r.calls[0]!.url).toBe('https://relay.staging.example.com/send'); + }); + + it('sends the English line when the caller names none', async () => { + const r = relay(); + const sender = new RelayPushSender({ config: { transport: 'relay' }, binding: () => binding(), fetch: r.fetch }); + await sender.send({ ...message, notice: undefined }); + expect((r.calls[0]!.body as { deliveries: Array<{ payload: { text: string } }> }).deliveries[0]!.payload.text) + .toBe(DEFAULT_PUSH_NOTICE); + }); + + it('reads the credential on every send: a bind done while the server runs takes effect at once', async () => { + let credential = ''; + const live: ICloudBindingService = { + credential: () => credential, + bound: () => credential !== '', + controlPlaneUrl: () => 'https://cloud.example.com', + }; + const r = relay(); + const sender = new RelayPushSender({ config: { transport: 'relay' }, binding: () => live, fetch: r.fetch }); + expect(sender.bound()).toBe(false); + expect(await sender.send(message)).toMatchObject({ status: 'failed', retryable: false }); + credential = SECRET; + expect(sender.bound()).toBe(true); + expect(await sender.send(message)).toEqual({ status: 'sent' }); + expect(r.calls).toHaveLength(1); + }); +}); + +describe('RelayPushSender — what the relay answers', () => { + it('`invalid` is a dead token, so the channel retires the device row', async () => { + const r = relay(200, (b) => ({ results: [{ token: b.deliveries[0]!.token, status: 'invalid' }] })); + const sender = new RelayPushSender({ config: { transport: 'relay' }, binding: () => binding(), fetch: r.fetch }); + expect(await sender.send(message)).toEqual({ status: 'invalid_token', reason: 'relay: invalid' }); + }); + + it('`retry` is a retryable failure', async () => { + const r = relay(200, (b) => ({ results: [{ token: b.deliveries[0]!.token, status: 'retry' }] })); + const sender = new RelayPushSender({ config: { transport: 'relay' }, binding: () => binding(), fetch: r.fetch }); + expect(await sender.send(message)).toMatchObject({ status: 'failed', retryable: true }); + }); + + it('401 fails the request, not retryable, naming the lapsed binding — never the credential', async () => { + const r = relay(401, () => ({ + success: false, + error: { + code: 'UNAUTHENTICATED', + message: 'The server binding credential was not accepted', + httpStatus: 401, + details: { reason: 'binding_revoked' }, + helpText: 'Bind the server to the cloud again', + }, + })); + const sender = new RelayPushSender({ config: { transport: 'relay' }, binding: () => binding(), fetch: r.fetch }); + const result = await sender.send(message); + expect(result).toMatchObject({ status: 'failed', retryable: false }); + const error = (result as { error: string }).error; + expect(error.startsWith(`${RELAY_BINDING_REFUSED}:`)).toBe(true); + expect(error).toContain('binding_revoked'); + expect(error).not.toContain(SECRET); + }); + + it('429 RATE_LIMIT_EXCEEDED is retryable, as the relay error schema pins; QUOTA_EXCEEDED is not', async () => { + const limited = relay(429, () => ({ + success: false, + error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many', httpStatus: 429, retryAfterSeconds: 5, helpText: 'Wait' }, + })); + const quota = relay(429, () => ({ + success: false, + error: { code: 'QUOTA_EXCEEDED', message: 'Quota', httpStatus: 429, helpText: 'Raise the plan' }, + })); + const send = (f: typeof fetch) => new RelayPushSender({ config: { transport: 'relay' }, binding: () => binding(), fetch: f }).send(message); + expect(await send(limited.fetch)).toMatchObject({ status: 'failed', retryable: true }); + expect(await send(quota.fetch)).toMatchObject({ status: 'failed', retryable: false }); + }); + + it('a 5xx is retryable, another refusal is not', async () => { + const send = (status: number) => new RelayPushSender({ config: { transport: 'relay' }, binding: () => binding(), fetch: relay(status, () => ({})).fetch }).send(message); + expect(await send(503)).toMatchObject({ status: 'failed', retryable: true }); + expect(await send(400)).toMatchObject({ status: 'failed', retryable: false }); + }); +}); + +describe('RelayPushSender — nothing is sent', () => { + it('an unbound server fails the send, not retryable, naming the missing binding', async () => { + for (const resolve of [() => binding({ credential: '' }), () => undefined, () => { throw new Error('not registered'); }]) { + const r = relay(); + const sender = new RelayPushSender({ config: { transport: 'relay' }, binding: resolve, fetch: r.fetch }); + const result = await sender.send(message); + expect(result).toMatchObject({ status: 'failed', retryable: false }); + expect((result as { error: string }).error).toMatch(new RegExp(`^${RELAY_NOT_BOUND}: the server is not bound to cloud`)); + expect(r.calls).toHaveLength(0); + } + }); + + it('no endpoint and no control-plane URL fails the send, naming the relay address as not published', async () => { + const r = relay(); + const sender = new RelayPushSender({ config: { transport: 'relay' }, binding: () => binding({ controlPlaneUrl: '' }), fetch: r.fetch }); + const result = await sender.send(message); + expect(result).toMatchObject({ status: 'failed', retryable: false }); + expect((result as { error: string }).error).toMatch(new RegExp(`^${RELAY_ADDRESS_NOT_PUBLISHED}: `)); + expect(r.calls).toHaveLength(0); + }); + + it('a content key in the delivery data is refused before anything leaves the server', async () => { + const r = relay(); + const sender = new RelayPushSender({ config: { transport: 'relay' }, binding: () => binding(), fetch: r.fetch }); + const result = await sender.send({ ...message, data: { notificationId: 'ntf_1', title: 'Deal won' } }); + expect(result).toMatchObject({ status: 'failed', retryable: false }); + expect((result as { error: string }).error).toMatch(/^relay_payload_refused: payload\.data/); + expect(r.calls).toHaveLength(0); + }); +}); + +describe('relayTransport — the push service lists a vendor only while the server is bound', () => { + it('an unbound server lists no vendor, so the push channel reports transport_not_configured', () => { + let credential = ''; + const live: ICloudBindingService = { credential: () => credential, bound: () => credential !== '', controlPlaneUrl: () => 'https://cloud.example.com' }; + const sender = new RelayPushSender({ config: { transport: 'relay' }, binding: () => live }); + const push = new PushService({ transports: { apns: relayTransport('apns', sender), fcm: relayTransport('fcm', sender) } }); + expect(push.vendors()).toEqual([]); + credential = SECRET; + expect(push.vendors().sort()).toEqual(['apns', 'fcm']); + }); +}); diff --git a/packages/verify/src/harness.served-composition.test.ts b/packages/verify/src/harness.served-composition.test.ts index dddb5f385ba..853c17ce8b4 100644 --- a/packages/verify/src/harness.served-composition.test.ts +++ b/packages/verify/src/harness.served-composition.test.ts @@ -184,7 +184,8 @@ describe('the email service is built from the app\'s mail configuration', () => * `requires: ['push']` gets `PushServicePlugin`, built from its `push` key by * the reader `serve` reads (`resolvePushCapabilityArg`, off the provider * module); the relay that an absent key reads as is refused before the kernel - * boots, because nothing sends it yet. + * boots on a deployment that declared it has no control plane + * (`OS_CLOUD_URL=off`), because nothing could ever deliver through it there. */ describe('the push service is built from the app\'s push configuration', () => { const direct = { transport: 'direct', fcm: { projectId: 'example-app', credentialRef: 'SVC_FCM_SERVICE_ACCOUNT' } }; @@ -200,11 +201,18 @@ describe('the push service is built from the app\'s push configuration', () => { expect(plain!.kernel.hasPlugin(CAPABILITY_PROVIDERS.push!.identities[0]!)).toBe(false); }, BOOT_TIMEOUT); - it('requires push with no push key fails the boot before the kernel starts: the relay it reads as has no sender', async () => { + it('requires push with no push key under OS_CLOUD_URL=off fails the boot before the kernel starts: the relay it reads as is unreachable', async () => { // Refused by the capability's reader while the boot builds the provider's // argument (`cannot build it`), not later by the plugin's own init. - await expect(bootStack(onePackage({ requires: ['push'] }))).rejects.toThrow( - /this configuration cannot build it: PushServicePlugin: the hosted push relay[\s\S]*its sender has not shipped[\s\S]*transport: 'direct'[\s\S]*stack definition's push key/, - ); + const saved = process.env.OS_CLOUD_URL; + process.env.OS_CLOUD_URL = 'off'; + try { + await expect(bootStack(onePackage({ requires: ['push'] }))).rejects.toThrow( + /this configuration cannot build it: PushServicePlugin: the hosted push relay[\s\S]*declared it has no control plane[\s\S]*transport: 'direct'[\s\S]*stack definition's push key/, + ); + } finally { + if (saved === undefined) delete process.env.OS_CLOUD_URL; + else process.env.OS_CLOUD_URL = saved; + } }, BOOT_TIMEOUT); }); From a7f8da193a1f792b5b810c4116605f6cd46e0b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:59:57 +0000 Subject: [PATCH 4/9] wip(spec): regenerate artifacts for the served relay wire and the cloud-binding contract Claude-Session: https://claude.ai/code/session_01Utp6CZX77joBRgcruRQSMN Co-authored-by: Claude --- content/docs/references/api/push-relay.mdx | 40 ++++++++++--------- .../docs/references/system/push-config.mdx | 31 +++++++------- .../api.md | 2 +- .../service-push/src/push-plugin.test.ts | 2 +- .../service-push/src/transports/relay.test.ts | 2 +- packages/spec/api-surface/contracts.json | 1 + packages/spec/export-origins/contracts.json | 1 + 7 files changed, 43 insertions(+), 36 deletions(-) diff --git a/content/docs/references/api/push-relay.mdx b/content/docs/references/api/push-relay.mdx index 9e690771ade..f9fb5f46c3f 100644 --- a/content/docs/references/api/push-relay.mdx +++ b/content/docs/references/api/push-relay.mdx @@ -33,10 +33,12 @@ nothing else. There is no title or body: the shell reads the notification from the server after the tap, by its id. To the relay the payload is opaque: it puts it in the vendor message unchanged. -**Declared ahead of its producer.** No push channel delivers yet, so -nothing sends this request today; each schema here carries a -`[PLANNED — not yet served]` marker in its `.describe()` until the push -channel ships. +**Served.** `@objectstack/service-push` sends this request: its relay +transport (`RelayPushSender`) builds one delivery per device token, parses +the request with `PushRelayRequestSchema` before it leaves the +server, and reads the answer with `PushRelayResponseSchema` and +`PushRelayErrorResponseSchema`. The relay that receives it is served +by the cloud's control plane (objectstack-ai/cloud#3519). **Source:** `packages/spec/src/api/push-relay.zod.ts` @@ -56,7 +58,7 @@ const result = PushRelayDeliverySchema.parse(data); ## PushRelayDelivery -[PLANNED — not yet served] One relay delivery: a device token, its vendor, the ID-only payload and the delivery priority +One relay delivery: a device token, its vendor, the ID-only payload and the delivery priority ### Properties @@ -71,15 +73,15 @@ const result = PushRelayDeliverySchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **data** | `{ notificationId: string; route?: string; origin?: string }` | ✅ | The push delivery data: the notification id, its same-server route and the origin of the sending server. The relay puts it in the vendor message unchanged | -| **text** | `string` | ✅ | One generic visible line the device shows (for example "You have a new notification"), in the device locale. Never the notification title or body | +| **data** | `{ notificationId: string; route?: string; origin?: string }` | ✅ | The push delivery data: the notification id, its same-server route and the origin of the sending server, and no other key. The relay puts it in the vendor message unchanged | +| **text** | `string` | ✅ | One generic visible line the device shows (for example "You have a new notification"), in the recipient's language. Never the notification title or body | --- ## PushRelayError -[PLANNED — not yet served] A push relay error: UNAUTHENTICATED (401, bad or revoked binding), RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED (429), each with its next step in helpText +A push relay error: UNAUTHENTICATED (401, bad or revoked binding), RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED (429), each with its next step in helpText ### Union Options @@ -141,7 +143,7 @@ This schema accepts one of the following structures: | **refusal** | `true` | optional | Producer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose `message` is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld. | | **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>` | optional | Error category | | **httpStatus** | `429` | ✅ | HTTP status code | -| **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | +| **retryable** | `true` | optional (default: `true`) | Always true: the same request succeeds once retryAfterSeconds has passed | | **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | | **retryAfterSeconds** | `integer` | ✅ | Seconds to wait before sending again | | **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | @@ -210,7 +212,7 @@ This schema accepts one of the following structures: ## PushRelayErrorResponse -[PLANNED — not yet served] The push relay error response: the house error envelope, for 401 (bad or revoked binding) and 429 (rate limit or quota) +The push relay error response: the house error envelope, for 401 (bad or revoked binding) and 429 (rate limit or quota) ### Properties @@ -253,7 +255,7 @@ This schema accepts one of the following structures: | **refusal** | `true` | optional | Producer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose `message` is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld. | | **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| …>` | optional | Error category | | **httpStatus** | `429` | ✅ | HTTP status code | -| **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | +| **retryable** | `true` | optional (default: `true`) | Always true: the same request succeeds once retryAfterSeconds has passed | | **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | | **retryAfterSeconds** | `integer` | ✅ | Seconds to wait before sending again | | **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | @@ -294,14 +296,14 @@ This schema accepts one of the following structures: ## PushRelayPayload -[PLANNED — not yet served] The ID-only payload of one relay delivery: the push delivery data and one generic visible line, with no title or body. Opaque to the relay +The ID-only payload of one relay delivery: the push delivery data and one generic visible line, with no title or body. Opaque to the relay ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **data** | `{ notificationId: string; route?: string; origin?: string }` | ✅ | The push delivery data: the notification id, its same-server route and the origin of the sending server. The relay puts it in the vendor message unchanged | -| **text** | `string` | ✅ | One generic visible line the device shows (for example "You have a new notification"), in the device locale. Never the notification title or body | +| **data** | `{ notificationId: string; route?: string; origin?: string }` | ✅ | The push delivery data: the notification id, its same-server route and the origin of the sending server, and no other key. The relay puts it in the vendor message unchanged | +| **text** | `string` | ✅ | One generic visible line the device shows (for example "You have a new notification"), in the recipient's language. Never the notification title or body | ### Nested Shape: `PushRelayPayload.data` @@ -328,7 +330,7 @@ Vendor delivery priority: high delivers at once and wakes the device (APNs 10, F ## PushRelayRequest -[PLANNED — not yet served] The request a server sends the hosted push relay: one delivery per device token. Authenticated by the server cloud binding credential as its bearer token +The request a server sends the hosted push relay: one delivery per device token. Authenticated by the server cloud binding credential as its bearer token ### Properties @@ -338,7 +340,7 @@ Vendor delivery priority: high delivers at once and wakes the device (APNs 10, F ### Nested Shape: `PushRelayRequest.deliveries[number]` -[PLANNED — not yet served] One relay delivery: a device token, its vendor, the ID-only payload and the delivery priority +One relay delivery: a device token, its vendor, the ID-only payload and the delivery priority | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -352,7 +354,7 @@ Vendor delivery priority: high delivers at once and wakes the device (APNs 10, F ## PushRelayResponse -[PLANNED — not yet served] The push relay answer: one result per delivery token, ok, invalid or retry +The push relay answer: one result per delivery token, ok, invalid or retry ### Properties @@ -362,7 +364,7 @@ Vendor delivery priority: high delivers at once and wakes the device (APNs 10, F ### Nested Shape: `PushRelayResponse.results[number]` -[PLANNED — not yet served] The relay answer for one delivery: its device token and its outcome +The relay answer for one delivery: its device token and its outcome | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -374,7 +376,7 @@ Vendor delivery priority: high delivers at once and wakes the device (APNs 10, F ## PushRelayResult -[PLANNED — not yet served] The relay answer for one delivery: its device token and its outcome +The relay answer for one delivery: its device token and its outcome ### Properties diff --git a/content/docs/references/system/push-config.mdx b/content/docs/references/system/push-config.mdx index c79d137a31f..4825ed7712f 100644 --- a/content/docs/references/system/push-config.mdx +++ b/content/docs/references/system/push-config.mdx @@ -11,8 +11,9 @@ vendors. **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 +for the relay transport it sends through the hosted push relay with the +server's cloud binding credential, and for the direct transport it builds +one vendor transport per block. The `push` channel in `@objectstack/service-messaging` delivers through the push service it registers. Under `objectstack serve` the value is the stack definition's `push` key, read when the stack declares `requires: ['push']` (below). @@ -44,14 +45,15 @@ 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), 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. +**What is served.** Both transports, in `@objectstack/service-push` +(ADR-0012, amended 2026-11-03, #29041), read by `objectstack serve` from +the stack's `push` key. The relay sender (#29920) reads the binding +credential from the `cloud-binding` service on every send, so a server +that is not bound yet still boots: its push channel reports no transport +until the bind is done, and the bind takes effect without a restart. The +boot refuses a relay block, the default `{}` included, only on a deployment +that declared it has no control plane (`OS_CLOUD_URL=off`), where nothing +could ever deliver through the relay (ADR-0012 §6.1, the air-gap rule). It sits beside `EmailServiceConfigSchema` (`email-config.zod.ts`), the declaration of the email channel's transport selection. @@ -76,7 +78,8 @@ stack that declares `requires: ['push']` has `objectstack serve` (and 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. +absent key included) on a deployment that declared it has no control +plane, 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` @@ -168,7 +171,7 @@ Direct push transport for a white-label build: one block per vendor (apns, fcm), ## PushRelayTransportConfig -[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 +The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none. Read by the push service, which sends each push to the relay once the server is bound to the cloud ### Properties @@ -182,7 +185,7 @@ Direct push transport for a white-label build: one block per vendor (apns, fcm), ## PushServiceConfig -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 +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: relay sends through the hosted relay with the server cloud binding, and direct drives the vendor transports ### Union Options @@ -190,7 +193,7 @@ This schema accepts one of the following structures: #### Option 1 -[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 +The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none. Read by the push service, which sends each push to the relay once the server is bound to the cloud ### Properties diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 21eda271dce..3ba6578f2de 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 499 | +| `api/` | 500 | diff --git a/packages/services/service-push/src/push-plugin.test.ts b/packages/services/service-push/src/push-plugin.test.ts index 711d041aa0c..c10ef7e428c 100644 --- a/packages/services/service-push/src/push-plugin.test.ts +++ b/packages/services/service-push/src/push-plugin.test.ts @@ -111,7 +111,7 @@ describe('PushServicePlugin — the hosted relay', () => { await fire('kernel:ready'); const push = services.get('push') as PushService; expect(push.vendors().sort()).toEqual(['apns', 'fcm']); - expect(await push.send(message)).toEqual({ status: 'sent' }); + expect(await push.send({ ...message, data: { notificationId: 'ntf_1' } })).toEqual({ status: 'sent' }); expect(calls).toHaveLength(1); expect(calls[0]!.url).toBe('https://cloud.example.com/api/v1/cloud/push/send'); expect(calls[0]!.headers.authorization).toBe(`Bearer ${SECRET}`); diff --git a/packages/services/service-push/src/transports/relay.test.ts b/packages/services/service-push/src/transports/relay.test.ts index 00076838eec..b01469dd1dd 100644 --- a/packages/services/service-push/src/transports/relay.test.ts +++ b/packages/services/service-push/src/transports/relay.test.ts @@ -201,7 +201,7 @@ describe('RelayPushSender — nothing is sent', () => { const sender = new RelayPushSender({ config: { transport: 'relay' }, binding: () => binding(), fetch: r.fetch }); const result = await sender.send({ ...message, data: { notificationId: 'ntf_1', title: 'Deal won' } }); expect(result).toMatchObject({ status: 'failed', retryable: false }); - expect((result as { error: string }).error).toMatch(/^relay_payload_refused: payload\.data/); + expect((result as { error: string }).error).toMatch(/^relay_payload_refused: deliveries\.0\.payload\.data: Unrecognized key.*`title`/); expect(r.calls).toHaveLength(0); }); }); diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 8c64b5d9c1d..705bfd732df 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -132,6 +132,7 @@ "IBasicServiceRegistry (interface)", "IBusinessUnitGraphService (interface)", "ICacheService (interface)", + "ICloudBindingService (interface)", "IClusterService (interface)", "ICounter (interface)", "ICryptoProvider (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 01f0dbbada6..e9d1ae8ed9d 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -132,6 +132,7 @@ "IBasicServiceRegistry": "src/contracts/service-registry.ts#IBasicServiceRegistry (interface)", "IBusinessUnitGraphService": "src/contracts/sharing-service.ts#IBusinessUnitGraphService (interface)", "ICacheService": "src/contracts/cache-service.ts#ICacheService (interface)", + "ICloudBindingService": "src/contracts/cloud-binding-service.ts#ICloudBindingService (interface)", "IClusterService": "src/contracts/cluster-service.ts#IClusterService (interface)", "ICounter": "src/contracts/cluster-service.ts#ICounter (interface)", "ICryptoProvider": "src/contracts/crypto-provider.ts#ICryptoProvider (interface)", From 5e53013031f99a578756d88873c80b9a5834f2d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 22:01:58 +0000 Subject: [PATCH 5/9] test(cloud-connection, core): pin the cloud-binding service and the declined-spelling reader Claude-Session: https://claude.ai/code/session_01Utp6CZX77joBRgcruRQSMN Co-authored-by: Claude --- .../src/cloud-binding.test.ts | 92 +++++++++++++++++++ packages/core/src/cloud-managed.test.ts | 17 +++- 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 packages/cloud-connection/src/cloud-binding.test.ts diff --git a/packages/cloud-connection/src/cloud-binding.test.ts b/packages/cloud-connection/src/cloud-binding.test.ts new file mode 100644 index 00000000000..93cb9df4f68 --- /dev/null +++ b/packages/cloud-connection/src/cloud-binding.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `cloud-binding` service (#29920, Q1 A): the one reader of this server's + * effective control-plane credential, registered by `CloudConnectionPlugin` + * in `init()` and read on every call, so a bind done while the server runs is + * seen without a restart. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ICloudBindingService } from '@objectstack/spec/contracts'; +import { CLOUD_BINDING_SERVICE, createCloudBinding } from './cloud-binding.js'; +import { CloudConnectionPlugin } from './cloud-connection-plugin.js'; +import { ConnectionCredentialStore } from './connection-credential-store.js'; +import { DEFAULT_CLOUD_URL } from './cloud-url.js'; + +let dir: string; +let store: ConnectionCredentialStore; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cloud-binding-')); + store = new ConnectionCredentialStore(join(dir, 'cloud-connection.json')); + vi.stubEnv('OS_CLOUD_API_KEY', ''); + vi.stubEnv('OS_CLOUD_URL', ''); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('createCloudBinding — the credential', () => { + it('is empty, and not bound, before any bind', () => { + const binding = createCloudBinding({ store }); + expect(binding.credential()).toBe(''); + expect(binding.bound()).toBe(false); + }); + + it('is read on every call: a bind stored while the server runs is presented at once, an unbind withdraws it', () => { + const binding = createCloudBinding({ store }); + store.write({ runtimeToken: 'oscc_bound-token' }); + expect(binding.credential()).toBe('oscc_bound-token'); + expect(binding.bound()).toBe(true); + store.write({ runtimeToken: '', runtimeId: 'rt_1' }); + expect(binding.credential()).toBe(''); + expect(binding.bound()).toBe(false); + }); + + it('is the env→cloud service key when one is set, over a stored bearer', () => { + store.write({ runtimeToken: 'oscc_bound-token' }); + expect(createCloudBinding({ store, controlPlaneApiKey: ' svc-key ' }).credential()).toBe('svc-key'); + vi.stubEnv('OS_CLOUD_API_KEY', 'env-key'); + expect(createCloudBinding({ store }).credential()).toBe('env-key'); + }); +}); + +describe('createCloudBinding — the control-plane URL', () => { + it('is the configured URL, else OS_CLOUD_URL, else the default, without a trailing slash', () => { + expect(createCloudBinding({ store, controlPlaneUrl: 'https://cp.example.com/' }).controlPlaneUrl()).toBe('https://cp.example.com'); + vi.stubEnv('OS_CLOUD_URL', 'https://env.example.com'); + expect(createCloudBinding({ store }).controlPlaneUrl()).toBe('https://env.example.com'); + vi.stubEnv('OS_CLOUD_URL', ''); + expect(createCloudBinding({ store }).controlPlaneUrl()).toBe(DEFAULT_CLOUD_URL); + }); + + it('is empty on a deployment that declared it has no control plane', () => { + vi.stubEnv('OS_CLOUD_URL', 'off'); + expect(createCloudBinding({ store }).controlPlaneUrl()).toBe(''); + }); +}); + +describe('CloudConnectionPlugin — registers the binding in init()', () => { + it('registers cloud-binding, declared in providesServices, reading the plugin\'s own credential store', async () => { + const services = new Map(); + const plugin = new CloudConnectionPlugin({ credentialPath: join(dir, 'cloud-connection.json'), controlPlaneUrl: 'https://cp.example.com' }); + await plugin.init({ + hook() {}, + getService: (name: string) => services.get(name), + registerService: (name: string, service: unknown) => services.set(name, service), + logger: { warn() {} }, + }); + expect(plugin.providesServices).toContain(CLOUD_BINDING_SERVICE); + const binding = services.get(CLOUD_BINDING_SERVICE) as ICloudBindingService; + expect(binding.bound()).toBe(false); + store.write({ runtimeToken: 'oscc_bound-token' }); + expect(binding.credential()).toBe('oscc_bound-token'); + expect(binding.controlPlaneUrl()).toBe('https://cp.example.com'); + }); +}); diff --git a/packages/core/src/cloud-managed.test.ts b/packages/core/src/cloud-managed.test.ts index 97c48401a06..620efa58835 100644 --- a/packages/core/src/cloud-managed.test.ts +++ b/packages/core/src/cloud-managed.test.ts @@ -14,10 +14,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ObjectKernel } from './kernel.js'; import { LiteKernel } from './lite-kernel.js'; -import { isCloudManaged, resetCloudManagedForTesting, resolveControlPlaneApiKey } from './stack-auth.js'; +import { isCloudManaged, isControlPlaneDeclined, resetCloudManagedForTesting, resolveControlPlaneApiKey } from './stack-auth.js'; import type { Plugin } from './types.js'; -const ENV_VARS = ['OS_CLOUD_MANAGED', 'OS_CLOUD_API_KEY', 'NODE_ENV'] as const; +const ENV_VARS = ['OS_CLOUD_MANAGED', 'OS_CLOUD_API_KEY', 'OS_CLOUD_URL', 'NODE_ENV'] as const; const saved = Object.fromEntries(ENV_VARS.map((k) => [k, process.env[k]])); beforeEach(() => { for (const k of ENV_VARS) delete process.env[k]; @@ -118,6 +118,19 @@ describe('resolveControlPlaneApiKey — the one derivation of "a control plane i }); }); +describe('isControlPlaneDeclined — the one reader of the "no control plane" spellings (#29920)', () => { + it('answers the four documented spellings, any case, from the argument or OS_CLOUD_URL; empty and a URL are not one', () => { + for (const spelling of ['off', 'none', 'local', 'disabled', ' OFF ']) expect(isControlPlaneDeclined(spelling)).toBe(true); + expect(isControlPlaneDeclined('')).toBe(false); + expect(isControlPlaneDeclined('https://cloud.example.com')).toBe(false); + expect(isControlPlaneDeclined()).toBe(false); + process.env.OS_CLOUD_URL = 'off'; + expect(isControlPlaneDeclined()).toBe(true); + // The host's explicit argument is asked about itself, not the environment. + expect(isControlPlaneDeclined('https://cloud.example.com')).toBe(false); + }); +}); + describe('the fuse at kernel boot — refused before any plugin initialises', () => { const probe = () => { const calls: string[] = []; From 634f4145cf0ecc7cd2e894e24604eded9910d19f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 22:05:14 +0000 Subject: [PATCH 6/9] wip(spec, cloud-connection): flip relay marker pins to served; pin strict data and rate-limit retryable WIP handover: the seat released the card at 22:01Z. These pins were written, but the full gate set has not run yet; the handover note on the card lists it. Claude-Session: https://claude.ai/code/session_01Utp6CZX77joBRgcruRQSMN Co-authored-by: Claude --- .../src/cloud-binding.test.ts | 2 +- packages/spec/src/api/push-relay.test.ts | 35 ++++++++++++++++--- packages/spec/src/system/push-config.test.ts | 7 ++-- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/cloud-connection/src/cloud-binding.test.ts b/packages/cloud-connection/src/cloud-binding.test.ts index 93cb9df4f68..d6ba71ede76 100644 --- a/packages/cloud-connection/src/cloud-binding.test.ts +++ b/packages/cloud-connection/src/cloud-binding.test.ts @@ -81,7 +81,7 @@ describe('CloudConnectionPlugin — registers the binding in init()', () => { getService: (name: string) => services.get(name), registerService: (name: string, service: unknown) => services.set(name, service), logger: { warn() {} }, - }); + } as never); expect(plugin.providesServices).toContain(CLOUD_BINDING_SERVICE); const binding = services.get(CLOUD_BINDING_SERVICE) as ICloudBindingService; expect(binding.bound()).toBe(false); diff --git a/packages/spec/src/api/push-relay.test.ts b/packages/spec/src/api/push-relay.test.ts index b881ff43f67..40b02f1ef48 100644 --- a/packages/spec/src/api/push-relay.test.ts +++ b/packages/spec/src/api/push-relay.test.ts @@ -177,9 +177,9 @@ describe('PushRelayErrorResponseSchema', () => { }); }); -// Declared ahead of its producer (ADR-0049): no push channel sends this -// request yet, and each schema says so in its description. -describe('the push relay wire shape is declared, not yet served', () => { +// Served (#29920): `@objectstack/service-push` sends this request, so no +// schema here carries a PLANNED marker any more. +describe('the push relay wire shape is served', () => { it.each([ ['PushRelayPayloadSchema', PushRelayPayloadSchema], ['PushRelayDeliverySchema', PushRelayDeliverySchema], @@ -188,7 +188,32 @@ describe('the push relay wire shape is declared, not yet served', () => { ['PushRelayResponseSchema', PushRelayResponseSchema], ['PushRelayErrorSchema', PushRelayErrorSchema], ['PushRelayErrorResponseSchema', PushRelayErrorResponseSchema], - ] as const)('%s carries the PLANNED marker', (_name, schema) => { - expect(schema.description).toMatch(/^\[PLANNED — /); + ] as const)('%s carries no PLANNED marker', (_name, schema) => { + expect(schema.description).not.toMatch(/PLANNED/); + }); +}); + +// The two carried-in narrowings from the wire's contract review: the payload +// data is judged strictly (a content key is refused, never stripped), and a +// rate limit is always retryable. +describe('the push relay wire narrows where the review asked', () => { + it('refuses a key the delivery data does not declare, with the ID-only guidance', () => { + const verdict = PushRelayPayloadSchema.safeParse({ data: { notificationId: 'ntf_1', title: 'Deal won' }, text: 'You have a new notification' }); + expect(verdict.success).toBe(false); + expect(verdict.error!.issues[0]!.path).toEqual(['data']); + expect(verdict.error!.issues[0]!.message).toMatch(/Unrecognized key.*`title`[\s\S]*ID-only/); + }); + + it('keeps every declared delivery data member', () => { + const data = { notificationId: 'ntf_1', route: '/opportunity/1', origin: 'https://crm.example.com' }; + expect(PushRelayPayloadSchema.parse({ data, text: 'x' }).data).toEqual(data); + }); + + it('reads a RATE_LIMIT_EXCEEDED answer as retryable, and refuses one that says it is not', () => { + const limit = { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many', httpStatus: 429, retryAfterSeconds: 5, helpText: 'Wait' }; + expect(PushRelayErrorSchema.parse(limit).retryable).toBe(true); + expect(PushRelayErrorSchema.safeParse({ ...limit, retryable: false }).success).toBe(false); + const quota = { code: 'QUOTA_EXCEEDED', message: 'Quota', httpStatus: 429, helpText: 'Raise the plan' }; + expect(PushRelayErrorSchema.parse(quota).retryable).toBe(false); }); }); diff --git a/packages/spec/src/system/push-config.test.ts b/packages/spec/src/system/push-config.test.ts index 38672280fa7..4d0d22a1b7f 100644 --- a/packages/spec/src/system/push-config.test.ts +++ b/packages/spec/src/system/push-config.test.ts @@ -204,13 +204,14 @@ describe('PushServiceConfigSchema — direct vendor blocks', () => { // (ADR-0049: a marker states a fact). The relay is refused at boot until its // sender ships (#29920), so its block keeps a marker that says so. describe('push transport configuration is read', () => { - it('marks the relay block not yet served, and says the push service refuses it', () => { - expect(PushRelayTransportConfigSchema.description).toMatch(/^\[PLANNED — not yet served\] /); - expect(PushRelayTransportConfigSchema.description).toMatch(/refuses it at boot/); + it('describes the relay block as served, sending once the server is bound (#29920)', () => { + expect(PushRelayTransportConfigSchema.description).not.toMatch(/PLANNED/); + expect(PushRelayTransportConfigSchema.description).toMatch(/once the server is bound to the cloud/); }); it.each([ ['PushServiceConfigSchema', PushServiceConfigSchema], + ['PushRelayTransportConfigSchema', PushRelayTransportConfigSchema], ['PushDirectTransportConfigSchema', PushDirectTransportConfigSchema], ['ApnsTransportConfigSchema', ApnsTransportConfigSchema], ['FcmTransportConfigSchema', FcmTransportConfigSchema], From 2e35f600914610929e3641f874f56be98fb383c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 23:13:02 +0000 Subject: [PATCH 7/9] docs(changeset): name only the retired export on the FROM side; state the two wire narrowings as prose check:adr-0087-registration reads every FROM line of a runtime-export-retired changeset as a retired export. The package name in parentheses and the two wire-schema lines were read as operands the marker does not name. Claude-Session: https://claude.ai/code/session_016WV4cznEfnAJ8CyWBdiL2G Co-authored-by: Claude --- .changeset/29920-push-relay-cloud-binding.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/29920-push-relay-cloud-binding.md b/.changeset/29920-push-relay-cloud-binding.md index 9b1111bd08d..4914d0e068f 100644 --- a/.changeset/29920-push-relay-cloud-binding.md +++ b/.changeset/29920-push-relay-cloud-binding.md @@ -24,6 +24,6 @@ Metadata-protocol: none **BREAKING — what narrows, and what to do:** -- FROM `PUSH_RELAY_NOT_SERVED_MESSAGE` (`@objectstack/service-push`) TO `PUSH_RELAY_AIR_GAPPED_MESSAGE`: 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. -- FROM a relay payload `data` with extra keys (stripped) TO refused: send only `notificationId`, `route` and `origin` in `data`; a content key such as `title` is refused with the ID-only guidance. -- FROM a `RATE_LIMIT_EXCEEDED` relay error with any `retryable` TO `retryable: true` (the default when absent): a relay that answered `retryable: false` on a rate limit answers `true` or leaves it out. +- 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. From 1b82707de37511f76495017893bc60ecf63733f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 23:43:17 +0000 Subject: [PATCH 8/9] test(service-push): read @objectstack/spec/api and @objectstack/core from source The relay tests parse with PushRelayRequestSchema and the plugin reads isControlPlaneDeclined from core; unaliased, both resolve to dist and the verdict follows build state (check:test-source-alias). Claude-Session: https://claude.ai/code/session_016WV4cznEfnAJ8CyWBdiL2G Co-authored-by: Claude --- packages/services/service-push/vitest.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/services/service-push/vitest.config.ts b/packages/services/service-push/vitest.config.ts index 4c49dc2450f..89ca038fe8c 100644 --- a/packages/services/service-push/vitest.config.ts +++ b/packages/services/service-push/vitest.config.ts @@ -19,6 +19,11 @@ export default defineConfig({ resolve: { alias: [ { find: /^@objectstack\/spec\/system$/, replacement: path.resolve(__dirname, '../../spec/src/system/index.ts') }, + // The relay sender parses its request with `PushRelayRequestSchema` + // (`@objectstack/spec/api`), and the plugin reads `isControlPlaneDeclined` + // from `@objectstack/core`: both from source, for the same reason. + { find: /^@objectstack\/spec\/api$/, replacement: path.resolve(__dirname, '../../spec/src/api/index.ts') }, + { find: /^@objectstack\/core$/, replacement: path.resolve(__dirname, '../../core/src/index.ts') }, ], }, }); From d14d749e6b978c11f1d959ba985a21fd864d776e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 00:46:08 +0000 Subject: [PATCH 9/9] docs(adr): ADR-0012 reads the hosted push relay as served The status line, the push row in section 4, the dead-token note in section 6 and section 6.1's served-on-main bullet said the relay was not served and anchored the retired PUSH_RELAY_NOT_SERVED_MESSAGE export. Each amended passage quotes what it replaces. The push-config test's heading comment says the same. Claude-Session: https://claude.ai/code/session_016WV4cznEfnAJ8CyWBdiL2G Co-authored-by: Claude --- docs/adr/0012-notification-platform.md | 8 ++++---- packages/spec/src/system/push-config.test.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/adr/0012-notification-platform.md b/docs/adr/0012-notification-platform.md index b763e86f7b4..162f2ed18ef 100644 --- a/docs/adr/0012-notification-platform.md +++ b/docs/adr/0012-notification-platform.md @@ -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. · **Amended (2026-11-03, #29041; extended the same day by #29817 on ruling objectstack-ai/cloud#3519 — 1C 2A 3A 4B 5A 6B 7B, 2026-11-03 — and the spec shape of #29816)**: 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. The #29817 extension amends: the `push` backend in §4 (it read "APNs (HTTP/2, token auth) + FCM (HTTP v1)" alone; it is now `relay` (hosted, the default) or `direct` (APNs/FCM, white-label)); §6's domestic-vendor bullet (it read "alternate channel implementations"; `vendor` on `sys_user_device` is a token type the relay routes on, not a plugin boundary) and the new §6.1, which records the relay's privacy rule (ID-only, then end-to-end encryption with a device key) and the air-gap rule (no system push; the in-app inbox only); the TL;DR item 2 and the Goals bullet "Four built-in channels" (both read "`push` (APNs + FCM)"); the Goals bullet "Plugin extension" and the Non-Goals bullet "Domestic mobile push vendors" (both read "domestic push vendors ship as plugins / plugin transports"); the M3 milestone line; and risk 5. Each amended passage quotes the wording it replaces; three passages are new and replace nothing: the Non-Goals bullet "System push in an air-gapped deployment", §6.1 and the Decision bullet "Push transports". ADR-0030 is unchanged. +**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; extended the same day by #29817 on ruling objectstack-ai/cloud#3519 — 1C 2A 3A 4B 5A 6B 7B, 2026-11-03 — and the spec shape of #29816)**: push ships as `@objectstack/service-push` (the hosted relay, the default transport per cloud#3519, and the direct APNs and FCM transports, behind the kernel's `push` service; amended 2026-11-06, #29920 / PR #30749: this parenthesis read "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. The #29817 extension amends: the `push` backend in §4 (it read "APNs (HTTP/2, token auth) + FCM (HTTP v1)" alone; it is now `relay` (hosted, the default) or `direct` (APNs/FCM, white-label)); §6's domestic-vendor bullet (it read "alternate channel implementations"; `vendor` on `sys_user_device` is a token type the relay routes on, not a plugin boundary) and the new §6.1, which records the relay's privacy rule (ID-only, then end-to-end encryption with a device key) and the air-gap rule (no system push; the in-app inbox only); the TL;DR item 2 and the Goals bullet "Four built-in channels" (both read "`push` (APNs + FCM)"); the Goals bullet "Plugin extension" and the Non-Goals bullet "Domestic mobile push vendors" (both read "domestic push vendors ship as plugins / plugin transports"); the M3 milestone line; and risk 5. Each amended passage quotes the wording it replaces; three passages are new and replace nothing: the Non-Goals bullet "System push in an air-gapped deployment", §6.1 and the Decision bullet "Push transports". 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) @@ -307,7 +307,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` | `service-push` (transports) + `push-channel.ts` in `service-messaging` (channel); amended 2026-11-03, #29041 and #29817 | `relay` (hosted, **the default**): the server POSTs an ID-only `PushRelayRequestSchema` to the hosted push relay in objectstack-ai/cloud, authenticated by its cloud binding credential; the relay holds the store-published app's APNs and FCM credentials and forwards each delivery to its vendor. `direct` (APNs HTTP/2 with token auth + FCM HTTP v1, the operator's own credentials): for a white-label build only. The two are the `transport` union on `PushServiceConfigSchema` (#29816); §6.1 has the rules. Before 2026-11-03 this cell read "APNs (HTTP/2, token auth) + FCM (HTTP v1)" alone | `direct` shipped (#29041, PR #29794). `relay` decided (cloud#3519), not yet served: `PushServicePlugin` refuses a relay block, the default `{}` included, at init until #29920 ships the sender | +| `push` | `service-push` (transports) + `push-channel.ts` in `service-messaging` (channel); amended 2026-11-03, #29041 and #29817 | `relay` (hosted, **the default**): the server POSTs an ID-only `PushRelayRequestSchema` to the hosted push relay in objectstack-ai/cloud, authenticated by its cloud binding credential; the relay holds the store-published app's APNs and FCM credentials and forwards each delivery to its vendor. `direct` (APNs HTTP/2 with token auth + FCM HTTP v1, the operator's own credentials): for a white-label build only. The two are the `transport` union on `PushServiceConfigSchema` (#29816); §6.1 has the rules. Before 2026-11-03 this cell read "APNs (HTTP/2, token auth) + FCM (HTTP v1)" alone | `direct` shipped (#29041, PR #29794). `relay` shipped (#29920, PR #30749): the relay transport sends through the hosted relay with the server's cloud binding credential, and `PushServicePlugin` refuses a relay block only on a deployment that declared it has no control plane (§6.1). *Amended 2026-11-06 (#29920, PR #30749): this cell read "`relay` decided (cloud#3519), not yet served: `PushServicePlugin` refuses a relay block, the default `{}` included, at init until #29920 ships the sender".* | All four bind into `channel-registry` during `onEnable`. @@ -376,7 +376,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. + > 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 and the hosted relay, the default per cloud#3519; a relay `invalid` answer reaches the channel as a dead token and is retired the same way. *Amended 2026-11-06 (#29920, PR #30749): this sentence read "`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 are token types the hosted relay routes on, not a plugin boundary** (amended 2026-11-03, cloud#3519). `vendor` on `sys_user_device` says which vendor issued the token, so the sender knows where the delivery goes; it names no plugin and no channel. Core ships `apns` and `fcm` (`packages/spec/src/system/push-config.zod.ts#PushVendorSchema`, closed on `main` at this amendment; `packages/spec/src/system/push-device.zod.ts#UserDeviceSchema` narrows `vendor` to it). In phase 2 the relay fronts HMS / Honor / Xiaomi / OPPO / vivo as well: the server still sends one relay request and the relay routes each delivery by its `vendor`, and `PushVendorSchema` widens to those values in the same change that routes them (its docblock says a new value is a spec change made together with its transport, and #29816 kept it `apns | fcm` on purpose). No card tracks the phase-2 widening yet. *This bullet read: "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."* ### 6.1 Push transports — the hosted relay and direct delivery (amended 2026-11-03, cloud#3519) @@ -388,7 +388,7 @@ Ruling objectstack-ai/cloud#3519 (**1C 2A 3A 4B 5A 6B 7B**, 2026-11-03) decides * **Vendor is a token type the relay routes on.** `vendor` on the device row tells the sender, and through it the relay, which vendor the token belongs to, and nothing else. Phase 1 routes `apns` and `fcm`; phase 2 adds the domestic vendors (HMS / Honor / Xiaomi / OPPO / vivo) behind the same relay and widens `PushVendorSchema` with them. There is no per-vendor plugin and no per-vendor channel. * **Privacy — the relay never receives plaintext content.** Phase 1 is ID-only: a relay delivery's payload (`packages/spec/src/api/push-relay.zod.ts#PushRelayPayloadSchema`) carries the push delivery data — the notification id, its same-server `route` and the sending server's `origin` — and one generic visible line (for example "You have a new notification", in the device locale), and nothing else. `title`, `body`, `subtitle`, `message` and `content` are refused by name; the shell reads the notification from the server after the tap, by its id; the relay treats the payload as opaque and puts it in the vendor message unchanged. Phase 2 is end-to-end encryption of the payload with a device key the app registers (the device registration gains a `pushKey` in that phase, which #29816 left out on purpose; no card yet). The direct transport is outside this rule: it reaches APNs and FCM under the operator's own credentials with no third party in between, and the push channel sends the notification's title and body through it today (PR #29794). * **Air-gap — no system push; the in-app inbox only.** The relay is hosted in objectstack-ai/cloud and there is no self-hosted relay. A deployment that cannot reach it has no system push, and the direct transport does not restore one, since it needs APNs and FCM. Its users read notifications in the in-app `inbox` channel, which needs no external service. (Email's own air-gap rule, SMTP in core, is §5 and is unchanged.) -* **What `main` serves at this amendment** (objectstack-ai/objectstack at 6a4737ab, 2026-11-03). `direct` is served: `@objectstack/service-push` builds `ApnsPushTransport` (HTTP/2, token auth) and `FcmPushTransport` (HTTP v1) from the direct block and registers the kernel's `push` service; the `push` channel shell in `packages/services/service-messaging/src/push-channel.ts` fans one delivery out to the recipient's enabled devices and retires dead tokens (#29041, PR #29794). `relay` is decided and not served: `PushServicePlugin` refuses a relay block, the default `{}` included, at init with `packages/services/service-push/src/push-plugin.ts#PUSH_RELAY_NOT_SERVED_MESSAGE`, so no server is left believing it pushes when nothing would deliver; the relay sender is #29920; until it ships, the relay transport block (`PushRelayTransportConfigSchema`) and the seven relay object schemas in `packages/spec/src/api/push-relay.zod.ts` (`PushRelayPayloadSchema`, `PushRelayDeliverySchema`, `PushRelayRequestSchema`, `PushRelayResultSchema`, `PushRelayResponseSchema`, `PushRelayErrorSchema`, `PushRelayErrorResponseSchema`) carry a `[PLANNED — not yet served]` marker, and the two enums there, `PushRelayPrioritySchema` and `PushRelayResultStatusSchema`, carry none. The phase-2 vendors and the end-to-end encryption are decided and planned, with no card yet. +* **What `main` serves** (amended 2026-11-06, #29920 / PR #30749). Both transports are served by `@objectstack/service-push`, behind the kernel's `push` service. `direct`: `ApnsPushTransport` (HTTP/2, token auth) and `FcmPushTransport` (HTTP v1), built from the direct block (#29041, PR #29794). `relay`, the default: `packages/services/service-push/src/transports/relay.ts#RelayPushSender` POSTs one ID-only `PushRelayRequestSchema` per push to the relay endpoint (the relay block's `endpoint`, else the control-plane URL plus `packages/services/service-push/src/transports/relay.ts#PUSH_RELAY_PATH`), with the bearer read on every send from the `cloud-binding` service that `@objectstack/cloud-connection` registers (`packages/cloud-connection/src/cloud-binding.ts#createCloudBinding`, the interface `packages/spec/src/contracts/cloud-binding-service.ts#ICloudBindingService`). While the server is not bound, the relay transport lists no vendor, so the push channel is suppressed as `transport_not_configured` and notifications reach the in-app inbox; the bind takes effect without a restart. A deployment that declared it has no control plane (`OS_CLOUD_URL=off`, `none`, `local`, `disabled`) has its relay block refused at init with `packages/services/service-push/src/push-plugin.ts#PUSH_RELAY_AIR_GAPPED_MESSAGE`, the air-gap rule above. The `push` channel shell in `packages/services/service-messaging/src/push-channel.ts` fans one delivery out to the recipient's enabled devices and retires dead tokens. The relay schemas in `packages/spec/src/api/push-relay.zod.ts` no longer carry a `[PLANNED — not yet served]` marker. The phase-2 vendors and the end-to-end encryption are decided and planned, with no card yet. *Amended 2026-11-06 (#29920, PR #30749): this bullet was headed "What `main` serves at this amendment (objectstack-ai/objectstack at 6a4737ab, 2026-11-03)", and its relay half read "`relay` is decided and not served: `PushServicePlugin` refuses a relay block, the default `{}` included, at init with `PUSH_RELAY_NOT_SERVED_MESSAGE` (`push-plugin.ts`), so no server is left believing it pushes when nothing would deliver; the relay sender is #29920; until it ships, the relay transport block (`PushRelayTransportConfigSchema`) and the seven relay object schemas in `push-relay.zod.ts` … carry a `[PLANNED — not yet served]` marker". That export is retired by PR #30749, so the quotation names it without its anchor.* ### 7. Topic + Preference matrix diff --git a/packages/spec/src/system/push-config.test.ts b/packages/spec/src/system/push-config.test.ts index 4d0d22a1b7f..e061e5c39e2 100644 --- a/packages/spec/src/system/push-config.test.ts +++ b/packages/spec/src/system/push-config.test.ts @@ -199,10 +199,10 @@ describe('PushServiceConfigSchema — direct vendor blocks', () => { }); }); -// Read by `PushServicePlugin` (`@objectstack/service-push`, #29041): the direct -// transport and its vendor blocks are served, so they claim nothing unread -// (ADR-0049: a marker states a fact). The relay is refused at boot until its -// sender ships (#29920), so its block keeps a marker that says so. +// Read by `PushServicePlugin` (`@objectstack/service-push`): both transports +// are served, the direct one (#29041) and the hosted relay (#29920), so no +// block claims anything unread (ADR-0049: a marker states a fact). The relay +// is refused at boot only on a deployment that declared no control plane. describe('push transport configuration is read', () => { it('describes the relay block as served, sending once the server is bound (#29920)', () => { expect(PushRelayTransportConfigSchema.description).not.toMatch(/PLANNED/);