Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Notifications architecture

Notifications are a platform feature, not a gatekeeper. The Workshop backend can call the
install-local `notification-proxy` through the explicit `NOTIFICATION_DELIVERY` service binding,
but the proxy has no public route and is never included in `GATEKEEPER_*` discovery, connector UI,
or agent bindings.

The proxy isolates the installation signing key from Workshop core and stores only an opaque
subscription id for each Workshop user. The Cloudflare-operated notification service owns APNs
delivery and the device/subscription directory. It accepts only fixed, typed notification templates;
it does not accept arbitrary notification text or any provider OAuth credential.

## Registration

```mermaid
flowchart LR
subgraph Phone[Native app and Apple boundary]
APNS[APNs device token]
App[Cloudflare OS app]
end

subgraph Central[Cloudflare-operated notification service]
Device[Device registration API]
Registry[(Device and subscription directory)]
end

subgraph Install[One customer CFOS installation]
Browser[Authenticated Workshop session]
User[User Durable Object]
Proxy[notification-proxy]
ProxyState[(Opaque subscription id)]
Key[Install signing private key]
end

APNS -->|device token| App
App -->|Dashboard OAuth plus device token| Device
Device -->|store token; return one-time id| Registry
Device -->|one-time registration id| App
App -->|inject opaque id| Browser
Browser -->|registerNotificationDevice| User
User -->|account id plus one-time id| Proxy
Key -->|sign request locally| Proxy
Proxy -->|signed POST /v1/subscriptions| Device
Device -->|validate install; consume one-time id| Registry
Device -->|opaque subscription id| Proxy
Proxy --> ProxyState
```

Data boundaries:

- The APNs device token and Dashboard OAuth bearer never enter the customer installation.
- The install signing private key never leaves `notification-proxy`.
- Workshop core stores only a random proxy account id; the proxy stores only the opaque central
subscription id.
- A one-time device registration id is short-lived and cannot send a notification.

## Delivery

```mermaid
flowchart LR
subgraph Install[One customer CFOS installation]
Agent[Agent turn]
User[User Durable Object]
Browser[Visible browser subscriber]
Proxy[notification-proxy]
Key[Install signing private key]
end

subgraph Central[Cloudflare-operated notification service]
Delivery[Typed delivery API]
Registry[(Device and subscription directory)]
Audit[(Dedupe, rate limit, audit state)]
end

subgraph Apple[Apple and phone boundary]
APNS[APNs]
App[Cloudflare OS app]
end

Agent -->|completed or needs permission| User
User -->|visible client first| Browser
Browser -->|presentation acknowledged| User
User -->|fallback if no acknowledgement| Proxy
Key -->|sign request locally| Proxy
Proxy -->|typed title and same-origin path| Delivery
Delivery -->|validate install and subscription| Registry
Delivery --> Audit
Delivery -->|fixed APNs template| APNS
APNS --> App
```

Only the event id, event type, bounded chat title, opaque subscription id, and same-origin deep-link
path cross the central boundary during a send. Permission details, chat content, gatekeeper grants,
and provider credentials do not. The browser is attempted first; successful visible presentation
suppresses mobile push.

## Deployment contract

The release manifest classifies `notification-proxy` as a non-installable, preinstalled `system`
worker. The trusted deploy service must inject these values only into that worker:

- `NOTIFICATION_SERVICE_URL`
- `CFOS_INSTALL_ID`
- `CFOS_INSTALL_KEY_ID`
- `CFOS_INSTALL_PRIVATE_KEY`

Self-hosted deployments may omit them. Browser notifications continue to work, while native device
registration reports that push delivery is unavailable.
19 changes: 19 additions & 0 deletions packages/notification-proxy/cloudflare.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {
CAPNWEB_VALIDATE_BUILD, OBSERVABILITY, defineGadgetsWorker,
type DurableObjectMigration, type WranglerExtras,
} from "@gadgets/scripts/worker-config";

