@agentshouse/kit 0.1.0-alpha.9 → 0.2.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 (83) hide show
  1. package/README.md +14 -1740
  2. package/dist/api.js +11 -0
  3. package/dist/home.js +24 -0
  4. package/dist/house-main.js +4 -0
  5. package/dist/kit-main.js +16 -0
  6. package/dist/login.js +112 -0
  7. package/dist/resident.js +10 -0
  8. package/dist/stream.js +31 -0
  9. package/package.json +16 -11
  10. package/bin/connect-linux.sh +0 -134
  11. package/bin/kit.js +0 -1023
  12. package/bootstrap.json +0 -17
  13. package/src/acp.js +0 -99
  14. package/src/adapter-mcp.js +0 -44
  15. package/src/adapter.js +0 -252
  16. package/src/agent-logins.js +0 -65
  17. package/src/agents.js +0 -504
  18. package/src/apply.js +0 -29
  19. package/src/attach.js +0 -52
  20. package/src/binding.js +0 -90
  21. package/src/blocks.js +0 -156
  22. package/src/bridge.js +0 -449
  23. package/src/bytes.js +0 -89
  24. package/src/channel.js +0 -37
  25. package/src/command.js +0 -109
  26. package/src/configuration.js +0 -465
  27. package/src/connector-login.js +0 -91
  28. package/src/connector.js +0 -107
  29. package/src/continuation.js +0 -61
  30. package/src/contract.js +0 -363
  31. package/src/conversation-originals.js +0 -143
  32. package/src/custom/authorization.js +0 -63
  33. package/src/declaration.js +0 -35
  34. package/src/delivery.js +0 -191
  35. package/src/directory.js +0 -102
  36. package/src/draft.js +0 -89
  37. package/src/google/calendar.js +0 -177
  38. package/src/google/declared.js +0 -146
  39. package/src/google/drive.js +0 -435
  40. package/src/google/gateway.js +0 -166
  41. package/src/google/gmail.js +0 -249
  42. package/src/google/id.js +0 -1
  43. package/src/google/oauth.js +0 -273
  44. package/src/google/source.js +0 -25
  45. package/src/hook.js +0 -376
  46. package/src/host.js +0 -121
  47. package/src/house-main.js +0 -79
  48. package/src/house.js +0 -1090
  49. package/src/inbound.js +0 -175
  50. package/src/instance/google.js +0 -1
  51. package/src/kit-work-stream.js +0 -84
  52. package/src/launcher-main.js +0 -19
  53. package/src/launcher.js +0 -276
  54. package/src/local-files.js +0 -113
  55. package/src/login.js +0 -274
  56. package/src/mcp.js +0 -71
  57. package/src/migrator.js +0 -248
  58. package/src/mount.js +0 -582
  59. package/src/operation.js +0 -20
  60. package/src/original.js +0 -631
  61. package/src/peer.js +0 -124
  62. package/src/plan.js +0 -57
  63. package/src/question.js +0 -316
  64. package/src/refusal.js +0 -79
  65. package/src/repository.js +0 -521
  66. package/src/session-bin/house +0 -2
  67. package/src/settlement.js +0 -57
  68. package/src/setup.js +0 -190
  69. package/src/source-originals.js +0 -116
  70. package/src/source.js +0 -286
  71. package/src/starter-set/grilling/LICENSE +0 -21
  72. package/src/starter-set/grilling/SKILL.md +0 -30
  73. package/src/starter-set/grilling/agents/openai.yaml +0 -3
  74. package/src/starter-set.js +0 -17
  75. package/src/state.js +0 -157
  76. package/src/sync-library.js +0 -293
  77. package/src/sync-state.js +0 -120
  78. package/src/sync.js +0 -482
  79. package/src/telegram.js +0 -1045
  80. package/src/terminal.js +0 -57
  81. package/src/tmux.js +0 -119
  82. package/src/worker.js +0 -925
  83. package/src/workspace.js +0 -500
