@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,244 @@
1
+ // Google OAuth CONNECTOR — the live-vendor pull path.
2
+ //
3
+ // PULL (real → twin): the twin's account chooser is only as useful as the identities in it, and
4
+ // its client registry is only as useful as the client_ids an app really holds. So a pull seeds BOTH
5
+ // from the operator's OWN real Google credentials, using the three read endpoints that exist on
6
+ // this surface:
7
+ // • GET /tokeninfo?access_token=… → the real `azp`/`aud` (the OAuth CLIENT the token belongs
8
+ // to), the real `sub`, and the real granted `scope`
9
+ // • GET /v1/userinfo → the real persona behind that `sub` (email, name, picture)
10
+ // • GET /.well-known/openid-configuration → the vendor's own endpoint list, observed rather than
11
+ // assumed (this is how the twin can SAY the endpoints it
12
+ // serves are the endpoints Google publishes)
13
+ //
14
+ // PUSH IS AN HONEST GAP, not an omission: Google exposes NO write API for this surface. OAuth
15
+ // clients are created in the Cloud Console, and a user's grants are managed at
16
+ // myaccount.google.com — neither is an API a connector could call. `pushPendingGoogleOAuthActions`
17
+ // therefore exists only to report that, and the gap is filed as `googleoauth.connector.push`
18
+ // (todo). ADDING_A_TWIN.md §10 sanctions exactly this for a vendor with no write path.
19
+ //
20
+ // The vendor I/O is an INJECTED executor (the auth boundary): the kernel and this pack hold NO
21
+ // Google credential and import NO network client. Offline/tests pass a fake executor; live runs
22
+ // pass `liveGoogleOAuthExecute(accessToken)`. Same code path either way.
23
+ import { assertBudgetGuardIntact, deployableEntries, observeResources } from '@volter/world-core';
24
+ import { GoogleOAuthBudget, GoogleOAuthBudgetError, googleOAuthCallWeight } from "./googleoauth-budget.js";
25
+ const SERVICE = 'googleoauth';
26
+ /** Google's real hosts, one per endpoint family — the live executor's routing table. */
27
+ const HOSTS = {
28
+ '/tokeninfo': 'https://oauth2.googleapis.com',
29
+ '/revoke': 'https://oauth2.googleapis.com',
30
+ '/v1/userinfo': 'https://openidconnect.googleapis.com',
31
+ '/oauth2/v3/certs': 'https://www.googleapis.com',
32
+ '/.well-known/openid-configuration': 'https://accounts.google.com',
33
+ };
34
+ /**
35
+ * A live executor against the real Google OAuth endpoints, holding the operator's OWN access token.
36
+ *
37
+ * THIS IS THE ONE PLACE this pack issues a live Google request, and therefore the one place the
38
+ * rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the request
39
+ * goes out (`checkBudget`, which THROWS instead of returning when the ceiling or a cooldown says
40
+ * stop) and the response is fed back (`recordCall`) so a `Retry-After` / 429 becomes a PERSISTED
41
+ * cooldown that makes every later call fail fast WITHOUT touching Google. There is deliberately no
42
+ * option to disable the guard and no value of `budget` that yields an unguarded client — a
43
+ * duck-typed stand-in, a SUBCLASS overriding `checkBudget`, and a Proxy trapping it are all refused
44
+ * (`assertBudgetGuardIntact`). What this cannot stop is deliberate sabotage from inside the process
45
+ * (an injected clock, a throwaway ledger path); the kernel's header states that limit rather than
46
+ * pretending otherwise.
47
+ */
48
+ export function liveGoogleOAuthExecute(accessToken, opts = {}) {
49
+ const doFetch = opts.fetchImpl ?? fetch;
50
+ const budget = opts.budget !== undefined && opts.budget !== null
51
+ ? assertBudgetGuardIntact(opts.budget, GoogleOAuthBudget, 'liveGoogleOAuthExecute')
52
+ : new GoogleOAuthBudget({ token: accessToken, ...(opts.budgetOptions ?? {}) });
53
+ return async (method, path, init) => {
54
+ const bare = path.split('?')[0] ?? path;
55
+ const host = HOSTS[bare];
56
+ if (!host)
57
+ throw new Error(`liveGoogleOAuthExecute: refusing to call an unmapped Google path: ${bare}`);
58
+ const weight = googleOAuthCallWeight(method, path);
59
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
60
+ const reservation = budget.checkBudget(weight);
61
+ const res = await doFetch(`${host}${path}`, {
62
+ method,
63
+ headers: { Authorization: `Bearer ${accessToken}`, ...(init?.headers ?? {}) },
64
+ ...(init?.body !== undefined ? { body: init.body } : {}),
65
+ });
66
+ const resHeaders = {};
67
+ res.headers.forEach((v, k) => {
68
+ resHeaders[k.toLowerCase()] = v;
69
+ });
70
+ const parsed = (await res.json());
71
+ // Settles the reservation and, on a back-off signal, arms the cooldown. The cooldown is
72
+ // persisted before any throw, so the refusal survives it.
73
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
74
+ // call that louder refusal wins; an answer Google ACCEPTED is kept, so a write that landed is
75
+ // never recorded as failed and performed again on retry.
76
+ try {
77
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
78
+ }
79
+ catch (error) {
80
+ if (!(error instanceof GoogleOAuthBudgetError) || !res.ok)
81
+ throw error;
82
+ }
83
+ // A REFUSED pull is NOT an empty account: Google answers a bad token with an `error` envelope
84
+ // (sometimes under HTTP 200 on the tokeninfo endpoint), and mapping that to "no data" would let
85
+ // the fold put an empty account OVER real observed state. Throw instead.
86
+ if (parsed && typeof parsed === 'object' && 'error' in parsed) {
87
+ const err = parsed.error;
88
+ const detail = typeof err === 'string' ? err : JSON.stringify(err);
89
+ throw new Error(`google oauth refused ${method} ${bare}: ${detail}`);
90
+ }
91
+ if (res.status >= 400)
92
+ throw new Error(`google oauth refused ${method} ${bare}: HTTP ${res.status}`);
93
+ return parsed;
94
+ };
95
+ }
96
+ /** Map a real `tokeninfo` response → the OAuth client SyncResource it names. */
97
+ export function mapTokenInfoClient(info) {
98
+ const clientId = String(info.azp ?? info.aud ?? '');
99
+ return {
100
+ type: 'oauth_client',
101
+ id: clientId,
102
+ fields: {
103
+ // NOTHING HERE IS INVENTED. /tokeninfo discloses the client_id and nothing else about the
104
+ // client, so every other field records that absence rather than filling it in:
105
+ // • `secret: null` — the API never returns a client secret; a placeholder would be a
106
+ // fabricated credential, and the token endpoint therefore treats a pulled-only client as
107
+ // a public (`installed`) one until an operator registers the real thing.
108
+ // • `name` — an earlier version used `clientId.split('-')[0]`, which put the bare numeric
109
+ // project prefix ("1044839207") on the consent screen as if it were the app's name. It is
110
+ // not a name; it is half an id. The row now says so, and the screen shows that (§9 round
111
+ // one).
112
+ // • `verified: null` — this flag drives the "Google hasn't verified this app" interstitial.
113
+ // tokeninfo does not disclose it, and claiming `true` would silently SUPPRESS a warning
114
+ // the real screen might show. Unknown reads as unverified, which is the safe direction.
115
+ name: `Pulled client ${clientId}`,
116
+ secret: null,
117
+ redirectUris: [],
118
+ clientType: 'installed',
119
+ supportEmail: null,
120
+ verified: null,
121
+ pulled: true,
122
+ },
123
+ };
124
+ }
125
+ /** Map a real `userinfo` response → the account (persona) SyncResource. */
126
+ export function mapUserInfoAccount(profile) {
127
+ return {
128
+ type: 'account',
129
+ id: String(profile.sub ?? ''),
130
+ fields: {
131
+ email: profile.email ?? null,
132
+ emailVerified: profile.email_verified === true,
133
+ name: profile.name ?? null,
134
+ givenName: profile.given_name ?? null,
135
+ familyName: profile.family_name ?? null,
136
+ picture: profile.picture ?? null,
137
+ ...(profile.hd ? { hd: profile.hd } : {}),
138
+ pulled: true,
139
+ },
140
+ };
141
+ }
142
+ /** Map a real `tokeninfo` response → the (client, user) grant SyncResource it evidences. */
143
+ export function mapTokenInfoGrant(info) {
144
+ const clientId = String(info.azp ?? info.aud ?? '');
145
+ const sub = String(info.sub ?? '');
146
+ return {
147
+ type: 'grant',
148
+ id: `${clientId}:${sub}`,
149
+ fields: {
150
+ clientId,
151
+ sub,
152
+ scopes: typeof info.scope === 'string' ? info.scope.split(/\s+/).filter(Boolean) : [],
153
+ pulled: true,
154
+ },
155
+ };
156
+ }
157
+ /**
158
+ * A MOVING pull timestamp, forced strictly increasing within the process.
159
+ *
160
+ * NOT a pinned constant (the hazard ADDING_A_TWIN.md §6 names, and which `calcom-connector.ts`'s
161
+ * `PULL_AT` is a live instance of): the kernel hashes an observed event over (`occurredAt` +
162
+ * post-state), so under a fixed poll time a vendor value that REVERTS across polls — a scope
163
+ * granted, revoked and re-granted; a display name changed back — collides with its own earlier
164
+ * observation and the fold reports `deltasAppended: 1` while nothing lands.
165
+ */
166
+ let lastPollMs = 0;
167
+ function pollTimestamp() {
168
+ const now = Date.now();
169
+ lastPollMs = now > lastPollMs ? now : lastPollMs + 1;
170
+ return new Date(lastPollMs).toISOString();
171
+ }
172
+ /** Fetch + map the identity behind the operator's own token (no fold). */
173
+ async function collectGoogleOAuthIdentity(execute) {
174
+ const info = await execute('GET', '/tokeninfo');
175
+ const profile = await execute('GET', '/v1/userinfo');
176
+ const resources = [];
177
+ const clientId = String(info.azp ?? info.aud ?? '');
178
+ if (clientId)
179
+ resources.push(mapTokenInfoClient(info));
180
+ if (profile.sub)
181
+ resources.push(mapUserInfoAccount(profile));
182
+ if (clientId && info.sub)
183
+ resources.push(mapTokenInfoGrant(info));
184
+ return resources;
185
+ }
186
+ /** PULL the operator's own identity + client into the twin's observed log (idempotent). */
187
+ export async function pullGoogleOAuthIdentity(execute, root, occurredAt) {
188
+ const resources = await collectGoogleOAuthIdentity(execute);
189
+ fold(resources, { occurredAt: occurredAt ?? pollTimestamp(), ...(root !== undefined ? { root } : {}) });
190
+ return resources.length;
191
+ }
192
+ /**
193
+ * D7 consumer-facing pull entry point: pull everything readable from the real Google OAuth surface
194
+ * and fold it into the tree in ONE observed batch, returning the standard
195
+ * `{ observed, deltasAppended }`. Idempotent — a re-pull of identical state appends nothing.
196
+ */
197
+ /** One fold onto the head: protocol 2's observe, one batch, one instant. */
198
+ function fold(resources, opts) {
199
+ return observeResources(SERVICE, resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), {
200
+ ...(opts.root !== undefined ? { root: opts.root } : {}), at: opts.occurredAt, batch: `obs:${SERVICE}:${opts.occurredAt}`,
201
+ });
202
+ }
203
+ export async function syncGoogleOAuthFromReal(execute, opts = {}) {
204
+ const occurredAt = opts.occurredAt ?? pollTimestamp();
205
+ const resources = await collectGoogleOAuthIdentity(execute);
206
+ const result = fold(resources, { occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
207
+ return { observed: result.observed, deltasAppended: result.appended };
208
+ }
209
+ // ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
210
+ /** The pack's executor over the kernel's. */
211
+ export function googleOAuthExecuteOver(execute) {
212
+ return async (method, path, init) => {
213
+ const res = await execute({ method, path, headers: { accept: 'application/json', ...(init?.headers ?? {}) }, ...(init?.body === undefined ? {} : { body: String(init.body) }) });
214
+ let data = {};
215
+ if (res.body) {
216
+ try {
217
+ data = JSON.parse(res.body);
218
+ }
219
+ catch {
220
+ data = { error: res.body };
221
+ }
222
+ }
223
+ return { status: res.status, data };
224
+ };
225
+ }
226
+ /** The refresh adapter: read the account this credential names (its identity and granted scopes). */
227
+ export async function syncGoogleOAuthFromRemote(execute, opts = {}) {
228
+ return syncGoogleOAuthFromReal(googleOAuthExecuteOver(execute), {
229
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
230
+ ...(opts.occurredAt !== undefined ? { occurredAt: opts.occurredAt } : {}),
231
+ });
232
+ }
233
+ /** The perform adapter — and the honest answer is that NOTHING here can cross. Google publishes no API
234
+ * that creates an OAuth client, seeds a user or grants a scope: those are Cloud Console and
235
+ * myaccount.google.com actions a person takes in a browser. So every entry settles with that reason,
236
+ * which is exactly what the v1 push reported by refusing to confirm anything. */
237
+ export async function performGoogleOAuthAction(_execute, action, _ctx) {
238
+ return { externalId: action.subject.id, data: { performed: false, reason: `${action.operation ?? action.subject.type}: Google publishes no API that creates an OAuth client, seeds a user or grants a scope — those are console actions, so nothing can cross` } };
239
+ }
240
+ /** How much local state has no upstream home — the count the v1 push reported, kept for the claim that
241
+ * states this vendor's bound honestly. */
242
+ export function unpushableGoogleOAuthEntries(root) {
243
+ return { pushed: 0, unpushable: deployableEntries(SERVICE, root).length };
244
+ }