export default defineGadgetsWorker({
name: "notification-proxy",
entrypoint: ".wrangler/validate/src/worker.ts",
compatibilityFlags: ["nodejs_als"],
observability: OBSERVABILITY,
});

export const wrangler = {
build: CAPNWEB_VALIDATE_BUILD,
} satisfies WranglerExtras;

export const migrations: DurableObjectMigration[] = [
{ tag: "v0", new_sqlite_classes: ["NotificationAccountState"] },
];
24 changes: 24 additions & 0 deletions packages/notification-proxy/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@gadgets/notification-proxy",
"version": "1.0.0",
"type": "module",
"main": "./src/worker.ts",
"scripts": {
"dev": "echo \"run 'pnpm dev-server' in the root directory instead\" >&2 && exit 1",
"deploy": "wrangler deploy",
"build": "tsc",
"clean": "rm -rf dist .wrangler",
"test:run": "vitest run"
},
"dependencies": {
"@gadgets/workshop-shared": "workspace:*",
"capnweb-validate": "catalog:"
},
"devDependencies": {
"@gadgets/scripts": "workspace:*",
"typescript": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:",
"wrangler": "catalog:"
}
}
50 changes: 50 additions & 0 deletions packages/notification-proxy/src/account-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { DurableObject } from "cloudflare:workers";
import type {
PermissionRequestedDelivery, TaskCompletedDelivery,
} from "@gadgets/workshop-shared/notification-delivery";
import { registerDevice, sendPermissionRequested, sendTaskCompleted } from "./delegate.js";

const SUBSCRIPTION_KEY = "deliverySubscription";

/** Durable storage and central-delivery boundary for one installation user. */
export class NotificationAccountState extends DurableObject<Cloudflare.Env> {
/** Exchange a one-time device registration for an install-bound delivery subscription. */
async registerDevice(deviceRegistrationId: string): Promise<void> {
if (!/^[0-9a-f]{64}$/.test(deviceRegistrationId)) {
throw new Error("Invalid notification device registration.");
}
let { serviceUrl, identity } = this.#configuration();
let subscriptionId = await registerDevice(
serviceUrl, identity, deviceRegistrationId,
);
this.ctx.storage.kv.put(SUBSCRIPTION_KEY, subscriptionId);
}

/** Deliver a typed task completion using the stored grant. */
async deliverTaskCompleted(delivery: TaskCompletedDelivery): Promise<void> {
let subscriptionId = this.ctx.storage.kv.get<string>(SUBSCRIPTION_KEY);
if (!subscriptionId) return;
let { serviceUrl, identity } = this.#configuration();
await sendTaskCompleted(serviceUrl, identity, subscriptionId, delivery);
}

/** Deliver a permission-request alert using the existing install-bound subscription. */
async deliverPermissionRequested(delivery: PermissionRequestedDelivery): Promise<void> {
let subscriptionId = this.ctx.storage.kv.get<string>(SUBSCRIPTION_KEY);
if (!subscriptionId) return;
let { serviceUrl, identity } = this.#configuration();
await sendPermissionRequested(serviceUrl, identity, subscriptionId, delivery);
}

#configuration() {
let serviceUrl = this.env.NOTIFICATION_SERVICE_URL;
if (!serviceUrl) throw new Error("Notification service is not configured.");
let installId = this.env.CFOS_INSTALL_ID;
let keyId = this.env.CFOS_INSTALL_KEY_ID;
let privateKey = this.env.CFOS_INSTALL_PRIVATE_KEY;
if (!installId || !keyId || !privateKey) {
throw new Error("Cloudflare OS install identity is not configured.");
}
return { serviceUrl, identity: { installId, keyId, privateKey } };
}
}
117 changes: 117 additions & 0 deletions packages/notification-proxy/src/delegate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import {
permissionRequestedRequest,
registerDevice,
sendPermissionRequested,
sendTaskCompleted,
taskCompletedRequest,
} from "./delegate.js";

