zele 0.1.3 → 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 (64) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +112 -0
  3. package/dist/api-utils.d.ts +6 -0
  4. package/dist/api-utils.js +52 -0
  5. package/dist/api-utils.js.map +1 -0
  6. package/dist/auth.d.ts +16 -0
  7. package/dist/auth.js +74 -5
  8. package/dist/auth.js.map +1 -1
  9. package/dist/calendar-client.d.ts +135 -0
  10. package/dist/calendar-client.js +498 -0
  11. package/dist/calendar-client.js.map +1 -0
  12. package/dist/calendar-time.d.ts +24 -0
  13. package/dist/calendar-time.js +245 -0
  14. package/dist/calendar-time.js.map +1 -0
  15. package/dist/cli.js +5 -3
  16. package/dist/cli.js.map +1 -1
  17. package/dist/commands/auth-cmd.js +5 -5
  18. package/dist/commands/auth-cmd.js.map +1 -1
  19. package/dist/commands/calendar.d.ts +2 -0
  20. package/dist/commands/calendar.js +563 -0
  21. package/dist/commands/calendar.js.map +1 -0
  22. package/dist/generated/browser.d.ts +10 -0
  23. package/dist/generated/client.d.ts +10 -0
  24. package/dist/generated/internal/class.d.ts +22 -0
  25. package/dist/generated/internal/class.js +2 -2
  26. package/dist/generated/internal/class.js.map +1 -1
  27. package/dist/generated/internal/prismaNamespace.d.ts +174 -1
  28. package/dist/generated/internal/prismaNamespace.js +21 -0
  29. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  30. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +23 -0
  31. package/dist/generated/internal/prismaNamespaceBrowser.js +21 -0
  32. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  33. package/dist/generated/models/accounts.d.ts +281 -0
  34. package/dist/generated/models/calendar_events.d.ts +1433 -0
  35. package/dist/generated/models/calendar_events.js +2 -0
  36. package/dist/generated/models/calendar_events.js.map +1 -0
  37. package/dist/generated/models/calendar_lists.d.ts +1131 -0
  38. package/dist/generated/models/calendar_lists.js +2 -0
  39. package/dist/generated/models/calendar_lists.js.map +1 -0
  40. package/dist/generated/models.d.ts +2 -0
  41. package/dist/gmail-cache.d.ts +22 -0
  42. package/dist/gmail-cache.js +76 -0
  43. package/dist/gmail-cache.js.map +1 -1
  44. package/dist/gmail-client.js +1 -48
  45. package/dist/gmail-client.js.map +1 -1
  46. package/dist/output.d.ts +11 -0
  47. package/dist/output.js +42 -0
  48. package/dist/output.js.map +1 -1
  49. package/package.json +4 -2
  50. package/schema.prisma +39 -6
  51. package/scripts/test-device-code-clients.ts +186 -0
  52. package/scripts/test-micropython-scopes.ts +72 -0
  53. package/scripts/test-oauth-clients.ts +257 -0
  54. package/src/api-utils.ts +60 -0
  55. package/src/auth.ts +92 -5
  56. package/src/calendar-client.ts +758 -0
  57. package/src/calendar-time.ts +299 -0
  58. package/src/cli.ts +5 -3
  59. package/src/commands/auth-cmd.ts +5 -5
  60. package/src/commands/calendar.ts +634 -0
  61. package/src/gmail-cache.ts +96 -0
  62. package/src/gmail-client.ts +1 -57
  63. package/src/output.ts +51 -0
  64. package/src/schema.sql +22 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Test Google OAuth client IDs for device code flow support.
