@frockbot/cloudflare 0.0.0 → 0.7.291

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/build-artifact.ts +130 -0
  2. package/build-flutter-web.ts +508 -0
  3. package/deployment-config/README.md +414 -0
  4. package/deployment-config/cli.ts +228 -0
  5. package/deployment-config/generate.ts +689 -0
  6. package/deployment-config/jsonc.ts +18 -0
  7. package/deployment-config/profile-schema.generated.ts +391 -0
  8. package/deployment-config/profile.schema.json +358 -0
  9. package/deployment-config/profile.ts +124 -0
  10. package/migrations/0001_better_auth.sql +15 -0
  11. package/migrations/0002_drop_account_issuer.sql +8 -0
  12. package/package.json +61 -5
  13. package/release-version.ts +20 -0
  14. package/src/account-admission.ts +179 -0
  15. package/src/account-deletion.ts +274 -0
  16. package/src/admin-entrypoint.ts +194 -0
  17. package/src/admin-identities.ts +26 -0
  18. package/src/audit.ts +103 -0
  19. package/src/auth-package.access.ts +24 -0
  20. package/src/auth-package.ts +36 -0
  21. package/src/avatar-state-cleanup.ts +107 -0
  22. package/src/billing-computer.ts +267 -0
  23. package/src/billing-readiness.ts +21 -0
  24. package/src/billing.ts +461 -0
  25. package/src/bot-capabilities.ts +480 -0
  26. package/src/bot-recovery.ts +1 -0
  27. package/src/bot-state-channel.ts +702 -0
  28. package/src/bot-state.ts +3995 -0
  29. package/src/bot-template-cleanup.ts +98 -0
  30. package/src/bot-title-cleanup.ts +104 -0
  31. package/src/brand-icon.png +0 -0
  32. package/src/brand-logo.ts +2 -0
  33. package/src/brand.ts +36 -0
  34. package/src/client-compatibility.ts +51 -0
  35. package/src/compaction-announcement-cleanup.ts +113 -0
  36. package/src/computer-egress.ts +110 -0
  37. package/src/computer-host.ts +92 -0
  38. package/src/computer-screenshot-cleanup.ts +78 -0
  39. package/src/contracts.ts +832 -0
  40. package/src/debug.ts +272 -0
  41. package/src/default-packages-marker-cleanup.ts +39 -0
  42. package/src/deployment-policy-admin-host.ts +121 -0
  43. package/src/deployment-policy.ts +426 -0
  44. package/src/directory-profile-cleanup.ts +101 -0
  45. package/src/durable-rpc.ts +753 -0
  46. package/src/durable-session.ts +21 -0
  47. package/src/entry-boundary.ts +103 -0
  48. package/src/frock-ai.ts +338 -0
  49. package/src/gateway.ts +1392 -0
  50. package/src/group-chat.ts +736 -0
  51. package/src/hidden-bot-notifications-cleanup.ts +32 -0
  52. package/src/index.ts +2798 -0
  53. package/src/insights.ts +15 -0
  54. package/src/machine-messages-cleanup.ts +121 -0
  55. package/src/machine-socket.ts +164 -0
  56. package/src/memory-records.ts +181 -0
  57. package/src/memory.ts +145 -0
  58. package/src/model-rates.ts +252 -0
  59. package/src/native-auth.ts +1006 -0
  60. package/src/native-sessions.ts +121 -0
  61. package/src/notification-state-cleanup.ts +18 -0
  62. package/src/ollama-web-search-cleanup.ts +69 -0
  63. package/src/package-page-shapes-cleanup.ts +190 -0
  64. package/src/plugin-egress.ts +31 -0
  65. package/src/plugin-page-route.ts +72 -0
  66. package/src/plugin-panels-cleanup.ts +101 -0
  67. package/src/prepared-input-cleanup.ts +105 -0
  68. package/src/production-secrets.ts +484 -0
  69. package/src/project-cleanup.ts +123 -0
  70. package/src/project-events-cleanup.ts +152 -0
  71. package/src/publication-state-cleanup.ts +92 -0
  72. package/src/push.ts +498 -0
  73. package/src/request-body.ts +165 -0
  74. package/src/routine-state-cleanup.ts +57 -0
  75. package/src/search.ts +88 -0
  76. package/src/sidebar-label-cleanup.ts +165 -0
  77. package/src/skill-index-cleanup.ts +53 -0
  78. package/src/supersede-cleanup.ts +162 -0
  79. package/src/test-chat-cleanup.ts +93 -0
  80. package/src/uploads.ts +486 -0
  81. package/src/user-application.ts +1068 -0
  82. package/src/user-configuration.ts +4491 -0
  83. package/src/voice-assistant.ts +4999 -0
  84. package/src/voice-dictation.ts +743 -0
  85. package/src/working-context-cleanup.ts +96 -0
  86. package/src/workspace.ts +201 -0
  87. package/wrangler.jsonc +385 -0
  88. package/README.md +0 -3
