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
1 change: 0 additions & 1 deletion apps/example/nitro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const config = defineConfig({
juniorNitro({
dashboard: {
authRequired: exampleDashboardAuthRequired(),
allowedGoogleDomains: ["sentry.io"],
componentGallery: exampleDashboardComponentGallery(),
mockConversations: exampleDashboardMockConversations(),
},
Expand Down
2 changes: 1 addition & 1 deletion apps/example/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ const [
]);

const app = await createApp({
allowedDomains: ["sentry.io"],
briefs: { enabled: true },
dashboard: {
authRequired: exampleDashboardAuthRequired(),
allowedGoogleDomains: ["sentry.io"],
componentGallery: exampleDashboardComponentGallery(),
mockConversations: exampleDashboardMockConversations(),
},
Expand Down
13 changes: 6 additions & 7 deletions 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. 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.
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. The allowed Google domains also limit who can use Junior in Slack. See [Allowed domains](/reference/config-and-env/#allowed-domains).

## Install

Expand All @@ -25,16 +25,17 @@ pnpm add @sentry/junior-dashboard

## Configure the dashboard

Pass `dashboard` to `createApp()`. Configure the Google Workspace domain that
should be allowed to view the dashboard:
Pass `dashboard` to `createApp()`. Dashboard sign-in requires at least one
[allowed domain](/reference/config-and-env/#allowed-domains). The same list
also gates who can use Junior in Slack:

```ts title="server.ts"
import { createApp } from "@sentry/junior";
import { plugins } from "./plugins";

export default await createApp({
allowedDomains: ["example.com"],
dashboard: {
allowedGoogleDomains: ["sentry.io"],
trustedOrigins: ["https://<your-domain>"],
},
plugins,
Expand All @@ -52,7 +53,6 @@ export default defineConfig({
modules: [
juniorNitro({
dashboard: {
allowedGoogleDomains: ["sentry.io"],
trustedOrigins: ["https://<your-domain>"],
},
plugins: "./plugins",
Expand All @@ -68,8 +68,7 @@ You can also provide the same authorization policy through deployment environmen

| Variable | Purpose |
| ---------------------------------- | ------------------------------------------------------------- |
| `JUNIOR_DASHBOARD_GOOGLE_DOMAINS` | Comma-separated or JSON array of allowed Google domains. |
| `JUNIOR_DASHBOARD_ALLOWED_EMAILS` | Comma-separated or JSON array of explicit email allowlist. |
| `JUNIOR_ALLOWED_DOMAINS` | Comma-separated or JSON array of allowed domains. |
| `JUNIOR_DASHBOARD_TRUSTED_ORIGINS` | Comma-separated or JSON array of Better Auth trusted origins. |
| `JUNIOR_DASHBOARD_AUTH_REQUIRED` | Set to `false` only for explicit local dashboard auth bypass. |

Expand Down
50 changes: 30 additions & 20 deletions packages/docs/src/content/docs/reference/config-and-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,10 @@ If you mount `@sentry/junior-dashboard`, set these browser-auth variables:
| `GOOGLE_CLIENT_SECRET` | Yes | Google OAuth client secret. |
| `BETTER_AUTH_SECRET` | No | Optional override for dashboard cookies. Defaults to `JUNIOR_SECRET`. |

Configure allowed Google Workspace domains in `createApp({ dashboard })` and `juniorNitro({ dashboard })` for normal deployments. Set these optional policy variables when you prefer environment-managed dashboard authorization:
Dashboard auth requires at least one [allowed domain](#allowed-domains). Set these optional policy variables when you prefer environment-managed dashboard authorization:

| Variable | Required | Purpose |
| ------------------------------------- | -------- | --------------------------------------------------------------------------------------- |
| `JUNIOR_DASHBOARD_GOOGLE_DOMAINS` | No | Comma-separated or JSON array of allowed Google domains. |
| `JUNIOR_DASHBOARD_ALLOWED_EMAILS` | No | Comma-separated or JSON array of explicit email allowlist. |
| `JUNIOR_DASHBOARD_TRUSTED_ORIGINS` | No | Comma-separated or JSON array of Better Auth trusted origins. |
| `JUNIOR_DASHBOARD_MOCK_CONVERSATIONS` | No | Set to `true` to replace conversation API responses with local/demo visual-QA fixtures. |

Expand Down Expand Up @@ -168,9 +166,6 @@ 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 @@ -214,20 +209,35 @@ 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.
## Allowed domains

`createApp({ allowedDomains })` is one list of domains for Slack and the
dashboard. When the option is unset, Junior reads `JUNIOR_ALLOWED_DOMAINS`, a
comma-separated or JSON array of domains. It is optional, but we recommend it.
Junior logs a warning at startup when it is empty.

```ts title="server.ts"
export default await createApp({
allowedDomains: ["example.com"],
plugins,
});
```

- **Slack:** Junior starts work from a mention or DM only when the email of the
author's Slack profile is on an allowed domain. Slack verifies that email.
Junior drops other messages and does not reply. Passive thread replies, slash
commands, and Slack actions are not checked yet.
- **Dashboard:** Google sign-in accepts only Google Workspace accounts on an
allowed domain. Junior checks the verified `hd` claim of the Google ID token,
not the email. With one domain, Junior also sends it to Google as the `hd`
hint. With more domains, it sends `hd=*`, so Google shows only Workspace
accounts. Dashboard auth requires at least one domain.

When the list is empty, every member of the Slack workspace can use Junior.

The old `dashboard.allowedGoogleDomains` option and
`JUNIOR_DASHBOARD_GOOGLE_DOMAINS` variable were removed. Junior fails at
startup when either is set, so a stale config cannot silently allow everyone.

`operator-tools` adds `runOperatorSql` to non-public Conversations. The tool
runs one SQL statement against the app database and returns up to 200 rows.
Expand Down
5 changes: 5 additions & 0 deletions packages/docs/src/content/docs/start-here/existing-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,17 @@ import { createApp } from "@sentry/junior";
import { plugins } from "./plugins.ts";

const app = await createApp({
allowedDomains: ["example.com"],
plugins,
});

export default app;
```

Replace `example.com` with your company's email domain. Only Slack users and
dashboard sign-ins on [allowed domains](/reference/config-and-env/#allowed-domains)
can use Junior.

`createApp()` mounts the supported Junior routes listed in [Route & Handler Surface](/reference/handler-surface/).

## Add Nitro wiring
Expand Down
5 changes: 5 additions & 0 deletions packages/docs/src/content/docs/start-here/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,17 @@ import { createApp } from "@sentry/junior";
import { plugins } from "./plugins.ts";

const app = await createApp({
allowedDomains: ["example.com"],
plugins,
});

export default app;
```

Replace `example.com` with your company's email domain. Only Slack users and
dashboard sign-ins on [allowed domains](/reference/config-and-env/#allowed-domains)
can use Junior.

Run the app check after changing plugins or skills:

```bash
Expand Down
6 changes: 3 additions & 3 deletions packages/junior-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ name, through dashboard settings.

- `createDashboardApp` mounts the dashboard routes and receives host
configuration through `JuniorDashboardOptions`.
- 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.
- Core owns the Better Auth session and passes it in as `auth`. Core accepts
only Google Workspace accounts on the allowed domains. 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
2 changes: 1 addition & 1 deletion packages/junior-dashboard/e2e/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export async function startDashboardE2eServer(
process.env.DATABASE_URL ??= "postgres://localhost/junior-dashboard-e2e";
const { createDashboardApp } = await import("../dist/app.js");
const app = createDashboardApp({
allowedEmails: ["dev@example.com"],
allowedDomains: ["example.com"],
auth: {
async getSession() {
return {
Expand Down
3 changes: 1 addition & 2 deletions packages/junior-dashboard/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ export const dashboardProfileUpdateSchema = z

export const dashboardConfigSchema = z
.object({
allowedEmailCount: z.number(),
allowedGoogleDomainCount: z.number(),
allowedDomainCount: z.number(),
authRequired: z.boolean(),
authPath: z.string(),
basePath: z.string(),
Expand Down
65 changes: 9 additions & 56 deletions packages/junior-dashboard/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,14 @@ export interface JuniorDashboardOptions {
baseURL?: string;
authPath?: string;
authRequired?: boolean;
allowedGoogleDomains?: string[];
allowedEmails?: string[];
auth?: DashboardAuth;
componentGallery?: boolean;
mockConversations?: boolean;
}

interface DashboardRuntimeOptions extends JuniorDashboardOptions {
/** The app's `allowedDomains`, from core. Auth requires at least one. */
allowedDomains?: string[];
authenticatedRoutes?: readonly AuthenticatedRoute[];
/** The queue `createApp()` uses, so web input reaches the same worker. */
conversationWorkQueue?: ConversationWorkQueue;
Expand Down Expand Up @@ -113,37 +113,6 @@ function normalizeValues(values: string[] | undefined): string[] {
];
}

/** Read dashboard list env vars as comma-separated strings or JSON arrays. */
function readEnvList(name: string): string[] | undefined {
const value = process.env[name];
if (!value?.trim()) {
return undefined;
}

if (value.trim().startsWith("[")) {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error(`${name} must be a JSON string array`, {
cause: error,
});
}
if (
!Array.isArray(parsed) ||
parsed.some((item) => typeof item !== "string")
) {
throw new Error(`${name} must be a JSON string array`);
}
return parsed;
}

return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}

/** Read dashboard boolean env vars; only explicit true/false values apply. */
function readEnvFlag(name: string): boolean | undefined {
const value = process.env[name]?.trim();
Expand All @@ -160,11 +129,6 @@ function resolveDashboardOptions(
...options,
authRequired:
options.authRequired ?? readEnvFlag("JUNIOR_DASHBOARD_AUTH_REQUIRED"),
allowedGoogleDomains:
options.allowedGoogleDomains ??
readEnvList("JUNIOR_DASHBOARD_GOOGLE_DOMAINS"),
allowedEmails:
options.allowedEmails ?? readEnvList("JUNIOR_DASHBOARD_ALLOWED_EMAILS"),
componentGallery:
options.componentGallery ??
readEnvFlag("JUNIOR_DASHBOARD_COMPONENT_GALLERY"),
Expand Down Expand Up @@ -404,8 +368,7 @@ export function createDashboardApp(
options.authPath ?? DEFAULT_AUTH_PATH,
DEFAULT_AUTH_PATH,
);
const allowedDomains = normalizeValues(options.allowedGoogleDomains);
const allowedEmails = normalizeValues(options.allowedEmails);
const allowedDomains = normalizeValues(options.allowedDomains);
const agentName = options.agentName?.trim() || "Junior";

const authRequired = options.authRequired !== false;
Expand All @@ -415,17 +378,11 @@ export function createDashboardApp(
canonicalBaseURL = resolveDashboardBaseURL({ baseURL: configuredBaseURL });
}

if (
authRequired &&
allowedDomains.length === 0 &&
allowedEmails.length === 0
) {
throw new Error(
"Junior dashboard auth requires allowedGoogleDomains or allowedEmails",
);
if (authRequired && allowedDomains.length === 0) {
throw new Error("Junior dashboard auth requires allowedDomains");
}

// Core owns the Google sign-in session and shares it with Slack.
// Core owns the Google sign-in session.
const auth = options.auth;
if (authRequired && !auth) {
throw new Error("Junior dashboard auth requires the core sign-in session");
Expand All @@ -448,10 +405,7 @@ export function createDashboardApp(
return Response.redirect(returnUrl, 302);
}
const session = await auth.getSession(c.req.raw);
if (
session &&
dashboardSessionIsAuthorized(session, allowedDomains, allowedEmails)
) {
if (session && dashboardSessionIsAuthorized(session, allowedDomains)) {
return Response.redirect(returnUrl, 302);
}
return auth.signInWithGoogle(c.req.raw, returnUrl);
Expand Down Expand Up @@ -540,7 +494,7 @@ export function createDashboardApp(
componentGallery: options.componentGallery,
});
}
if (!dashboardSessionIsAuthorized(session, allowedDomains, allowedEmails)) {
if (!dashboardSessionIsAuthorized(session, allowedDomains)) {
return forbidden(c.req.raw, agentName);
}
const sanitizedSession = sanitizeDashboardSession(session);
Expand Down Expand Up @@ -622,8 +576,7 @@ export function createDashboardApp(
);
app.get("/api/config", () => {
return jsonResponse(dashboardConfigSchema, {
allowedEmailCount: allowedEmails.length,
allowedGoogleDomainCount: allowedDomains.length,
allowedDomainCount: allowedDomains.length,
authRequired,
authPath,
basePath,
Expand Down
13 changes: 8 additions & 5 deletions packages/junior-dashboard/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { DashboardSession } from "./api/schema";
export type DashboardUser = DashboardSession["user"];
export type { DashboardSession };

/** Google sign-in session that core shares with Slack. */
/** Google sign-in session that core owns. */
export interface DashboardAuth {
handler(request: Request): Promise<Response>;
getSession(request: Request): Promise<DashboardSession | null>;
Expand All @@ -25,19 +25,22 @@ export function dashboardBearerSession(email: string): DashboardSession {
return { user: { email, emailVerified: true } };
}

/** Check the dashboard allowlist against one verified session. */
/**
* Check the allowed domains against one verified session. Core accepts a
* Google sign-in only for a Workspace account on an allowed domain. This
* check also applies a changed list to existing sessions and personal tokens.
*/
export function dashboardSessionIsAuthorized(
session: DashboardSession,
allowedDomains: string[],
allowedEmails: string[],
): boolean {
const email = session.user.email.toLowerCase();
const separator = email.lastIndexOf("@");
const domain = separator > 0 ? email.slice(separator + 1) : undefined;
return Boolean(
session.user.emailVerified &&
(allowedEmails.includes(email) ||
(domain !== undefined && allowedDomains.includes(domain))),
domain !== undefined &&
allowedDomains.includes(domain),
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/junior-dashboard/tests/client-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe("dashboard client API", () => {
user: { email: "dev@example.com", emailVerified: true },
});
const app = createDashboardApp({
allowedEmails: ["dev@example.com"],
allowedDomains: ["example.com"],
mockConversations: true,
auth: {
...sessionAuth,
Expand Down
Loading
Loading