3
+ * These are candidates that might be registered as "TV/Limited Input" type.
4
+ *
5
+ * Usage: npx tsx scripts/test-device-code-clients.ts
6
+ */
7
+
8
+ interface OAuthClient {
9
+ name: string;
10
+ clientId: string;
11
+ clientSecret: string;
12
+ source: string;
13
+ }
14
+
15
+ const CLIENTS: OAuthClient[] = [
16
+ {
17
+ name: "Smallstep CLI (device authz)",
18
+ clientId:
19
+ "1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com",
20
+ clientSecret: "udTrOT3gzrO7W9fDPgZQLfYJ",
21
+ source: "github.com/smallstep/cli - defaultDeviceAuthzClientID",
22
+ },
23
+ {
24
+ name: "Google Cloud SDK (gcloud)",
25
+ clientId:
26
+ "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
27
+ clientSecret: "d-FL95Q19q7MQmFpd7hHD0Ty",
28
+ source: "google-cloud-sdk (well-known public client)",
29
+ },
30
+ {
31
+ name: "MicroPython OAuth2 example",
32
+ clientId:
33
+ "648445354032-mv5p4b09hcj0116v57pnkmp42fn8m220.apps.googleusercontent.com",
34
+ clientSecret: "",
35
+ source: "github.com/micropython/micropython-lib PR",
36
+ },
37
+ {
38
+ name: "OIDC Bash Client",
39
+ clientId:
40
+ "947227895516-68tp60nti613r42u41bch5vesr5iqpbi.apps.googleusercontent.com",
41
+ clientSecret: "",
42
+ source: "github.com/please-openit/oidc-bash-client",
43
+ },
44
+ // Also re-test existing ones for comparison
45
+ {
46
+ name: "Thunderbird Desktop",
47
+ clientId:
48
+ "406964657835-aq8lmia8j95dhl1a2bvharmfk3t1hgqj.apps.googleusercontent.com",
49
+ clientSecret: "kSmqreRr0qwBWJgbf5Y-PjSU",
50
+ source: "searchfox.org/comm-central",
51
+ },
52
+ {
53
+ name: "GNOME Online Accounts",
54
+ clientId:
55
+ "44438659992-7kgjeitenc16ssihbtdjbgguch7ju55s.apps.googleusercontent.com",
56
+ clientSecret: "-gMLuQyDiI0XrQS_vx_mhuYF",
57
+ source: "github.com/GNOME/gnome-online-accounts",
58
+ },
59
+ ];
60
+
61
+ // Scopes we care about
62
+ const SCOPES_TO_TEST: Record<string, string> = {
63
+ "Gmail (full)": "https://mail.google.com/",
64
+ Calendar: "https://www.googleapis.com/auth/calendar",
65
+ "Calendar (readonly)": "https://www.googleapis.com/auth/calendar.readonly",
66
+ Contacts: "https://www.googleapis.com/auth/carddav",
67
+ Drive: "https://www.googleapis.com/auth/drive",
68
+ Tasks: "https://www.googleapis.com/auth/tasks",
69
+ "UserInfo (email)": "https://www.googleapis.com/auth/userinfo.email",
70
+ openid: "openid",
71
+ };
72
+
73
+ const DEVICE_CODE_ENDPOINT = "https://oauth2.googleapis.com/device/code";
74
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
75
+
76
+ async function testDeviceCode(
77
+ client: OAuthClient,
78
+ scope: string
79
+ ): Promise<{ ok: boolean; data: any }> {
80
+ const res = await fetch(DEVICE_CODE_ENDPOINT, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
83
+ body: new URLSearchParams({
84
+ client_id: client.clientId,
85
+ scope,
86
+ }),
87
+ });
88
+ return { ok: res.ok, data: await res.json() };
89
+ }
90
+
91
+ async function testScopeViaRefresh(
92
+ client: OAuthClient,
93
+ scopeValue: string
94
+ ): Promise<{ enabled: boolean; error?: string }> {
95
+ const res = await fetch(TOKEN_ENDPOINT, {
96
+ method: "POST",
97
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
98
+ body: new URLSearchParams({
99
+ client_id: client.clientId,
100
+ client_secret: client.clientSecret,
101
+ refresh_token: "1//dummy_refresh_token_probe",
102
+ grant_type: "refresh_token",
103
+ scope: scopeValue,
104
+ }),
105
+ });
106
+ const data = await res.json();
107
+ const err = data.error || "";
108
+ if (err === "invalid_grant") return { enabled: true };
109
+ if (err === "invalid_scope")
110
+ return { enabled: false, error: data.error_description };
111
+ // invalid_client could mean bad secret, but scope might still work via device code
112
+ return { enabled: true, error: `${err}: ${data.error_description || ""}` };
113
+ }
114
+
115
+ async function main() {
116
+ console.log("=".repeat(80));
117
+ console.log("Device Code Flow + Scope Capability Tester");
118
+ console.log("=".repeat(80));
119
+
120
+ for (const client of CLIENTS) {
121
+ console.log(`\n${"━".repeat(80)}`);
122
+ console.log(`CLIENT: ${client.name}`);
123
+ console.log(`ID: ${client.clientId}`);
124
+ console.log(`Source: ${client.source}`);
125
+ console.log(`${"━".repeat(80)}`);
126
+
127
+ // 1. Test device code flow with a basic scope
128
+ console.log("\n[1] Device Code Flow Test (scope: openid email)");
129
+ const dc = await testDeviceCode(client, "openid email");
130
+ if (dc.ok && dc.data.device_code) {
131
+ console.log(` ✅ SUPPORTED`);
132
+ console.log(` verification_url: ${dc.data.verification_url}`);
133
+ console.log(` user_code: ${dc.data.user_code}`);
134
+ console.log(` expires_in: ${dc.data.expires_in}s`);
135
+ console.log(` interval: ${dc.data.interval}s`);
136
+ } else {
137
+ console.log(
138
+ ` ❌ NOT SUPPORTED: ${dc.data.error} - ${dc.data.error_description || ""}`
139
+ );
140
+ }
141
+
142
+ // 2. If device code works, test it with each scope
143
+ if (dc.ok && dc.data.device_code) {
144
+ console.log("\n[2] Per-Scope Device Code Test");
145
+ console.log(
146
+ ` ${"Scope".padEnd(25)} ${"Device Code?".padEnd(14)} Notes`
147
+ );
148
+ console.log(` ${"─".repeat(65)}`);
149
+
150
+ for (const [scopeName, scopeValue] of Object.entries(SCOPES_TO_TEST)) {
151
+ const r = await testDeviceCode(client, scopeValue);
152
+ if (r.ok && r.data.device_code) {
153
+ console.log(` ${scopeName.padEnd(25)} ✅ YES`);
154
+ } else {
155
+ const err = r.data.error || "unknown";
156
+ const desc = r.data.error_description || "";
157
+ console.log(
158
+ ` ${scopeName.padEnd(25)} ❌ NO ${err}: ${desc}`
159
+ );
160
+ }
161
+ await new Promise((r) => setTimeout(r, 200));
162
+ }
163
+ } else {
164
+ // Fallback: test scopes via token exchange
165
+ console.log("\n[2] Scope Enablement (via refresh token probe)");
166
+ console.log(
167
+ ` ${"Scope".padEnd(25)} ${"Enabled?".padEnd(12)} Notes`
168
+ );
169
+ console.log(` ${"─".repeat(65)}`);
170
+
171
+ for (const [scopeName, scopeValue] of Object.entries(SCOPES_TO_TEST)) {
172
+ const r = await testScopeViaRefresh(client, scopeValue);
173
+ const icon = r.enabled ? "✅ YES" : "❌ NO";
174
+ console.log(
175
+ ` ${scopeName.padEnd(25)} ${icon.padEnd(12)} ${r.error || ""}`
176
+ );
177
+ await new Promise((r) => setTimeout(r, 200));
178
+ }
179
+ }
180
+ }
181
+
182
+ console.log(`\n${"=".repeat(80)}`);
183
+ console.log("Done!");
184
+ }
185
+
186
+ main().catch(console.error);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Exhaustive scope test for the MicroPython device code client.
3
+ * Usage: npx tsx scripts/test-micropython-scopes.ts
4
+ */
5
+
6
+ const DEVICE_CODE_ENDPOINT = "https://oauth2.googleapis.com/device/code";
7
+ const clientId =
8
+ "648445354032-mv5p4b09hcj0116v57pnkmp42fn8m220.apps.googleusercontent.com";
9
+
10
+ const scopes: [string, string][] = [
11
+ ["Gmail full", "https://mail.google.com/"],
12
+ ["Gmail readonly", "https://www.googleapis.com/auth/gmail.readonly"],
13
+ ["Gmail send", "https://www.googleapis.com/auth/gmail.send"],
14
+ ["Gmail modify", "https://www.googleapis.com/auth/gmail.modify"],
15
+ ["Gmail labels", "https://www.googleapis.com/auth/gmail.labels"],
16
+ ["Calendar full", "https://www.googleapis.com/auth/calendar"],
17
+ ["Calendar readonly", "https://www.googleapis.com/auth/calendar.readonly"],
18
+ ["Calendar events", "https://www.googleapis.com/auth/calendar.events"],
19
+ [
20
+ "Calendar + email combo",
21
+ "https://www.googleapis.com/auth/calendar openid email",
22
+ ],
23
+ ["Tasks", "https://www.googleapis.com/auth/tasks"],
24
+ ["Tasks readonly", "https://www.googleapis.com/auth/tasks.readonly"],
25
+ ["Drive", "https://www.googleapis.com/auth/drive"],
26
+ ["Drive file", "https://www.googleapis.com/auth/drive.file"],
27
+ ["Drive readonly", "https://www.googleapis.com/auth/drive.readonly"],
28
+ ["People/Contacts", "https://www.googleapis.com/auth/contacts.readonly"],
29
+ ["CardDAV", "https://www.googleapis.com/auth/carddav"],
30
+ ["UserInfo email", "https://www.googleapis.com/auth/userinfo.email"],
31
+ ["UserInfo profile", "https://www.googleapis.com/auth/userinfo.profile"],
32
+ ["YouTube readonly", "https://www.googleapis.com/auth/youtube.readonly"],
33
+ [
34
+ "Photos readonly",
35
+ "https://www.googleapis.com/auth/photoslibrary.readonly",
36
+ ],
37
+ ["Keep", "https://www.googleapis.com/auth/keep"],
38
+ ["openid", "openid"],
39
+ ["email", "email"],
40
+ ["profile", "profile"],
41
+ [
42
+ "ALL: cal+email+openid",
43
+ "https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/userinfo.email openid",
44
+ ],
45
+ ];
46
+
47
+ async function test(name: string, scope: string) {
48
+ const res = await fetch(DEVICE_CODE_ENDPOINT, {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
51
+ body: new URLSearchParams({ client_id: clientId, scope }),
52
+ });
53
+ const data = await res.json();
54
+ if (res.ok && data.device_code) {
55
+ console.log(` ✅ ${name.padEnd(28)}`);
56
+ } else {
57
+ console.log(
58
+ ` ❌ ${name.padEnd(28)} ${data.error_description || data.error}`
59
+ );
60
+ }
61
+ await new Promise((r) => setTimeout(r, 250));
62
+ }
63
+
64
+ async function main() {
65
+ console.log("MicroPython client - Device code flow scope test");
66
+ console.log("=".repeat(70));
67
+ for (const [name, scope] of scopes) {
68
+ await test(name, scope);
69
+ }
70
+ }
71
+
72
+ main().catch(console.error);
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Test Google OAuth client IDs from open-source apps.
3
+ * Probes scope/API enablement using token exchange with dummy codes.
4
+ * Also tests device code flow and redirect URI support.
5
+ *
6
+ * Key insight: When sending a token exchange request with an invalid auth code,
7
+ * Google checks client_id, redirect_uri, and scope BEFORE rejecting the code.
8
+ * - "invalid_grant" = client + redirect + scope all valid, code is just wrong
9
+ * - "redirect_uri_mismatch" = client valid, redirect URI not registered
10
+ * - "invalid_scope" or API-specific errors = scope/API not enabled for this project
11
+ * - "invalid_client" = client ID or secret is wrong
12
+ *
13
+ * Usage: npx tsx scripts/test-oauth-clients.ts
14
+ */
15
+
16
+ interface OAuthClient {
17
+ name: string;
18
+ clientId: string;
19
+ clientSecret: string;
20
+ source: string;
21
+ }
22
+
23
+ const CLIENTS: OAuthClient[] = [
24
+ {
25
+ name: "Thunderbird Desktop",
26
+ clientId:
27
+ "406964657835-aq8lmia8j95dhl1a2bvharmfk3t1hgqj.apps.googleusercontent.com",
28
+ clientSecret: "kSmqreRr0qwBWJgbf5Y-PjSU",
29
+ source: "searchfox.org/comm-central OAuth2Providers.sys.mjs",
30
+ },
31
+ {
32
+ name: "GNOME Online Accounts",
33
+ clientId:
34
+ "44438659992-7kgjeitenc16ssihbtdjbgguch7ju55s.apps.googleusercontent.com",
35
+ clientSecret: "-gMLuQyDiI0XrQS_vx_mhuYF",
36
+ source: "github.com/GNOME/gnome-online-accounts meson_options.txt",
37
+ },
38
+ {
39
+ name: "KDE KAccounts",
40
+ clientId:
41
+ "317066460457-pkpkedrvt2ldq6g2hj1egfka2n7vpuoo.apps.googleusercontent.com",
42
+ clientSecret: "Y8eFAaWfcanV3amZdDvtbYUq",
43
+ source: "github.com/KDE/kaccounts-providers google.provider.in",
44
+ },
45
+ ];
46
+
47
+ // All interesting Google API scopes to test
48
+ const SCOPES_TO_TEST: Record<string, string> = {
49
+ "Gmail (full)": "https://mail.google.com/",
50
+ "Gmail (readonly)": "https://www.googleapis.com/auth/gmail.readonly",
51
+ "Gmail (send)": "https://www.googleapis.com/auth/gmail.send",
52
+ "Calendar": "https://www.googleapis.com/auth/calendar",
53
+ "Calendar (readonly)": "https://www.googleapis.com/auth/calendar.readonly",
54
+ "Calendar (events)": "https://www.googleapis.com/auth/calendar.events",
55
+ "Contacts (CardDAV)": "https://www.googleapis.com/auth/carddav",
56
+ "People API": "https://www.googleapis.com/auth/contacts.readonly",
57
+ "Drive": "https://www.googleapis.com/auth/drive",
58
+ "Drive (readonly)": "https://www.googleapis.com/auth/drive.readonly",
59
+ "Drive (file)": "https://www.googleapis.com/auth/drive.file",
60
+ "Tasks": "https://www.googleapis.com/auth/tasks",
61
+ "Tasks (readonly)": "https://www.googleapis.com/auth/tasks.readonly",
62
+ "UserInfo (email)": "https://www.googleapis.com/auth/userinfo.email",
63
+ "UserInfo (profile)": "https://www.googleapis.com/auth/userinfo.profile",
64
+ "Keep": "https://www.googleapis.com/auth/keep",
65
+ "YouTube (readonly)": "https://www.googleapis.com/auth/youtube.readonly",
66
+ "Photos (readonly)": "https://www.googleapis.com/auth/photoslibrary.readonly",
67
+ openid: "openid",
68
+ };
69
+
70
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
71
+ const DEVICE_CODE_ENDPOINT = "https://oauth2.googleapis.com/device/code";
72
+
73
+ // Redirect URIs to test (determines what auth flows the client supports)
74
+ const REDIRECT_URIS = [
75
+ "http://localhost",
76
+ "http://localhost:8080",
77
+ "http://127.0.0.1",
78
+ "urn:ietf:wg:oauth:2.0:oob",
79
+ ];
80
+
81
+ // ─── Test redirect URI support ───────────────────────────────────────────────
82
+ async function testRedirectUri(
83
+ client: OAuthClient,
84
+ redirectUri: string
85
+ ): Promise<{ uri: string; supported: boolean; error?: string }> {
86
+ try {
87
+ const res = await fetch(TOKEN_ENDPOINT, {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
90
+ body: new URLSearchParams({
91
+ client_id: client.clientId,
92
+ client_secret: client.clientSecret,
93
+ code: "4/dummy_probe_code_not_real",
94
+ grant_type: "authorization_code",
95
+ redirect_uri: redirectUri,
96
+ }),
97
+ });
98
+ const data = await res.json();
99
+ const err = data.error || "";
100
+ const desc = data.error_description || "";
101
+
102
+ // "invalid_grant" = redirect URI is accepted, code is just wrong
103
+ if (err === "invalid_grant") {
104
+ return { uri: redirectUri, supported: true };
105
+ }
106
+ // "redirect_uri_mismatch" = this redirect URI is not registered
107
+ if (err === "redirect_uri_mismatch") {
108
+ return { uri: redirectUri, supported: false, error: "not registered" };
109
+ }
110
+ return { uri: redirectUri, supported: false, error: `${err}: ${desc}` };
111
+ } catch (err: any) {
112
+ return { uri: redirectUri, supported: false, error: err.message };
113
+ }
114
+ }
115
+
116
+ // ─── Test scope via token exchange ───────────────────────────────────────────
117
+ // Uses a known-good redirect URI, sends a dummy code with a specific scope.
118
+ // Google's error response tells us whether the scope is valid for this project.
119
+ async function testScope(
120
+ client: OAuthClient,
121
+ redirectUri: string,
122
+ scopeName: string,
123
+ scopeValue: string
124
+ ): Promise<{ scopeName: string; enabled: boolean; error?: string; raw?: string }> {
125
+ try {
126
+ // Note: The token endpoint for authorization_code grant doesn't actually
127
+ // validate scope - it's validated at the authorization endpoint.
128
+ // So we need a different approach: try the authorization URL construction
129
+ // and use the device code endpoint as a secondary check.
130
+ //
131
+ // Actually, the most reliable probe is:
132
+ // 1. Use the token endpoint with a dummy refresh_token + scope
133
+ // Google checks if the scope's API is enabled before attempting refresh
134
+ const res = await fetch(TOKEN_ENDPOINT, {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
137
+ body: new URLSearchParams({
138
+ client_id: client.clientId,
139
+ client_secret: client.clientSecret,
140
+ refresh_token: "1//dummy_refresh_token_for_scope_probe",
141
+ grant_type: "refresh_token",
142
+ scope: scopeValue,
143
+ }),
144
+ });
145
+ const data = await res.json();
146
+ const err = data.error || "";
147
+ const desc = data.error_description || "";
148
+
149
+ // "invalid_grant" = token is bad but scope + client combo is valid
150
+ if (err === "invalid_grant") {
151
+ return { scopeName, enabled: true, raw: desc };
152
+ }
153
+ // "invalid_scope" = API not enabled or scope not allowed
154
+ if (err === "invalid_scope") {
155
+ return { scopeName, enabled: false, error: desc || "not enabled", raw: desc };
156
+ }
157
+ // "unauthorized_client" = client not authorized for this flow/scope
158
+ if (err === "unauthorized_client") {
159
+ return { scopeName, enabled: false, error: desc, raw: desc };
160
+ }
161
+ // "invalid_client" = something wrong with credentials
162
+ if (err === "invalid_client") {
163
+ return { scopeName, enabled: false, error: "invalid client credentials", raw: desc };
164
+ }
165
+ // Any other error - report it
166
+ return { scopeName, enabled: false, error: `${err}: ${desc}`, raw: desc };
167
+ } catch (err: any) {
168
+ return { scopeName, enabled: false, error: err.message };
169
+ }
170
+ }
171
+
172
+ // ─── Test device code flow ───────────────────────────────────────────────────
173
+ async function testDeviceCode(
174
+ client: OAuthClient,
175
+ scope: string
176
+ ): Promise<{ supported: boolean; error?: string; data?: any }> {
177
+ try {
178
+ const res = await fetch(DEVICE_CODE_ENDPOINT, {
179
+ method: "POST",
180
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
181
+ body: new URLSearchParams({
182
+ client_id: client.clientId,
183
+ scope,
184
+ }),
185
+ });
186
+ const data = await res.json();
187
+ if (res.ok && data.device_code) {
188
+ return { supported: true, data };
189
+ }
190
+ return {
191
+ supported: false,
192
+ error: `${data.error}: ${data.error_description || ""}`,
193
+ };
194
+ } catch (err: any) {
195
+ return { supported: false, error: err.message };
196
+ }
197
+ }
198
+
199
+ // ─── Main ────────────────────────────────────────────────────────────────────
200
+ async function main() {
201
+ console.log("=".repeat(80));
202
+ console.log("Google OAuth Client Capability Tester");
203
+ console.log("=".repeat(80));
204
+
205
+ for (const client of CLIENTS) {
206
+ console.log(`\n${"━".repeat(80)}`);
207
+ console.log(`CLIENT: ${client.name}`);
208
+ console.log(`ID: ${client.clientId}`);
209
+ console.log(`Source: ${client.source}`);
210
+ console.log(`${"━".repeat(80)}`);
211
+
212
+ // 1. Test device code flow
213
+ console.log("\n[1] Device Code Flow (headless/remote login)");
214
+ const dc = await testDeviceCode(client, "openid email");
215
+ if (dc.supported) {
216
+ console.log(` ✅ SUPPORTED`);
217
+ console.log(` verification_url: ${dc.data.verification_url}`);
218
+ } else {
219
+ console.log(` ❌ NOT SUPPORTED: ${dc.error}`);
220
+ }
221
+
222
+ // 2. Test redirect URIs
223
+ console.log("\n[2] Redirect URI Support");
224
+ let validRedirectUri = "http://localhost"; // fallback
225
+ for (const uri of REDIRECT_URIS) {
226
+ const r = await testRedirectUri(client, uri);
227
+ const icon = r.supported ? "✅" : "❌";
228
+ console.log(` ${icon} ${uri}${r.error ? ` (${r.error})` : ""}`);
229
+ if (r.supported && validRedirectUri === "http://localhost") {
230
+ validRedirectUri = uri;
231
+ }
232
+ await new Promise((r) => setTimeout(r, 150));
233
+ }
234
+
235
+ // 3. Test all scopes
236
+ console.log("\n[3] API Scope Enablement");
237
+ console.log(
238
+ ` ${"Scope".padEnd(25)} ${"Status".padEnd(12)} Details`
239
+ );
240
+ console.log(` ${"─".repeat(70)}`);
241
+
242
+ for (const [scopeName, scopeValue] of Object.entries(SCOPES_TO_TEST)) {
243
+ const r = await testScope(client, validRedirectUri, scopeName, scopeValue);
244
+ const icon = r.enabled ? "✅ ENABLED" : "❌ DISABLED";
245
+ const detail = r.error || "";
246
+ console.log(
247
+ ` ${scopeName.padEnd(25)} ${icon.padEnd(12)} ${detail}`
248
+ );
249
+ await new Promise((r) => setTimeout(r, 150));
250
+ }
251
+ }
252
+
253
+ console.log(`\n${"=".repeat(80)}`);
254
+ console.log("Done!");
255
+ }
256
+
257
+ main().catch(console.error);
@@ -0,0 +1,60 @@
1
+ // Shared API utilities for Gmail and Calendar clients.
2
+ // Retry logic for rate limit errors and bounded concurrency helper.
3
+ // Extracted from gmail-client.ts to be reused across API clients.
4
+
5
+ const MAX_CONCURRENCY = 10
6
+
7
+ /** Run promises with bounded concurrency */
8
+ export async function mapConcurrent<T, R>(
9
+ items: T[],
10
+ fn: (item: T) => Promise<R>,
11
+ concurrency = MAX_CONCURRENCY,
12
+ ): Promise<R[]> {
13
+ const results: R[] = []
14
+ let index = 0
15
+
16
+ async function worker() {
17
+ while (index < items.length) {
18
+ const i = index++
19
+ results[i] = await fn(items[i]!)
20
+ }
21
+ }
22
+
23
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
24
+ await Promise.all(workers)
25
+ return results
26
+ }
27
+
28
+ /** Simple retry for rate limit errors (429 and 403 quota errors).
29
+ * Matches Zero's gmail-rate-limit.ts schedule: up to 10 attempts, 60s base delay. */
30
+ export async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 10, delayMs = 60000): Promise<T> {
31
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
32
+ try {
33
+ return await fn()
34
+ } catch (err: any) {
35
+ if (!isRateLimitError(err) || attempt === maxAttempts) throw err
36
+ const wait = delayMs * Math.pow(2, attempt - 1)
37
+ await new Promise((r) => setTimeout(r, wait))
38
+ }
39
+ }
40
+ throw new Error('unreachable')
41
+ }
42
+
43
+ export function isRateLimitError(err: any): boolean {
44
+ const status = err?.code ?? err?.status ?? err?.response?.status
45
+ if (status === 429) return true
46
+ if (status === 403) {
47
+ const errors = err?.errors ?? err?.response?.data?.error?.errors ?? []
48
+ return errors.some((e: any) =>
49
+ [
50
+ 'userRateLimitExceeded',
51
+ 'rateLimitExceeded',
52
+ 'quotaExceeded',
53
+ 'dailyLimitExceeded',
54
+ 'limitExceeded',
55
+ 'backendError',
56
+ ].includes(e.reason),
57
+ )
58
+ }
59
+ return false
60
+ }