@@ -0,0 +1,121 @@
1
+ import {
2
+ isProtocolValue,
3
+ type ClientHello,
4
+ } from "@frockbot/core/protocol-schemas";
5
+
6
+ export interface NativeSessionRecord {
7
+ schemaVersion: 1;
8
+ userId: string;
9
+ sessionId: string;
10
+ hello: ClientHello;
11
+ expiresAt: number;
12
+ revoked: boolean;
13
+ }
14
+
15
+ export interface NativeSessionStorage {
16
+ get<T>(key: string): T | undefined;
17
+ put(key: string, value: unknown): void;
18
+ }
19
+
20
+ export type NativeSessionOperation = {
21
+ schemaVersion: 1;
22
+ userId: string;
23
+ action: "check-issue" | "issue" | "read" | "revoke";
24
+ sessionId: string;
25
+ hello: ClientHello;
26
+ expiresAt: number;
27
+ };
28
+
29
+ export function decodeNativeSessionOperation(
30
+ value: unknown,
31
+ ): NativeSessionOperation {
32
+ if (!value || typeof value !== "object" || Array.isArray(value))
33
+ throw new Error("Invalid native session");
34
+ const v = value as Record<string, unknown>;
35
+ if (
36
+ Object.keys(v).sort().join() !==
37
+ "action,expiresAt,hello,schemaVersion,sessionId,userId" ||
38
+ v.schemaVersion !== 1 ||
39
+ !isProtocolValue("Identifier", v.userId) ||
40
+ !isProtocolValue("Identifier", v.sessionId) ||
41
+ !isProtocolValue("ClientHello", v.hello) ||
42
+ (v.action !== "check-issue" &&
43
+ v.action !== "issue" &&
44
+ v.action !== "read" &&
45
+ v.action !== "revoke") ||
46
+ typeof v.expiresAt !== "number" ||
47
+ !Number.isSafeInteger(v.expiresAt)
48
+ )
49
+ throw new Error("Invalid native session");
50
+ return {
51
+ schemaVersion: 1,
52
+ userId: v.userId,
53
+ sessionId: v.sessionId,
54
+ hello: v.hello,
55
+ action: v.action,
56
+ expiresAt: v.expiresAt,
57
+ };
58
+ }
59
+
60
+ // Called only inside the User DO's synchronous transaction. Bounded, versioned
61
+ // records survive eviction; expiry tombstones cannot be evicted by new logins.
62
+ export function nativeSessionOperation(
63
+ storage: NativeSessionStorage,
64
+ input: unknown,
65
+ now: number,
66
+ ): NativeSessionRecord | null {
67
+ const op = decodeNativeSessionOperation(input);
68
+ const key = "native:sessions:v1";
69
+ const stored = storage.get<unknown>(key);
70
+ if (stored !== undefined && !Array.isArray(stored))
71
+ throw new Error("Invalid stored native sessions");
72
+ const records = (stored ?? []) as unknown[];
73
+ const current: NativeSessionRecord[] = records
74
+ .map((value) => {
75
+ if (!value || typeof value !== "object" || Array.isArray(value))
76
+ throw new Error("Invalid stored native session");
77
+ const { revoked, ...fields } = value as Record<string, unknown>;
78
+ if (typeof revoked !== "boolean")
79
+ throw new Error("Invalid stored native session");
80
+ const decoded = decodeNativeSessionOperation({
81
+ ...fields,
82
+ action: "read",
83
+ });
84
+ const { action: _action, ...record } = decoded;
85
+ return { ...record, revoked };
86
+ })
87
+ .filter((record) => record.expiresAt > now);
88
+ let record = current.find((item) => item.sessionId === op.sessionId);
89
+ if (op.action === "issue" || op.action === "check-issue") {
90
+ if (record)
91
+ throw new Error("This sign-in has already been used. Sign in again.");
92
+ // Active ones only. A revoked session is a device that has signed out,
93
+ // and counting it kept the slot for the rest of its seven days: sign in
94
+ // and out enough times on one phone and the app told you to sign out on
95
+ // another device, which could not have helped.
96
+ if (current.filter((item) => !item.revoked).length >= 32)
97
+ throw new Error("Too many active sign-ins. Sign out on another device.");
98
+ if (op.expiresAt <= now || op.expiresAt > now + 7 * 86400_000)
99
+ throw new Error("Invalid native session expiry");
100
+ if (op.action === "check-issue") return null;
101
+ record = {
102
+ schemaVersion: 1,
103
+ userId: op.userId,
104
+ sessionId: op.sessionId,
105
+ hello: op.hello,
106
+ expiresAt: op.expiresAt,
107
+ revoked: false,
108
+ };
109
+ current.push(record);
110
+ } else if (record) {
111
+ if (
112
+ record.userId !== op.userId ||
113
+ record.expiresAt !== op.expiresAt ||
114
+ JSON.stringify(record.hello) !== JSON.stringify(op.hello)
115
+ )
116
+ return null;
117
+ if (op.action === "revoke") record.revoked = true;
118
+ }
119
+ if (op.action !== "read") storage.put(key, current);
120
+ return record && !record.revoked ? record : null;
121
+ }
@@ -0,0 +1,18 @@
1
+ /** Disposable pre-user projection cleanup. Message history and Bot settings are retained. */
2
+ export async function cleanNotificationTestState(
3
+ storage: DurableObjectStorage,
4
+ ): Promise<void> {
5
+ const receiptKey = "maintenance:message-notifications:2026-09-09";
6
+ await storage.transaction(async (tx) => {
7
+ if (await tx.get(receiptKey)) return;
8
+ let deleted = 0;
9
+ for (const prefix of ["shell:unread", "notification:"]) {
10
+ for (;;) {
11
+ const keys = [...(await tx.list({ prefix, limit: 128 })).keys()];
12
+ if (!keys.length) break;
13
+ deleted += await tx.delete(keys);
14
+ }
15
+ }
16
+ await tx.put(receiptKey, { at: new Date().toISOString(), deleted });
17
+ });
18
+ }
@@ -0,0 +1,69 @@
1
+ // Disposable pre-user cleanup for the retired Ollama web search.
2
+ //
3
+ // Web search is the Web Package's now, on the platform's own key. The Ollama
4
+ // Package's `web-search-max-results` setting capped the search it retired, and
5
+ // its value sits on that Package's installation row in `user-configuration`.
6
+ // Resolution already ignores a value no manifest declares, so this removes a
7
+ // dead value from the row a client reads back rather than repairing a read.
8
+ // The `ollama-cloud-web-search` Capability was never stored: a Package's
9
+ // enabled Capabilities are derived from its definition, so nothing else is
10
+ // left. The receipt makes a second load free.
11
+
12
+ const USER_CONFIGURATION_KEY = "user-configuration";
13
+ const OLLAMA_PACKAGE_ID = "provider-ollama-cloud";
14
+ const RETIRED_SETTING_ID = "web-search-max-results";
15
+ export const OLLAMA_WEB_SEARCH_CLEANUP_RECEIPT_KEY =
16
+ "maintenance:ollama-web-search:2026-09-24";
17
+
18
+ type Stored = Record<string, unknown>;
19
+
20
+ function record(value: unknown): Stored | undefined {
21
+ if (!value || typeof value !== "object" || Array.isArray(value))
22
+ return undefined;
23
+ return value as Stored;
24
+ }
25
+
26
+ /** The settings without the retired value, or `undefined` to keep them. */
27
+ export function withoutRetiredWebSearchSettingV1(
28
+ stored: unknown,
29
+ ): Stored | undefined {
30
+ const settings = record(stored);
31
+ if (!settings || !Array.isArray(settings.packages)) return undefined;
32
+ let changed = false;
33
+ const packages = settings.packages.map((entry) => {
34
+ const installation = record(entry);
35
+ const values = record(installation?.values);
36
+ if (
37
+ !installation ||
38
+ !values ||
39
+ installation.packageId !== OLLAMA_PACKAGE_ID ||
40
+ !Object.hasOwn(values, RETIRED_SETTING_ID)
41
+ ) {
42
+ return entry;
43
+ }
44
+ changed = true;
45
+ const { [RETIRED_SETTING_ID]: _retired, ...kept } = values;
46
+ const { values: _values, ...rest } = installation;
47
+ // The settings writer omits an empty bag, so the cleanup does too.
48
+ return Object.keys(kept).length > 0 ? { ...rest, values: kept } : rest;
49
+ });
50
+ return changed ? { ...settings, packages } : undefined;
51
+ }
52
+
53
+ export async function cleanRetiredOllamaWebSearchV1(
54
+ storage: DurableObjectStorage,
55
+ now: Date = new Date(),
56
+ ): Promise<void> {
57
+ if (await storage.get(OLLAMA_WEB_SEARCH_CLEANUP_RECEIPT_KEY)) return;
58
+ await storage.transaction(async (tx) => {
59
+ if (await tx.get(OLLAMA_WEB_SEARCH_CLEANUP_RECEIPT_KEY)) return;
60
+ const cleaned = withoutRetiredWebSearchSettingV1(
61
+ await tx.get<unknown>(USER_CONFIGURATION_KEY),
62
+ );
63
+ if (cleaned) await tx.put(USER_CONFIGURATION_KEY, cleaned);
64
+ await tx.put(OLLAMA_WEB_SEARCH_CLEANUP_RECEIPT_KEY, {
65
+ at: now.toISOString(),
66
+ removed: cleaned !== undefined,
67
+ });
68
+ });
69
+ }
@@ -0,0 +1,190 @@
1
+ // Disposable pre-user cleanup for the turn-log shapes the Package page
2
+ // pipeline left behind.
3
+ //
4
+ // The Applet canvas admitted a Package page's tool call as a `directTool`
5
+ // run, and an Applet publish reconciled the Computer with a `publish` sync.
6
+ // Both producers are gone and neither decoder accepts the value, so this
7
+ // rewrites them before anything decodes a run or a Session event.
8
+ //
9
+ // A finished run keeps its transcript and loses the field: it reads as the
10
+ // ordinary Turn it was drawn as. An unfinished one could only resume as a chat
11
+ // Turn replaying its label, so it is removed with the pointers that named it,
12
+ // as the prepared-input cleanup does. A `publish` sync becomes `signal`, the
13
+ // other mid-Turn reason: the event is diagnostic, its counts stay true, and no
14
+ // sequence number moves. The walk is once per Bot; it pages rather than
15
+ // loading the archive into one value.
16
+
17
+ import {
18
+ ACTIVE_RUN_KEY,
19
+ LATEST_EVENTS_KEY,
20
+ PENDING_AGENT_RUN_PREFIX,
21
+ PENDING_USER_RUN_PREFIX,
22
+ REPAIR_DUE_PREFIX,
23
+ REPAIR_RUN_PREFIX,
24
+ RUN_INDEX_PREFIX,
25
+ RUN_PREFIX,
26
+ SESSION_EVENT_LOG_INDEX_PREFIX,
27
+ SessionEventLog,
28
+ type SessionEventLogStorage,
29
+ } from "@frockbot/core/durable";
30
+
31
+ const RECEIPT = "maintenance:package-page-shapes:2026-09-24";
32
+ const PAGE = 50;
33
+
34
+ interface CleanupStorage extends SessionEventLogStorage {
35
+ delete(key: string): Promise<boolean>;
36
+ transaction<T>(body: (tx: SessionEventLogStorage) => Promise<T>): Promise<T>;
37
+ }
38
+
39
+ type Stored = Record<string, unknown>;
40
+
41
+ function record(value: unknown): Stored | undefined {
42
+ if (!value || typeof value !== "object" || Array.isArray(value))
43
+ return undefined;
44
+ return value as Stored;
45
+ }
46
+
47
+ /** The event without a `publish` sync, or `undefined` to keep it. */
48
+ export function withoutPublishSyncV1(event: Stored): Stored | undefined {
49
+ return event.type === "computer/sync" && event.reason === "publish"
50
+ ? { ...event, reason: "signal" }
51
+ : undefined;
52
+ }
53
+
54
+ /** The run without Package page shapes, or `undefined` to keep it. */
55
+ function withoutPackagePageShapes(run: Stored): Stored | undefined {
56
+ let changed = false;
57
+ const cleaned = { ...run };
58
+ if (Object.hasOwn(cleaned, "directTool")) {
59
+ delete cleaned.directTool;
60
+ changed = true;
61
+ }
62
+ if (Array.isArray(cleaned.events)) {
63
+ cleaned.events = cleaned.events.map((event) => {
64
+ const stored = record(event);
65
+ const replaced = stored && withoutPublishSyncV1(stored);
66
+ if (!replaced) return event;
67
+ changed = true;
68
+ return replaced;
69
+ });
70
+ }
71
+ return changed ? cleaned : undefined;
72
+ }
73
+
74
+ function isTerminal(run: Stored): boolean {
75
+ return (
76
+ run.status === "completed" ||
77
+ run.status === "failed" ||
78
+ run.status === "cancelled"
79
+ );
80
+ }
81
+
82
+ async function page(
83
+ storage: CleanupStorage,
84
+ prefix: string,
85
+ visit: (key: string, value: unknown) => Promise<void>,
86
+ ): Promise<void> {
87
+ let start: string | undefined;
88
+ for (;;) {
89
+ const listed = await storage.list({
90
+ prefix,
91
+ limit: PAGE,
92
+ ...(start ? { start } : {}),
93
+ });
94
+ if (listed.size === 0) return;
95
+ let last = "";
96
+ for (const [key, value] of listed) {
97
+ last = key;
98
+ if (start !== undefined && key === start) continue;
99
+ await visit(key, value);
100
+ }
101
+ if (listed.size < PAGE) return;
102
+ start = `${last}\0`;
103
+ }
104
+ }
105
+
106
+ export async function cleanPackagePageShapesV1(
107
+ storage: CleanupStorage,
108
+ ): Promise<void> {
109
+ if (await storage.get(RECEIPT)) return;
110
+ let rewrittenRuns = 0;
111
+ const removed = new Set<string>();
112
+ await page(storage, RUN_PREFIX, async (key, value) => {
113
+ const run = record(value);
114
+ if (!run) return;
115
+ if (Object.hasOwn(run, "directTool") && !isTerminal(run)) {
116
+ if (typeof run.runId === "string") removed.add(run.runId);
117
+ await storage.delete(key);
118
+ return;
119
+ }
120
+ const cleaned = withoutPackagePageShapes(run);
121
+ if (!cleaned) return;
122
+ rewrittenRuns += 1;
123
+ await storage.put(key, cleaned);
124
+ });
125
+ if (removed.size > 0) {
126
+ const active = await storage.get(ACTIVE_RUN_KEY);
127
+ if (typeof active === "string" && removed.has(active)) {
128
+ await storage.delete(ACTIVE_RUN_KEY);
129
+ }
130
+ for (const prefix of [
131
+ PENDING_USER_RUN_PREFIX,
132
+ PENDING_AGENT_RUN_PREFIX,
133
+ RUN_INDEX_PREFIX,
134
+ REPAIR_DUE_PREFIX,
135
+ ]) {
136
+ await page(storage, prefix, async (key, value) => {
137
+ if (typeof value === "string" && removed.has(value)) {
138
+ await storage.delete(key);
139
+ }
140
+ });
141
+ }
142
+ for (const runId of removed) {
143
+ await storage.delete(`${REPAIR_RUN_PREFIX}${runId}`);
144
+ }
145
+ }
146
+
147
+ const sessions: string[] = [];
148
+ await page(storage, SESSION_EVENT_LOG_INDEX_PREFIX, async (key) => {
149
+ sessions.push(
150
+ decodeURIComponent(key.slice(SESSION_EVENT_LOG_INDEX_PREFIX.length)),
151
+ );
152
+ });
153
+ let events = 0;
154
+ const unreadable: string[] = [];
155
+ for (const sessionId of sessions) {
156
+ try {
157
+ events += await storage.transaction((tx) =>
158
+ new SessionEventLog(tx).repairStoredEvents(
159
+ sessionId,
160
+ withoutPublishSyncV1,
161
+ ),
162
+ );
163
+ } catch {
164
+ // A throw here would reset the object on every wake. A log this cannot
165
+ // read was unreadable before it ran, and is left as it was.
166
+ unreadable.push(sessionId);
167
+ }
168
+ }
169
+ // The pre-paging log, if a Bot still has one, is read the same way.
170
+ const legacy = await storage.get(LATEST_EVENTS_KEY);
171
+ if (Array.isArray(legacy)) {
172
+ let changed = false;
173
+ const repaired = legacy.map((event) => {
174
+ const stored = record(event);
175
+ const replaced = stored && withoutPublishSyncV1(stored);
176
+ if (!replaced) return event;
177
+ changed = true;
178
+ events += 1;
179
+ return replaced;
180
+ });
181
+ if (changed) await storage.put(LATEST_EVENTS_KEY, repaired);
182
+ }
183
+ await storage.put(RECEIPT, {
184
+ schemaVersion: 1,
185
+ rewrittenRuns,
186
+ removedRuns: removed.size,
187
+ events,
188
+ unreadable,
189
+ });
190
+ }
@@ -0,0 +1,31 @@
1
+ // The egress loopback a Plugin worker's `globalOutbound` is bound to.
2
+ //
3
+ // A Plugin's `fetch` never reaches the network directly: `globalOutbound` is
4
+ // this service, minted per User with the hosts the enabled Plugins declared
5
+ // (or open access, if one asked for it and the User approved). Every Plugin
6
+ // in the worker shares a realm, so the policy is the union of what they
7
+ // declared and is described to the User that way (ADR 0026). A refused host
8
+ // is a thrown error, which is what a Plugin's `fetch` sees as a rejection —
9
+ // the same shape the platform gives a worker with no outbound at all.
10
+ import { WorkerEntrypoint } from "cloudflare:workers";
11
+ import {
12
+ pluginEgressAdmitsV1,
13
+ type PluginEgressPropsV1,
14
+ } from "@frockbot/app/isolates/capabilities";
15
+
16
+ export type { PluginEgressPropsV1 };
17
+
18
+ export class PluginEgress extends WorkerEntrypoint<
19
+ unknown,
20
+ PluginEgressPropsV1
21
+ > {
22
+ override async fetch(request: Request): Promise<Response> {
23
+ const verdict = pluginEgressAdmitsV1(this.ctx.props, request.url);
24
+ if (!verdict.admitted) {
25
+ // Thrown, not answered: a 403 would read as the remote's answer, and a
26
+ // Plugin has no contract for telling the two apart.
27
+ throw new Error(verdict.reason);
28
+ }
29
+ return fetch(request);
30
+ }
31
+ }
@@ -0,0 +1,72 @@
1
+ // A Plugin's page, served from the app's own origin (ADR 0036).
2
+ //
3
+ // The page is untrusted HTML, so the app origin is only where its bytes come
4
+ // from, never what they run as. The response's CSP `sandbox` gives the
5
+ // document an opaque origin however it is opened — framed, or pasted into an
6
+ // address bar — so it can read no cookie and no storage of the app, and the
7
+ // policy lets it load and connect to nothing. The frame's own `sandbox`
8
+ // attribute says the same again. The route is anonymous because the frame's
9
+ // request is credentialless: the page is named by the hash of its bytes, and
10
+ // holds nothing of the account.
11
+ import { PLUGIN_PAGE_ROUTE_V1 } from "@frockbot/core/contracts";
12
+ import { sha256HexTextV1 } from "@frockbot/core/crypto";
13
+ import { INSIGHTS_REPORT_ORIGIN, INSIGHTS_SCRIPT_ORIGIN } from "./insights.js";
14
+
15
+ /*
16
+ * The zone injects its Insights beacon into HTML it serves; `no-transform`
17
+ * asks it not to, because the hash names these bytes. The beacon's origins
18
+ * are named anyway, since that is a request rather than a guarantee, and
19
+ * naming them opens nothing else.
20
+ */
21
+ export const PLUGIN_PAGE_CSP_V1 = [
22
+ "sandbox allow-scripts",
23
+ "default-src 'none'",
24
+ `script-src 'unsafe-inline' ${INSIGHTS_SCRIPT_ORIGIN}`,
25
+ "style-src 'unsafe-inline'",
26
+ "img-src data:",
27
+ `connect-src ${INSIGHTS_REPORT_ORIGIN}`,
28
+ "base-uri 'none'",
29
+ "form-action 'none'",
30
+ "frame-ancestors 'self'",
31
+ ].join("; ");
32
+
33
+ export function isPluginPagePathV1(pathname: string): boolean {
34
+ return PLUGIN_PAGE_ROUTE_V1.test(pathname);
35
+ }
36
+
37
+ export async function servePluginPageV1(
38
+ request: Request,
39
+ url: URL,
40
+ load: ((contentHash: string) => Promise<string | undefined>) | undefined,
41
+ ): Promise<Response> {
42
+ if (request.method !== "GET" && request.method !== "HEAD") {
43
+ return Response.json({ error: "method not allowed" }, { status: 405 });
44
+ }
45
+ const contentHash = PLUGIN_PAGE_ROUTE_V1.exec(url.pathname)?.[1];
46
+ if (!contentHash) {
47
+ return Response.json({ error: "page was not found" }, { status: 404 });
48
+ }
49
+ const etag = `"${contentHash}"`;
50
+ const headers = {
51
+ "content-type": "text/html; charset=utf-8",
52
+ "content-security-policy": PLUGIN_PAGE_CSP_V1,
53
+ "x-content-type-options": "nosniff",
54
+ "referrer-policy": "no-referrer",
55
+ "cache-control": "public, max-age=31536000, immutable, no-transform",
56
+ etag,
57
+ };
58
+ if (request.headers.get("if-none-match") === etag) {
59
+ return new Response(null, { status: 304, headers });
60
+ }
61
+ const html = await load?.(contentHash);
62
+ if (html === undefined) {
63
+ return Response.json({ error: "page was not found" }, { status: 404 });
64
+ }
65
+ if ((await sha256HexTextV1(html)) !== contentHash) {
66
+ return Response.json(
67
+ { error: "page failed verification" },
68
+ { status: 502 },
69
+ );
70
+ }
71
+ return new Response(request.method === "HEAD" ? null : html, { headers });
72
+ }
@@ -0,0 +1,101 @@
1
+ // Disposable pre-user cleanup for ADR 0034: Applets are deleted.
2
+ //
3
+ // There are no Users yet. Directory entries, Composition `applets[]`, the
4
+ // account feature flag, cleanup to-dos and the Bot's focused-Applet pointer
5
+ // are dropped rather than migrated. A receipt makes a second load free. The
6
+ // key strings are restated here so this file does not import the Applet
7
+ // product it is deleting.
8
+ import { COMPOSITION_GENERATION_PREFIX } from "@frockbot/core/durable";
9
+
10
+ export const PLUGIN_PANELS_USER_CLEANUP_RECEIPT_KEY =
11
+ "maintenance:plugin-panels:2026-09-21";
12
+ export const PLUGIN_PANELS_BOT_CLEANUP_RECEIPT_KEY =
13
+ "maintenance:plugin-panels:2026-09-21";
14
+
15
+ const USER_FEATURES_KEY = "user:features:v1";
16
+ const APPLET_DIRECTORY_ENTRY_PREFIX = "applets:entry:";
17
+ const APPLET_DIRECTORY_REVISION_KEY = "applets:directory-revision";
18
+ const APPLET_CLEANUP_PREFIX = "applets:cleanup:";
19
+ const APPLET_FOCUSED_KEY = "applets:focused";
20
+
21
+ export async function cleanUserAppletsV1(
22
+ storage: DurableObjectStorage,
23
+ now: Date = new Date(),
24
+ ): Promise<void> {
25
+ if (await storage.get(PLUGIN_PANELS_USER_CLEANUP_RECEIPT_KEY)) return;
26
+ const at = now.toISOString();
27
+ let removedEntries = 0;
28
+ let removedCleanups = 0;
29
+ let strippedGenerations = 0;
30
+ let strippedFeatures = false;
31
+ let removedRevision = false;
32
+
33
+ await storage.transaction(async (tx) => {
34
+ if (await tx.get(PLUGIN_PANELS_USER_CLEANUP_RECEIPT_KEY)) return;
35
+ const entryKeys = [
36
+ ...(await tx.list({ prefix: APPLET_DIRECTORY_ENTRY_PREFIX })).keys(),
37
+ ];
38
+ if (entryKeys.length > 0) {
39
+ removedEntries = await tx.delete(entryKeys);
40
+ }
41
+ const cleanupKeys = [
42
+ ...(await tx.list({ prefix: APPLET_CLEANUP_PREFIX })).keys(),
43
+ ];
44
+ if (cleanupKeys.length > 0) {
45
+ removedCleanups = await tx.delete(cleanupKeys);
46
+ }
47
+ if ((await tx.get(APPLET_DIRECTORY_REVISION_KEY)) !== undefined) {
48
+ await tx.delete(APPLET_DIRECTORY_REVISION_KEY);
49
+ removedRevision = true;
50
+ }
51
+ for (const [key, value] of await tx.list<unknown>({
52
+ prefix: COMPOSITION_GENERATION_PREFIX,
53
+ })) {
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
55
+ if (!Object.hasOwn(value, "applets")) continue;
56
+ const { applets: _dropped, ...rest } = value as Record<string, unknown>;
57
+ await tx.put(key, rest);
58
+ strippedGenerations += 1;
59
+ }
60
+ const features = await tx.get<unknown>(USER_FEATURES_KEY);
61
+ if (
62
+ features &&
63
+ typeof features === "object" &&
64
+ !Array.isArray(features) &&
65
+ Object.hasOwn(features, "applets")
66
+ ) {
67
+ const { applets: _dropped, ...rest } = features as Record<
68
+ string,
69
+ unknown
70
+ >;
71
+ await tx.put(USER_FEATURES_KEY, rest);
72
+ strippedFeatures = true;
73
+ }
74
+ await tx.put(PLUGIN_PANELS_USER_CLEANUP_RECEIPT_KEY, {
75
+ schemaVersion: 1,
76
+ at,
77
+ removedEntries,
78
+ removedCleanups,
79
+ strippedGenerations,
80
+ strippedFeatures,
81
+ removedRevision,
82
+ });
83
+ });
84
+ }
85
+
86
+ export async function cleanBotAppletsV1(
87
+ storage: DurableObjectStorage,
88
+ now: Date = new Date(),
89
+ ): Promise<void> {
90
+ if (await storage.get(PLUGIN_PANELS_BOT_CLEANUP_RECEIPT_KEY)) return;
91
+ await storage.transaction(async (tx) => {
92
+ if (await tx.get(PLUGIN_PANELS_BOT_CLEANUP_RECEIPT_KEY)) return;
93
+ const hadFocus = (await tx.get(APPLET_FOCUSED_KEY)) !== undefined;
94
+ if (hadFocus) await tx.delete(APPLET_FOCUSED_KEY);
95
+ await tx.put(PLUGIN_PANELS_BOT_CLEANUP_RECEIPT_KEY, {
96
+ schemaVersion: 1,
97
+ at: now.toISOString(),
98
+ clearedFocus: hadFocus,
99
+ });
100
+ });
101
+ }