import { procedure } from "../protocol/route-meta.js"; import { PushChannelIdSchema, PushChannelSchema, PushConfigQuerySchema, PushConfigSchema, PushSentNotificationSchema, PushSentQuerySchema, PushTestSchema, } from "../schemas/push.js"; import { OkSchema } from "../schemas/shared.js"; // A member's own devices, which no control token reaches. const pushRoute = procedure.meta({ control: "never" }); // Push notifications for this sandbox. The daemon owns the VAPID keypair and the channel list (see // push/push-store.ts for why the key lives on the history volume), and sends on the three moments where the // operator's attention is genuinely wanted: a turn finished, the agent is blocked on an answer, and an // automation is waiting for approval. A channel is either a browser's web-push subscription or a native // install reached through the platform's push relay, see PushChannelSchema for the split and why. // // `notification` is the words of one the caller's devices were sent, for a native app whose push carried only its tag. // // `test` exists because a notification pipeline has four independent failure points the user cannot inspect // (device permission, service-worker or shell registration, the daemon's key, the push service itself), a // button that proves the whole chain end-to-end is worth more than any amount of status rendering. export const pushContract = { config: pushRoute .route({ method: "GET", path: "/push/config", summary: "What a device needs to subscribe", description: "The public key and settings a browser or app needs before it can register for notifications from this sandbox.", }) .input(PushConfigQuerySchema) .output(PushConfigSchema), subscribe: pushRoute .route({ method: "POST", path: "/push/subscribe", summary: "Send notifications to this device", description: "Registers one device. The sandbox only interrupts you on the three moments where attention is genuinely wanted: a turn has finished, the agent is stuck on a question, and something is waiting for approval.", }) // A member's own device notifications. .meta({ floor: "collaborator" }) .input(PushChannelSchema) .output(OkSchema), unsubscribe: pushRoute .route({ method: "POST", path: "/push/unsubscribe", summary: "Stop notifying a device", description: "Removes one registered device. Others keep receiving.", }) .meta({ floor: "collaborator" }) .input(PushChannelIdSchema) .output(OkSchema), test: pushRoute .route({ method: "POST", path: "/push/test", summary: "Send a test notification", description: "Proves the whole chain end to end. Worth having, because there are four separate places a notification can be lost that nobody can inspect from the outside: the device's permission, its registration, the sandbox's key, and the delivery service.", }) .meta({ floor: "collaborator" }) .output(PushTestSchema), // A read at the default viewer floor: the gate is the recipient check in the handler, which answers only what was sent // to the caller's own devices, and answers not found for everything else. notification: pushRoute .route({ method: "GET", path: "/push/notification", summary: "What a notification said", description: "The notification this sandbox last sent to your devices under a tag: its words, where it points, and when it went. For a native app whose push carries only the tag, so the words never pass through the push service. Not found once it is no longer kept (the newest 200 tags for a day, never across a restart) and for a tag none of your devices was sent.", }) .input(PushSentQuerySchema) .output(PushSentNotificationSchema), };