@@ -1,166 +0,0 @@
1
- import { googleAccessToken, revokeGoogleAuthorization } from "./oauth.js";
2
- import { KitRefusal } from "../refusal.js";
3
-
4
- const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
5
- const RETRYABLE_REASONS = new Set(["rateLimitExceeded", "userRateLimitExceeded"]);
6
- const RETRY_LIMIT = 3;
7
- const RETRY_DELAY_MAX_MS = 30_000;
8
-
9
- function retryDelay(response, now) {
10
- const declared = response.headers.get("retry-after");
11
- if (declared === null) return 1000;
12
- if (/^\d+$/.test(declared)) return Math.min(RETRY_DELAY_MAX_MS, Number(declared) * 1000);
13
- const at = Date.parse(declared);
14
- return Number.isNaN(at)
15
- ? 1000
16
- : Math.min(RETRY_DELAY_MAX_MS, Math.max(0, at - now().getTime()));
17
- }
18
-
19
- function abortableSleep(milliseconds, signal) {
20
- return new Promise((resolve, reject) => {
21
- if (signal?.aborted === true) {
22
- reject(new KitRefusal("kit_command_cancelled"));
23
- return;
24
- }
25
- const timer = setTimeout(finish, milliseconds);
26
- function finish() {
27
- signal?.removeEventListener("abort", cancel);
28
- resolve();
29
- }
30
- function cancel() {
31
- clearTimeout(timer);
32
- signal.removeEventListener("abort", cancel);
33
- reject(new KitRefusal("kit_command_cancelled"));
34
- }
35
- signal?.addEventListener("abort", cancel, { once: true });
36
- });
37
- }
38
-
39
- async function failureReasons(response) {
40
- let answer = null;
41
- try {
42
- answer = await response.clone().json();
43
- } catch {
44
- answer = null;
45
- }
46
- const failures = answer?.error?.errors;
47
- if (!Array.isArray(failures)) return [];
48
- return failures.flatMap((failure) =>
49
- typeof failure?.reason === "string" ? [failure.reason] : [],
50
- );
51
- }
52
-
53
- async function googleResponse(options, dependencies) {
54
- const fetcher = dependencies.fetch ?? fetch;
55
- const now = dependencies.now ?? (() => new Date());
56
- const sleep = dependencies.sleep ?? abortableSleep;
57
- let accessToken = await googleAccessToken(options, { fetch: fetcher, now });
58
- let refreshedAfterUnauthorized = false;
59
- for (let attempt = 0; attempt < RETRY_LIMIT; attempt += 1) {
60
- let response;
61
- try {
62
- response = await fetcher(options.url, {
63
- method: options.method ?? "GET",
64
- headers: {
65
- authorization: `Bearer ${accessToken}`,
66
- ...(options.headers ?? {}),
67
- },
68
- signal: options.signal,
69
- ...(options.body === undefined ? {} : { body: options.body }),
70
- });
71
- } catch (error) {
72
- if (options.signal?.aborted === true || error?.name === "AbortError") {
73
- throw new KitRefusal("kit_command_cancelled");
74
- }
75
- throw new KitRefusal("google_provider_unavailable");
76
- }
77
- if (response.status === 401) {
78
- if (!refreshedAfterUnauthorized) {
79
- accessToken = await googleAccessToken(
80
- { ...options, forceRefresh: true },
81
- { fetch: fetcher, now },
82
- );
83
- refreshedAfterUnauthorized = true;
84
- attempt -= 1;
85
- continue;
86
- }
87
- await revokeGoogleAuthorization(options.directory, options.instance, options.requiredScopes);
88
- throw new KitRefusal("google_authority_revoked");
89
- }
90
- if (response.status === 403) {
91
- const reasons = await failureReasons(response);
92
- if (reasons.some((reason) => RETRYABLE_REASONS.has(reason))) {
93
- if (attempt + 1 < RETRY_LIMIT) {
94
- await sleep(retryDelay(response, now), options.signal);
95
- if (options.signal?.aborted === true) throw new KitRefusal("kit_command_cancelled");
96
- continue;
97
- }
98
- throw new KitRefusal("google_provider_unavailable");
99
- }
100
- if (reasons.includes("insufficientPermissions")) {
101
- await revokeGoogleAuthorization(
102
- options.directory,
103
- options.instance,
104
- options.requiredScopes,
105
- );
106
- throw new KitRefusal("google_scope_revoked");
107
- }
108
- throw new KitRefusal("google_provider_unavailable");
109
- }
110
- if (RETRYABLE_STATUS.has(response.status) && attempt + 1 < RETRY_LIMIT) {
111
- await sleep(retryDelay(response, now), options.signal);
112
- if (options.signal?.aborted === true) throw new KitRefusal("kit_command_cancelled");
113
- continue;
114
- }
115
- if (response.status === 404 && typeof options.notFoundCode === "string") {
116
- throw new KitRefusal(options.notFoundCode);
117
- }
118
- if (response.status === 410 && typeof options.goneCode === "string") {
119
- throw new KitRefusal(options.goneCode);
120
- }
121
- if (!response.ok) throw new KitRefusal("google_provider_unavailable");
122
- return response;
123
- }
124
- throw new KitRefusal("google_provider_unavailable");
125
- }
126
-
127
- export async function googleJson(options, dependencies = {}) {
128
- const response = await googleResponse(options, dependencies);
129
- let answer = null;
130
- try {
131
- answer = await response.json();
132
- } catch {
133
- answer = null;
134
- }
135
- if (typeof answer !== "object" || answer === null || Array.isArray(answer)) {
136
- throw new KitRefusal("google_provider_response_invalid");
137
- }
138
- return answer;
139
- }
140
-
141
- export async function googleBoundedText(options, limit, dependencies = {}) {
142
- const response = await googleResponse(options, dependencies);
143
- const declaredLength = response.headers.get("content-length");
144
- if (declaredLength !== null && /^\d+$/.test(declaredLength) && Number(declaredLength) > limit) {
145
- throw new KitRefusal("google_content_exceeded");
146
- }
147
- if (response.body === null) return "";
148
- const reader = response.body.getReader();
149
- const decoder = new TextDecoder("utf-8", { fatal: true });
150
- let bytes = 0;
151
- let text = "";
152
- try {
153
- for (;;) {
154
- const part = await reader.read();
155
- if (part.done) break;
156
- bytes += part.value.byteLength;
157
- if (bytes > limit) throw new KitRefusal("google_content_exceeded");
158
- text += decoder.decode(part.value, { stream: true });
159
- }
160
- return text + decoder.decode();
161
- } catch (error) {
162
- await reader.cancel().catch(() => undefined);
163
- if (error instanceof KitRefusal) throw error;
164
- throw new KitRefusal("google_provider_response_invalid");
165
- }
166
- }
@@ -1,249 +0,0 @@
1
- import { ITEM_BODY_MAX_BYTES } from "../contract.js";
2
- import { KitRefusal } from "../refusal.js";
3
- import { googleJson } from "./gateway.js";
4
- import { GOOGLE_SCOPES } from "./oauth.js";
5
-
6
- const GMAIL_ORIGIN = "https://gmail.googleapis.com";
7
-
8
- function cursorOf(value) {
9
- if (value === null) return { mode: "messages", pageToken: null, historyId: null };
10
- let cursor;
11
- try {
12
- cursor = JSON.parse(value);
13
- } catch {
14
- throw new KitRefusal("google_checkpoint_invalid", "gmail");
15
- }
16
- if (
17
- typeof cursor !== "object" ||
18
- cursor === null ||
19
- (cursor.mode !== "messages" && cursor.mode !== "history") ||
20
- (cursor.pageToken !== null && typeof cursor.pageToken !== "string") ||
21
- (cursor.historyId !== null && typeof cursor.historyId !== "string") ||
22
- (cursor.mode === "history" && cursor.historyId === null)
23
- ) {
24
- throw new KitRefusal("google_checkpoint_invalid", "gmail");
25
- }
26
- return cursor;
27
- }
28
-
29
- function textBody(part) {
30
- if (part?.mimeType === "text/plain" && typeof part.body?.data === "string") {
31
- try {
32
- return Buffer.from(part.body.data, "base64url").toString("utf8");
33
- } catch {
34
- return null;
35
- }
36
- }
37
- if (!Array.isArray(part?.parts)) return null;
38
- for (const child of part.parts) {
39
- const body = textBody(child);
40
- if (body !== null) return body;
41
- }
42
- return null;
43
- }
44
-
45
- function header(message, name) {
46
- if (!Array.isArray(message.payload?.headers)) return null;
47
- const found = message.payload.headers.find(
48
- (entry) => typeof entry?.name === "string" && entry.name.toLowerCase() === name,
49
- );
50
- return typeof found?.value === "string" ? found.value : null;
51
- }
52
-
53
- function normalizedMessage(message) {
54
- if (
55
- typeof message?.id !== "string" ||
56
- typeof message.historyId !== "string" ||
57
- !/^\d+$/.test(message.historyId) ||
58
- typeof message.internalDate !== "string" ||
59
- !/^\d+$/.test(message.internalDate) ||
60
- !Array.isArray(message.labelIds) ||
61
- message.labelIds.some((label) => typeof label !== "string")
62
- ) {
63
- throw new KitRefusal("google_provider_response_invalid", "gmail message");
64
- }
65
- const observedAt = new Date(Number(message.internalDate));
66
- if (Number.isNaN(observedAt.getTime())) {
67
- throw new KitRefusal("google_provider_response_invalid", "gmail internalDate");
68
- }
69
- const author = header(message, "from");
70
- const subject = header(message, "subject");
71
- const plain = textBody(message.payload);
72
- const body = plain ?? (typeof message.snippet === "string" ? message.snippet : "");
73
- const content = subject === null || body.startsWith(subject) ? body : `${subject}\n\n${body}`;
74
- if (Buffer.byteLength(content, "utf8") > ITEM_BODY_MAX_BYTES) {
75
- throw new KitRefusal("google_gmail_content_exceeded");
76
- }
77
- return {
78
- namespace: "google/gmail",
79
- nativeObjectId: message.id,
80
- nativeVersion: message.historyId,
81
- providerModifiedAt: observedAt.toISOString(),
82
- observedAt: observedAt.toISOString(),
83
- body: content,
84
- author,
85
- url: `https://mail.google.com/mail/u/0/#all/${encodeURIComponent(message.id)}`,
86
- attachments: [],
87
- container: `google/gmail/${message.labelIds[0] ?? "mail"}`,
88
- };
89
- }
90
-
91
- async function currentHistoryId(common, dependencies) {
92
- const profile = await googleJson(
93
- { ...common, url: `${GMAIL_ORIGIN}/gmail/v1/users/me/profile` },
94
- dependencies,
95
- );
96
- if (typeof profile.historyId !== "string" || !/^\d+$/.test(profile.historyId)) {
97
- throw new KitRefusal("google_provider_response_invalid", "gmail profile");
98
- }
99
- return profile.historyId;
100
- }
101
-
102
- function listUrl(selection, cursor, limit, backfillFloor) {
103
- const url = new URL(
104
- cursor.mode === "messages"
105
- ? "/gmail/v1/users/me/messages"
106
- : "/gmail/v1/users/me/history",
107
- GMAIL_ORIGIN,
108
- );
109
- url.searchParams.set("maxResults", `${limit}`);
110
- if (cursor.pageToken !== null) url.searchParams.set("pageToken", cursor.pageToken);
111
- if (cursor.mode === "messages") {
112
- for (const label of selection.labels) url.searchParams.append("labelIds", label);
113
- if (backfillFloor !== null && backfillFloor !== undefined) {
114
- url.searchParams.set("q", `after:${Math.floor(Date.parse(backfillFloor) / 1000)}`);
115
- }
116
- } else {
117
- url.searchParams.set("startHistoryId", cursor.historyId);
118
- url.searchParams.append("historyTypes", "messageAdded");
119
- url.searchParams.append("historyTypes", "labelAdded");
120
- }
121
- return url.toString();
122
- }
123
-
124
- function referencesOf(answer, mode) {
125
- if (mode === "messages") {
126
- const messages = answer.messages ?? [];
127
- if (!Array.isArray(messages) || messages.some((entry) => typeof entry?.id !== "string")) {
128
- throw new KitRefusal("google_provider_response_invalid", "gmail messages");
129
- }
130
- return messages.map((entry) => entry.id);
131
- }
132
- const history = answer.history ?? [];
133
- if (!Array.isArray(history)) throw new KitRefusal("google_provider_response_invalid", "gmail history");
134
- const identifiers = [];
135
- for (const entry of history) {
136
- const groups = [entry?.messagesAdded ?? [], entry?.labelsAdded ?? []];
137
- if (groups.some((group) => !Array.isArray(group))) {
138
- throw new KitRefusal("google_provider_response_invalid", "gmail history entry");
139
- }
140
- for (const group of groups) {
141
- for (const added of group) {
142
- if (typeof added?.message?.id !== "string") {
143
- throw new KitRefusal("google_provider_response_invalid", "gmail message id");
144
- }
145
- identifiers.push(added.message.id);
146
- }
147
- }
148
- }
149
- return [...new Set(identifiers)];
150
- }
151
-
152
- export async function gmailPage(options, dependencies = {}) {
153
- let cursor = cursorOf(options.cursor);
154
- const common = {
155
- directory: options.directory,
156
- instance: options.instance,
157
- client: options.client,
158
- requiredScopes: [GOOGLE_SCOPES.gmail],
159
- signal: options.signal,
160
- };
161
- if (cursor.mode === "messages" && cursor.historyId === null) {
162
- cursor = { ...cursor, historyId: await currentHistoryId(common, dependencies) };
163
- }
164
- let answer;
165
- try {
166
- answer = await googleJson(
167
- {
168
- ...common,
169
- url: listUrl(options.selection, cursor, options.limit, options.backfillFloor),
170
- ...(cursor.mode === "history" ? { notFoundCode: "google_history_expired" } : {}),
171
- },
172
- dependencies,
173
- );
174
- } catch (error) {
175
- if (!(error instanceof KitRefusal) || error.code !== "google_history_expired") throw error;
176
- cursor = {
177
- mode: "messages",
178
- pageToken: null,
179
- historyId: await currentHistoryId(common, dependencies),
180
- };
181
- answer = await googleJson(
182
- { ...common, url: listUrl(options.selection, cursor, options.limit, options.backfillFloor) },
183
- dependencies,
184
- );
185
- }
186
- if (
187
- (answer.nextPageToken !== undefined && typeof answer.nextPageToken !== "string") ||
188
- (answer.historyId !== undefined && typeof answer.historyId !== "string")
189
- ) {
190
- throw new KitRefusal("google_provider_response_invalid", "gmail cursor");
191
- }
192
- const identifiers = referencesOf(answer, cursor.mode);
193
- const items = [];
194
- const dropped = [];
195
- for (const identifier of identifiers) {
196
- let detail;
197
- try {
198
- detail = await googleJson(
199
- {
200
- ...common,
201
- notFoundCode: "google_gmail_item_missing",
202
- url: `${GMAIL_ORIGIN}/gmail/v1/users/me/messages/${encodeURIComponent(identifier)}?format=full`,
203
- },
204
- dependencies,
205
- );
206
- const normalized = normalizedMessage(detail);
207
- if (options.selection.labels.every((label) => detail.labelIds.includes(label))) {
208
- items.push(normalized);
209
- }
210
- } catch (error) {
211
- if (
212
- error instanceof KitRefusal &&
213
- ["google_gmail_item_missing", "google_gmail_content_exceeded"].includes(error.code)
214
- ) {
215
- dropped.push({
216
- namespace: "google/gmail",
217
- nativeObjectId: identifier,
218
- nativeVersion: typeof detail?.historyId === "string" ? detail.historyId : null,
219
- code: error.code,
220
- });
221
- } else {
222
- throw error;
223
- }
224
- }
225
- }
226
- let next;
227
- if (cursor.mode === "messages" && answer.nextPageToken !== undefined) {
228
- next = { mode: "messages", pageToken: answer.nextPageToken, historyId: cursor.historyId };
229
- } else if (cursor.mode === "messages") {
230
- next = {
231
- mode: "history",
232
- pageToken: null,
233
- historyId: cursor.historyId,
234
- };
235
- } else if (answer.nextPageToken !== undefined) {
236
- next = { mode: "history", pageToken: answer.nextPageToken, historyId: cursor.historyId };
237
- } else {
238
- if (typeof answer.historyId !== "string" || !/^\d+$/.test(answer.historyId)) {
239
- throw new KitRefusal("google_provider_response_invalid", "gmail history id");
240
- }
241
- next = { mode: "history", pageToken: null, historyId: answer.historyId };
242
- }
243
- return {
244
- items,
245
- dropped,
246
- cursor: JSON.stringify(next),
247
- more: cursor.mode === "messages" || answer.nextPageToken !== undefined,
248
- };
249
- }
package/src/google/id.js DELETED
@@ -1 +0,0 @@
1
- export const GOOGLE_DRIVE_ID = /^[A-Za-z0-9_-]{1,512}$/;
@@ -1,273 +0,0 @@
1
- import { createHash, randomBytes, randomInt } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
- import { KitRefusal } from "../refusal.js";
5
- import { writeNote } from "../state.js";
6
-
7
- export const GOOGLE_SCOPES = Object.freeze({
8
- gmail: "https://www.googleapis.com/auth/gmail.readonly",
9
- calendar: "https://www.googleapis.com/auth/calendar.readonly",
10
- drive: "https://www.googleapis.com/auth/drive.readonly",
11
- });
12
-
13
- const AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
14
- const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
15
- const REFRESH_MARGIN_MS = 60_000;
16
- const LOOPBACK_PORT_FLOOR = 49152;
17
- const LOOPBACK_PORT_SPAN = 16384;
18
-
19
- function credentialPath(directory, instance) {
20
- return join(directory, "state", instance, "credentials", "google.json");
21
- }
22
-
23
- async function rawAuthorization(directory, instance) {
24
- const path = credentialPath(directory, instance);
25
- let document;
26
- try {
27
- document = JSON.parse(await readFile(path, "utf8"));
28
- } catch (error) {
29
- if (error.code === "ENOENT") return null;
30
- throw new KitRefusal("google_authorization_unreadable", instance);
31
- }
32
- if (
33
- typeof document !== "object" ||
34
- document === null ||
35
- !Array.isArray(document.grants) ||
36
- document.grants.some(
37
- (grant) =>
38
- typeof grant !== "object" ||
39
- grant === null ||
40
- typeof grant.access_token !== "string" ||
41
- typeof grant.refresh_token !== "string" ||
42
- typeof grant.expires_at !== "string" ||
43
- Number.isNaN(Date.parse(grant.expires_at)) ||
44
- !Array.isArray(grant.scopes) ||
45
- grant.scopes.length === 0 ||
46
- grant.scopes.some((scope) => typeof scope !== "string"),
47
- )
48
- ) {
49
- throw new KitRefusal("google_authorization_unreadable", instance);
50
- }
51
- return document;
52
- }
53
-
54
- async function writeAuthorization(directory, instance, authorization) {
55
- await writeNote(credentialPath(directory, instance), authorization);
56
- }
57
-
58
- export async function googleAuthorization(directory, instance) {
59
- const authorization = await rawAuthorization(directory, instance);
60
- if (authorization === null || authorization.grants.length === 0) return null;
61
- return {
62
- scopes: [...new Set(authorization.grants.flatMap((grant) => grant.scopes))].sort(),
63
- grants: authorization.grants.map((grant) => ({
64
- accessToken: grant.access_token,
65
- refreshToken: grant.refresh_token,
66
- expiresAt: grant.expires_at,
67
- scopes: [...grant.scopes],
68
- })),
69
- };
70
- }
71
-
72
- function requiredScopesOf(value) {
73
- if (
74
- !Array.isArray(value) ||
75
- value.length === 0 ||
76
- value.some((scope) => typeof scope !== "string" || scope.length === 0)
77
- ) {
78
- throw new KitRefusal("google_scope_requirement_invalid");
79
- }
80
- return value;
81
- }
82
-
83
- export async function revokeGoogleAuthorization(directory, instance, scopes) {
84
- const requiredScopes = requiredScopesOf(scopes);
85
- const authorization = await rawAuthorization(directory, instance);
86
- if (authorization !== null) {
87
- await writeAuthorization(directory, instance, {
88
- grants: authorization.grants
89
- .map((grant) => ({
90
- ...grant,
91
- scopes: grant.scopes.filter((scope) => !requiredScopes.includes(scope)),
92
- }))
93
- .filter((grant) => grant.scopes.length > 0),
94
- });
95
- }
96
- }
97
-
98
- function oauthValue() {
99
- return randomBytes(32).toString("base64url");
100
- }
101
-
102
- function authorizationUrl({ clientId, scopes, redirectUri, verifier, state }) {
103
- const url = new URL(AUTHORIZATION_ENDPOINT);
104
- url.searchParams.set("client_id", clientId);
105
- url.searchParams.set("redirect_uri", redirectUri);
106
- url.searchParams.set("response_type", "code");
107
- url.searchParams.set("scope", scopes.join(" "));
108
- url.searchParams.set("access_type", "offline");
109
- url.searchParams.set("code_challenge", createHash("sha256").update(verifier).digest("base64url"));
110
- url.searchParams.set("code_challenge_method", "S256");
111
- url.searchParams.set("state", state);
112
- url.searchParams.set("prompt", "consent");
113
- return url.toString();
114
- }
115
-
116
- function loopbackRedirect(port) {
117
- return `http://127.0.0.1:${port ?? LOOPBACK_PORT_FLOOR + randomInt(LOOPBACK_PORT_SPAN)}/`;
118
- }
119
-
120
- function approvedCode(address, redirectUri, state) {
121
- const pasted = typeof address === "string" ? address.trim() : "";
122
- if (!URL.canParse(pasted)) throw new KitRefusal("google_authorization_rejected");
123
- const returned = new URL(pasted);
124
- const expected = new URL(redirectUri);
125
- if (
126
- returned.origin !== expected.origin ||
127
- returned.pathname !== expected.pathname ||
128
- returned.searchParams.get("state") !== state
129
- ) {
130
- throw new KitRefusal("google_authorization_rejected");
131
- }
132
- if (returned.searchParams.has("error")) throw new KitRefusal("google_authorization_declined");
133
- const code = returned.searchParams.get("code");
134
- if (code === null || code === "") throw new KitRefusal("google_authorization_rejected");
135
- return code;
136
- }
137
-
138
- async function tokenRequest(fetcher, body) {
139
- let response;
140
- try {
141
- response = await fetcher(TOKEN_ENDPOINT, {
142
- method: "POST",
143
- headers: { "content-type": "application/x-www-form-urlencoded" },
144
- body: new URLSearchParams(body).toString(),
145
- });
146
- } catch {
147
- throw new KitRefusal("google_provider_unavailable");
148
- }
149
- let answer = null;
150
- try {
151
- answer = await response.json();
152
- } catch {
153
- answer = null;
154
- }
155
- if (!response.ok) {
156
- if (answer?.error === "invalid_grant") throw new KitRefusal("google_authority_revoked");
157
- throw new KitRefusal("google_provider_unavailable");
158
- }
159
- if (
160
- typeof answer?.access_token !== "string" ||
161
- !Number.isInteger(answer.expires_in) ||
162
- answer.expires_in < 1 ||
163
- (answer.token_type !== undefined && answer.token_type !== "Bearer")
164
- ) {
165
- throw new KitRefusal("google_provider_response_invalid");
166
- }
167
- return answer;
168
- }
169
-
170
- function configuredClient(client) {
171
- if (
172
- typeof client?.id !== "string" ||
173
- client.id.length === 0 ||
174
- typeof client.secret !== "string" ||
175
- client.secret.length === 0
176
- ) {
177
- throw new KitRefusal("google_oauth_client_unconfigured");
178
- }
179
- return client;
180
- }
181
-
182
- export async function authorizeGoogle(options, dependencies = {}) {
183
- const client = configuredClient(options.client);
184
- const stored = await rawAuthorization(options.directory, options.instance);
185
- const scopes = [...new Set(options.scopes)];
186
- const covered = stored?.grants.some((grant) =>
187
- scopes.every((scope) => grant.scopes.includes(scope)),
188
- );
189
- if (covered === true) return { status: "skipped", requestedScopes: [] };
190
- const verifier = oauthValue();
191
- const state = oauthValue();
192
- const redirectUri = loopbackRedirect(dependencies.loopbackPort);
193
- const address = await options.approve({
194
- url: authorizationUrl({ clientId: client.id, scopes, redirectUri, verifier, state }),
195
- redirectUri,
196
- });
197
- const code = approvedCode(address, redirectUri, state);
198
- const now = dependencies.now?.() ?? new Date();
199
- const token = await tokenRequest(dependencies.fetch ?? fetch, {
200
- client_id: client.id,
201
- client_secret: client.secret,
202
- code,
203
- code_verifier: verifier,
204
- grant_type: "authorization_code",
205
- redirect_uri: redirectUri,
206
- });
207
- const refreshToken = token.refresh_token;
208
- if (typeof refreshToken !== "string" || refreshToken.length === 0) {
209
- throw new KitRefusal("google_authorization_rejected");
210
- }
211
- const returnedScopes =
212
- typeof token.scope === "string" ? token.scope.split(/\s+/).filter(Boolean) : [];
213
- if (returnedScopes.length > 0 && scopes.some((scope) => !returnedScopes.includes(scope))) {
214
- throw new KitRefusal("google_authorization_rejected");
215
- }
216
- const grantedScopes = [...new Set(returnedScopes.length === 0 ? scopes : returnedScopes)].sort();
217
- const key = grantedScopes.join("\n");
218
- const grants = (stored?.grants ?? []).filter((grant) => [...grant.scopes].sort().join("\n") !== key);
219
- await writeAuthorization(options.directory, options.instance, {
220
- grants: [
221
- ...grants,
222
- {
223
- access_token: token.access_token,
224
- refresh_token: refreshToken,
225
- expires_at: new Date(now.getTime() + token.expires_in * 1000).toISOString(),
226
- scopes: grantedScopes,
227
- },
228
- ],
229
- });
230
- return { status: "ready", requestedScopes: scopes };
231
- }
232
-
233
- export async function googleAccessToken(options, dependencies = {}) {
234
- const stored = await rawAuthorization(options.directory, options.instance);
235
- const requiredScopes = requiredScopesOf(options.requiredScopes);
236
- const grant = stored?.grants
237
- .filter((candidate) => requiredScopes.every((scope) => candidate.scopes.includes(scope)))
238
- .sort((left, right) => left.scopes.length - right.scopes.length)[0];
239
- if (grant === undefined) {
240
- throw new KitRefusal("google_authorization_required", options.instance);
241
- }
242
- const now = dependencies.now?.() ?? new Date();
243
- if (options.forceRefresh !== true && Date.parse(grant.expires_at) - now.getTime() > REFRESH_MARGIN_MS) {
244
- return grant.access_token;
245
- }
246
- const client = configuredClient(options.client);
247
- let token;
248
- try {
249
- token = await tokenRequest(dependencies.fetch ?? fetch, {
250
- client_id: client.id,
251
- client_secret: client.secret,
252
- refresh_token: grant.refresh_token,
253
- grant_type: "refresh_token",
254
- });
255
- } catch (error) {
256
- if (error instanceof KitRefusal && error.code === "google_authority_revoked") {
257
- await revokeGoogleAuthorization(options.directory, options.instance, grant.scopes);
258
- }
259
- throw error;
260
- }
261
- await writeAuthorization(options.directory, options.instance, {
262
- grants: stored.grants.map((candidate) =>
263
- candidate === grant
264
- ? {
265
- ...candidate,
266
- access_token: token.access_token,
267
- expires_at: new Date(now.getTime() + token.expires_in * 1000).toISOString(),
268
- }
269
- : candidate,
270
- ),
271
- });
272
- return token.access_token;
273
- }