@sentry/junior-google 0.0.0-stage → 0.238.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/dist/index.js ADDED
@@ -0,0 +1,1460 @@
1
+ // src/index.ts
2
+ import {
3
+ defineJuniorPlugin
4
+ } from "@sentry/junior-plugin-api";
5
+
6
+ // src/cli.ts
7
+ import { createServer } from "http";
8
+ import { userInfo } from "os";
9
+ import { InvalidArgumentError } from "commander";
10
+
11
+ // src/config.ts
12
+ var GOOGLE_CLIENT_ID_ENV = "GOOGLE_WORKSPACE_CLIENT_ID";
13
+ var GOOGLE_CLIENT_SECRET_ENV = "GOOGLE_WORKSPACE_CLIENT_SECRET";
14
+ var GOOGLE_ACCOUNT_EMAIL_ENV = "GOOGLE_WORKSPACE_ACCOUNT_EMAIL";
15
+ var GOOGLE_ALLOWED_DOMAINS_ENV = "GOOGLE_WORKSPACE_ALLOWED_DOMAINS";
16
+ var GOOGLE_CALENDAR_SCOPES = [
17
+ "https://www.googleapis.com/auth/calendar.events.freebusy",
18
+ "https://www.googleapis.com/auth/calendar.events.owned",
19
+ "https://www.googleapis.com/auth/calendar.events.readonly"
20
+ ];
21
+ var GOOGLE_IDENTITY_SCOPES = ["openid", "email"];
22
+ function readEnv(name) {
23
+ const value = process.env[name]?.trim();
24
+ return value ? value : void 0;
25
+ }
26
+ function readList(name) {
27
+ return (readEnv(name) ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean);
28
+ }
29
+ function emailDomain(email) {
30
+ const at = email.lastIndexOf("@");
31
+ if (at <= 0 || at === email.length - 1) {
32
+ return void 0;
33
+ }
34
+ return email.slice(at + 1).toLowerCase();
35
+ }
36
+ function readGoogleConfig() {
37
+ const clientId = readEnv(GOOGLE_CLIENT_ID_ENV);
38
+ const clientSecret = readEnv(GOOGLE_CLIENT_SECRET_ENV);
39
+ const accountEmail = readEnv(GOOGLE_ACCOUNT_EMAIL_ENV)?.toLowerCase();
40
+ if (!clientId || !clientSecret || !accountEmail) {
41
+ return void 0;
42
+ }
43
+ const accountDomain = emailDomain(accountEmail);
44
+ if (!accountDomain) {
45
+ throw new Error(`${GOOGLE_ACCOUNT_EMAIL_ENV} must be an email address`);
46
+ }
47
+ const allowedDomains = readList(GOOGLE_ALLOWED_DOMAINS_ENV);
48
+ return {
49
+ accountEmail,
50
+ allowedDomains: allowedDomains.length ? allowedDomains : [accountDomain],
51
+ clientId,
52
+ clientSecret
53
+ };
54
+ }
55
+
56
+ // src/oauth.ts
57
+ import { createHash, randomBytes } from "crypto";
58
+ import { z } from "zod";
59
+
60
+ // src/store.ts
61
+ import { and, eq } from "drizzle-orm";
62
+
63
+ // src/db/schema.ts
64
+ import { bigint, pgTable, text } from "drizzle-orm/pg-core";
65
+ var juniorGoogleAccounts = pgTable("junior_google_accounts", {
66
+ accountEmail: text("account_email").primaryKey(),
67
+ refreshToken: text("refresh_token").notNull(),
68
+ scope: text("scope").notNull(),
69
+ connectedBy: text("connected_by").notNull(),
70
+ connectedAtMs: bigint("connected_at_ms", { mode: "number" }).notNull()
71
+ });
72
+
73
+ // src/store.ts
74
+ async function getGoogleAccount(db, accountEmail) {
75
+ const [row] = await db.select().from(juniorGoogleAccounts).where(eq(juniorGoogleAccounts.accountEmail, accountEmail)).limit(1);
76
+ return row;
77
+ }
78
+ async function saveGoogleAccount(db, record) {
79
+ await db.insert(juniorGoogleAccounts).values(record).onConflictDoUpdate({
80
+ target: juniorGoogleAccounts.accountEmail,
81
+ set: {
82
+ refreshToken: record.refreshToken,
83
+ scope: record.scope,
84
+ connectedBy: record.connectedBy,
85
+ connectedAtMs: record.connectedAtMs
86
+ }
87
+ });
88
+ }
89
+ async function deleteRejectedGoogleAccount(db, record) {
90
+ await db.delete(juniorGoogleAccounts).where(
91
+ and(
92
+ eq(juniorGoogleAccounts.accountEmail, record.accountEmail),
93
+ eq(juniorGoogleAccounts.refreshToken, record.refreshToken)
94
+ )
95
+ );
96
+ }
97
+ function googleAccountStatus(record) {
98
+ return {
99
+ accountEmail: record.accountEmail,
100
+ connectedAt: new Date(record.connectedAtMs).toISOString(),
101
+ connectedBy: record.connectedBy,
102
+ scope: record.scope
103
+ };
104
+ }
105
+
106
+ // src/oauth.ts
107
+ var AUTHORIZE_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
108
+ var TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
109
+ var REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke";
110
+ var GOOGLE_ISSUERS = /* @__PURE__ */ new Set([
111
+ "accounts.google.com",
112
+ "https://accounts.google.com"
113
+ ]);
114
+ var GoogleConnectError = class extends Error {
115
+ name = "GoogleConnectError";
116
+ };
117
+ var tokenResponseSchema = z.object({
118
+ access_token: z.string().min(1),
119
+ expires_in: z.number().positive(),
120
+ id_token: z.string().min(1).optional(),
121
+ refresh_token: z.string().min(1).optional(),
122
+ scope: z.string().optional()
123
+ });
124
+ var tokenErrorSchema = z.object({ error: z.string() });
125
+ var idTokenClaimsSchema = z.object({
126
+ aud: z.string(),
127
+ email: z.string(),
128
+ email_verified: z.boolean().optional(),
129
+ iss: z.string()
130
+ });
131
+ function createGoogleSignInRequest() {
132
+ return {
133
+ codeVerifier: randomBytes(32).toString("base64url"),
134
+ state: randomBytes(24).toString("base64url")
135
+ };
136
+ }
137
+ function googleAuthorizationUrl(input) {
138
+ const url = new URL(AUTHORIZE_ENDPOINT);
139
+ const challenge = createHash("sha256").update(input.request.codeVerifier).digest("base64url");
140
+ url.search = new URLSearchParams({
141
+ access_type: "offline",
142
+ client_id: input.config.clientId,
143
+ code_challenge: challenge,
144
+ code_challenge_method: "S256",
145
+ login_hint: input.config.accountEmail,
146
+ // Always ask for consent so Google returns a refresh token.
147
+ prompt: "consent select_account",
148
+ redirect_uri: input.redirectUri,
149
+ response_type: "code",
150
+ scope: [...GOOGLE_IDENTITY_SCOPES, ...GOOGLE_CALENDAR_SCOPES].join(" "),
151
+ state: input.request.state
152
+ }).toString();
153
+ return url.toString();
154
+ }
155
+ async function postTokenEndpoint(body) {
156
+ const response = await fetch(TOKEN_ENDPOINT, {
157
+ body: new URLSearchParams(body),
158
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
159
+ method: "POST"
160
+ });
161
+ const parsed = await response.json().catch(() => void 0);
162
+ if (response.ok) {
163
+ return { ok: true, value: parsed };
164
+ }
165
+ const error = tokenErrorSchema.safeParse(parsed);
166
+ return {
167
+ ok: false,
168
+ error: error.success ? error.data.error : `http_${response.status}`
169
+ };
170
+ }
171
+ function decodeIdTokenClaims(idToken) {
172
+ try {
173
+ const payload = idToken.split(".")[1] ?? "";
174
+ return idTokenClaimsSchema.parse(
175
+ JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
176
+ );
177
+ } catch {
178
+ return void 0;
179
+ }
180
+ }
181
+ function grantedScopes(tokens) {
182
+ return (tokens.scope ?? "").split(/\s+/).filter(Boolean).sort();
183
+ }
184
+ function verifyGrant(tokens, config) {
185
+ const claims = tokens.id_token ? decodeIdTokenClaims(tokens.id_token) : void 0;
186
+ if (!claims || !GOOGLE_ISSUERS.has(claims.iss) || claims.aud !== config.clientId || claims.email_verified !== true) {
187
+ return { reason: "Google did not return a valid account identity." };
188
+ }
189
+ const email = claims.email.toLowerCase();
190
+ if (email !== config.accountEmail) {
191
+ return {
192
+ reason: `You signed in as ${email}. Sign in as ${config.accountEmail} instead.`
193
+ };
194
+ }
195
+ const granted = new Set(grantedScopes(tokens));
196
+ const missing = GOOGLE_CALENDAR_SCOPES.filter((scope) => !granted.has(scope));
197
+ if (missing.length > 0) {
198
+ return {
199
+ reason: `Google did not grant every Calendar permission. Start again and allow all requested access. Missing: ${missing.join(", ")}`
200
+ };
201
+ }
202
+ if (!tokens.refresh_token) {
203
+ return { reason: "Google did not return a refresh token. Start again." };
204
+ }
205
+ return { refreshToken: tokens.refresh_token };
206
+ }
207
+ async function connectGoogleAccount(input) {
208
+ const result = await postTokenEndpoint({
209
+ client_id: input.config.clientId,
210
+ client_secret: input.config.clientSecret,
211
+ code: input.code,
212
+ code_verifier: input.codeVerifier,
213
+ grant_type: "authorization_code",
214
+ redirect_uri: input.redirectUri
215
+ });
216
+ if (!result.ok) {
217
+ throw new GoogleConnectError(
218
+ `Google rejected the sign-in (${result.error}). Start again.`
219
+ );
220
+ }
221
+ const tokens = tokenResponseSchema.parse(result.value);
222
+ const grant = verifyGrant(tokens, input.config);
223
+ if ("reason" in grant) {
224
+ await fetch(REVOKE_ENDPOINT, {
225
+ body: new URLSearchParams({
226
+ token: tokens.refresh_token ?? tokens.access_token
227
+ }),
228
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
229
+ method: "POST"
230
+ }).catch(() => void 0);
231
+ throw new GoogleConnectError(grant.reason);
232
+ }
233
+ const scope = grantedScopes(tokens).join(" ");
234
+ await saveGoogleAccount(input.db, {
235
+ accountEmail: input.config.accountEmail,
236
+ connectedAtMs: Date.now(),
237
+ connectedBy: input.connectedBy,
238
+ refreshToken: grant.refreshToken,
239
+ scope
240
+ });
241
+ return { accountEmail: input.config.accountEmail, scope };
242
+ }
243
+ async function refreshGoogleAccessToken(input) {
244
+ const result = await postTokenEndpoint({
245
+ client_id: input.config.clientId,
246
+ client_secret: input.config.clientSecret,
247
+ grant_type: "refresh_token",
248
+ refresh_token: input.refreshToken
249
+ });
250
+ if (!result.ok) {
251
+ if (result.error === "invalid_grant") {
252
+ return void 0;
253
+ }
254
+ throw new Error(`Google token refresh failed (${result.error})`);
255
+ }
256
+ const tokens = tokenResponseSchema.parse(result.value);
257
+ return {
258
+ accessToken: tokens.access_token,
259
+ expiresAtMs: Date.now() + tokens.expires_in * 1e3
260
+ };
261
+ }
262
+
263
+ // src/cli.ts
264
+ var DEFAULT_LOOPBACK_PORT = 8765;
265
+ var SIGN_IN_TIMEOUT_MS = 5 * 60 * 1e3;
266
+ function parsePort(value) {
267
+ const port = Number(value);
268
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
269
+ throw new InvalidArgumentError("--port must be between 1024 and 65535");
270
+ }
271
+ return port;
272
+ }
273
+ async function requireConfig(ctx) {
274
+ const config = readGoogleConfig();
275
+ if (!config) {
276
+ await ctx.io.writeError(
277
+ `Google plugin is not configured. Set ${GOOGLE_CLIENT_ID_ENV}, ${GOOGLE_CLIENT_SECRET_ENV}, and ${GOOGLE_ACCOUNT_EMAIL_ENV}.
278
+ `
279
+ );
280
+ }
281
+ return config;
282
+ }
283
+ async function waitForLoopbackCode(input) {
284
+ return await new Promise((resolve, reject) => {
285
+ const server = createServer((request, response) => {
286
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
287
+ if (url.pathname !== "/oauth/callback") {
288
+ response.writeHead(404).end();
289
+ return;
290
+ }
291
+ const finish = (message, result) => {
292
+ response.writeHead(200, {
293
+ Connection: "close",
294
+ "Content-Type": "text/plain; charset=utf-8",
295
+ "Referrer-Policy": "no-referrer"
296
+ }).end(`${message}
297
+ You can close this window.
298
+ `);
299
+ clearTimeout(timer);
300
+ server.close();
301
+ result();
302
+ };
303
+ if (url.searchParams.get("state") !== input.state) {
304
+ finish(
305
+ "Sign-in state did not match.",
306
+ () => reject(new GoogleConnectError("Sign-in state did not match."))
307
+ );
308
+ return;
309
+ }
310
+ const error = url.searchParams.get("error");
311
+ const code = url.searchParams.get("code");
312
+ if (error || !code) {
313
+ const message = `Google sign-in did not finish (${error ?? "no code"}).`;
314
+ finish(message, () => reject(new GoogleConnectError(message)));
315
+ return;
316
+ }
317
+ finish(
318
+ "Google sign-in received. Return to your terminal.",
319
+ () => resolve(code)
320
+ );
321
+ });
322
+ const timer = setTimeout(() => {
323
+ server.close();
324
+ reject(new GoogleConnectError("Timed out waiting for Google sign-in."));
325
+ }, SIGN_IN_TIMEOUT_MS);
326
+ server.once("error", (error) => {
327
+ clearTimeout(timer);
328
+ reject(error);
329
+ });
330
+ server.listen(input.port, "127.0.0.1", () => {
331
+ Promise.resolve(input.onListening()).catch((error) => {
332
+ clearTimeout(timer);
333
+ server.close();
334
+ reject(error);
335
+ });
336
+ });
337
+ });
338
+ }
339
+ async function runConnect(ctx, options) {
340
+ const config = await requireConfig(ctx);
341
+ if (!config) return 1;
342
+ const redirectUri = `http://127.0.0.1:${options.port}/oauth/callback`;
343
+ const signIn = createGoogleSignInRequest();
344
+ try {
345
+ const code = await waitForLoopbackCode({
346
+ onListening: () => ctx.io.writeOutput(
347
+ [
348
+ `Open this URL in a browser and sign in as ${config.accountEmail}:`,
349
+ "",
350
+ googleAuthorizationUrl({ config, redirectUri, request: signIn }),
351
+ "",
352
+ `Waiting for Google to redirect to ${redirectUri} ...`,
353
+ ""
354
+ ].join("\n")
355
+ ),
356
+ port: options.port,
357
+ state: signIn.state
358
+ });
359
+ const connected = await connectGoogleAccount({
360
+ code,
361
+ codeVerifier: signIn.codeVerifier,
362
+ config,
363
+ connectedBy: `cli:${userInfo().username}`,
364
+ db: ctx.db,
365
+ redirectUri
366
+ });
367
+ await ctx.io.writeOutput(
368
+ `Connected ${connected.accountEmail} (scopes: ${connected.scope}).
369
+ `
370
+ );
371
+ return 0;
372
+ } catch (error) {
373
+ if (error instanceof GoogleConnectError) {
374
+ await ctx.io.writeError(`${error.message}
375
+ `);
376
+ return 1;
377
+ }
378
+ throw error;
379
+ }
380
+ }
381
+ async function runStatus(ctx) {
382
+ const config = await requireConfig(ctx);
383
+ if (!config) return 1;
384
+ const record = await getGoogleAccount(
385
+ ctx.db,
386
+ config.accountEmail
387
+ );
388
+ if (!record) {
389
+ await ctx.io.writeOutput(
390
+ `account=${config.accountEmail}
391
+ status=not_connected
392
+ `
393
+ );
394
+ return 0;
395
+ }
396
+ const status = googleAccountStatus(record);
397
+ await ctx.io.writeOutput(
398
+ [
399
+ `account=${status.accountEmail}`,
400
+ "status=connected",
401
+ `connected_at=${status.connectedAt}`,
402
+ `connected_by=${status.connectedBy}`,
403
+ `scope=${status.scope}`,
404
+ ""
405
+ ].join("\n")
406
+ );
407
+ return 0;
408
+ }
409
+ function createGoogleCliCommand() {
410
+ return {
411
+ name: "google",
412
+ summary: "Connect and inspect Junior's Google account",
413
+ configure(command, junior) {
414
+ command.command("connect").description(
415
+ "Sign in as Junior's Google account in a browser and store the grant"
416
+ ).option(
417
+ "--port <port>",
418
+ "Loopback port registered as an OAuth redirect URI",
419
+ parsePort,
420
+ DEFAULT_LOOPBACK_PORT
421
+ ).action(
422
+ junior.action(async (ctx, options) => {
423
+ return await runConnect(ctx, options);
424
+ })
425
+ );
426
+ command.command("status").description("Show whether Junior's Google account is connected").action(junior.action(async (ctx) => await runStatus(ctx)));
427
+ }
428
+ };
429
+ }
430
+
431
+ // src/credentials.ts
432
+ import {
433
+ EgressPolicyDenied
434
+ } from "@sentry/junior-plugin-api";
435
+ var GOOGLE_API_DOMAIN = "www.googleapis.com";
436
+ var GRANT_NAME = "account";
437
+ var LEASE_SAFETY_MS = 6e4;
438
+ var GOOGLE_OPERATIONS = {
439
+ "google.calendar.event.create": {
440
+ access: "write",
441
+ method: "POST",
442
+ path: /^\/calendar\/v3\/calendars\/primary\/events$/
443
+ },
444
+ "google.calendar.event.get": {
445
+ access: "read",
446
+ method: "GET",
447
+ path: /^\/calendar\/v3\/calendars\/primary\/events\/[A-Za-z0-9_]+$/
448
+ },
449
+ "google.calendar.event.update": {
450
+ access: "write",
451
+ method: "PATCH",
452
+ path: /^\/calendar\/v3\/calendars\/primary\/events\/[A-Za-z0-9_]+$/
453
+ },
454
+ "google.calendar.events.list": {
455
+ access: "read",
456
+ method: "GET",
457
+ path: /^\/calendar\/v3\/calendars\/[^/]+\/events$/
458
+ },
459
+ "google.calendar.freebusy.query": {
460
+ access: "read",
461
+ method: "POST",
462
+ path: /^\/calendar\/v3\/freeBusy$/
463
+ }
464
+ };
465
+ function googleGrantForEgress(ctx) {
466
+ const operation = ctx.request.operation;
467
+ const rule = operation ? GOOGLE_OPERATIONS[operation] : void 0;
468
+ if (!rule) {
469
+ throw new EgressPolicyDenied(
470
+ "Google APIs are only available through Junior's Google tools."
471
+ );
472
+ }
473
+ const url = new URL(ctx.request.url);
474
+ if (url.hostname !== GOOGLE_API_DOMAIN || ctx.request.method.toUpperCase() !== rule.method || !rule.path.test(url.pathname)) {
475
+ throw new EgressPolicyDenied(
476
+ `Google request does not match operation ${operation}.`
477
+ );
478
+ }
479
+ return {
480
+ access: rule.access,
481
+ name: GRANT_NAME,
482
+ reason: `Use Junior's Google account for ${operation}`
483
+ };
484
+ }
485
+ function unavailable(message) {
486
+ return { type: "unavailable", message };
487
+ }
488
+ var NOT_CONNECTED_MESSAGE = `Junior's Google account is not connected. A Junior admin must connect it on the dashboard Admin page or with \`junior google connect\`.`;
489
+ async function issueGoogleCredential(ctx) {
490
+ if (ctx.grant.name !== GRANT_NAME) {
491
+ throw new Error(
492
+ `Google plugin cannot issue unknown grant "${ctx.grant.name}".`
493
+ );
494
+ }
495
+ const config = readGoogleConfig();
496
+ if (!config) {
497
+ return unavailable(
498
+ "The Google plugin is not configured on this deployment."
499
+ );
500
+ }
501
+ const db = ctx.db;
502
+ const record = await getGoogleAccount(db, config.accountEmail);
503
+ if (!record) {
504
+ return unavailable(NOT_CONNECTED_MESSAGE);
505
+ }
506
+ const token = await refreshGoogleAccessToken({
507
+ config,
508
+ refreshToken: record.refreshToken
509
+ });
510
+ if (!token) {
511
+ await deleteRejectedGoogleAccount(db, record);
512
+ ctx.log.warn("google.account.token_rejected", {
513
+ "app.google.account": config.accountEmail
514
+ });
515
+ return unavailable(
516
+ `Google revoked Junior's access. ${NOT_CONNECTED_MESSAGE}`
517
+ );
518
+ }
519
+ return {
520
+ type: "lease",
521
+ lease: {
522
+ account: { id: config.accountEmail, label: config.accountEmail },
523
+ expiresAt: new Date(token.expiresAtMs - LEASE_SAFETY_MS).toISOString(),
524
+ headerTransforms: [
525
+ {
526
+ domain: GOOGLE_API_DOMAIN,
527
+ headers: { Authorization: `Bearer ${token.accessToken}` }
528
+ }
529
+ ]
530
+ }
531
+ };
532
+ }
533
+
534
+ // src/setup-routes.ts
535
+ var GOOGLE_PLUGIN_ROUTE_PREFIX = "/api/plugins/google";
536
+ var SIGN_IN_COOKIE = "junior_google_sign_in";
537
+ var SIGN_IN_COOKIE_PATH = `${GOOGLE_PLUGIN_ROUTE_PREFIX}/oauth`;
538
+ var SIGN_IN_MAX_AGE_SECONDS = 600;
539
+ var SETUP_PAGE_PATH = "/plugins/google/account";
540
+ function escapeHtml(value) {
541
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
542
+ }
543
+ function htmlPage(title, body, init = {}) {
544
+ return new Response(
545
+ `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>body{font-family:system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem;line-height:1.5}code{background:#f3f3f3;padding:0 .2rem}a.button{display:inline-block;padding:.5rem 1rem;border:1px solid #333;border-radius:.4rem;text-decoration:none;color:inherit}</style></head><body><h1>${escapeHtml(title)}</h1>${body}</body></html>`,
546
+ {
547
+ status: init.status ?? 200,
548
+ headers: {
549
+ "Cache-Control": "no-store",
550
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
551
+ "Content-Type": "text/html; charset=utf-8",
552
+ // The callback URL carries the authorization code.
553
+ "Referrer-Policy": "no-referrer",
554
+ ...init.headers
555
+ }
556
+ }
557
+ );
558
+ }
559
+ function googleCallbackUrl(request) {
560
+ const base = process.env.JUNIOR_BASE_URL?.trim() || new URL(request.url).origin;
561
+ return new URL(
562
+ `${GOOGLE_PLUGIN_ROUTE_PREFIX}/oauth/callback`,
563
+ base.endsWith("/") ? base : `${base}/`
564
+ ).toString();
565
+ }
566
+ function adminEmail(context) {
567
+ const user = context?.auth.user;
568
+ const email = user?.email?.trim().toLowerCase();
569
+ if (!email || user?.emailVerified !== true || user.isAdmin !== true) {
570
+ return void 0;
571
+ }
572
+ return email;
573
+ }
574
+ function forbidden() {
575
+ return htmlPage(
576
+ "Google setup",
577
+ "<p>Only Junior admins can connect Junior's Google account. An operator can grant the role with <code>junior admin grant &lt;email&gt;</code>.</p>",
578
+ { status: 403 }
579
+ );
580
+ }
581
+ function readCookie(request, name) {
582
+ for (const part of (request.headers.get("cookie") ?? "").split(";")) {
583
+ const [key, ...rest] = part.trim().split("=");
584
+ if (key === name) {
585
+ return rest.join("=");
586
+ }
587
+ }
588
+ return void 0;
589
+ }
590
+ function signInCookie(value, maxAgeSeconds) {
591
+ return `${SIGN_IN_COOKIE}=${value}; Path=${SIGN_IN_COOKIE_PATH}; Max-Age=${maxAgeSeconds}; HttpOnly; Secure; SameSite=Lax`;
592
+ }
593
+ function startSignIn(config, request) {
594
+ const signIn = createGoogleSignInRequest();
595
+ const location = googleAuthorizationUrl({
596
+ config,
597
+ redirectUri: googleCallbackUrl(request),
598
+ request: signIn
599
+ });
600
+ return new Response(null, {
601
+ status: 302,
602
+ headers: {
603
+ "Cache-Control": "no-store",
604
+ Location: location,
605
+ "Set-Cookie": signInCookie(
606
+ `${signIn.state}.${signIn.codeVerifier}`,
607
+ SIGN_IN_MAX_AGE_SECONDS
608
+ )
609
+ }
610
+ });
611
+ }
612
+ async function finishSignIn(input) {
613
+ const url = new URL(input.request.url);
614
+ const clearCookie = { "Set-Cookie": signInCookie("", 0) };
615
+ const failed = (message, status = 400) => htmlPage(
616
+ "Google setup failed",
617
+ `<p>${escapeHtml(message)}</p><p><a href="${SETUP_PAGE_PATH}">Back to setup</a></p>`,
618
+ { headers: clearCookie, status }
619
+ );
620
+ const [expectedState, codeVerifier] = (readCookie(input.request, SIGN_IN_COOKIE) ?? "").split(".");
621
+ const state = url.searchParams.get("state");
622
+ if (!expectedState || !codeVerifier || state !== expectedState) {
623
+ return failed("This sign-in link expired or did not start here.");
624
+ }
625
+ const providerError = url.searchParams.get("error");
626
+ if (providerError) {
627
+ return failed(`Google sign-in did not finish (${providerError}).`);
628
+ }
629
+ const code = url.searchParams.get("code");
630
+ if (!code) {
631
+ return failed("Google did not return an authorization code.");
632
+ }
633
+ try {
634
+ const connected = await connectGoogleAccount({
635
+ code,
636
+ codeVerifier,
637
+ config: input.config,
638
+ connectedBy: input.connectedBy,
639
+ db: input.db,
640
+ redirectUri: googleCallbackUrl(input.request)
641
+ });
642
+ return htmlPage(
643
+ "Google connected",
644
+ `<p>Junior now acts as <code>${escapeHtml(connected.accountEmail)}</code>.</p><p><a href="${SETUP_PAGE_PATH}">Back to setup</a></p>`,
645
+ { headers: clearCookie }
646
+ );
647
+ } catch (error) {
648
+ if (error instanceof GoogleConnectError) {
649
+ return failed(error.message);
650
+ }
651
+ throw error;
652
+ }
653
+ }
654
+ function createGoogleSetupRoutes(input) {
655
+ return {
656
+ async fetch(request, context) {
657
+ const { pathname } = new URL(request.url);
658
+ if (request.method !== "GET") {
659
+ return new Response("Method not allowed", { status: 405 });
660
+ }
661
+ const admin = adminEmail(context);
662
+ if (!admin) {
663
+ return forbidden();
664
+ }
665
+ switch (pathname) {
666
+ case "/oauth/start":
667
+ return startSignIn(input.config, request);
668
+ case "/oauth/callback":
669
+ return await finishSignIn({ ...input, connectedBy: admin, request });
670
+ default:
671
+ return new Response("Not found", { status: 404 });
672
+ }
673
+ }
674
+ };
675
+ }
676
+ function createGoogleSetupPage() {
677
+ return {
678
+ id: "account",
679
+ label: "Google account",
680
+ description: "Connect the Google Workspace account that Junior acts as.",
681
+ navigation: "admin",
682
+ async read(ctx) {
683
+ const config = readGoogleConfig();
684
+ if (!config) {
685
+ return {
686
+ type: "list",
687
+ records: [
688
+ {
689
+ id: "not-configured",
690
+ title: "Not configured",
691
+ description: `Set ${GOOGLE_CLIENT_ID_ENV}, ${GOOGLE_CLIENT_SECRET_ENV}, and ${GOOGLE_ACCOUNT_EMAIL_ENV} on the deployment.`
692
+ }
693
+ ]
694
+ };
695
+ }
696
+ const record = await getGoogleAccount(
697
+ ctx.db,
698
+ config.accountEmail
699
+ );
700
+ const status = record ? googleAccountStatus(record) : void 0;
701
+ return {
702
+ type: "list",
703
+ records: [
704
+ {
705
+ id: config.accountEmail,
706
+ title: config.accountEmail,
707
+ description: status ? "Connected. Reconnect after Google revokes access or Junior needs new permissions." : `Not connected. Sign in as ${config.accountEmail}, not as yourself.`,
708
+ ...status ? {
709
+ metadata: [
710
+ { label: "Connected by", value: status.connectedBy },
711
+ { label: "Connected at", value: status.connectedAt },
712
+ { label: "Scopes", value: status.scope }
713
+ ]
714
+ } : void 0,
715
+ actions: [
716
+ {
717
+ href: `${GOOGLE_PLUGIN_ROUTE_PREFIX}/oauth/start`,
718
+ label: status ? "Reconnect" : "Connect",
719
+ method: "GET"
720
+ }
721
+ ]
722
+ }
723
+ ]
724
+ };
725
+ }
726
+ };
727
+ }
728
+
729
+ // src/tools/create-event.ts
730
+ import { createHash as createHash2, randomUUID } from "crypto";
731
+ import {
732
+ definePluginTool,
733
+ PluginToolInputError as PluginToolInputError2,
734
+ pluginToolOutputSchema
735
+ } from "@sentry/junior-plugin-api";
736
+ import { z as z3 } from "zod";
737
+
738
+ // src/tools/shared.ts
739
+ import {
740
+ PluginToolInputError
741
+ } from "@sentry/junior-plugin-api";
742
+ import { z as z2 } from "zod";
743
+ var emailListSchema = (max) => z2.array(z2.string().trim().toLowerCase().pipe(z2.email())).max(max).describe("Attendee email addresses.");
744
+ var timeZoneSchema = z2.string().min(1).refine(isTimeZone, "timeZone must be an IANA time zone").describe(
745
+ "IANA time zone used to read and show times, such as America/Los_Angeles."
746
+ );
747
+ function isTimeZone(value) {
748
+ try {
749
+ new Intl.DateTimeFormat("en-US", { timeZone: value });
750
+ return true;
751
+ } catch {
752
+ return false;
753
+ }
754
+ }
755
+ function requireAllowedEmails(emails, allowedDomains) {
756
+ const outside = emails.filter((email) => {
757
+ const domain = emailDomain(email);
758
+ return !domain || !allowedDomains.includes(domain);
759
+ });
760
+ if (outside.length > 0) {
761
+ throw new PluginToolInputError(
762
+ `Junior can only schedule with people at ${allowedDomains.join(", ")}. Not allowed: ${outside.join(", ")}`
763
+ );
764
+ }
765
+ }
766
+ async function requesterEmail(ctx) {
767
+ return (await ctx.users.resolveActor())?.user?.email?.trim().toLowerCase();
768
+ }
769
+ async function withRequester(ctx, emails) {
770
+ const requester = await requesterEmail(ctx);
771
+ const domain = requester ? emailDomain(requester) : void 0;
772
+ const all = requester && domain && ctx.allowedDomains.includes(domain) ? [requester, ...emails] : emails;
773
+ return [...new Set(all)];
774
+ }
775
+ var googleErrorSchema = z2.object({
776
+ error: z2.object({ message: z2.string().optional() }).optional()
777
+ });
778
+ async function googleApiRequest(ctx, input) {
779
+ const url = new URL(`https://${GOOGLE_API_DOMAIN}${input.path}`);
780
+ for (const [key, value] of Object.entries(input.query ?? {})) {
781
+ url.searchParams.set(key, value);
782
+ }
783
+ const response = await ctx.egress.fetch({
784
+ operation: input.operation,
785
+ provider: "google",
786
+ request: new Request(url, {
787
+ method: GOOGLE_OPERATIONS[input.operation].method,
788
+ headers: {
789
+ Accept: "application/json",
790
+ "Content-Type": "application/json"
791
+ },
792
+ body: input.body === void 0 ? void 0 : JSON.stringify(input.body)
793
+ })
794
+ });
795
+ const body = await response.json().catch(() => void 0);
796
+ return { body, status: response.status };
797
+ }
798
+ function googleApiError(operation, response) {
799
+ const parsed = googleErrorSchema.safeParse(response.body);
800
+ const detail = parsed.success ? parsed.data.error?.message : void 0;
801
+ const message = `Google ${operation} failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`;
802
+ return response.status === 400 ? new PluginToolInputError(message) : new Error(message);
803
+ }
804
+ function formatInterval(startMs, endMs, timeZone) {
805
+ const day = new Intl.DateTimeFormat("en-US", {
806
+ day: "numeric",
807
+ month: "short",
808
+ timeZone,
809
+ weekday: "short"
810
+ }).format(startMs);
811
+ const time = new Intl.DateTimeFormat("en-US", {
812
+ hour: "2-digit",
813
+ hourCycle: "h23",
814
+ minute: "2-digit",
815
+ timeZone,
816
+ timeZoneName: "short"
817
+ });
818
+ return `${day}, ${time.format(startMs)} \u2013 ${time.format(endMs)}`;
819
+ }
820
+ var MAX_EVENT_MS = 8 * 60 * 60 * 1e3;
821
+ var ownEventSchema = z2.object({
822
+ attendees: z2.array(
823
+ z2.object({ email: z2.string(), responseStatus: z2.string().optional() }).loose()
824
+ ).optional(),
825
+ end: z2.object({ dateTime: z2.string() }),
826
+ hangoutLink: z2.string().optional(),
827
+ htmlLink: z2.string().optional(),
828
+ id: z2.string(),
829
+ organizer: z2.object({ self: z2.boolean().optional() }).optional(),
830
+ start: z2.object({ dateTime: z2.string() }),
831
+ summary: z2.string().optional()
832
+ });
833
+ function ownEventResult(event, timeZone) {
834
+ return {
835
+ attendees: (event.attendees ?? []).map((attendee) => attendee.email),
836
+ end: event.end.dateTime,
837
+ eventId: event.id,
838
+ label: formatInterval(
839
+ Date.parse(event.start.dateTime),
840
+ Date.parse(event.end.dateTime),
841
+ timeZone
842
+ ),
843
+ start: event.start.dateTime,
844
+ ...event.summary ? { title: event.summary } : void 0,
845
+ ...event.htmlLink ? { url: event.htmlLink } : void 0,
846
+ ...event.hangoutLink ? { videoCallUrl: event.hangoutLink } : void 0
847
+ };
848
+ }
849
+ var ownEventOutputFields = {
850
+ attendees: z2.array(z2.string()),
851
+ end: z2.string(),
852
+ eventId: z2.string(),
853
+ label: z2.string(),
854
+ start: z2.string(),
855
+ title: z2.string().optional(),
856
+ url: z2.string().optional(),
857
+ videoCallUrl: z2.string().optional()
858
+ };
859
+
860
+ // src/tools/create-event.ts
861
+ var inputSchema = z3.object({
862
+ title: z3.string().trim().min(1).max(200),
863
+ description: z3.string().max(4e3).optional().describe(
864
+ "Event description. Do not include private details the attendees should not see."
865
+ ),
866
+ start: z3.iso.datetime({ offset: true }).describe("Event start, RFC 3339 with offset."),
867
+ end: z3.iso.datetime({ offset: true }).describe("Event end, RFC 3339 with offset."),
868
+ timeZone: timeZoneSchema,
869
+ attendees: emailListSchema(50).describe(
870
+ "Email addresses to invite. Junior adds the requester automatically when it knows their email."
871
+ ),
872
+ addVideoCall: z3.boolean().default(true).describe("Add a Google Meet link.")
873
+ }).strict();
874
+ var outputSchema = pluginToolOutputSchema.extend({
875
+ target: z3.literal("createCalendarEvent"),
876
+ created: z3.boolean(),
877
+ ...ownEventOutputFields
878
+ });
879
+ function calendarEventId(toolCallId) {
880
+ return createHash2("sha256").update(`junior-google:${toolCallId ?? randomUUID()}`).digest("hex").slice(0, 40);
881
+ }
882
+ function createCreateCalendarEventTool(ctx) {
883
+ return definePluginTool({
884
+ annotations: {
885
+ destructiveHint: false,
886
+ idempotentHint: true,
887
+ openWorldHint: true,
888
+ readOnlyHint: false
889
+ },
890
+ describeProposal(input) {
891
+ return `Create Google Calendar event "${input.title}" from ${input.start} to ${input.end} and email invites to ${input.attendees.join(", ") || "the requester"}.`;
892
+ },
893
+ description: "Create a Google Calendar event organized by Junior's own Google account and email invites to the attendees. Use after the requester confirms the time. Only people in the company's Google Workspace domains can be invited.",
894
+ inputSchema,
895
+ outputSchema,
896
+ async execute(input, options) {
897
+ const startMs = Date.parse(input.start);
898
+ const endMs = Date.parse(input.end);
899
+ if (endMs <= startMs) {
900
+ throw new PluginToolInputError2("end must be after start.");
901
+ }
902
+ if (endMs - startMs > MAX_EVENT_MS) {
903
+ throw new PluginToolInputError2("Events can be at most 8 hours long.");
904
+ }
905
+ const attendees = await withRequester(ctx, input.attendees);
906
+ if (attendees.length === 0) {
907
+ throw new PluginToolInputError2("Invite at least one attendee.");
908
+ }
909
+ requireAllowedEmails(attendees, ctx.allowedDomains);
910
+ const eventId = calendarEventId(options.toolCallId);
911
+ const response = await googleApiRequest(ctx, {
912
+ body: {
913
+ attendees: attendees.map((email) => ({ email })),
914
+ ...input.addVideoCall ? {
915
+ conferenceData: {
916
+ createRequest: {
917
+ conferenceSolutionKey: { type: "hangoutsMeet" },
918
+ requestId: eventId
919
+ }
920
+ }
921
+ } : void 0,
922
+ ...input.description ? { description: input.description } : void 0,
923
+ end: { dateTime: input.end, timeZone: input.timeZone },
924
+ guestsCanModify: false,
925
+ id: eventId,
926
+ start: { dateTime: input.start, timeZone: input.timeZone },
927
+ summary: input.title
928
+ },
929
+ operation: "google.calendar.event.create",
930
+ path: "/calendar/v3/calendars/primary/events",
931
+ query: {
932
+ conferenceDataVersion: input.addVideoCall ? "1" : "0",
933
+ sendUpdates: "all"
934
+ }
935
+ });
936
+ let created = true;
937
+ let event = response;
938
+ if (response.status === 409) {
939
+ created = false;
940
+ event = await googleApiRequest(ctx, {
941
+ operation: "google.calendar.event.get",
942
+ path: `/calendar/v3/calendars/primary/events/${eventId}`
943
+ });
944
+ if (event.status !== 200) {
945
+ throw googleApiError("google.calendar.event.get", event);
946
+ }
947
+ } else if (response.status !== 200) {
948
+ throw googleApiError("google.calendar.event.create", response);
949
+ }
950
+ return {
951
+ target: "createCalendarEvent",
952
+ created,
953
+ ...ownEventResult(ownEventSchema.parse(event.body), input.timeZone)
954
+ };
955
+ }
956
+ });
957
+ }
958
+
959
+ // src/tools/find-meeting-times.ts
960
+ import {
961
+ definePluginTool as definePluginTool2,
962
+ PluginToolInputError as PluginToolInputError3,
963
+ pluginToolOutputSchema as pluginToolOutputSchema2
964
+ } from "@sentry/junior-plugin-api";
965
+ import { z as z4 } from "zod";
966
+ var MAX_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
967
+ var clockTimeSchema = z4.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use 24-hour HH:MM");
968
+ var inputSchema2 = z4.object({
969
+ attendees: emailListSchema(20).min(1).describe(
970
+ "Email addresses of the people who must attend. Junior adds the requester automatically when it knows their email."
971
+ ),
972
+ durationMinutes: z4.number().int().min(15).max(480),
973
+ timeMin: z4.iso.datetime({ offset: true }).describe("Start of the search window, RFC 3339 with offset."),
974
+ timeMax: z4.iso.datetime({ offset: true }).describe(
975
+ "End of the search window, RFC 3339 with offset. At most 14 days after timeMin."
976
+ ),
977
+ timeZone: timeZoneSchema,
978
+ workdayStart: clockTimeSchema.default("09:00").describe("Earliest local start time in timeZone, 24-hour HH:MM."),
979
+ workdayEnd: clockTimeSchema.default("17:00").describe("Latest local end time in timeZone, 24-hour HH:MM."),
980
+ maxResults: z4.number().int().min(1).max(20).default(5)
981
+ }).strict();
982
+ var slotSchema = z4.object({
983
+ end: z4.string(),
984
+ label: z4.string(),
985
+ start: z4.string()
986
+ });
987
+ var outputSchema2 = pluginToolOutputSchema2.extend({
988
+ target: z4.literal("findMeetingTimes"),
989
+ checked: z4.array(z4.string()),
990
+ slots: z4.array(slotSchema),
991
+ timeZone: z4.string(),
992
+ unavailable: z4.array(z4.object({ email: z4.string(), reason: z4.string() }))
993
+ });
994
+ var freeBusyResponseSchema = z4.object({
995
+ calendars: z4.record(
996
+ z4.string(),
997
+ z4.object({
998
+ busy: z4.array(z4.object({ end: z4.string(), start: z4.string() })).default([]),
999
+ errors: z4.array(z4.object({ reason: z4.string() })).optional()
1000
+ })
1001
+ )
1002
+ });
1003
+ function localParts(ms, format) {
1004
+ const parts = Object.fromEntries(
1005
+ format.formatToParts(ms).map((part) => [part.type, part.value])
1006
+ );
1007
+ return {
1008
+ date: `${parts.year}-${parts.month}-${parts.day}`,
1009
+ minutes: Number(parts.hour) * 60 + Number(parts.minute),
1010
+ weekday: parts.weekday ?? ""
1011
+ };
1012
+ }
1013
+ function clockMinutes(value) {
1014
+ const [hours, minutes] = value.split(":").map(Number);
1015
+ return hours * 60 + minutes;
1016
+ }
1017
+ function findFreeSlots(input) {
1018
+ const durationMs = input.durationMinutes * 6e4;
1019
+ const stepMs = (input.durationMinutes < 30 ? 15 : 30) * 6e4;
1020
+ const format = new Intl.DateTimeFormat("en-US", {
1021
+ day: "2-digit",
1022
+ hour: "2-digit",
1023
+ hourCycle: "h23",
1024
+ minute: "2-digit",
1025
+ month: "2-digit",
1026
+ timeZone: input.timeZone,
1027
+ weekday: "short",
1028
+ year: "numeric"
1029
+ });
1030
+ const dayStart = clockMinutes(input.workdayStart);
1031
+ const dayEnd = clockMinutes(input.workdayEnd);
1032
+ const slots = [];
1033
+ let startMs = Math.ceil(input.timeMinMs / stepMs) * stepMs;
1034
+ while (startMs + durationMs <= input.timeMaxMs && slots.length < input.maxResults) {
1035
+ const endMs = startMs + durationMs;
1036
+ const start = localParts(startMs, format);
1037
+ const end = localParts(endMs, format);
1038
+ const inWorkday = start.weekday !== "Sat" && start.weekday !== "Sun" && start.date === end.date && start.minutes >= dayStart && end.minutes <= dayEnd;
1039
+ const free = input.busy.every(
1040
+ (busy) => busy.endMs <= startMs || busy.startMs >= endMs
1041
+ );
1042
+ if (inWorkday && free) {
1043
+ slots.push({ startMs, endMs });
1044
+ startMs = endMs;
1045
+ } else {
1046
+ startMs += stepMs;
1047
+ }
1048
+ }
1049
+ return slots;
1050
+ }
1051
+ function createFindMeetingTimesTool(ctx) {
1052
+ return definePluginTool2({
1053
+ annotations: {
1054
+ destructiveHint: false,
1055
+ idempotentHint: true,
1056
+ openWorldHint: true,
1057
+ readOnlyHint: true
1058
+ },
1059
+ description: "Find meeting times when every attendee is free, using Google Calendar free/busy data from Junior's own Google account. Returns open slots only, never event details. Only people in the company's Google Workspace domains can be checked. People listed in `unavailable` were not checked; tell the user instead of treating them as free.",
1060
+ inputSchema: inputSchema2,
1061
+ outputSchema: outputSchema2,
1062
+ async execute(input) {
1063
+ const timeMinMs = Math.max(Date.parse(input.timeMin), Date.now());
1064
+ const timeMaxMs = Date.parse(input.timeMax);
1065
+ if (timeMaxMs <= timeMinMs) {
1066
+ throw new PluginToolInputError3(
1067
+ "timeMax must be after timeMin and now."
1068
+ );
1069
+ }
1070
+ if (timeMaxMs - timeMinMs > MAX_WINDOW_MS) {
1071
+ throw new PluginToolInputError3("Search at most 14 days at a time.");
1072
+ }
1073
+ if (clockMinutes(input.workdayEnd) <= clockMinutes(input.workdayStart)) {
1074
+ throw new PluginToolInputError3(
1075
+ "workdayEnd must be after workdayStart."
1076
+ );
1077
+ }
1078
+ const attendees = await withRequester(ctx, input.attendees);
1079
+ requireAllowedEmails(attendees, ctx.allowedDomains);
1080
+ const response = await googleApiRequest(ctx, {
1081
+ body: {
1082
+ items: attendees.map((id) => ({ id })),
1083
+ timeMax: new Date(timeMaxMs).toISOString(),
1084
+ timeMin: new Date(timeMinMs).toISOString()
1085
+ },
1086
+ operation: "google.calendar.freebusy.query",
1087
+ path: "/calendar/v3/freeBusy"
1088
+ });
1089
+ if (response.status !== 200) {
1090
+ throw googleApiError("google.calendar.freebusy.query", response);
1091
+ }
1092
+ const { calendars } = freeBusyResponseSchema.parse(response.body);
1093
+ const checked = [];
1094
+ const unavailable2 = [];
1095
+ const busy = [];
1096
+ for (const email of attendees) {
1097
+ const calendar = calendars[email];
1098
+ const error = calendar?.errors?.[0]?.reason;
1099
+ if (!calendar || error) {
1100
+ unavailable2.push({ email, reason: error ?? "notFound" });
1101
+ continue;
1102
+ }
1103
+ checked.push(email);
1104
+ for (const interval of calendar.busy) {
1105
+ busy.push({
1106
+ endMs: Date.parse(interval.end),
1107
+ startMs: Date.parse(interval.start)
1108
+ });
1109
+ }
1110
+ }
1111
+ const slots = checked.length === 0 ? [] : findFreeSlots({
1112
+ busy,
1113
+ durationMinutes: input.durationMinutes,
1114
+ maxResults: input.maxResults,
1115
+ timeMaxMs,
1116
+ timeMinMs,
1117
+ timeZone: input.timeZone,
1118
+ workdayEnd: input.workdayEnd,
1119
+ workdayStart: input.workdayStart
1120
+ });
1121
+ return {
1122
+ target: "findMeetingTimes",
1123
+ checked,
1124
+ slots: slots.map((slot) => ({
1125
+ end: new Date(slot.endMs).toISOString(),
1126
+ label: formatInterval(slot.startMs, slot.endMs, input.timeZone),
1127
+ start: new Date(slot.startMs).toISOString()
1128
+ })),
1129
+ timeZone: input.timeZone,
1130
+ unavailable: unavailable2
1131
+ };
1132
+ }
1133
+ });
1134
+ }
1135
+
1136
+ // src/tools/list-events.ts
1137
+ import {
1138
+ definePluginTool as definePluginTool3,
1139
+ PluginToolInputError as PluginToolInputError4,
1140
+ pluginToolOutputSchema as pluginToolOutputSchema3
1141
+ } from "@sentry/junior-plugin-api";
1142
+ import { z as z5 } from "zod";
1143
+ var MAX_WINDOW_MS2 = 31 * 24 * 60 * 60 * 1e3;
1144
+ var inputSchema3 = z5.object({
1145
+ calendar: z5.string().trim().toLowerCase().pipe(z5.email()).describe("Email address of the person whose calendar to read."),
1146
+ timeMin: z5.iso.datetime({ offset: true }).describe("Start of the window, RFC 3339 with offset."),
1147
+ timeMax: z5.iso.datetime({ offset: true }).describe(
1148
+ "End of the window, RFC 3339 with offset. At most 31 days after timeMin."
1149
+ ),
1150
+ timeZone: timeZoneSchema,
1151
+ maxResults: z5.number().int().min(1).max(100).default(50)
1152
+ }).strict();
1153
+ var eventSchema = z5.object({
1154
+ attendees: z5.array(z5.string()).optional(),
1155
+ end: z5.string(),
1156
+ eventId: z5.string(),
1157
+ label: z5.string(),
1158
+ location: z5.string().optional(),
1159
+ organizer: z5.string().optional(),
1160
+ start: z5.string(),
1161
+ title: z5.string().optional(),
1162
+ url: z5.string().optional(),
1163
+ videoCallUrl: z5.string().optional()
1164
+ });
1165
+ var outputSchema3 = pluginToolOutputSchema3.extend({
1166
+ target: z5.literal("listCalendarEvents"),
1167
+ calendar: z5.string(),
1168
+ events: z5.array(eventSchema),
1169
+ timeZone: z5.string(),
1170
+ truncated: z5.boolean(),
1171
+ visible: z5.boolean()
1172
+ });
1173
+ var eventTimeSchema = z5.object({
1174
+ date: z5.string().optional(),
1175
+ dateTime: z5.string().optional()
1176
+ });
1177
+ var listResponseSchema = z5.object({
1178
+ items: z5.array(
1179
+ z5.object({
1180
+ attendees: z5.array(z5.object({ email: z5.string() })).optional(),
1181
+ end: eventTimeSchema,
1182
+ hangoutLink: z5.string().optional(),
1183
+ htmlLink: z5.string().optional(),
1184
+ id: z5.string(),
1185
+ location: z5.string().optional(),
1186
+ organizer: z5.object({ email: z5.string().optional() }).optional(),
1187
+ start: eventTimeSchema,
1188
+ status: z5.string().optional(),
1189
+ summary: z5.string().optional()
1190
+ })
1191
+ ).default([]),
1192
+ nextPageToken: z5.string().optional()
1193
+ });
1194
+ function lastAllDayDate(end) {
1195
+ if (!end.date) return void 0;
1196
+ const day = /* @__PURE__ */ new Date(`${end.date}T00:00:00Z`);
1197
+ day.setUTCDate(day.getUTCDate() - 1);
1198
+ return day.toISOString().slice(0, 10);
1199
+ }
1200
+ function eventLabel(start, end, timeZone) {
1201
+ if (start.dateTime && end.dateTime) {
1202
+ return formatInterval(
1203
+ Date.parse(start.dateTime),
1204
+ Date.parse(end.dateTime),
1205
+ timeZone
1206
+ );
1207
+ }
1208
+ const first = start.date ?? "unknown date";
1209
+ const last = lastAllDayDate(end);
1210
+ return last && last !== first ? `${first} to ${last}, all day` : `${first}, all day`;
1211
+ }
1212
+ function createListCalendarEventsTool(ctx) {
1213
+ return definePluginTool3({
1214
+ annotations: {
1215
+ destructiveHint: false,
1216
+ idempotentHint: true,
1217
+ openWorldHint: true,
1218
+ readOnlyHint: true
1219
+ },
1220
+ description: "List events on a colleague's Google Calendar, as seen by Junior's own Google account. Shows titles, times, locations, organizer, and attendees when the calendar's sharing settings allow it. Events without a title are busy blocks the owner did not share. Only calendars in the company's Google Workspace domains can be read. Calendar details can be private: share only what the requester needs, and avoid repeating them in public channels.",
1221
+ inputSchema: inputSchema3,
1222
+ outputSchema: outputSchema3,
1223
+ async execute(input) {
1224
+ const timeMinMs = Date.parse(input.timeMin);
1225
+ const timeMaxMs = Date.parse(input.timeMax);
1226
+ if (timeMaxMs <= timeMinMs) {
1227
+ throw new PluginToolInputError4("timeMax must be after timeMin.");
1228
+ }
1229
+ if (timeMaxMs - timeMinMs > MAX_WINDOW_MS2) {
1230
+ throw new PluginToolInputError4("Read at most 31 days at a time.");
1231
+ }
1232
+ requireAllowedEmails([input.calendar], ctx.allowedDomains);
1233
+ const response = await googleApiRequest(ctx, {
1234
+ operation: "google.calendar.events.list",
1235
+ path: `/calendar/v3/calendars/${encodeURIComponent(input.calendar)}/events`,
1236
+ query: {
1237
+ maxResults: String(input.maxResults),
1238
+ orderBy: "startTime",
1239
+ singleEvents: "true",
1240
+ timeMax: new Date(timeMaxMs).toISOString(),
1241
+ timeMin: new Date(timeMinMs).toISOString(),
1242
+ timeZone: input.timeZone
1243
+ }
1244
+ });
1245
+ const result = {
1246
+ target: "listCalendarEvents",
1247
+ calendar: input.calendar,
1248
+ timeZone: input.timeZone
1249
+ };
1250
+ if (response.status === 404) {
1251
+ return { ...result, events: [], truncated: false, visible: false };
1252
+ }
1253
+ if (response.status !== 200) {
1254
+ throw googleApiError("google.calendar.events.list", response);
1255
+ }
1256
+ const { items, nextPageToken } = listResponseSchema.parse(response.body);
1257
+ return {
1258
+ ...result,
1259
+ events: items.filter((event) => event.status !== "cancelled").map((event) => ({
1260
+ end: event.end.dateTime ?? lastAllDayDate(event.end) ?? "",
1261
+ eventId: event.id,
1262
+ label: eventLabel(event.start, event.end, input.timeZone),
1263
+ start: event.start.dateTime ?? event.start.date ?? "",
1264
+ ...event.summary ? { title: event.summary } : void 0,
1265
+ ...event.location ? { location: event.location } : void 0,
1266
+ ...event.organizer?.email ? { organizer: event.organizer.email } : void 0,
1267
+ ...event.attendees ? { attendees: event.attendees.map((person) => person.email) } : void 0,
1268
+ ...event.htmlLink ? { url: event.htmlLink } : void 0,
1269
+ ...event.hangoutLink ? { videoCallUrl: event.hangoutLink } : void 0
1270
+ })),
1271
+ truncated: nextPageToken !== void 0,
1272
+ visible: true
1273
+ };
1274
+ }
1275
+ });
1276
+ }
1277
+
1278
+ // src/tools/update-event.ts
1279
+ import {
1280
+ definePluginTool as definePluginTool4,
1281
+ PluginToolInputError as PluginToolInputError5,
1282
+ pluginToolOutputSchema as pluginToolOutputSchema4
1283
+ } from "@sentry/junior-plugin-api";
1284
+ import { z as z6 } from "zod";
1285
+ var inputSchema4 = z6.object({
1286
+ eventId: z6.string().regex(/^[A-Za-z0-9_]{5,1024}$/, "Use an event id from a Calendar tool").describe(
1287
+ "Event id from createCalendarEvent or listCalendarEvents. Only events that Junior organizes can be changed."
1288
+ ),
1289
+ title: z6.string().trim().min(1).max(200).optional(),
1290
+ description: z6.string().max(4e3).optional().describe(
1291
+ "Replacement description. Do not include private details the attendees should not see."
1292
+ ),
1293
+ start: z6.iso.datetime({ offset: true }).optional().describe("New start, RFC 3339 with offset. Give end too."),
1294
+ end: z6.iso.datetime({ offset: true }).optional().describe("New end, RFC 3339 with offset. Give start too."),
1295
+ timeZone: timeZoneSchema,
1296
+ addAttendees: emailListSchema(50).default([]).describe("Email addresses to invite."),
1297
+ removeAttendees: emailListSchema(50).default([]).describe("Email addresses to remove from the event.")
1298
+ }).strict().refine(
1299
+ (input) => input.start === void 0 === (input.end === void 0),
1300
+ {
1301
+ message: "Give both start and end to move an event."
1302
+ }
1303
+ ).refine(
1304
+ (input) => input.title !== void 0 || input.description !== void 0 || input.start !== void 0 || input.addAttendees.length > 0 || input.removeAttendees.length > 0,
1305
+ { message: "Give at least one change." }
1306
+ );
1307
+ var outputSchema4 = pluginToolOutputSchema4.extend({
1308
+ target: z6.literal("updateCalendarEvent"),
1309
+ ...ownEventOutputFields
1310
+ });
1311
+ function createUpdateCalendarEventTool(ctx) {
1312
+ return definePluginTool4({
1313
+ annotations: {
1314
+ destructiveHint: false,
1315
+ idempotentHint: true,
1316
+ openWorldHint: true,
1317
+ readOnlyHint: false
1318
+ },
1319
+ describeProposal(input) {
1320
+ const changes = [
1321
+ input.title && `rename it to "${input.title}"`,
1322
+ input.start && `move it to ${input.start} \u2013 ${input.end}`,
1323
+ input.description !== void 0 && "replace the description",
1324
+ input.addAttendees.length > 0 && `invite ${input.addAttendees.join(", ")}`,
1325
+ input.removeAttendees.length > 0 && `remove ${input.removeAttendees.join(", ")}`
1326
+ ].filter(Boolean);
1327
+ return `Update Google Calendar event ${input.eventId}: ${changes.join("; ")}. Attendees get an email about the change.`;
1328
+ },
1329
+ description: "Change a Google Calendar event organized by Junior's own Google account: title, description, time, or attendees. Google emails the attendees about the change. Only people invited to the event can ask for changes, and only people in the company's Google Workspace domains can be invited. Use after the requester confirms the change.",
1330
+ inputSchema: inputSchema4,
1331
+ outputSchema: outputSchema4,
1332
+ async execute(input) {
1333
+ if (input.start && input.end) {
1334
+ const durationMs = Date.parse(input.end) - Date.parse(input.start);
1335
+ if (durationMs <= 0) {
1336
+ throw new PluginToolInputError5("end must be after start.");
1337
+ }
1338
+ if (durationMs > MAX_EVENT_MS) {
1339
+ throw new PluginToolInputError5("Events can be at most 8 hours long.");
1340
+ }
1341
+ }
1342
+ requireAllowedEmails(input.addAttendees, ctx.allowedDomains);
1343
+ const requester = await requesterEmail(ctx);
1344
+ if (!requester) {
1345
+ throw new PluginToolInputError5(
1346
+ "Junior does not know the requester's email, so it cannot check that they may change this event."
1347
+ );
1348
+ }
1349
+ const path = `/calendar/v3/calendars/primary/events/${input.eventId}`;
1350
+ const current = await googleApiRequest(ctx, {
1351
+ operation: "google.calendar.event.get",
1352
+ path
1353
+ });
1354
+ if (current.status === 404) {
1355
+ throw new PluginToolInputError5(
1356
+ `Junior's calendar has no event ${input.eventId}.`
1357
+ );
1358
+ }
1359
+ if (current.status !== 200) {
1360
+ throw googleApiError("google.calendar.event.get", current);
1361
+ }
1362
+ const event = ownEventSchema.parse(current.body);
1363
+ if (event.organizer?.self !== true) {
1364
+ throw new PluginToolInputError5(
1365
+ "Junior does not organize this event, so it cannot change it."
1366
+ );
1367
+ }
1368
+ const attendees = event.attendees ?? [];
1369
+ const emailKey = (email) => email.toLowerCase();
1370
+ if (!attendees.some((attendee) => emailKey(attendee.email) === requester)) {
1371
+ throw new PluginToolInputError5(
1372
+ "Only people invited to this event can ask Junior to change it."
1373
+ );
1374
+ }
1375
+ const removed = new Set(input.removeAttendees);
1376
+ const kept = attendees.filter(
1377
+ (attendee) => !removed.has(emailKey(attendee.email))
1378
+ );
1379
+ const existing = new Set(
1380
+ kept.map((attendee) => emailKey(attendee.email))
1381
+ );
1382
+ const added = input.addAttendees.filter((email) => !existing.has(email)).map((email) => ({ email }));
1383
+ const attendeesChanged = kept.length !== attendees.length || added.length;
1384
+ const response = await googleApiRequest(ctx, {
1385
+ body: {
1386
+ ...input.title !== void 0 && { summary: input.title },
1387
+ ...input.description !== void 0 && {
1388
+ description: input.description
1389
+ },
1390
+ ...input.start && input.end && {
1391
+ end: { dateTime: input.end, timeZone: input.timeZone },
1392
+ start: { dateTime: input.start, timeZone: input.timeZone }
1393
+ },
1394
+ ...attendeesChanged && { attendees: [...kept, ...added] }
1395
+ },
1396
+ operation: "google.calendar.event.update",
1397
+ path,
1398
+ query: { sendUpdates: "all" }
1399
+ });
1400
+ if (response.status !== 200) {
1401
+ throw googleApiError("google.calendar.event.update", response);
1402
+ }
1403
+ return {
1404
+ target: "updateCalendarEvent",
1405
+ ...ownEventResult(ownEventSchema.parse(response.body), input.timeZone)
1406
+ };
1407
+ }
1408
+ });
1409
+ }
1410
+
1411
+ // src/index.ts
1412
+ function googlePlugin() {
1413
+ return defineJuniorPlugin({
1414
+ packageName: "@sentry/junior-google",
1415
+ manifest: {
1416
+ name: "google",
1417
+ displayName: "Google Workspace",
1418
+ description: "Google Calendar scheduling as Junior's own Google Workspace account",
1419
+ domains: [GOOGLE_API_DOMAIN],
1420
+ envVars: {
1421
+ [GOOGLE_CLIENT_ID_ENV]: {},
1422
+ [GOOGLE_CLIENT_SECRET_ENV]: {},
1423
+ [GOOGLE_ACCOUNT_EMAIL_ENV]: {},
1424
+ [GOOGLE_ALLOWED_DOMAINS_ENV]: {}
1425
+ }
1426
+ },
1427
+ cli: {
1428
+ commands: [createGoogleCliCommand()]
1429
+ },
1430
+ userPages: [createGoogleSetupPage()],
1431
+ hooks: {
1432
+ apiRoutes(ctx) {
1433
+ const config = readGoogleConfig();
1434
+ return config ? createGoogleSetupRoutes({ config, db: ctx.db }) : void 0;
1435
+ },
1436
+ grantForEgress: googleGrantForEgress,
1437
+ issueCredential: issueGoogleCredential,
1438
+ tools(ctx) {
1439
+ const config = readGoogleConfig();
1440
+ if (!config) {
1441
+ return {};
1442
+ }
1443
+ const toolContext = {
1444
+ allowedDomains: config.allowedDomains,
1445
+ egress: ctx.egress,
1446
+ users: ctx.users
1447
+ };
1448
+ return {
1449
+ createCalendarEvent: createCreateCalendarEventTool(toolContext),
1450
+ findMeetingTimes: createFindMeetingTimesTool(toolContext),
1451
+ listCalendarEvents: createListCalendarEventsTool(toolContext),
1452
+ updateCalendarEvent: createUpdateCalendarEventTool(toolContext)
1453
+ };
1454
+ }
1455
+ }
1456
+ });
1457
+ }
1458
+ export {
1459
+ googlePlugin
1460
+ };