From cfd972575f21565ece2c93e536c3cd189b0a7d5f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 00:48:45 +0100 Subject: [PATCH 1/3] feat(members): offer the observer role on invite and role change Observer joins the roles the Members page assigns, so the invite and role pickers offer it and the role change prompt says what it can do. Member stays the default for a new invite. A role key the page does not know now shows as Observer, matching how the API reads it. The role has to exist on the auth instance before it can be assigned. --- .../page/settings/kit/org-membership.test.ts | 18 +++++++++++++++--- .../src/page/settings/kit/org-membership.ts | 5 +++-- packages/domain/src/policy/policy.test.ts | 13 ++++++++----- packages/domain/src/policy/roles.ts | 18 +++++++----------- 4 files changed, 33 insertions(+), 21 deletions(-) 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 4f9e39a0e6..25784a1999 100644 --- a/apps/web/src/page/settings/kit/org-membership.test.ts +++ b/apps/web/src/page/settings/kit/org-membership.test.ts @@ -22,10 +22,22 @@ describe("leaveEligibility", () => { }) describe("toOrgRole", () => { - it("reads unknown Clerk roles as member", () => { + it("reads unknown Clerk roles as observer, the least a person can hold", () => { expect(toOrgRole("org:admin")).toBe("org:admin") - expect(toOrgRole("org:billing")).toBe("org:member") - expect(orgRoleLabel("org:viewer")).toBe("Member") + expect(toOrgRole("org:billing")).toBe("org:observer") + expect(orgRoleLabel("org:viewer")).toBe("Observer") + }) + + 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({ + question: + "Make Ada an observer? Follows incidents, reliability, product analytics and cost, without changing anything.", + confirmLabel: "Make observer", + }) }) }) diff --git a/apps/web/src/page/settings/kit/org-membership.ts b/apps/web/src/page/settings/kit/org-membership.ts index 81f933be72..4b421a693f 100644 --- a/apps/web/src/page/settings/kit/org-membership.ts +++ b/apps/web/src/page/settings/kit/org-membership.ts @@ -7,9 +7,10 @@ export type OrgRole = Policy.ClerkRoleKey /** The assignable Clerk role keys, lowest role first. */ export const orgRoleKeys: ReadonlyArray = Arr.map(Policy.assignableRoles, (entry) => entry.clerkKey) -const [memberEntry] = Policy.assignableRoles +// `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 members). */ +/** A Clerk role string's entry, read through `Policy.roleOf` (unknown roles are observers). */ const entryOf = (role: string): Policy.AssignableRole => { const policyRole = Policy.roleOf(role) return Option.getOrElse( diff --git a/packages/domain/src/policy/policy.test.ts b/packages/domain/src/policy/policy.test.ts index ae4febfa64..1e5631d6af 100644 --- a/packages/domain/src/policy/policy.test.ts +++ b/packages/domain/src/policy/policy.test.ts @@ -113,17 +113,20 @@ 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", "member", "engineer", "admin", ]) }) - it("reads the observer key as observer, below every assignable role", () => { - expect(Policy.roleOf("org:observer")).toBe("observer") - for (const entry of Policy.assignableRoles) { - expect(Policy.outranks(entry.role, "observer")).toBe(true) - } + it("lists the assignable roles lowest first, so each outranks the one before it", () => { + const roles = Arr.map(Policy.assignableRoles, (entry) => entry.role) + expect( + Arr.every(Arr.zip(roles, Arr.drop(roles, 1)), ([lower, higher]) => + Policy.outranks(higher, lower), + ), + ).toBe(true) }) }) diff --git a/packages/domain/src/policy/roles.ts b/packages/domain/src/policy/roles.ts index db865fd59a..8ba1dcfefc 100644 --- a/packages/domain/src/policy/roles.ts +++ b/packages/domain/src/policy/roles.ts @@ -20,6 +20,12 @@ 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", + summary: "Follows incidents, reliability, product analytics and cost, without changing anything.", + }, { role: "member", clerkKey: "org:member", @@ -43,16 +49,6 @@ export const assignableRoles = [ export type AssignableRole = (typeof assignableRoles)[number] export type ClerkRoleKey = AssignableRole["clerkKey"] -/** Read-only, for people who follow the product without operating it. Not offered on the Members page yet. */ -const observerRole = { - role: "observer", - clerkKey: "org:observer", - label: "Observer", - summary: "Follows incidents, reliability, product analytics and cost. Changes nothing.", -} as const satisfies { role: Role; clerkKey: string; label: string; summary: string } - -const clerkRoles = [observerRole, ...assignableRoles] as const - /** * 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. @@ -61,7 +57,7 @@ export const roleOf = (name: string): Role => { if (name === "root") return "root" return Option.getOrElse( Option.map( - Arr.findFirst(clerkRoles, (entry) => entry.clerkKey === name), + Arr.findFirst(assignableRoles, (entry) => entry.clerkKey === name), (entry): Role => entry.role, ), (): Role => "observer", From 0b097584947908b2be068096b7a4c775524a4849 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:12:08 +0100 Subject: [PATCH 2/3] 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 +-- .../page/settings/kit/org-membership.test.ts | 14 ++++---- .../src/page/settings/kit/org-membership.ts | 2 +- .../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 +++++----- 40 files changed, 159 insertions(+), 159 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/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/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 1031cfa4265179fc3738d055c2d60a66b02d4edd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:17:44 +0100 Subject: [PATCH 3/3] test(members): follow the analyst role's new summary --- apps/web/src/page/settings/kit/org-membership.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 3fd4bff249..3c6620387f 100644 --- a/apps/web/src/page/settings/kit/org-membership.test.ts +++ b/apps/web/src/page/settings/kit/org-membership.test.ts @@ -33,11 +33,10 @@ describe("toOrgRole", () => { }) it("names the analyst role in a role change prompt", () => { - expect(roleChangePrompt("Ada", "org:analyst")).toEqual({ - question: - "Make Ada an analyst? Follows incidents, reliability, product analytics and cost, without changing anything.", - confirmLabel: "Make analyst", - }) + const prompt = roleChangePrompt("Ada", "org:analyst") + expect(prompt.confirmLabel).toBe("Make analyst") + expect(prompt.question).toContain("Make Ada an analyst?") + expect(prompt.question).toContain("builds dashboards") }) })