const delivery = {
id: "22222222-2222-4222-8222-222222222222",
workspaceId: "abc123",
chatId: 7,
workspaceTitle: "Demo workspace",
chatTitle: "Build the demo",
completedAt: new Date("2026-09-29T20:00:00.000Z"),
};

const signingIdentity = async () => {
let pair = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"],
) as CryptoKeyPair;
return {
installId: `${"a".repeat(32)}:router`,
keyId: "11111111-1111-4111-8111-111111111111",
privateKey: btoa(String.fromCharCode(...new Uint8Array(
await crypto.subtle.exportKey("pkcs8", pair.privateKey) as ArrayBuffer,
))),
};
};

describe("notification service delegate", () => {
it("serializes the typed task-completion contract", () => {
expect(taskCompletedRequest(delivery, "a".repeat(64))).toEqual({
type: "task_completed",
eventId: delivery.id,
taskId: "abc123:7",
threadTitle: "Build the demo",
path: "/workspace/abc123?chat=7",
subscriptionId: "a".repeat(64),
});
});

it("bounds the user-controlled title sent to the central renderer", () => {
expect(taskCompletedRequest({
...delivery,
chatTitle: ` Build\n\tthe demo ${"🚂".repeat(100)} `,
}, "a".repeat(64)).threadTitle).toBe(`Build the demo ${"🚂".repeat(81)}`);
expect(taskCompletedRequest({
...delivery,
chatTitle: " \n\t ",
}, "a".repeat(64)).threadTitle).toBe("Task");
});

it("serializes permission prompts without permission contents", () => {
let { completedAt, ...task } = delivery;
expect(permissionRequestedRequest({
...task, requestedAt: completedAt,
}, "a".repeat(64))).toEqual({
type: "permission_requested",
eventId: delivery.id,
taskId: "abc123:7",
threadTitle: "Build the demo",
path: "/workspace/abc123?chat=7",
subscriptionId: "a".repeat(64),
});
});

it.each(["task_completed", "permission_requested"] as const)(
"signs the complete %s request with the install identity", async type => {
let identity = await signingIdentity();
let fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 202 }));
if (type === "task_completed") {
await sendTaskCompleted(
"https://notifications.example.test", identity, "b".repeat(64), delivery, fetcher,
);
} else {
let { completedAt, ...task } = delivery;
await sendPermissionRequested(
"https://notifications.example.test", identity, "b".repeat(64),
{ ...task, requestedAt: completedAt }, fetcher,
);
}

let [url, init] = fetcher.mock.calls[0];
let headers = new Headers(init?.headers);
expect(String(url)).toBe("https://notifications.example.test/v1/deliveries");
expect(headers.get("authorization")).toBeNull();
expect(headers.get("x-cfos-install-id")).toBe(identity.installId);
expect(headers.get("x-cfos-key-id")).toBe(identity.keyId);
expect(headers.get("x-cfos-signature")).toMatch(/^[A-Za-z0-9_-]+$/);
expect(JSON.parse(String(init?.body)).type).toBe(type);
},
);

it("registers a one-time native device capability through the install identity", async () => {
let identity = await signingIdentity();
let fetcher = vi.fn<typeof fetch>().mockResolvedValue(Response.json(
{ subscriptionId: "c".repeat(64) }, { status: 201 },
));
await expect(registerDevice(
"https://notifications.example.test", identity, "b".repeat(64), fetcher,
)).resolves.toBe("c".repeat(64));

let [url, init] = fetcher.mock.calls[0];
expect(String(url)).toBe("https://notifications.example.test/v1/subscriptions");
expect(JSON.parse(String(init?.body))).toEqual({ deviceRegistrationId: "b".repeat(64) });
});

it("rejects non-HTTPS service configuration before fetching", async () => {
let fetcher = vi.fn<typeof fetch>();
await expect(sendTaskCompleted("http://notifications.example.test", {
installId: "install", keyId: "key", privateKey: "key",
}, "a".repeat(64), delivery, fetcher)).rejects.toThrow("HTTPS origin");
expect(fetcher).not.toHaveBeenCalled();
});
});
Loading
Loading