@volter/twin-googleoauth 0.1.0

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 (52) hide show
  1. package/README.md +219 -0
  2. package/client/googleoauth-consent.css +207 -0
  3. package/client/googleoauth-consent.tsx +286 -0
  4. package/dist/client/googleoauth-consent.bundle.js +237 -0
  5. package/dist/client/googleoauth-consent.css +207 -0
  6. package/dist/client/googleoauth-consent.d.ts +88 -0
  7. package/dist/client/googleoauth-consent.js +94 -0
  8. package/dist/client/googleoauth-consent.tsx +286 -0
  9. package/dist/src/cli.d.ts +2 -0
  10. package/dist/src/cli.js +42 -0
  11. package/dist/src/googleoauth-autherror.d.ts +25 -0
  12. package/dist/src/googleoauth-autherror.js +144 -0
  13. package/dist/src/googleoauth-budget.d.ts +48 -0
  14. package/dist/src/googleoauth-budget.js +121 -0
  15. package/dist/src/googleoauth-capabilities.d.ts +3 -0
  16. package/dist/src/googleoauth-capabilities.js +1651 -0
  17. package/dist/src/googleoauth-conformance.d.ts +10 -0
  18. package/dist/src/googleoauth-conformance.js +426 -0
  19. package/dist/src/googleoauth-connector.d.ts +70 -0
  20. package/dist/src/googleoauth-connector.js +244 -0
  21. package/dist/src/googleoauth-consent-client.gen.d.ts +2 -0
  22. package/dist/src/googleoauth-consent-client.gen.js +10 -0
  23. package/dist/src/googleoauth-consent-ui.d.ts +25 -0
  24. package/dist/src/googleoauth-consent-ui.js +102 -0
  25. package/dist/src/googleoauth-jwt.d.ts +78 -0
  26. package/dist/src/googleoauth-jwt.js +183 -0
  27. package/dist/src/googleoauth-scopes.d.ts +36 -0
  28. package/dist/src/googleoauth-scopes.js +92 -0
  29. package/dist/src/googleoauth-server.d.ts +34 -0
  30. package/dist/src/googleoauth-server.js +89 -0
  31. package/dist/src/googleoauth-store.d.ts +78 -0
  32. package/dist/src/googleoauth-store.js +313 -0
  33. package/dist/src/googleoauth-twin.d.ts +53 -0
  34. package/dist/src/googleoauth-twin.js +1050 -0
  35. package/dist/src/index.d.ts +16 -0
  36. package/dist/src/index.js +102 -0
  37. package/package.json +75 -0
  38. package/src/cli.ts +41 -0
  39. package/src/googleoauth-autherror.ts +150 -0
  40. package/src/googleoauth-budget.ts +147 -0
  41. package/src/googleoauth-capabilities.ts +1775 -0
  42. package/src/googleoauth-conformance.ts +472 -0
  43. package/src/googleoauth-connector.ts +266 -0
  44. package/src/googleoauth-consent-client.gen.ts +10 -0
  45. package/src/googleoauth-consent-ui.ts +124 -0
  46. package/src/googleoauth-journey.uitest.ts +296 -0
  47. package/src/googleoauth-jwt.ts +207 -0
  48. package/src/googleoauth-scopes.ts +109 -0
  49. package/src/googleoauth-server.ts +101 -0
  50. package/src/googleoauth-store.ts +359 -0
  51. package/src/googleoauth-twin.ts +1207 -0
  52. package/src/index.ts +175 -0
