@frockbot/cloudflare 0.0.0 → 0.7.292

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,1006 @@
1
+ import {
2
+ isProtocolValue,
3
+ type AuthStartCommand,
4
+ type ClientHello,
5
+ } from "@frockbot/core/protocol-schemas";
6
+ import {
7
+ clientCompatibilityResponse,
8
+ CLIENT_HELLO_HEADER,
9
+ UPDATE_APP_MESSAGE,
10
+ } from "./client-compatibility.js";
11
+ import {
12
+ returnPageV1,
13
+ type ReturnPageBrandV1,
14
+ } from "@frockbot/app/return-page";
15
+ import {
16
+ AUTH_NO_STORE_HEADERS_V1,
17
+ signInFailedV1 as error,
18
+ signInRedirectV1 as redirect,
19
+ } from "@frockbot/app/auth/shared";
20
+ import type { AccountAdmissionDecisionV1 } from "@frockbot/app/admin/shared";
21
+ import {
22
+ admissionRefusedResponse,
23
+ admissionUnavailableResponse,
24
+ } from "./account-admission.js";
25
+ import {
26
+ nativeReturnSchemeV1,
27
+ type AuthIdentityV1,
28
+ type AuthPackageIdentityV1,
29
+ type BrandV1,
30
+ } from "@frockbot/core/contracts";
31
+ import type {
32
+ NativeSessionOperation,
33
+ NativeSessionRecord,
34
+ } from "./native-sessions.js";
35
+
36
+ /**
37
+ * Each signed app's return page under `/native/return/`: Android's verified
38
+ * App Link, and the page each Apple build's browser return hands over on its
39
+ * own scheme. `macos-dev` and `ios-dev` are the FrockBot Dev builds, separate
40
+ * apps installed beside the released ones, which a deployment serves only
41
+ * when its profile names them.
42
+ */
43
+ export type NativeReturnPlatformV1 =
44
+ "android" | "macos" | "macos-dev" | "ios" | "ios-dev";
45
+ const NATIVE_RETURN_PLATFORMS: readonly NativeReturnPlatformV1[] = [
46
+ "android",
47
+ "macos",
48
+ "macos-dev",
49
+ "ios",
50
+ "ios-dev",
51
+ ];
52
+
53
+ /**
54
+ * Where a signed app receives its sign-in: a path on the deployment's own
55
+ * origin. No deployment is named here — the origin is the one the Worker was
56
+ * given, so a second deployment's App Links are its own
57
+ * ([ADR 0028](../../../docs/adr/0028-open-deployment.md)).
58
+ */
59
+ export function nativeReturnUriV1(
60
+ origin: string,
61
+ platform: NativeReturnPlatformV1,
62
+ ): string {
63
+ return `${new URL(origin).origin}/native/return/${platform}`;
64
+ }
65
+ /**
66
+ * Where a development build of the app receives its sign-in. A custom scheme,
67
+ * the brand's development one, because a plain-HTTP loopback origin can never
68
+ * be an App Link; only a Worker running with `ALLOW_DEVELOPMENT_AUTH` ever
69
+ * lists it.
70
+ */
71
+ export function nativeDevelopmentReturnUriV1(
72
+ brand: Pick<BrandV1, "nativeScheme">,
73
+ ): string {
74
+ return `${nativeReturnSchemeV1(brand, "development")}://native/return/android`;
75
+ }
76
+ /**
77
+ * Which build each Apple return page hands over to, on the brand's scheme
78
+ * (`nativeReturnSchemeV1`). A Universal Link only reaches the app from
79
+ * Safari, and only on a user's own click; Chrome and Firefox never dispatch
80
+ * one, and Google's completion redirect is not a click — on an iPhone as on a
81
+ * Mac. The return page hands the same code and state to the app's scheme,
82
+ * which every browser can open. The code is useless without the PKCE verifier
83
+ * the app never shares.
84
+ *
85
+ * The development builds (`bun run update:desktop` on a Mac, a
86
+ * `FROCKBOT_IOS_DEV` build on an iPhone) are each a separate app beside the
87
+ * released one, with its own bundle identifier, so their sign-in comes back
88
+ * through their own `-dev` page and scheme: handed to the released scheme,
89
+ * the released app would take the code, or the system would open the wrong
90
+ * copy. Not in the Apple association, so Safari never offers it to the
91
+ * released app as a Universal Link either. Android needs no scheme.
92
+ */
93
+ const NATIVE_RETURN_BUILDS: Record<
94
+ Exclude<NativeReturnPlatformV1, "android">,
95
+ "released" | "development"
96
+ > = {
97
+ macos: "released",
98
+ "macos-dev": "development",
99
+ ios: "released",
100
+ "ios-dev": "development",
101
+ };
102
+ /** The returns the Apple association claims: the released apps' own. */
103
+ const APPLE_ASSOCIATED_RETURNS = ["macos", "ios"] as const;
104
+ const PREFIX = "frockbot-native.";
105
+ const encoder = new TextEncoder();
106
+ const NO_STORE = AUTH_NO_STORE_HEADERS_V1;
107
+
108
+ /**
109
+ * Deployment policy, never a client-selected target or a per-Bot grant: the
110
+ * returns the profile's `nativeAuth` names, comma-separated. Nothing is
111
+ * implied — a FrockBot Dev return is served only where it is named — and a
112
+ * flag with an unknown name, a space or a repeat serves none.
113
+ */
114
+ export function nativeReturnUris(
115
+ flag: string | undefined,
116
+ origin: string,
117
+ ): readonly string[] {
118
+ const named = flag?.split(",") ?? [];
119
+ const valid =
120
+ new Set(named).size === named.length &&
121
+ named.every((name) =>
122
+ (NATIVE_RETURN_PLATFORMS as readonly string[]).includes(name),
123
+ );
124
+ return valid
125
+ ? NATIVE_RETURN_PLATFORMS.filter((platform) =>
126
+ named.includes(platform),
127
+ ).map((platform) => nativeReturnUriV1(origin, platform))
128
+ : [];
129
+ }
130
+
131
+ /** What the consent page calls the app a return belongs to. */
132
+ function nativeAppName(
133
+ platform: NativeReturnPlatformV1,
134
+ product: string,
135
+ ): string {
136
+ switch (platform) {
137
+ case "android":
138
+ return `the ${product} app on this Android device`;
139
+ case "macos":
140
+ return `the ${product} app on this Mac`;
141
+ case "macos-dev":
142
+ return `${product} Dev on this Mac`;
143
+ case "ios":
144
+ return `the ${product} app on this iPhone`;
145
+ case "ios-dev":
146
+ return `${product} Dev on this iPhone`;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The signed apps a deployment's association files name: the profile's
152
+ * `nativeApps`, carried as the `NATIVE_APPS` var.
153
+ */
154
+ export interface NativeAppsV1 {
155
+ android: readonly {
156
+ packageName: string;
157
+ sha256CertFingerprints: readonly string[];
158
+ }[];
159
+ apple: readonly string[];
160
+ }
161
+
162
+ const ANDROID_PACKAGE_V1 = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
163
+ const CERT_FINGERPRINT_V1 = /^[0-9A-F]{2}(:[0-9A-F]{2}){31}$/;
164
+ const APPLE_APP_ID_V1 = /^[A-Z0-9]{10}\.[A-Za-z0-9.-]+$/;
165
+
166
+ /**
167
+ * The `NATIVE_APPS` var, decoded. Deployment policy like `nativeAuth`: a value
168
+ * that is absent or malformed names no app, so the deployment claims no link
169
+ * rather than a guessed one.
170
+ */
171
+ export function nativeAppsV1(flag: string | undefined): NativeAppsV1 {
172
+ const none: NativeAppsV1 = { android: [], apple: [] };
173
+ try {
174
+ const value: unknown = JSON.parse(flag ?? "null");
175
+ if (!isRecord(value) || !hasOnly(value, ["android", "apple"])) return none;
176
+ const android = value.android ?? [];
177
+ const apple = value.apple ?? [];
178
+ const valid =
179
+ Array.isArray(android) &&
180
+ android.every(
181
+ (app) =>
182
+ isRecord(app) &&
183
+ hasOnly(app, ["packageName", "sha256CertFingerprints"]) &&
184
+ typeof app.packageName === "string" &&
185
+ ANDROID_PACKAGE_V1.test(app.packageName) &&
186
+ Array.isArray(app.sha256CertFingerprints) &&
187
+ app.sha256CertFingerprints.length > 0 &&
188
+ app.sha256CertFingerprints.every(
189
+ (print) =>
190
+ typeof print === "string" && CERT_FINGERPRINT_V1.test(print),
191
+ ),
192
+ ) &&
193
+ Array.isArray(apple) &&
194
+ apple.every((id) => typeof id === "string" && APPLE_APP_ID_V1.test(id));
195
+ return valid ? ({ android, apple } as NativeAppsV1) : none;
196
+ } catch {
197
+ return none;
198
+ }
199
+ }
200
+
201
+ function isRecord(value: unknown): value is Record<string, unknown> {
202
+ return !!value && typeof value === "object" && !Array.isArray(value);
203
+ }
204
+
205
+ function hasOnly(
206
+ value: Record<string, unknown>,
207
+ keys: readonly string[],
208
+ ): boolean {
209
+ return Object.keys(value).every((key) => keys.includes(key));
210
+ }
211
+
212
+ /** The request origin with a fully qualified (trailing-dot) host normalised. */
213
+ export function requestOrigin(url: URL): string {
214
+ const host = url.hostname.endsWith(".")
215
+ ? url.hostname.slice(0, -1)
216
+ : url.hostname;
217
+ return `${url.protocol}//${host}${url.port ? `:${url.port}` : ""}`;
218
+ }
219
+
220
+ // A supported app upgrade reuses a session, whatever version and protocol it
221
+ // moved to: the protocol is compatibility, and the gate refuses one it no longer
222
+ // serves. Catalogs stay bound.
223
+ function sameClient(a: ClientHello, b: ClientHello): boolean {
224
+ const shape = (h: ClientHello) =>
225
+ JSON.stringify({
226
+ schemaVersion: h.schemaVersion,
227
+ catalogs: h.catalogs.map((c) => `${c.id}:${c.digest}`).sort(),
228
+ });
229
+ return shape(a) === shape(b);
230
+ }
231
+
232
+ export function isNativeAuthPath(path: string): boolean {
233
+ return (
234
+ path.startsWith("/api/auth/native/") ||
235
+ path.startsWith("/native/") ||
236
+ path === "/.well-known/assetlinks.json" ||
237
+ path === "/.well-known/apple-app-site-association"
238
+ );
239
+ }
240
+
241
+ interface StartClaims {
242
+ kind: "start";
243
+ start: AuthStartCommand;
244
+ hello: ClientHello;
245
+ expires: number;
246
+ }
247
+ /**
248
+ * A pending authorization bound to the User it is for. `consent` is what the
249
+ * consent page's button posts, and only that post turns it into the
250
+ * `exchange` code the app redeems. The two are signed alike and are never
251
+ * each other: `verify` reads only the kind it was asked for.
252
+ */
253
+ interface ExchangeClaims {
254
+ kind: "consent" | "exchange";
255
+ start: AuthStartCommand;
256
+ hello: ClientHello;
257
+ expires: number;
258
+ userId: string;
259
+ }
260
+ interface SessionClaims {
261
+ kind: "session";
262
+ userId: string;
263
+ sessionId: string;
264
+ hello: ClientHello;
265
+ expires: number;
266
+ }
267
+ interface SettingsClaims {
268
+ kind: "settings";
269
+ userId: string;
270
+ hello: ClientHello;
271
+ home: "models" | "connections";
272
+ expires: number;
273
+ }
274
+ type Claims = StartClaims | ExchangeClaims | SessionClaims | SettingsClaims;
275
+
276
+ export interface NativeAuthOptions {
277
+ secret: string;
278
+ auth: AuthPackageIdentityV1;
279
+ // Only associated, signed targets belong here. No request can add an entry.
280
+ returnUris: readonly string[];
281
+ /** The signed apps the association files name. */
282
+ nativeApps: NativeAppsV1;
283
+ /** What the pages show of the product. */
284
+ brand: ReturnPageBrandV1;
285
+ /**
286
+ * The beta-access authority, asked after a bearer passes its read-only
287
+ * session check, or before a session is issued. Reads never provision a User.
288
+ * `null` means the identity no longer exists; a throw means the authority
289
+ * could not answer.
290
+ * Never asked for `developmentUserId`, which only a development stack sets.
291
+ */
292
+ admit(userId: string): Promise<AccountAdmissionDecisionV1 | null>;
293
+ session(
294
+ userId: string,
295
+ operation: NativeSessionOperation,
296
+ ): Promise<NativeSessionRecord | null>;
297
+ now?: () => number;
298
+ /**
299
+ * The deployment's own origin — every sign-in redirect, return URI and the
300
+ * origin check on `/native/*` is built from it. The Worker reads it from
301
+ * `BETTER_AUTH_URL`, which is what a development stack already points at its
302
+ * own host.
303
+ */
304
+ origin: string;
305
+ /**
306
+ * The development sign-in door: with this set, `/native/authorize` issues
307
+ * the code for this User when the browser holds no session, in place of
308
+ * Google. Set only from `ALLOW_DEVELOPMENT_AUTH`, which production refuses.
309
+ */
310
+ developmentUserId?: string;
311
+ }
312
+
313
+ export interface NativeAuth {
314
+ route(request: Request): Promise<Response | undefined>;
315
+ /**
316
+ * `admission` is the authority's answer for this bearer, so the gateway
317
+ * does not ask twice; it is absent for the development identity.
318
+ */
319
+ authenticate(request: Request): Promise<
320
+ | {
321
+ session: AuthIdentityV1 | null;
322
+ refusal?: Response;
323
+ admission?: AccountAdmissionDecisionV1;
324
+ }
325
+ | undefined
326
+ >;
327
+ }
328
+
329
+ function base64(bytes: Uint8Array): string {
330
+ return btoa(String.fromCharCode(...bytes))
331
+ .replaceAll("+", "-")
332
+ .replaceAll("/", "_")
333
+ .replace(/=+$/, "");
334
+ }
335
+ function unbase64(text: string): Uint8Array<ArrayBuffer> {
336
+ return Uint8Array.from(
337
+ atob(text.replaceAll("-", "+").replaceAll("_", "/")),
338
+ (c) => c.charCodeAt(0),
339
+ );
340
+ }
341
+ export async function readNativeJsonBody(
342
+ request: Request,
343
+ maximumBytes = 8192,
344
+ ): Promise<unknown> {
345
+ const text = await readLimitedText(request, maximumBytes);
346
+ let depth = 0,
347
+ quoted = false,
348
+ escaped = false;
349
+ for (const character of text) {
350
+ if (quoted) {
351
+ if (escaped) escaped = false;
352
+ else if (character === "\\") escaped = true;
353
+ else if (character === '"') quoted = false;
354
+ } else if (character === '"') quoted = true;
355
+ else if (character === "{" || character === "[") {
356
+ if (++depth > 16) throw new Error("Input nesting limit");
357
+ } else if (character === "}" || character === "]") depth--;
358
+ }
359
+ return JSON.parse(text);
360
+ }
361
+
362
+ async function readLimitedText(
363
+ request: Request,
364
+ maximumBytes: number,
365
+ ): Promise<string> {
366
+ const reader = request.body?.getReader();
367
+ if (!reader) throw new Error("Missing input");
368
+ let size = 0;
369
+ const chunks: Uint8Array[] = [];
370
+ while (true) {
371
+ const next = await reader.read();
372
+ if (next.done) break;
373
+ size += next.value.length;
374
+ if (size > maximumBytes) {
375
+ await reader.cancel();
376
+ throw new Error("Too much input");
377
+ }
378
+ chunks.push(next.value);
379
+ }
380
+ const data = new Uint8Array(size);
381
+ let offset = 0;
382
+ for (const chunk of chunks) {
383
+ data.set(chunk, offset);
384
+ offset += chunk.length;
385
+ }
386
+ return new TextDecoder().decode(data);
387
+ }
388
+
389
+ export function createNativeAuth(options: NativeAuthOptions): NativeAuth {
390
+ // Normalised, so a configured origin with a trailing slash or a default port
391
+ // still matches the request origin the `/native/*` check compares it against.
392
+ const origin = new URL(options.origin).origin;
393
+ const product = options.brand.productName;
394
+ const now = options.now ?? Date.now;
395
+ const key = () =>
396
+ crypto.subtle.importKey(
397
+ "raw",
398
+ encoder.encode(`frockbot-native-v1:${options.secret}`),
399
+ { name: "HMAC", hash: "SHA-256" },
400
+ false,
401
+ ["sign", "verify"],
402
+ );
403
+ async function sign(claims: Claims): Promise<string> {
404
+ const payload = base64(encoder.encode(JSON.stringify(claims)));
405
+ const signature = await crypto.subtle.sign(
406
+ "HMAC",
407
+ await key(),
408
+ encoder.encode(payload),
409
+ );
410
+ return `${payload}.${base64(new Uint8Array(signature))}`;
411
+ }
412
+ async function verify(token: string, kind: Claims["kind"]): Promise<Claims> {
413
+ if (token.length > 4096) throw new Error("Invalid token");
414
+ const parts = token.split(".");
415
+ if (
416
+ parts.length !== 2 ||
417
+ !/^[A-Za-z0-9_-]+$/.test(parts[0]!) ||
418
+ !/^[A-Za-z0-9_-]+$/.test(parts[1]!)
419
+ )
420
+ throw new Error("Invalid token");
421
+ if (
422
+ !(await crypto.subtle.verify(
423
+ "HMAC",
424
+ await key(),
425
+ unbase64(parts[1]!),
426
+ encoder.encode(parts[0]!),
427
+ ))
428
+ )
429
+ throw new Error("Invalid signature");
430
+ const value: unknown = JSON.parse(
431
+ new TextDecoder().decode(unbase64(parts[0]!)),
432
+ );
433
+ if (!value || typeof value !== "object" || Array.isArray(value))
434
+ throw new Error("Invalid claims");
435
+ const v = value as Record<string, unknown>;
436
+ if (
437
+ v.kind !== kind ||
438
+ typeof v.expires !== "number" ||
439
+ !Number.isSafeInteger(v.expires) ||
440
+ v.expires <= now() ||
441
+ !isProtocolValue("ClientHello", v.hello)
442
+ )
443
+ throw new Error("Expired sign-in");
444
+ if (kind === "settings") {
445
+ if (
446
+ !isProtocolValue("Identifier", v.userId) ||
447
+ (v.home !== "models" && v.home !== "connections")
448
+ )
449
+ throw new Error("Invalid settings destination");
450
+ return {
451
+ kind,
452
+ userId: v.userId,
453
+ home: v.home,
454
+ hello: v.hello,
455
+ expires: v.expires,
456
+ };
457
+ }
458
+ if (kind !== "session") {
459
+ if (
460
+ !isProtocolValue("AuthStartCommand", v.start) ||
461
+ !options.returnUris.includes(v.start.returnUri)
462
+ )
463
+ throw new Error("Invalid return link");
464
+ if (kind === "start")
465
+ return { kind, start: v.start, hello: v.hello, expires: v.expires };
466
+ if (!isProtocolValue("Identifier", v.userId))
467
+ throw new Error("Invalid User");
468
+ return {
469
+ kind,
470
+ start: v.start,
471
+ hello: v.hello,
472
+ expires: v.expires,
473
+ userId: v.userId,
474
+ };
475
+ }
476
+ if (
477
+ !isProtocolValue("Identifier", v.userId) ||
478
+ !isProtocolValue("Identifier", v.sessionId)
479
+ )
480
+ throw new Error("Invalid session");
481
+ return {
482
+ kind,
483
+ userId: v.userId,
484
+ sessionId: v.sessionId,
485
+ hello: v.hello,
486
+ expires: v.expires,
487
+ };
488
+ }
489
+ function hello(request: Request): ClientHello {
490
+ const value: unknown = JSON.parse(
491
+ request.headers.get(CLIENT_HELLO_HEADER) ?? "null",
492
+ );
493
+ if (!isProtocolValue("ClientHello", value))
494
+ throw new Error(UPDATE_APP_MESSAGE);
495
+ return value;
496
+ }
497
+ async function browserIdentity(
498
+ request: Request,
499
+ ): Promise<AuthIdentityV1 | null> {
500
+ // Browser cookies only: this endpoint cannot be used to launder another bearer.
501
+ const headers = new Headers();
502
+ const cookie = request.headers.get("cookie");
503
+ if (cookie) headers.set("cookie", cookie);
504
+ return options.auth.getSession(headers);
505
+ }
506
+ function operation(
507
+ claims: SessionClaims,
508
+ action: NativeSessionOperation["action"],
509
+ ): NativeSessionOperation {
510
+ return {
511
+ schemaVersion: 1,
512
+ userId: claims.userId,
513
+ sessionId: claims.sessionId,
514
+ hello: claims.hello,
515
+ expiresAt: claims.expires,
516
+ action,
517
+ };
518
+ }
519
+ async function admitBeforeUser(
520
+ userId: string,
521
+ ): Promise<AccountAdmissionDecisionV1 | "development" | Response | null> {
522
+ if (
523
+ options.developmentUserId !== undefined &&
524
+ userId === options.developmentUserId
525
+ ) {
526
+ return "development";
527
+ }
528
+ let decision: AccountAdmissionDecisionV1 | null;
529
+ try {
530
+ decision = await options.admit(userId);
531
+ } catch {
532
+ return admissionUnavailableResponse();
533
+ }
534
+ if (decision && !decision.admitted) {
535
+ return admissionRefusedResponse(decision.reason, false);
536
+ }
537
+ return decision;
538
+ }
539
+ /** The code for `userId`, sent to the return the app started with. */
540
+ async function handBack(
541
+ claims: StartClaims | ExchangeClaims,
542
+ userId: string,
543
+ status: 302 | 303,
544
+ ): Promise<Response> {
545
+ const code = await sign({
546
+ kind: "exchange",
547
+ start: claims.start,
548
+ hello: claims.hello,
549
+ expires: claims.expires,
550
+ userId,
551
+ });
552
+ const destination = new URL(claims.start.returnUri);
553
+ destination.searchParams.set("code", code);
554
+ destination.searchParams.set("state", claims.start.state);
555
+ return new Response(null, {
556
+ status,
557
+ headers: { ...NO_STORE, location: destination.toString() },
558
+ });
559
+ }
560
+ /**
561
+ * The press that lets this browser's User sign the app in. The consent it
562
+ * posts is bound to that User and to this authorization's state, challenge
563
+ * and return, and expires with it; the page cannot be framed.
564
+ */
565
+ async function consentPage(
566
+ claims: StartClaims,
567
+ session: AuthIdentityV1,
568
+ ): Promise<Response> {
569
+ const consent = await sign({
570
+ kind: "consent",
571
+ start: claims.start,
572
+ hello: claims.hello,
573
+ expires: claims.expires,
574
+ userId: session.user.id,
575
+ });
576
+ const platform = NATIVE_RETURN_PLATFORMS.find(
577
+ (candidate) =>
578
+ nativeReturnUriV1(origin, candidate) === claims.start.returnUri,
579
+ );
580
+ const destination = new URL(claims.start.returnUri);
581
+ return returnPageV1({
582
+ brand: options.brand,
583
+ title: `Sign in to ${product}`,
584
+ heading: `Sign in to ${platform ? nativeAppName(platform, product) : `the ${product} development build on this device`}?`,
585
+ lead: session.user.email
586
+ ? `You’ll be signed in as ${session.user.email}.`
587
+ : "You’ll be signed in with the account this browser uses.",
588
+ form: {
589
+ label: "Sign in",
590
+ action: `${origin}/native/authorize`,
591
+ fields: { consent },
592
+ // The development scheme is the one return off this origin.
593
+ ...(destination.origin === origin
594
+ ? {}
595
+ : { redirects: [destination.protocol] }),
596
+ },
597
+ footnote:
598
+ "Only continue if you just started signing in from the app. If you didn’t, close this page: nothing is signed in until you press Sign in.",
599
+ });
600
+ }
601
+ return {
602
+ async authenticate(request) {
603
+ const bearer = request.headers.get("authorization");
604
+ if (!bearer?.startsWith(`Bearer ${PREFIX}`)) return undefined;
605
+ const refusal = clientCompatibilityResponse(
606
+ request,
607
+ new URL(`${origin}/api/native/session`),
608
+ );
609
+ if (refusal) return { session: null, refusal };
610
+ let claims: Claims;
611
+ try {
612
+ claims = await verify(bearer.slice(7 + PREFIX.length), "session");
613
+ if (claims.kind !== "session") return { session: null };
614
+ // The app recovers a rejected session through sign-in; 426 asks for an update.
615
+ if (!sameClient(hello(request), claims.hello)) return { session: null };
616
+ } catch {
617
+ return { session: null };
618
+ }
619
+ let record: NativeSessionRecord | null;
620
+ try {
621
+ record = await options.session(
622
+ claims.userId,
623
+ operation(claims, "read"),
624
+ );
625
+ } catch {
626
+ return { session: null };
627
+ }
628
+ if (!record) return { session: null };
629
+ const admitted = await admitBeforeUser(claims.userId);
630
+ if (admitted instanceof Response) {
631
+ return { session: null, refusal: admitted };
632
+ }
633
+ if (admitted === null) return { session: null };
634
+ try {
635
+ // The email, not just the id: admission and the admin check read it,
636
+ // so a native session that omitted it made a listed admin ordinary on
637
+ // the phone while the same account was an admin in a browser.
638
+ const profile = await options.auth.profile?.(record.userId);
639
+ return {
640
+ session: {
641
+ user: {
642
+ id: record.userId,
643
+ ...(profile?.email ? { email: profile.email } : {}),
644
+ ...(profile?.emailVerified ? { emailVerified: true } : {}),
645
+ },
646
+ },
647
+ ...(admitted === "development" ? {} : { admission: admitted }),
648
+ };
649
+ } catch {
650
+ return { session: null };
651
+ }
652
+ },
653
+ async route(request) {
654
+ const url = new URL(request.url);
655
+ if (!isNativeAuthPath(url.pathname)) return undefined;
656
+ // The signed application's callback origin is not configurable by input.
657
+ // Google's asset-links fetcher asks for the fully qualified host
658
+ // ("bot.frockbot.com."); that trailing dot names the same origin.
659
+ if (requestOrigin(url) !== origin) return error(403);
660
+ const association =
661
+ url.pathname === "/.well-known/assetlinks.json" ||
662
+ url.pathname === "/.well-known/apple-app-site-association";
663
+ if (association && request.method === "HEAD") {
664
+ const full = await this.route(new Request(request.url));
665
+ return new Response(null, {
666
+ status: full?.status ?? 404,
667
+ headers: full?.headers,
668
+ });
669
+ }
670
+ try {
671
+ if (
672
+ url.pathname === "/.well-known/assetlinks.json" &&
673
+ request.method === "GET"
674
+ ) {
675
+ if (options.nativeApps.android.length === 0) return error(404);
676
+ return Response.json(
677
+ options.nativeApps.android.map((app) => ({
678
+ relation: ["delegate_permission/common.handle_all_urls"],
679
+ target: {
680
+ namespace: "android_app",
681
+ package_name: app.packageName,
682
+ sha256_cert_fingerprints: app.sha256CertFingerprints,
683
+ },
684
+ })),
685
+ { headers: { "cache-control": "public, max-age=300" } },
686
+ );
687
+ }
688
+ if (
689
+ url.pathname === "/.well-known/apple-app-site-association" &&
690
+ request.method === "GET"
691
+ ) {
692
+ // Only the Apple returns this deployment serves: a path it answers
693
+ // 404 for is never offered to an app as a verified link.
694
+ const components = APPLE_ASSOCIATED_RETURNS.filter((platform) =>
695
+ options.returnUris.includes(nativeReturnUriV1(origin, platform)),
696
+ ).map((platform) => ({ "/": `/native/return/${platform}` }));
697
+ if (components.length === 0 || options.nativeApps.apple.length === 0)
698
+ return error(404);
699
+ return Response.json(
700
+ {
701
+ applinks: {
702
+ details: [
703
+ {
704
+ appIDs: options.nativeApps.apple,
705
+ components,
706
+ },
707
+ ],
708
+ },
709
+ },
710
+ { headers: { "cache-control": "public, max-age=300" } },
711
+ );
712
+ }
713
+ // This signed link grants no browser session and no Connection. It only
714
+ // carries a short-lived navigation intent to the same authenticated User.
715
+ if (
716
+ url.pathname === "/api/auth/native/settings" &&
717
+ request.method === "POST"
718
+ ) {
719
+ const principal = await this.authenticate(request);
720
+ if (principal?.refusal) return principal.refusal;
721
+ if (!principal?.session) return error(401);
722
+ const command = await readNativeJsonBody(request);
723
+ if (!isProtocolValue("SettingsHandoffCommand", command))
724
+ return error();
725
+ const expires = now() + 300_000;
726
+ const token = await sign({
727
+ kind: "settings",
728
+ userId: principal.session.user.id,
729
+ home: command.home,
730
+ hello: hello(request),
731
+ expires,
732
+ });
733
+ return Response.json(
734
+ {
735
+ schemaVersion: 1,
736
+ authorizationUrl: `${origin}/native/settings?request=${token}`,
737
+ expiresAt: new Date(expires).toISOString(),
738
+ },
739
+ { headers: NO_STORE },
740
+ );
741
+ }
742
+ if (url.pathname === "/native/settings" && request.method === "GET") {
743
+ if ([...url.searchParams.keys()].join() !== "request") return error();
744
+ const token = url.searchParams.get("request") ?? "";
745
+ const claims = await verify(token, "settings");
746
+ if (claims.kind !== "settings") return error();
747
+ const session = await browserIdentity(request);
748
+ if (!session)
749
+ return options.auth.startSignIn(
750
+ request,
751
+ `${origin}/native/settings?request=${token}`,
752
+ );
753
+ if (session.user.id !== claims.userId)
754
+ return new Response(
755
+ `This browser is signed in to a different ${product} account. Switch accounts in the browser, then return to the app and try again.`,
756
+ {
757
+ status: 403,
758
+ headers: {
759
+ ...NO_STORE,
760
+ "content-type": "text/plain; charset=utf-8",
761
+ },
762
+ },
763
+ );
764
+ return redirect(
765
+ `${origin}/?settings=${claims.home}${claims.home === "models" ? "#user-model-providers" : ""}`,
766
+ );
767
+ }
768
+ if (
769
+ url.pathname === "/api/auth/native/start" &&
770
+ request.method === "POST"
771
+ ) {
772
+ const invalid = clientCompatibilityResponse(request, url);
773
+ if (invalid) return invalid;
774
+ const start = await readNativeJsonBody(request);
775
+ if (
776
+ !isProtocolValue("AuthStartCommand", start) ||
777
+ !options.returnUris.includes(start.returnUri)
778
+ )
779
+ return error();
780
+ const expires = now() + 300_000;
781
+ const token = await sign({
782
+ kind: "start",
783
+ start,
784
+ hello: hello(request),
785
+ expires,
786
+ });
787
+ return Response.json(
788
+ {
789
+ schemaVersion: 1,
790
+ authorizationUrl: `${origin}/native/authorize?request=${token}`,
791
+ expiresAt: new Date(expires).toISOString(),
792
+ },
793
+ { headers: NO_STORE },
794
+ );
795
+ }
796
+ // A code is issued only from the consent page's press. Anyone can
797
+ // call the start with their own PKCE values and open this URL in the
798
+ // person's browser — an app that claims `frockbot://` can — so a
799
+ // browser that is already signed in must not hand that app a code by
800
+ // itself. `/native/complete` shows the same page after a fresh
801
+ // sign-in: it is a GET any page can open, so it cannot tell a sign-in
802
+ // that just happened from a browser that was signed in already.
803
+ if (
804
+ ["/native/authorize", "/native/complete"].includes(url.pathname) &&
805
+ request.method === "GET"
806
+ ) {
807
+ if ([...url.searchParams.keys()].join() !== "request") return error();
808
+ const token = url.searchParams.get("request") ?? "";
809
+ const claims = await verify(token, "start");
810
+ if (claims.kind !== "start") return error();
811
+ const session = await browserIdentity(request);
812
+ if (session) return consentPage(claims, session);
813
+ // The development door signs in a scripted app with no browser to
814
+ // press in, on a stack production refuses.
815
+ if (options.developmentUserId !== undefined)
816
+ return handBack(claims, options.developmentUserId, 302);
817
+ if (url.pathname === "/native/complete") return error(401);
818
+ return options.auth.startSignIn(
819
+ request,
820
+ `${origin}/native/complete?request=${token}`,
821
+ );
822
+ }
823
+ if (url.pathname === "/native/authorize" && request.method === "POST") {
824
+ // The press must come from the consent page itself. The signed
825
+ // consent below is what a cross-site form cannot have; this refuses
826
+ // one before any identity is asked.
827
+ const site = request.headers.get("sec-fetch-site");
828
+ const from = request.headers.get("origin");
829
+ if (
830
+ (site === null && from === null) ||
831
+ (site !== null && site !== "same-origin") ||
832
+ (from !== null && from !== origin)
833
+ )
834
+ return error(403);
835
+ const form = new URLSearchParams(
836
+ await readLimitedText(request, 8192),
837
+ );
838
+ if ([...form.keys()].join() !== "consent") return error();
839
+ const claims = await verify(form.get("consent") ?? "", "consent");
840
+ if (claims.kind !== "consent") return error();
841
+ const session = await browserIdentity(request);
842
+ if (!session) return error(401);
843
+ if (session.user.id !== claims.userId) return error(403);
844
+ return handBack(claims, claims.userId, 303);
845
+ }
846
+ if (
847
+ url.pathname === "/api/auth/native/exchange" &&
848
+ request.method === "POST"
849
+ ) {
850
+ const invalid = clientCompatibilityResponse(request, url);
851
+ if (invalid) return invalid;
852
+ const command = await readNativeJsonBody(request);
853
+ if (!isProtocolValue("AuthExchangeCommand", command)) return error();
854
+ const claims = await verify(command.code, "exchange");
855
+ if (claims.kind !== "exchange") return error();
856
+ const digest = base64(
857
+ new Uint8Array(
858
+ await crypto.subtle.digest(
859
+ "SHA-256",
860
+ encoder.encode(command.codeVerifier),
861
+ ),
862
+ ),
863
+ );
864
+ if (
865
+ digest !== claims.start.codeChallenge ||
866
+ command.state !== claims.start.state ||
867
+ command.returnUri !== claims.start.returnUri ||
868
+ JSON.stringify(hello(request)) !== JSON.stringify(claims.hello)
869
+ )
870
+ return error();
871
+ const session: SessionClaims = {
872
+ kind: "session",
873
+ userId: claims.userId,
874
+ sessionId: claims.start.commandId,
875
+ hello: claims.hello,
876
+ expires: now() + 7 * 86400_000,
877
+ };
878
+ await options.session(
879
+ session.userId,
880
+ operation(session, "check-issue"),
881
+ );
882
+ const admitted = await admitBeforeUser(session.userId);
883
+ if (admitted instanceof Response) return admitted;
884
+ if (admitted === null) return error(401);
885
+ // Admission is committed before the bearer is returned. Replaying the
886
+ // same authorization, including a repeated callback, cannot issue twice.
887
+ const issued = await options.session(
888
+ session.userId,
889
+ operation(session, "issue"),
890
+ );
891
+ if (
892
+ !issued ||
893
+ issued.revoked ||
894
+ issued.userId !== session.userId ||
895
+ issued.sessionId !== session.sessionId ||
896
+ issued.expiresAt !== session.expires ||
897
+ JSON.stringify(issued.hello) !== JSON.stringify(session.hello)
898
+ )
899
+ return error();
900
+ return Response.json(
901
+ {
902
+ schemaVersion: 1,
903
+ userId: session.userId,
904
+ sessionId: session.sessionId,
905
+ expiresAt: new Date(session.expires).toISOString(),
906
+ sessionToken: PREFIX + (await sign(session)),
907
+ },
908
+ { headers: NO_STORE },
909
+ );
910
+ }
911
+ if (
912
+ url.pathname === "/api/auth/native/revoke" &&
913
+ request.method === "POST"
914
+ ) {
915
+ // This route is also called without the gateway's compatibility gate.
916
+ const invalid = clientCompatibilityResponse(request, url);
917
+ if (invalid) return invalid;
918
+ const bearer = request.headers.get("authorization") ?? "";
919
+ if (!bearer.startsWith(`Bearer ${PREFIX}`)) return error(401);
920
+ const claims = await verify(
921
+ bearer.slice(7 + PREFIX.length),
922
+ "session",
923
+ );
924
+ if (claims.kind !== "session") return error(401);
925
+ const command = await readNativeJsonBody(request);
926
+ if (
927
+ !isProtocolValue("SessionRevokeCommand", command) ||
928
+ command.sessionId !== claims.sessionId
929
+ )
930
+ return error();
931
+ // A 401 lets the app discard the unusable session and finish signing out.
932
+ if (!sameClient(hello(request), claims.hello))
933
+ return error(401, "Please sign in again.");
934
+ // Signing out narrows authority even while access is paused. The
935
+ // session owner revokes existing records without provisioning a User.
936
+ await options.session(claims.userId, operation(claims, "revoke"));
937
+ return Response.json(
938
+ { schemaVersion: 1, status: "signed-out" },
939
+ { headers: NO_STORE },
940
+ );
941
+ }
942
+ if (
943
+ options.returnUris.includes(url.origin + url.pathname) &&
944
+ request.method === "GET"
945
+ ) {
946
+ const page = url.origin + url.pathname;
947
+ const platform = NATIVE_RETURN_PLATFORMS.find(
948
+ (candidate) => nativeReturnUriV1(origin, candidate) === page,
949
+ );
950
+ if (platform)
951
+ return nativeReturnPage(platform, origin, options.brand);
952
+ }
953
+ return error(404);
954
+ } catch {
955
+ return error();
956
+ }
957
+ },
958
+ };
959
+ }
960
+
961
+ /**
962
+ * The page Google's completion lands on in the user's browser. On a Mac or an
963
+ * iPhone it carries the code and state across to the app on its custom
964
+ * scheme, forwarding only those two query parameters and never reflecting
965
+ * them into markup. On Android the verified App Link has already opened the
966
+ * app; this page is what remains in the browser, and what a user sees if it
967
+ * did not.
968
+ */
969
+ function nativeReturnPage(
970
+ platform: NativeReturnPlatformV1,
971
+ origin: string,
972
+ brand: ReturnPageBrandV1,
973
+ ): Response {
974
+ const product = brand.productName;
975
+ const returnUri = new URL(nativeReturnUriV1(origin, platform));
976
+ const target =
977
+ platform === "android"
978
+ ? undefined
979
+ : `${nativeReturnSchemeV1(brand, NATIVE_RETURN_BUILDS[platform])}://${returnUri.host}${returnUri.pathname}`;
980
+ return returnPageV1({
981
+ brand,
982
+ title: `Return to ${product}`,
983
+ heading: `Return to ${product} to finish signing in`,
984
+ lead: target
985
+ ? `Your browser is handing you over to the ${product} app. Once it opens, you can close this tab.`
986
+ : `Head back to the ${product} app to finish signing in. You can close this page.`,
987
+ footnote: `If ${product} did not open, check that the latest app is installed and try signing in again.`,
988
+ ...(target === undefined
989
+ ? {}
990
+ : {
991
+ status: `Opening ${product}`,
992
+ action: { label: `Open ${product}`, href: target, id: "open" },
993
+ script: `(function () {
994
+ var incoming = new URLSearchParams(location.search);
995
+ var forwarded = new URLSearchParams();
996
+ ["code", "state"].forEach(function (key) {
997
+ var value = incoming.get(key);
998
+ if (value !== null) forwarded.set(key, value);
999
+ });
1000
+ var href = ${JSON.stringify(target)} + (forwarded.toString() ? "?" + forwarded.toString() : "");
1001
+ document.getElementById("open").setAttribute("href", href);
1002
+ location.replace(href);
1003
+ })();`,
1004
+ }),
1005
+ });
1006
+ }