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.
- package/CHANGELOG.md +11 -0
- package/README.md +112 -0
- package/dist/api-utils.d.ts +6 -0
- package/dist/api-utils.js +52 -0
- package/dist/api-utils.js.map +1 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.js +74 -5
- package/dist/auth.js.map +1 -1
- package/dist/calendar-client.d.ts +135 -0
- package/dist/calendar-client.js +498 -0
- package/dist/calendar-client.js.map +1 -0
- package/dist/calendar-time.d.ts +24 -0
- package/dist/calendar-time.js +245 -0
- package/dist/calendar-time.js.map +1 -0
- package/dist/cli.js +5 -3
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth-cmd.js +5 -5
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.d.ts +2 -0
- package/dist/commands/calendar.js +563 -0
- package/dist/commands/calendar.js.map +1 -0
- package/dist/generated/browser.d.ts +10 -0
- package/dist/generated/client.d.ts +10 -0
- package/dist/generated/internal/class.d.ts +22 -0
- package/dist/generated/internal/class.js +2 -2
- package/dist/generated/internal/class.js.map +1 -1
- package/dist/generated/internal/prismaNamespace.d.ts +174 -1
- package/dist/generated/internal/prismaNamespace.js +21 -0
- package/dist/generated/internal/prismaNamespace.js.map +1 -1
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +23 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +21 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
- package/dist/generated/models/accounts.d.ts +281 -0
- package/dist/generated/models/calendar_events.d.ts +1433 -0
- package/dist/generated/models/calendar_events.js +2 -0
- package/dist/generated/models/calendar_events.js.map +1 -0
- package/dist/generated/models/calendar_lists.d.ts +1131 -0
- package/dist/generated/models/calendar_lists.js +2 -0
- package/dist/generated/models/calendar_lists.js.map +1 -0
- package/dist/generated/models.d.ts +2 -0
- package/dist/gmail-cache.d.ts +22 -0
- package/dist/gmail-cache.js +76 -0
- package/dist/gmail-cache.js.map +1 -1
- package/dist/gmail-client.js +1 -48
- package/dist/gmail-client.js.map +1 -1
- package/dist/output.d.ts +11 -0
- package/dist/output.js +42 -0
- package/dist/output.js.map +1 -1
- package/package.json +4 -2
- package/schema.prisma +39 -6
- package/scripts/test-device-code-clients.ts +186 -0
- package/scripts/test-micropython-scopes.ts +72 -0
- package/scripts/test-oauth-clients.ts +257 -0
- package/src/api-utils.ts +60 -0
- package/src/auth.ts +92 -5
- package/src/calendar-client.ts +758 -0
- package/src/calendar-time.ts +299 -0
- package/src/cli.ts +5 -3
- package/src/commands/auth-cmd.ts +5 -5
- package/src/commands/calendar.ts +634 -0
- package/src/gmail-cache.ts +96 -0
- package/src/gmail-client.ts +1 -57
- package/src/output.ts +51 -0
- 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);
|
package/src/api-utils.ts
ADDED
|
@@ -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
|
+
}
|