@@ -0,0 +1,109 @@
1
+ // Google OAuth scope catalog — the SCOPE STRINGS Google publishes
2
+ // (developers.google.com/identity/protocols/oauth2/scopes) paired with the human sentence its
3
+ // consent screen shows for each ("See your primary Google Account email address", …).
4
+ //
5
+ // This file is PURE data + pure functions and is imported by the browser consent client as well as
6
+ // the server, so it must stay free of `@volter/world-core`, `node:*` and Bun.
7
+ //
8
+ // HONESTY NOTE. Google's scope list is ~400 entries long and grows with every product; this
9
+ // catalog carries the OIDC core scopes plus the scopes real integrations actually request (the
10
+ // Cal.com/Dub calendar+contacts+drive families). An UNKNOWN scope is NOT an error — Google accepts
11
+ // any scope string a project has enabled — so the twin renders an unknown scope with its raw
12
+ // string rather than inventing a description for it. Fabricating a friendly sentence for a scope
13
+ // we have not read from the vendor would be exactly the "serving surface the vendor doesn't have"
14
+ // false-green ADDING_A_TWIN.md §6 warns about.
15
+
16
+ export type ScopeInfo = {
17
+ /** The scope string a client requests. */
18
+ scope: string;
19
+ /** The consent-screen sentence Google shows for it. */
20
+ label: string;
21
+ /** Google's own grouping — OIDC scopes are shown above the granular product scopes. */
22
+ group: 'openid' | 'product';
23
+ /** Google marks a subset "sensitive"/"restricted"; those show the extra review notice. */
24
+ sensitivity?: 'sensitive' | 'restricted';
25
+ };
26
+
27
+ /** The three OIDC scopes, which Google treats specially (they mint id_token claims). */
28
+ export const OIDC_SCOPES = ['openid', 'email', 'profile'] as const;
29
+
30
+ export const SCOPE_CATALOG: ScopeInfo[] = [
31
+ { scope: 'openid', label: 'Associate you with your personal info on Google', group: 'openid' },
32
+ { scope: 'email', label: 'See your primary Google Account email address', group: 'openid' },
33
+ { scope: 'profile', label: 'See your personal info, including any personal info you\'ve made publicly available', group: 'openid' },
34
+ { scope: 'https://www.googleapis.com/auth/userinfo.email', label: 'See your primary Google Account email address', group: 'openid' },
35
+ { scope: 'https://www.googleapis.com/auth/userinfo.profile', label: 'See your personal info, including any personal info you\'ve made publicly available', group: 'openid' },
36
+ // Calendar — the family every scheduling integration (Cal.com has 12 OAuth integrations) asks for.
37
+ { scope: 'https://www.googleapis.com/auth/calendar', label: 'See, edit, share, and permanently delete all the calendars you can access using Google Calendar', group: 'product', sensitivity: 'sensitive' },
38
+ { scope: 'https://www.googleapis.com/auth/calendar.readonly', label: 'See and download any calendar you can access using your Google Calendar', group: 'product', sensitivity: 'sensitive' },
39
+ { scope: 'https://www.googleapis.com/auth/calendar.events', label: 'View and edit events on all your calendars', group: 'product', sensitivity: 'sensitive' },
40
+ { scope: 'https://www.googleapis.com/auth/calendar.events.readonly', label: 'View events on all your calendars', group: 'product', sensitivity: 'sensitive' },
41
+ // Drive.
42
+ { scope: 'https://www.googleapis.com/auth/drive', label: 'See, edit, create, and delete all of your Google Drive files', group: 'product', sensitivity: 'restricted' },
43
+ { scope: 'https://www.googleapis.com/auth/drive.file', label: 'See, edit, create, and delete only the specific Google Drive files you use with this app', group: 'product' },
44
+ { scope: 'https://www.googleapis.com/auth/drive.readonly', label: 'See and download all your Google Drive files', group: 'product', sensitivity: 'restricted' },
45
+ // Gmail.
46
+ { scope: 'https://www.googleapis.com/auth/gmail.readonly', label: 'Read all resources and their metadata—no write operations', group: 'product', sensitivity: 'restricted' },
47
+ { scope: 'https://www.googleapis.com/auth/gmail.send', label: 'Send email on your behalf', group: 'product', sensitivity: 'sensitive' },
48
+ // Contacts / directory.
49
+ { scope: 'https://www.googleapis.com/auth/contacts.readonly', label: 'See and download your contacts', group: 'product', sensitivity: 'sensitive' },
50
+ { scope: 'https://www.googleapis.com/auth/directory.readonly', label: 'See and download your organization\'s GSuite directory', group: 'product', sensitivity: 'sensitive' },
51
+ // Spreadsheets — the other integration workhorse.
52
+ { scope: 'https://www.googleapis.com/auth/spreadsheets', label: 'See, edit, create, and delete all your Google Sheets spreadsheets', group: 'product', sensitivity: 'sensitive' },
53
+ { scope: 'https://www.googleapis.com/auth/spreadsheets.readonly', label: 'See all your Google Sheets spreadsheets', group: 'product', sensitivity: 'sensitive' },
54
+ // YouTube — the sibling twin in this repo.
55
+ { scope: 'https://www.googleapis.com/auth/youtube.readonly', label: 'View your YouTube account', group: 'product', sensitivity: 'sensitive' },
56
+ { scope: 'https://www.googleapis.com/auth/youtube.upload', label: 'Manage your YouTube videos', group: 'product', sensitivity: 'sensitive' },
57
+ // Cloud — what a service-account / Vertex flow asks for.
58
+ { scope: 'https://www.googleapis.com/auth/cloud-platform', label: 'See, edit, configure, and delete your Google Cloud data and see the email address for your Google Account', group: 'product', sensitivity: 'sensitive' },
59
+ { scope: 'https://www.googleapis.com/auth/devstorage.read_write', label: 'Manage your data in Cloud Storage and see the email address of your Google Account', group: 'product', sensitivity: 'sensitive' },
60
+ ];
61
+
62
+ const BY_SCOPE = new Map(SCOPE_CATALOG.map((s) => [s.scope, s]));
63
+
64
+ /** Split a `scope` parameter the way OAuth 2.0 does — space-delimited, order preserved, deduped. */
65
+ export function parseScopeParam(raw: string | null | undefined): string[] {
66
+ if (typeof raw !== 'string') return [];
67
+ const out: string[] = [];
68
+ for (const piece of raw.split(/[\s+]+/)) {
69
+ if (piece && !out.includes(piece)) out.push(piece);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Render a scope list back to the wire form Google echoes in `scope` responses. */
75
+ export function formatScopeParam(scopes: readonly string[]): string {
76
+ return scopes.join(' ');
77
+ }
78
+
79
+ /**
80
+ * The consent-screen row for one scope. An UNKNOWN scope is shown with its raw string as the
81
+ * label (see the honesty note above) and flagged so the caller can tell the two apart.
82
+ */
83
+ export function describeScope(scope: string): ScopeInfo & { known: boolean } {
84
+ const known = BY_SCOPE.get(scope);
85
+ if (known) return { ...known, known: true };
86
+ return { scope, label: scope, group: 'product', known: false };
87
+ }
88
+
89
+ /** Every requested scope, in request order, described for the consent screen. */
90
+ export function describeScopes(scopes: readonly string[]): Array<ScopeInfo & { known: boolean }> {
91
+ return scopes.map(describeScope);
92
+ }
93
+
94
+ /** Google shows the OIDC ("Associate you with…") rows above the granular product rows. */
95
+ export function sortScopesForConsent(scopes: readonly string[]): string[] {
96
+ const described = describeScopes(scopes);
97
+ return [...described].sort((a, b) => (a.group === b.group ? 0 : a.group === 'openid' ? -1 : 1)).map((s) => s.scope);
98
+ }
99
+
100
+ /**
101
+ * Granular consent: Google lets a user UNCHECK individual non-OIDC scopes, and the resulting grant
102
+ * carries only what was ticked. The OIDC scopes are not individually declinable — they ride with
103
+ * signing in — so they are always returned.
104
+ */
105
+ export function isGranularlyDeclinable(scope: string): boolean {
106
+ return !(OIDC_SCOPES as readonly string[]).includes(scope)
107
+ && scope !== 'https://www.googleapis.com/auth/userinfo.email'
108
+ && scope !== 'https://www.googleapis.com/auth/userinfo.profile';
109
+ }
@@ -0,0 +1,101 @@
1
+ // Google OAuth twin HTTP server — one server for the WHOLE vendor surface, because Google's OAuth
2
+ // is one product spread over four hostnames. Point `accounts.google.com`, `oauth2.googleapis.com`,
3
+ // `www.googleapis.com/oauth2/*` and `openidconnect.googleapis.com` here (the injector's
4
+ // `googleoauth` VENDOR_HOSTS entry does exactly that) and an unmodified OAuth client completes a
5
+ // full authorization-code round trip against it.
6
+ //
7
+ // Two things this server does that a JSON-only twin server does not:
8
+ // • it can answer with HTML and with 302 redirects — the consent screen and the callback bounce
9
+ // are the protocol, not decoration, so the handler's `headers` (content-type, location) are
10
+ // passed straight through rather than being flattened into a JSON envelope;
11
+ // • it serves the consent page's own assets under the twin-namespaced `/_twin/assets/*`.
12
+ //
13
+ // It also tells the handler its OWN origin, so the OIDC discovery document and the consent form's
14
+ // action point BACK AT THE TWIN. Without that a discovery-driven client (openid-client) would read
15
+ // the document and walk straight out to the real accounts.google.com.
16
+ import { serveHttp } from '@volter/world-core';
17
+ import { CONSENT_CLIENT_CSS, CONSENT_CLIENT_JS, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH } from './googleoauth-consent-ui.ts';
18
+ import { worldNow, statefulTwinManifest, twinPublicBase } from '@volter/world-core';
19
+ import { handleGoogleOAuthTwinRequest } from './googleoauth-twin.ts';
20
+
21
+ /** Options every Google-OAuth-twin HTTP surface needs, independent of who owns the socket. */
22
+ export interface GoogleOAuthTwinFetchOptions {
23
+ root?: string;
24
+ readOnly?: boolean;
25
+ }
26
+
27
+ /**
28
+ * The pack's whole HTTP surface as a plain `fetch` — Request in, Response out, no listener.
29
+ *
30
+ * This is the composable form (runtime contract R12b): a Worker / Durable Object entry has NO
31
+ * loopback ports, so it must mount a pack's handler IN-PROCESS. `createGoogleOAuthTwinServer`
32
+ * is nothing but `Bun.serve` wrapped around this closure, so the standalone (R1) and hosted
33
+ * surfaces are the SAME code — there is no second HTTP adaptation to drift.
34
+ *
35
+ * WHAT IT SERVES IS UNCHANGED (R9): GET /twin is built from constants, and the only clock on
36
+ * the path is `worldNow()`, the world's frozen instant. The consent screen's own script and
37
+ * stylesheet routes serve COMMITTED text (`googleoauth-consent-client.gen.ts`, built on the dev
38
+ * plane by `scripts/consent-clients.ts`) — no build and no disk read on the fetch path.
39
+ */
40
+ export function createGoogleOAuthTwinFetch(options: GoogleOAuthTwinFetchOptions = {}): (request: Request) => Promise<Response> {
41
+ const readOnly = options.readOnly ?? false;
42
+ return async function googleOAuthTwinFetch(request: Request): Promise<Response> {
43
+ const url = new URL(request.url);
44
+ // GET /twin — the discovery manifest (education inside the twin).
45
+ if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
46
+ return Response.json(statefulTwinManifest({ vendor: 'googleoauth', twinOf: 'Google OAuth 2.0 / OpenID Connect', stores: 'authorization codes, tokens and RS256 id_tokens minted by the full code round trip' }));
47
+ }
48
+
49
+ if (request.method === 'GET' && url.pathname === CONSENT_SCRIPT_PATH) {
50
+ return new Response(CONSENT_CLIENT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
51
+ }
52
+ if (request.method === 'GET' && url.pathname === CONSENT_STYLE_PATH) {
53
+ return new Response(CONSENT_CLIENT_CSS, { headers: { 'content-type': 'text/css; charset=utf-8' } });
54
+ }
55
+
56
+ const body = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text();
57
+ const headers: Record<string, string> = {};
58
+ request.headers.forEach((value, key) => {
59
+ headers[key.toLowerCase()] = value;
60
+ });
61
+
62
+ const res = await handleGoogleOAuthTwinRequest({
63
+ method: request.method,
64
+ path: url.pathname + (url.search || ''),
65
+ body,
66
+ headers,
67
+ readOnly,
68
+ occurredAt: worldNow(),
69
+ origin: twinPublicBase(request),
70
+ callbackOrigin: url.origin,
71
+ ...(options.root !== undefined ? { root: options.root } : {}),
72
+ });
73
+
74
+ const out = { ...(res.headers ?? {}) };
75
+ // A string body is already rendered (HTML, or the empty body of a 302); anything else is the
76
+ // vendor's JSON.
77
+ if (typeof res.body === 'string') {
78
+ if (!out['content-type'] && res.body) out['content-type'] = 'text/html; charset=utf-8';
79
+ return new Response(res.body, { status: res.status, headers: out });
80
+ }
81
+ out['content-type'] = out['content-type'] ?? 'application/json; charset=utf-8';
82
+ return new Response(JSON.stringify(res.body), { status: res.status, headers: out });
83
+ };
84
+ }
85
+
86
+ export async function createGoogleOAuthTwinServer(options: { root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; stop: () => void }> {
87
+ const server = await serveHttp({
88
+ port: options.port ?? 0,
89
+ idleTimeout: 60,
90
+ fetch: createGoogleOAuthTwinFetch(options),
91
+ });
92
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
93
+ }
94
+
95
+ /**
96
+ * The consent screen is served BY THE TWIN, at the vendor's own path — there is no second "mirror"
97
+ * server to start, and pretending otherwise would imply a second renderer. This alias exists so the
98
+ * `world-googleoauth mirror` command and the journey harness have the conventional entry point,
99
+ * and it returns the very same server.
100
+ */
101
+ export const createGoogleOAuthConsentServer = createGoogleOAuthTwinServer;
@@ -0,0 +1,359 @@
1
+ // Google OAuth twin — STATE. Everything the twin knows lives in the shared kernel action log
2
+ // (D1): OAuth clients, the Google accounts a user can choose between, pending authorization
3
+ // requests, authorization codes, access/refresh tokens and the per-(client, user) grant record.
4
+ // There is no parallel store.
5
+ //
6
+ // ID MINTING (ADDING_A_TWIN.md §5): every id is either the vendor's own natural key (a client_id,
7
+ // an account `sub`) or MINTED HERE. The minted ones used to be ENTROPIC (`randomUUID`) — never a
8
+ // module counter and never a row count, the collision class three packs' §9 reviews each found
9
+ // independently. Every one of them is also SERVED: the `ar_` consent handle is rendered into the
10
+ // screen, the `4/0A` code rides the callback redirect, the `ya29.`/`1//0` tokens ride the /token
11
+ // reply. Runtime contract R9 (a served response is a pure function of (request, stored state) —
12
+ // no clock and no randomness in served content) therefore governs all of them, and entropy is
13
+ // exactly the defect it names, so they are ALL minted the resend way now: a stable hash of the
14
+ // world instant + the number of rows of that type the store already holds (soft-deleted rows
15
+ // counted, so a value can never recur) + the subject it is issued for.
16
+ //
17
+ // §5's count-mint hazard — a pulled vendor id sitting in a GAP above the row count, which local
18
+ // creates then walk into and clobber — cannot arise here: these types are twin-internal or
19
+ // twin-issued, no connector pulls one, and the count is a hash SEED rather than the id itself.
20
+ // What §5's entropy bought (two writes at one instant never colliding) the count buys instead:
21
+ // it advances on every mint, so `googleoauth-twin.test.ts`'s "two grants in the same millisecond
22
+ // mint DIFFERENT credentials" holds by construction rather than by luck.
23
+ //
24
+ // The `ar_` handle carries one extra rule the credentials do not: an authorize request whose
25
+ // parameters already name an UNSETTLED pending row reuses that row's id, so re-serving the same
26
+ // consent screen is byte-identical (and a reload does not pile up a second pending request).
27
+ //
28
+ // The one state shape that DOES revisit values is the mutable-in-place kind: a code being consumed,
29
+ // a token being revoked, a grant's scope set being re-granted with the same scopes. Those carry a
30
+ // per-subject `rev` ordinal (the upstash precedent) so a genuine write can never be mistaken
31
+ // for a replay.
32
+ import { applyTwinWrite, projectResources, type TwinResource } from '@volter/world-core';
33
+
34
+ export const SERVICE = 'googleoauth';
35
+
36
+ export const RESOURCE_TYPES = [
37
+ 'oauth_client',
38
+ 'account',
39
+ 'auth_request',
40
+ 'authorization_code',
41
+ 'access_token',
42
+ 'refresh_token',
43
+ 'grant',
44
+ ] as const;
45
+ export type ResourceType = (typeof RESOURCE_TYPES)[number];
46
+
47
+ export type Row = TwinResource & Record<string, any>;
48
+
49
+ export function readAll(root: string | undefined): Row[] {
50
+ return projectResources(SERVICE, root) as Row[];
51
+ }
52
+ export function readType(root: string | undefined, type: ResourceType): Row[] {
53
+ return readAll(root).filter((r) => r.type === type);
54
+ }
55
+ export function readOne(root: string | undefined, type: ResourceType, id: string): Row | undefined {
56
+ return readAll(root).find((r) => r.type === type && r.id === id);
57
+ }
58
+
59
+ /** The next `rev` for a subject that is being rewritten in place (survives deletes/tombstones). */
60
+ export function nextRev(root: string | undefined, type: ResourceType, id: string): number {
61
+ const existing = readOne(root, type, id);
62
+ return (typeof existing?.rev === 'number' ? existing.rev : 0) + 1;
63
+ }
64
+
65
+ export type WriteOpts = { root?: string; occurredAt?: string };
66
+ /** Seeding also needs the origin the twin was reached on, so the demo client's registered
67
+ * callbacks point back at THIS twin rather than at a baked-in port (runtime contract R7). */
68
+ export type SeedOpts = WriteOpts & { origin?: string };
69
+
70
+ export async function write(
71
+ type: ResourceType,
72
+ id: string,
73
+ operation: string,
74
+ fields: Record<string, unknown>,
75
+ opts: WriteOpts,
76
+ ): Promise<Row> {
77
+ const { resource } = await applyTwinWrite(
78
+ SERVICE,
79
+ {
80
+ operation,
81
+ subjectType: type,
82
+ subjectId: id,
83
+ fields,
84
+ ...(opts.occurredAt ? { occurredAt: opts.occurredAt } : {}),
85
+ actor: { kind: 'agent' },
86
+ },
87
+ opts.root,
88
+ );
89
+ return resource as Row;
90
+ }
91
+
92
+ // ── vendor-shaped credential minting: DETERMINISTIC, because it is SERVED (R9) ──────────────
93
+ // Google's opaque credentials have RECOGNISABLE prefixes and an integrator's code often keys on
94
+ // them (a `ya29.` access token, a `1//` refresh token, a `4/0A` auth code). The twin reproduces the
95
+ // prefix and the LENGTH — the shape is vendor surface, the bytes are not.
96
+ //
97
+ // The bytes used to be `randomUUID()` entropy, and every one of them is SERVED (the code rides the
98
+ // callback redirect, the tokens ride the /token reply), so R9 governs them exactly as it governs
99
+ // the `ar_` handle below. They are now the resend pattern: 32 bytes of `stableHex` over
100
+ // (type, world instant, rows of that type held BEFORE the insert, the subject being issued for),
101
+ // base64url-ed and sliced to the vendor's length — the SAME construction as before, with the
102
+ // entropy source swapped for a pure hash, so the shape is byte-for-byte what it was.
103
+ //
104
+ // A twin's credentials are stand-ins, not secrets: determinism is the contract, and predictability
105
+ // from public state is not a threat model a twin has. UNIQUENESS is, and the seed carries it: the
106
+ // row COUNT advances on every mint, so two credentials of one type at one instant differ even when
107
+ // the subject is identical (`googleoauth-twin.test.ts` — "two grants in the same millisecond mint
108
+ // DIFFERENT credentials"), and the subject separates concurrent exchanges.
109
+
110
+ /** The seed every credential is minted from. `readType` is unfiltered (soft-deletes counted), so a
111
+ * count — and therefore a credential — can never recur for one (type, instant, subject). */
112
+ function credentialSeed(root: string | undefined, type: ResourceType, instant: string, subject: string): string {
113
+ return `${type}:${instant}:${readType(root, type).length}:${subject}`;
114
+ }
115
+
116
+ /** base64url over 32 hashed bytes, sliced to the vendor's length (unchanged from the entropic
117
+ * version — only the byte source moved from `randomUUID` to `stableHex`). */
118
+ const opaque = (seed: string, bytes = 24) =>
119
+ Buffer.from(stableHex(seed, 64), 'hex')
120
+ .toString('base64')
121
+ .replace(/=/g, '')
122
+ .replace(/\+/g, '-')
123
+ .replace(/\//g, '_')
124
+ .slice(0, bytes);
125
+
126
+ /** `4/0A…` — Google's authorization codes. `subject` is the consent screen this code settles. */
127
+ export const mintAuthorizationCode = (root: string | undefined, instant: string, subject: string): string =>
128
+ `4/0A${opaque(credentialSeed(root, 'authorization_code', instant, subject), 40)}`;
129
+ /** `ya29.…` — Google's OAuth 2.0 access tokens. `subject` is the code or refresh token exchanged. */
130
+ export const mintAccessToken = (root: string | undefined, instant: string, subject: string): string =>
131
+ `ya29.${opaque(credentialSeed(root, 'access_token', instant, subject), 64)}`;
132
+ /** `1//0…` — Google's refresh tokens. `subject` is the code the grant was exchanged from. */
133
+ export const mintRefreshToken = (root: string | undefined, instant: string, subject: string): string =>
134
+ `1//0${opaque(credentialSeed(root, 'refresh_token', instant, subject), 48)}`;
135
+
136
+ // ── the consent screen's handle: DETERMINISTIC, because it is SERVED (R9) ───────────────────
137
+ /** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist
138
+ * (the resend exemplar's `stableHex`). Pure: no clock, no entropy, no host state. */
139
+ function stableHex(seed: string, n: number): string {
140
+ let out = '';
141
+ for (let round = 0; out.length < n; round += 1) {
142
+ let h = 0x811c9dc5;
143
+ const s = `${round}:${seed}`;
144
+ for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
145
+ out += h.toString(16).padStart(8, '0');
146
+ }
147
+ return out.slice(0, n);
148
+ }
149
+
150
+ /** Every authorize parameter an `auth_request` row holds — its stable identity. Read off the
151
+ * fields being written OR off a projected row (the key set is the same on both). */
152
+ const AUTH_REQUEST_SIG_KEYS = [
153
+ 'clientId', 'redirectUri', 'scope', 'state', 'nonce', 'accessType', 'prompt', 'loginHint',
154
+ 'includeGrantedScopes', 'codeChallenge', 'codeChallengeMethod',
155
+ ] as const;
156
+ function authRequestSignature(fields: Record<string, unknown>): string {
157
+ return AUTH_REQUEST_SIG_KEYS.map((k) => `${k}=${String(fields[k] ?? '')}`).join('\u0001');
158
+ }
159
+
160
+ /**
161
+ * Twin-internal handle for a consent screen in flight (never leaves the twin's own pages) —
162
+ * `ar_` + 32 hex, the shape `randomUUID()` gave it, now a pure function of (request, stored state).
163
+ *
164
+ * An authorize request whose parameters ALREADY name an unsettled pending row reuses that row's
165
+ * id: pressing reload on a consent screen is the same screen, not a new one. Otherwise the id is
166
+ * `stableHex(auth_request:<world instant>:<rows already held>)` — two authorize requests at one
167
+ * instant differ by the count, and two identical worlds mint identical ids.
168
+ */
169
+ export function authRequestIdFor(root: string | undefined, occurredAt: string, fields: Record<string, unknown>): string {
170
+ const prior = readType(root, 'auth_request');
171
+ const signature = authRequestSignature(fields);
172
+ const reusable = prior.find((r) => r.settled !== true && authRequestSignature(r) === signature);
173
+ if (reusable) return String(reusable.id);
174
+ return `ar_${stableHex(`auth_request:${occurredAt}:${prior.length}`, 32)}`;
175
+ }
176
+
177
+ // ── seeded world ────────────────────────────────────────────────────────────────────────────
178
+ // A twin has to have SOMETHING to consent as. Google's own account chooser lists the Google
179
+ // accounts signed in to the browser; the twin's equivalent is a small set of deterministic
180
+ // personas seeded into kernel state on first use, and `POST /_twin/accounts` adds more. The
181
+ // default OAuth client mirrors the shape the Google Cloud console hands out (a `.apps.
182
+ // googleusercontent.com` client_id and a `GOCSPX-` secret).
183
+
184
+ export const DEFAULT_CLIENT_ID = '1044839207-twindemoapp0000000000000000000.apps.googleusercontent.com';
185
+ export const DEFAULT_CLIENT_SECRET = 'GOCSPX-twin0000000000000000000000';
186
+
187
+ export type SeedAccount = {
188
+ sub: string;
189
+ email: string;
190
+ name: string;
191
+ givenName: string;
192
+ familyName: string;
193
+ picture: string;
194
+ emailVerified: boolean;
195
+ hd?: string;
196
+ };
197
+
198
+ /** Google's `sub` is a stable 21-digit numeric string; these are deterministic stand-ins. */
199
+ export const DEFAULT_ACCOUNTS: SeedAccount[] = [
200
+ {
201
+ sub: '100000000000000000001',
202
+ email: 'ada.lovelace@gmail.com',
203
+ name: 'Ada Lovelace',
204
+ givenName: 'Ada',
205
+ familyName: 'Lovelace',
206
+ picture: 'https://lh3.googleusercontent.com/a/twin-ada',
207
+ emailVerified: true,
208
+ },
209
+ {
210
+ sub: '100000000000000000002',
211
+ email: 'grace.hopper@navy.example',
212
+ name: 'Grace Hopper',
213
+ givenName: 'Grace',
214
+ familyName: 'Hopper',
215
+ picture: 'https://lh3.googleusercontent.com/a/twin-grace',
216
+ emailVerified: true,
217
+ hd: 'navy.example',
218
+ },
219
+ ];
220
+
221
+ /** NextAuth's callback path — the one every Google integration guide walks a reader through. */
222
+ const CALLBACK_PATH = '/api/auth/callback/google';
223
+ /** Google's out-of-band redirect for installed apps: an URN, so it carries no host at all. */
224
+ const OOB_REDIRECT = 'urn:ietf:wg:oauth:2.0:oob';
225
+
226
+ /**
227
+ * Every spelling of the ORIGIN this twin was reached on that a browser could hand back.
228
+ *
229
+ * Google matches redirect URIs EXACTLY, and it treats `localhost` and the loopback IP as different
230
+ * hosts (see `redirectUriAllowed` below) — while a local app is reachable as BOTH on the same port.
231
+ * A demo registration that named only one of them would fail half the time for a reason the
232
+ * developer cannot see, so the seed registers both spellings of whatever origin it was reached on.
233
+ */
234
+ function originSpellings(origin?: string): string[] {
235
+ const base = (origin ?? '').trim().replace(/\/+$/, '');
236
+ if (!base) return [];
237
+ let u: URL;
238
+ try { u = new URL(base); } catch { return [base]; }
239
+ if (u.protocol !== 'http:') return [base];
240
+ const port = u.port ? `:${u.port}` : '';
241
+ if (u.hostname === 'localhost') return [base, `http://127.0.0.1${port}`];
242
+ if (u.hostname === '127.0.0.1') return [base, `http://localhost${port}`];
243
+ return [base];
244
+ }
245
+
246
+ /**
247
+ * The redirect URIs the seeded demo client registers.
248
+ *
249
+ * Runtime contract R7: a twin NEVER bakes a port into served content or config. The callbacks are
250
+ * DERIVED from the origin the twin was actually reached on (`GoogleOAuthRequest.origin`, which
251
+ * googleoauth-server fills from the URL the request arrived at). A caller that declares no origin
252
+ * — an in-process call — gets the host-free out-of-band URN only, and registers whatever callbacks
253
+ * it wants through `POST /_twin/clients`.
254
+ */
255
+ export function defaultRedirectUris(origin?: string): string[] {
256
+ return [...originSpellings(origin).map((base) => `${base}${CALLBACK_PATH}`), OOB_REDIRECT];
257
+ }
258
+
259
+ /**
260
+ * Materialise the default client + accounts once per root. Idempotent by subject id: re-running it
261
+ * over a seeded root writes nothing new, and it NEVER overwrites an operator-registered client or
262
+ * account (an existing subject id is left exactly as it is).
263
+ */
264
+ export async function ensureSeed(opts: SeedOpts): Promise<void> {
265
+ const existing = readAll(opts.root);
266
+ const has = (type: ResourceType, id: string) => existing.some((r) => r.type === type && r.id === id);
267
+ if (!has('oauth_client', DEFAULT_CLIENT_ID)) {
268
+ await write(
269
+ 'oauth_client',
270
+ DEFAULT_CLIENT_ID,
271
+ 'oauth_client.create',
272
+ {
273
+ name: 'Twin Demo App',
274
+ secret: DEFAULT_CLIENT_SECRET,
275
+ redirectUris: defaultRedirectUris(opts.origin),
276
+ clientType: 'web',
277
+ supportEmail: 'support@twin.example',
278
+ verified: false,
279
+ rev: 1,
280
+ },
281
+ opts,
282
+ );
283
+ }
284
+ for (const account of DEFAULT_ACCOUNTS) {
285
+ if (has('account', account.sub)) continue;
286
+ await write(
287
+ 'account',
288
+ account.sub,
289
+ 'account.create',
290
+ {
291
+ email: account.email,
292
+ emailVerified: account.emailVerified,
293
+ name: account.name,
294
+ givenName: account.givenName,
295
+ familyName: account.familyName,
296
+ picture: account.picture,
297
+ ...(account.hd ? { hd: account.hd } : {}),
298
+ rev: 1,
299
+ },
300
+ opts,
301
+ );
302
+ }
303
+ }
304
+
305
+ /** Every account the chooser can offer, oldest-seeded first (a stable screen order). */
306
+ export function listAccounts(root: string | undefined): Row[] {
307
+ return readType(root, 'account');
308
+ }
309
+
310
+ /**
311
+ * Is `candidate` an EXACT registered redirect URI for this client? Google matches redirect URIs
312
+ * exactly (scheme, host, port and path all compared literally; a trailing slash is a different
313
+ * URI), which is precisely why `redirect_uri_mismatch` is the single most common integration
314
+ * error. A twin that matched loosely would hide every one of those bugs.
315
+ *
316
+ * The one documented exception is the loopback-IP flow for installed apps: Google ignores the PORT
317
+ * for `http://127.0.0.1` / `http://[::1]` redirect URIs, because the app binds an ephemeral port.
318
+ */
319
+ export function redirectUriAllowed(client: Row, candidate: string): boolean {
320
+ const registered: string[] = Array.isArray(client.redirectUris) ? client.redirectUris : [];
321
+ // A FRAGMENT is refused outright, before any comparison. Google documents that a redirect URI may
322
+ // not contain one, and an exact-string match alone did not catch it: the loopback branch below
323
+ // compares protocol/host/path/search and never looked at `hash`, so
324
+ // `http://127.0.0.1:3000/cb#evil` was accepted against a registration of `http://127.0.0.1:3000/cb`
325
+ // (§9 round two). A fragment is attacker-controlled and never reaches the server, which is
326
+ // exactly why it is banned.
327
+ if (candidate.includes('#')) return false;
328
+ // USERINFO (`http://user:pass@host/…`) likewise: Google forbids the subcomponent, and the
329
+ // loopback branch never compared it either.
330
+ if (/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*@/i.test(candidate)) return false;
331
+ if (registered.includes(candidate)) return true;
332
+ return registered.some((uri) => loopbackEquivalent(uri, candidate));
333
+ }
334
+
335
+ function loopbackEquivalent(registered: string, candidate: string): boolean {
336
+ let a: URL, b: URL;
337
+ try {
338
+ a = new URL(registered);
339
+ b = new URL(candidate);
340
+ } catch {
341
+ return false;
342
+ }
343
+ if (a.protocol !== 'http:' || b.protocol !== 'http:') return false;
344
+ // The exception is documented for the loopback IP LITERALS only (`http://127.0.0.1:<port>` and
345
+ // `http://[::1]:<port>` — an installed app binds a random available port). `localhost` is NOT in
346
+ // that list: Google treats it as an ordinary host string that must match exactly, port included.
347
+ // `URL` normalises `[::1]` to the bracketed form in `hostname`, so both spellings land here.
348
+ const loopbackIp = (h: string) => h === '127.0.0.1' || h === '[::1]';
349
+ if (!loopbackIp(a.hostname) || !loopbackIp(b.hostname)) return false;
350
+ // The CANDIDATE's host must be the literal Google documents, not merely something WHATWG `URL`
351
+ // normalises to it. `URL` folds `0177.0.0.1`, `2130706433` and `127.1` all onto `127.0.0.1`, so
352
+ // reading `.hostname` alone accepted three spellings Google's exact-match rule does not
353
+ // (§9 round two). Compare the raw authority the caller actually sent.
354
+ const rawHost = /^https?:\/\/([^/?#]*)/i.exec(candidate)?.[1] ?? '';
355
+ const rawName = rawHost.replace(/:\d+$/, '');
356
+ if (rawName !== '127.0.0.1' && rawName !== '[::1]') return false;
357
+ // Only the PORT is forgiven — the host and path still have to agree exactly.
358
+ return a.hostname === b.hostname && a.pathname === b.pathname && a.search === b.search;
359
+ }