Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/operate/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions packages/docs/src/content/docs/reference/config-and-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
```
Expand Down Expand Up @@ -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 `<base-url>/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
Expand Down
5 changes: 3 additions & 2 deletions packages/junior-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion packages/junior-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 6 additions & 18 deletions packages/junior-dashboard/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,9 @@ import {
dashboardProfileUpdateSchema,
} from "./api/schema";
import {
createDashboardAuth,
dashboardBearerSession,
dashboardPersonalBearerToken,
dashboardSessionIsAuthorized,
resolveGoogleHostedDomainHint,
sanitizeDashboardSession,
verifiedDashboardSessionEmail,
type DashboardAuth,
Expand Down Expand Up @@ -82,8 +80,6 @@ export interface JuniorDashboardOptions {
authRequired?: boolean;
allowedGoogleDomains?: string[];
allowedEmails?: string[];
sessionMaxAgeSeconds?: number;
trustedOrigins?: string[];
auth?: DashboardAuth;
componentGallery?: boolean;
mockConversations?: boolean;
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
Expand Down
128 changes: 1 addition & 127 deletions packages/junior-dashboard/src/auth.ts
Original file line number Diff line number Diff line change
@@ -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<Response>;
getSession(request: Request): Promise<DashboardSession | null>;
Expand Down Expand Up @@ -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()));
}
123 changes: 1 addition & 122 deletions packages/junior-dashboard/tests/auth-config.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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");
});
});
Loading
Loading