diff --git a/packages/docs/src/content/docs/operate/dashboard.md b/packages/docs/src/content/docs/operate/dashboard.md index aaabab6ccc..0cbd13be79 100644 --- a/packages/docs/src/content/docs/operate/dashboard.md +++ b/packages/docs/src/content/docs/operate/dashboard.md @@ -13,7 +13,7 @@ related: - /start-here/verify-and-troubleshoot/ --- -Use `@sentry/junior-dashboard` when you want browser access to Junior runtime diagnostics without exposing plugin, skill, or filesystem discovery publicly. The dashboard mounts into the same Nitro deployment as Junior, but its Better Auth session only protects dashboard routes. +Use `@sentry/junior-dashboard` when you want browser access to Junior runtime diagnostics without exposing plugin, skill, or filesystem discovery publicly. The dashboard mounts into the same Nitro deployment as Junior. Junior core owns its Better Auth session. With the experimental `slack-sign-in` feature, Slack uses the same session, so a dashboard sign-in also counts as a recent Slack sign-in. ## Install diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index 5fa6c9736a..4f2971ae30 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -168,6 +168,9 @@ const app = await createApp({ // database directly and skip Conversation privacy checks. Enable them only // on isolated deployments, such as Previews behind deployment protection. "operator-tools": true, + // Ask each Slack user for a recent Google sign-in before Junior + // replies to their mentions and DMs. + "slack-sign-in": true, }, }); ``` @@ -211,6 +214,21 @@ pre-stable surface. `passive-routing` turns on replies to non-mention messages in threads Junior already joined. Leave it unset in production unless you are testing that path. +`slack-sign-in` makes each Slack user sign in with Google before Junior starts +work from their mentions or DMs. Until then, Junior drops the message and sends +the user a private sign-in link. A sign-in counts for 7 days. Set +`slack.signInMaxAgeDays` to change this number. A dashboard sign-in counts too: +Slack and the dashboard use one Better Auth session, so a person who signed in +to one of them does not sign in again for the other. + +The dashboard is not required. Sign-in uses the same Google OAuth client, +allowlist, and callback as the dashboard: `GOOGLE_CLIENT_ID`, +`GOOGLE_CLIENT_SECRET`, `JUNIOR_DASHBOARD_GOOGLE_DOMAINS` or +`JUNIOR_DASHBOARD_ALLOWED_EMAILS`, and `/api/auth/callback/google` as +an authorized redirect URI. The Google email must match the Slack profile +email. Passive thread replies, slash commands, and Slack actions are not checked +yet. + `operator-tools` adds `runOperatorSql` to non-public Conversations. The tool runs one SQL statement against the app database and returns up to 200 rows. Each call uses its own database connection, which is closed afterwards, so diff --git a/packages/junior-dashboard/README.md b/packages/junior-dashboard/README.md index 1def392527..760ba020df 100644 --- a/packages/junior-dashboard/README.md +++ b/packages/junior-dashboard/README.md @@ -9,8 +9,9 @@ name, through dashboard settings. - `createDashboardApp` mounts the dashboard routes and receives host configuration through `JuniorDashboardOptions`. -- Better Auth owns authentication; dashboard routes fail closed when identity - or required configuration is missing. +- Core owns the Better Auth session and passes it in as `auth`. Slack sign-in + uses the same session. Dashboard routes fail closed when identity or required + configuration is missing. - The installable shell is static only: a public web app manifest, theme color, and install icon. There is no service worker and no offline app shell cache. - API schemas under `src/api/` define the client/server boundary. diff --git a/packages/junior-dashboard/package.json b/packages/junior-dashboard/package.json index c3587608f2..bce5fdf34f 100644 --- a/packages/junior-dashboard/package.json +++ b/packages/junior-dashboard/package.json @@ -38,7 +38,6 @@ "@sentry/junior": "workspace:*", "@sentry/junior-plugin-api": "workspace:*", "@tanstack/react-query": "^5.100.14", - "better-auth": "^1.6.22", "hono": "^4.12.27", "lucide-react": "^1.17.0", "nitro": "3.0.260522-beta", diff --git a/packages/junior-dashboard/src/app.ts b/packages/junior-dashboard/src/app.ts index 40093f8897..6f107abbbb 100644 --- a/packages/junior-dashboard/src/app.ts +++ b/packages/junior-dashboard/src/app.ts @@ -25,11 +25,9 @@ import { dashboardProfileUpdateSchema, } from "./api/schema"; import { - createDashboardAuth, dashboardBearerSession, dashboardPersonalBearerToken, dashboardSessionIsAuthorized, - resolveGoogleHostedDomainHint, sanitizeDashboardSession, verifiedDashboardSessionEmail, type DashboardAuth, @@ -82,8 +80,6 @@ export interface JuniorDashboardOptions { authRequired?: boolean; allowedGoogleDomains?: string[]; allowedEmails?: string[]; - sessionMaxAgeSeconds?: number; - trustedOrigins?: string[]; auth?: DashboardAuth; componentGallery?: boolean; mockConversations?: boolean; @@ -169,8 +165,6 @@ function resolveDashboardOptions( readEnvList("JUNIOR_DASHBOARD_GOOGLE_DOMAINS"), allowedEmails: options.allowedEmails ?? readEnvList("JUNIOR_DASHBOARD_ALLOWED_EMAILS"), - trustedOrigins: - options.trustedOrigins ?? readEnvList("JUNIOR_DASHBOARD_TRUSTED_ORIGINS"), componentGallery: options.componentGallery ?? readEnvFlag("JUNIOR_DASHBOARD_COMPONENT_GALLERY"), @@ -431,17 +425,11 @@ export function createDashboardApp( ); } - const auth = authRequired - ? (options.auth ?? - createDashboardAuth({ - agentName, - authPath, - baseURL: options.baseURL, - trustedOrigins: options.trustedOrigins ?? [], - googleHostedDomain: resolveGoogleHostedDomainHint(allowedDomains), - sessionMaxAgeSeconds: options.sessionMaxAgeSeconds, - })) - : undefined; + // Core owns the Google sign-in session and shares it with Slack. + const auth = options.auth; + if (authRequired && !auth) { + throw new Error("Junior dashboard auth requires the core sign-in session"); + } const app = new Hono<{ Variables: Variables }>(); const authenticatedRoutes = options.authenticatedRoutes ?? []; app.use("*", async (c, next) => { @@ -456,7 +444,7 @@ export function createDashboardApp( authenticatedRoutes, componentGallery: options.componentGallery, }); - if (!auth) { + if (!authRequired || !auth) { return Response.redirect(returnUrl, 302); } const session = await auth.getSession(c.req.raw); diff --git a/packages/junior-dashboard/src/auth.ts b/packages/junior-dashboard/src/auth.ts index 1b6f47bf20..a8cc683c72 100644 --- a/packages/junior-dashboard/src/auth.ts +++ b/packages/junior-dashboard/src/auth.ts @@ -1,25 +1,10 @@ -import { betterAuth } from "better-auth/minimal"; import { dashboardSessionSchema } from "./api/schema"; import type { DashboardSession } from "./api/schema"; -import { resolveDashboardBaseURL } from "./url"; - -const DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 8; export type DashboardUser = DashboardSession["user"]; export type { DashboardSession }; -export interface DashboardAuthConfig { - agentName?: string; - baseURL?: string; - authPath: string; - trustedOrigins: string[]; - secret?: string; - googleClientId?: string; - googleClientSecret?: string; - googleHostedDomain?: string; - sessionMaxAgeSeconds?: number; -} - +/** Google sign-in session that core shares with Slack. */ export interface DashboardAuth { handler(request: Request): Promise; getSession(request: Request): Promise; @@ -78,114 +63,3 @@ export function sanitizeDashboardSession( }, }); } - -function required(value: string | undefined, name: string): string { - if (!value?.trim()) { - throw new Error(`${name} is required for Junior dashboard auth`); - } - return value.trim(); -} - -function firstHostedDomain(domains: string[]): string | undefined { - return domains.length === 1 ? domains[0] : undefined; -} - -/** Create the Better Auth bridge used by dashboard browser routes. */ -export function createDashboardAuth( - config: DashboardAuthConfig, -): DashboardAuth { - const secret = required( - config.secret ?? - process.env.BETTER_AUTH_SECRET ?? - process.env.JUNIOR_SECRET, - "JUNIOR_SECRET or BETTER_AUTH_SECRET", - ); - const baseURL = resolveDashboardBaseURL({ baseURL: config.baseURL }); - const googleClientId = required( - config.googleClientId ?? process.env.GOOGLE_CLIENT_ID, - "GOOGLE_CLIENT_ID", - ); - const googleClientSecret = required( - config.googleClientSecret ?? process.env.GOOGLE_CLIENT_SECRET, - "GOOGLE_CLIENT_SECRET", - ); - - const auth = betterAuth({ - appName: `${config.agentName?.trim() || "Junior"} Dashboard`, - baseURL, - basePath: config.authPath, - secret, - trustedOrigins: config.trustedOrigins, - socialProviders: { - google: { - clientId: googleClientId, - clientSecret: googleClientSecret, - hd: config.googleHostedDomain, - prompt: "select_account", - mapProfileToUser(profile) { - return { - email: profile.email, - emailVerified: profile.email_verified, - image: profile.picture, - name: profile.name, - }; - }, - }, - }, - account: { - storeStateStrategy: "cookie", - storeAccountCookie: false, - updateAccountOnSignIn: false, - }, - session: { - expiresIn: config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS, - disableSessionRefresh: true, - cookieCache: { - enabled: true, - strategy: "jwe", - maxAge: config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS, - refreshCache: false, - }, - }, - }); - - return { - handler(request) { - return auth.handler(request); - }, - async getSession(request) { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session) { - return null; - } - return sanitizeDashboardSession(session as DashboardSession); - }, - async signInWithGoogle(request, callbackURL) { - const result = await auth.api.signInSocial({ - body: { - provider: "google", - callbackURL, - }, - headers: request.headers, - returnHeaders: true, - }); - - if (!("url" in result.response) || !result.response.url) { - throw new Error("Google sign-in did not return a redirect URL"); - } - - result.headers.set("location", result.response.url); - return new Response(null, { - status: 302, - headers: result.headers, - }); - }, - }; -} - -/** Resolve a Google hosted-domain login hint when it is unambiguous. */ -export function resolveGoogleHostedDomainHint( - domains: string[], -): string | undefined { - return firstHostedDomain(domains.map((domain) => domain.toLowerCase())); -} diff --git a/packages/junior-dashboard/tests/auth-config.test.ts b/packages/junior-dashboard/tests/auth-config.test.ts index 5952f64c2e..c5e29ec5e7 100644 --- a/packages/junior-dashboard/tests/auth-config.test.ts +++ b/packages/junior-dashboard/tests/auth-config.test.ts @@ -1,13 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { dashboardSessionSchema } from "../src/api/schema"; -import { resetDashboardEnv } from "./dashboard-test-helpers"; describe("dashboard auth config", () => { - afterEach(() => { - vi.doUnmock("better-auth/minimal"); - vi.resetModules(); - }); - it("requires a valid email for every dashboard session", () => { expect( dashboardSessionSchema.parse({ @@ -21,119 +15,4 @@ describe("dashboard auth config", () => { ).toBe(false); } }); - - it("keeps Google account tokens out of persistent dashboard cookies", async () => { - let capturedOptions: unknown; - - vi.doMock("better-auth/minimal", () => ({ - betterAuth(options: unknown) { - capturedOptions = options; - return { - handler: vi.fn(async () => new Response(null)), - api: { - getSession: vi.fn(async () => null), - signInSocial: vi.fn(async () => ({ - headers: new Headers(), - response: { url: "https://accounts.google.com/o/oauth2/v2/auth" }, - })), - }, - }; - }, - })); - - const { createDashboardAuth } = await import("../src/auth"); - - createDashboardAuth({ - authPath: "/api/auth", - googleClientId: "google-client-id", - googleClientSecret: "google-client-secret", - secret: "0123456789abcdef0123456789abcdef", - trustedOrigins: [], - }); - - expect(capturedOptions).toMatchObject({ - account: { - storeAccountCookie: false, - storeStateStrategy: "cookie", - updateAccountOnSignIn: false, - }, - session: { - cookieCache: { - strategy: "jwe", - }, - }, - }); - expect(capturedOptions).not.toHaveProperty("database"); - }); -}); - -describe("dashboard auth setup", () => { - beforeEach(() => { - resetDashboardEnv(); - }); - - afterEach(() => { - resetDashboardEnv(); - }); - - it("uses JUNIOR_SECRET as the default Better Auth secret", async () => { - process.env.JUNIOR_SECRET = "junior-secret"; - const { createDashboardAuth } = await import("../src/auth"); - - expect(() => - createDashboardAuth({ - authPath: "/api/auth", - trustedOrigins: [], - }), - ).toThrow("GOOGLE_CLIENT_ID is required for Junior dashboard auth"); - }); - - it("defaults dashboard auth to the local development URL", async () => { - process.env.JUNIOR_SECRET = "junior-secret"; - process.env.GOOGLE_CLIENT_ID = "google-client-id"; - process.env.GOOGLE_CLIENT_SECRET = "google-client-secret"; - const { createDashboardAuth } = await import("../src/auth"); - - expect(() => - createDashboardAuth({ - authPath: "/api/auth", - trustedOrigins: [], - }), - ).not.toThrow(); - }); - - it("derives the Better Auth base URL from Junior deployment env", async () => { - process.env.JUNIOR_SECRET = "junior-secret"; - process.env.GOOGLE_CLIENT_ID = "google-client-id"; - process.env.GOOGLE_CLIENT_SECRET = "google-client-secret"; - process.env.JUNIOR_BASE_URL = "https://junior.example.com"; - const { createDashboardAuth } = await import("../src/auth"); - - expect(() => - createDashboardAuth({ - authPath: "/api/auth", - trustedOrigins: [], - }), - ).not.toThrow(); - }); - - it("preserves the Better Auth OAuth state cookie during Google sign-in", async () => { - const { createDashboardAuth } = await import("../src/auth"); - const auth = createDashboardAuth({ - authPath: "/api/auth", - googleClientId: "google-client-id", - googleClientSecret: "google-client-secret", - secret: "0123456789abcdef0123456789abcdef", - trustedOrigins: [], - }); - - const response = await auth.signInWithGoogle( - new Request("http://localhost/auth/login"), - "http://localhost/", - ); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toContain("accounts.google.com"); - expect(response.headers.get("set-cookie")).toContain("oauth_state"); - }); }); diff --git a/packages/junior-dashboard/tsup.config.ts b/packages/junior-dashboard/tsup.config.ts index d72e2006bb..3a82fe2580 100644 --- a/packages/junior-dashboard/tsup.config.ts +++ b/packages/junior-dashboard/tsup.config.ts @@ -114,7 +114,6 @@ export default defineConfig({ "@sentry/junior/api", "@sentry/junior/api/schema", "@sentry/junior/instrumentation", - "better-auth", "hono", ], }); diff --git a/packages/junior/migrations/0060_identity_signed_in_at.sql b/packages/junior/migrations/0060_identity_signed_in_at.sql new file mode 100644 index 0000000000..782a9bfcd0 --- /dev/null +++ b/packages/junior/migrations/0060_identity_signed_in_at.sql @@ -0,0 +1 @@ +ALTER TABLE "junior_identities" ADD COLUMN "signed_in_at" timestamp with time zone; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0060_snapshot.json b/packages/junior/migrations/meta/0060_snapshot.json new file mode 100644 index 0000000000..1dbf5a7333 --- /dev/null +++ b/packages/junior/migrations/meta/0060_snapshot.json @@ -0,0 +1,3465 @@ +{ + "id": "f2dd4b37-6caf-4453-9552-3ffafb9ffe5c", + "prevId": "6936b8a8-1c94-4be1-a259-10512747eb34", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": ["parent_conversation_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_profile": { + "name": "model_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_mailbox_status": { + "name": "parent_mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_parent_mailbox_idx": { + "name": "junior_agent_invocations_parent_mailbox_idx", + "columns": [ + { + "expression": "parent_mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_artifacts": { + "name": "junior_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_artifacts_conversation_sha_uidx": { + "name": "junior_artifacts_conversation_sha_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_gc_idx": { + "name": "junior_artifacts_gc_idx", + "columns": [ + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_conversation_idx": { + "name": "junior_artifacts_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_attachments": { + "name": "junior_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vision_summary": { + "name": "vision_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_attachments_conversation_idx": { + "name": "junior_attachments_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_provider_idx": { + "name": "junior_attachments_provider_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_gc_idx": { + "name": "junior_attachments_gc_idx", + "columns": [ + { + "expression": "storage_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_attachments_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_attachments_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_attachments", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automation_runs": { + "name": "junior_automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_version": { + "name": "automation_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at_ms": { + "name": "claimed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at_ms": { + "name": "started_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "completed_at_ms": { + "name": "completed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_message_ts": { + "name": "result_message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_automation_runs_automation_idx": { + "name": "junior_automation_runs_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_completed_idx": { + "name": "junior_automation_runs_completed_idx", + "columns": [ + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_incomplete_idx": { + "name": "junior_automation_runs_incomplete_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automation_runs\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automation_runs_automation_id_junior_automations_id_fk": { + "name": "junior_automation_runs_automation_id_junior_automations_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_automation_runs_automation_version_fk": { + "name": "junior_automation_runs_automation_version_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automation_versions", + "columnsFrom": ["automation_id", "automation_version"], + "columnsTo": ["automation_id", "version"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "junior_automation_runs_conversation_id_unique": { + "name": "junior_automation_runs_conversation_id_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id"] + } + }, + "policies": {}, + "checkConstraints": { + "junior_automation_runs_status_check": { + "name": "junior_automation_runs_status_check", + "value": "\"junior_automation_runs\".\"status\" in ('pending', 'running', 'completed', 'failed', 'blocked', 'skipped')" + } + }, + "isRLSEnabled": false + }, + "public.junior_automation_versions": { + "name": "junior_automation_versions", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "edited_by": { + "name": "edited_by", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_automation_versions_automation_id_fk": { + "name": "junior_automation_versions_automation_id_fk", + "tableFrom": "junior_automation_versions", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_automation_versions_pk": { + "name": "junior_automation_versions_pk", + "columns": ["automation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automations": { + "name": "junior_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_automations_team_idx": { + "name": "junior_automations_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_creator_identity_idx": { + "name": "junior_automations_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_run_now_due_idx": { + "name": "junior_automations_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_next_run_due_idx": { + "name": "junior_automations_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_match_idx": { + "name": "junior_automations_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_automations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_automations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_automations_kind_check": { + "name": "junior_automations_kind_check", + "value": "\"junior_automations\".\"kind\" in ('scheduled', 'event')" + }, + "junior_automations_status_check": { + "name": "junior_automations_status_check", + "value": "\"junior_automations\".\"status\" in ('active', 'blocked', 'paused', 'completed', 'deleted')" + }, + "junior_automations_record_check": { + "name": "junior_automations_record_check", + "value": "jsonb_typeof(\"junior_automations\".\"record\") = 'object'" + }, + "junior_automations_trigger_check": { + "name": "junior_automations_trigger_check", + "value": "(\"junior_automations\".\"kind\" = 'scheduled' and \"junior_automations\".\"namespace\" is null and \"junior_automations\".\"identifier\" is null) or (\"junior_automations\".\"kind\" = 'event' and \"junior_automations\".\"namespace\" is not null and \"junior_automations\".\"identifier\" is not null and \"junior_automations\".\"next_run_at_ms\" is null and \"junior_automations\".\"run_now_at_ms\" is null and \"junior_automations\".\"status\" <> 'completed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_code_changes": { + "name": "junior_code_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "conversation_ids": { + "name": "conversation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_code_changes_provider_id_idx": { + "name": "junior_code_changes_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_opened_at_idx": { + "name": "junior_code_changes_opened_at_idx", + "columns": [ + { + "expression": "opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_merged_at_idx": { + "name": "junior_code_changes_merged_at_idx", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_closed_at_idx": { + "name": "junior_code_changes_closed_at_idx", + "columns": [ + { + "expression": "closed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_open_idx": { + "name": "junior_code_changes_open_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_code_changes\".\"state\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_code_changes_repository_id_junior_code_repositories_id_fk": { + "name": "junior_code_changes_repository_id_junior_code_repositories_id_fk", + "tableFrom": "junior_code_changes", + "tableTo": "junior_code_repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_code_repositories": { + "name": "junior_code_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_code_repositories_provider_id_idx": { + "name": "junior_code_repositories_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": ["conversation_id", "plugin", "kind", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_briefs": { + "name": "junior_conversation_briefs", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "through_seq": { + "name": "through_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "junior_conversation_briefs_conversation_turn_idx": { + "name": "junior_conversation_briefs_conversation_turn_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_conversation_version_idx": { + "name": "junior_conversation_briefs_conversation_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_search_idx": { + "name": "junior_conversation_briefs_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_briefs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_briefs_conversation_id_version_pk": { + "name": "junior_conversation_briefs_conversation_id_version_pk", + "columns": ["conversation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_actor_identity_idx": { + "name": "junior_conversation_events_actor_identity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_events_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_metrics": { + "name": "junior_conversation_metrics", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_metrics_occurred_at_metric_idx": { + "name": "junior_conversation_metrics_occurred_at_metric_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_metrics", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_metrics_conversation_id_run_id_metric_pk": { + "name": "junior_conversation_metrics_conversation_id_run_id_metric_pk", + "columns": ["conversation_id", "run_id", "metric"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_participants": { + "name": "junior_conversation_participants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversation_participants_user_activity_idx": { + "name": "junior_conversation_participants_user_activity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_participants_root_idx": { + "name": "junior_conversation_participants_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_participants_user_id_junior_users_id_fk": { + "name": "junior_conversation_participants_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_participants_user_root_pk": { + "name": "junior_conversation_participants_user_root_pk", + "columns": ["user_id", "root_conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location_json": { + "name": "location_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_conversation_id": { + "name": "forked_from_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_forked_from_idx": { + "name": "junior_conversations_forked_from_idx", + "columns": [ + { + "expression": "forked_from_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": ["destination_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["credential_subject_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["forked_from_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "signed_in_at": { + "name": "signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_location_configurations": { + "name": "junior_location_configurations", + "schema": "", + "columns": { + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "junior_location_configurations_location_id_junior_destinations_id_fk": { + "name": "junior_location_configurations_location_id_junior_destinations_id_fk", + "tableFrom": "junior_location_configurations", + "tableTo": "junior_destinations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_location_configurations_location_id_key_pk": { + "name": "junior_location_configurations_location_id_key_pk", + "columns": ["location_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_snapshots": { + "name": "junior_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_hash": { + "name": "profile_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_duration_ms": { + "name": "build_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_started_at": { + "name": "build_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_phase": { + "name": "build_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_sandbox_name": { + "name": "build_sandbox_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_command_id": { + "name": "build_command_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_error": { + "name": "build_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_snapshots_snapshot_id_uidx": { + "name": "junior_snapshots_snapshot_id_uidx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_idx": { + "name": "junior_snapshots_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_profile_status_idx": { + "name": "junior_snapshots_workspace_profile_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_active_build_uidx": { + "name": "junior_snapshots_active_build_uidx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_snapshots\".\"status\" = 'building'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_status_idx": { + "name": "junior_snapshots_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_snapshots_workspace_id_junior_workspaces_id_fk": { + "name": "junior_snapshots_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_snapshots", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_snapshots_status_check": { + "name": "junior_snapshots_status_check", + "value": "\"junior_snapshots\".\"status\" in ('building', 'failed', 'ready')" + }, + "junior_snapshots_build_phase_check": { + "name": "junior_snapshots_build_phase_check", + "value": "\"junior_snapshots\".\"build_phase\" is null or \"junior_snapshots\".\"build_phase\" in ('created', 'dependencies_installed', 'repositories_prepared')" + }, + "junior_snapshots_ready_fields_check": { + "name": "junior_snapshots_ready_fields_check", + "value": "\"junior_snapshots\".\"status\" <> 'ready' or (\"junior_snapshots\".\"snapshot_id\" is not null and \"junior_snapshots\".\"build_duration_ms\" is not null and \"junior_snapshots\".\"generated_at\" is not null)" + }, + "junior_snapshots_build_fields_check": { + "name": "junior_snapshots_build_fields_check", + "value": "\"junior_snapshots\".\"status\" = 'ready' or (\"junior_snapshots\".\"build_started_at\" is not null and \"junior_snapshots\".\"build_phase\" is not null)" + }, + "junior_snapshots_build_duration_check": { + "name": "junior_snapshots_build_duration_check", + "value": "\"junior_snapshots\".\"build_duration_ms\" is null or \"junior_snapshots\".\"build_duration_ms\" >= 0" + }, + "junior_snapshots_size_bytes_check": { + "name": "junior_snapshots_size_bytes_check", + "value": "\"junior_snapshots\".\"size_bytes\" is null or \"junior_snapshots\".\"size_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": ["date", "namespace", "metric", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspace_repos": { + "name": "junior_workspace_repos", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_workspace_repos_workspace_id_junior_workspaces_id_fk": { + "name": "junior_workspace_repos_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_workspace_repos", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_workspace_repos_workspace_id_provider_repo_pk": { + "name": "junior_workspace_repos_workspace_id_provider_repo_pk", + "columns": ["workspace_id", "provider", "repo"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspaces": { + "name": "junior_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_script": { + "name": "setup_script", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_workspaces_name_idx": { + "name": "junior_workspaces_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 4cb48d53b8..e9ed0af0fc 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -421,6 +421,13 @@ "when": 1792739994091, "tag": "0059_automation_run_version", "breakpoints": true + }, + { + "idx": 60, + "version": "7", + "when": 1792971097553, + "tag": "0060_identity_signed_in_at", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/junior/package.json b/packages/junior/package.json index 30ec8596ae..e06bce612a 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -92,6 +92,7 @@ "@vercel/queue": "^0.2.0", "@vercel/sandbox": "2.8.0", "ai": "^6.0.190", + "better-auth": "^1.6.22", "chat": "4.29.0", "commander": "^14.0.3", "diff": "^8.0.4", diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 70823abd55..08d9c17bc0 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -26,6 +26,7 @@ import { executeAgentRun } from "@/chat/agent"; import { normalizeSandboxEgressTracePropagationDomains } from "@/chat/sandbox/egress/tracing"; import { getExperimentalFeatures, + isExperimentalFeatureEnabled, setExperimentalFeatures, type ExperimentalFeaturesConfig, } from "@/chat/experimental"; @@ -115,6 +116,18 @@ import { ACP_AUTHORIZATION_PATH, handleAcpAuthorizationPage, } from "@/api/acp/authorization-page"; +import { + createSignInAuth, + resolveSignInAuthConfig, + type SignInAuth, + type SignInAuthConfig, +} from "@/chat/sign-in/auth"; +import { + DEFAULT_SLACK_SIGN_IN_MAX_AGE_DAYS, + handleSlackSignIn, + SLACK_SIGN_IN_PATH, + type SlackSignIn, +} from "@/chat/sign-in/slack"; import { JUNIOR_VERSION } from "./version"; import { createDashboardRouteRegistrations, @@ -170,6 +183,12 @@ export interface JuniorAppOptions extends BotModelConfig { * `JUNIOR_CROSS_ACTOR_MID_RUN_MODE` or `follow_up`. */ crossActorMidRunMode?: CrossActorMidRunMode; + /** + * With `experimental["slack-sign-in"]`, how many days a Google sign-in, + * in Slack or the dashboard, lets a person use Junior in Slack. + * Defaults to 7. + */ + signInMaxAgeDays?: number; }; /** Install-wide provider defaults. Unregistered `provider.key` entries warn at startup. */ configDefaults?: Record; @@ -435,6 +454,8 @@ export async function createApp(options?: JuniorAppOptions): Promise { }), }); let sandboxEgressTracePropagationDomains: string[] = []; + let signInConfig: SignInAuthConfig | undefined; + let slackSignIn: SlackSignIn | undefined; try { sandboxEgressTracePropagationDomains = normalizeSandboxEgressTracePropagationDomains( @@ -456,6 +477,27 @@ export async function createApp(options?: JuniorAppOptions): Promise { if (options?.slack?.crossActorMidRunMode) { setCrossActorMidRunMode(options.slack.crossActorMidRunMode); } + const dashboardMounted = Boolean(dashboard && !dashboard.disabled); + if (isExperimentalFeatureEnabled("slack-sign-in") || dashboardMounted) { + signInConfig = resolveSignInAuthConfig(botConfig.userName, dashboard); + } + if (signInConfig && isExperimentalFeatureEnabled("slack-sign-in")) { + const { allowlist } = signInConfig; + if (!allowlist.domains.length && !allowlist.emails.length) { + throw new Error( + "experimental.slack-sign-in requires allowed Google domains or emails", + ); + } + const maxAgeDays = + options?.slack?.signInMaxAgeDays ?? DEFAULT_SLACK_SIGN_IN_MAX_AGE_DAYS; + if (!(maxAgeDays > 0)) { + throw new Error("slack.signInMaxAgeDays must be greater than 0"); + } + slackSignIn = { + baseURL: signInConfig.baseURL, + maxAgeMs: maxAgeDays * 24 * 60 * 60 * 1000, + }; + } if (shouldValidatePluginCatalog) { pluginCatalogRuntime.getSignature(); validatePluginRegistrations(configuredPlugins?.registrations ?? []); @@ -570,10 +612,17 @@ export async function createApp(options?: JuniorAppOptions): Promise { restoreRuntimeConfig(); throw error; } - const slackWebhookServices = createProductionSlackWebhookServices({ - queue: conversationWorkQueue, - services: runtimeServiceOverrides, - }); + // One Google sign-in session for the dashboard and Slack. + const signInAuth: SignInAuth | undefined = signInConfig + ? createSignInAuth(signInConfig) + : undefined; + const slackWebhookServices = { + ...createProductionSlackWebhookServices({ + queue: conversationWorkQueue, + services: runtimeServiceOverrides, + }), + ...(slackSignIn ? { signIn: slackSignIn } : undefined), + }; const app = new Hono(); @@ -597,9 +646,14 @@ export async function createApp(options?: JuniorAppOptions): Promise { mountRoutes( app, await createDashboardRouteRegistrations({ + auth: signInAuth, authenticatedRoutes, conversationWorkQueue, - dashboard, + // Keep dashboard redirects on the origin of the sign-in callback. + dashboard: + dashboard && signInConfig + ? { ...dashboard, baseURL: signInConfig.baseURL } + : dashboard, createDashboardApp: virtualConfig?.createDashboardApp, pluginRoutes: pluginApiRoutes, }), @@ -628,6 +682,27 @@ export async function createApp(options?: JuniorAppOptions): Promise { }); }); + if (signInAuth && signInConfig && slackSignIn) { + const auth = signInAuth; + // The dashboard serves the sign-in routes when it is mounted. + if (!dashboard || dashboard.disabled) { + app.on(["GET", "POST"], `${signInConfig.authPath}/*`, (c) => + auth.handler(c.req.raw), + ); + } + const { allowlist } = signInConfig; + const signIn = slackSignIn; + app.get(SLACK_SIGN_IN_PATH, (c) => + handleSlackSignIn({ + allowlist, + auth, + signIn, + request: c.req.raw, + state: getStateAdapter(), + }), + ); + } + app.get("/api/internal/sandbox-egress/signals", (c) => { return sandboxEgressSignalsGET(c.req.raw); }); diff --git a/packages/junior/src/chat/experimental.ts b/packages/junior/src/chat/experimental.ts index dc7d1ef166..81cc8a73c8 100644 --- a/packages/junior/src/chat/experimental.ts +++ b/packages/junior/src/chat/experimental.ts @@ -6,6 +6,7 @@ export const EXPERIMENTAL_FEATURES = [ "operator-tools", "passive-routing", + "slack-sign-in", "subagents", ] as const; diff --git a/packages/junior/src/chat/identities/README.md b/packages/junior/src/chat/identities/README.md index b8c10296b1..78c8703abf 100644 --- a/packages/junior/src/chat/identities/README.md +++ b/packages/junior/src/chat/identities/README.md @@ -16,6 +16,22 @@ timezone to this column. The scheduler and plugin tools use it, through `ctx.users.resolveTimezone()`. See the Timezones section in `chat/scheduled-automations/README.md`. +## Native Identities + +Core owns two identity providers: `slack` and `google`. Other checks trust +these identities, so only core links them. `isNativeIdentityProvider()` in +`identity.ts` lists them. + +- A Slack identity has the Slack team ID as its tenant and the Slack user ID as + its subject. +- A Google identity has an empty tenant and the Google OIDC `sub` as its + subject. Each allowed Google sign-in, from Slack or the dashboard, saves it + with the verified Google email and sets `signed_in_at`. One Google account is + one row, so every feature that reads the `google` identity sees the same + account. +- The Google Workspace plugin is not a Google identity. It stores Junior's own + Workspace account in its own table. + ## OAuth Account Links When a person connects a provider account, the OAuth callback links the @@ -32,9 +48,32 @@ the account. The person must start the connection from Slack or the web UI. - A verified email that matches the linked user is saved on the provider identity. Other verified emails are not saved. Verified emails are never stored with the OAuth tokens. +- A plugin OAuth account never links under a native identity provider name. + The tokens are still stored. - Linking is best effort. A failure keeps the stored tokens and logs `oauth.callback.identity_link.failed`. Provider handles and subject IDs always remain identity-scoped. Display names are presentation data and must never be used to link identities or grant authority. + +## Slack Sign-In + +With the experimental `slack-sign-in` feature, Slack ingress accepts mentions +and DMs only when the user of the Slack identity has a Google identity with a +recent `signed_in_at`. The default limit is 7 days +(`slack.signInMaxAgeDays`). `chat/sign-in/` owns the sign-in. It does not need +the dashboard. + +- `chat/sign-in/auth.ts` owns the one Better Auth instance. Slack and the + dashboard use it, so they share one browser session and one Google callback. + Core mounts its routes when the dashboard is not mounted. +- After each Google callback, a Better Auth hook saves the Google identity when + the verified email is on the dashboard allowlist. +- Ingress saves the Slack identity with its Slack profile email. Then it stores + one sign-in state for that Slack identity and sends a private link to + `/api/sign-in/slack`. +- That page reads the shared session. It starts one Google sign-in when there is + no session or no recent sign-in. It succeeds only when the user of the Slack + identity has the session email. A forwarded link cannot give another + person's Slack account access. diff --git a/packages/junior/src/chat/identities/identity.ts b/packages/junior/src/chat/identities/identity.ts index a5c416f22b..33ad13abba 100644 --- a/packages/junior/src/chat/identities/identity.ts +++ b/packages/junior/src/chat/identities/identity.ts @@ -1,3 +1,20 @@ +/** + * Provider of the native Google identity. Core links it through Google + * sign-in. Its tenant is empty and its subject is the Google OIDC `sub`. + */ +export const GOOGLE_IDENTITY_PROVIDER = "google"; + +const NATIVE_IDENTITY_PROVIDERS = new Set(["slack", GOOGLE_IDENTITY_PROVIDER]); + +/** + * Return whether only core may link identities for this provider. Plugin + * OAuth accounts must not link under these names, because other checks trust + * them. + */ +export function isNativeIdentityProvider(provider: string): boolean { + return NATIVE_IDENTITY_PROVIDERS.has(provider); +} + export type IdentityKind = "service" | "system" | "user"; export interface IdentityUpsert { diff --git a/packages/junior/src/chat/identities/sql.ts b/packages/junior/src/chat/identities/sql.ts index 725022ad35..a8f36e477d 100644 --- a/packages/junior/src/chat/identities/sql.ts +++ b/packages/junior/src/chat/identities/sql.ts @@ -1,8 +1,10 @@ import { randomUUID } from "node:crypto"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, gte, inArray, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { JuniorDatabase, JuniorSqlDatabase } from "@/db/db"; import { juniorIdentities, juniorUsers } from "@/db/schema"; import { + GOOGLE_IDENTITY_PROVIDER, normalizeIdentityEmail, type IdentityUpsert, type StoredIdentity, @@ -10,6 +12,8 @@ import { type IdentityRow = typeof juniorIdentities.$inferSelect; +const googleIdentity = alias(juniorIdentities, "google_identity"); + function dateFromMs(ms: number): Date { return new Date(ms); } @@ -314,3 +318,86 @@ export async function deleteProviderIdentityForUser( ), ); } + +/** Read the user linked to one Slack identity. */ +export async function findSlackIdentityUserId( + executor: JuniorSqlDatabase, + slackTeamId: string, + slackUserId: string, +): Promise { + const rows = await executor + .db() + .select({ userId: juniorIdentities.userId }) + .from(juniorIdentities) + .where( + and( + eq(juniorIdentities.kind, "user"), + eq(juniorIdentities.provider, "slack"), + eq(juniorIdentities.providerTenantId, slackTeamId), + eq(juniorIdentities.providerSubjectId, slackUserId), + ), + ) + .limit(1); + return rows[0]?.userId ?? undefined; +} + +/** + * Save one Google sign-in. Link the Google identity to the user of its + * verified email, and set its sign-in time. + */ +export async function recordGoogleSignIn( + executor: JuniorSqlDatabase, + account: { displayName?: string; email: string; subject: string }, + nowMs: number = Date.now(), +): Promise { + const stored = await upsertIdentity( + executor, + { + kind: "user", + provider: GOOGLE_IDENTITY_PROVIDER, + providerSubjectId: account.subject, + email: account.email, + emailVerified: true, + displayName: account.displayName, + }, + nowMs, + ); + await executor + .db() + .update(juniorIdentities) + .set({ signedInAt: dateFromMs(nowMs) }) + .where(eq(juniorIdentities.id, stored.id)); + return stored; +} + +/** + * Return whether the user of one Slack identity signed in with Google at or + * after `signedInSince`. + */ +export async function slackUserHasGoogleSignIn( + executor: JuniorSqlDatabase, + args: { signedInSince: Date; slackTeamId: string; slackUserId: string }, +): Promise { + const rows = await executor + .db() + .select({ id: googleIdentity.id }) + .from(juniorIdentities) + .innerJoin( + googleIdentity, + eq(googleIdentity.userId, juniorIdentities.userId), + ) + .where( + and( + eq(juniorIdentities.kind, "user"), + eq(juniorIdentities.provider, "slack"), + eq(juniorIdentities.providerTenantId, args.slackTeamId), + eq(juniorIdentities.providerSubjectId, args.slackUserId), + eq(googleIdentity.kind, "user"), + eq(googleIdentity.provider, GOOGLE_IDENTITY_PROVIDER), + eq(googleIdentity.emailVerified, true), + gte(googleIdentity.signedInAt, args.signedInSince), + ), + ) + .limit(1); + return rows.length > 0; +} diff --git a/packages/junior/src/chat/ingress/slack-form.ts b/packages/junior/src/chat/ingress/slack-form.ts new file mode 100644 index 0000000000..49eeda6eb1 --- /dev/null +++ b/packages/junior/src/chat/ingress/slack-form.ts @@ -0,0 +1,216 @@ +/** Slack slash command and interactive payload forms. */ +import type { SlackAdapter } from "@chat-adapter/slack"; +import { ChannelImpl, type StateAdapter } from "chat"; +import { parseActorUserId } from "@/chat/actor"; +import type { UserTokenStore } from "@/chat/credentials/user-token-store"; +import { unlinkProvider } from "@/chat/credentials/unlink-provider"; +import { handleSlashCommand } from "@/chat/ingress/slash-command"; +import { logException, withLogContext, withSpan } from "@/chat/logging"; +import { + runWithSlackInstallation, + type SlackInstallationContext, +} from "@/chat/slack/adapter-context"; +import { publishAppHomeView } from "@/chat/slack/app-home"; +import { getSlackClient } from "@/chat/slack/client"; +import { + getWorkspaceTeamId, + runWithWorkspaceTeamId, +} from "@/chat/slack/workspace-context"; +import type { WaitUntilFn } from "@/handlers/types"; +import { + slackInteractivePayloadSchema, + slackSlashCommandSchema, + type SlackInteractivePayload, + type SlackSlashCommandForm, +} from "./slack-payload"; + +function parseJson(body: string): unknown { + try { + return JSON.parse(body); + } catch { + return undefined; + } +} + +function requireSlackPayloadUserId( + value: string | null | undefined, + source: string, +): string { + const userId = parseActorUserId(value); + if (!userId) { + throw new Error(`${source} is missing a Slack user id`); + } + return userId; +} + +async function handleSlashCommandForm(args: { + adapter: SlackAdapter; + form: SlackSlashCommandForm; + state: StateAdapter; +}): Promise { + const { channel_id: channelId, team_id: teamId, user_id: userId } = args.form; + const channel = new ChannelImpl({ + id: `slack:${channelId}`, + adapter: args.adapter, + stateAdapter: args.state, + }); + await withSpan( + "chat.slash_command", + "chat.slash_command", + { userId: userId }, + async () => { + await handleSlashCommand({ + channel, + channelId, + teamId, + text: args.form.text, + userId, + }); + }, + ); +} + +async function handleInteractivePayload(args: { + payload: SlackInteractivePayload; + userTokenStore: UserTokenStore; +}): Promise { + if (args.payload.type !== "block_actions") { + return; + } + const action = args.payload.actions?.find( + (candidate) => candidate.action_id === "app_home_disconnect", + ); + const provider = action?.selected_option?.value ?? action?.value; + if (!provider) { + return; + } + const userId = requireSlackPayloadUserId( + args.payload.user?.id, + "Slack app home disconnect payload", + ); + + await withSpan( + "chat.app_home_disconnect", + "chat.app_home_disconnect", + { userId: userId }, + async () => { + try { + await unlinkProvider( + userId, + provider, + args.userTokenStore, + getWorkspaceTeamId(), + ); + } catch (error) { + logException(error, "app_home.disconnect_unlink.failed", { + "app.credential.provider": provider, + }); + } + + try { + await publishAppHomeView(getSlackClient(), userId, args.userTokenStore); + } catch (error) { + logException(error, "app_home.disconnect_publish.failed", { + "app.credential.provider": provider, + }); + } + }, + ); +} + +function installationFromInteractive( + payload: SlackInteractivePayload, +): SlackInstallationContext { + return { + teamId: payload.team?.id ?? payload.user?.team_id, + }; +} + +/** Handle a Slack slash command or interactive payload form. */ +export async function handleSlackForm(args: { + adapter: SlackAdapter; + body: string; + getUserTokenStore: () => UserTokenStore; + state: StateAdapter; + waitUntil: WaitUntilFn; +}): Promise { + const params = new URLSearchParams(args.body); + const { adapter, state } = args; + await state.connect(); + + if (params.has("command") && !params.has("payload")) { + const result = slackSlashCommandSchema.safeParse( + Object.fromEntries(params), + ); + if (!result.success) { + return new Response("Invalid slash command payload", { status: 400 }); + } + const form = result.data; + const installation: SlackInstallationContext = { + teamId: form.team_id, + enterpriseId: form.enterprise_id, + isEnterpriseInstall: form.is_enterprise_install === "true", + }; + args.waitUntil( + withLogContext( + { + platform: "slack", + userId: form.user_id, + }, + () => + runWithWorkspaceTeamId(installation.teamId, () => + runWithSlackInstallation({ + adapter, + installation, + state, + task: () => + handleSlashCommandForm({ + adapter, + form, + state, + }), + }), + ).catch((error) => { + logException(error, "slash_command.failed"); + }), + ), + ); + return new Response("", { status: 200 }); + } + + const rawPayload = params.get("payload"); + if (!rawPayload) { + return new Response("Missing payload", { status: 400 }); + } + const result = slackInteractivePayloadSchema.safeParse(parseJson(rawPayload)); + if (!result.success) { + return new Response("Invalid payload JSON", { status: 400 }); + } + const payload = result.data; + const installation = installationFromInteractive(payload); + + args.waitUntil( + withLogContext( + { + platform: "slack", + userId: payload.user?.id?.trim() || undefined, + }, + () => + runWithWorkspaceTeamId(installation.teamId, () => + runWithSlackInstallation({ + adapter, + installation, + state, + task: () => + handleInteractivePayload({ + payload, + userTokenStore: args.getUserTokenStore(), + }), + }), + ).catch((error) => { + logException(error, "slack.interactive.payload.failed"); + }), + ), + ); + return new Response("", { status: 200 }); +} diff --git a/packages/junior/src/chat/ingress/slack-sign-in.ts b/packages/junior/src/chat/ingress/slack-sign-in.ts new file mode 100644 index 0000000000..10e372d6e7 --- /dev/null +++ b/packages/junior/src/chat/ingress/slack-sign-in.ts @@ -0,0 +1,77 @@ +/** Require a recent Google sign-in before a Slack user can start a Turn. */ +import { botConfig } from "@/chat/config"; +import { getSqlExecutor } from "@/chat/db"; +import type { SlackInboundEvent } from "@/chat/ingress/slack-payload"; +import { + slackUserHasGoogleSignIn, + upsertIdentity, +} from "@/chat/identities/sql"; +import type { SlackInstallationContext } from "@/chat/slack/adapter-context"; +import { parseSlackUserId } from "@/chat/slack/ids"; +import { formatSlackLink } from "@/chat/slack/mrkdwn"; +import { lookupSlackUserProfile } from "@/chat/slack/users"; +import { deliverPrivateMessage } from "@/chat/oauth-flow"; +import { createSlackSignInUrl, type SlackSignIn } from "@/chat/sign-in/slack"; +import type { StateAdapter } from "chat"; + +/** + * Return whether this Slack user may start work. + * + * The user of the Slack identity must have signed in with Google, in Slack or + * in the dashboard, within `signIn.maxAgeMs`. When it has not, save the Slack + * identity with the email from the Slack profile, so the sign-in page can match + * it, and check again. Then send the user a private sign-in link and drop the + * message. + */ +export async function hasSlackSignIn(args: { + event: SlackInboundEvent; + installation: SlackInstallationContext; + signIn: SlackSignIn | undefined; + state: StateAdapter; +}): Promise { + if (!args.signIn) return true; + const teamId = args.installation.teamId; + const channelId = args.event.channel; + const userId = parseSlackUserId(args.event.user); + if (!teamId || !channelId || !userId) return false; + const executor = getSqlExecutor(); + const signedInSince = new Date(Date.now() - args.signIn.maxAgeMs); + const hasSignIn = () => + slackUserHasGoogleSignIn(executor, { + signedInSince, + slackTeamId: teamId, + slackUserId: userId, + }); + if (await hasSignIn()) return true; + + const profile = await lookupSlackUserProfile(userId); + await upsertIdentity(executor, { + kind: "user", + provider: "slack", + providerTenantId: teamId, + providerSubjectId: userId, + ...(profile.real_name ? { displayName: profile.real_name } : undefined), + ...(profile.name ? { handle: profile.name } : undefined), + ...(profile.email + ? { email: profile.email, emailVerified: true } + : undefined), + metadata: { platform: "slack" }, + }); + // The first Slack message links the Slack identity to its user. That user + // may already have a recent dashboard sign-in. + if (await hasSignIn()) return true; + + const link = await createSlackSignInUrl({ + baseURL: args.signIn.baseURL, + slackTeamId: teamId, + slackUserId: userId, + state: args.state, + }); + await deliverPrivateMessage({ + channelId, + ...(args.event.thread_ts ? { threadTs: args.event.thread_ts } : undefined), + userId, + text: `${formatSlackLink(link, `Sign in to use ${botConfig.userName}`)}. Sign in with your work Google account, then send your message again.`, + }); + return false; +} diff --git a/packages/junior/src/chat/ingress/slack-webhook.ts b/packages/junior/src/chat/ingress/slack-webhook.ts index d2c1a1b4c6..b7afc5544a 100644 --- a/packages/junior/src/chat/ingress/slack-webhook.ts +++ b/packages/junior/src/chat/ingress/slack-webhook.ts @@ -3,14 +3,11 @@ import type { SlackAdapter } from "@chat-adapter/slack"; import { slackAssistantThreadSchema, slackEventEnvelopeSchema, - slackInteractivePayloadSchema, - slackSlashCommandSchema, - type SlackSlashCommandForm, type SlackEventEnvelope, type SlackInboundEvent, - type SlackInteractivePayload, } from "./slack-payload"; -import { ChannelImpl, ThreadImpl, type Message, type StateAdapter } from "chat"; +import { handleSlackForm } from "./slack-form"; +import { ThreadImpl, type Message, type StateAdapter } from "chat"; import type { SlackTurnRuntime } from "@/chat/providers/slack/runtime"; import { THREAD_OPTOUT_ACK } from "@/chat/providers/slack/runtime"; import { @@ -62,17 +59,14 @@ import { withNormalizedThreadId, } from "@/chat/ingress/message-router"; import { isSlackWorkspaceMember } from "@/chat/ingress/workspace-membership"; +import { hasSlackSignIn } from "@/chat/ingress/slack-sign-in"; +import type { SlackSignIn } from "@/chat/sign-in/slack"; import { slackMessageAttributes } from "./slack-message-telemetry"; -import { - getWorkspaceTeamId, - runWithWorkspaceTeamId, -} from "@/chat/slack/workspace-context"; +import { runWithWorkspaceTeamId } from "@/chat/slack/workspace-context"; import { parseSlackThreadId } from "@/chat/slack/context"; import { getStateAdapter } from "@/chat/state/adapter"; -import { handleSlashCommand } from "@/chat/ingress/slash-command"; import { parseActorUserId } from "@/chat/actor"; import { createUserTokenStore } from "@/chat/capabilities/factory"; -import { unlinkProvider } from "@/chat/credentials/unlink-provider"; import type { UserTokenStore } from "@/chat/credentials/user-token-store"; import { publishAppHomeView } from "@/chat/slack/app-home"; import { presentSlackAutomationDetails } from "@/chat/slack/automation-details"; @@ -142,6 +136,8 @@ export interface SlackWebhookServices { getSlackAdapter: () => SlackAdapter; queue: ConversationWorkQueue; conversationStore?: ConversationStore; + /** Set when Slack use needs a recent Google sign-in. */ + signIn?: SlackSignIn; runtime: Pick< SlackTurnRuntime, | "handleAssistantContextChanged" @@ -453,6 +449,7 @@ async function routeParsedMessage(args: { conversationStore?: ConversationStore; queue: ConversationWorkQueue; receivedAtMs: number; + signIn: SlackSignIn | undefined; state: StateAdapter; }): Promise { if (shouldIgnoreMessage(args.message)) { @@ -537,6 +534,9 @@ async function routeParsedMessage(args: { return; } + // Passive thread messages are not checked for sign-in yet. + if (route === "mention" && !(await hasSlackSignIn(args))) return; + // Keep non-mention thread messages as Conversation history without waking a // worker when passive routing is off. Later explicit mentions still need them. // Write against the Slack thread id, not a bound mailbox Conversation id, so @@ -711,6 +711,7 @@ async function handleSlackEvent(args: { conversationStore: args.services.conversationStore, queue: args.services.queue, receivedAtMs, + signIn: args.services.signIn, state, }), { keepAlive: true, waitMs: 10_000 }, @@ -724,189 +725,6 @@ async function handleSlackEvent(args: { ); } -function requireSlackPayloadUserId( - value: string | null | undefined, - source: string, -): string { - const userId = parseActorUserId(value); - if (!userId) { - throw new Error(`${source} is missing a Slack user id`); - } - return userId; -} - -async function handleSlashCommandForm(args: { - adapter: SlackAdapter; - form: SlackSlashCommandForm; - state: StateAdapter; -}): Promise { - const { channel_id: channelId, team_id: teamId, user_id: userId } = args.form; - const channel = new ChannelImpl({ - id: `slack:${channelId}`, - adapter: args.adapter, - stateAdapter: args.state, - }); - await withSpan( - "chat.slash_command", - "chat.slash_command", - { userId: userId }, - async () => { - await handleSlashCommand({ - channel, - channelId, - teamId, - text: args.form.text, - userId, - }); - }, - ); -} - -async function handleInteractivePayload(args: { - payload: SlackInteractivePayload; - userTokenStore: UserTokenStore; -}): Promise { - if (args.payload.type !== "block_actions") { - return; - } - const action = args.payload.actions?.find( - (candidate) => candidate.action_id === "app_home_disconnect", - ); - const provider = action?.selected_option?.value ?? action?.value; - if (!provider) { - return; - } - const userId = requireSlackPayloadUserId( - args.payload.user?.id, - "Slack app home disconnect payload", - ); - - await withSpan( - "chat.app_home_disconnect", - "chat.app_home_disconnect", - { userId: userId }, - async () => { - try { - await unlinkProvider( - userId, - provider, - args.userTokenStore, - getWorkspaceTeamId(), - ); - } catch (error) { - logException(error, "app_home.disconnect_unlink.failed", { - "app.credential.provider": provider, - }); - } - - try { - await publishAppHomeView(getSlackClient(), userId, args.userTokenStore); - } catch (error) { - logException(error, "app_home.disconnect_publish.failed", { - "app.credential.provider": provider, - }); - } - }, - ); -} - -function installationFromInteractive( - payload: SlackInteractivePayload, -): SlackInstallationContext { - return { - teamId: payload.team?.id ?? payload.user?.team_id, - }; -} - -async function handleSlackForm(args: { - body: string; - services: SlackWebhookServices; - waitUntil: WaitUntilFn; -}): Promise { - const params = new URLSearchParams(args.body); - const adapter = args.services.getSlackAdapter(); - const state = args.services.state ?? getStateAdapter(); - await state.connect(); - - if (params.has("command") && !params.has("payload")) { - const result = slackSlashCommandSchema.safeParse( - Object.fromEntries(params), - ); - if (!result.success) { - return new Response("Invalid slash command payload", { status: 400 }); - } - const form = result.data; - const installation: SlackInstallationContext = { - teamId: form.team_id, - enterpriseId: form.enterprise_id, - isEnterpriseInstall: form.is_enterprise_install === "true", - }; - enqueue( - args.waitUntil, - withLogContext( - { - platform: "slack", - userId: form.user_id, - }, - () => - runWithWorkspaceTeamId(installation.teamId, () => - runWithSlackInstallation({ - adapter, - installation, - state, - task: () => - handleSlashCommandForm({ - adapter, - form, - state, - }), - }), - ).catch((error) => { - logException(error, "slash_command.failed"); - }), - ), - ); - return new Response("", { status: 200 }); - } - - const rawPayload = params.get("payload"); - if (!rawPayload) { - return new Response("Missing payload", { status: 400 }); - } - const result = slackInteractivePayloadSchema.safeParse(parseJson(rawPayload)); - if (!result.success) { - return new Response("Invalid payload JSON", { status: 400 }); - } - const payload = result.data; - const installation = installationFromInteractive(payload); - - enqueue( - args.waitUntil, - withLogContext( - { - platform: "slack", - userId: payload.user?.id?.trim() || undefined, - }, - () => - runWithWorkspaceTeamId(installation.teamId, () => - runWithSlackInstallation({ - adapter, - installation, - state, - task: () => - handleInteractivePayload({ - payload, - userTokenStore: getUserTokenStore(args.services), - }), - }), - ).catch((error) => { - logException(error, "slack.interactive.payload.failed"); - }), - ), - ); - return new Response("", { status: 200 }); -} - /** Handle Slack webhooks by enqueueing durable conversation work. */ export async function handleSlackWebhook(args: { request: Request; @@ -923,8 +741,10 @@ export async function handleSlackWebhook(args: { const contentType = args.request.headers.get("content-type") || ""; if (contentType.includes("application/x-www-form-urlencoded")) { return await handleSlackForm({ + adapter, body, - services: args.services, + getUserTokenStore: () => getUserTokenStore(args.services), + state: args.services.state ?? getStateAdapter(), waitUntil: args.waitUntil, }); } diff --git a/packages/junior/src/chat/sign-in/allowlist.ts b/packages/junior/src/chat/sign-in/allowlist.ts new file mode 100644 index 0000000000..d0089fe6a3 --- /dev/null +++ b/packages/junior/src/chat/sign-in/allowlist.ts @@ -0,0 +1,56 @@ +/** Google accounts that may sign in. The dashboard uses the same list. */ +import { z } from "zod"; + +/** Google accounts that may sign in. Empty lists allow nobody. */ +export interface SignInAllowlist { + domains: string[]; + emails: string[]; +} + +/** + * Read a list env var the same way as the dashboard: a JSON string array or + * comma-separated values. + */ +export function readEnvList(name: string): string[] | undefined { + const value = process.env[name]?.trim(); + if (!value) return undefined; + if (!value.startsWith("[")) return value.split(","); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error(`${name} must be a JSON string array`, { cause: error }); + } + const list = z.array(z.string()).safeParse(parsed); + if (!list.success) throw new Error(`${name} must be a JSON string array`); + return list.data; +} + +/** + * Resolve the sign-in allowlist. Use the same values as the dashboard: its + * options when set, else its environment variables. + */ +export function resolveSignInAllowlist( + options: { allowedEmails?: string[]; allowedGoogleDomains?: string[] } = {}, +): SignInAllowlist { + const list = (values: string[] | undefined, name: string) => + (values ?? readEnvList(name) ?? []) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + return { + domains: list( + options.allowedGoogleDomains, + "JUNIOR_DASHBOARD_GOOGLE_DOMAINS", + ), + emails: list(options.allowedEmails, "JUNIOR_DASHBOARD_ALLOWED_EMAILS"), + }; +} + +/** Return whether a normalized Google email is on the allowlist. */ +export function isSignInAllowed( + email: string, + allowlist: SignInAllowlist, +): boolean { + const domain = email.slice(email.lastIndexOf("@") + 1); + return allowlist.emails.includes(email) || allowlist.domains.includes(domain); +} diff --git a/packages/junior/src/chat/sign-in/auth.ts b/packages/junior/src/chat/sign-in/auth.ts new file mode 100644 index 0000000000..18e2bfbaaf --- /dev/null +++ b/packages/junior/src/chat/sign-in/auth.ts @@ -0,0 +1,229 @@ +/** + * Google sign-in session shared by the dashboard and Slack. + * + * Better Auth runs the Google OAuth flow and keeps the browser session in an + * encrypted cookie. Each sign-in also saves the native Google identity with its + * sign-in time, so Slack can check a recent sign-in without the browser. + */ +import { createAuthMiddleware } from "better-auth/api"; +import { betterAuth } from "better-auth/minimal"; +import { getSqlExecutor } from "@/chat/db"; +import { normalizeIdentityEmail } from "@/chat/identities/identity"; +import { recordGoogleSignIn } from "@/chat/identities/sql"; +import { resolveBaseUrl } from "@/chat/oauth-flow"; +import { + isSignInAllowed, + readEnvList, + resolveSignInAllowlist, + type SignInAllowlist, +} from "./allowlist"; + +const DEFAULT_AUTH_PATH = "/api/auth"; +const DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 8; + +/** Verified Google account of one browser session. */ +export interface SignInSession { + user: { email: string; emailVerified: boolean; name?: string }; +} + +/** Browser sign-in routes and session reads. */ +export interface SignInAuth { + handler(request: Request): Promise; + getSession(request: Request): Promise; + signInWithGoogle( + request: Request, + callbackURL: string, + errorCallbackURL?: string, + ): Promise; +} + +/** Settings for the shared Google sign-in session. */ +export interface SignInAuthConfig { + agentName: string; + allowlist: SignInAllowlist; + authPath: string; + baseURL: string; + googleClientId?: string; + googleClientSecret?: string; + secret?: string; + sessionMaxAgeSeconds?: number; + trustedOrigins: string[]; +} + +/** Dashboard options that also configure the sign-in session. */ +export interface SignInAuthOptions { + allowedEmails?: string[]; + allowedGoogleDomains?: string[]; + authPath?: string; + baseURL?: string; + sessionMaxAgeSeconds?: number; + trustedOrigins?: string[]; +} + +// Loop instead of a regex, so many slashes cannot make it slow. +function trimSlashes(value: string, leading: boolean): string { + let start = 0; + let end = value.length; + while (leading && start < end && value[start] === "/") start += 1; + while (end > start && value[end - 1] === "/") end -= 1; + return value.slice(start, end); +} + +function withHttps(host: string): string { + return /^https?:\/\//.test(host) ? host : `https://${host}`; +} + +/** Resolve sign-in settings from dashboard options and environment. */ +export function resolveSignInAuthConfig( + agentName: string, + options: SignInAuthOptions = {}, +): SignInAuthConfig { + const authPath = `/${trimSlashes(options.authPath?.trim() || DEFAULT_AUTH_PATH, true)}`; + const baseURL = trimSlashes( + withHttps( + options.baseURL?.trim() || resolveBaseUrl() || "http://localhost:3000", + ), + false, + ); + return { + agentName, + allowlist: resolveSignInAllowlist(options), + authPath, + baseURL, + sessionMaxAgeSeconds: options.sessionMaxAgeSeconds, + trustedOrigins: + options.trustedOrigins ?? + readEnvList("JUNIOR_DASHBOARD_TRUSTED_ORIGINS") ?? + [], + }; +} + +function required(value: string | undefined, name: string): string { + if (!value?.trim()) { + throw new Error(`${name} is required for Junior sign-in`); + } + return value.trim(); +} + +function createBetterAuth(config: SignInAuthConfig) { + const secret = required( + config.secret ?? + process.env.BETTER_AUTH_SECRET ?? + process.env.JUNIOR_SECRET, + "JUNIOR_SECRET or BETTER_AUTH_SECRET", + ); + const sessionMaxAgeSeconds = + config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS; + // Google rejects other accounts when `hd` is set, so set it only when the + // allowlist is one domain. + const { domains, emails } = config.allowlist; + const hostedDomain = + domains.length === 1 && emails.length === 0 ? domains[0] : undefined; + return betterAuth({ + appName: config.agentName, + baseURL: config.baseURL, + basePath: config.authPath, + secret, + trustedOrigins: config.trustedOrigins, + socialProviders: { + google: { + clientId: required( + config.googleClientId ?? process.env.GOOGLE_CLIENT_ID, + "GOOGLE_CLIENT_ID", + ), + clientSecret: required( + config.googleClientSecret ?? process.env.GOOGLE_CLIENT_SECRET, + "GOOGLE_CLIENT_SECRET", + ), + hd: hostedDomain, + prompt: "select_account", + mapProfileToUser(profile) { + return { + email: profile.email, + emailVerified: profile.email_verified, + image: profile.picture, + name: profile.name, + }; + }, + }, + }, + account: { + storeStateStrategy: "cookie", + storeAccountCookie: false, + updateAccountOnSignIn: false, + }, + session: { + expiresIn: sessionMaxAgeSeconds, + disableSessionRefresh: true, + cookieCache: { + enabled: true, + strategy: "jwe", + maxAge: sessionMaxAgeSeconds, + refreshCache: false, + }, + }, + hooks: { + // Save each allowed Google sign-in, so Slack can check it later. + after: createAuthMiddleware(async (ctx) => { + const created = ctx.context.newSession; + if (!created || !ctx.path.startsWith("/callback/")) return; + const email = normalizeIdentityEmail(created.user.email); + if ( + !created.user.emailVerified || + !email || + !isSignInAllowed(email, config.allowlist) + ) { + return; + } + const accounts = await ctx.context.internalAdapter.findAccounts( + created.user.id, + ); + const google = accounts.find( + (account) => account.providerId === "google", + ); + if (!google) { + throw new Error("Google sign-in has no Google account"); + } + await recordGoogleSignIn(getSqlExecutor(), { + email, + subject: google.accountId, + displayName: created.user.name || undefined, + }); + }), + }, + }); +} + +/** + * Create the shared Google sign-in session. Better Auth starts on first use, + * so apps that never sign in need no Google credentials. + */ +export function createSignInAuth(config: SignInAuthConfig): SignInAuth { + let auth: ReturnType | undefined; + const getAuth = () => (auth ??= createBetterAuth(config)); + return { + handler(request) { + return getAuth().handler(request); + }, + async getSession(request) { + const session = await getAuth().api.getSession({ + headers: request.headers, + }); + if (!session) return null; + const { email, emailVerified, name } = session.user; + return { user: { email, emailVerified, name } }; + }, + async signInWithGoogle(request, callbackURL, errorCallbackURL) { + const result = await getAuth().api.signInSocial({ + body: { provider: "google", callbackURL, errorCallbackURL }, + headers: request.headers, + returnHeaders: true, + }); + if (!("url" in result.response) || !result.response.url) { + throw new Error("Google sign-in did not return a redirect URL"); + } + result.headers.set("location", result.response.url); + return new Response(null, { status: 302, headers: result.headers }); + }, + }; +} diff --git a/packages/junior/src/chat/sign-in/slack.ts b/packages/junior/src/chat/sign-in/slack.ts new file mode 100644 index 0000000000..6de94e44a3 --- /dev/null +++ b/packages/junior/src/chat/sign-in/slack.ts @@ -0,0 +1,163 @@ +/** + * Slack sign-in through the shared Google sign-in session. + * + * Slack sends a person here when they have no recent Google sign-in. The page + * uses the browser session that the dashboard also uses. When the person has + * no session, or an old one, it starts a Google sign-in and comes back here. + * The sign-in counts only when its Google email belongs to the user of the + * Slack identity that got the link. + */ +import { randomBytes } from "node:crypto"; +import type { StateAdapter } from "chat"; +import { z } from "zod"; +import { botConfig } from "@/chat/config"; +import { getSqlExecutor } from "@/chat/db"; +import { normalizeIdentityEmail } from "@/chat/identities/identity"; +import { + findSlackIdentityUserId, + slackUserHasGoogleSignIn, +} from "@/chat/identities/sql"; +import { findUserByEmailFromSql } from "@/chat/plugins/viewer"; +import { escapeXml } from "@/chat/xml"; +import { htmlCallbackResponse } from "@/handlers/oauth-html"; +import { isSignInAllowed, type SignInAllowlist } from "./allowlist"; +import type { SignInAuth } from "./auth"; + +export const SLACK_SIGN_IN_PATH = "/api/sign-in/slack"; + +/** Default time a Google sign-in lets a Slack user use Junior. */ +export const DEFAULT_SLACK_SIGN_IN_MAX_AGE_DAYS = 7; + +const SIGN_IN_STATE_TTL_MS = 10 * 60 * 1000; + +/** What Slack ingress needs to check and request sign-in. */ +export interface SlackSignIn { + baseURL: string; + maxAgeMs: number; +} + +const signInStateSchema = z.object({ + // Set after one Google sign-in, so a failed match cannot loop. + redirected: z.boolean(), + slackTeamId: z.string().min(1), + slackUserId: z.string().min(1), +}); + +function stateKey(state: string): string { + return `sign-in:v2:${state}`; +} + +function page(title: string, message: string, status: number): Response { + return htmlCallbackResponse(escapeXml(title), escapeXml(message), status); +} + +/** Store one Slack sign-in request and return its link. */ +export async function createSlackSignInUrl(args: { + baseURL: string; + slackTeamId: string; + slackUserId: string; + state: StateAdapter; +}): Promise { + const state = randomBytes(24).toString("base64url"); + await args.state.set( + stateKey(state), + { + redirected: false, + slackTeamId: args.slackTeamId, + slackUserId: args.slackUserId, + } satisfies z.input, + SIGN_IN_STATE_TTL_MS, + ); + const url = new URL(SLACK_SIGN_IN_PATH, args.baseURL); + url.searchParams.set("state", state); + return url.toString(); +} + +/** + * Finish a Slack sign-in when the browser has a recent Google sign-in for the + * user of the Slack identity. Otherwise start one Google sign-in. + */ +export async function handleSlackSignIn(args: { + allowlist: SignInAllowlist; + auth: SignInAuth; + request: Request; + signIn: SlackSignIn; + state: StateAdapter; +}): Promise { + const token = new URL(args.request.url).searchParams.get("state"); + const parsed = token + ? signInStateSchema.safeParse(await args.state.get(stateKey(token))) + : undefined; + if (!token || !parsed?.success) { + return page( + "Sign-in link expired", + `Mention ${botConfig.userName} in Slack again to get a new link.`, + 400, + ); + } + const stored = parsed.data; + const session = await args.auth.getSession(args.request); + const email = session?.user.emailVerified + ? normalizeIdentityEmail(session.user.email) + : undefined; + let failure: Response | undefined; + if (email && !isSignInAllowed(email, args.allowlist)) { + failure = page( + "Account not allowed", + `${email} cannot use ${botConfig.userName}. Sign in with your work account.`, + 403, + ); + } else if (email) { + const executor = getSqlExecutor(); + const userId = await findSlackIdentityUserId( + executor, + stored.slackTeamId, + stored.slackUserId, + ); + const user = await findUserByEmailFromSql(executor.db(), email); + if (!userId || user?.id !== userId) { + failure = page( + "Slack account does not match", + `Your Slack email is not ${email}. Sign in with the account that matches your Slack email.`, + 403, + ); + } else if ( + await slackUserHasGoogleSignIn(executor, { + signedInSince: new Date(Date.now() - args.signIn.maxAgeMs), + slackTeamId: stored.slackTeamId, + slackUserId: stored.slackUserId, + }) + ) { + await args.state.delete(stateKey(token)); + return page( + "You are signed in", + "Return to Slack and send your message again.", + 200, + ); + } + } + if (stored.redirected) { + return ( + failure ?? + page( + "Sign-in failed", + "Google sign-in did not finish. Sign in with your work account.", + 400, + ) + ); + } + // No session, another account, or an old sign-in: sign in with Google once. + await args.state.set( + stateKey(token), + { ...stored, redirected: true } satisfies z.input, + SIGN_IN_STATE_TTL_MS, + ); + // Come back to the public URL, not the URL this server received. + const returnUrl = new URL(SLACK_SIGN_IN_PATH, args.signIn.baseURL); + returnUrl.searchParams.set("state", token); + return await args.auth.signInWithGoogle( + args.request, + returnUrl.toString(), + returnUrl.toString(), + ); +} diff --git a/packages/junior/src/dashboard-routes.ts b/packages/junior/src/dashboard-routes.ts index a7a6591e25..3b003b326b 100644 --- a/packages/junior/src/dashboard-routes.ts +++ b/packages/junior/src/dashboard-routes.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import type { PluginRouteMethod, User } from "@sentry/junior-plugin-api"; import { botConfig } from "@/chat/config"; +import type { SignInAuth } from "@/chat/sign-in/auth"; import type { PluginApiRouteRegistration, PluginRouteRegistration, @@ -37,6 +38,8 @@ export interface JuniorDashboardOptions { export interface JuniorDashboardRuntimeOptions extends JuniorDashboardOptions { agentName?: string; + /** Google sign-in session that the dashboard shares with Slack. */ + auth?: SignInAuth; authenticatedRoutes?: readonly AuthenticatedRoute[]; conversationWorkQueue?: ConversationWorkQueue; pluginRoutes?: PluginApiRouteRegistration[]; @@ -66,6 +69,7 @@ const DASHBOARD_PACKAGE_NAME = "@sentry/junior-dashboard"; /** Mount the dashboard app on the dashboard-owned host paths. */ export async function createDashboardRouteRegistrations(args: { + auth: SignInAuth | undefined; authenticatedRoutes: readonly AuthenticatedRoute[]; conversationWorkQueue: ConversationWorkQueue; dashboard: JuniorDashboardOptions | undefined; @@ -79,6 +83,7 @@ export async function createDashboardRouteRegistrations(args: { const createDashboardApp = args.createDashboardApp ?? (await loadDashboardAppFactory()); return dashboardRouteRegistrations({ + auth: args.auth, authenticatedRoutes: args.authenticatedRoutes, conversationWorkQueue: args.conversationWorkQueue, dashboard: args.dashboard, @@ -305,6 +310,7 @@ function dashboardOwnedRoutePath( } function dashboardRouteRegistrations(args: { + auth: SignInAuth | undefined; authenticatedRoutes: readonly AuthenticatedRoute[]; conversationWorkQueue: ConversationWorkQueue; dashboard: JuniorDashboardOptions; @@ -316,6 +322,7 @@ function dashboardRouteRegistrations(args: { const dashboardOptions: JuniorDashboardRuntimeOptions = { ...args.dashboard, agentName: botConfig.userName, + ...(args.auth ? { auth: args.auth } : undefined), authenticatedRoutes: args.authenticatedRoutes, conversationWorkQueue: args.conversationWorkQueue, pluginRoutes: args.pluginRoutes, diff --git a/packages/junior/src/db/schema/identities.ts b/packages/junior/src/db/schema/identities.ts index 7a40695ced..72f960c4d3 100644 --- a/packages/junior/src/db/schema/identities.ts +++ b/packages/junior/src/db/schema/identities.ts @@ -29,6 +29,8 @@ export const juniorIdentities = pgTable( userId: text("user_id").references(() => juniorUsers.id), emailNormalized: text("email_normalized"), emailVerified: boolean("email_verified").notNull().default(false), + // Last Google sign-in for a native Google identity. Null otherwise. + signedInAt: timestamptz("signed_in_at"), }, (table) => [ uniqueIndex("junior_identities_provider_subject_uidx").on( diff --git a/packages/junior/src/handlers/oauth-callback.ts b/packages/junior/src/handlers/oauth-callback.ts index 6425f26530..77b6956010 100644 --- a/packages/junior/src/handlers/oauth-callback.ts +++ b/packages/junior/src/handlers/oauth-callback.ts @@ -54,6 +54,7 @@ import { upsertLinkedIdentity, } from "@/chat/identities/sql"; import { identityFromActor } from "@/chat/conversations/sql/actor-identity"; +import { isNativeIdentityProvider } from "@/chat/identities/identity"; import type { SlackActor, WebActor } from "@/chat/actor"; import type { PluginProviderAccount } from "@sentry/junior-plugin-api"; import { lookupSlackUserProfile } from "@/chat/slack/users"; @@ -410,6 +411,7 @@ export async function GET( const linkActor = stored.actor; if ( account && + !isNativeIdentityProvider(provider) && (linkActor?.platform === "slack" || linkActor?.platform === "web") ) { await runBestEffort( diff --git a/packages/junior/tests/fixtures/eval-oauth-account.ts b/packages/junior/tests/fixtures/eval-oauth-account.ts index 001bdb3921..3c41384eb2 100644 --- a/packages/junior/tests/fixtures/eval-oauth-account.ts +++ b/packages/junior/tests/fixtures/eval-oauth-account.ts @@ -6,16 +6,18 @@ import { /** * Register the eval OAuth provider with a `resolveOAuthAccount` hook through * the public app API. Its endpoints match the shared eval-oauth MSW handlers. + * Pass `name` to register the same provider under another plugin name. */ export async function createEvalOAuthAccountApp( account: PluginProviderAccount, + name = "eval-oauth", ): Promise { const { createApp, defineJuniorPlugins } = await import("@/app"); await createApp({ plugins: defineJuniorPlugins([ defineJuniorPlugin({ manifest: { - name: "eval-oauth", + name, displayName: "Eval OAuth", description: "Eval OAuth fixture", credentials: { diff --git a/packages/junior/tests/integration/oauth-identity-link.test.ts b/packages/junior/tests/integration/oauth-identity-link.test.ts index 8b08ed33f4..d1f8f72305 100644 --- a/packages/junior/tests/integration/oauth-identity-link.test.ts +++ b/packages/junior/tests/integration/oauth-identity-link.test.ts @@ -74,4 +74,36 @@ describe("oauth identity link", () => { ]), ); }); + + it("does not link a plugin OAuth account as a native Google identity", async () => { + await createEvalOAuthAccountApp( + { id: "google-sub-1", verifiedEmails: ["person@example.com"] }, + "google", + ); + await resolveViewerUser("person@example.com"); + queueSlackApiResponse("users.info", { + body: usersInfoOk({ userId: "U0TEST", email: "person@example.com" }), + }); + await getStateAdapter().set("oauth-state:google-plugin-link", { + actor: { platform: "slack", teamId: "T0TEST", userId: "U0TEST" }, + provider: "google", + userId: "U0TEST", + }); + + const response = await handleOAuthCallback( + new Request( + "https://junior.example.com/api/oauth/callback/google?state=google-plugin-link&code=eval-oauth-code", + ), + "google", + testWaitUntil, + {}, + ); + + expect(response.status).toBe(200); + const providers = await getSqlExecutor() + .db() + .select({ provider: juniorIdentities.provider }) + .from(juniorIdentities); + expect(providers).not.toContainEqual({ provider: "google" }); + }); }); diff --git a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts index 4b437f2883..d0ddc863e5 100644 --- a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts +++ b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts @@ -1,6 +1,9 @@ import { createMemoryState } from "@chat-adapter/state-memory"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import { createApp } from "@/app"; import { closeDb, getConversationEventStore } from "@/chat/db"; +import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; import { JuniorChat } from "@/chat/ingress/junior-chat"; import { runWithWorkspaceTeamId } from "@/chat/ingress/workspace-membership"; import { getConversationWorkState } from "@/chat/task-execution/store"; @@ -15,6 +18,7 @@ import { slackWebhookRequest, } from "../../fixtures/conversation-work"; import { createSlackWebhookTestClient } from "../../fixtures/slack/webhook-client"; +import { SUITE_EXPERIMENTAL } from "../../fixtures/experimental-setup"; import { createTestMessage } from "../../fixtures/slack-harness"; import { slackApiOutbox } from "../../fixtures/slack-api-outbox"; import { usersInfoOk } from "../../fixtures/slack/factories/api"; @@ -24,6 +28,7 @@ import { queueSlackApiResponse, queueSlackApiError, } from "../../msw/handlers/slack-api"; +import { mswServer } from "../../msw/server"; const SIGNING_SECRET = "test-signing-secret"; @@ -31,6 +36,7 @@ describe("Slack webhook auth boundary", () => { afterEach(async () => { vi.unstubAllEnvs(); vi.useRealTimers(); + await disconnectStateAdapter(); await closeDb(); }); @@ -255,6 +261,196 @@ describe("Slack webhook auth boundary", () => { await state.disconnect(); }); + it("asks Slack members for a recent Google sign-in shared with the dashboard", async () => { + vi.stubEnv("GOOGLE_CLIENT_ID", "google-client"); + vi.stubEnv("GOOGLE_CLIENT_SECRET", "google-secret"); + const baseURL = "https://junior.example.com"; + const queue = createConversationWorkQueueTestAdapter(); + // The production Slack adapter uses SLACK_SIGNING_SECRET from test setup. + const slack = createSlackWebhookTestClient({ + signingSecret: SIGNING_SECRET, + }); + const waitUntil = slack.waitUntil(); + const app = await createApp({ + conversationWorkQueue: () => queue, + // No dashboard app, so core serves the Better Auth routes itself. + dashboard: { + // Two domains, so Google does not limit accounts with `hd`. + allowedGoogleDomains: ["example.com", "example.org"], + baseURL, + disabled: true, + }, + experimental: { ...SUITE_EXPERIMENTAL, "slack-sign-in": true }, + waitUntil: waitUntil.fn, + }); + await getStateAdapter().connect(); + // Fake Google. Each visit signs in as `googleEmail`, or the person + // cancels when it is empty. + let googleEmail = ""; + let googleVisits = 0; + mswServer.use( + http.post("https://oauth2.googleapis.com/token", () => { + const claims = { + aud: "google-client", + email: googleEmail, + email_verified: true, + hd: googleEmail.split("@")[1], + iss: "https://accounts.google.com", + sub: `google-${googleEmail}`, + }; + const payload = Buffer.from(JSON.stringify(claims)).toString( + "base64url", + ); + return HttpResponse.json({ id_token: `e30.${payload}.sig` }); + }), + ); + let sequence = 1; + const send = async (fields: Record = {}) => { + const envelope = slackEventsApiEnvelope({ + eventType: "message", + channel: "D123", + user: "U123", + ts: `1712345.${String(sequence++).padStart(6, "0")}`, + threadTs: "1712345.000001", + text: "hello", + }); + Object.assign(envelope.event, fields); + const response = await app.fetch(slack.event(envelope)); + await waitUntil.flush(); + return response; + }; + const sendBlocked = async () => { + const prompts = getCapturedSlackApiCalls("chat.postMessage").length; + expect((await send()).status).toBe(200); + const calls = getCapturedSlackApiCalls("chat.postMessage"); + expect(calls).toHaveLength(prompts + 1); + expect(calls.at(-1)?.params).toMatchObject({ channel: "D123" }); + return /<([^|>]+)\|/ + .exec(String(calls.at(-1)?.params.text))![1]! + .replaceAll("&", "&"); + }; + // A browser with one cookie jar. It follows redirects through the app + // and the fake Google consent page. + const createBrowser = () => { + const cookies = new Map(); + const fetchApp = async (url: URL, init: RequestInit = {}) => { + if (url.origin === "https://accounts.google.com") { + googleVisits += 1; + const back = new URL(url.searchParams.get("redirect_uri")!); + back.searchParams.set("state", url.searchParams.get("state")!); + if (googleEmail) back.searchParams.set("code", "google-code"); + else back.searchParams.set("error", "access_denied"); + return Response.redirect(back, 302); + } + const headers = new Headers(init.headers); + headers.set( + "cookie", + [...cookies].map(([k, v]) => `${k}=${v}`).join("; "), + ); + const response = await app.fetch( + new Request(url, { ...init, headers }), + ); + for (const cookie of response.headers.getSetCookie()) { + const [pair] = cookie.split(";"); + const name = pair!.slice(0, pair!.indexOf("=")); + const value = pair!.slice(pair!.indexOf("=") + 1); + if (value && !/max-age=0/i.test(cookie)) cookies.set(name, value); + else cookies.delete(name); + } + return response; + }; + const follow = async (response: Response, from: URL) => { + for (;;) { + const location = response.headers.get("location"); + if (!location) return response; + from = new URL(location, from); + response = await fetchApp(from); + } + }; + const visit = async (start: string, email: string) => { + googleEmail = email; + return await follow(await fetchApp(new URL(start)), new URL(start)); + }; + // Like the dashboard client: start Google sign-in with Better Auth. + const signInFromDashboard = async (email: string) => { + googleEmail = email; + const url = new URL("/api/auth/sign-in/social", baseURL); + const started = await fetchApp(url, { + body: JSON.stringify({ + callbackURL: `${baseURL}/`, + provider: "google", + }), + headers: { "content-type": "application/json", origin: baseURL }, + method: "POST", + }); + const { url: google } = (await started.json()) as { url: string }; + return await follow(await fetchApp(new URL(google)), new URL(google)); + }; + const session = async () => + await ( + await fetchApp(new URL("/api/auth/get-session", baseURL)) + ).json(); + return { cookies, session, signInFromDashboard, visit }; + }; + + // Slack Connect authors never get a sign-in link or a Google redirect. + // Membership is checked first, so Slack is not even asked for a profile. + expect( + (await send({ user: "UEXTERNAL", user_team: "TEXTERNAL" })).status, + ).toBe(200); + expect(queue.queuedMessages()).toEqual([]); + expect(getCapturedSlackApiCalls("users.info")).toEqual([]); + expect(slackApiOutbox.messages()).toEqual([]); + expect(getCapturedSlackApiCalls("chat.postEphemeral")).toEqual([]); + expect(googleVisits).toBe(0); + + // A dashboard sign-in before the first Slack message counts for Slack. + const browser = createBrowser(); + await browser.signInFromDashboard("testuser@example.com"); + expect(await browser.session()).toMatchObject({ + user: { email: "testuser@example.com" }, + }); + expect(googleVisits).toBe(1); + // The session cookie never carries the Google account or its tokens. + expect([...browser.cookies.keys()].join()).not.toContain("account"); + expect((await send()).status).toBe(200); + expect(queue.queuedMessages()).toHaveLength(1); + expect(getCapturedSlackApiCalls("chat.postMessage")).toEqual([]); + + // After the max age, Slack asks again. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 8 * 24 * 60 * 60 * 1000); + + // Accounts outside the allowlist, another person's account, or a + // cancelled Google sign-in fail. + for (const [email, status] of [ + ["testuser@contractor.dev", 403], + ["someone@example.com", 403], + ["", 400], + ] as const) { + expect( + (await createBrowser().visit(await sendBlocked(), email)).status, + ).toBe(status); + } + expect(queue.queuedMessages()).toHaveLength(1); + + // The Slack page signs in the matching account. + const link = await sendBlocked(); + const visits = googleVisits; + expect((await browser.visit(link, "testuser@example.com")).status).toBe( + 200, + ); + expect(googleVisits).toBe(visits + 1); + expect((await send()).status).toBe(200); + expect(queue.queuedMessages()).toHaveLength(2); + + // The dashboard uses the session from the Slack page. + expect(await browser.session()).toMatchObject({ + user: { email: "testuser@example.com" }, + }); + expect(googleVisits).toBe(visits + 1); + }); + it.each(["message", "factory"] as const)( "checks membership before SDK routing for a %s", async (mode) => { diff --git a/packages/junior/tests/unit/chat/sign-in-auth.test.ts b/packages/junior/tests/unit/chat/sign-in-auth.test.ts new file mode 100644 index 0000000000..0da9d70991 --- /dev/null +++ b/packages/junior/tests/unit/chat/sign-in-auth.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveSignInAllowlist } from "@/chat/sign-in/allowlist"; +import { createSignInAuth } from "@/chat/sign-in/auth"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("sign-in allowlist", () => { + it("reads dashboard env lists as JSON arrays or comma separated values", () => { + vi.stubEnv( + "JUNIOR_DASHBOARD_GOOGLE_DOMAINS", + '["Sentry.io", "example.com"]', + ); + vi.stubEnv( + "JUNIOR_DASHBOARD_ALLOWED_EMAILS", + "a@example.net, b@example.net", + ); + + expect(resolveSignInAllowlist()).toEqual({ + domains: ["sentry.io", "example.com"], + emails: ["a@example.net", "b@example.net"], + }); + }); + + it("rejects an env list that is not a JSON string array", () => { + vi.stubEnv("JUNIOR_DASHBOARD_GOOGLE_DOMAINS", "[1]"); + + expect(() => resolveSignInAllowlist()).toThrow( + "JUNIOR_DASHBOARD_GOOGLE_DOMAINS must be a JSON string array", + ); + }); +}); + +describe("sign-in session", () => { + it("limits the Google account chooser only when the allowlist is one domain", async () => { + const googleUrl = async (domains: string[], emails: string[]) => { + const auth = createSignInAuth({ + agentName: "junior", + allowlist: { domains, emails }, + authPath: "/api/auth", + baseURL: "https://junior.example.com", + googleClientId: "google-client", + googleClientSecret: "google-secret", + secret: "0123456789abcdef0123456789abcdef", + trustedOrigins: [], + }); + const response = await auth.signInWithGoogle( + new Request("https://junior.example.com/auth/login"), + "https://junior.example.com/", + ); + expect(response.headers.get("set-cookie")).toContain("oauth_state"); + return new URL(response.headers.get("location")!); + }; + + expect((await googleUrl(["example.com"], [])).searchParams.get("hd")).toBe( + "example.com", + ); + expect( + ( + await googleUrl(["example.com"], ["person@partner.dev"]) + ).searchParams.has("hd"), + ).toBe(false); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d423599877..48ed42ec46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,7 +102,7 @@ importers: version: 2.9.0(@opentelemetry/api@1.9.1) '@sentry/junior': specifier: workspace:* - version: file:packages/junior + version: file:packages/junior(@types/node@25.9.1) '@sentry/junior-agent-browser': specifier: workspace:* version: link:../../packages/junior-agent-browser @@ -111,7 +111,7 @@ importers: version: link:../../packages/junior-amplitude '@sentry/junior-dashboard': specifier: workspace:* - version: file:packages/junior-dashboard(@opentelemetry/api@1.9.1)(jiti@2.7.0) + version: file:packages/junior-dashboard(@types/node@25.9.1)(jiti@2.7.0) '@sentry/junior-datadog': specifier: workspace:* version: link:../../packages/junior-datadog @@ -254,6 +254,9 @@ importers: ai: specifier: 6.0.190 version: 6.0.190(zod@4.5.4) + better-auth: + specifier: ^1.6.22 + version: 1.6.22(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.7) chat: specifier: 4.29.0 version: 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) @@ -365,16 +368,13 @@ importers: version: 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@sentry/junior': specifier: workspace:* - version: file:packages/junior + version: file:packages/junior(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7) '@sentry/junior-plugin-api': specifier: workspace:* version: link:../junior-plugin-api '@tanstack/react-query': specifier: ^5.100.14 version: 5.100.14(react@19.2.6) - better-auth: - specifier: ^1.6.22 - version: 1.6.22(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7) hono: specifier: ^4.12.27 version: 4.12.27 @@ -455,7 +455,7 @@ importers: version: 0.85.1(zod@4.5.4) '@sentry/junior': specifier: workspace:* - version: file:packages/junior + version: file:packages/junior(vitest@4.1.7(msw@2.14.6(typescript@6.0.3))(tsx@4.22.3)) '@sentry/junior-github': specifier: workspace:* version: file:packages/junior-github @@ -2703,6 +2703,7 @@ packages: '@opentelemetry/sdk-trace-base@2.9.0': resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' @@ -9773,38 +9774,23 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 - '@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)': - dependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@opentelemetry/semantic-conventions': 1.43.0 - '@standard-schema/spec': 1.1.0 - better-call: 1.3.7(zod@4.5.4) - jose: 6.2.4 - kysely: 0.29.4 - nanostores: 1.4.1 - zod: 4.5.4 - - '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0))': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0) - '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/utils': 0.4.2 - - '@better-auth/kysely-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 optionalDependencies: - kysely: 0.29.4 + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) - '@better-auth/kysely-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + '@better-auth/kysely-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.4 @@ -9814,43 +9800,22 @@ snapshots: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 - '@better-auth/memory-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - '@better-auth/telemetry@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': - dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@better-auth/utils@0.4.2': dependencies: '@noble/hashes': 2.2.0 @@ -11844,13 +11809,12 @@ snapshots: dependencies: '@sentry/conventions': 0.15.1 - '@sentry/junior-dashboard@file:packages/junior-dashboard(@opentelemetry/api@1.9.1)(jiti@2.7.0)': + '@sentry/junior-dashboard@file:packages/junior-dashboard(@types/node@25.9.1)(jiti@2.7.0)': dependencies: '@radix-ui/react-hover-card': 1.1.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@sentry/junior': file:packages/junior + '@sentry/junior': file:packages/junior(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@sentry/junior-plugin-api': file:packages/junior-plugin-api '@tanstack/react-query': 5.100.14(react@19.2.6) - better-auth: 1.6.22(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) hono: 4.12.27 lucide-react: 1.17.0(react@19.2.6) nitro: 3.0.260522-beta(jiti@2.7.0) @@ -11881,7 +11845,6 @@ snapshots: - '@netlify/runtime' - '@node-rs/xxhash' - '@op-engineering/op-sqlite' - - '@opentelemetry/api' - '@opentelemetry/exporter-trace-otlp-http' - '@planetscale/database' - '@prisma/client' @@ -11890,6 +11853,7 @@ snapshots: - '@tanstack/solid-start' - '@tidbcloud/serverless' - '@types/better-sqlite3' + - '@types/node' - '@types/pg' - '@types/react' - '@types/react-dom' @@ -11924,7 +11888,6 @@ snapshots: - mongodb - mysql2 - next - - pg - pg-native - postgres - prisma @@ -12283,7 +12246,195 @@ snapshots: - sql.js - sqlite3 - '@sentry/junior@file:packages/junior': + '@sentry/junior@file:packages/junior(@types/node@25.9.1)': + dependencies: + '@agentclientprotocol/sdk': 1.3.0(zod@4.5.4) + '@ai-sdk/gateway': 3.0.119(zod@4.5.4) + '@chat-adapter/slack': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-memory': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-redis': 4.29.0(@opentelemetry/api@1.9.1)(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-agent-core': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-ai': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@hono/zod-validator': 0.9.0(hono@4.12.27)(zod@4.5.4) + '@logtape/logtape': 2.1.1 + '@modelcontextprotocol/sdk': 1.31.0(zod@4.5.4) + '@neondatabase/serverless': 1.1.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/junior-plugin-api': file:packages/junior-plugin-api + '@sentry/node': 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) + '@sinclair/typebox': 0.34.49 + '@slack/types': 2.21.1 + '@slack/web-api': 7.16.0 + '@vercel/blob': 2.8.0 + '@vercel/functions': 3.6.0 + '@vercel/oidc': 3.4.1 + '@vercel/queue': 0.2.0 + '@vercel/sandbox': 2.8.0 + ai: 6.0.190(zod@4.5.4) + better-auth: 1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0) + chat: 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + commander: 14.0.3 + diff: 8.0.4 + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + hono: 4.12.27 + jiti: 2.7.0 + jose: 6.2.3 + just-bash: 3.0.1 + node-html-markdown: 2.0.0 + pg: 8.21.0 + sharp: 0.35.3(@types/node@25.9.1) + yaml: 2.9.0 + zod: 4.5.4 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@aws-sdk/credential-provider-web-identity' + - '@cfworker/json-schema' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@lynx-js/react' + - '@node-rs/xxhash' + - '@op-engineering/op-sqlite' + - '@opentelemetry/exporter-trace-otlp-http' + - '@planetscale/database' + - '@prisma/client' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/node' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - bare-abort-controller + - better-sqlite3 + - bufferutil + - bun-types + - debug + - drizzle-kit + - expo-sqlite + - gel + - knex + - kysely + - mongodb + - mysql2 + - next + - pg-native + - postgres + - prisma + - react + - react-dom + - react-native-b4a + - solid-js + - sql.js + - sqlite3 + - supports-color + - svelte + - utf-8-validate + - vitest + - vue + - ws + + '@sentry/junior@file:packages/junior(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@agentclientprotocol/sdk': 1.3.0(zod@4.5.4) + '@ai-sdk/gateway': 3.0.119(zod@4.5.4) + '@chat-adapter/slack': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-memory': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-redis': 4.29.0(@opentelemetry/api@1.9.1)(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-agent-core': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-ai': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@hono/zod-validator': 0.9.0(hono@4.12.27)(zod@4.5.4) + '@logtape/logtape': 2.1.1 + '@modelcontextprotocol/sdk': 1.31.0(zod@4.5.4) + '@neondatabase/serverless': 1.1.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/junior-plugin-api': file:packages/junior-plugin-api + '@sentry/node': 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) + '@sinclair/typebox': 0.34.49 + '@slack/types': 2.21.1 + '@slack/web-api': 7.16.0 + '@vercel/blob': 2.8.0 + '@vercel/functions': 3.6.0 + '@vercel/oidc': 3.4.1 + '@vercel/queue': 0.2.0 + '@vercel/sandbox': 2.8.0 + ai: 6.0.190(zod@4.5.4) + better-auth: 1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + chat: 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + commander: 14.0.3 + diff: 8.0.4 + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + hono: 4.12.27 + jiti: 2.7.0 + jose: 6.2.3 + just-bash: 3.0.1 + node-html-markdown: 2.0.0 + pg: 8.21.0 + sharp: 0.35.3(@types/node@25.9.1) + yaml: 2.9.0 + zod: 4.5.4 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@aws-sdk/credential-provider-web-identity' + - '@cfworker/json-schema' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@lynx-js/react' + - '@node-rs/xxhash' + - '@op-engineering/op-sqlite' + - '@opentelemetry/exporter-trace-otlp-http' + - '@planetscale/database' + - '@prisma/client' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/node' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - bare-abort-controller + - better-sqlite3 + - bufferutil + - bun-types + - debug + - drizzle-kit + - expo-sqlite + - gel + - knex + - kysely + - mongodb + - mysql2 + - next + - pg-native + - postgres + - prisma + - react + - react-dom + - react-native-b4a + - solid-js + - sql.js + - sqlite3 + - supports-color + - svelte + - utf-8-validate + - vitest + - vue + - ws + + '@sentry/junior@file:packages/junior(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7)': dependencies: '@agentclientprotocol/sdk': 1.3.0(zod@4.5.4) '@ai-sdk/gateway': 3.0.119(zod@4.5.4) @@ -12309,6 +12460,101 @@ snapshots: '@vercel/queue': 0.2.0 '@vercel/sandbox': 2.8.0 ai: 6.0.190(zod@4.5.4) + better-auth: 1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7) + chat: 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + commander: 14.0.3 + diff: 8.0.4 + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + hono: 4.12.27 + jiti: 2.7.0 + jose: 6.2.3 + just-bash: 3.0.1 + node-html-markdown: 2.0.0 + pg: 8.21.0 + sharp: 0.35.3(@types/node@25.9.1) + yaml: 2.9.0 + zod: 4.5.4 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@aws-sdk/credential-provider-web-identity' + - '@cfworker/json-schema' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@lynx-js/react' + - '@node-rs/xxhash' + - '@op-engineering/op-sqlite' + - '@opentelemetry/exporter-trace-otlp-http' + - '@planetscale/database' + - '@prisma/client' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/node' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - bare-abort-controller + - better-sqlite3 + - bufferutil + - bun-types + - debug + - drizzle-kit + - expo-sqlite + - gel + - knex + - kysely + - mongodb + - mysql2 + - next + - pg-native + - postgres + - prisma + - react + - react-dom + - react-native-b4a + - solid-js + - sql.js + - sqlite3 + - supports-color + - svelte + - utf-8-validate + - vitest + - vue + - ws + + '@sentry/junior@file:packages/junior(vitest@4.1.7(msw@2.14.6(typescript@6.0.3))(tsx@4.22.3))': + dependencies: + '@agentclientprotocol/sdk': 1.3.0(zod@4.5.4) + '@ai-sdk/gateway': 3.0.119(zod@4.5.4) + '@chat-adapter/slack': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-memory': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-redis': 4.29.0(@opentelemetry/api@1.9.1)(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-agent-core': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-ai': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@hono/zod-validator': 0.9.0(hono@4.12.27)(zod@4.5.4) + '@logtape/logtape': 2.1.1 + '@modelcontextprotocol/sdk': 1.31.0(zod@4.5.4) + '@neondatabase/serverless': 1.1.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/junior-plugin-api': file:packages/junior-plugin-api + '@sentry/node': 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) + '@sinclair/typebox': 0.34.49 + '@slack/types': 2.21.1 + '@slack/web-api': 7.16.0 + '@vercel/blob': 2.8.0 + '@vercel/functions': 3.6.0 + '@vercel/oidc': 3.4.1 + '@vercel/queue': 0.2.0 + '@vercel/sandbox': 2.8.0 + ai: 6.0.190(zod@4.5.4) + better-auth: 1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.7(msw@2.14.6(typescript@6.0.3))(tsx@4.22.3)) chat: 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) commander: 14.0.3 diff: 8.0.4 @@ -12330,13 +12576,18 @@ snapshots: - '@electric-sql/pglite' - '@libsql/client' - '@libsql/client-wasm' + - '@lynx-js/react' - '@node-rs/xxhash' - '@op-engineering/op-sqlite' - '@opentelemetry/exporter-trace-otlp-http' - '@planetscale/database' - '@prisma/client' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' - '@tidbcloud/serverless' - '@types/better-sqlite3' + - '@types/node' - '@types/pg' - '@types/sql.js' - '@upstash/redis' @@ -12347,19 +12598,28 @@ snapshots: - bufferutil - bun-types - debug + - drizzle-kit - expo-sqlite - gel - knex - kysely + - mongodb - mysql2 + - next - pg-native - postgres - prisma + - react + - react-dom - react-native-b4a + - solid-js - sql.js - sqlite3 - supports-color + - svelte - utf-8-validate + - vitest + - vue - ws '@sentry/nitro@10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(nitro@3.0.260522-beta(jiti@2.7.0))': @@ -13680,10 +13940,10 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 - better-auth@1.6.22(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + better-auth@1.6.22(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.7): dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0)) '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) @@ -13700,21 +13960,77 @@ snapshots: nanostores: 1.4.1 zod: 4.5.4 optionalDependencies: + drizzle-kit: 0.31.10 + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0) + pg: 8.21.0 + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(tsx@4.22.3)(yaml@2.9.0) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-auth@1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0): + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0)) + '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.7(zod@4.5.4) + defu: 6.1.7 + jose: 6.2.4 + kysely: 0.29.4 + nanostores: 1.4.1 + zod: 4.5.4 + optionalDependencies: + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + pg: 8.21.0 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-auth@1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0)) + '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.7(zod@4.5.4) + defu: 6.1.7 + jose: 6.2.4 + kysely: 0.29.4 + nanostores: 1.4.1 + zod: 4.5.4 + optionalDependencies: + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + pg: 8.21.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-auth@1.6.22(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7): + better-auth@1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7): dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) - '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0)) + '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.2.0 @@ -13726,6 +14042,8 @@ snapshots: nanostores: 1.4.1 zod: 4.5.4 optionalDependencies: + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + pg: 8.21.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) vitest: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(tsx@4.22.3) @@ -13733,6 +14051,33 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' + better-auth@1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.7(msw@2.14.6(typescript@6.0.3))(tsx@4.22.3)): + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0)) + '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.7(zod@4.5.4) + defu: 6.1.7 + jose: 6.2.4 + kysely: 0.29.4 + nanostores: 1.4.1 + zod: 4.5.4 + optionalDependencies: + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + pg: 8.21.0 + vitest: 4.1.7(msw@2.14.6(typescript@6.0.3))(tsx@4.22.3) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + better-call@1.3.7(zod@4.5.4): dependencies: '@better-auth/utils': 0.4.2 @@ -17488,7 +17833,6 @@ snapshots: '@img/sharp-win32-arm64': 0.35.3 '@img/sharp-win32-ia32': 0.35.3 '@img/sharp-win32-x64': 0.35.3 - optional: true sharp@0.35.3(@types/node@25.9.1): dependencies: diff --git a/policies/provider-boundaries.md b/policies/provider-boundaries.md index b6d040f617..edb8310aa7 100644 --- a/policies/provider-boundaries.md +++ b/policies/provider-boundaries.md @@ -35,5 +35,8 @@ runtime and the provider-neutral contracts that plugins use. must not perform the provider action itself. - Slack runtime and delivery code remains in core until Slack is a plugin. Keep Slack behavior in Slack-owned modules and out of shared services. +- Core owns the native Slack and Google identities and the Google sign-in that + links a Google identity. Plugins must not link identities under these + provider names. See `packages/junior/src/chat/identities/README.md`. - Existing domain-specific core behavior is debt, not a pattern for new code. Move it to the owning plugin when the touched contract makes that practical.