diff --git a/.changeset/29820-push-capability-serve.md b/.changeset/29820-push-capability-serve.md new file mode 100644 index 00000000000..b5b11c7950c --- /dev/null +++ b/.changeset/29820-push-capability-serve.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/service-push': minor +'@objectstack/cli': minor +'@objectstack/verify': minor +--- + +feat(push): `requires: ['push']` and the stack's `push` key turn mobile push on under `objectstack serve`, and a relay configuration is refused at boot + +Clause-②: yes (widening) +Metadata-protocol: added +Skills-docs: follow-up owed — #29969 (skills/objectstack-platform/evals/config-plugins-ops.json: no eval asks for `requires: ['push']` or the `push` key); this diff teaches both in `skills/objectstack-platform/SKILL.md` (the top-level key enumeration and the `requires` table) and in the new `content/docs/kernel/runtime-services/push-service.mdx` + +A deployment that runs `objectstack serve` from a stack file can now turn on mobile push without host code. Before this, the only way to register a push service was a host that composed `PushServicePlugin` in code, so a `notify` node naming `push` had nothing to deliver through on a served stack. + +- **`@objectstack/spec`: the `push` capability token and the `push` stack key.** `push` joins `PLATFORM_CAPABILITY_TOKENS`, provided by `@objectstack/service-push` in the open edition. It is requested, never on the always-on slate. The stack definition gains a top-level `push` key beside `email` and `sms`: it is `PushServiceConfigSchema`, composed as a single value (`COMPOSE_KEY_DISPOSITIONS`). Authoring accepts a direct block, a relay block and absence; it refuses an inline secret, by key name (`key`, `privateKey`, `serviceAccount`, …) with `credentialRef` as the remedy, or pasted into a `credentialRef` (ADR-0143 D3). `PushServiceConfigSchema`'s docblock now names the stack key and `objectstack serve` as its reader; `PushRelayTransportConfigSchema` keeps its `[PLANNED — not yet served]` marker. +- **`@objectstack/core`: the `push` provider row and its argument.** `CAPABILITY_PROVIDERS.push` names `PushServicePlugin`, and `resolveCapabilityArgument('push', …)` builds its argument from the stack's `push` key through `resolvePushCapabilityArg`, read off the provider module as the `email` and `sms` readers are. Both `objectstack serve` and `@objectstack/verify`'s `bootStack` read this table, so an app's tests get the push service its server gets. +- **`@objectstack/service-push`: `resolvePushCapabilityArg`.** It parses the `push` key with `PushServiceConfigSchema` and refuses, before the kernel boots, two configurations: the relay transport (an absent key and `push: {}` read as it), whose sender has not shipped, with a message that says so and prescribes `transport: 'direct'` with an `apns` and/or `fcm` block; and anything the schema refuses, an inline secret first among them, naming each path and the schema's guidance and never the value. On a stack that declares `requires: ['push']` either refusal fails the boot as `Capability "push" (@objectstack/service-push) failed to start: …`. +- **`@objectstack/cli` and `@objectstack/verify`** depend on `@objectstack/service-push`, so the provider the token names resolves out of the box, as the open edition promises. + +To turn push on, add `requires: ['push']` and a direct block naming each vendor's secret by reference: + +```ts +push: { + transport: 'direct', + apns: { teamId: 'ABCDE12345', keyId: 'KEY1234567', topic: 'com.example.app', environment: 'production', credentialRef: 'APNS_SIGNING_KEY' }, + fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' }, +}, +``` + +In the open tier each `credentialRef` is the name of an environment variable holding the secret. A `push` key on a stack whose `requires` does not name `push` is not read, and `objectstack serve` logs one boot warning saying so. The runtime-services chapter has a new `services.push` page. diff --git a/content/docs/getting-started/quick-start.mdx b/content/docs/getting-started/quick-start.mdx index 1c9604dd81e..3a7f14c5660 100644 --- a/content/docs/getting-started/quick-start.mdx +++ b/content/docs/getting-started/quick-start.mdx @@ -232,10 +232,11 @@ schema page is the authority until one lands. **Not in the table, and why.** `viewItems` and `runtimeModule` are not authorable at all (`viewItems` is `z.never()`, the machine-assembled channel for runtime-assembled manifests; `runtimeModule` is written by `objectstack build`); -`manifest`, `i18n`, `api`, `server`, `appName`, `email`, `sms`, `requires`, +`manifest`, `i18n`, `api`, `server`, `appName`, `email`, `sms`, `push`, `requires`, `tiers`, `plugins`, `devPlugins`, `devHint`, `devLogins` and `demoStaff` configure the stack rather than declare metadata items (`email` and `sms` carry no -credentials — those stay in the environment or in Settings; `demoStaff` names the +credentials — those stay in the environment or in Settings; `push` names each +vendor secret by `credentialRef` and is read only when `requires` names `push`; `demoStaff` names the demo accounts a local `os dev` boot creates, and `os build` leaves it out of `objectstack.json`); and `functions` and `onEnable` are code the runtime calls, not metadata it stores. diff --git a/content/docs/kernel/index.mdx b/content/docs/kernel/index.mdx index 28ff12c7fb8..a50cbabd4cf 100644 --- a/content/docs/kernel/index.mdx +++ b/content/docs/kernel/index.mdx @@ -18,6 +18,7 @@ The kernel is ObjectStack's runtime: it loads your metadata artifact, hosts plug | [`services.sharing`](/docs/kernel/runtime-services/sharing-service) | stable | `buildReadFilter`, `canEdit`, `canDelete`, `canManageShares`, `grant`/`revoke`, `listShares` | | [`services.email`](/docs/kernel/runtime-services/email-service) | stable | `send`, `sendTemplate` | | [`services.sms`](/docs/kernel/runtime-services/sms-service) | stable | Outbound SMS delivery through pluggable providers (Aliyun SMS, Twilio) | +| [`services.push`](/docs/kernel/runtime-services/push-service) | experimental | Mobile push to APNs and FCM, turned on by `requires: ['push']` | | [`services.queue`](/docs/kernel/runtime-services/queue-service) | stable | Background work and queues | | [`services.settings`](/docs/kernel/runtime-services/settings-service) | stable | App/environment settings | | [`services.storage`](/docs/kernel/runtime-services/storage-service) | stable | File storage | diff --git a/content/docs/kernel/runtime-services/index.mdx b/content/docs/kernel/runtime-services/index.mdx index ce49eea6f2b..009454a344f 100644 --- a/content/docs/kernel/runtime-services/index.mdx +++ b/content/docs/kernel/runtime-services/index.mdx @@ -30,6 +30,7 @@ This chapter documents the runtime `services.*` APIs used in hook/action/flow/pl - `services.queue` - `services.email` - `services.sms` +- `services.push` - `services.settings` - `services.storage` @@ -56,6 +57,7 @@ Each page links the canonical TypeScript source used to derive signatures. - Queue: `packages/spec/src/contracts/queue-service.ts` - Email: `packages/spec/src/contracts/email-service.ts` - SMS: `packages/spec/src/contracts/sms-service.ts` +- Push: `packages/services/service-push/src/push-service.ts` - Storage: `packages/spec/src/contracts/storage-service.ts` - Settings: `packages/services/service-settings/src/settings-service.ts` - Audit: `packages/plugins/plugin-audit/src/auth-event-audit.ts` diff --git a/content/docs/kernel/runtime-services/meta.json b/content/docs/kernel/runtime-services/meta.json index d46a31c9c4c..3f59d1d26e7 100644 --- a/content/docs/kernel/runtime-services/meta.json +++ b/content/docs/kernel/runtime-services/meta.json @@ -7,6 +7,7 @@ "queue-service", "email-service", "sms-service", + "push-service", "settings-service", "storage-service", "examples", diff --git a/content/docs/kernel/runtime-services/push-service.mdx b/content/docs/kernel/runtime-services/push-service.mdx new file mode 100644 index 00000000000..4c977e6d152 --- /dev/null +++ b/content/docs/kernel/runtime-services/push-service.mdx @@ -0,0 +1,180 @@ +--- +title: services.push — the mobile push service API +navTitle: services.push +description: Mobile push delivery to APNs and FCM, configured from the stack's push key with credentials by reference. +--- + +- **Stability:** `experimental` +- **Canonical source:** `packages/services/service-push/src/push-service.ts` +- **Registry slot:** `push` — resolve with `ctx.getService('push')`. + +## Methods + +```ts +services.push.vendors(): PushVendor[] +services.push.send(message: PushMessage): Promise +``` + +`PushMessage` is one push to one device: the `vendor` the token belongs to +(`apns` or `fcm`), the device `token`, a plain-text `title` and `body`, and +`data`, the delivery data the app shell reads (string values only, because FCM +accepts only strings there). + +`vendors()` lists the vendors this deployment has a transport for. An empty list +means push delivers nothing. + +Application code rarely calls this service directly. A `notify` flow node or a +`NotificationService.emit` that names the `push` channel goes through the +messaging service, whose `push` channel reads the recipient's devices and sends +through this service. + +## Returns + +`PushSendResult`, one of: + +- `{ status: 'sent', messageId? }`: the vendor accepted the push. `messageId` + is APNs' `apns-id` or FCM's message `name`. +- `{ status: 'invalid_token', reason }`: the vendor reported the token dead + (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`). +- `{ status: 'failed', error, retryable }`: anything else. `retryable` is true + for a timeout, a 429 or a 5xx, and false for a refused credential or payload. + +`send()` never throws: a transport error is answered `failed`. + +## Turning it on + +Push is a requested capability, never on by default. A stack turns it on with +`requires: ['push']` and configures it with the top-level `push` key, which is +`PushServiceConfigSchema`: + +```ts +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { /* … */ }, + requires: ['push'], + push: { + transport: 'direct', + apns: { + teamId: 'ABCDE12345', + keyId: 'KEY1234567', + topic: 'com.example.app', + environment: 'production', + credentialRef: 'APNS_SIGNING_KEY', + }, + fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' }, + }, +}); +``` + +A `push` key without `push` in `requires` is not read: `objectstack serve` logs +one boot warning naming the key and the fix. With the token, `objectstack serve` +mounts `PushServicePlugin` from +`@objectstack/service-push`, which the CLI ships with. `@objectstack/verify`'s +`bootStack` mounts it the same way, so an app's tests run with the same push +service its server gets. + +### The two vendors + +- **APNs** (Apple Push Notification service), token-based authentication: the + Apple Developer `teamId`, the auth key's `keyId`, the app bundle ID as + `topic`, and the `environment`, `sandbox` for development builds or + `production` for TestFlight and App Store builds. +- **FCM** (Firebase Cloud Messaging) over the HTTP v1 API: the Firebase + `projectId`, authenticated by a service account. + +Each vendor block is optional, and at least one is required. A vendor with no +block is a vendor this deployment does not push to; devices registered on it are +skipped. Other Android vendors (HMS, Xiaomi, OPPO, vivo) are not transports of +this service: they plug in as separate channel implementations. + +### Credential references + +Each vendor has one secret, the APNs `.p8` signing key and the FCM +service-account JSON, and the stack names it by `credentialRef`, never inline. +In the open tier a `credentialRef` is the name of an environment variable, read +when the transport first signs a request. For the example above, set +`APNS_SIGNING_KEY` to the contents of the `.p8` file and `FCM_SERVICE_ACCOUNT` to +the service-account JSON. + +The stack definition is compiled into the published artifact, so an inline +secret is refused twice: + +- at authoring, by `defineStack` and `os validate`. A key an operator would reach + for (`key`, `privateKey`, `serviceAccount`, …) is refused by name with + `credentialRef` as the remedy, and a PEM block or JSON document pasted into + `credentialRef` itself is refused, because a reference never contains + whitespace or braces; +- at boot, because a compiled artifact or a plain config object can reach + `objectstack serve` without passing `defineStack`. The `push` capability fails + to start, naming each refused path and its remedy. The refusal never repeats + the value. + +A `credentialRef` that names an unset variable is not a boot error: the push to +that vendor fails with `credential_unresolved`, naming the reference. + +### The hosted relay: not served yet + +`PushServiceConfigSchema` has a second transport, `transport: 'relay'`, and it is +the schema's default: a `push` block with no vendor block, `push: {}` and an +absent `push` key all read as the relay. The relay delivers to the +store-published ObjectOS app, whose APNs key and Firebase project stay with +ObjectStack, and authenticates the server by its cloud binding credential. + +**Its sender has not shipped.** Until it does, a stack that requires `push` with +a relay configuration (including no `push` key) fails at boot: + +``` +Capability "push" (@objectstack/service-push) failed to start: PushServicePlugin: the hosted push relay +(transport: 'relay', which is also what an empty push config reads as) is not served yet: its sender has +not shipped, and nothing would deliver through it. … +``` + +The fix is the direct transport: `transport: 'direct'` with an `apns` and/or +`fcm` block for an app signed with your own APNs key and Firebase project. The +boot refuses rather than serving the relay because a served relay would mount +the push channel and answer every push as sent while nothing left the server. +Authoring still accepts a relay block, so a stack written for the relay does not +need to change when the sender ships. + +## Devices + +The push channel addresses the recipient's devices in the device registry, +`sys_user_device`, owned by the messaging service. A signed-in app registers +its device and removes it through two routes: + +- `POST /api/v1/notifications/devices` registers the current user's device, or + refreshes it when its `deviceId` is already registered + (`RegisterDeviceRequestSchema`: `token`, `platform`, optional `deviceId`, + `vendor`, `appId`, `locale`, `origin`). +- `DELETE /api/v1/notifications/devices/:deviceId` removes one of the current + user's devices. A device the user does not hold answers 404. + +### Dead-token retirement + +When a vendor answers `invalid_token`, the push channel retires that device: the +row is kept, disabled, with the time it was retired +(`INotificationService.invalidateDeviceToken`). The channel does it, not the +dispatcher, because one notification fans out to every device of the recipient +and only the channel knows which token died. The recipient's other devices +still receive the push. + +## How a delivery is graded + +The push channel grades one delivery, one recipient user, by what all of that +user's devices answered: + +| What happened | Outcome | +|:--|:--| +| at least one device accepted it | delivered; a device that failed beside it is logged, never retried, so the devices that received it are not pushed twice | +| every addressed device was reported dead | `invalid_recipient`: suppressed, not dead-lettered | +| the user has no enabled device | `invalid_recipient` | +| devices exist, but none on a vendor this deployment has a transport for, or the vendor refused the push for a reason no retry repairs | `permanent` | +| a timeout, a 429 or a 5xx with nothing delivered | `retryable` | +| no push service registered | refused `transport_not_configured`, `permanent`, as the email and SMS channels answer it | + +## Consumers + +- **Messaging `push` channel**: `notify(channels: ['push'])` renders the + notification in each recipient's language and pushes it to their enabled + devices. It is mounted while a `push` service is registered. diff --git a/content/docs/kernel/runtime-services/versioning.mdx b/content/docs/kernel/runtime-services/versioning.mdx index b919132830b..fcc27562ace 100644 --- a/content/docs/kernel/runtime-services/versioning.mdx +++ b/content/docs/kernel/runtime-services/versioning.mdx @@ -33,5 +33,6 @@ When a runtime service API changes in a breaking way: | `services.queue` | `stable` | | `services.email` | `stable` | | `services.sms` | `stable` | +| `services.push` | `experimental` | | `services.settings` | `stable` | | `services.storage` | `stable` | diff --git a/content/docs/references/system/push-config.mdx b/content/docs/references/system/push-config.mdx index 9667b92fc9b..5ccd654f6eb 100644 --- a/content/docs/references/system/push-config.mdx +++ b/content/docs/references/system/push-config.mdx @@ -14,7 +14,8 @@ vendors. for the direct transport it builds one vendor transport per block, and it refuses the relay transport, not yet served (#29920). The `push` channel in `@objectstack/service-messaging` delivers through the push service it -registers. +registers. Under `objectstack serve` the value is the stack definition's +`push` key, read when the stack declares `requires: ['push']` (below). **Two transports, and the hosted relay is the default.** The store-published ObjectOS app is signed with ObjectStack's own APNs key and Firebase project, @@ -44,11 +45,13 @@ direct block with no vendor block, which names no `credentialRef` and so could deliver to nothing. **What is served today.** The direct transport is: it ships as -`@objectstack/service-push` (ADR-0012, amended 2026-11-03, #29041). The -relay transport is not yet: the relay sender is follow-up card #29920, and -until it ships `PushServicePlugin` refuses a relay block, the default `{}` -included, when it initialises, so a server is never left believing it -pushes when nothing would deliver. +`@objectstack/service-push` (ADR-0012, amended 2026-11-03, #29041), and +`objectstack serve` reads it from the stack's `push` key. The relay +transport is not yet: the relay sender is follow-up card #29920, and until +it ships the boot refuses a relay block, the default `{}` included (the +`push` capability's reader before the kernel boots, and `PushServicePlugin` +when it initialises), so a server is never left believing it pushes when +nothing would deliver. It sits beside `EmailServiceConfigSchema` (`email-config.zod.ts`), the declaration of the email channel's transport selection. @@ -65,13 +68,19 @@ refused two ways: written under a key an operator would reach for (`key`, is refused because a reference is one token — a PEM block carries whitespace and a JSON document carries braces, and a name carries neither. -**What reads it, and why it is not a stack key.** `PushServicePlugin` -(`@objectstack/service-push`) is its one reader, through the plugin's -constructor: the host that composes the plugin hands it the value. The email -transport selection it sits beside is read from `config.email` of the stack -definition (`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape has no -stack-definition key and no `os serve` capability yet, so it is authorable -only where a host composes the plugin. +**What reads it: the stack's `push` key under `objectstack serve`.** It is +the stack definition's top-level `push` key, beside `email` and `sms`. A +stack that declares `requires: ['push']` has `objectstack serve` (and +`@objectstack/verify`'s `bootStack`) mount `PushServicePlugin`, built by +`resolvePushCapabilityArg` (`@objectstack/service-push`) from that key. +The reader parses the key with this schema again at boot, because a +compiled artifact may not have passed `defineStack`, and it refuses two +things there, failing the boot: a relay block (the default `{}` and an +absent key included), because nothing sends it yet, and an inline secret. +A host that composes the plugin in code hands it the value through the +constructor instead. No `OS_PUSH_*` environment variable carries any of +it: the vendor blocks hold no secret to override, and each `credentialRef` +already names one. Out of scope here: the other vendors ADR-0012 §6 routes to alternate channel plugins (HMS, Xiaomi, OPPO, vivo). diff --git a/packages/cli/package.json b/packages/cli/package.json index bfec9a68f52..c7672bdcd98 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -94,6 +94,7 @@ "@objectstack/service-mailbox": "workspace:*", "@objectstack/service-messaging": "workspace:*", "@objectstack/service-package": "workspace:*", + "@objectstack/service-push": "workspace:*", "@objectstack/service-queue": "workspace:*", "@objectstack/service-realtime": "workspace:*", "@objectstack/service-settings": "workspace:*", diff --git a/packages/cli/src/commands/serve-push-key-advisory.test.ts b/packages/cli/src/commands/serve-push-key-advisory.test.ts new file mode 100644 index 00000000000..c91e0e6dbca --- /dev/null +++ b/packages/cli/src/commands/serve-push-key-advisory.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #29820 — a stack `push` key with no `push` in `requires` is read by nothing: + * push is requested, never on the always-on slate, so `PushServicePlugin` is + * not mounted. `objectstack serve` says so once at boot instead of starting as + * if the key took effect. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { formatUnrequestedPushKeyWarning } from './serve.js'; + +const DIRECT = { transport: 'direct', fcm: { projectId: 'example-app', credentialRef: 'FCM_ACCOUNT' } }; + +describe('formatUnrequestedPushKeyWarning', () => { + it('a push key without push in requires is named, with the fix', () => { + const line = formatUnrequestedPushKeyWarning({ push: DIRECT }, ['data', 'auth']); + expect(line).toMatch(/sets a push key, but requires does not name 'push'/); + expect(line).toMatch(/the push service is not started and the key is not read/); + expect(line).toMatch(/Add 'push' to requires/); + }); + + it('an empty push block counts as written', () => { + expect(formatUnrequestedPushKeyWarning({ push: {} }, [])).not.toBeNull(); + }); + + it('says nothing when push is requested, or when no key is written', () => { + expect(formatUnrequestedPushKeyWarning({ push: DIRECT }, ['push'])).toBeNull(); + expect(formatUnrequestedPushKeyWarning({}, [])).toBeNull(); + expect(formatUnrequestedPushKeyWarning({ push: undefined }, [])).toBeNull(); + expect(formatUnrequestedPushKeyWarning(undefined, [])).toBeNull(); + }); + + it('never repeats the configuration', () => { + const line = formatUnrequestedPushKeyWarning({ push: { ...DIRECT, fcm: { projectId: 'p', credentialRef: 'SECRET_NAME_X' } } }, []); + expect(line).not.toMatch(/SECRET_NAME_X/); + }); +}); + +describe('serve prints it at boot, from the served capability list', () => { + it('the boot reads the resolved requires, not only the declared ones', () => { + const src = readFileSync(fileURLToPath(new URL('./serve.ts', import.meta.url)), 'utf8'); + const resolvedAt = src.indexOf('const requires: string[] = [...served.tokens];'); + const calledAt = src.indexOf('formatUnrequestedPushKeyWarning(config, requires)'); + expect(resolvedAt).toBeGreaterThan(-1); + expect(calledAt).toBeGreaterThan(resolvedAt); + expect(src.slice(calledAt, calledAt + 200)).toMatch(/console\.warn/); + }); +}); 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 a2d64612b50..836b30d34af 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 @@ -16,6 +16,12 @@ // pinned by the source scan in `serve-auth-app-name.contract.test.ts`; the two // files together are the reading from authored stack to constructed provider. // +// `push` is the push service configuration whole (`PushServiceConfigSchema`), +// read only when the stack declares `requires: ['push']` (the token is not on +// the always-on slate): the direct transport reaches `PushServicePlugin`, and +// the relay — what an absent key reads as — is refused before the kernel +// boots, as is an inline secret a compiled artifact may still carry. +// // `sms` carries `retries` alone: the stack refuses `sms.provider` by name, // because the delivering SMS transport is built by the `sms` settings // namespace from its own provider setting (`OS_SMS_PROVIDER`), so the provider @@ -26,6 +32,7 @@ import { resolveCapabilityArgument } from '@objectstack/core'; import { defineStack } from '@objectstack/spec'; import * as pluginEmail from '@objectstack/plugin-email'; import * as serviceSms from '@objectstack/service-sms'; +import * as servicePush from '@objectstack/service-push'; const manifest = { id: 'com.example.mailer', @@ -36,11 +43,11 @@ const manifest = { }; /** The argument `serve`'s capability loop constructs the provider with, for this stack and environment. */ -function argumentFor(token: 'email' | 'sms', stack: unknown, env: NodeJS.ProcessEnv): Record { +function argumentFor(token: 'email' | 'sms' | 'push', stack: unknown, env: NodeJS.ProcessEnv): Record { return resolveCapabilityArgument(token, { stack, packageRoot: '/app', - providerModule: token === 'email' ? pluginEmail : serviceSms, + providerModule: token === 'email' ? pluginEmail : token === 'sms' ? serviceSms : servicePush, env, }).argument as Record; } @@ -102,3 +109,51 @@ describe('a defineStack-built email / sms / appName reaches the providers serve expect(argumentFor('sms', stack, env)).toEqual({ provider: 'aliyun' }); }); }); + +describe("a defineStack-built push block reaches the PushServicePlugin serve constructs for requires: ['push']", () => { + const apns = { teamId: 'ABCDE12345', keyId: 'KEY1234567', topic: 'com.example.app', environment: 'production' as const }; + + it('the push token keys PushServicePlugin in the table serve resolves requires through, and is not appended to every app', async () => { + const { CAPABILITY_PROVIDERS, resolveServedCapabilities } = await import('@objectstack/core'); + expect(CAPABILITY_PROVIDERS.push).toMatchObject({ pkg: '@objectstack/service-push', export: 'PushServicePlugin' }); + expect(resolveServedCapabilities(['push']).tokens).toContain('push'); + expect(resolveServedCapabilities([]).tokens).not.toContain('push'); + }); + + it('a direct block reaches the plugin, and the plugin built from it registers the push service', async () => { + const stack = defineStack({ + manifest, + requires: ['push'], + push: { transport: 'direct', apns: { ...apns, credentialRef: 'APNS_SIGNING_KEY' } }, + }); + const argument = argumentFor('push', stack, {}); + expect(argument).toEqual({ config: { transport: 'direct', apns: { ...apns, credentialRef: 'APNS_SIGNING_KEY' } } }); + + const services = new Map(); + const logger = { info() {}, warn() {}, error() {}, debug() {} }; + await new servicePush.PushServicePlugin(argument).init({ registerService: (n: string, v: unknown) => services.set(n, v), logger } as never); + expect((services.get('push') as { vendors(): string[] }).vendors()).toEqual(['apns']); + }); + + it.each([ + ['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) => { + 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'/); + }); + + it('an inline secret in a configuration defineStack never built is refused at boot, by name, value not echoed', () => { + // A compiled artifact or a plain object reaches the boot without the + // authoring door, so the boot judges the key again. + const raw = { manifest, requires: ['push'], push: { transport: 'direct', fcm: { projectId: 'p', credentialRef: 'FCM', serviceAccount: '{"private_key":"SECRET-VALUE"}' } } }; + const boot = () => argumentFor('push', raw, {}); + expect(boot).toThrow(/push\.fcm: Unrecognized key/); + expect(boot).toThrow(/`credentialRef`/); + expect(boot).not.toThrow(/SECRET-VALUE/); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index dd0987d7f3b..e8f43794001 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -3025,6 +3025,11 @@ export default class Serve extends Command { const tiers: Set = resolveStackTiers({ declaredTiers, requires, preset: presetName }); const tierEnabled = (t: string) => tiers.has(t); const requiresCapability = (c: string) => requires.includes(c); + // [#29820] A stack `push` key with no `push` in `requires` mounts nothing + // and is read by nothing: push is requested, never on the always-on + // slate. Say so once instead of booting as if the key took effect. + const unrequestedPushKey = formatUnrequestedPushKeyWarning(config, requires); + if (unrequestedPushKey) console.warn(chalk.yellow(` ⚠ ${unrequestedPushKey}`)); // Import ObjectStack runtime const { Runtime } = await import('@objectstack/runtime'); @@ -6486,6 +6491,22 @@ export function resolveTenancyPostureOrRefusal(): TenancyPostureGateVerdict { } +/** + * The boot warning for a stack whose `push` key is set while its served + * capabilities do not include `push` (#29820), or `null` when there is nothing + * to say. Push is a requested capability, never on the always-on slate, so + * without the token `PushServicePlugin` is not mounted and the key is read by + * nothing; the operator who wrote the key is told how to turn it on. + * + * Returns PLAIN text; the caller owns the channel (`console.warn`, stderr). + */ +export function formatUnrequestedPushKeyWarning(stack: unknown, requires: readonly string[]): string | null { + const push = stack && typeof stack === 'object' ? (stack as { push?: unknown }).push : undefined; + if (push === undefined || push === null || requires.includes('push')) return null; + return "The stack definition sets a push key, but requires does not name 'push', so the push service is not " + + "started and the key is not read. Add 'push' to requires to turn mobile push on."; +} + /** * The diagnosis the optional i18n service load prints when it fails — a * DIAGNOSIS ahead of behaviour that does not change (#14042's rule, applied at diff --git a/packages/cli/test/option-b-reader-acceptance.pin.test.ts b/packages/cli/test/option-b-reader-acceptance.pin.test.ts index a0e1c7dbd17..f0068400e46 100644 --- a/packages/cli/test/option-b-reader-acceptance.pin.test.ts +++ b/packages/cli/test/option-b-reader-acceptance.pin.test.ts @@ -215,12 +215,12 @@ describe('#15004 — option-B acceptance pin: every subsystem must see its colle // #17556 added `devHint` / `devLogins`: what the DEVELOPMENT BOOT BANNER // prints, read off the top level by the CLI and refused inside a package // body — envelope keys by the same rule `devPlugins` is one. `appName` / - // `email` / `sms` are the deployment's product name and its mail and SMS - // configuration, read off the top level when the boot builds those - // providers. #25777 added `demoStaff`: the demo accounts the development + // `email` / `sms` / `push` are the deployment's product name and its mail, + // SMS and push configuration, read off the top level when the boot builds + // those providers. #25777 added `demoStaff`: the demo accounts the development // boot creates, read off the top level by the CLI and refused inside a // package body by the same rule. - ['api', 'appName', 'demoStaff', 'devHint', 'devLogins', 'devPlugins', 'email', 'i18n', 'manifest', 'onEnable', 'packages', 'plugins', 'runtimeModule', 'server', 'sms'], + ['api', 'appName', 'demoStaff', 'devHint', 'devLogins', 'devPlugins', 'email', 'i18n', 'manifest', 'onEnable', 'packages', 'plugins', 'push', 'runtimeModule', 'server', 'sms'], ); // Every key option B drops is a package-owned collection, and every key it diff --git a/packages/cli/test/serve-capability-identity.test.ts b/packages/cli/test/serve-capability-identity.test.ts index 4ac01cf72a1..59ed8718f88 100644 --- a/packages/cli/test/serve-capability-identity.test.ts +++ b/packages/cli/test/serve-capability-identity.test.ts @@ -125,6 +125,7 @@ const EXPECTED_PROVIDER_NAME: Record = { email: 'com.objectstack.service.email', mailbox: 'com.objectstack.service.mailbox', sms: 'com.objectstack.service.sms', + push: 'com.objectstack.service.push', sharing: 'com.objectstack.service.sharing', 'pinyin-search': 'com.objectstack.plugin.pinyin-search', approvals: 'com.objectstack.service.approvals', diff --git a/packages/cli/test/serve-capability-vocabulary.test.ts b/packages/cli/test/serve-capability-vocabulary.test.ts index 598c11766d0..4f21f9535b6 100644 --- a/packages/cli/test/serve-capability-vocabulary.test.ts +++ b/packages/cli/test/serve-capability-vocabulary.test.ts @@ -124,6 +124,30 @@ describe('PLATFORM_CAPABILITY_PROVIDERS vs vocabulary + serve resolver (#3366)', } }); + /** + * The reverse direction of the pin above. `serve`'s resolver stays quiet on + * a KNOWN token with no CAPABILITY_PROVIDERS row (it reads that as provided + * elsewhere: a tier block, an enterprise plugin, cloud), so an open-edition + * token that ships in the spec without a row here is accepted by + * `defineStack`, classified installable by the `os build` preflight, and then + * mounts nothing under `serve`, without a word. Every open-edition token is + * therefore either a tier token (its own block) or keyed in + * CAPABILITY_PROVIDERS, on the pull request that adds it. + */ + it('every open-edition token has a serve mount — a provider entry or a tier, never a silent drop', () => { + const providerTokens = new Set(Object.keys(CAPABILITY_PROVIDERS)); + const tierTokens = new Set(Object.keys(Serve.CAPABILITY_TO_TIER)); + const open = Object.entries(PLATFORM_CAPABILITY_PROVIDERS).filter(([, p]) => p.edition === 'open').map(([t]) => t); + // Non-vacuity: the open edition is most of the vocabulary. + expect(open.length).toBeGreaterThan(10); + expect(open).toContain('push'); + const unmounted = open.filter((token) => !providerTokens.has(token) && !tierTokens.has(token)); + expect( + unmounted, + 'open-edition tokens `serve` would accept and then mount nothing for — key each in CAPABILITY_PROVIDERS', + ).toEqual([]); + }); + it('tier-gated tokens carry a provider entry; ai/ai-studio are cloud-only', () => { for (const token of Object.keys(Serve.CAPABILITY_TO_TIER)) { expect(PLATFORM_CAPABILITY_PROVIDERS[token], `tier token '${token}' has no provider entry`).toBeTruthy(); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index bc6ffa7acc8..6b97a5c1bc9 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -987,6 +987,17 @@ export default defineConfig({ find: /^@objectstack\/service-package$/, replacement: path.resolve(__dirname, '../services/service-package/src/index.ts'), }, + // `serve-stack-email-sms-door.contract.test.ts` reads the push reader + // (`resolvePushCapabilityArg`) and constructs `PushServicePlugin` from a + // stack's `push` key, and `serve-capability-identity.test.ts` imports the + // package as the `push` provider. Resolved to source for the reason its + // neighbours give; its own value imports (`@objectstack/spec`, + // `@objectstack/core`) are already in this package's ledger entry. + // `check:test-source-alias` names this import and fails without it. + { + find: /^@objectstack\/service-push$/, + replacement: path.resolve(__dirname, '../services/service-push/src/index.ts'), + }, ], }, test: { diff --git a/packages/core/src/capability-composition.test.ts b/packages/core/src/capability-composition.test.ts index 12640c1daba..c8b22fc8ba5 100644 --- a/packages/core/src/capability-composition.test.ts +++ b/packages/core/src/capability-composition.test.ts @@ -143,6 +143,32 @@ describe('resolveCapabilityArgument — what each provider is constructed with', expect(calls).toEqual([['email', {}, env, undefined]]); }); + it('`push` is built by resolvePushCapabilityArg off its provider module, from the stack\'s push key alone', () => { + const calls: unknown[][] = []; + const providerModule = { + resolvePushCapabilityArg: (...args: unknown[]) => { + calls.push(args); + return { options: { config: { built: 'push' } } }; + }, + }; + const push = { transport: 'direct', fcm: { projectId: 'p', credentialRef: 'FCM' } }; + expect(resolveCapabilityArgument('push', { stack: { push }, packageRoot: '/app', providerModule, env: { OS_X: '1' } }).argument) + .toEqual({ config: { built: 'push' } }); + // The stack's key only: no environment carries push configuration. + expect(calls).toEqual([[push]]); + // An absent key is read as `{}` — the reader decides (it refuses the relay that reads as), never skipped. + calls.length = 0; + resolveCapabilityArgument('push', { stack: {}, packageRoot: '/app', providerModule }); + expect(calls).toEqual([[{}]]); + // A reader's refusal reaches the boot unchanged. + const refusing = { resolvePushCapabilityArg: () => { throw new Error('relay not served'); } }; + expect(() => resolveCapabilityArgument('push', { stack: {}, packageRoot: '/app', providerModule: refusing })) + .toThrow('relay not served'); + expect(() => resolveCapabilityArgument('push', { stack: {}, packageRoot: '/app', providerModule: {} })).toThrow( + /@objectstack\/service-push does not export resolvePushCapabilityArg/, + ); + }); + it('a provider module without its reader is refused by name, never built with defaults', () => { expect(() => resolveCapabilityArgument('email', { stack: {}, packageRoot: '/app', providerModule: {} })).toThrow( /does not export resolveEmailCapabilityArg/, diff --git a/packages/core/src/capability-composition.ts b/packages/core/src/capability-composition.ts index d6bcaa74c5c..d7e841dfacf 100644 --- a/packages/core/src/capability-composition.ts +++ b/packages/core/src/capability-composition.ts @@ -42,12 +42,14 @@ * one fails the boot, with its remedy), and `serve`'s production warning * about a local storage root. * - * What lives in the provider's own package, and why: the `email` and `sms` - * arguments are read by `resolveEmailCapabilityArg` (`@objectstack/plugin-email`) - * and `resolveSmsCapabilityArg` (`@objectstack/service-sms`). Each refuses - * exactly the configurations its package's transports cannot build, so it reads - * that package's transport vocabulary — and both packages depend on this one, - * so this package cannot import them. {@link resolveCapabilityArgument} reads + * What lives in the provider's own package, and why: the `email`, `sms` and + * `push` arguments are read by `resolveEmailCapabilityArg` + * (`@objectstack/plugin-email`), `resolveSmsCapabilityArg` + * (`@objectstack/service-sms`) and `resolvePushCapabilityArg` + * (`@objectstack/service-push`). Each refuses exactly the configurations its + * package's transports cannot build, so it reads that package's transport + * vocabulary — and all three packages depend on this one, so this package + * cannot import them. {@link resolveCapabilityArgument} reads * each reader from the module the provider is constructed from, which every * boot has already loaded to reach the provider's class. * @@ -139,14 +141,14 @@ export function resolveServedCapabilities( /** What {@link resolveCapabilityArgument} reads to build one provider's argument. */ export interface CapabilityArgumentInput { - /** The stack being booted: its `analyticsCubes` (or `cubes`), `email`, `sms` and `appName` are read. */ + /** The stack being booted: its `analyticsCubes` (or `cubes`), `email`, `sms`, `push` and `appName` are read. */ readonly stack: unknown; /** The app's root — the directory holding its `objectstack.config.ts` — handed to `automation`. */ readonly packageRoot: string; /** * The module the provider is constructed from. Its own configuration reader - * is read from it for `email` and `sms` (see the module header); required - * for those two tokens. + * is read from it for `email`, `sms` and `push` (see the module header); + * required for those three tokens. */ readonly providerModule?: Readonly>; /** The environment the mail and SMS readers read. Defaults to `process.env`. */ @@ -200,6 +202,10 @@ function providerReader( * - `sms`: the SMS provider's options from `stack.sms` and `OS_SMS_*` — * `resolveSmsCapabilityArg`, read off the provider module. It THROWS on a * provider tag no transport can deliver through. + * - `push`: `{ config }` from `stack.push`, parsed by `PushServiceConfigSchema` + * — `resolvePushCapabilityArg`, read off the provider module. It THROWS on + * the relay transport (an absent key reads as it), which nothing sends yet, + * and on a configuration the schema refuses, an inline secret included. * - `storage`: the local adapter at {@link resolveStorageLocalRootEnv}'s root, * else `.objectstack/data/uploads` ({@link resolveStorageCapabilityArg}). * - any other token: no argument. @@ -225,6 +231,10 @@ export function resolveCapabilityArgument(token: string, input: CapabilityArgume const read = providerReader<[unknown, unknown]>(token, input, 'resolveSmsCapabilityArg'); return { argument: read(stack.sms ?? {}, env).options }; } + if (token === 'push') { + const read = providerReader<[unknown]>(token, input, 'resolvePushCapabilityArg'); + return { argument: read(stack.push ?? {}).options }; + } if (token === 'storage') { const storage = resolveStorageCapabilityArg(resolveStorageLocalRootEnv()); return { argument: storage.options, localStorageRoot: storage.localRoot }; diff --git a/packages/core/src/capability-providers.ts b/packages/core/src/capability-providers.ts index 20c613376ce..18918e22679 100644 --- a/packages/core/src/capability-providers.ts +++ b/packages/core/src/capability-providers.ts @@ -197,6 +197,16 @@ export const CAPABILITY_PROVIDERS: Readonly> = { export: 'SmsServicePlugin', identities: ['com.objectstack.service.sms', 'SmsServicePlugin'], }, + push: { + // Mobile push (ADR-0012 §4, §6): registers the kernel's `push` service + // (APNs and FCM), which the messaging `push` channel delivers through. + // Built from the stack's `push` key by `resolvePushCapabilityArg`, which + // refuses the not-yet-served relay transport (and an absent key, which + // reads as it) at boot. Requested, never always-on. + pkg: '@objectstack/service-push', + export: 'PushServicePlugin', + identities: ['com.objectstack.service.push', 'PushServicePlugin'], + }, sharing: { pkg: '@objectstack/plugin-sharing', export: 'SharingServicePlugin', diff --git a/packages/services/service-push/src/capability-arg.test.ts b/packages/services/service-push/src/capability-arg.test.ts new file mode 100644 index 00000000000..6c808f2fb25 --- /dev/null +++ b/packages/services/service-push/src/capability-arg.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `resolvePushCapabilityArg` — what `objectstack serve` and `bootStack` + * construct `PushServicePlugin` with when a stack declares `requires: ['push']`: + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { resolvePushCapabilityArg } from './capability-arg.js'; +import { PushServicePlugin, PUSH_RELAY_NOT_SERVED_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' }; +const FCM = { projectId: 'example-app', credentialRef: 'FCM_ACCOUNT' }; + +describe('resolvePushCapabilityArg — the direct transport is built', () => { + it('hands the plugin the parsed config, transport filled in', () => { + expect(resolvePushCapabilityArg({ transport: 'direct', apns: APNS, fcm: FCM })).toEqual({ + options: { config: { transport: 'direct', apns: APNS, fcm: FCM } }, + }); + // A vendor block with no transport reads as direct, as the schema says. + expect(resolvePushCapabilityArg({ fcm: FCM }).options.config).toEqual({ transport: 'direct', fcm: FCM }); + }); + + it('the argument constructs a PushServicePlugin that registers the push service', async () => { + const services = new Map(); + const logger = { info() {}, warn() {}, error() {}, debug() {} }; + const ctx = { registerService: (name: string, s: unknown) => services.set(name, s), logger } as never; + await new PushServicePlugin(resolvePushCapabilityArg({ apns: APNS }).options).init(ctx); + expect(services.get('push')).toBeInstanceOf(PushService); + expect((services.get('push') as PushService).vendors()).toEqual(['apns']); + }); +}); + +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 read = () => resolvePushCapabilityArg(push); + expect(read).toThrow(PUSH_RELAY_NOT_SERVED_MESSAGE); + expect(read).toThrow(/its sender has not shipped/); + expect(read).toThrow(/transport: 'direct'/); + // Where the operator writes it on this boot path. + expect(read).toThrow(/stack definition's push key/); + }); +}); + +describe('resolvePushCapabilityArg — a configuration the schema refuses fails the boot', () => { + it('an inline APNs key, written beside credentialRef, is refused by name — the value is never echoed', () => { + const read = () => resolvePushCapabilityArg({ transport: 'direct', apns: { ...APNS, key: 'PRIVATE KEY BODY' } }); + expect(read).toThrow(/push configuration cannot be delivered through/); + expect(read).toThrow(/push\.apns: Unrecognized key/); + expect(read).toThrow(/`credentialRef`/); + expect(read).not.toThrow(/PRIVATE KEY BODY/); + }); + + it('with no transport, the refusal is opened to the direct member — the guidance is not lost to "Invalid input"', () => { + const read = () => resolvePushCapabilityArg({ apns: { teamId: 'TEAM123456', keyId: 'KEY1234567', topic: 'com.example.app', environment: 'production', key: 'PRIVATE KEY BODY' } }); + expect(read).toThrow(/push\.apns: Unrecognized key/); + expect(read).toThrow(/never written inline/); + expect(read).not.toThrow(/Invalid input"?$/); + expect(read).not.toThrow(/relay push transport/); + expect(read).not.toThrow(/PRIVATE KEY BODY/); + }); + + it('a PEM block pasted into credentialRef is refused at its path, value not echoed', () => { + const pem = '-----BEGIN PRIVATE KEY-----\nMIGT\n-----END PRIVATE KEY-----'; + const read = () => resolvePushCapabilityArg({ apns: { ...APNS, credentialRef: pem } }); + expect(read).toThrow(/push\.apns\.credentialRef: This names the APNs \.p8 signing key in the secrets layer and never contains it/); + expect(read).not.toThrow(/MIGT/); + }); + + it('a credential on a relay block is refused with the relay guidance', () => { + expect(() => resolvePushCapabilityArg({ transport: 'relay', apiKey: 'x' })).toThrow(/Not authorable on the relay push transport/); + }); + + it('a direct block with no vendor block is refused — it would deliver to nothing', () => { + expect(() => resolvePushCapabilityArg({ transport: 'direct' })).toThrow(/names at least one vendor block/); + }); +}); diff --git a/packages/services/service-push/src/capability-arg.ts b/packages/services/service-push/src/capability-arg.ts new file mode 100644 index 00000000000..8638e2857a5 --- /dev/null +++ b/packages/services/service-push/src/capability-arg.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * 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. + * + * ## Why this lives here, and not in `@objectstack/cli` or `@objectstack/core` + * + * The same reason `resolveSmsCapabilityArg` lives in `@objectstack/service-sms`. + * `objectstack serve` (`@objectstack/cli`) and `@objectstack/verify`'s + * `bootStack` both build the providers a stack's `requires` names through ONE + * rule, `resolveCapabilityArgument` (`@objectstack/core`), and this package + * depends on core, so core cannot import it. The reader sits beside the + * transports it refuses against, and `resolveCapabilityArgument` reads it off + * this module, the module every boot already loads to reach + * `PushServicePlugin`. ⛔ Never a second copy beside a reader. + * + * ## What it refuses, and why at this point + * + * `PushServicePlugin.init` refuses the same two things. The reader refuses + * them first, when the capability loop builds the plugin's argument, so the + * 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}.) + * - **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 + * parses again. The refusal names each path and the schema's own guidance, + * never the value: the value may be the secret itself. + * + * No environment variable is read. The vendor blocks carry no secret, and each + * `credentialRef` names the environment variable (in the open tier) the + * transport reads when it first signs. + */ + +import { PushServiceConfigSchema, type PushServiceConfigParsed } from '@objectstack/spec/system'; +import { PUSH_RELAY_NOT_SERVED_MESSAGE } from './push-plugin.js'; + +/** Constructor options for `PushServicePlugin`, as the capability loop builds them. */ +export interface PushCapabilityArg { + options: { config: PushServiceConfigParsed }; +} + +/** + * 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. + */ +export function resolvePushCapabilityArg(cfgPush: unknown = {}): PushCapabilityArg { + const raw = cfgPush ?? {}; + const verdict = PushServiceConfigSchema.safeParse(raw); + if (!verdict.success) { + const issues = leafIssues(verdict.error.issues, raw) + .map((issue) => `push${issue.path.map((p) => (typeof p === 'number' ? `[${p}]` : `.${String(p)}`)).join('')}: ${issue.message}`) + .join('; '); + throw new Error( + `the stack's push configuration cannot be delivered through, so the push service is not started: ${issues}`, + ); + } + const config = verdict.data; + if (config.transport !== 'direct') { + throw new Error( + `${PUSH_RELAY_NOT_SERVED_MESSAGE} On objectstack serve this is the stack definition's push key, and an absent ` + + 'key reads as the relay too.', + ); + } + return { options: { config } }; +} + +type Issue = { code: string; path: readonly PropertyKey[]; message: string; errors?: Issue[][] }; + +/** + * The issues to name, with a union's verdict opened to the transport the block + * reads as. A block without `transport` is tried as the relay and then as + * direct, and when both fail Zod answers one `invalid_union` reading "Invalid + * input", which would hide the guidance (an inline APNs key, say). The schema's + * own rule picks the member: a block with an `apns` or `fcm` block reads as + * direct, any other as the relay. + */ +function leafIssues(issues: readonly Issue[], input: unknown): Issue[] { + return issues.flatMap((issue) => { + if (issue.code !== 'invalid_union' || !issue.errors || issue.errors.length !== 2) return [issue]; + const bag = input && typeof input === 'object' ? (input as Record) : {}; + const direct = bag.apns !== undefined || bag.fcm !== undefined; + return leafIssues(issue.errors[direct ? 1 : 0] ?? [], input); + }); +} diff --git a/packages/services/service-push/src/index.ts b/packages/services/service-push/src/index.ts index 31fdc20f3c1..cb90c0bb2c6 100644 --- a/packages/services/service-push/src/index.ts +++ b/packages/services/service-push/src/index.ts @@ -10,6 +10,7 @@ export { type PushServiceOptions, } from './push-service.js'; export { PushServicePlugin, PUSH_RELAY_NOT_SERVED_MESSAGE, type PushServicePluginOptions } from './push-plugin.js'; +export { resolvePushCapabilityArg, type PushCapabilityArg } from './capability-arg.js'; export { envPushCredentialResolver, PushCredentialError, diff --git a/packages/spec/src/assembled-package-body.test.ts b/packages/spec/src/assembled-package-body.test.ts index 73bd215f011..35eba3e4834 100644 --- a/packages/spec/src/assembled-package-body.test.ts +++ b/packages/spec/src/assembled-package-body.test.ts @@ -83,6 +83,7 @@ const ARTIFACT_ENVELOPE_KEYS = [ 'appName', // the deployment's product name — read off the top level by the boot's mail/auth wiring 'email', // the deployment's mail configuration — read off the top level when the boot builds the email provider 'sms', // the deployment's SMS configuration — read off the top level when the boot builds the SMS provider + 'push', // the deployment's push configuration — read off the top level when the boot builds the push provider ].sort(); const shapeKeys = (schema: unknown): string[] => diff --git a/packages/spec/src/kernel/platform-capabilities.test.ts b/packages/spec/src/kernel/platform-capabilities.test.ts index dc7e00a1c9d..679aba79215 100644 --- a/packages/spec/src/kernel/platform-capabilities.test.ts +++ b/packages/spec/src/kernel/platform-capabilities.test.ts @@ -360,3 +360,29 @@ describe("package-registry carve-out — its persistence is always-on core, spli expect(absent.provider?.package).toBe('@objectstack/service-package'); }); }); + +// Mobile push — the `push` token is the door `requires: ['push']` opens for +// `PushServicePlugin`, built from the stack's `push` key. Its provider is an +// open-edition package the CLI depends on (the CLI's +// `serve-capability-vocabulary.test.ts` holds the serve resolver to the same +// package), and it is requested, never appended to every app: its default +// configuration is the not-yet-served relay, which the boot refuses. +describe('push — the mobile push capability', () => { + it('is a vocabulary token, provided by @objectstack/service-push in the open edition', () => { + expect(PLATFORM_CAPABILITY_TOKENS).toContain('push'); + expect(isKnownPlatformCapability('push')).toBe(true); + expect(PLATFORM_CAPABILITY_PROVIDERS.push).toEqual({ package: '@objectstack/service-push', edition: 'open' }); + }); + + it('is requested, never on the always-on slate', () => { + expect(PLATFORM_ALWAYS_ON_CAPABILITIES).not.toContain('push'); + }); + + it('classifies as an installable open package when absent, and ok when installed', () => { + expect(classifyRequiredCapability('push', () => false)).toMatchObject({ + status: 'installable', + provider: { package: '@objectstack/service-push', edition: 'open' }, + }); + expect(classifyRequiredCapability('push', (pkg) => pkg === '@objectstack/service-push').status).toBe('ok'); + }); +}); diff --git a/packages/spec/src/kernel/platform-capabilities.ts b/packages/spec/src/kernel/platform-capabilities.ts index bfdab364b22..d5575e49585 100644 --- a/packages/spec/src/kernel/platform-capabilities.ts +++ b/packages/spec/src/kernel/platform-capabilities.ts @@ -63,6 +63,11 @@ export const PLATFORM_CAPABILITY_TOKENS: readonly string[] = Object.freeze([ // mailboxes (ADR-0029 D2). 'mailbox', 'sms', + // Mobile push (ADR-0012 §4, §6): the kernel's `push` service, the transport + // half of the messaging `push` channel, built from the stack's `push` key. + // Requested, never always-on: it delivers only with the operator's own + // vendor credentials, and a boot with none refuses it. + 'push', 'sharing', 'pinyin-search', // `reports` was RETIRED (#20102) — see RETIRED_PLATFORM_CAPABILITY_GUIDANCE. @@ -212,6 +217,7 @@ export const PLATFORM_CAPABILITY_PROVIDERS: Readonly composeStacks([a, c])).toThrow("top-level key 'email'"); }); }); + +// ─── PUSH — the `push` key carries the push service configuration ─── +// +// 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 +// authoring refuses is an inline secret, by key name or inside a +// `credentialRef`, because the stack is compiled into the published artifact. + +const apns = { teamId: 'ABCDE12345', keyId: 'KEY1234567', topic: 'com.example.app', environment: 'production' as const }; + +describe('push — the stack key carries the push service configuration', () => { + it('a direct block with credentialRef builds and carries through, transport filled in', () => { + const stack = defineStack({ + manifest, + requires: ['push'], + push: { + transport: 'direct', + apns: { ...apns, credentialRef: 'APNS_SIGNING_KEY' }, + fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' }, + }, + }); + expect(stack.push).toEqual({ + transport: 'direct', + apns: { ...apns, credentialRef: 'APNS_SIGNING_KEY' }, + fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' }, + }); + }); + + it('a vendor block with no transport reads as direct', () => { + const stack = defineStack({ manifest, push: { fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' } } }); + expect(stack.push).toEqual({ transport: 'direct', fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' } }); + }); + + it('a relay block and an empty block are accepted at authoring and read as the relay', () => { + expect(defineStack({ manifest, push: { transport: 'relay' } }).push).toEqual({ transport: 'relay' }); + expect(defineStack({ manifest, push: {} }).push).toEqual({ transport: 'relay' }); + }); + + it('absence is accepted and left absent — no push key is invented', () => { + const verdict = parse({ manifest }); + expect(verdict.success).toBe(true); + if (!verdict.success) return; + expect('push' in verdict.data).toBe(false); + }); + + it('the key reads as PushServiceConfigSchema, a declared top-level key composed as a single value', () => { + // Same verdict as the operator contract, on each transport and on a refusal. + for (const push of [{}, { transport: 'relay' }, { apns: { ...apns, credentialRef: 'APNS_SIGNING_KEY' } }, { transport: 'direct' }]) { + const viaStack = parse({ manifest, push }); + const direct = PushServiceConfigSchema.safeParse(push); + expect(viaStack.success, JSON.stringify(push)).toBe(direct.success); + if (viaStack.success && direct.success) expect(viaStack.data.push).toEqual(direct.data); + } + expect(STACK_DEFINITION_KEYS).toContain('push'); + expect(COMPOSE_KEY_DISPOSITIONS.push).toBe('single'); + }); + + it('an inline APNs key is refused by name at authoring, prescribing credentialRef', () => { + const raw = { manifest, push: { transport: 'direct', apns: { ...apns, credentialRef: 'APNS_SIGNING_KEY', key: 'PRIVATE KEY BODY' } } }; + const issue = unrecognized(parse(raw)); + expect(issue?.path).toEqual(['push', 'apns']); + expect(issue?.keys).toEqual(['key']); + expect(issue?.message).toContain('`credentialRef`'); + + const err = refusalOf(raw); + expect(err.code).toBe('STACK_SCHEMA_INVALID'); + expect(err.status).toBe(422); + expect(err.message).toContain('`key`'); + // The refusal names the key, never the value it carried. + expect(err.message).not.toContain('PRIVATE KEY BODY'); + }); + + it('a secret pasted into credentialRef is refused at its path', () => { + const raw = { + manifest, + push: { transport: 'direct', fcm: { projectId: 'example-app', credentialRef: '{"type": "service_account"}' } }, + }; + const verdict = parse(raw); + expect(verdict.success).toBe(false); + if (verdict.success) return; + const issue = verdict.error.issues.find((i) => i.path.join('.') === 'push.fcm.credentialRef'); + expect(issue?.message).toContain('never contains it'); + + const err = refusalOf(raw); + expect(err.code).toBe('STACK_SCHEMA_INVALID'); + expect(err.status).toBe(422); + }); + + it('two stacks declaring different push blocks are named on compose', () => { + const stackWith = (id: string, projectId: string) => defineStack({ + manifest: { ...manifest, id: `com.example.${id}`, name: id, namespace: id }, + push: { fcm: { projectId, credentialRef: 'FCM_SERVICE_ACCOUNT' } }, + }); + expect(composeStacks([stackWith('one', 'a'), stackWith('two', 'a')]).push) + .toEqual({ transport: 'direct', fcm: { projectId: 'a', credentialRef: 'FCM_SERVICE_ACCOUNT' } }); + expect(() => composeStacks([stackWith('one', 'a'), stackWith('three', 'b')])).toThrow("top-level key 'push'"); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 6da5539b86c..2ee9f27d96b 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -18,6 +18,7 @@ import { TranslationBundleSchema, TranslationConfigSchema } from './system/trans import { StackServerConfigSchema } from './system/stack-server.zod'; import { StackEmailConfigSchema } from './system/email-config.zod'; import { StackSmsConfigSchema } from './system/sms-config.zod'; +import { PushServiceConfigSchema } from './system/push-config.zod'; import { DevLoginSchema } from './system/dev-login.zod'; import { DemoStaffSchema } from './system/demo-staff.zod'; import { hasPlatformObjectPrefix, isPlatformProvidedObjectName } from './system/constants/platform-object-names'; @@ -1131,6 +1132,23 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { + 'Setup → Settings → SMS Delivery, or OS_SMS_PROVIDER and the other OS_SMS_* env overrides', ), + /** + * Mobile push configuration — {@link PushServiceConfigSchema}, the transport + * selection `PushServicePlugin` (`@objectstack/service-push`) is built from. + * Read by `resolvePushCapabilityArg` when `objectstack serve` / `bootStack` + * 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. + */ + 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', + ), + /** * ObjectAI: Artificial Intelligence Layer * @@ -1673,12 +1691,13 @@ export const COMPOSE_KEY_DISPOSITIONS = Object.freeze({ // #5051: the last key still on last-wins; aligned here, see the note above. i18n: 'single', // Deployment configuration the boot reads off the top level — one product - // name, one mail configuration and one SMS configuration per booted stack, - // so two stacks declaring different ones is a conflict to name, like - // `api` / `server`. + // name, one mail configuration, one SMS configuration and one push + // configuration per booted stack, so two stacks declaring different ones is + // a conflict to name, like `api` / `server`. appName: 'single', email: 'single', sms: 'single', + push: 'single', } as const satisfies Record); /** diff --git a/packages/spec/src/system/push-config.zod.ts b/packages/spec/src/system/push-config.zod.ts index 01efcd456b0..662359ef7f4 100644 --- a/packages/spec/src/system/push-config.zod.ts +++ b/packages/spec/src/system/push-config.zod.ts @@ -14,7 +14,8 @@ import { strictObject } from '../shared/strict-object'; * for the direct transport it builds one vendor transport per block, and it * refuses the relay transport, not yet served (#29920). The `push` channel in * `@objectstack/service-messaging` delivers through the push service it - * registers. + * registers. Under `objectstack serve` the value is the stack definition's + * `push` key, read when the stack declares `requires: ['push']` (below). * * **Two transports, and the hosted relay is the default.** The store-published * ObjectOS app is signed with ObjectStack's own APNs key and Firebase project, @@ -44,11 +45,13 @@ import { strictObject } from '../shared/strict-object'; * could deliver to nothing. * * **What is served today.** The direct transport is: it ships as - * `@objectstack/service-push` (ADR-0012, amended 2026-11-03, #29041). The - * relay transport is not yet: the relay sender is follow-up card #29920, and - * until it ships `PushServicePlugin` refuses a relay block, the default `{}` - * included, when it initialises, so a server is never left believing it - * pushes when nothing would deliver. + * `@objectstack/service-push` (ADR-0012, amended 2026-11-03, #29041), and + * `objectstack serve` reads it from the stack's `push` key. The relay + * transport is not yet: the relay sender is follow-up card #29920, and until + * it ships the boot refuses a relay block, the default `{}` included (the + * `push` capability's reader before the kernel boots, and `PushServicePlugin` + * when it initialises), so a server is never left believing it pushes when + * nothing would deliver. * * It sits beside `EmailServiceConfigSchema` (`email-config.zod.ts`), the * declaration of the email channel's transport selection. @@ -65,13 +68,19 @@ import { strictObject } from '../shared/strict-object'; * is refused because a reference is one token — a PEM block carries * whitespace and a JSON document carries braces, and a name carries neither. * - * **What reads it, and why it is not a stack key.** `PushServicePlugin` - * (`@objectstack/service-push`) is its one reader, through the plugin's - * constructor: the host that composes the plugin hands it the value. The email - * transport selection it sits beside is read from `config.email` of the stack - * definition (`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape has no - * stack-definition key and no `os serve` capability yet, so it is authorable - * only where a host composes the plugin. + * **What reads it: the stack's `push` key under `objectstack serve`.** It is + * the stack definition's top-level `push` key, beside `email` and `sms`. A + * stack that declares `requires: ['push']` has `objectstack serve` (and + * `@objectstack/verify`'s `bootStack`) mount `PushServicePlugin`, built by + * `resolvePushCapabilityArg` (`@objectstack/service-push`) from that key. + * The reader parses the key with this schema again at boot, because a + * compiled artifact may not have passed `defineStack`, and it refuses two + * things there, failing the boot: a relay block (the default `{}` and an + * absent key included), because nothing sends it yet, and an inline secret. + * A host that composes the plugin in code hands it the value through the + * constructor instead. No `OS_PUSH_*` environment variable carries any of + * it: the vendor blocks hold no secret to override, and each `credentialRef` + * already names one. * * Out of scope here: the other vendors ADR-0012 §6 routes to alternate * channel plugins (HMS, Xiaomi, OPPO, vivo). diff --git a/packages/verify/package.json b/packages/verify/package.json index 390b584d55c..d13ef0b63f8 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -51,6 +51,7 @@ "@objectstack/service-mailbox": "workspace:*", "@objectstack/service-messaging": "workspace:*", "@objectstack/service-package": "workspace:*", + "@objectstack/service-push": "workspace:*", "@objectstack/service-queue": "workspace:*", "@objectstack/service-realtime": "workspace:*", "@objectstack/service-settings": "workspace:*", diff --git a/packages/verify/src/harness.served-composition.test.ts b/packages/verify/src/harness.served-composition.test.ts index 2cf3e0075a9..dddb5f385ba 100644 --- a/packages/verify/src/harness.served-composition.test.ts +++ b/packages/verify/src/harness.served-composition.test.ts @@ -1,5 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// test-budget: 20 — spawns the real producer once per single-cause fixture: five in-process bootStack boots, no two the same configuration (the plain package, read by the slate, cube-control and mail-control cases; a declared cube; a package body's cube; email.persist false; the smtp boot that is refused), the first paying the file's first-boot cost; slowest CI reading 10.9 s (scheduled full-battery run 41199450735, Test Core shard log) x 1.25, rounded up; read before the three plain boots became one +// test-budget: 20 — spawns the real producer once per single-cause fixture: in-process bootStack boots, no two the same configuration (the plain package, read by the slate, cube-control, mail-control and push-control cases; a declared cube; a package body's cube; email.persist false; the smtp boot that is refused; a direct push block; the relay push boot that is refused), the first paying the file's first-boot cost; slowest CI reading 10.9 s (scheduled full-battery run 41199450735, Test Core shard log) x 1.25, rounded up; read before the three plain boots became one /** * #22301 item 1, the composition gap the stage-2 contract review recorded: @@ -178,3 +178,33 @@ describe('the email service is built from the app\'s mail configuration', () => } }, BOOT_TIMEOUT); }); + +/** + * The `push` capability: requested, never on the slate. A stack that declares + * `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. + */ +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' } }; + + it('requires push with a direct block mounts PushServicePlugin, and the push service holds the configured vendor', async () => { + stack = await bootStack(onePackage({ requires: ['push'], push: direct })); + expect(stack.kernel.hasPlugin(CAPABILITY_PROVIDERS.push!.identities[0]!)).toBe(true); + const push = await stack.kernel.getServiceAsync<{ vendors(): string[] }>('push'); + expect(push.vendors()).toEqual(['fcm']); + }, BOOT_TIMEOUT); + + it('control — an app that does not require push mounts no push provider', async () => { + 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 () => { + // 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/, + ); + }, BOOT_TIMEOUT); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d71cd40f5d..41d5ec0b3d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -661,6 +661,9 @@ importers: '@objectstack/service-package': specifier: workspace:* version: link:../services/service-package + '@objectstack/service-push': + specifier: workspace:* + version: link:../services/service-push '@objectstack/service-queue': specifier: workspace:* version: link:../services/service-queue @@ -3488,6 +3491,9 @@ importers: '@objectstack/service-package': specifier: workspace:* version: link:../services/service-package + '@objectstack/service-push': + specifier: workspace:* + version: link:../services/service-push '@objectstack/service-queue': specifier: workspace:* version: link:../services/service-queue diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json index 05b00f4b3f9..893020d8826 100644 --- a/scripts/docs-audit/handwritten-docs.json +++ b/scripts/docs-audit/handwritten-docs.json @@ -170,6 +170,7 @@ "content/docs/kernel/runtime-services/email-service.mdx", "content/docs/kernel/runtime-services/examples.mdx", "content/docs/kernel/runtime-services/index.mdx", + "content/docs/kernel/runtime-services/push-service.mdx", "content/docs/kernel/runtime-services/queue-service.mdx", "content/docs/kernel/runtime-services/settings-service.mdx", "content/docs/kernel/runtime-services/sharing-service.mdx", diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index 56f4e4a78e8..f75d219e7d2 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -39,7 +39,7 @@ are `manifest`, `packages`, `objects`, `objectExtensions`, `picklists`, `agents`, `tools`, `skills`, `hooks`, `functions`, `mappings`, `analyticsCubes`, `connectors`, `data` (seed), `datasources`, `datasourceMapping`, `translations`, `i18n`, `plugins`, `devPlugins`, -`devHint`, `devLogins`, `demoStaff`, `requires`, `tiers`, `onEnable`, `appName`, `email`, `sms`. +`devHint`, `devLogins`, `demoStaff`, `requires`, `tiers`, `onEnable`, `appName`, `email`, `sms`, `push`. There is deliberately **no** top-level `workflows` or `approvals` collection: an approval is a flow with Approval nodes, and record @@ -304,12 +304,12 @@ production fails loudly): [Ports & networking](./references/operations.md#ports- Step 4's list is the fixed core. Every other service plugin is opt-in, and `requires: [...]` on the stack root is what turns it on. The CLI expands each token through the `CAPABILITY_PROVIDERS` registry in -`packages/cli/src/commands/serve.ts` — its 19 opt-in entries: +`@objectstack/core`: | Token | Provider package | |:--|:--| | `automation` | `@objectstack/service-automation` — flows, and any declarative `connectors:` entry | -| `analytics` `cache` `storage` `queue` `job` `messaging` `realtime` `settings` `sms` | `@objectstack/service-` + the token | +| `analytics` `cache` `storage` `queue` `job` `messaging` `realtime` `settings` `sms` `push` | `@objectstack/service-` + the token | | `marketplace` | `@objectstack/service-package` | | `audit` `email` `sharing` `approvals` `webhooks` | `@objectstack/plugin-` + the token | | `pinyin-search` | `@objectstack/plugin-pinyin-search` |