From 4d13c36b732389991f69c304a161c8e68dc5dd15 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 00:58:59 +0100 Subject: [PATCH 1/5] feat(shell): give observers their own sidebar and command palette An observer follows the product without operating it, so the shell shows them the pages that answer whether it works and how it is doing: incidents with uptime and SLOs, errors, status pages, web analytics, users, replays, dashboards, releases and cost. The explorers, infrastructure, alert setup, review, security, Sources, Settings and the Connect button are left out. The command palette lists the same pages plus dashboards and the app actions. Other pages still open from a link: nothing is redirected, and the API refuses any change. The shell lab gains a viewer toggle to draw the sidebar for an observer. --- apps/web/src/lab/shell-frame.ts | 4 +- apps/web/src/lab/shell-states-lab.ts | 30 ++++++- apps/web/src/shell/model.ts | 9 +- apps/web/src/shell/nav.test.ts | 35 +++++++- apps/web/src/shell/nav.ts | 82 ++++++++++++++++++- apps/web/src/shell/palette/entries.ts | 53 +++++++----- .../shell/palette/global-shortcuts.test.ts | 7 +- apps/web/src/shell/palette/model.ts | 3 +- apps/web/src/shell/palette/palette.test.ts | 33 +++++++- apps/web/src/shell/palette/update.ts | 37 ++++++--- apps/web/src/shell/palette/view.ts | 11 ++- apps/web/src/shell/view-nav.ts | 18 ++-- apps/web/src/shell/view-sidebar.ts | 33 ++++++-- apps/web/src/shell/view.ts | 9 +- 14 files changed, 302 insertions(+), 62 deletions(-) diff --git a/apps/web/src/lab/shell-frame.ts b/apps/web/src/lab/shell-frame.ts index dd99c21860..889bdc871e 100644 --- a/apps/web/src/lab/shell-frame.ts +++ b/apps/web/src/lab/shell-frame.ts @@ -2,7 +2,7 @@ import { Option } from "effect" import { Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import type { Message as ShellMessage } from "../shell/message" -import type { Model as ShellModel } from "../shell/model" +import type { Model as ShellModel, ShellClerk } from "../shell/model" import { update as shellUpdate } from "../shell/update" import { type BreadcrumbEntry, frameView, menuLayerView, sheetLayerView } from "../shell/view" @@ -30,6 +30,8 @@ export const labShellView = ( currentPath: string breadcrumbs: ReadonlyArray page: () => Html + /** Who the shell is drawn for; a lab has no Clerk session, so it hands in a fixture. */ + clerk?: ShellClerk | null }>, ): ReadonlyArray => [ h.submodel({ diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 49d780bef8..2234470dbc 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -22,7 +22,8 @@ import type { Resources } from "../platform/resources" import { dashboardPage } from "../page/kit/dashboard-page" import { dismissSheetUrl, pushSheetUrl, sheetParamOf } from "../page/kit/sheet-param" import { Message as ShellMessage, Theme } from "../shell/message" -import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model" +import { init as initShell, Model as ShellModel, OnCallData, type ShellClerk } from "../shell/model" +import { NavAudience } from "../shell/nav" import type { OnCallSnapshot } from "../shell/oncall" import { foldLabShell, labShellView } from "./shell-frame" @@ -106,6 +107,7 @@ export const Model = Schema.Struct({ href: Schema.String, onCall: OnCallFixture, standOn: StandOn, + viewer: NavAudience, }) export type Model = typeof Model.Type @@ -119,6 +121,7 @@ export const Message = defineMessageUnion({ ClickedSubmitPanel: {}, ClickedOnCallFixture: { fixture: OnCallFixture }, ClickedStandOn: { standOn: StandOn }, + ClickedViewer: { viewer: NavAudience }, CompletedNavigate: {}, }) export type Message = typeof Message.Type @@ -164,6 +167,15 @@ const sheetStep = (model: Model, step: SheetStep): Return => ({ ], }) +/** A signed-in observer with nothing else read yet: all the shell needs to pick their sidebar. */ +const OBSERVER_CLERK: ShellClerk = { + maybeUser: Option.none(), + maybeOrganization: Option.none(), + memberships: [], + customer: undefined, + maybeRole: Option.some("org:observer"), +} + /** The fixture's shifts, read on the lab's fixed clock. */ const withOnCall = (shell: ShellModel, fixture: OnCallFixture): ShellModel => ({ ...shell, @@ -184,6 +196,7 @@ export const init = (flags: Flags): Return => { href: flags.href, onCall: "onCall", standOn: "lab", + viewer: "operator", }, commands: mapSheetCommands(sheet, toSheet), } @@ -222,6 +235,7 @@ export const update = (model: Model, message: Message): Return => model: { ...model, onCall: fixture, shell: withOnCall(model.shell, fixture) }, }), ClickedStandOn: ({ standOn }) => ({ model: { ...model, standOn } }), + ClickedViewer: ({ viewer }) => ({ model: { ...model, viewer } }), CompletedNavigate: () => ({ model }), }) @@ -341,6 +355,19 @@ const page = (h: HtmlBuilder, model: Model): Html => ], }), ), + control( + h, + "Viewer", + segmentedSelect(h, { + ariaLabel: "Viewer", + value: model.viewer, + onChange: (viewer) => Message.ClickedViewer({ viewer }), + options: [ + { value: "operator", label: "Member and above" }, + { value: "observer", label: "Observer" }, + ], + }), + ), control( h, "Current page", @@ -368,6 +395,7 @@ export const view = (model: Model, h: HtmlBuilder): Html => TooltipHost.view(h, model.tooltip, toTooltip), ...labShellView(h, model.shell, toShell, { currentPath: STAND_ON_PATH[model.standOn], + clerk: model.viewer === "observer" ? OBSERVER_CLERK : null, breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], page: () => page(h, model), }), diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index dfee746a4c..b205ea7592 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -2,6 +2,7 @@ import { InfraSurfaceLiteral } from "@maple/domain/http" import { V2Source } from "@maple/domain/http/v2" import type { BillingCustomer } from "@maple/domain/http" import type { ClerkOrganization, ClerkUser } from "../platform/clerk" +import * as Policy from "@maple/domain/policy" import { Option, Schema } from "effect" import { AsyncData } from "foldkit" import { PopupPosition } from "@maple/views/ui/anchor" @@ -15,7 +16,7 @@ import { PinnedDashboardRow } from "./dashboards-shape" import * as Palette from "./palette/model" import { Platform } from "./palette/shortcuts" import { Menu, Theme } from "./message" -import { NavSurface } from "./nav" +import { type NavAudience, NavSurface } from "./nav" import { OnCallSnapshot } from "./oncall" import { type InfraSurfaces, @@ -133,6 +134,12 @@ export type ShellClerk = Readonly<{ maybeRole: Option.Option }> +/** Who the shell is drawn for; self-hosted has no Clerk role, so it is never an observer. */ +export const shellAudience = (clerk: ShellClerk | null): NavAudience => + clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.isReadOnly([role])) + ? "observer" + : "operator" + /** A rail item's tooltip: right of the item, centred. */ export const initRailTooltip = (): Tooltip.Model => Tooltip.init("shell-rail-tooltip", { side: "right", align: "center" }) diff --git a/apps/web/src/shell/nav.test.ts b/apps/web/src/shell/nav.test.ts index f12651beb7..fec17df6ac 100644 --- a/apps/web/src/shell/nav.test.ts +++ b/apps/web/src/shell/nav.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest" -import { isNavItemActive, type NavFlags, navGroups, navPreview, partitionSubItems } from "./nav" +import { isNavItemActive, type NavFlags, navGroups, navHrefs, navPreview, partitionSubItems } from "./nav" import { Array as Arr, Option } from "effect" const infrastructure = Option.getOrUndefined( @@ -62,6 +62,39 @@ describe("isNavItemActive", () => { }) }) +describe("navGroups for an observer", () => { + const off = { cloudCost: false, releases: false, securityAudit: false, statusPages: false } + const hrefs = (flags: NavFlags) => navHrefs(navGroups(flags, "observer")) + + test("links to what is wrong and how the product is doing, and to no setup or explorer page", () => { + expect(hrefs(off)).toEqual([ + "/", + "/alerts", + "/alerts/uptime", + "/alerts/slos", + "/errors", + "/analytics", + "/users", + "/replays", + "/dashboards", + ]) + }) + + test("adds Status pages, Releases and Cost only for an org in each rollout", () => { + expect(hrefs({ ...off, statusPages: true })).toContain("/status-pages") + expect(hrefs({ ...off, releases: true })).toContain("/releases") + expect(hrefs({ ...off, cloudCost: true })).toContain("/cost") + // Security is an engineer's page, whatever the flag says. + expect(hrefs({ ...off, securityAudit: true })).not.toContain("/security") + }) + + test("marks only Incidents on an uptime page, with its Uptime row", () => { + const items = Arr.flatMap(navGroups(off, "observer"), (group) => group.items) + const active = Arr.filter(items, (item) => isNavItemActive("/alerts/uptime", item)) + expect(Arr.map(active, (item) => item.title)).toEqual(["Incidents"]) + }) +}) + describe("navGroups", () => { const titles = (flags: NavFlags) => Arr.flatMap(navGroups(flags), (group) => Arr.map(group.items, (item) => item.title)) diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index a5c643785e..b0156fc76f 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -182,6 +182,33 @@ const alertsItem: NavItem = { ], } +/** The observer's Alerts: what is wrong now and how reliable things are, without the setup pages. */ +const observerIncidentsItem: NavItem = { + title: "Incidents", + href: "/alerts", + icon: "BellIcon", + alsoActive: ["/anomalies"], + subItems: [ + { + title: "Inbox", + href: "/alerts", + icon: "BellIcon", + paletteTitle: "Incidents", + alsoActive: ["/anomalies"], + count: "alertsInbox", + }, + { title: "Uptime", href: "/alerts/uptime", icon: "GlobeIcon", paletteTitle: "Uptime checks" }, + { title: "SLOs", href: "/alerts/slos", icon: "GaugeIcon" }, + ], +} + +/** + * Who the sidebar is drawn for. An observer follows the product without operating it, so they get + * the pages that answer "is it working and how is it doing", not the ones for digging or setup. + */ +export const NavAudience = Schema.Literals(["operator", "observer"]) +export type NavAudience = typeof NavAudience.Type + /** The org rollout flags that gate a nav row each. */ export type NavFlags = Readonly<{ cloudCost: boolean @@ -190,8 +217,38 @@ export type NavFlags = Readonly<{ statusPages: boolean }> -/** `navGroups(flags)`; `cloudCost`, `releases`, `securityAudit` and `statusPages` gate a row each. */ -export const navGroups = (flags: NavFlags): ReadonlyArray => [ +const observerNavGroups = (flags: NavFlags): ReadonlyArray => [ + { id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] }, + { + id: "reliability", + label: "Reliability", + items: [ + observerIncidentsItem, + { title: "Errors", href: "/errors", icon: "CircleWarningIcon" }, + ...(flags.statusPages + ? [{ title: "Status pages", href: "/status-pages", icon: "WindowChartLineIcon" } as const] + : []), + ], + }, + { + id: "product", + label: "Product", + items: [ + { title: "Web analytics", href: "/analytics", icon: "ChartBarHorizontalIcon" }, + { title: "Users", href: "/users", icon: "UsersIcon" }, + { title: "Replays", href: "/replays", icon: "PlayRotateClockwiseIcon" }, + { title: "Dashboards", href: "/dashboards", icon: "GridSquareCirclePlusIcon" }, + ...(flags.releases + ? [{ title: "Releases", href: "/releases", icon: "RocketIcon" } as const] + : []), + ...(flags.cloudCost + ? [{ title: "Cost", href: "/cost", icon: "CircleDollarIcon", badge: "Beta" } as const] + : []), + ], + }, +] + +const operatorNavGroups = (flags: NavFlags): ReadonlyArray => [ { id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] }, { id: "monitor", @@ -245,6 +302,27 @@ export const navGroups = (flags: NavFlags): ReadonlyArray => [ }, ] +/** + * `navGroups(flags, audience)`; `cloudCost`, `releases`, `securityAudit` and `statusPages` gate a + * row each, and an observer gets {@link observerNavGroups}. + */ +export const navGroups = (flags: NavFlags, audience: NavAudience = "operator"): ReadonlyArray => + audience === "observer" ? observerNavGroups(flags) : operatorNavGroups(flags) + +/** Every page the groups link to: rows, their sub-rows and those rows' views. */ +export const navHrefs = (groups: ReadonlyArray): ReadonlyArray => + Arr.dedupe( + Arr.flatMap(groups, (group) => + Arr.flatMap(group.items, (item) => [ + item.href, + ...Arr.flatMap(item.subItems ?? [], (sub) => [ + sub.href, + ...Arr.map(sub.views ?? [], (view) => view.href), + ]), + ]), + ), + ) + /** Segment-aware prefix match: `/services` must not claim `/service-map`. */ export const isPathActive = (currentPath: string, href: string): boolean => href === "/" diff --git a/apps/web/src/shell/palette/entries.ts b/apps/web/src/shell/palette/entries.ts index cbcf5f8604..9cbe50f5a1 100644 --- a/apps/web/src/shell/palette/entries.ts +++ b/apps/web/src/shell/palette/entries.ts @@ -7,7 +7,7 @@ import { AppRoute, hrefOf } from "../../route" import type { IconName } from "@maple/views/ui/icon" import type { PinnedDashboardRow } from "../dashboards-shape" import type { Theme } from "../message" -import { navGroups } from "../nav" +import { type NavAudience, navGroups, navHrefs } from "../nav" // The palette's entries. @@ -64,28 +64,41 @@ export const FLAGGED_HREFS: ReadonlySet = new Set([ const icon = (name: IconName): Glyph => ({ _tag: "Icon", name }) const href = (url: string): Target => ({ _tag: "Href", href: url }) +const EVERY_FLAG = { cloudCost: true, releases: true, securityAudit: true, statusPages: true } + +/** The pages an observer's sidebar links to, plus their account. */ +const OBSERVER_HREFS: ReadonlySet = new Set([ + ...navHrefs(navGroups(EVERY_FLAG, "observer")), + "/account", +]) + +/** Whether the viewer's palette lists the entry: an observer gets their own pages, dashboards and the app actions. */ +export const isForAudience = (entry: PaletteEntry, audience: NavAudience): boolean => + audience === "operator" || + entry.group === "Actions" || + entry.group === "Dashboards" || + (entry.group === "Navigation" && entry.target._tag === "Href" && OBSERVER_HREFS.has(entry.target.href)) + /** Every section, its children and their views, deduplicated by title and href. */ const navigationEntries = (): ReadonlyArray => { - const rows = Arr.flatMap( - navGroups({ cloudCost: true, releases: true, securityAudit: true, statusPages: true }), - (group) => - Arr.flatMap(group.items, (item) => [ - { id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon }, - ...Arr.flatMap(item.subItems ?? [], (sub) => [ - { - id: `nav:${item.title}:${sub.title}`, - title: sub.paletteTitle ?? sub.title, - href: sub.href, - icon: sub.icon ?? item.icon, - }, - ...Arr.map(sub.views ?? [], (view) => ({ - id: `nav:${item.title}:${sub.title}:${view.title}`, - title: view.paletteTitle ?? `${sub.title} ${view.title}`, - href: view.href, - icon: view.paletteIcon ?? sub.icon ?? item.icon, - })), - ]), + const rows = Arr.flatMap(navGroups(EVERY_FLAG), (group) => + Arr.flatMap(group.items, (item) => [ + { id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon }, + ...Arr.flatMap(item.subItems ?? [], (sub) => [ + { + id: `nav:${item.title}:${sub.title}`, + title: sub.paletteTitle ?? sub.title, + href: sub.href, + icon: sub.icon ?? item.icon, + }, + ...Arr.map(sub.views ?? [], (view) => ({ + id: `nav:${item.title}:${sub.title}:${view.title}`, + title: view.paletteTitle ?? `${sub.title} ${view.title}`, + href: view.href, + icon: view.paletteIcon ?? sub.icon ?? item.icon, + })), ]), + ]), ) // One row per page, titled by its most specific row ("Traces", not its "Explore" parent); // every title that opens the same page stays searchable as a keyword. diff --git a/apps/web/src/shell/palette/global-shortcuts.test.ts b/apps/web/src/shell/palette/global-shortcuts.test.ts index 25553890eb..1ed9b8ac96 100644 --- a/apps/web/src/shell/palette/global-shortcuts.test.ts +++ b/apps/web/src/shell/palette/global-shortcuts.test.ts @@ -59,7 +59,12 @@ describe("global shortcuts", () => { const opened = step(signedIn(), Message.ClickedSearch()).model const entered = step( opened, - toPalette(Palette.Message.PressedEnter({ visibleFlagged: ["/releases", "/security"] })), + toPalette( + Palette.Message.PressedEnter({ + visibleFlagged: ["/releases", "/security"], + audience: "operator", + }), + ), ) expect(entered.outMessage).toEqual({ _tag: "RequestedNavigation", url: "/" }) }) diff --git a/apps/web/src/shell/palette/model.ts b/apps/web/src/shell/palette/model.ts index 91825017b2..96d1d42899 100644 --- a/apps/web/src/shell/palette/model.ts +++ b/apps/web/src/shell/palette/model.ts @@ -2,6 +2,7 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" +import { NavAudience } from "../nav" import { PaletteAction } from "./entries" // The command palette: mounted while open, so @@ -32,7 +33,7 @@ export const Message = defineMessageUnion({ /** A pick: a click on an entry or Enter on the highlighted one (`items` in list order). */ ClickedEntry: { id: Schema.String }, /** Enter in the input; the view knows whether the org shows Releases. */ - PressedEnter: { visibleFlagged: Schema.Array(Schema.String) }, + PressedEnter: { visibleFlagged: Schema.Array(Schema.String), audience: NavAudience }, SucceededLoadServices: { serviceNames: Schema.Array(Schema.String) }, FailedLoadServices: {}, CompletedReleaseServices: {}, diff --git a/apps/web/src/shell/palette/palette.test.ts b/apps/web/src/shell/palette/palette.test.ts index 59cfda351c..89fa47071a 100644 --- a/apps/web/src/shell/palette/palette.test.ts +++ b/apps/web/src/shell/palette/palette.test.ts @@ -106,7 +106,7 @@ describe("command palette", () => { const model = typed(open().model, "logs").model const pressed = update( model, - Message.PressedEnter({ visibleFlagged: ["/releases", "/security"] }), + Message.PressedEnter({ visibleFlagged: ["/releases", "/security"], audience: "operator" }), context, ) expect(pressed.outMessage).toEqual(OutMessage.Navigated({ href: "/logs" })) @@ -151,6 +151,37 @@ describe("command palette", () => { expect(titles).toEqual(expect.arrayContaining(["Connect Slack", "Connect Discord"])) }) + test("an observer finds their own pages and dashboards, not setup or the explorers", () => { + const model = loadedWith(["storefront"]) + const shown = (query: string) => + Arr.map( + shownEntries(typed(model, query).model, context, ["/releases", "/cost"], "observer").entries, + (entry) => entry.title, + ) + expect(shown("analytics")).toContain("Web analytics") + expect(shown("checkout")).toContain("Checkout health") + expect(shown("traces")).not.toContain("Traces") + expect(shown("connect")).toEqual([]) + expect(shown("storefront")).not.toContain("storefront") + expect(shown("settings")).not.toContain("Settings") + // The same queries still find them for everyone else. + const operator = Arr.map( + shownEntries(typed(model, "traces").model, context, ALL_FLAGGED, "operator").entries, + (entry) => entry.title, + ) + expect(operator).toContain("Traces") + }) + + test("Enter opens the page an observer sees highlighted, not the full list's", () => { + const model = typed(loadedWith([]), "uptime").model + const result = update( + model, + Message.PressedEnter({ visibleFlagged: [], audience: "observer" }), + context, + ) + expect(result.outMessage).toEqual(OutMessage.Navigated({ href: "/alerts/uptime" })) + }) + test("a flagged-off row is not shown", () => { const titles = (visibleFlagged: ReadonlyArray) => Arr.map(shownEntries(open().model, context, visibleFlagged).entries, (entry) => entry.title) diff --git a/apps/web/src/shell/palette/update.ts b/apps/web/src/shell/palette/update.ts index 8c3decf94c..19edd25824 100644 --- a/apps/web/src/shell/palette/update.ts +++ b/apps/web/src/shell/palette/update.ts @@ -4,6 +4,7 @@ import Fuse from "fuse.js" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import type { Resources } from "../../platform/resources" +import type { NavAudience } from "../nav" import { browseEntries, type EntryInputs, @@ -12,6 +13,7 @@ import { type PaletteEntry, paletteEntries, searchEntriesOf, + isForAudience, } from "./entries" import { LoadPaletteServices, ScrollHighlighted } from "./command" import { init, Message, type Model, OutMessage } from "./model" @@ -61,13 +63,15 @@ const computeShownEntries = ( model: Model, context: Context, visibleFlagged: ReadonlyArray, + audience: NavAudience, ): Shown => { const all = entriesOf(model, context) const entries = Arr.filter( all, (entry) => - entry.isFlagged !== true || - (entry.target._tag === "Href" && visibleFlagged.includes(entry.target.href)), + isForAudience(entry, audience) && + (entry.isFlagged !== true || + (entry.target._tag === "Href" && visibleFlagged.includes(entry.target.href))), ) const query = Str.trim(model.list.inputValue) if (query === "") return { entries: browseEntries(entries), isSearch: false } @@ -84,7 +88,7 @@ const sameItems = (a: ReadonlyArray, b: ReadonlyArray) => type ShownInputs = Readonly<{ query: string; serviceNames: ReadonlyArray }> & Context & - Readonly<{ visibleFlagged: ReadonlyArray }> + Readonly<{ visibleFlagged: ReadonlyArray; audience: NavAudience }> const sameInputs = (a: ShownInputs, b: ShownInputs) => a.query === b.query && @@ -92,7 +96,8 @@ const sameInputs = (a: ShownInputs, b: ShownInputs) => sameItems(a.serviceNames, b.serviceNames) && sameItems(a.dashboards, b.dashboards) && sameItems(a.favorites, b.favorites) && - sameItems(a.visibleFlagged, b.visibleFlagged) + sameItems(a.visibleFlagged, b.visibleFlagged) && + a.audience === b.audience /** Every render rebuilds the context, so the last answer is kept by its inputs, not by reference. */ let lastShown: { inputs: ShownInputs; shown: Shown } | null = null @@ -101,23 +106,30 @@ export const shownEntries = ( model: Model, context: Context, visibleFlagged: ReadonlyArray, + audience: NavAudience = "operator", ): Shown => { const inputs: ShownInputs = { ...context, query: model.list.inputValue, serviceNames: model.serviceNames, visibleFlagged, + audience, } if (lastShown !== null && sameInputs(lastShown.inputs, inputs)) return lastShown.shown - const shown = computeShownEntries(model, context, visibleFlagged) + const shown = computeShownEntries(model, context, visibleFlagged, audience) lastShown = { inputs, shown } return shown } /** The entries in list order (grouped), what the highlight indexes. */ -const listedEntries = (model: Model, context: Context, visibleFlagged: ReadonlyArray) => +const listedEntries = ( + model: Model, + context: Context, + visibleFlagged: ReadonlyArray, + audience: NavAudience, +) => Arr.flatMap( - groupEntries(shownEntries(model, context, visibleFlagged).entries), + groupEntries(shownEntries(model, context, visibleFlagged, audience).entries), (section) => section.entries, ) @@ -143,8 +155,13 @@ const toList = Command.mapMessage((message: Combobox.Message) => Message.GotList const toDialog = Command.mapMessage((message: Dialog.Message) => Message.GotDialogMessage({ message })) /** Base UI's Enter: the highlighted entry is picked. */ -const enter = (model: Model, context: Context, visibleFlagged: ReadonlyArray): UpdateReturn => - picked(model, listedEntries(model, context, visibleFlagged)[model.list.highlighted ?? -1]) +const enter = ( + model: Model, + context: Context, + visibleFlagged: ReadonlyArray, + audience: NavAudience, +): UpdateReturn => + picked(model, listedEntries(model, context, visibleFlagged, audience)[model.list.highlighted ?? -1]) /** The inline autocomplete: typing searches, arrows move the highlight into view, Enter picks. */ const listStep = (model: Model, child: Combobox.Message, context: Context): UpdateReturn => { @@ -176,7 +193,7 @@ export const update = (model: Model, message: Message, context: Context): Update } }, GotListMessage: ({ message: child }) => listStep(model, child, context), - PressedEnter: ({ visibleFlagged }) => enter(model, context, visibleFlagged), + PressedEnter: ({ visibleFlagged, audience }) => enter(model, context, visibleFlagged, audience), ClickedEntry: ({ id }) => { const entry = findEntry(model, context, id) // A link's own click navigates (the root routes it), so only the palette closes. diff --git a/apps/web/src/shell/palette/view.ts b/apps/web/src/shell/palette/view.ts index a5d683bbf6..df9b5316a9 100644 --- a/apps/web/src/shell/palette/view.ts +++ b/apps/web/src/shell/palette/view.ts @@ -16,6 +16,7 @@ import { kbd } from "@maple/views/ui/display" import { icon } from "@maple/views/ui/icon" import { manifestGlyph } from "@maple/views/ui/manifest-glyph" import { serviceDot } from "@maple/views/ui/values" +import type { NavAudience } from "../nav" import { type Glyph, groupEntries, type PaletteEntry } from "./entries" import { Message, type Model } from "./model" import { type Context, shownEntries } from "./update" @@ -32,7 +33,11 @@ const PALETTE_LIST_CLASS = // The command palette's popup: the input, the grouped entries (or "No results"), the footer hints. -export type ViewInputs = Readonly<{ context: Context; visibleFlagged: ReadonlyArray }> +export type ViewInputs = Readonly<{ + context: Context + visibleFlagged: ReadonlyArray + audience: NavAudience +}> const GLYPH_CLASS = "shrink-0 text-muted-foreground" @@ -81,10 +86,10 @@ export const paletteView = ( const toList = (message: Combobox.Message) => toMessage( message._tag === "PressedInputKey" && message.key === "Enter" - ? Message.PressedEnter({ visibleFlagged: inputs.visibleFlagged }) + ? Message.PressedEnter({ visibleFlagged: inputs.visibleFlagged, audience: inputs.audience }) : Message.GotListMessage({ message }), ) - const shown = shownEntries(model, inputs.context, inputs.visibleFlagged) + const shown = shownEntries(model, inputs.context, inputs.visibleFlagged, inputs.audience) const groups = groupsOf(h, shown.entries) return h.submodel({ slotId: "command-palette", diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 120e133a2c..2825a186b8 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -21,7 +21,7 @@ import { } from "@maple/views/ui/sidebar" import type { PinnedDashboardRow } from "./dashboards-shape" import { Menu, Message } from "./message" -import { type Model, type ShellClerk, shellProbe, shellSurfaces } from "./model" +import { type Model, type ShellClerk, shellAudience, shellProbe, shellSurfaces } from "./model" import { isNavItemActive, matchSubItem, @@ -43,18 +43,22 @@ import { typography } from "@maple/views/ui/typography" * Security also needs a role with `security:manage`: members never see it. */ export const shellNavGroups = (clerk: ShellClerk | null = null): ReadonlyArray => { + const audience = shellAudience(clerk) const flag = (name: keyof NavFlags) => !isClerkAuthEnabled || (clerk !== null && Option.exists(clerk.maybeOrganization, (organization) => organization.flags[name])) const canReadSecurity = !isClerkAuthEnabled || (clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.can([role], "security:manage"))) - return navGroups({ - cloudCost: flag("cloudCost"), - releases: flag("releases"), - securityAudit: flag("securityAudit") && canReadSecurity, - statusPages: flag("statusPages"), - }) + return navGroups( + { + cloudCost: flag("cloudCost"), + releases: flag("releases"), + securityAudit: flag("securityAudit") && canReadSecurity, + statusPages: flag("statusPages"), + }, + audience, + ) } /** The last-hour probe's answer; `null` while it is first in flight or after it failed. */ diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 2582590afd..6e4392524f 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -19,7 +19,7 @@ import { } from "@maple/views/ui/sidebar" import { isSameMenu, triggerIdOf } from "./menus" import { Menu, Message } from "./message" -import type { Model, ShellClerk } from "./model" +import { type Model, type ShellClerk, shellAudience } from "./model" import * as ClerkOrgSwitcherView from "./clerk-org-switcher-view" import { orgAvatar } from "./clerk-org-switcher-view" import { typography } from "@maple/views/ui/typography" @@ -405,14 +405,29 @@ export const shellSidebarFooter = ( sidebarMenu(h, undefined, [ // A row of the footer's list, so it keeps the list's 4px rhythm. ...onCallPill(h, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), - footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), - // `/integrations` renders inside the settings rail, so Settings owns it. - footerNavRow( - h, - currentPath, - { href: "/settings", title: "Settings", icon: "GearIcon", alsoActive: "/integrations" }, - tip, - ), + // An observer sets nothing up: their account is in the user menu below. + ...(shellAudience(clerk) === "observer" + ? [] + : [ + footerNavRow( + h, + currentPath, + { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, + tip, + ), + // `/integrations` renders inside the settings rail, so Settings owns it. + footerNavRow( + h, + currentPath, + { + href: "/settings", + title: "Settings", + icon: "GearIcon", + alsoActive: "/integrations", + }, + tip, + ), + ]), ]), h.div( [h.Class("-mx-2 border-sidebar-border border-t px-2 pt-2")], diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index 0e5f36cf29..c6fac636cd 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -18,7 +18,7 @@ import { placementOf, popupIdOf, triggerIdOf } from "./menus" import { billingBanners } from "./billing-banners" import * as SupportChannelView from "./support-channel-view" import { Message } from "./message" -import type { Model, ShellClerk } from "./model" +import { type Model, type ShellClerk, shellAudience } from "./model" import { MeasureSidebarOverflow, PositionMenu } from "./mounts" import { isMenuKey } from "./update" import { menuContent } from "./view-menus" @@ -68,8 +68,7 @@ const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray Arr.map(group.items, (item) => item.title)), ...(isPillShown(onCall) ? [onCallDetail(onCall)] : []), - "Sources", - "Settings", + ...(shellAudience(clerk) === "observer" ? [] : ["Sources", "Settings"]), ] } @@ -196,7 +195,8 @@ export const frameView = Submodel.defineView((model inputs.breadcrumbs, inputs.topbarActions(), railTriggers, - !hidesTopbarConnect(inputs.currentPath), + !hidesTopbarConnect(inputs.currentPath) && + shellAudience(inputs.clerk ?? null) !== "observer", inputs.sourceLink ?? null, ), ...(inputs.clerk === undefined || inputs.clerk === null @@ -301,6 +301,7 @@ export const paletteLayerView = Submodel.defineView Arr.flatMap(group.items, (item) => FLAGGED_HREFS.has(item.href) ? [item.href] : [], From cb42d733505a27c536f8ea60f655a1ae6da63b3d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 01:21:20 +0100 Subject: [PATCH 2/5] fix(shell): list an observer's palette pages under their sidebar's names An observer's sidebar calls the alerts inbox Incidents, but the palette built its rows from the full nav, so it listed that page as Alerts and a search for incidents found nothing. Their entries now carry the observer nav's titles; the ids are unchanged, so a pick resolves the same way. --- apps/web/src/shell/nav.test.ts | 13 +++++- apps/web/src/shell/nav.ts | 14 ------ apps/web/src/shell/palette/entries.ts | 50 ++++++++++++++-------- apps/web/src/shell/palette/palette.test.ts | 3 ++ apps/web/src/shell/palette/update.ts | 9 ++-- 5 files changed, 51 insertions(+), 38 deletions(-) diff --git a/apps/web/src/shell/nav.test.ts b/apps/web/src/shell/nav.test.ts index fec17df6ac..e9bc82a8c5 100644 --- a/apps/web/src/shell/nav.test.ts +++ b/apps/web/src/shell/nav.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest" -import { isNavItemActive, type NavFlags, navGroups, navHrefs, navPreview, partitionSubItems } from "./nav" +import { isNavItemActive, type NavFlags, navGroups, navPreview, partitionSubItems } from "./nav" import { Array as Arr, Option } from "effect" const infrastructure = Option.getOrUndefined( @@ -64,7 +64,16 @@ describe("isNavItemActive", () => { describe("navGroups for an observer", () => { const off = { cloudCost: false, releases: false, securityAudit: false, statusPages: false } - const hrefs = (flags: NavFlags) => navHrefs(navGroups(flags, "observer")) + /** Every page the observer's sidebar links to: rows, then their sub-rows. */ + const hrefs = (flags: NavFlags) => + Arr.dedupe( + Arr.flatMap(navGroups(flags, "observer"), (group) => + Arr.flatMap(group.items, (item) => [ + item.href, + ...Arr.map(item.subItems ?? [], (sub) => sub.href), + ]), + ), + ) test("links to what is wrong and how the product is doing, and to no setup or explorer page", () => { expect(hrefs(off)).toEqual([ diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index b0156fc76f..4e96819ddf 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -309,20 +309,6 @@ const operatorNavGroups = (flags: NavFlags): ReadonlyArray => [ export const navGroups = (flags: NavFlags, audience: NavAudience = "operator"): ReadonlyArray => audience === "observer" ? observerNavGroups(flags) : operatorNavGroups(flags) -/** Every page the groups link to: rows, their sub-rows and those rows' views. */ -export const navHrefs = (groups: ReadonlyArray): ReadonlyArray => - Arr.dedupe( - Arr.flatMap(groups, (group) => - Arr.flatMap(group.items, (item) => [ - item.href, - ...Arr.flatMap(item.subItems ?? [], (sub) => [ - sub.href, - ...Arr.map(sub.views ?? [], (view) => view.href), - ]), - ]), - ), - ) - /** Segment-aware prefix match: `/services` must not claim `/service-map`. */ export const isPathActive = (currentPath: string, href: string): boolean => href === "/" diff --git a/apps/web/src/shell/palette/entries.ts b/apps/web/src/shell/palette/entries.ts index 9cbe50f5a1..0f074f4f3b 100644 --- a/apps/web/src/shell/palette/entries.ts +++ b/apps/web/src/shell/palette/entries.ts @@ -7,7 +7,7 @@ import { AppRoute, hrefOf } from "../../route" import type { IconName } from "@maple/views/ui/icon" import type { PinnedDashboardRow } from "../dashboards-shape" import type { Theme } from "../message" -import { type NavAudience, navGroups, navHrefs } from "../nav" +import { type NavAudience, type NavGroup, navGroups } from "../nav" // The palette's entries. @@ -66,22 +66,9 @@ const href = (url: string): Target => ({ _tag: "Href", href: url }) const EVERY_FLAG = { cloudCost: true, releases: true, securityAudit: true, statusPages: true } -/** The pages an observer's sidebar links to, plus their account. */ -const OBSERVER_HREFS: ReadonlySet = new Set([ - ...navHrefs(navGroups(EVERY_FLAG, "observer")), - "/account", -]) - -/** Whether the viewer's palette lists the entry: an observer gets their own pages, dashboards and the app actions. */ -export const isForAudience = (entry: PaletteEntry, audience: NavAudience): boolean => - audience === "operator" || - entry.group === "Actions" || - entry.group === "Dashboards" || - (entry.group === "Navigation" && entry.target._tag === "Href" && OBSERVER_HREFS.has(entry.target.href)) - -/** Every section, its children and their views, deduplicated by title and href. */ -const navigationEntries = (): ReadonlyArray => { - const rows = Arr.flatMap(navGroups(EVERY_FLAG), (group) => +/** A page's palette row under each title the nav gives it: the section, its children, their views. */ +const navRowsOf = (groups: ReadonlyArray) => + Arr.flatMap(groups, (group) => Arr.flatMap(group.items, (item) => [ { id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon }, ...Arr.flatMap(item.subItems ?? [], (sub) => [ @@ -100,6 +87,35 @@ const navigationEntries = (): ReadonlyArray => { ]), ]), ) + +/** What an observer's sidebar calls each of its pages (`/alerts` is "Incidents"), plus their account. */ +const OBSERVER_TITLES: ReadonlyMap = new Map([ + ...Arr.map(navRowsOf(navGroups(EVERY_FLAG, "observer")), (row) => [row.href, row.title] as const), + ["/account", "Account"] as const, +]) + +/** + * The entries as the viewer's palette lists them. An observer gets their own pages under their + * sidebar's names, dashboards and the app actions; the ids stay, so a pick resolves either way. + */ +export const entriesFor = ( + entries: ReadonlyArray, + audience: NavAudience, +): ReadonlyArray => + audience === "operator" + ? entries + : Arr.flatMap(entries, (entry): ReadonlyArray => { + if (entry.group === "Actions" || entry.group === "Dashboards") return [entry] + if (entry.group !== "Navigation" || entry.target._tag !== "Href") return [] + const title = OBSERVER_TITLES.get(entry.target.href) + return title === undefined + ? [] + : [{ ...entry, title, keywords: `${entry.keywords} ${title}` }] + }) + +/** Every section, its children and their views, deduplicated by title and href. */ +const navigationEntries = (): ReadonlyArray => { + const rows = navRowsOf(navGroups(EVERY_FLAG)) // One row per page, titled by its most specific row ("Traces", not its "Explore" parent); // every title that opens the same page stays searchable as a keyword. const unique = Arr.reverse(Arr.dedupeWith(Arr.reverse(rows), (a, b) => a.href === b.href)) diff --git a/apps/web/src/shell/palette/palette.test.ts b/apps/web/src/shell/palette/palette.test.ts index 89fa47071a..afa5a94390 100644 --- a/apps/web/src/shell/palette/palette.test.ts +++ b/apps/web/src/shell/palette/palette.test.ts @@ -159,6 +159,9 @@ describe("command palette", () => { (entry) => entry.title, ) expect(shown("analytics")).toContain("Web analytics") + // Their sidebar calls the alerts inbox Incidents, so that is what they search for and see. + expect(shown("incidents")).toContain("Incidents") + expect(shown("alerts")).not.toContain("Alerts") expect(shown("checkout")).toContain("Checkout health") expect(shown("traces")).not.toContain("Traces") expect(shown("connect")).toEqual([]) diff --git a/apps/web/src/shell/palette/update.ts b/apps/web/src/shell/palette/update.ts index 19edd25824..5ec54f2993 100644 --- a/apps/web/src/shell/palette/update.ts +++ b/apps/web/src/shell/palette/update.ts @@ -7,13 +7,13 @@ import type { Resources } from "../../platform/resources" import type { NavAudience } from "../nav" import { browseEntries, + entriesFor, type EntryInputs, groupEntries, MAX_RESULTS, type PaletteEntry, paletteEntries, searchEntriesOf, - isForAudience, } from "./entries" import { LoadPaletteServices, ScrollHighlighted } from "./command" import { init, Message, type Model, OutMessage } from "./model" @@ -65,13 +65,12 @@ const computeShownEntries = ( visibleFlagged: ReadonlyArray, audience: NavAudience, ): Shown => { - const all = entriesOf(model, context) + const all = entriesFor(entriesOf(model, context), audience) const entries = Arr.filter( all, (entry) => - isForAudience(entry, audience) && - (entry.isFlagged !== true || - (entry.target._tag === "Href" && visibleFlagged.includes(entry.target.href))), + entry.isFlagged !== true || + (entry.target._tag === "Href" && visibleFlagged.includes(entry.target.href)), ) const query = Str.trim(model.list.inputValue) if (query === "") return { entries: browseEntries(entries), isSearch: false } From 5f07072e96980accd86fc2249725eed27526f9c7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 01:53:43 +0100 Subject: [PATCH 3/5] test(shell): pin the observer shell's missing setup rows and Connect button --- .../src/lab/shell-states-lab.scene.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts index 7499d93bff..870399739b 100644 --- a/apps/web/src/lab/shell-states-lab.scene.test.ts +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -139,3 +139,31 @@ describe("URL-held right sheet", () => { ) }) }) + +describe("the shell for an observer", () => { + const setup = [ + role("link", { name: "Sources" }), + role("link", { name: "Settings" }), + role("button", { name: "Connect" }), + ] + + test("a member has the Sources and Settings rows and the Connect button", () => { + scene( + { update, view }, + given(at("/lab/shell")), + measured, + ...Arr.map(setup, (it) => expect(it).toExist()), + ) + }) + + test("an observer has none of them, and keeps their own pages", () => { + scene( + { update, view }, + given(at("/lab/shell")), + measured, + click(role("button", { name: "Observer" })), + ...Arr.map(setup, (it) => expect(it).toBeAbsent()), + expect(role("link", { name: "Web analytics" })).toExist(), + ) + }) +}) From 7bd831bb8e06fd41bdfed0c8a88b0c2235876b35 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:12:08 +0100 Subject: [PATCH 4/5] refactor: rename the observer role to analyst --- CLAUDE.md | 4 +-- apps/ai/src/chat/agents.test.ts | 6 ++-- apps/ai/src/chat/profiles.ts | 4 +-- apps/ai/src/mcp/server.ts | 2 +- .../mcp/tools/__tests__/read-only-key.test.ts | 8 ++--- apps/ai/src/mcp/tools/types.ts | 2 +- .../src/routes/v2/mobile-devices.http.test.ts | 4 +-- .../routes/v2/phase1-resources.http.test.ts | 6 ++-- apps/api/src/routes/v2/telemetry.http.test.ts | 4 +-- .../src/lab/shell-states-lab.scene.test.ts | 6 ++-- apps/web/src/lab/shell-states-lab.ts | 10 +++--- .../page/settings/kit/org-membership.test.ts | 14 ++++---- .../src/page/settings/kit/org-membership.ts | 2 +- apps/web/src/shell/model.ts | 4 +-- apps/web/src/shell/nav.test.ts | 8 ++--- apps/web/src/shell/nav.ts | 16 +++++----- apps/web/src/shell/palette/entries.ts | 10 +++--- apps/web/src/shell/palette/palette.test.ts | 8 ++--- apps/web/src/shell/view-sidebar.ts | 4 +-- apps/web/src/shell/view.ts | 4 +-- .../src/services/auth/ApiAuthorizationLive.ts | 6 ++-- .../services/auth/ApiAuthorizationV2Live.ts | 8 ++--- .../services/auth/SessionAuthorizationLive.ts | 6 ++-- ...rver-gate.test.ts => analyst-gate.test.ts} | 32 +++++++++---------- .../{observer-gate.ts => analyst-gate.ts} | 16 +++++----- packages/domain/src/http/ai-models.ts | 6 ++-- packages/domain/src/http/ai-sessions.ts | 6 ++-- ...-access.test.ts => analyst-access.test.ts} | 16 +++++----- .../{observer-access.ts => analyst-access.ts} | 16 +++++----- packages/domain/src/http/auth.ts | 14 ++++---- packages/domain/src/http/billing.ts | 4 +-- packages/domain/src/http/current-tenant.ts | 2 +- packages/domain/src/http/digest.ts | 10 +++--- packages/domain/src/http/errors.ts | 4 +-- packages/domain/src/http/index.ts | 2 +- packages/domain/src/http/integrations.ts | 6 ++-- packages/domain/src/http/mcp.ts | 6 ++-- packages/domain/src/http/query-engine.ts | 6 ++-- packages/domain/src/http/session-replay.ts | 6 ++-- packages/domain/src/http/users.ts | 6 ++-- packages/domain/src/http/v2/agent-feedback.ts | 4 +-- .../domain/src/http/v2/device-credentials.ts | 8 ++--- .../domain/src/http/v2/integrations-chat.ts | 4 +-- .../domain/src/http/v2/mcp-connections.ts | 4 +-- packages/domain/src/http/v2/mobile-devices.ts | 10 +++--- .../domain/src/http/v2/widget-credentials.ts | 6 ++-- packages/domain/src/policy/index.ts | 8 ++--- packages/domain/src/policy/policy.test.ts | 24 +++++++------- packages/domain/src/policy/roles.ts | 16 +++++----- 49 files changed, 194 insertions(+), 194 deletions(-) rename packages/backend/src/services/auth/{observer-gate.test.ts => analyst-gate.test.ts} (52%) rename packages/backend/src/services/auth/{observer-gate.ts => analyst-gate.ts} (71%) rename packages/domain/src/http/{observer-access.test.ts => analyst-access.test.ts} (75%) rename packages/domain/src/http/{observer-access.ts => analyst-access.ts} (57%) diff --git a/CLAUDE.md b/CLAUDE.md index e91207cae8..42a0c57ba0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,8 @@ with `packageManager`. - LLM code (Effect AI + `@yielded/agent`) is only in `apps/ai`; Maple-specific wiring lives in `apps/ai/src/platform/Llm.ts`. - Shared services live in `packages/backend`, imported via `@maple/backend/*` subpaths. -- Observers (`org:observer`) only read. The auth middlewares refuse them every non-GET endpoint unless - it is marked `ObserverAccess` (`packages/domain/src/http/observer-access.ts`): `read` for a POST that +- Analysts (`org:analyst`) only read. The auth middlewares refuse them every non-GET endpoint unless + it is marked `AnalystAccess` (`packages/domain/src/http/analyst-access.ts`): `read` for a POST that only queries, `personal` for the caller's own state. An unmarked read-only POST is a 403 for them. ### Service conventions diff --git a/apps/ai/src/chat/agents.test.ts b/apps/ai/src/chat/agents.test.ts index 6f206ceef8..0412a4587e 100644 --- a/apps/ai/src/chat/agents.test.ts +++ b/apps/ai/src/chat/agents.test.ts @@ -137,14 +137,14 @@ describe("profileForTurn", () => { } }) - it("offers an observer's turn the read tools and none that change data", () => { + it("offers an analyst's turn the read tools and none that change data", () => { const profile = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ - "org:observer", + "org:analyst", ]) const { tools } = buildChatToolkit(executor, tenant, profile.ruleset, profile.surface).toolkit assert.property(tools, "search_traces") for (const name of MUTATING_TOOL_NAMES) { - assert.notProperty(tools, name, `${name} was offered to an observer`) + assert.notProperty(tools, name, `${name} was offered to an analyst`) } // A member's turn on the same session still proposes them. const member = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ diff --git a/apps/ai/src/chat/profiles.ts b/apps/ai/src/chat/profiles.ts index bcdf976a03..f80614f1cd 100644 --- a/apps/ai/src/chat/profiles.ts +++ b/apps/ai/src/chat/profiles.ts @@ -23,13 +23,13 @@ export interface TurnProfile { readonly prompt: string } -/** The ruleset with every approval-gated tool withheld: an observer could not approve one. */ +/** The ruleset with every approval-gated tool withheld: an analyst could not approve one. */ const withoutProposals = (ruleset: PermissionRuleset): PermissionRuleset => Arr.map(ruleset, (rule) => rule.action === "ask" ? new PermissionRule({ tool: rule.tool, action: "deny" }) : rule, ) -/** The profile for whoever the turn's tools run as: an observer is offered nothing that changes data. */ +/** The profile for whoever the turn's tools run as: an analyst is offered nothing that changes data. */ export const profileForRoles = (profile: TurnProfile, roles: ReadonlyArray): TurnProfile => Policy.isReadOnly(roles) ? { ...profile, ruleset: withoutProposals(profile.ruleset) } : profile diff --git a/apps/ai/src/mcp/server.ts b/apps/ai/src/mcp/server.ts index c00bc1e4bd..264fcf9293 100644 --- a/apps/ai/src/mcp/server.ts +++ b/apps/ai/src/mcp/server.ts @@ -69,7 +69,7 @@ const annotateToolFailure = (result: McpToolResult) => : Effect.void /** - * Hides a tool from a read-only credential's or an observer's `tools/list`, and answers a call to it as unknown: + * Hides a tool from a read-only credential's or an analyst's `tools/list`, and answers a call to it as unknown: * the registry would refuse every such call anyway, and a listed tool invites it. */ const HiddenFromReadOnlyCredentials = EffectMcpServer.visibility(() => diff --git a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts index 090ad00061..52c6d0e614 100644 --- a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts @@ -71,15 +71,15 @@ describe("read-only API key over MCP", () => { ) }) -describe("an observer over MCP", () => { +describe("an analyst over MCP", () => { // A layer per test: each owns a database that `afterEach` removes. - const observer = () => makeLayer(false, undefined, "org:observer") + const analyst = () => makeLayer(false, undefined, "org:analyst") it.effect("runs read tools", () => Effect.gen(function* () { const result = yield* call("get_error_notification_policy", {}) expect(Result.isSuccess(result)).toBe(true) - }).pipe(Effect.provide(observer())), + }).pipe(Effect.provide(analyst())), ) it.effect("refuses a tool that changes data and says which role can", () => @@ -91,7 +91,7 @@ describe("an observer over MCP", () => { expect(result.failure.message).toContain("update_error_notification_policy") expect(result.failure.message).toContain("Ask an org admin for the Member role") } - }).pipe(Effect.provide(observer())), + }).pipe(Effect.provide(analyst())), ) }) diff --git a/apps/ai/src/mcp/tools/types.ts b/apps/ai/src/mcp/tools/types.ts index 384e0240f6..3b949cf34e 100644 --- a/apps/ai/src/mcp/tools/types.ts +++ b/apps/ai/src/mcp/tools/types.ts @@ -124,7 +124,7 @@ export const FEEDBACK_TOOL = "send_maple_feedback" export const refusesReadOnlyCredential = (name: string, readOnly: boolean): boolean => !readOnly && name !== FEEDBACK_TOOL -/** Whether the caller may only read: a read-only API key, or anyone whose role is observer. */ +/** Whether the caller may only read: a read-only API key, or anyone whose role is analyst. */ export const isReadOnlyTenant = (tenant: { readonly readOnly?: true readonly roles: ReadonlyArray diff --git a/apps/api/src/routes/v2/mobile-devices.http.test.ts b/apps/api/src/routes/v2/mobile-devices.http.test.ts index 61c527929c..08fc58a2b4 100644 --- a/apps/api/src/routes/v2/mobile-devices.http.test.ts +++ b/apps/api/src/routes/v2/mobile-devices.http.test.ts @@ -190,10 +190,10 @@ describe("v2 mobile devices", () => { } }) - it("lets an observer register and remove their own device", async () => { + it("lets an analyst register and remove their own device", async () => { const harness = makeHarness() try { - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const created = await harness.request("PUT", `/v2/mobile_devices/${TOKEN}`, key.secret, { platform: "ios", environment: "sandbox", diff --git a/apps/api/src/routes/v2/phase1-resources.http.test.ts b/apps/api/src/routes/v2/phase1-resources.http.test.ts index b37296d26b..f863cf37f8 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -902,9 +902,9 @@ describe("v2 error_issues over HTTP", () => { await harness.dispose() }) - it("lets an observer read an issue and refuses to let them triage it", async () => { + it("lets an analyst read an issue and refuses to let them triage it", async () => { const harness = makeHarness() - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const read = await harness.request("GET", `/v2/error_issues/${ISS_ID}`, { token: key.secret }) expect(read.status).toBe(200) @@ -915,7 +915,7 @@ describe("v2 error_issues over HTTP", () => { }) expect(refused.status).toBe(403) expect(refused.body.error.code).toBe("insufficient_permissions") - expect(refused.body.error.message).toContain("Observers can read Maple") + expect(refused.body.error.message).toContain("Analysts can read Maple") expect(harness.issueActions()).toEqual([]) await harness.dispose() }) diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index 205b616bda..92fdacb02b 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -571,9 +571,9 @@ describe("v2 telemetry reads over HTTP", () => { await harness.dispose() }) - it("lets an observer search traces, a POST that only reads", async () => { + it("lets an analyst search traces, a POST that only reads", async () => { const harness = makeHarness() - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const searched = await harness.request("POST", "/v2/traces/search", key.secret, { start_time: START, end_time: END, diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts index 870399739b..0470e67128 100644 --- a/apps/web/src/lab/shell-states-lab.scene.test.ts +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -140,7 +140,7 @@ describe("URL-held right sheet", () => { }) }) -describe("the shell for an observer", () => { +describe("the shell for an analyst", () => { const setup = [ role("link", { name: "Sources" }), role("link", { name: "Settings" }), @@ -156,12 +156,12 @@ describe("the shell for an observer", () => { ) }) - test("an observer has none of them, and keeps their own pages", () => { + test("an analyst has none of them, and keeps their own pages", () => { scene( { update, view }, given(at("/lab/shell")), measured, - click(role("button", { name: "Observer" })), + click(role("button", { name: "Analyst" })), ...Arr.map(setup, (it) => expect(it).toBeAbsent()), expect(role("link", { name: "Web analytics" })).toExist(), ) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 2234470dbc..2f2df77060 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -167,13 +167,13 @@ const sheetStep = (model: Model, step: SheetStep): Return => ({ ], }) -/** A signed-in observer with nothing else read yet: all the shell needs to pick their sidebar. */ -const OBSERVER_CLERK: ShellClerk = { +/** A signed-in analyst with nothing else read yet: all the shell needs to pick their sidebar. */ +const ANALYST_CLERK: ShellClerk = { maybeUser: Option.none(), maybeOrganization: Option.none(), memberships: [], customer: undefined, - maybeRole: Option.some("org:observer"), + maybeRole: Option.some("org:analyst"), } /** The fixture's shifts, read on the lab's fixed clock. */ @@ -364,7 +364,7 @@ const page = (h: HtmlBuilder, model: Model): Html => onChange: (viewer) => Message.ClickedViewer({ viewer }), options: [ { value: "operator", label: "Member and above" }, - { value: "observer", label: "Observer" }, + { value: "analyst", label: "Analyst" }, ], }), ), @@ -395,7 +395,7 @@ export const view = (model: Model, h: HtmlBuilder): Html => TooltipHost.view(h, model.tooltip, toTooltip), ...labShellView(h, model.shell, toShell, { currentPath: STAND_ON_PATH[model.standOn], - clerk: model.viewer === "observer" ? OBSERVER_CLERK : null, + clerk: model.viewer === "analyst" ? ANALYST_CLERK : null, breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], page: () => page(h, model), }), diff --git a/apps/web/src/page/settings/kit/org-membership.test.ts b/apps/web/src/page/settings/kit/org-membership.test.ts index 25784a1999..3fd4bff249 100644 --- a/apps/web/src/page/settings/kit/org-membership.test.ts +++ b/apps/web/src/page/settings/kit/org-membership.test.ts @@ -22,21 +22,21 @@ describe("leaveEligibility", () => { }) describe("toOrgRole", () => { - it("reads unknown Clerk roles as observer, the least a person can hold", () => { + it("reads unknown Clerk roles as analyst, the least a person can hold", () => { expect(toOrgRole("org:admin")).toBe("org:admin") - expect(toOrgRole("org:billing")).toBe("org:observer") - expect(orgRoleLabel("org:viewer")).toBe("Observer") + expect(toOrgRole("org:billing")).toBe("org:analyst") + expect(orgRoleLabel("org:viewer")).toBe("Analyst") }) it("shows root, which has no entry of its own, as a member", () => { expect(orgRoleLabel("root")).toBe("Member") }) - it("names the observer role in a role change prompt", () => { - expect(roleChangePrompt("Ada", "org:observer")).toEqual({ + it("names the analyst role in a role change prompt", () => { + expect(roleChangePrompt("Ada", "org:analyst")).toEqual({ question: - "Make Ada an observer? Follows incidents, reliability, product analytics and cost, without changing anything.", - confirmLabel: "Make observer", + "Make Ada an analyst? Follows incidents, reliability, product analytics and cost, without changing anything.", + confirmLabel: "Make analyst", }) }) }) diff --git a/apps/web/src/page/settings/kit/org-membership.ts b/apps/web/src/page/settings/kit/org-membership.ts index 4b421a693f..6443e5b73d 100644 --- a/apps/web/src/page/settings/kit/org-membership.ts +++ b/apps/web/src/page/settings/kit/org-membership.ts @@ -10,7 +10,7 @@ export const orgRoleKeys: ReadonlyArray = Arr.map(Policy.assignableRole // `root`, which Clerk never assigns, has no entry of its own and shows as a member. const memberEntry = Policy.assignableRoles[1] -/** A Clerk role string's entry, read through `Policy.roleOf` (unknown roles are observers). */ +/** A Clerk role string's entry, read through `Policy.roleOf` (unknown roles are analysts). */ const entryOf = (role: string): Policy.AssignableRole => { const policyRole = Policy.roleOf(role) return Option.getOrElse( diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index b205ea7592..f791a14b85 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -134,10 +134,10 @@ export type ShellClerk = Readonly<{ maybeRole: Option.Option }> -/** Who the shell is drawn for; self-hosted has no Clerk role, so it is never an observer. */ +/** Who the shell is drawn for; self-hosted has no Clerk role, so it is never an analyst. */ export const shellAudience = (clerk: ShellClerk | null): NavAudience => clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.isReadOnly([role])) - ? "observer" + ? "analyst" : "operator" /** A rail item's tooltip: right of the item, centred. */ diff --git a/apps/web/src/shell/nav.test.ts b/apps/web/src/shell/nav.test.ts index e9bc82a8c5..8ecc8cb7af 100644 --- a/apps/web/src/shell/nav.test.ts +++ b/apps/web/src/shell/nav.test.ts @@ -62,12 +62,12 @@ describe("isNavItemActive", () => { }) }) -describe("navGroups for an observer", () => { +describe("navGroups for an analyst", () => { const off = { cloudCost: false, releases: false, securityAudit: false, statusPages: false } - /** Every page the observer's sidebar links to: rows, then their sub-rows. */ + /** Every page the analyst's sidebar links to: rows, then their sub-rows. */ const hrefs = (flags: NavFlags) => Arr.dedupe( - Arr.flatMap(navGroups(flags, "observer"), (group) => + Arr.flatMap(navGroups(flags, "analyst"), (group) => Arr.flatMap(group.items, (item) => [ item.href, ...Arr.map(item.subItems ?? [], (sub) => sub.href), @@ -98,7 +98,7 @@ describe("navGroups for an observer", () => { }) test("marks only Incidents on an uptime page, with its Uptime row", () => { - const items = Arr.flatMap(navGroups(off, "observer"), (group) => group.items) + const items = Arr.flatMap(navGroups(off, "analyst"), (group) => group.items) const active = Arr.filter(items, (item) => isNavItemActive("/alerts/uptime", item)) expect(Arr.map(active, (item) => item.title)).toEqual(["Incidents"]) }) diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 4e96819ddf..cf85977aa7 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -182,8 +182,8 @@ const alertsItem: NavItem = { ], } -/** The observer's Alerts: what is wrong now and how reliable things are, without the setup pages. */ -const observerIncidentsItem: NavItem = { +/** The analyst's Alerts: what is wrong now and how reliable things are, without the setup pages. */ +const analystIncidentsItem: NavItem = { title: "Incidents", href: "/alerts", icon: "BellIcon", @@ -203,10 +203,10 @@ const observerIncidentsItem: NavItem = { } /** - * Who the sidebar is drawn for. An observer follows the product without operating it, so they get + * Who the sidebar is drawn for. An analyst follows the product without operating it, so they get * the pages that answer "is it working and how is it doing", not the ones for digging or setup. */ -export const NavAudience = Schema.Literals(["operator", "observer"]) +export const NavAudience = Schema.Literals(["operator", "analyst"]) export type NavAudience = typeof NavAudience.Type /** The org rollout flags that gate a nav row each. */ @@ -217,13 +217,13 @@ export type NavFlags = Readonly<{ statusPages: boolean }> -const observerNavGroups = (flags: NavFlags): ReadonlyArray => [ +const analystNavGroups = (flags: NavFlags): ReadonlyArray => [ { id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] }, { id: "reliability", label: "Reliability", items: [ - observerIncidentsItem, + analystIncidentsItem, { title: "Errors", href: "/errors", icon: "CircleWarningIcon" }, ...(flags.statusPages ? [{ title: "Status pages", href: "/status-pages", icon: "WindowChartLineIcon" } as const] @@ -304,10 +304,10 @@ const operatorNavGroups = (flags: NavFlags): ReadonlyArray => [ /** * `navGroups(flags, audience)`; `cloudCost`, `releases`, `securityAudit` and `statusPages` gate a - * row each, and an observer gets {@link observerNavGroups}. + * row each, and an analyst gets {@link analystNavGroups}. */ export const navGroups = (flags: NavFlags, audience: NavAudience = "operator"): ReadonlyArray => - audience === "observer" ? observerNavGroups(flags) : operatorNavGroups(flags) + audience === "analyst" ? analystNavGroups(flags) : operatorNavGroups(flags) /** Segment-aware prefix match: `/services` must not claim `/service-map`. */ export const isPathActive = (currentPath: string, href: string): boolean => diff --git a/apps/web/src/shell/palette/entries.ts b/apps/web/src/shell/palette/entries.ts index 0f074f4f3b..1ba68a5cf5 100644 --- a/apps/web/src/shell/palette/entries.ts +++ b/apps/web/src/shell/palette/entries.ts @@ -88,14 +88,14 @@ const navRowsOf = (groups: ReadonlyArray) => ]), ) -/** What an observer's sidebar calls each of its pages (`/alerts` is "Incidents"), plus their account. */ -const OBSERVER_TITLES: ReadonlyMap = new Map([ - ...Arr.map(navRowsOf(navGroups(EVERY_FLAG, "observer")), (row) => [row.href, row.title] as const), +/** What an analyst's sidebar calls each of its pages (`/alerts` is "Incidents"), plus their account. */ +const ANALYST_TITLES: ReadonlyMap = new Map([ + ...Arr.map(navRowsOf(navGroups(EVERY_FLAG, "analyst")), (row) => [row.href, row.title] as const), ["/account", "Account"] as const, ]) /** - * The entries as the viewer's palette lists them. An observer gets their own pages under their + * The entries as the viewer's palette lists them. An analyst gets their own pages under their * sidebar's names, dashboards and the app actions; the ids stay, so a pick resolves either way. */ export const entriesFor = ( @@ -107,7 +107,7 @@ export const entriesFor = ( : Arr.flatMap(entries, (entry): ReadonlyArray => { if (entry.group === "Actions" || entry.group === "Dashboards") return [entry] if (entry.group !== "Navigation" || entry.target._tag !== "Href") return [] - const title = OBSERVER_TITLES.get(entry.target.href) + const title = ANALYST_TITLES.get(entry.target.href) return title === undefined ? [] : [{ ...entry, title, keywords: `${entry.keywords} ${title}` }] diff --git a/apps/web/src/shell/palette/palette.test.ts b/apps/web/src/shell/palette/palette.test.ts index afa5a94390..2830cc59ed 100644 --- a/apps/web/src/shell/palette/palette.test.ts +++ b/apps/web/src/shell/palette/palette.test.ts @@ -151,11 +151,11 @@ describe("command palette", () => { expect(titles).toEqual(expect.arrayContaining(["Connect Slack", "Connect Discord"])) }) - test("an observer finds their own pages and dashboards, not setup or the explorers", () => { + test("an analyst finds their own pages and dashboards, not setup or the explorers", () => { const model = loadedWith(["storefront"]) const shown = (query: string) => Arr.map( - shownEntries(typed(model, query).model, context, ["/releases", "/cost"], "observer").entries, + shownEntries(typed(model, query).model, context, ["/releases", "/cost"], "analyst").entries, (entry) => entry.title, ) expect(shown("analytics")).toContain("Web analytics") @@ -175,11 +175,11 @@ describe("command palette", () => { expect(operator).toContain("Traces") }) - test("Enter opens the page an observer sees highlighted, not the full list's", () => { + test("Enter opens the page an analyst sees highlighted, not the full list's", () => { const model = typed(loadedWith([]), "uptime").model const result = update( model, - Message.PressedEnter({ visibleFlagged: [], audience: "observer" }), + Message.PressedEnter({ visibleFlagged: [], audience: "analyst" }), context, ) expect(result.outMessage).toEqual(OutMessage.Navigated({ href: "/alerts/uptime" })) diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 6e4392524f..1ec2c911d0 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -405,8 +405,8 @@ export const shellSidebarFooter = ( sidebarMenu(h, undefined, [ // A row of the footer's list, so it keeps the list's 4px rhythm. ...onCallPill(h, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), - // An observer sets nothing up: their account is in the user menu below. - ...(shellAudience(clerk) === "observer" + // An analyst sets nothing up: their account is in the user menu below. + ...(shellAudience(clerk) === "analyst" ? [] : [ footerNavRow( diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index c6fac636cd..db0784c776 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -68,7 +68,7 @@ const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray Arr.map(group.items, (item) => item.title)), ...(isPillShown(onCall) ? [onCallDetail(onCall)] : []), - ...(shellAudience(clerk) === "observer" ? [] : ["Sources", "Settings"]), + ...(shellAudience(clerk) === "analyst" ? [] : ["Sources", "Settings"]), ] } @@ -196,7 +196,7 @@ export const frameView = Submodel.defineView((model inputs.topbarActions(), railTriggers, !hidesTopbarConnect(inputs.currentPath) && - shellAudience(inputs.clerk ?? null) !== "observer", + shellAudience(inputs.clerk ?? null) !== "analyst", inputs.sourceLink ?? null, ), ...(inputs.clerk === undefined || inputs.clerk === null diff --git a/packages/backend/src/services/auth/ApiAuthorizationLive.ts b/packages/backend/src/services/auth/ApiAuthorizationLive.ts index 95ba60f791..11c7b604a7 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationLive.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationLive.ts @@ -11,7 +11,7 @@ import { withAuditedRead } from "@maple/backend/services/audit/audit-access" import { Env } from "@maple/backend/platform/Env" import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" import { API_KEY_DEFAULT_ROLES, getBearerToken } from "./bearer-token" -import { refuseObserverWrites } from "./observer-gate" +import { refuseAnalystWrites } from "./analyst-gate" export const ApiAuthorizationLive = Layer.effect( CurrentTenant.Authorization, @@ -75,7 +75,7 @@ export const ApiAuthorizationLive = Layer.effect( roles: resolved.roles ?? API_KEY_DEFAULT_ROLES, authMode: "self_hosted", }) - yield* refuseObserverWrites(tenant.roles, options).pipe( + yield* refuseAnalystWrites(tenant.roles, options).pipe( Effect.tapError((error) => recordDenied(error.message)), ) return yield* httpEffect.pipe( @@ -103,7 +103,7 @@ export const ApiAuthorizationLive = Layer.effect( yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) // API keys are minted per instance; only a session can name an org from elsewhere. yield* regions.ensureServedHere(tenant.orgId) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), diff --git a/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts b/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts index 6bd974d9d3..aa2c1e8b79 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts @@ -33,7 +33,7 @@ import { deviceRateLimitKey, } from "./ApiV2RateLimiter" import { API_KEY_DEFAULT_ROLES, getBearerToken } from "./bearer-token" -import { refuseObserverWritesV2 } from "./observer-gate" +import { refuseAnalystWritesV2 } from "./analyst-gate" /** The access token of an RFC 9449 `Authorization: DPoP ` request. */ const getDpopToken = (headers: Record): string | undefined => { @@ -181,7 +181,7 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", scopes: resolved.scopes, }) - yield* refuseObserverWritesV2(tenant.roles, options, route.path) + yield* refuseAnalystWritesV2(tenant.roles, options, route.path) const label = "Device credential" return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, tenant), @@ -302,7 +302,7 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", ...(resolved.scopes !== null ? { scopes: resolved.scopes } : undefined), }) - yield* refuseObserverWritesV2(tenant.roles, options, route.path).pipe( + yield* refuseAnalystWritesV2(tenant.roles, options, route.path).pipe( Effect.tapError((error) => recordDenied(error.message)), ) return yield* httpEffect.pipe( @@ -347,7 +347,7 @@ export const ApiAuthorizationV2Live = Layer.effect( .pipe(Effect.mapError((error) => V2OrganizationAccessDenied.make(error.message))) } const { route } = yield* HttpRouter.RouteContext - yield* refuseObserverWritesV2(tenant.roles, options, route.path) + yield* refuseAnalystWritesV2(tenant.roles, options, route.path) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), diff --git a/packages/backend/src/services/auth/SessionAuthorizationLive.ts b/packages/backend/src/services/auth/SessionAuthorizationLive.ts index 7760b8ab5a..3aa7548927 100644 --- a/packages/backend/src/services/auth/SessionAuthorizationLive.ts +++ b/packages/backend/src/services/auth/SessionAuthorizationLive.ts @@ -10,7 +10,7 @@ import { withAuditedRead } from "@maple/backend/services/audit/audit-access" import { Env } from "@maple/backend/platform/Env" import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" import { getBearerToken } from "./bearer-token" -import { refuseObserverWrites } from "./observer-gate" +import { refuseAnalystWrites } from "./analyst-gate" /** * Authorization for the internal API: Clerk sessions only. @@ -48,7 +48,7 @@ export const SessionAuthorizationLive = Layer.effect( const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) yield* regions.ensureServedHere(tenant.orgId) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) const actor = { type: "user", source: "dashboard" } as const return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), @@ -87,7 +87,7 @@ export const RegionlessSessionAuthorizationLive = Layer.effect( } const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) const actor = { type: "user", source: "dashboard" } as const return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), diff --git a/packages/backend/src/services/auth/observer-gate.test.ts b/packages/backend/src/services/auth/analyst-gate.test.ts similarity index 52% rename from packages/backend/src/services/auth/observer-gate.test.ts rename to packages/backend/src/services/auth/analyst-gate.test.ts index b59b4a992c..abc15270e9 100644 --- a/packages/backend/src/services/auth/observer-gate.test.ts +++ b/packages/backend/src/services/auth/analyst-gate.test.ts @@ -2,9 +2,9 @@ import { assert, describe, expect, it } from "@effect/vitest" import { CurrentTenant, MapleInternalApi } from "@maple/domain/http" import { MapleApiV2 } from "@maple/domain/http/v2" import { Effect, Exit } from "effect" -import { refuseObserverWrites, refuseObserverWritesV2 } from "./observer-gate" +import { refuseAnalystWrites, refuseAnalystWritesV2 } from "./analyst-gate" -const OBSERVER = ["org:observer"] +const ANALYST = ["org:analyst"] const MEMBER = ["org:member"] const errors = MapleInternalApi.groups.errors @@ -16,10 +16,10 @@ const dashboards = MapleApiV2.groups.dashboards /** Whether the gate let the call through. */ const allowed = (gate: Effect.Effect) => Effect.map(Effect.exit(gate), Exit.isSuccess) -describe("refuseObserverWrites", () => { - it.effect("refuses an observer an issue transition and says how to get it", () => +describe("refuseAnalystWrites", () => { + it.effect("refuses an analyst an issue transition and says how to get it", () => Effect.gen(function* () { - const error = yield* Effect.flip(refuseObserverWrites(OBSERVER, transition)) + const error = yield* Effect.flip(refuseAnalystWrites(ANALYST, transition)) assert.instanceOf(error, CurrentTenant.ReadOnlyRoleError) expect(error.message).toContain("Ask an org admin for the Member role") }), @@ -27,34 +27,34 @@ describe("refuseObserverWrites", () => { it.effect("lets a member, and Maple's own agents with no roles, make the same change", () => Effect.gen(function* () { - expect(yield* allowed(refuseObserverWrites(MEMBER, transition))).toBe(true) - expect(yield* allowed(refuseObserverWrites([], transition))).toBe(true) + expect(yield* allowed(refuseAnalystWrites(MEMBER, transition))).toBe(true) + expect(yield* allowed(refuseAnalystWrites([], transition))).toBe(true) }), ) - it.effect("lets an observer run a dashboard query", () => + it.effect("lets an analyst run a dashboard query", () => Effect.gen(function* () { const query = { group: queries, endpoint: queries.endpoints.executeBatch } - expect(yield* allowed(refuseObserverWrites(OBSERVER, query))).toBe(true) + expect(yield* allowed(refuseAnalystWrites(ANALYST, query))).toBe(true) }), ) }) -describe("refuseObserverWritesV2", () => { +describe("refuseAnalystWritesV2", () => { const create = { group: dashboards, endpoint: dashboards.endpoints.create } - it.effect("lets an observer search traces, which the scope registry names as a read", () => + it.effect("lets an analyst search traces, which the scope registry names as a read", () => Effect.gen(function* () { const search = { group: traces, endpoint: traces.endpoints.search } - expect(yield* allowed(refuseObserverWritesV2(OBSERVER, search, "/v2/traces/search"))).toBe(true) + expect(yield* allowed(refuseAnalystWritesV2(ANALYST, search, "/v2/traces/search"))).toBe(true) }), ) - it.effect("refuses an observer a new dashboard and lets a member create one", () => + it.effect("refuses an analyst a new dashboard and lets a member create one", () => Effect.gen(function* () { - const error = yield* Effect.flip(refuseObserverWritesV2(OBSERVER, create, "/v2/dashboards")) - expect(error.message).toContain("Observers can read Maple") - expect(yield* allowed(refuseObserverWritesV2(MEMBER, create, "/v2/dashboards"))).toBe(true) + const error = yield* Effect.flip(refuseAnalystWritesV2(ANALYST, create, "/v2/dashboards")) + expect(error.message).toContain("Analysts can read Maple") + expect(yield* allowed(refuseAnalystWritesV2(MEMBER, create, "/v2/dashboards"))).toBe(true) }), ) }) diff --git a/packages/backend/src/services/auth/observer-gate.ts b/packages/backend/src/services/auth/analyst-gate.ts similarity index 71% rename from packages/backend/src/services/auth/observer-gate.ts rename to packages/backend/src/services/auth/analyst-gate.ts index d3bc7b0025..a3afc86ef6 100644 --- a/packages/backend/src/services/auth/observer-gate.ts +++ b/packages/backend/src/services/auth/analyst-gate.ts @@ -1,34 +1,34 @@ -import { CurrentTenant, isOpenToObservers } from "@maple/domain/http" +import { CurrentTenant, isOpenToAnalysts } from "@maple/domain/http" import { requiredScopeForRoute, V2InsufficientPermissions } from "@maple/domain/http/v2" import * as Policy from "@maple/domain/policy" import { Effect } from "effect" -type Called = Parameters[0] +type Called = Parameters[0] /** * Refuses a read-only role every endpoint that changes the organization. It runs in the auth * layers, so an endpoint added later is refused until it, or its whole group, is marked - * `ObserverAccess`. + * `AnalystAccess`. */ -export const refuseObserverWrites = ( +export const refuseAnalystWrites = ( roles: ReadonlyArray, called: Called, ): Effect.Effect => - Policy.isReadOnly(roles) && !isOpenToObservers(called) + Policy.isReadOnly(roles) && !isOpenToAnalysts(called) ? Effect.fail(new CurrentTenant.ReadOnlyRoleError({ message: Policy.readOnlyMessage })) : Effect.void /** - * {@link refuseObserverWrites} for `/v2`, where the scope registry already names the POSTs that + * {@link refuseAnalystWrites} for `/v2`, where the scope registry already names the POSTs that * only read. `routePath` is the matched route template, as that registry expects. */ -export const refuseObserverWritesV2 = ( +export const refuseAnalystWritesV2 = ( roles: ReadonlyArray, called: Called, routePath: string, ): Effect.Effect> => Policy.isReadOnly(roles) && - !isOpenToObservers(called) && + !isOpenToAnalysts(called) && requiredScopeForRoute(called.endpoint.method, routePath)?.access !== "read" ? Effect.fail(V2InsufficientPermissions.make(Policy.readOnlyMessage)) : Effect.void diff --git a/packages/domain/src/http/ai-models.ts b/packages/domain/src/http/ai-models.ts index d15ad4d15e..555f03a866 100644 --- a/packages/domain/src/http/ai-models.ts +++ b/packages/domain/src/http/ai-models.ts @@ -1,7 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { SessionAuthorization } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // AI model detection: a model string as an instrumentation reported it // (`gen_ai.request.model`, an OpenRouter id, a Bedrock id) → the model's @@ -75,5 +75,5 @@ export class AiModelsInternalApiGroup extends HttpApiGroup.make("aiModelsInterna ) .prefix("/internal/ai-models") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index c20c2ed8da..92357cff53 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -6,7 +6,7 @@ import { BucketSeconds } from "./query-engine" import { SessionAuthorization } from "./current-tenant" import { HttpTaggedError } from "./error-policy" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Backed by the `maple_ai.*` span attributes the ingest gateway stamps at // decode time; a session is resolved at trace granularity by @@ -1101,5 +1101,5 @@ export class AiSessionsInternalApiGroup extends HttpApiGroup.make("aiSessionsInt ) .prefix("/internal/ai-sessions") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/observer-access.test.ts b/packages/domain/src/http/analyst-access.test.ts similarity index 75% rename from packages/domain/src/http/observer-access.test.ts rename to packages/domain/src/http/analyst-access.test.ts index fc4a9907c2..cb31658008 100644 --- a/packages/domain/src/http/observer-access.test.ts +++ b/packages/domain/src/http/analyst-access.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "@effect/vitest" import { Array as Arr } from "effect" import { MapleApi } from "./api" import { MapleInternalApi } from "./internal-api" -import { isOpenToObservers } from "./observer-access" +import { isOpenToAnalysts } from "./analyst-access" import { MapleApiV2 } from "./v2/api" -type Called = Parameters[0] +type Called = Parameters[0] type Group = Called["group"] & { readonly endpoints: { readonly [name: string]: Called["endpoint"] & { readonly identifier: string } } } @@ -16,18 +16,18 @@ const endpointsOf = (group: Group) => Object.values(group.endpoints) const open = (group: Group, name: string): boolean => Arr.some( endpointsOf(group), - (endpoint) => endpoint.identifier === name && isOpenToObservers({ group, endpoint }), + (endpoint) => endpoint.identifier === name && isOpenToAnalysts({ group, endpoint }), ) const internal = MapleInternalApi.groups const v2 = MapleApiV2.groups -describe("isOpenToObservers", () => { - it("lets an observer run every dashboard query, which are all POSTs", () => { +describe("isOpenToAnalysts", () => { + it("lets an analyst run every dashboard query, which are all POSTs", () => { const queries = endpointsOf(internal.queryEngine) expect(queries.length).toBeGreaterThan(0) expect( - Arr.every(queries, (endpoint) => isOpenToObservers({ group: internal.queryEngine, endpoint })), + Arr.every(queries, (endpoint) => isOpenToAnalysts({ group: internal.queryEngine, endpoint })), ).toBe(true) }) @@ -41,7 +41,7 @@ describe("isOpenToObservers", () => { it("keeps every GET of a group that also changes things", () => { const reads = Arr.filter(endpointsOf(internal.errors), (endpoint) => endpoint.method === "GET") expect(reads.length).toBeGreaterThan(0) - expect(Arr.every(reads, (endpoint) => isOpenToObservers({ group: internal.errors, endpoint }))).toBe( + expect(Arr.every(reads, (endpoint) => isOpenToAnalysts({ group: internal.errors, endpoint }))).toBe( true, ) }) @@ -51,7 +51,7 @@ describe("isOpenToObservers", () => { expect(open(internal.digest, "preview")).toBe(true) }) - it("lets an observer manage their own digest, devices and connections", () => { + it("lets an analyst manage their own digest, devices and connections", () => { expect(open(internal.digest, "upsertSubscription")).toBe(true) expect(open(internal.digest, "deleteSubscription")).toBe(true) expect(open(v2.mobileDevices, "register")).toBe(true) diff --git a/packages/domain/src/http/observer-access.ts b/packages/domain/src/http/analyst-access.ts similarity index 57% rename from packages/domain/src/http/observer-access.ts rename to packages/domain/src/http/analyst-access.ts index ebcf0bde89..8b7a4159bd 100644 --- a/packages/domain/src/http/observer-access.ts +++ b/packages/domain/src/http/analyst-access.ts @@ -1,12 +1,12 @@ import { Context } from "effect" /** - * Why an observer, who only reads, may still call an endpoint that is not a GET: `read` only looks + * Why an analyst, who only reads, may still call an endpoint that is not a GET: `read` only looks * (a query with a body), `personal` changes the caller's own state. Unset means the endpoint changes - * the organization, and the auth middlewares refuse it to an observer. + * the organization, and the auth middlewares refuse it to an analyst. */ -export class ObserverAccess extends Context.Reference<"read" | "personal" | undefined>( - "@maple/http/ObserverAccess", +export class AnalystAccess extends Context.Reference<"read" | "personal" | undefined>( + "@maple/http/AnalystAccess", { defaultValue: () => undefined }, ) {} @@ -14,8 +14,8 @@ interface Annotated { readonly annotations: Context.Context } -/** Whether a read-only role may call the endpoint: a GET, or one marked {@link ObserverAccess}. */ -export const isOpenToObservers = (options: { +/** Whether a read-only role may call the endpoint: a GET, or one marked {@link AnalystAccess}. */ +export const isOpenToAnalysts = (options: { readonly endpoint: Annotated & { readonly method: string } readonly group: Annotated }): boolean => { @@ -23,7 +23,7 @@ export const isOpenToObservers = (options: { if (method === "GET" || method === "HEAD") return true // A group-level `.annotate` lands on the group only, so both are read; the endpoint wins. const access = - Context.get(options.endpoint.annotations, ObserverAccess) ?? - Context.get(options.group.annotations, ObserverAccess) + Context.get(options.endpoint.annotations, AnalystAccess) ?? + Context.get(options.group.annotations, AnalystAccess) return access !== undefined } diff --git a/packages/domain/src/http/auth.ts b/packages/domain/src/http/auth.ts index 72e2981dee..f2c40962ce 100644 --- a/packages/domain/src/http/auth.ts +++ b/packages/domain/src/http/auth.ts @@ -2,7 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { OrgId, UserId } from "@maple/primitives" import { Authorization, SessionAuthorization, TenantSchema, UnauthorizedError } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export class CliDeviceStartRequest extends Schema.Class("CliDeviceStartRequest")({ deviceName: Schema.String, @@ -265,13 +265,13 @@ export class AuthApiGroup extends HttpApiGroup.make("auth") HttpApiEndpoint.post("sessionRefresh", "/session/refresh", { success: SelfHostedLoginResponse, error: [SelfHostedAuthDisabledError, UnauthorizedError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.delete("cliSessionRevoke", "/cli/session", { success: CliDeviceActionResponse, error: [CliDeviceConflictError, CliDevicePersistenceError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .prefix("/api/auth") .middleware(Authorization) {} @@ -298,7 +298,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("cliDeviceDeny", "/cli/device/:userCode/deny", { @@ -310,7 +310,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.get("mcpOAuthAuthorizationInspect", "/mcp/oauth/authorization/:requestId", { @@ -333,7 +333,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("mcpOAuthAuthorizationDeny", "/mcp/oauth/authorization/:requestId/deny", { @@ -345,7 +345,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .prefix("/api/auth") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/billing.ts b/packages/domain/src/http/billing.ts index 23b430de22..ae110490ae 100644 --- a/packages/domain/src/http/billing.ts +++ b/packages/domain/src/http/billing.ts @@ -15,7 +15,7 @@ import { LinkBillingOrgRequest, } from "./billing-account" import { WarehouseQueryError } from "./warehouse-errors" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Contract for raw Autumn proxy responses. Schemas model only consumed fields // and tolerate additive upstream fields. @@ -726,7 +726,7 @@ export class BillingApiGroup extends HttpApiGroup.make("billing") payload: PreviewAttachRequest, success: PreviewAttachResult, error: [BillingManagedByBillingOrgError, ...billingRequestErrors], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("openCustomerPortal", "/portal", { diff --git a/packages/domain/src/http/current-tenant.ts b/packages/domain/src/http/current-tenant.ts index 1c4706cb48..c76c0bdfb3 100644 --- a/packages/domain/src/http/current-tenant.ts +++ b/packages/domain/src/http/current-tenant.ts @@ -91,7 +91,7 @@ export class OrganizationWrongRegionError extends HttpTaggedError()( "@maple/http/errors/ReadOnlyRoleError", { message: Schema.String }, diff --git a/packages/domain/src/http/digest.ts b/packages/domain/src/http/digest.ts index ac8ec68c6e..c0e38e6bc2 100644 --- a/packages/domain/src/http/digest.ts +++ b/packages/domain/src/http/digest.ts @@ -1,7 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { DateTime, Option, Schema } from "effect" import { SessionAuthorization } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export const DigestSubscriptionId = Schema.String.check(Schema.isUUID()).pipe( Schema.brand("@maple/DigestSubscriptionId"), @@ -164,25 +164,25 @@ export class DigestApiGroup extends HttpApiGroup.make("digest") payload: UpsertDigestSubscriptionRequest, success: DigestSubscriptionResponse, error: [DigestPersistenceError, DigestNotFoundError, DigestEmailUnavailableError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.delete("deleteSubscription", "/", { success: Schema.Void, error: DigestPersistenceError, - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("preview", "/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("previewWebAnalytics", "/web-analytics/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .prefix("/internal/digest") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/errors.ts b/packages/domain/src/http/errors.ts index 89750ffcba..b46fae0a47 100644 --- a/packages/domain/src/http/errors.ts +++ b/packages/domain/src/http/errors.ts @@ -43,7 +43,7 @@ import { IssueSeveritySource, WorkflowState, } from "../error-issues" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export { ActorType, ErrorIncidentReason, @@ -920,7 +920,7 @@ export class ErrorsApiGroup extends HttpApiGroup.make("errors") payload: EscalationPolicyEvaluationRequest, success: EscalationPolicyEvaluationDocument, error: ErrorPersistenceError, - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.get("listIssueEscalations", "/issues/:issueId/escalations", { diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index e079d9a9d5..491c80a7a0 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -21,7 +21,7 @@ export * from "./anomalies" export * from "./api-keys" export * from "./device-credentials" export * from "./audit-log" -export * from "./observer-access" +export * from "./analyst-access" export * from "./alerts" export * from "./escalation-chains" export * from "./oncall-schedules" diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index 9902e298a2..a0f0d24bd9 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -35,7 +35,7 @@ import { GcpCloudSqlSettings } from "../gcp" export { GcpCloudSqlSettings } from "../gcp" import { SupabaseAdvisorLint, SupabaseLogDrainSummary, SupabaseTopQuery } from "../supabase" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export { SupabaseAdvisorKind, SupabaseAdvisorLevel, @@ -1736,7 +1736,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") HttpApiEndpoint.post("infraSurfaces", "/infra-surfaces", { payload: InfraPresenceRequest, success: InfraSurfacesResponse, - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.get("cloudflareStatus", "/cloudflare/status", { @@ -1761,7 +1761,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") IntegrationsUpstreamError, IntegrationsPersistenceError, ], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("cloudflareStart", "/cloudflare/start", { diff --git a/packages/domain/src/http/mcp.ts b/packages/domain/src/http/mcp.ts index 3975ecb914..a278f9c6ef 100644 --- a/packages/domain/src/http/mcp.ts +++ b/packages/domain/src/http/mcp.ts @@ -5,7 +5,7 @@ import { TinybirdDateTime } from "../query-engine" import { BucketSeconds } from "./query-engine" import { SessionAuthorization } from "./current-tenant" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // MCP hub endpoint schemas (`/mcp-servers`, a server's overview, its tool page and its sessions). // @@ -341,5 +341,5 @@ export class McpInternalApiGroup extends HttpApiGroup.make("mcpInternal") ) .prefix("/internal/mcp-servers") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index d60d43dc93..afbd27dc42 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -44,7 +44,7 @@ import { PathsInclude, } from "@maple/query-model" import { WireRow } from "../wire" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" /** * A timeseries bucket width. @@ -5069,7 +5069,7 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") ) .prefix("/internal/query-engine") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") // Every endpoint here reads telemetry for the dashboard. .annotate(AuditedRead, "telemetry.read") {} diff --git a/packages/domain/src/http/session-replay.ts b/packages/domain/src/http/session-replay.ts index bebe700627..623f91f91a 100644 --- a/packages/domain/src/http/session-replay.ts +++ b/packages/domain/src/http/session-replay.ts @@ -6,7 +6,7 @@ import { AuditedRead } from "./audit-log" import { SessionAuthorization } from "./current-tenant" import { QueryEngineExecutionError, QueryEngineTimeoutError } from "./query-engine" import { warehouseHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Session replay schemas shared by the v2 contract and the internal replay group. @@ -170,6 +170,6 @@ export class SessionReplaysInternalApiGroup extends HttpApiGroup.make("sessionRe ) .prefix("/internal/session-replays") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") .annotate(AuditedRead, "session_replay.read") {} diff --git a/packages/domain/src/http/users.ts b/packages/domain/src/http/users.ts index 1deb76bd37..05b76487b8 100644 --- a/packages/domain/src/http/users.ts +++ b/packages/domain/src/http/users.ts @@ -7,7 +7,7 @@ import { TinybirdDateTime } from "../query-engine" import { AuditedRead } from "./audit-log" import { SessionAuthorization } from "./current-tenant" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // End users endpoint schemas (`/users`): the people Maple's customers identified with `identify()` // or a `user.id` span attribute, read by `ch/queries/users.ts` and `user-activity.ts`. Times are @@ -459,6 +459,6 @@ export class UsersInternalApiGroup extends HttpApiGroup.make("usersInternal") ) .prefix("/internal/users") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") .annotate(AuditedRead, "telemetry.read") {} diff --git a/packages/domain/src/http/v2/agent-feedback.ts b/packages/domain/src/http/v2/agent-feedback.ts index 78ce306610..a894ae206c 100644 --- a/packages/domain/src/http/v2/agent-feedback.ts +++ b/packages/domain/src/http/v2/agent-feedback.ts @@ -12,7 +12,7 @@ import { ListOf, ListQuery, Timestamp, wireExample } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" import { AgentFeedbackPublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" const isNotBlank = Schema.makeFilter((value: string) => Str.trim(value).length > 0, { description: "Must contain a non-whitespace character", @@ -136,7 +136,7 @@ export class V2AgentFeedbackApiGroup extends HttpApiGroup.make("agentFeedback") success: V2AgentFeedback, error: [persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "createAgentFeedback", diff --git a/packages/domain/src/http/v2/device-credentials.ts b/packages/domain/src/http/v2/device-credentials.ts index 9d50979fc4..a9000a67ed 100644 --- a/packages/domain/src/http/v2/device-credentials.ts +++ b/packages/domain/src/http/v2/device-credentials.ts @@ -18,7 +18,7 @@ import { V2RateLimited } from "./errors" import { publicError } from "./public-error" import { DeviceCredentialPublicId } from "./resource-ids" import { InstallationId } from "./widget-credentials" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" /** * Device-bound credentials (DPoP, RFC 9449) for the mobile Home Screen widgets. @@ -190,7 +190,7 @@ export class V2DeviceCredentialsApiGroup extends HttpApiGroup.make("deviceCreden persistence, ], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerDeviceCredential", @@ -206,7 +206,7 @@ export class V2DeviceCredentialsApiGroup extends HttpApiGroup.make("deviceCreden success: V2DeviceCredentialDeleteResponse, error: [sessionRequired, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeDeviceCredential", @@ -253,7 +253,7 @@ export class V2DeviceTokensApiGroup extends HttpApiGroup.make("deviceTokens") publicError(DeviceCredentialPersistenceError), ], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "issueDeviceToken", diff --git a/packages/domain/src/http/v2/integrations-chat.ts b/packages/domain/src/http/v2/integrations-chat.ts index 3307d82d2c..704970eef5 100644 --- a/packages/domain/src/http/v2/integrations-chat.ts +++ b/packages/domain/src/http/v2/integrations-chat.ts @@ -14,7 +14,7 @@ import { wireExample, Timestamp } from "./envelopes" import { V2CallbackHostUnavailable, V2InsufficientPermissions } from "./errors" import { publicErrors } from "./public-error" import { PublicId, PublicIdPrefixes } from "./public-id" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" // The chat-platform surface: one contract for every chat platform Maple can // connect to. The platform is a value (`connector`), never a path segment or a @@ -391,7 +391,7 @@ export class V2ChatIntegrationsApiGroup extends HttpApiGroup.make("chatIntegrati success: V2ChatIdentityDeleteResponse, error: [V2InsufficientPermissions.schema, chatNotFound, chatPersistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "deleteChatIdentity", diff --git a/packages/domain/src/http/v2/mcp-connections.ts b/packages/domain/src/http/v2/mcp-connections.ts index 6da5af4814..2b2a67af52 100644 --- a/packages/domain/src/http/v2/mcp-connections.ts +++ b/packages/domain/src/http/v2/mcp-connections.ts @@ -7,7 +7,7 @@ import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" import { McpConnectionPublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" const mcpConnectionExample = { id: "mcpc_p12S8T8agEHUkYc825nMz6", @@ -82,7 +82,7 @@ export class V2McpConnectionsApiGroup extends HttpApiGroup.make("mcpConnections" success: V2McpConnection, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeMcpConnection", diff --git a/packages/domain/src/http/v2/mobile-devices.ts b/packages/domain/src/http/v2/mobile-devices.ts index 46fa78a8f7..ba55eb443f 100644 --- a/packages/domain/src/http/v2/mobile-devices.ts +++ b/packages/domain/src/http/v2/mobile-devices.ts @@ -11,7 +11,7 @@ import { AuthorizationV2 } from "./auth" import { wireExample, ListOf, Timestamp } from "./envelopes" import { publicError } from "./public-error" import { AlertIncidentPublicId, MobileDevicePublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" // Code points, not UTF-16 units: identical for these ASCII values, and the only length the // JSON Schema `minLength` can state exactly (Effect halves a UTF-16 bound when it exports one). @@ -196,7 +196,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2MobileDevice, error: [persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerMobileDevice", @@ -212,7 +212,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2MobileDeviceDeleteResponse, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "unregisterMobileDevice", @@ -229,7 +229,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2LiveActivity, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerLiveActivity", @@ -245,7 +245,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2LiveActivityDeleteResponse, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "endLiveActivity", diff --git a/packages/domain/src/http/v2/widget-credentials.ts b/packages/domain/src/http/v2/widget-credentials.ts index dd1a91d729..cd48e0a652 100644 --- a/packages/domain/src/http/v2/widget-credentials.ts +++ b/packages/domain/src/http/v2/widget-credentials.ts @@ -5,7 +5,7 @@ import { OrgId } from "@maple/primitives" import { AuthorizationV2 } from "./auth" import { wireExample, Timestamp } from "./envelopes" import { publicError } from "./public-error" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" /** * The credential a device's Home Screen widgets fetch with. @@ -100,7 +100,7 @@ export class V2WidgetCredentialsApiGroup extends HttpApiGroup.make("widgetCreden success: V2WidgetCredential, error: [publicError(ApiKeyPersistenceError)], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "mintWidgetCredential", @@ -116,7 +116,7 @@ export class V2WidgetCredentialsApiGroup extends HttpApiGroup.make("widgetCreden success: V2WidgetCredentialDeleteResponse, error: [publicError(ApiKeyPersistenceError)], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeWidgetCredential", diff --git a/packages/domain/src/policy/index.ts b/packages/domain/src/policy/index.ts index d31d176803..d80afc066f 100644 --- a/packages/domain/src/policy/index.ts +++ b/packages/domain/src/policy/index.ts @@ -51,12 +51,12 @@ export const check = ( ): Effect.Effect => can(roles, permission) ? Effect.void : Effect.fail(onDenied(denialFor(permission))) -/** Whether the roles may only read: an observer, with no higher role beside it. */ -export const isReadOnly = (roles: ReadonlyArray): boolean => highestRole(roles) === "observer" +/** Whether the roles may only read: an analyst, with no higher role beside it. */ +export const isReadOnly = (roles: ReadonlyArray): boolean => highestRole(roles) === "analyst" -/** What an observer is told when a change is refused, on every surface. */ +/** What an analyst is told when a change is refused, on every surface. */ export const readOnlyMessage = - "Observers can read Maple but not change it. Ask an org admin for the Member role to make changes." + "Analysts can read Maple but not change it. Ask an org admin for the Member role to make changes." /** Every permission the roles hold, for a page model to keep instead of role flags. */ export const grantsOf = (roles: ReadonlyArray): ReadonlyArray => diff --git a/packages/domain/src/policy/policy.test.ts b/packages/domain/src/policy/policy.test.ts index 1e5631d6af..5a1bdce69b 100644 --- a/packages/domain/src/policy/policy.test.ts +++ b/packages/domain/src/policy/policy.test.ts @@ -32,8 +32,8 @@ describe("Policy.can", () => { } }) - it("holds nothing a member has for an observer or a Clerk role it does not know", () => { - for (const roles of [["org:observer"], ["org:billing"]]) { + it("holds nothing a member has for an analyst or a Clerk role it does not know", () => { + for (const roles of [["org:analyst"], ["org:billing"]]) { expect(Policy.can(roles, "ingest_keys:view_public")).toBe(false) expect(Policy.grantsOf(roles)).toEqual([]) } @@ -67,11 +67,11 @@ describe("Policy.check", () => { }), ) - it.effect("tells an observer which roles hold a member permission", () => + it.effect("tells an analyst which roles hold a member permission", () => Effect.gen(function* () { const error = yield* Effect.flip( Policy.check( - ["org:observer"], + ["org:analyst"], "ingest_keys:view_public", (denial) => new Forbidden({ message: denial.message }), ), @@ -113,7 +113,7 @@ describe("Policy.grantsOf", () => { describe("roleOf", () => { it("maps the Clerk keys the Members page assigns", () => { expect(Arr.map(Policy.assignableRoles, (entry) => Policy.roleOf(entry.clerkKey))).toEqual([ - "observer", + "analyst", "member", "engineer", "admin", @@ -131,17 +131,17 @@ describe("roleOf", () => { }) describe("highestRole", () => { - it("keeps an observer an observer instead of lifting them to member", () => { - expect(Policy.highestRole(["org:observer"])).toBe("observer") - expect(Policy.highestRole(["org:observer", "org:billing"])).toBe("observer") + it("keeps an analyst an analyst instead of lifting them to member", () => { + expect(Policy.highestRole(["org:analyst"])).toBe("analyst") + expect(Policy.highestRole(["org:analyst", "org:billing"])).toBe("analyst") }) - it("takes the highest when an observer role sits beside a higher one", () => { - expect(Policy.highestRole(["org:observer", "org:engineer"])).toBe("engineer") + it("takes the highest when an analyst role sits beside a higher one", () => { + expect(Policy.highestRole(["org:analyst", "org:engineer"])).toBe("engineer") }) - it("retires a member-pinned credential when its owner becomes an observer", () => { + it("retires a member-pinned credential when its owner becomes an analyst", () => { const pinned = Policy.highestRole(["org:member"]) - expect(Policy.outranks(pinned, Policy.highestRole(["org:observer"]))).toBe(true) + expect(Policy.outranks(pinned, Policy.highestRole(["org:analyst"]))).toBe(true) }) }) diff --git a/packages/domain/src/policy/roles.ts b/packages/domain/src/policy/roles.ts index 8ba1dcfefc..26988c35ef 100644 --- a/packages/domain/src/policy/roles.ts +++ b/packages/domain/src/policy/roles.ts @@ -5,11 +5,11 @@ import { Array as Arr, Option, Order, Schema } from "effect" * permission names only its lowest role. `root` is Maple's own operator access and self-hosted * mode; it is never assigned in Clerk. */ -export const Role = Schema.Literals(["observer", "member", "engineer", "admin", "root"]) +export const Role = Schema.Literals(["analyst", "member", "engineer", "admin", "root"]) export type Role = typeof Role.Type const RANK = { - observer: 0, + analyst: 0, member: 1, engineer: 2, admin: 3, @@ -21,9 +21,9 @@ const byRank: Order.Order = Order.mapInput(Order.Number, (role) => RANK[ro /** What the Members page shows for a role a person can be given in Clerk. */ export const assignableRoles = [ { - role: "observer", - clerkKey: "org:observer", - label: "Observer", + role: "analyst", + clerkKey: "org:analyst", + label: "Analyst", summary: "Follows incidents, reliability, product analytics and cost, without changing anything.", }, { @@ -51,7 +51,7 @@ export type ClerkRoleKey = AssignableRole["clerkKey"] /** * A Clerk or session role string as a {@link Role}. A role this code does not know reads as - * observer, the least it can hold. + * analyst, the least it can hold. */ export const roleOf = (name: string): Role => { if (name === "root") return "root" @@ -60,7 +60,7 @@ export const roleOf = (name: string): Role => { Arr.findFirst(assignableRoles, (entry) => entry.clerkKey === name), (entry): Role => entry.role, ), - (): Role => "observer", + (): Role => "analyst", ) } @@ -82,7 +82,7 @@ export const outranks = (role: Role, other: Role): boolean => RANK[role] > RANK[ /** "org admins and engineers": who holds a permission whose lowest role is `min`, for messages. */ export const holdersPhrase = (min: Role): string => { switch (min) { - case "observer": + case "analyst": return "people in the organization" case "member": return "org admins, engineers and members" From 9f44d69d203042578f384a49575a7f9a80aaca67 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:17:57 +0100 Subject: [PATCH 5/5] refactor(shell): read the analyst audience through Policy.isAnalyst --- apps/web/src/shell/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index f791a14b85..10123f69a5 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -136,7 +136,7 @@ export type ShellClerk = Readonly<{ /** Who the shell is drawn for; self-hosted has no Clerk role, so it is never an analyst. */ export const shellAudience = (clerk: ShellClerk | null): NavAudience => - clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.isReadOnly([role])) + clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.isAnalyst([role])) ? "analyst" : "operator"