diff --git a/apps/app/src/react-app/domains/automations/automation-runner-bridge.tsx b/apps/app/src/react-app/domains/automations/automation-runner-bridge.tsx index 67b51c585d8..ecf55bf995a 100644 --- a/apps/app/src/react-app/domains/automations/automation-runner-bridge.tsx +++ b/apps/app/src/react-app/domains/automations/automation-runner-bridge.tsx @@ -73,22 +73,6 @@ function ActivatedAutomationRunnerBridge() { if (!isCurrent()) return const agent = navigator.userAgent const platform = /Mac/i.test(agent) ? "darwin" : /Win/i.test(agent) ? "win32" : "linux" - // Recovery changes admission semantics. Negotiate it only for an - // explicitly enabled organization; old Den/missing features fail - // closed to the released capability ladder, not to a new engine. - const features = await client.getOrgFeatures(organizationId).catch((error): Record => { - // Released Automation registration can fall back to its old - // protocol. A remote-only desktop must retry an unavailable - // feature lookup rather than silently wait thirty minutes. - if (deploymentEnabled) return {} - throw error - }) - if (!isCurrent()) return - const recoveryEnabled = features.remoteSessionTargets === true - if (!deploymentEnabled && !recoveryEnabled) { - await disconnect() - return - } const mintToken = deploymentEnabled ? client.mintAutomationRunnerToken : client.mintSessionRunnerToken const mintRunner = async (id: string) => { const registration = ( @@ -112,7 +96,7 @@ function ActivatedAutomationRunnerBridge() { REMOTE_SESSION_RECOVERY_RUNNER_CAPABILITY, ] const capabilitySets: AutomationDesktopRunnerCapability[][] = deploymentEnabled ? [ - ...(recoveryEnabled ? [recoveryCapabilities] : []), + recoveryCapabilities, [ AUTOMATION_MODEL_ATTENTION_CAPABILITY, REMOTE_SESSION_DESKTOP_RUNNER_CAPABILITY, @@ -140,6 +124,15 @@ function ActivatedAutomationRunnerBridge() { try { runner = await mintRunner(runnerId) } catch (error) { + // An older Den answers 404 for remote session targets when that + // organization never had them turned on (or the route predates + // them). Nothing to connect: wait for the next refresh quietly + // instead of retrying every few seconds. + if (!deploymentEnabled && error instanceof DenApiError && error.status === 404) { + if (!isCurrent()) return + await disconnect() + return + } if (!(error instanceof DenApiError) || error.status !== 409 || error.code !== "automation_runner_identity_conflict") { throw error } diff --git a/apps/app/tests/automation-runner-bridge.test.tsx b/apps/app/tests/automation-runner-bridge.test.tsx new file mode 100644 index 00000000000..88304cf0336 --- /dev/null +++ b/apps/app/tests/automation-runner-bridge.test.tsx @@ -0,0 +1,81 @@ +/** @jsxImportSource react */ +import { GlobalRegistrator } from "@happy-dom/global-registrator" +import { afterAll, afterEach, expect, mock, spyOn, test } from "bun:test" +import { act } from "react" +import { createRoot } from "react-dom/client" +import type { AutomationRunnerTokenResponse } from "@openwork/types/automations" + +import * as den from "../src/app/lib/den" +import * as auth from "../src/react-app/domains/cloud/den-auth-provider" +import * as activation from "../src/react-app/domains/cloud/enterprise-activation-gate" +import * as availability from "../src/react-app/domains/automations/automation-availability" +import { AutomationRunnerBridge } from "../src/react-app/domains/automations/automation-runner-bridge" + +const ownedDom = typeof window === "undefined" +if (ownedDom) GlobalRegistrator.register({ url: "http://localhost/" }) +const previousAct = Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT") +Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true) +const cleanups: (() => Promise)[] = [] + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() + mock.restore() +}) +afterAll(async () => { + Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct) + if (ownedDom) await GlobalRegistrator.unregister() +}) + +test("a stale session-runner 404 cannot disconnect the replacement Automation runner", async () => { + const previousBridge = window.__OPENWORK_ELECTRON__ + const previousRunnerId = localStorage.getItem("openwork.automations.desktop-runner-id") + const configurations: unknown[] = [] + Reflect.set(window, "__OPENWORK_ELECTRON__", { + invokeDesktop: async (command: string, configuration: unknown) => { + if (command === "appBuildInfo") return { version: "test" } + if (command === "automationRunnerConfigure") configurations.push(configuration) + }, + }) + cleanups.push(async () => { + Reflect.set(window, "__OPENWORK_ELECTRON__", previousBridge) + if (previousRunnerId === null) localStorage.removeItem("openwork.automations.desktop-runner-id") + else localStorage.setItem("openwork.automations.desktop-runner-id", previousRunnerId) + }) + + spyOn(activation, "useEnterpriseActivationRequired").mockReturnValue(false) + spyOn(auth, "useDenAuth").mockReturnValue({ + status: "signed_in", user: null, verifiedIdentity: null, + error: null, isSignedIn: true, refresh: async () => {}, + }) + const deployment = spyOn(availability, "useAutomationDeploymentEnabled").mockReturnValue(false) + const settings = den.readDenSettings() + spyOn(den, "readDenSettings").mockReturnValue({ ...settings, authToken: "test-token", activeOrgId: "org_test" }) + const client = den.createDenClient({ baseUrl: "https://den.example.test", token: "test-token" }) + const request = Promise.withResolvers() + let sessionRequests = 0 + spyOn(den, "createDenClient").mockReturnValue({ + ...client, + mintSessionRunnerToken: async () => { sessionRequests += 1; return request.promise }, + mintAutomationRunnerToken: async () => ({ + token: "replacement-token", expiresAt: "2026-10-12T23:00:00Z", eventsPath: "/v1/automation-runners/events", + }), + }) + + const host = document.createElement("div") + document.body.append(host) + const root = createRoot(host) + cleanups.push(async () => { await act(async () => root.unmount()); host.remove() }) + await act(async () => { root.render() }) + expect(sessionRequests).toBe(1) + + deployment.mockReturnValue(true) + await act(async () => { root.render() }) + expect(configurations.at(-1)).toMatchObject({ token: "replacement-token" }) + const count = configurations.length + + await act(async () => { + request.reject(new den.DenApiError(404, "feature_disabled", "Old Den has no session runner route")) + }) + expect(configurations).toHaveLength(count) + expect(configurations.at(-1)).toMatchObject({ token: "replacement-token" }) +}) diff --git a/docs/marketplace-capabilities-architecture.md b/docs/marketplace-capabilities-architecture.md index 310a1c49e98..9d7b7a09e81 100644 --- a/docs/marketplace-capabilities-architecture.md +++ b/docs/marketplace-capabilities-architecture.md @@ -197,35 +197,19 @@ Scope note: these are `mcp:read`-class operations; nothing on this path writes; --- -## 7. Org kill switch +## 7. Availability -Marketplace capability search/execute uses the same effective Connect rail check -as External MCP connections: the `mcpConnections` feature, read with -`getOrganizationFeatures(organizationId)` from `ee/apps/den-api/src/features.ts` -and declared in `packages/features/src/registry.ts`. - -Connect is default-on, so local dev, self-hosted, evals, and hosted production -get the feature immediately unless a platform admin turns it off for an -organization in /admin (stored in the `organization_feature` table), or an -operator locks it with `config.features.mcpConnections` / `DEN_FEATURE_MCP_CONNECTIONS`. - -Check the kill switch in both paths: disabled orgs get an empty marketplace -merge; execute returns `unknown_capability`. Opted-out is byte-identical to -nonexistence. +Marketplace capability search/execute is available in every organization, like +External MCP connections; the former `mcpConnections` org switch is retired. ### Additive invariants 1. Desktop users without the cloud connection: this code is unreachable; zero change. -2. Cloud-connected users in opted-out orgs: byte-identical search results and execute behavior. -3. Tool surface unchanged: still exactly `search_capabilities` + `execute_capability`. -4. The desktop install flow was later removed (#2857 stopped desktop installs; the local GitHub import and the install route followed). `apps/server/src/cloud-plugins.ts` now only lists and removes local copies made by older builds. -5. The rich `/mcp` endpoint and `/mcp/admin` are untouched. External connections also merged only into `/mcp/agent`. -6. No schema changes, no migrations, no new tables in Phase 1; everything derives from existing plugin-arch tables in `ee/packages/den-db/src/schema/sharables/plugin-arch.ts`. -7. No prompt changes and no tool-description changes in Phase 1; results are self-describing via `summary`, `status`, and `hint`. - -Kill switch uses the same layer as connections: turn the `mcpConnections` -feature off for the organization in /admin (or with the `den_set_org_capability` -admin tool). +2. Tool surface unchanged: still exactly `search_capabilities` + `execute_capability`. +3. The desktop install flow was later removed (#2857 stopped desktop installs; the local GitHub import and the install route followed). `apps/server/src/cloud-plugins.ts` now only lists and removes local copies made by older builds. +4. The rich `/mcp` endpoint and `/mcp/admin` are untouched. External connections also merged only into `/mcp/agent`. +5. No schema changes, no migrations, no new tables in Phase 1; everything derives from existing plugin-arch tables in `ee/packages/den-db/src/schema/sharables/plugin-arch.ts`. +6. No prompt changes and no tool-description changes in Phase 1; results are self-describing via `summary`, `status`, and `hint`. --- @@ -233,7 +217,7 @@ admin tool). ### Phase 1: this document's scope, Den-only, additive -Build the marketplace search source, the execute branch for all classes with honest statuses, the org kill switch, tests, and eval flow. There are zero desktop changes; member-facing Connect rail behavior is default-on unless an org explicitly opts out. +Build the marketplace search source, the execute branch for all classes with honest statuses, tests, and eval flow. There are zero desktop changes; member-facing Connect rail behavior is on for every organization. Deliverables: @@ -330,7 +314,7 @@ Unit tests in `ee/apps/den-api/test/marketplace-capabilities.test.ts`: Existing contract must keep passing untouched: `evals/flows/mcp-search-capabilities.flow.mjs` asserts exactly two tools. -New eval flow `evals/flows/marketplace-capabilities.flow.mjs`: seed marketplace + plugin + skill config object via the plugin-system API → real chat turn → assert `search_capabilities` returns the `plugin:` match → `execute_capability` returns the skill content → the answer reflects the skill's instructions; control frame proves an opted-out org sees no `plugin:` matches. +New eval flow `evals/flows/marketplace-capabilities.flow.mjs`: seed marketplace + plugin + skill config object via the plugin-system API → real chat turn → assert `search_capabilities` returns the `plugin:` match → `execute_capability` returns the skill content → the answer reflects the skill's instructions. Test evidence follows `AGENTS.md`: `evals/results/test-runs//test-run.json` exists, and every claim is backed by observable assertion evidence or a validated screenshot. diff --git a/docs/opencode-app-parity.md b/docs/opencode-app-parity.md index e2bfa2c79cb..33add795229 100644 --- a/docs/opencode-app-parity.md +++ b/docs/opencode-app-parity.md @@ -32,9 +32,9 @@ Cloud skills now follow the same v1 path on both engines: the shared Connect met ### Implicit Cloud skill discovery (ENG-691) -`implicitCloudSkills` is a default-off organization rollout on Cloud and self-hosted deployments. When enabled in Den, the discovery index opts its entries into the v2 native skill registry. The existing context plugin refreshes that metadata in the background; setup and prompt admission never wait for the index. A cold first turn may run before the index arrives. Later engine steps see the updated native skill guidance without restarting the conversation. +Implicit Cloud skill discovery is on for every organization on Cloud and self-hosted deployments. The discovery index opts its entries into the v2 native skill registry. The existing context plugin refreshes that metadata in the background; setup and prompt admission never wait for the index. A cold first turn may run before the index arrives. Later engine steps see the updated native skill guidance without restarting the conversation. -Only names, descriptions and retrieval capabilities are held in memory. No skill bodies, credentials or authorization decisions are persisted by this feature. Native entries contain a retrieval note, not copied instructions: the model must load the current body through Connect `get_skill`, which keeps Den's per-request access checks. Removal therefore denies the next body fetch even if old discovery metadata or an earlier body remains in context. Disabling the rollout removes native entries on the next metadata refresh and leaves the existing Connect path intact. V1 is unchanged. +Only names, descriptions and retrieval capabilities are held in memory. No skill bodies, credentials or authorization decisions are persisted by this feature. Native entries contain a retrieval note, not copied instructions: the model must load the current body through Connect `get_skill`, which keeps Den's per-request access checks. Removal therefore denies the next body fetch even if old discovery metadata or an earlier body remains in context. V1 is unchanged. The inline inventory is bounded to 150 skills and 24,000 metadata characters (descriptions at most 200 characters); larger catalogs remain discoverable through Connect. `SKILL-CLOUD-DISCOVERY` in `opencode-v2-skill-jit.e2e.test.ts` exercises a held first index read, rollout-off behavior, unnamed skill use and removal with stale metadata against the pinned v2 engine and identity-scoped witnesses. This is integration evidence, not a live-model trigger-rate benchmark. diff --git a/docs/org-install-links.md b/docs/org-install-links.md index c070528dbfd..11631741bd8 100644 --- a/docs/org-install-links.md +++ b/docs/org-install-links.md @@ -36,9 +36,8 @@ The guided flow is scoped to Den organization install links. The public required. 3. Restart or roll the deployment. No install-link capability toggle is required for a normal self-hosted installation. -4. No rollout flag is required: install links are on by default. Configure - `config.public.bootstrapAdminEmails` only if platform admins need `/admin` - access to turn an org dark as a kill switch. +4. No rollout flag is required: install links are available in every + organization. ### Docker Compose diff --git a/docs/quality/analytics-integration.md b/docs/quality/analytics-integration.md index 6122024ff51..67b91853206 100644 --- a/docs/quality/analytics-integration.md +++ b/docs/quality/analytics-integration.md @@ -1,7 +1,8 @@ # Analytics integration The `library-usage`, `workflow-run-previews`, and `org-model-analytics` -journeys compare the existing presentation with `analyticsIntegrated` on. +journeys compare the existing presentation with the integrated one, which +comes with `libraryUsage` (it replaced the retired `analyticsIntegrated` flag). The Library journey also records Members and Connectors in the same workspace. ## Mismatches @@ -21,7 +22,7 @@ The Library journey also records Members and Connectors in the same workspace. ## Scope and rollback -`analyticsIntegrated` starts off on cloud and self-hosted. It changes +The integrated presentation follows `libraryUsage`. It changes presentation only: no counts, collection, consent, entitlements, permissions, API queries, or persisted history change. Turning it off restores the original views immediately. Rows keep their existing Library/Connectors destinations; diff --git a/docs/quality/visual-feature-audit.md b/docs/quality/visual-feature-audit.md index d75d778d3e2..a55f456a339 100644 --- a/docs/quality/visual-feature-audit.md +++ b/docs/quality/visual-feature-audit.md @@ -28,7 +28,7 @@ claimed. Raw PR material and historical images are not committed here. | Desktop Calendar | Readable initial hour, bounded creation/details, setup and blocked-state direction | `automation-calendar.e2e.test.ts` | | Den text and search | Readable sentence-case labels, long workspace heading, clear palette empty state | `den-dashboard-activity.e2e.test.ts`, `den-command-palette.e2e.test.ts` | | Shared save bars | Painted bottom inset and focus scroll clearance; Remove-only rows stay inline | `permissions-ui.e2e.test.ts`, `ai-gateway-cloud-sign-in.e2e.test.ts` | -| Gateway | Consistent provider filtering, loading/error/empty recovery, access-removal confirmation, searchable team assignment | `litellm-preview-flag.e2e.test.ts`, `ai-gateway-admin-provider.e2e.test.ts`, `ai-gateway-cloud-sign-in.e2e.test.ts`, `ai-gateway-spend-limits.e2e.test.ts` | +| Gateway | Consistent provider filtering, loading/error/empty recovery, access-removal confirmation, searchable team assignment | `ai-gateway-provider-catalog.e2e.test.ts`, `ai-gateway-admin-provider.e2e.test.ts`, `ai-gateway-cloud-sign-in.e2e.test.ts`, `ai-gateway-spend-limits.e2e.test.ts` | | Deployments | Explicit removal and cancel, consistent lanes, readable pending state, responsive consent | `managed-deployments.e2e.test.ts` | | Permissions and members | Readable tabs and identity, usable narrow rows, real team-admin controls and denied identities | `permissions-ui.e2e.test.ts`, `team-organization-admin-ui.e2e.test.ts` | | Analytics | Neutral zero-failure and plan-locked states, contextual reasons, compact filtering | `library-usage.e2e.test.ts`, `workflow-run-previews.e2e.test.ts` | @@ -66,10 +66,9 @@ absence. Neither observation workaround weakens the actual UI claim. before its implementation. It changes only presentation; turning it off restores the existing header without changing objects, permissions, navigation, or running work. No production rollout is changed by this audit. -- `auditLogsCompact` is declared **off** on cloud and self-hosted. It changes - only audit presentation, through Den's resolved feature map. Turning it off - restores the previous operation table and capture settings; recording, - retained evidence, filters, redaction and permission checks remain unchanged. +- Compact audit rows are now the only audit presentation (the former + `auditLogsCompact` flag is retired). Recording, retained evidence, filters, + redaction and permission checks are unchanged. - Other repairs preserve their existing feature gates and server contracts. - `Cloud default` is the organization's cloud-agent default, not the free `Auto` model. Explicit Automation model choices remain intact. diff --git a/docs/session-offloading-architecture.md b/docs/session-offloading-architecture.md index 0c571956048..e668a7f823f 100644 --- a/docs/session-offloading-architecture.md +++ b/docs/session-offloading-architecture.md @@ -34,8 +34,8 @@ remote session lifecycle. - Standalone `opencode-openwork` uses the existing approved OpenWork account. It requires explicit `remoteSessions: true`; signing in for models or MCP connections alone does not authorize remote control. -- `remoteSessionTargets` is off by default for Cloud and self-hosted installs. - Den's ordinary feature resolver controls admission; no second rollout system. +- Registered offloading is available on Cloud and self-hosted installs; there + is no rollout switch. - Runner tokens are time-limited, audience-bound credentials. Every runner request rechecks the owner's active membership. Credentials are not stored in execution journals. @@ -121,8 +121,6 @@ context is not a session error. Native status events are live hints, not a replayable source of truth. Recovered observation uses native session/context and permission state rather than an in-memory event alone. -Turning off `remoteSessionTargets` prevents new registered offloads/turns. It -must not hide existing receipts or prevent stopping an admitted session. Runner unload/account switch stops its control loop and releases ownership; normal native permission handling is never bypassed. diff --git a/ee/apps/den-api/.env.example b/ee/apps/den-api/.env.example index 8e8b27a6a91..b396d89e59a 100644 --- a/ee/apps/den-api/.env.example +++ b/ee/apps/den-api/.env.example @@ -114,18 +114,16 @@ DEN_ORG_MODE= DEN_OPENWORK_WEB_ENABLED=false # Audit availability is included in Enterprise plans. Self-hosted operators may # explicitly grant it installation-wide without a Cloud plan; keep false in Cloud. -# This does NOT grant the per-org auditLogs feature flag or initialize a policy. +# This does NOT initialize a policy. DEN_AUDIT_SELF_HOSTED_ENABLED=false # Default-on deployment switches; explicit false is a global kill switch. -# Platform admins must turn on the auditLogs feature for each org in /admin. # Ready orgs automatically get a temporary 6,000,000 retained-operation policy. # Capture starts ON; existing policies/OFF choices are preserved. No cap enforcement. -# Existing capture OFF preferences remain OFF. Flag disable hides history without deletion. +# Existing capture OFF preferences remain OFF. DEN_AUDIT_CAPTURE_ENABLED=true DEN_AUDIT_VISIBILITY_ENABLED=true # Tenantless requests (auth, public, failed-before-attribution) go to platform_audit_event -# while capture is enabled; successful side-effect-free (readOnly) platform reads are recorded -# only when the platformAuditReads feature is on (/admin › Features, default off). +# while capture is enabled, including successful side-effect-free (readOnly) platform reads. # Bearer token (24+ characters) for scheduled maintenance callers, such as the daily # audit usage refresh: POST /internal/audit/usage/refresh. Unset answers 404. # DEN_MAINTENANCE_TOKEN= diff --git a/ee/apps/den-api/scripts/check-audit-route-coverage.ts b/ee/apps/den-api/scripts/check-audit-route-coverage.ts index fc103963632..742b011fad7 100644 --- a/ee/apps/den-api/scripts/check-audit-route-coverage.ts +++ b/ee/apps/den-api/scripts/check-audit-route-coverage.ts @@ -230,7 +230,7 @@ const CLASS_POLICY: Readonly`, parent organization, method, route template and status. Never bodies, query strings, headers, IPs or secrets.", "- One operation per request: intent, outcome and domain change events (`appendAuditChanges`) share it. Requests without trustworthy tenant attribution go to `platform_audit_event` (no organization column, not tenant-visible) with a target: the declared resource type plus validated path id, else the first handler-named resource; an organization target only when it is the authenticated context's organization.", - "- The platform read filter keys on effect, not method: only declarations marked `readOnly` (platform GET/HEAD without side effects) skip successful requests unless the deployment-wide `platformAuditReads` feature is on; every other platform request, including side-effecting GETs (OAuth/social callbacks, verify-email, unsubscribe, end-session, SLO, token), is recorded.", + "- Every platform request is recorded, including side-effect-free reads (declarations marked `readOnly`: platform GET/HEAD without side effects) and side-effecting GETs (OAuth/social callbacks, verify-email, unsubscribe, end-session, SLO, token).", "- Retry: every standalone audit transaction (intent, served, outcome, service-action and job outcomes, user fan-out, after-commit change events, `/v1/audit/*` access evidence, platform rows) is re-run as a whole (entitlement recheck and policy check included) on transient database errors: deadlock (1213) and lock wait timeout (1205) always, connection loss (ECONNRESET, EPIPE, ETIMEDOUT, PROTOCOL_CONNECTION_LOST) only when the append is idempotent on replay (stable request/job operation plus idempotency key, or a pre-generated platform row id). Up to 3 attempts with ~50/150 ms jittered backoff, each retry logged `[audit-append-retry]`; then the failure behaviour below applies. `AuditLogError`s, feature/entitlement decisions and a full PlanetScale transaction pool (ResourceExhausted) are never retried. Change events appended inside a business transaction are not retried (a deadlock rolls the mutation back).", - "- When a tenant outcome append fails with `audit_policy_changed` after its intent was recorded (the request itself disabled capture, e.g. an `auditLogs` feature override set to off or a plan change removing the entitlement), the outcome is written to `platform_audit_event` targeting the verified organization.", + "- When a tenant outcome append fails with `audit_policy_changed` after its intent was recorded (the request itself disabled capture, e.g. a plan change removing the entitlement), the outcome is written to `platform_audit_event` targeting the verified organization.", "", "## Classes", "", @@ -306,7 +306,7 @@ function renderCoverage(input: { registry: Registry; coverage: Coverage; bridge: "", "## User-scoped events (attribution `user_memberships`)", "", - "Rule: endpoints acting on a user rather than one trusted tenant (sign-in, sign-out, session revocation, account and password changes, identity links, organization switching, desktop handoff) keep their request evidence in `platform_audit_event` (class `platform`, declared action stem) and declare `attribution: user_memberships` plus the emitter in `changeEvidence`. The emitter appends the events below through `src/audit/fanout.ts:recordAuditForUserMemberships` into the user's verified active memberships: session events into the session's (or destination) organization, or into every organization where the user is an active member when the session has no active organization (it can act in all of them); account and failed sign-in events into every active membership. Sessions Den deletes directly (organization-scoped credential revocation, bearer sign-out, admin user deletion) record `session.revoked` with a reasonCode in the affected organization, reusing the current request's change capture for that organization. Each organization gets its own operation and transaction (entitlement recheck, policy-current fence) sharing the request id; only organizations with the literal `auditLogs` flag, capture on, an enabled policy and the event category store anything; up to 3 attempts on transient database errors (connection loss only when the request id makes the replay idempotent), then a failure logs `[audit-outcome-lost]` and never fails the user's request. Endpoints already attributed to one tenant (better-auth `organization/set-active`, SSO callbacks) keep that request evidence; their `session.*` change event joins the request operation when it is captured for the same organization. No `session.updated` event exists: `update-session` cannot change a meaningful field in Den.", + "Rule: endpoints acting on a user rather than one trusted tenant (sign-in, sign-out, session revocation, account and password changes, identity links, organization switching, desktop handoff) keep their request evidence in `platform_audit_event` (class `platform`, declared action stem) and declare `attribution: user_memberships` plus the emitter in `changeEvidence`. The emitter appends the events below through `src/audit/fanout.ts:recordAuditForUserMemberships` into the user's verified active memberships: session events into the session's (or destination) organization, or into every organization where the user is an active member when the session has no active organization (it can act in all of them); account and failed sign-in events into every active membership. Sessions Den deletes directly (organization-scoped credential revocation, bearer sign-out, admin user deletion) record `session.revoked` with a reasonCode in the affected organization, reusing the current request's change capture for that organization. Each organization gets its own operation and transaction (entitlement recheck, policy-current fence) sharing the request id; only organizations with capture on, an enabled policy and the event category store anything; up to 3 attempts on transient database errors (connection loss only when the request id makes the replay idempotent), then a failure logs `[audit-outcome-lost]` and never fails the user's request. Endpoints already attributed to one tenant (better-auth `organization/set-active`, SSO callbacks) keep that request evidence; their `session.*` change event joins the request operation when it is captured for the same organization. No `session.updated` event exists: `update-session` cannot change a meaningful field in Den.", "", "| Event type | Category | Organizations | Actor | Chokepoints | Evidence |", "|---|---|---|---|---|---|", @@ -336,7 +336,7 @@ function renderCoverage(input: { registry: Registry; coverage: Coverage; bridge: const policy = CLASS_POLICY[declaration.class] const resource = `${declaration.resource.type}${declaration.resource.idParam ? ` (:${declaration.resource.idParam})` : ""}` const evidence = declaration.changeEvidence ?? (declaration.class.startsWith("tenant_") ? "request evidence only" : "") - const extra = [declaration.readOnly ? "read-only (successes need the platformAuditReads feature)" : "", declaration.external ? `external: ${declaration.external}` : "", declaration.jobOutcome ? `job outcome: ${declaration.jobOutcome}` : "", declaration.notes ?? ""].filter(Boolean).join("; ") + const extra = [declaration.readOnly ? "read-only" : "", declaration.external ? `external: ${declaration.external}` : "", declaration.jobOutcome ? `job outcome: ${declaration.jobOutcome}` : "", declaration.notes ?? ""].filter(Boolean).join("; ") return `| ${declaration.method} | \`${cell(declaration.path)}\` | ${declaration.class} | \`${cell(declaration.action)}\` | ${cell(declaration.kind)} | ${cell(resource)} | ${cell(declaration.attribution)} | ${cell(policy.capture)} | ${cell(evidence)} | ${cell(policy.failure)} | ${cell(extra)} |` }), "", diff --git a/ee/apps/den-api/src/artifact-views.ts b/ee/apps/den-api/src/artifact-views.ts index f319712b530..b1e166bfcea 100644 --- a/ee/apps/den-api/src/artifact-views.ts +++ b/ee/apps/den-api/src/artifact-views.ts @@ -3,19 +3,20 @@ import { ArtifactViewRevisionTable, ArtifactViewTable, DashboardAppTable } from import { createDenTypeId, normalizeDenTypeId, type DenTypeId } from "@openwork-ee/utils/typeid" import type { GeneratedArtifactView, GeneratedArtifactViewRevision } from "@openwork/types/workflows" import { db } from "./db.js" -import { organizationBuildsMcpApps } from "./mcp-app-rollout.js" +import { env } from "./env.js" import { getWorkflowAccess, getWorkflowDetail } from "./workflows.js" import { buildGeneratedArtifactView } from "./generated-artifact-view-builder.js" import type { PluginArchActorContext } from "./routes/org/plugin-system/access.js" import { artifactViewResourceUri } from "./artifact-view-resource.js" /** - * Workflow-bound views are read-only wherever the organization builds its own - * Apps as MCP servers (see appMcpServersEnabled). They keep rendering, running - * live data, and can be retired, but are not created, edited, or re-activated. + * Workflow-bound views are read-only wherever the deployment lets organizations + * build their own Apps as MCP servers (DEN_APP_MCP_SERVERS_ENABLED). They keep + * rendering, running live data, and can be retired, but are not created, + * edited, or re-activated. */ -export async function legacyArtifactViewsReadOnly(context: PluginArchActorContext): Promise { - return organizationBuildsMcpApps(context.organizationContext.organization.id) +function legacyArtifactViewsReadOnly(): boolean { + return env.appMcpServersEnabled } const ARTIFACT_VIEW_LIST_LIMIT = 50 @@ -243,7 +244,7 @@ export async function saveArtifactViewRevision(input: { cssSource?: string dataMode?: "live" | "snapshot" }): Promise { - if (await legacyArtifactViewsReadOnly(input.context)) throw new Error("legacy_view_read_only") + if (legacyArtifactViewsReadOnly()) throw new Error("legacy_view_read_only") const script = await getWorkflowDetail({ context: input.context, configObjectId: input.configObjectId }) if (!script.canManage) throw new Error("artifact_view_not_found") if (!script.currentVersion.outputSchema || !script.currentVersion.outputSchemaDigest) { @@ -332,7 +333,7 @@ export async function activateArtifactViewRevision(input: { revisionId: string save?: { title: string; useInWorkflow: boolean; expectedActiveRevisionId: string | null } }): Promise { - if (await legacyArtifactViewsReadOnly(input.context)) throw new Error("legacy_view_read_only") + if (legacyArtifactViewsReadOnly()) throw new Error("legacy_view_read_only") const view = await accessibleView({ context: input.context, artifactViewId: input.artifactViewId, role: "manager" }) const revisionId = parseRevisionId(input.revisionId) const revisions = await db.select().from(ArtifactViewRevisionTable).where(and( diff --git a/ee/apps/den-api/src/audit/COVERAGE.md b/ee/apps/den-api/src/audit/COVERAGE.md index c44e44107bb..51663e522e8 100644 --- a/ee/apps/den-api/src/audit/COVERAGE.md +++ b/ee/apps/den-api/src/audit/COVERAGE.md @@ -3,12 +3,12 @@ Generated by `pnpm --filter @openwork-ee/den-api audit:coverage --write` from `src/audit/routes`. Do not edit by hand; CI fails when this file is stale. - 575 den-api route keys (`app.routes`), 143 HTTP-reachable better-auth endpoints, 700 declarations. -- Tenant capture requires the trusted organization's `auditLogs` feature (features registry, read fresh), `DEN_AUDIT_CAPTURE_ENABLED`, an enabled policy and the class category; with capture off no audit database work happens. +- Tenant capture requires `DEN_AUDIT_CAPTURE_ENABLED`, an enabled policy and the class category; with capture off no audit database work happens. - Request evidence: declared resource type, validated path-parameter id (`unparseable` otherwise) or `collection:`, parent organization, method, route template and status. Never bodies, query strings, headers, IPs or secrets. - One operation per request: intent, outcome and domain change events (`appendAuditChanges`) share it. Requests without trustworthy tenant attribution go to `platform_audit_event` (no organization column, not tenant-visible) with a target: the declared resource type plus validated path id, else the first handler-named resource; an organization target only when it is the authenticated context's organization. -- The platform read filter keys on effect, not method: only declarations marked `readOnly` (platform GET/HEAD without side effects) skip successful requests unless the deployment-wide `platformAuditReads` feature is on; every other platform request, including side-effecting GETs (OAuth/social callbacks, verify-email, unsubscribe, end-session, SLO, token), is recorded. +- Every platform request is recorded, including side-effect-free reads (declarations marked `readOnly`: platform GET/HEAD without side effects) and side-effecting GETs (OAuth/social callbacks, verify-email, unsubscribe, end-session, SLO, token). - Retry: every standalone audit transaction (intent, served, outcome, service-action and job outcomes, user fan-out, after-commit change events, `/v1/audit/*` access evidence, platform rows) is re-run as a whole (entitlement recheck and policy check included) on transient database errors: deadlock (1213) and lock wait timeout (1205) always, connection loss (ECONNRESET, EPIPE, ETIMEDOUT, PROTOCOL_CONNECTION_LOST) only when the append is idempotent on replay (stable request/job operation plus idempotency key, or a pre-generated platform row id). Up to 3 attempts with ~50/150 ms jittered backoff, each retry logged `[audit-append-retry]`; then the failure behaviour below applies. `AuditLogError`s, feature/entitlement decisions and a full PlanetScale transaction pool (ResourceExhausted) are never retried. Change events appended inside a business transaction are not retried (a deadlock rolls the mutation back). -- When a tenant outcome append fails with `audit_policy_changed` after its intent was recorded (the request itself disabled capture, e.g. an `auditLogs` feature override set to off or a plan change removing the entitlement), the outcome is written to `platform_audit_event` targeting the verified organization. +- When a tenant outcome append fails with `audit_policy_changed` after its intent was recorded (the request itself disabled capture, e.g. a plan change removing the entitlement), the outcome is written to `platform_audit_event` targeting the verified organization. ## Classes @@ -22,7 +22,7 @@ Generated by `pnpm --filter @openwork-ee/den-api audit:coverage --write` from `s | tenant_signal | 13 | one outcome after handler: observed/attempted (category read, off by default; denials use security when selected); no intent | .observed .attempted | never fail-closed: capture-start failure served without capture; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | | domain_provider | 14 | provider emitter (src/audit/provider.ts) | none | evidence failure rolls back (in-transaction, not retried); attempts outside rollback: up to 3 attempts on transient database errors, then propagated | | domain_audit | 6 | audit emitter (src/routes/org/audit.ts) | none | up to 3 attempts on transient database errors, then fails closed with 503 | -| platform | 145 | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | .succeeded .attempted | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | +| platform | 145 | platform_audit_event after handler with target | .succeeded .attempted | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | | excluded_operational | 3 | excluded: operational probe (OPERATIONAL_EXCLUSIONS); no audit record | none | none | | excluded_high_volume | 1 | NOT AUDITED: bulk fan-out read (HIGH_VOLUME_EXCLUSIONS); no audit record | none | none | | proxy | 13 | destination route records | none | none | @@ -100,7 +100,7 @@ MCP tools that mutate organization state without re-entering a den-api route (`s | `remote_session.send` | tenant_job | remote_session.execution | remote_session (sessionId argument) | mcp | execute_capability remote-session:send | src/mcp/capability-registry.ts:remoteSessionSource.execute → executeRemoteSessionCapability | `remote_session.send.requested` `remote_session.send.accepted` `remote_session.send.attempted` | job outcome: Desktop: runner callbacks POST /v1/remote-session-requests/:id/{claim,complete} and /v1/remote-session-commands/:id/session (declared routes). Cloud: not_recorded: the turn runs on the member's OpenWork Web worker.; Tenant is the MCP token's organization claim; actor is the token user and its active member, credential the grant/client id. | | `remote_session.stop` | tenant_job | remote_session.execution | remote_session (sessionId argument) | mcp | execute_capability remote-session:stop | src/mcp/capability-registry.ts:remoteSessionSource.execute → executeRemoteSessionCapability | `remote_session.stop.requested` `remote_session.stop.accepted` `remote_session.stop.attempted` | job outcome: Desktop: runner callbacks POST /v1/remote-session-requests/:id/{claim,complete} (declared routes). Cloud: not_recorded: the abort is synchronous on the member's OpenWork Web worker.; Tenant is the MCP token's organization claim; actor is the token user and its active member, credential the grant/client id. | | `organization.plan.set` | tenant_change | platform.admin.organization | organization (organizationId argument (verified to exist)) | platform_admin | den_update_org_plan, admin:den_update_org_plan | src/mcp/admin-tools.ts:den_update_org_plan → updateOrganizationMetadata | `organization.plan.set.requested` `organization.plan.set.succeeded` `organization.plan.set.attempted` | Platform-admin MCP tool (/mcp/admin or /mcp/agent admin capability). Tenant is the verified argument organization; actor is the platform admin user without a member id. REST equivalent organization.plan.update. | -| `organization.capability.set` | tenant_change | platform.admin.organization | organization (organizationId argument (verified to exist)) | platform_admin | den_set_org_capability, admin:den_set_org_capability | src/mcp/admin-tools.ts:den_set_org_capability → setOrganizationFeatureOverrides | `organization.capability.set.requested` `organization.capability.set.succeeded` `organization.capability.set.attempted` | Platform-admin MCP tool: sets or clears one organization feature override (organization_feature). Can flip auditLogs itself (the feature read before the write decides capture). Tenant is the verified argument organization; actor is the platform admin user. REST equivalent organization.capabilities.update. The deployment-wide den_set_feature_rollout tool changes no organization's data and is not a tenant service action (see the notes on PUT /v1/admin/features/:key). | +| `organization.capability.set` | tenant_change | platform.admin.organization | organization (organizationId argument (verified to exist)) | platform_admin | den_set_org_capability, admin:den_set_org_capability | src/mcp/admin-tools.ts:den_set_org_capability → setOrganizationFeatureOverrides | `organization.capability.set.requested` `organization.capability.set.succeeded` `organization.capability.set.attempted` | Platform-admin MCP tool: sets or clears one organization feature override (organization_feature). Tenant is the verified argument organization; actor is the platform admin user. REST equivalent organization.capabilities.update. The deployment-wide den_set_feature_rollout tool changes no organization's data and is not a tenant service action (see the notes on PUT /v1/admin/features/:key). | ## Job outcomes @@ -114,7 +114,7 @@ Outcomes of asynchronous work started by den-api endpoints or its schedulers (`s ## User-scoped events (attribution `user_memberships`) -Rule: endpoints acting on a user rather than one trusted tenant (sign-in, sign-out, session revocation, account and password changes, identity links, organization switching, desktop handoff) keep their request evidence in `platform_audit_event` (class `platform`, declared action stem) and declare `attribution: user_memberships` plus the emitter in `changeEvidence`. The emitter appends the events below through `src/audit/fanout.ts:recordAuditForUserMemberships` into the user's verified active memberships: session events into the session's (or destination) organization, or into every organization where the user is an active member when the session has no active organization (it can act in all of them); account and failed sign-in events into every active membership. Sessions Den deletes directly (organization-scoped credential revocation, bearer sign-out, admin user deletion) record `session.revoked` with a reasonCode in the affected organization, reusing the current request's change capture for that organization. Each organization gets its own operation and transaction (entitlement recheck, policy-current fence) sharing the request id; only organizations with the literal `auditLogs` flag, capture on, an enabled policy and the event category store anything; up to 3 attempts on transient database errors (connection loss only when the request id makes the replay idempotent), then a failure logs `[audit-outcome-lost]` and never fails the user's request. Endpoints already attributed to one tenant (better-auth `organization/set-active`, SSO callbacks) keep that request evidence; their `session.*` change event joins the request operation when it is captured for the same organization. No `session.updated` event exists: `update-session` cannot change a meaningful field in Den. +Rule: endpoints acting on a user rather than one trusted tenant (sign-in, sign-out, session revocation, account and password changes, identity links, organization switching, desktop handoff) keep their request evidence in `platform_audit_event` (class `platform`, declared action stem) and declare `attribution: user_memberships` plus the emitter in `changeEvidence`. The emitter appends the events below through `src/audit/fanout.ts:recordAuditForUserMemberships` into the user's verified active memberships: session events into the session's (or destination) organization, or into every organization where the user is an active member when the session has no active organization (it can act in all of them); account and failed sign-in events into every active membership. Sessions Den deletes directly (organization-scoped credential revocation, bearer sign-out, admin user deletion) record `session.revoked` with a reasonCode in the affected organization, reusing the current request's change capture for that organization. Each organization gets its own operation and transaction (entitlement recheck, policy-current fence) sharing the request id; only organizations with capture on, an enabled policy and the event category store anything; up to 3 attempts on transient database errors (connection loss only when the request id makes the replay idempotent), then a failure logs `[audit-outcome-lost]` and never fails the user's request. Endpoints already attributed to one tenant (better-auth `organization/set-active`, SSO callbacks) keep that request evidence; their `session.*` change event joins the request operation when it is captured for the same organization. No `session.updated` event exists: `update-session` cannot change a meaningful field in Den. | Event type | Category | Organizations | Actor | Chokepoints | Evidence | |---|---|---|---|---|---| @@ -141,7 +141,7 @@ Better-auth and auth helper endpoints whose organization is proven by the reques | `oauth_token.issued` | change | MCP consent referenceId (the access token's org claim), after the user's active membership there is verified | the token's user + member id | POST /api/auth/oauth2/token (authorization_code, refresh_token): access-token claims extension notes the issuance; hooks.after attributes (phase after) and appends | after: clientId, scopes, grantType, resource; target the MCP grant (oauth_consent) or the client. Never token values, authorization codes, client secrets or hashes. Tokens without an organization (non-MCP OIDC) stay platform evidence. | | `oauth_token.revoked` | change | the revoked token row's referenceId, membership verified | the token's user + member id | POST /api/auth/oauth2/revoke: hooks.before looks the stored refresh/opaque access token up by its storage hash (nothing kept); hooks.after attributes (phase after) and appends on success | before: tokenType, clientId, scopes. JWT access tokens are stateless and not looked up (platform evidence). | | `invitation.rejected` | change | the invitation's organization | the invitee user (no member id) | POST /api/auth/organization/reject-invitation: hooks.before verifies the pending invitation and the session email = invitation email, attributes (intent before); hooks.after appends on success | status pending → rejected only; never the email or join token. | -| `.attempted (raw_endpoint_refused)` | security | body organizationId, else the session's active organization, only when the session user is an active member there | the session user + member id | hooks.before getRawBetterAuthMutationDenial (20 raw organization/api-key/sso endpoints) and the Den-shadowed POST /api/auth/scim/generate-token, /api/auth/scim/delete-provider-connection, /api/auth/organization/create-role, /api/auth/organization/update-role, /api/auth/organization/delete-role | Request evidence only: outcome denied, reasonCode raw_endpoint_refused (category request when security is not selected). No .requested intent (attribution refusal), so audit never changes the 403; a foreign or unflagged organization records nothing there (platform evidence). | +| `.attempted (raw_endpoint_refused)` | security | body organizationId, else the session's active organization, only when the session user is an active member there | the session user + member id | hooks.before getRawBetterAuthMutationDenial (20 raw organization/api-key/sso endpoints) and the Den-shadowed POST /api/auth/scim/generate-token, /api/auth/scim/delete-provider-connection, /api/auth/organization/create-role, /api/auth/organization/update-role, /api/auth/organization/delete-role | Request evidence only: outcome denied, reasonCode raw_endpoint_refused (category request when security is not selected). No .requested intent (attribution refusal), so audit never changes the 403; a foreign organization records nothing there (platform evidence). | | `oauth.authorize.succeeded / auth.oauth.authorize.post.succeeded` | request | postLogin.consentReferenceId (the session's active organization), membership verified | the session user + member id | GET (Den route) and POST /api/auth/oauth2/authorize: hooks.after, phase after | Request evidence only; the authorization code is never recorded. | | `invitation.preview.served` | read | the invitation's organization (the token proves it) | unknown | GET /v1/orgs/invitations/preview handler after the invitation is found | Request evidence only (category read, off by default); the token is never recorded. | @@ -184,123 +184,123 @@ When tenant capture is active the domain emitter appends the new change event in | Method | Path | Class | Action | Kind | Resource | Attribution | Capture | Change evidence | Failure | External / job / notes | |---|---|---|---|---|---|---|---|---|---|---| | GET | `/` | excluded_operational | `service.root` | platform.operational | service | none | excluded: operational probe (OPERATIONAL_EXCLUSIONS); no audit record | | none | Liveness/readiness probes and service identity; not organization or user activity; must not depend on the database. | -| GET | `/.well-known/oauth-authorization-server` | platform | `discovery.oauth_authorization_server.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | -| GET | `/.well-known/oauth-authorization-server/api/auth` | platform | `discovery.oauth_authorization_server.issuer_suffix.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | -| GET | `/.well-known/oauth-protected-resource` | platform | `discovery.oauth_protected_resource.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | -| GET | `/.well-known/oauth-protected-resource/mcp` | platform | `discovery.oauth_protected_resource.mcp.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Root-anchored RFC 9728 path; not in MCP_CONSUMPTION_EXCLUSIONS. | -| GET | `/.well-known/oauth-protected-resource/mcp/admin` | platform | `discovery.oauth_protected_resource.mcp_admin.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Root-anchored RFC 9728 path; not in MCP_CONSUMPTION_EXCLUSIONS. | -| GET | `/.well-known/oauth-protected-resource/mcp/agent` | platform | `discovery.oauth_protected_resource.mcp_agent.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Root-anchored RFC 9728 path; not in MCP_CONSUMPTION_EXCLUSIONS. | -| GET | `/.well-known/openid-configuration` | platform | `discovery.openid_configuration.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | -| GET | `/.well-known/openid-configuration/api/auth` | platform | `discovery.openid_configuration.issuer_suffix.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | +| GET | `/.well-known/oauth-authorization-server` | platform | `discovery.oauth_authorization_server.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | +| GET | `/.well-known/oauth-authorization-server/api/auth` | platform | `discovery.oauth_authorization_server.issuer_suffix.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | +| GET | `/.well-known/oauth-protected-resource` | platform | `discovery.oauth_protected_resource.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | +| GET | `/.well-known/oauth-protected-resource/mcp` | platform | `discovery.oauth_protected_resource.mcp.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Root-anchored RFC 9728 path; not in MCP_CONSUMPTION_EXCLUSIONS. | +| GET | `/.well-known/oauth-protected-resource/mcp/admin` | platform | `discovery.oauth_protected_resource.mcp_admin.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Root-anchored RFC 9728 path; not in MCP_CONSUMPTION_EXCLUSIONS. | +| GET | `/.well-known/oauth-protected-resource/mcp/agent` | platform | `discovery.oauth_protected_resource.mcp_agent.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Root-anchored RFC 9728 path; not in MCP_CONSUMPTION_EXCLUSIONS. | +| GET | `/.well-known/openid-configuration` | platform | `discovery.openid_configuration.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | +| GET | `/.well-known/openid-configuration/api/auth` | platform | `discovery.openid_configuration.issuer_suffix.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | | ALL | `/*` | support | `request.pipeline` | request.pipeline | middleware | none | app.use middleware entry, not an endpoint | | none | Global middleware stack (observability, request id, headers, access log, CORS, sessionMiddleware, audit request capture). | -| GET | `/api/auth/.well-known/oauth-authorization-server` | platform | `discovery.oauth_authorization_server.issuer_prefix.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | -| GET | `/api/auth/.well-known/openid-configuration` | platform | `discovery.openid_configuration.issuer_prefix.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | +| GET | `/api/auth/.well-known/oauth-authorization-server` | platform | `discovery.oauth_authorization_server.issuer_prefix.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | +| GET | `/api/auth/.well-known/openid-configuration` | platform | `discovery.openid_configuration.issuer_prefix.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | | DELETE | `/api/auth/*` | proxy | `auth.endpoint.dispatch` | platform.auth | auth_endpoint | none | destination route records | | none | Dispatches to better-auth endpoints, each declared with its own class; the generic layer resolves the concrete endpoint declaration. | | GET | `/api/auth/*` | proxy | `auth.endpoint.dispatch` | platform.auth | auth_endpoint | none | destination route records | | none | Dispatches to better-auth endpoints, each declared with its own class; the generic layer resolves the concrete endpoint declaration. | | PATCH | `/api/auth/*` | proxy | `auth.endpoint.dispatch` | platform.auth | auth_endpoint | none | destination route records | | none | Dispatches to better-auth endpoints, each declared with its own class; the generic layer resolves the concrete endpoint declaration. | | POST | `/api/auth/*` | proxy | `auth.endpoint.dispatch` | platform.auth | auth_endpoint | none | destination route records | | none | Dispatches to better-auth endpoints, each declared with its own class; the generic layer resolves the concrete endpoint declaration. | | PUT | `/api/auth/*` | proxy | `auth.endpoint.dispatch` | platform.auth | auth_endpoint | none | destination route records | | none | Dispatches to better-auth endpoints, each declared with its own class; the generic layer resolves the concrete endpoint declaration. | -| GET | `/api/auth/account-info` | platform | `auth.account.info.read` | platform.account | account | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | -| POST | `/api/auth/api-key/create` | tenant_change | `auth.api_key.create` | platform.auth.raw_mutation | api_key | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/api-keys (auth.api.createApiKey) performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/api-key/delete` | tenant_change | `auth.api_key.delete` | platform.auth.raw_mutation | api_key | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den DELETE /v1/api-keys/:apiKeyId performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| GET | `/api/auth/api-key/get` | platform | `auth.api_key.read` | platform.api_key | api_key | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Keys the caller owns may span orgs; GET /v1/api-keys is the org-attributed listing. | -| GET | `/api/auth/api-key/list` | platform | `auth.api_key.list` | platform.api_key | api_key | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Keys the caller owns may span orgs; GET /v1/api-keys is the org-attributed listing. | -| POST | `/api/auth/api-key/update` | tenant_change | `auth.api_key.update` | platform.auth.raw_mutation | api_key | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); No Den update route (keys are rotated by delete + create); nothing performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| GET | `/api/auth/callback/:id` | platform | `auth.social.callback` | platform.auth | social_provider (:id) | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts, audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. Social OAuth callback creates the user/session: databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. (method social:). A newly linked identity appends account.identity_linked and fans out to every organization where the user is an active member (own operation per organization, shared request id). | -| POST | `/api/auth/callback/:id` | platform | `auth.social.callback.form_post` | platform.auth | social_provider (:id) | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts, audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. form_post variant of the social callback; same session.created / account.identity_linked hooks. | -| POST | `/api/auth/change-email` | platform | `auth.user.email.change` | platform.account | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. When the email is updated here (or later at /verify-email), databaseHooks.user.update appends account.email_changed (changed marker only, never the addresses) and fans out to every organization where the user is an active member (own operation per organization, shared request id). | -| POST | `/api/auth/change-password` | platform | `auth.user.password.change` | platform.account | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts, audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. hooks.after on success appends account.password_changed (method only, never values) and fans out to every organization where the user is an active member (own operation per organization, shared request id). With revokeOtherSessions the deleted sessions record session.revoked and the replacement session session.created (method other). | -| POST | `/api/auth/delete-user` | platform | `auth.user.delete` | platform.account | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Disabled in Den (user.deleteUser is not enabled, so better-auth answers 404). If enabled, databaseHooks.user.delete.before appends account.deleted while the memberships still exist and fans out to every organization where the user is an active member (own operation per organization, shared request id). Per-organization member.removed evidence would then still be missing (member rows are not removed through the members emitter on this path). | -| GET | `/api/auth/delete-user/callback` | platform | `auth.user.delete.confirm` | platform.account | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Token-confirmed deletion; the token is never recorded. Disabled like /delete-user; same account.deleted hook. | -| GET | `/api/auth/device` | platform | `auth.device.verify` | platform.device | device_code | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| POST | `/api/auth/device/approve` | platform | `auth.device.approve` | platform.device | device_code | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Kept platform: the raw endpoint stages no organization (it writes status/userId only; device_code.organizationId is set solely by Den POST /v1/auth/device/decision, which is already attributed to the chosen member's organization before the code is decided). A code decided through Den is no longer pending, so this endpoint cannot act on a staged organization. | -| POST | `/api/auth/device/code` | platform | `auth.device.code.issue` | platform.device | device_code | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| POST | `/api/auth/device/deny` | platform | `auth.device.deny` | platform.device | device_code | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Kept platform: denial binds no organization (Den POST /v1/auth/device/decision is the org-aware path). | -| POST | `/api/auth/device/token` | platform | `auth.device.token.issue` | platform.device | device_code | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. Session org is staged from the Den decision (stageDeviceSessionOrganization); the poller has no verified tenant actor, so request evidence stays platform. databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. (method device) | -| POST | `/api/auth/email-otp/change-email` | platform | `auth.email_otp.email_change.complete` | platform.account | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. databaseHooks.user.update appends account.email_changed (changed marker only) and fans out to every organization where the user is an active member (own operation per organization, shared request id). | -| POST | `/api/auth/email-otp/check-verification-otp` | platform | `auth.email_otp.check` | platform.account | verification | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. On failure for an EXISTING account, hooks.after appends session.sign_in_failed (security; actor unknown; target the account's user; reasonCode from the better-auth error code) to every active membership, detached after the response is decided (status, body and timing unchanged; nothing for unknown emails; the submitted email/password/OTP is never stored). Trade-off: anyone guessing a member's email can generate these events (no rate limit). | -| POST | `/api/auth/email-otp/request-email-change` | platform | `auth.email_otp.email_change.request` | platform.account | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | -| POST | `/api/auth/email-otp/request-password-reset` | platform | `auth.email_otp.password_reset.request` | platform.account | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| POST | `/api/auth/email-otp/reset-password` | platform | `auth.email_otp.password_reset.complete` | platform.account | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts, audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Unauthenticated protocol endpoint; actor unknown unless a session exists. Success: account.password_changed (method email_otp_reset) fans out to every organization where the user is an active member (own operation per organization, shared request id). On failure for an EXISTING account, hooks.after appends session.sign_in_failed (security; actor unknown; target the account's user; reasonCode from the better-auth error code) to every active membership, detached after the response is decided (status, body and timing unchanged; nothing for unknown emails; the submitted email/password/OTP is never stored). Trade-off: anyone guessing a member's email can generate these events (no rate limit). | -| POST | `/api/auth/email-otp/send-verification-otp` | platform | `auth.email_otp.send` | platform.account | verification | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| POST | `/api/auth/email-otp/verify-email` | platform | `auth.email_otp.verify_email` | platform.account | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. Auto sign-in after verification: databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. | -| GET | `/api/auth/error` | platform | `auth.error_page.read` | platform.auth | error_page | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| POST | `/api/auth/forget-password/email-otp` | platform | `auth.password_reset.email_otp.request` | platform.account | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| POST | `/api/auth/get-access-token` | platform | `auth.account.access_token.get` | platform.account | account | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. Returns the linked provider's access token. hooks.after appends account.provider_token.accessed (category access, providerId only, outcome from the response) for the session user and fans out to every organization where the user is an active member (own operation per organization, shared request id). | -| GET | `/api/auth/get-session` | platform | `auth.session.get` | platform.session | session | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | -| POST | `/api/auth/get-session` | platform | `auth.session.get.post` | platform.session | session | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | -| GET | `/api/auth/jwks` | platform | `auth.jwks.read` | platform.auth | jwks | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| POST | `/api/auth/link-social` | platform | `auth.account.link` | platform.account | account | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. The link completes at /callback/:id: databaseHooks.account.create.after appends account.identity_linked (providerId, method) and fans out to every organization where the user is an active member (own operation per organization, shared request id). | -| GET | `/api/auth/list-accounts` | platform | `auth.account.list` | platform.account | account | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | -| GET | `/api/auth/list-sessions` | platform | `auth.session.list` | platform.session | session | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| GET | `/api/auth/account-info` | platform | `auth.account.info.read` | platform.account | account | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| POST | `/api/auth/api-key/create` | tenant_change | `auth.api_key.create` | platform.auth.raw_mutation | api_key | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/api-keys (auth.api.createApiKey) performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/api-key/delete` | tenant_change | `auth.api_key.delete` | platform.auth.raw_mutation | api_key | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den DELETE /v1/api-keys/:apiKeyId performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| GET | `/api/auth/api-key/get` | platform | `auth.api_key.read` | platform.api_key | api_key | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Keys the caller owns may span orgs; GET /v1/api-keys is the org-attributed listing. | +| GET | `/api/auth/api-key/list` | platform | `auth.api_key.list` | platform.api_key | api_key | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Keys the caller owns may span orgs; GET /v1/api-keys is the org-attributed listing. | +| POST | `/api/auth/api-key/update` | tenant_change | `auth.api_key.update` | platform.auth.raw_mutation | api_key | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); No Den update route (keys are rotated by delete + create); nothing performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| GET | `/api/auth/callback/:id` | platform | `auth.social.callback` | platform.auth | social_provider (:id) | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts, audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. Social OAuth callback creates the user/session: databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. (method social:). A newly linked identity appends account.identity_linked and fans out to every organization where the user is an active member (own operation per organization, shared request id). | +| POST | `/api/auth/callback/:id` | platform | `auth.social.callback.form_post` | platform.auth | social_provider (:id) | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts, audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. form_post variant of the social callback; same session.created / account.identity_linked hooks. | +| POST | `/api/auth/change-email` | platform | `auth.user.email.change` | platform.account | user | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. When the email is updated here (or later at /verify-email), databaseHooks.user.update appends account.email_changed (changed marker only, never the addresses) and fans out to every organization where the user is an active member (own operation per organization, shared request id). | +| POST | `/api/auth/change-password` | platform | `auth.user.password.change` | platform.account | user | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts, audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. hooks.after on success appends account.password_changed (method only, never values) and fans out to every organization where the user is an active member (own operation per organization, shared request id). With revokeOtherSessions the deleted sessions record session.revoked and the replacement session session.created (method other). | +| POST | `/api/auth/delete-user` | platform | `auth.user.delete` | platform.account | user | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Disabled in Den (user.deleteUser is not enabled, so better-auth answers 404). If enabled, databaseHooks.user.delete.before appends account.deleted while the memberships still exist and fans out to every organization where the user is an active member (own operation per organization, shared request id). Per-organization member.removed evidence would then still be missing (member rows are not removed through the members emitter on this path). | +| GET | `/api/auth/delete-user/callback` | platform | `auth.user.delete.confirm` | platform.account | user | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Token-confirmed deletion; the token is never recorded. Disabled like /delete-user; same account.deleted hook. | +| GET | `/api/auth/device` | platform | `auth.device.verify` | platform.device | device_code | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| POST | `/api/auth/device/approve` | platform | `auth.device.approve` | platform.device | device_code | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Kept platform: the raw endpoint stages no organization (it writes status/userId only; device_code.organizationId is set solely by Den POST /v1/auth/device/decision, which is already attributed to the chosen member's organization before the code is decided). A code decided through Den is no longer pending, so this endpoint cannot act on a staged organization. | +| POST | `/api/auth/device/code` | platform | `auth.device.code.issue` | platform.device | device_code | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| POST | `/api/auth/device/deny` | platform | `auth.device.deny` | platform.device | device_code | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Kept platform: denial binds no organization (Den POST /v1/auth/device/decision is the org-aware path). | +| POST | `/api/auth/device/token` | platform | `auth.device.token.issue` | platform.device | device_code | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. Session org is staged from the Den decision (stageDeviceSessionOrganization); the poller has no verified tenant actor, so request evidence stays platform. databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. (method device) | +| POST | `/api/auth/email-otp/change-email` | platform | `auth.email_otp.email_change.complete` | platform.account | user | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. databaseHooks.user.update appends account.email_changed (changed marker only) and fans out to every organization where the user is an active member (own operation per organization, shared request id). | +| POST | `/api/auth/email-otp/check-verification-otp` | platform | `auth.email_otp.check` | platform.account | verification | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. On failure for an EXISTING account, hooks.after appends session.sign_in_failed (security; actor unknown; target the account's user; reasonCode from the better-auth error code) to every active membership, detached after the response is decided (status, body and timing unchanged; nothing for unknown emails; the submitted email/password/OTP is never stored). Trade-off: anyone guessing a member's email can generate these events (no rate limit). | +| POST | `/api/auth/email-otp/request-email-change` | platform | `auth.email_otp.email_change.request` | platform.account | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| POST | `/api/auth/email-otp/request-password-reset` | platform | `auth.email_otp.password_reset.request` | platform.account | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| POST | `/api/auth/email-otp/reset-password` | platform | `auth.email_otp.password_reset.complete` | platform.account | user | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts, audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Unauthenticated protocol endpoint; actor unknown unless a session exists. Success: account.password_changed (method email_otp_reset) fans out to every organization where the user is an active member (own operation per organization, shared request id). On failure for an EXISTING account, hooks.after appends session.sign_in_failed (security; actor unknown; target the account's user; reasonCode from the better-auth error code) to every active membership, detached after the response is decided (status, body and timing unchanged; nothing for unknown emails; the submitted email/password/OTP is never stored). Trade-off: anyone guessing a member's email can generate these events (no rate limit). | +| POST | `/api/auth/email-otp/send-verification-otp` | platform | `auth.email_otp.send` | platform.account | verification | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| POST | `/api/auth/email-otp/verify-email` | platform | `auth.email_otp.verify_email` | platform.account | user | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. Auto sign-in after verification: databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. | +| GET | `/api/auth/error` | platform | `auth.error_page.read` | platform.auth | error_page | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| POST | `/api/auth/forget-password/email-otp` | platform | `auth.password_reset.email_otp.request` | platform.account | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| POST | `/api/auth/get-access-token` | platform | `auth.account.access_token.get` | platform.account | account | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. Returns the linked provider's access token. hooks.after appends account.provider_token.accessed (category access, providerId only, outcome from the response) for the session user and fans out to every organization where the user is an active member (own operation per organization, shared request id). | +| GET | `/api/auth/get-session` | platform | `auth.session.get` | platform.session | session | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| POST | `/api/auth/get-session` | platform | `auth.session.get.post` | platform.session | session | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| GET | `/api/auth/jwks` | platform | `auth.jwks.read` | platform.auth | jwks | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| POST | `/api/auth/link-social` | platform | `auth.account.link` | platform.account | account | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. The link completes at /callback/:id: databaseHooks.account.create.after appends account.identity_linked (providerId, method) and fans out to every organization where the user is an active member (own operation per organization, shared request id). | +| GET | `/api/auth/list-accounts` | platform | `auth.account.list` | platform.account | account | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| GET | `/api/auth/list-sessions` | platform | `auth.session.list` | platform.session | session | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | | GET | `/api/auth/oauth2/authorize` | tenant_change | `oauth.authorize` | oauth.authorization | oauth_client | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Den route forwarding to better-auth. When the signed-in user has an MCP consent organization (postLogin.consentReferenceId, the session's active organization) the request is attributed there after the endpoint ran (phase after, src/audit/better-auth.ts) once their active membership is verified; actor = user + memberId. Issuing the authorization code is request evidence only (never the code). No session, no MCP scope or a failed request: platform evidence. The request policy hook may update oauthClient.scopes on this GET. | | POST | `/api/auth/oauth2/authorize` | tenant_change | `auth.oauth.authorize.post` | oauth.authorization | oauth_client | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | GET /api/auth/oauth2/authorize is a Den route (same attribution). When the signed-in user has an MCP consent organization (postLogin.consentReferenceId = the session's active organization, noted for the request) the request is attributed there after the endpoint ran (phase after) once their active membership is verified; actor = user + memberId. The authorization code is request evidence only and never recorded. Non-MCP (OIDC) authorization, no session or failures: platform evidence. hooks.before may widen oauthClient scopes. | -| POST | `/api/auth/oauth2/client/rotate-secret` | platform | `auth.oauth.client.secret.rotate` | platform.oauth | oauth_client | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| POST | `/api/auth/oauth2/client/rotate-secret` | platform | `auth.oauth.client.secret.rotate` | platform.oauth | oauth_client | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | | POST | `/api/auth/oauth2/consent` | tenant_change | `auth.oauth.consent.grant` | oauth.consent | oauth_consent | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Security-relevant: issues credentials; token values are never recorded. hooks.before: org = consentReferenceId (consent/continue: the session's activeOrganizationId chosen on /mcp/select-organization; update/delete: the consent row's referenceId for the caller's own consent), used only after the session user's active membership is confirmed; actor = session user + memberId. | | POST | `/api/auth/oauth2/continue` | tenant_change | `auth.oauth.authorization.continue` | oauth.consent | oauth_consent | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Continues authorization after org selection (postLogin). hooks.before: org = consentReferenceId (consent/continue: the session's activeOrganizationId chosen on /mcp/select-organization; update/delete: the consent row's referenceId for the caller's own consent), used only after the session user's active membership is confirmed; actor = session user + memberId. | -| POST | `/api/auth/oauth2/create-client` | platform | `auth.oauth.client.create` | platform.oauth | oauth_client | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Alternate client creation path to POST /api/auth/oauth2/register. | -| POST | `/api/auth/oauth2/delete-client` | platform | `auth.oauth.client.delete` | platform.oauth | oauth_client | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| POST | `/api/auth/oauth2/create-client` | platform | `auth.oauth.client.create` | platform.oauth | oauth_client | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Alternate client creation path to POST /api/auth/oauth2/register. | +| POST | `/api/auth/oauth2/delete-client` | platform | `auth.oauth.client.delete` | platform.oauth | oauth_client | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | | POST | `/api/auth/oauth2/delete-consent` | tenant_change | `auth.oauth.consent.revoke` | oauth.consent | oauth_consent | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Revokes an MCP grant for the consented org. hooks.before: org = consentReferenceId (consent/continue: the session's activeOrganizationId chosen on /mcp/select-organization; update/delete: the consent row's referenceId for the caller's own consent), used only after the session user's active membership is confirmed; actor = session user + memberId. | -| GET | `/api/auth/oauth2/end-session` | platform | `auth.oauth.session.end` | platform.oauth | session | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. RP-initiated logout. The id_token_hint carries no organization claim (customAccessTokenClaims only shapes access tokens), so the request stays platform evidence; the ended session records session.revoked (reasonCode end_session) through databaseHooks.session.delete.after in its own organization (or every active membership). | -| GET | `/api/auth/oauth2/get-client` | platform | `auth.oauth.client.read` | platform.oauth | oauth_client | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | -| GET | `/api/auth/oauth2/get-clients` | platform | `auth.oauth.client.list` | platform.oauth | oauth_client | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | -| GET | `/api/auth/oauth2/get-consent` | platform | `auth.oauth.consent.read` | platform.oauth | oauth_consent | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. The user's own consent rows across orgs. | -| GET | `/api/auth/oauth2/get-consents` | platform | `auth.oauth.consent.list` | platform.oauth | oauth_consent | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. The user's own consent rows across orgs. | -| POST | `/api/auth/oauth2/introspect` | platform | `auth.oauth.token.introspect` | platform.oauth | oauth_token | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| GET | `/api/auth/oauth2/public-client` | platform | `auth.oauth.public_client.read` | platform.oauth | oauth_client | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| POST | `/api/auth/oauth2/public-client-prelogin` | platform | `auth.oauth.public_client.prelogin_read` | platform.oauth | oauth_client | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| POST | `/api/auth/oauth2/register` | platform | `oauth_client.register` | platform.auth | oauth_client | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public RFC 7591 registration; response carries client credentials (never captured). | +| GET | `/api/auth/oauth2/end-session` | platform | `auth.oauth.session.end` | platform.oauth | session | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. RP-initiated logout. The id_token_hint carries no organization claim (customAccessTokenClaims only shapes access tokens), so the request stays platform evidence; the ended session records session.revoked (reasonCode end_session) through databaseHooks.session.delete.after in its own organization (or every active membership). | +| GET | `/api/auth/oauth2/get-client` | platform | `auth.oauth.client.read` | platform.oauth | oauth_client | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| GET | `/api/auth/oauth2/get-clients` | platform | `auth.oauth.client.list` | platform.oauth | oauth_client | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| GET | `/api/auth/oauth2/get-consent` | platform | `auth.oauth.consent.read` | platform.oauth | oauth_consent | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. The user's own consent rows across orgs. | +| GET | `/api/auth/oauth2/get-consents` | platform | `auth.oauth.consent.list` | platform.oauth | oauth_consent | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. The user's own consent rows across orgs. | +| POST | `/api/auth/oauth2/introspect` | platform | `auth.oauth.token.introspect` | platform.oauth | oauth_token | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| GET | `/api/auth/oauth2/public-client` | platform | `auth.oauth.public_client.read` | platform.oauth | oauth_client | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| POST | `/api/auth/oauth2/public-client-prelogin` | platform | `auth.oauth.public_client.prelogin_read` | platform.oauth | oauth_client | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| POST | `/api/auth/oauth2/register` | platform | `oauth_client.register` | platform.auth | oauth_client | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public RFC 7591 registration; response carries client credentials (never captured). | | POST | `/api/auth/oauth2/revoke` | tenant_change | `auth.oauth.token.revoke` | oauth.token | oauth_token | handler | requested before handler; succeeded/attempted after (request) | audit/domain/oauth-tokens.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Security-relevant: revokes sessions. hooks.before looks up the stored refresh or opaque access token row by the same storage hash better-auth uses (nothing is kept) and notes its client, user, referenceId and scopes; on success hooks.after attributes the request to that organization (phase after, so a revocation never waits on audit) once the user's active membership is verified and appends oauth_token.revoked (tokenType, clientId, scopes). JWT access tokens are stateless (not looked up), tokens without an organization and failures stay platform evidence. | | POST | `/api/auth/oauth2/token` | tenant_change | `auth.oauth.token.issue` | oauth.token | oauth_token | handler | requested before handler; succeeded/attempted after (request) | audit/domain/oauth-tokens.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Security-relevant: issues credentials; token values are never recorded. authorization_code and refresh_token grants: the access-token claims extension (src/auth.ts) notes client id, user, consent referenceId (the token's org claim), scopes, grant type, MCP resource and grant (consent) id for the request; on success hooks.after attributes the request to that organization (phase after: the organization is only known once the token exists) once the user's active membership is verified, and appends oauth_token.issued (clientId, scopes, grantType, resource; target the grant, else the client; actor = the token's user + memberId). Never token values, codes, client secrets or hashes. Tokens without an organization (non-MCP OIDC sign-in), the RFC 7523 jwt-bearer grant Den answers itself and failures stay platform evidence. The claims extension runs for JWT access tokens (every MCP token carries an audience); opaque tokens are not noted. | -| POST | `/api/auth/oauth2/update-client` | platform | `auth.oauth.client.update` | platform.oauth | oauth_client | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| POST | `/api/auth/oauth2/update-client` | platform | `auth.oauth.client.update` | platform.oauth | oauth_client | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | | POST | `/api/auth/oauth2/update-consent` | tenant_change | `auth.oauth.consent.update` | oauth.consent | oauth_consent | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Changes MCP scopes granted for the consented org. hooks.before: org = consentReferenceId (consent/continue: the session's activeOrganizationId chosen on /mcp/select-organization; update/delete: the consent row's referenceId for the caller's own consent), used only after the session user's active membership is confirmed; actor = session user + memberId. | -| GET | `/api/auth/oauth2/userinfo` | platform | `auth.oauth.userinfo.read` | platform.oauth | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Bearer-authenticated user info. | -| POST | `/api/auth/oauth2/userinfo` | platform | `auth.oauth.userinfo.read_post` | platform.oauth | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Bearer-authenticated user info (POST variant). | -| GET | `/api/auth/ok` | platform | `auth.ok.read` | platform.auth | health | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| POST | `/api/auth/organization/accept-invitation` | tenant_change | `auth.organization.invitation.accept` | platform.auth.raw_mutation | invitation | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/orgs/invitations/accept performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/organization/add-team-member` | tenant_change | `auth.organization.team_member.add` | platform.auth.raw_mutation | team | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den PATCH /v1/teams/:teamId (organizationHooks also deny team mutations) performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/organization/cancel-invitation` | tenant_change | `auth.organization.invitation.cancel` | platform.auth.raw_mutation | invitation | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/invitations/:invitationId/cancel performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/organization/check-slug` | platform | `auth.organization.slug.check` | platform.organization | organization | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | -| POST | `/api/auth/organization/create` | platform | `auth.organization.create` | platform.organization | organization | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Creates a new org that has no audit policy yet; alternate route to POST /v1/org. | +| GET | `/api/auth/oauth2/userinfo` | platform | `auth.oauth.userinfo.read` | platform.oauth | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Bearer-authenticated user info. | +| POST | `/api/auth/oauth2/userinfo` | platform | `auth.oauth.userinfo.read_post` | platform.oauth | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Bearer-authenticated user info (POST variant). | +| GET | `/api/auth/ok` | platform | `auth.ok.read` | platform.auth | health | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| POST | `/api/auth/organization/accept-invitation` | tenant_change | `auth.organization.invitation.accept` | platform.auth.raw_mutation | invitation | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/orgs/invitations/accept performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/organization/add-team-member` | tenant_change | `auth.organization.team_member.add` | platform.auth.raw_mutation | team | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den PATCH /v1/teams/:teamId (organizationHooks also deny team mutations) performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/organization/cancel-invitation` | tenant_change | `auth.organization.invitation.cancel` | platform.auth.raw_mutation | invitation | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/invitations/:invitationId/cancel performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/organization/check-slug` | platform | `auth.organization.slug.check` | platform.organization | organization | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| POST | `/api/auth/organization/create` | platform | `auth.organization.create` | platform.organization | organization | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Creates a new org that has no audit policy yet; alternate route to POST /v1/org. | | POST | `/api/auth/organization/create-role` | tenant_change | `auth.organization.role.create` | platform.auth.raw_mutation | role | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Den-shadowed raw better-auth route; custom organization roles are not supported (permission sets are managed through /v1/permissions). Refused for every caller (403; 401 without a session). The refused attempt is attributed (attribution refusal: no .requested intent, so audit can never change the 403) to the organization the body names (organizationId), else the session's active organization, ONLY when the session user is an active member there: .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. Otherwise, including a foreign organization, it stays platform evidence. | -| POST | `/api/auth/organization/create-team` | tenant_change | `auth.organization.team.create` | platform.auth.raw_mutation | team | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/teams performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/organization/delete` | tenant_change | `auth.organization.delete` | platform.auth.raw_mutation | organization | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den DELETE /v1/org performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/organization/create-team` | tenant_change | `auth.organization.team.create` | platform.auth.raw_mutation | team | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/teams performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/organization/delete` | tenant_change | `auth.organization.delete` | platform.auth.raw_mutation | organization | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den DELETE /v1/org performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | | POST | `/api/auth/organization/delete-role` | tenant_change | `auth.organization.role.delete` | platform.auth.raw_mutation | role | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Den-shadowed raw better-auth route; custom organization roles are not supported (permission sets are managed through /v1/permissions). Refused for every caller (403; 401 without a session). The refused attempt is attributed (attribution refusal: no .requested intent, so audit can never change the 403) to the organization the body names (organizationId), else the session's active organization, ONLY when the session user is an active member there: .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. Otherwise, including a foreign organization, it stays platform evidence. | | GET | `/api/auth/organization/get-active-member` | tenant_read | `auth.organization.active_member.read` | organization.read | member | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.before (audit/better-auth.ts): org = query organizationId (get-full-organization: organizationSlug lookup), else the session's activeOrganizationId, used only once the cookie session user's active membership in it is confirmed; actor = session user + memberId. | | GET | `/api/auth/organization/get-active-member-role` | tenant_read | `auth.organization.active_member_role.read` | organization.read | member | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.before (audit/better-auth.ts): org = query organizationId (get-full-organization: organizationSlug lookup), else the session's activeOrganizationId, used only once the cookie session user's active membership in it is confirmed; actor = session user + memberId. | | GET | `/api/auth/organization/get-full-organization` | tenant_read | `auth.organization.read` | organization.read | organization | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.before (audit/better-auth.ts): org = query organizationId (get-full-organization: organizationSlug lookup), else the session's activeOrganizationId, used only once the cookie session user's active membership in it is confirmed; actor = session user + memberId. | -| GET | `/api/auth/organization/get-invitation` | platform | `auth.organization.invitation.read` | platform.organization | invitation | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Invitee view; the caller is not yet a member. | -| POST | `/api/auth/organization/has-permission` | platform | `auth.organization.permission.check` | platform.organization | organization | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Read-only check over POST. | -| POST | `/api/auth/organization/invite-member` | tenant_change | `auth.organization.invitation.create` | platform.auth.raw_mutation | invitation | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/invitations performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/organization/leave` | tenant_change | `auth.organization.leave` | organization.membership | member | handler | requested before handler; succeeded/attempted after (request) | audit/domain/members.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Alternate route to member removal (legacy organization.member.removed). hooks.before attributes once the session user's active membership in body.organizationId is confirmed (flagged organizations only; intent before the delete, 503 only when the intent append fails) and keeps the member row; hooks.after appends member.removed (memberRemovedEvent, reasonCode member_left) in a fresh transaction after better-auth's delete, or writes the legacy row when change capture is off for that flagged organization. Unflagged organizations and deployment capture off: no lookup and no record (as before this change). | -| GET | `/api/auth/organization/list` | platform | `auth.organization.list` | platform.organization | organization | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| GET | `/api/auth/organization/get-invitation` | platform | `auth.organization.invitation.read` | platform.organization | invitation | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Invitee view; the caller is not yet a member. | +| POST | `/api/auth/organization/has-permission` | platform | `auth.organization.permission.check` | platform.organization | organization | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Read-only check over POST. | +| POST | `/api/auth/organization/invite-member` | tenant_change | `auth.organization.invitation.create` | platform.auth.raw_mutation | invitation | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/invitations performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/organization/leave` | tenant_change | `auth.organization.leave` | organization.membership | member | handler | requested before handler; succeeded/attempted after (request) | audit/domain/members.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Alternate route to member removal (legacy organization.member.removed). hooks.before attributes once the session user's active membership in body.organizationId is confirmed (intent before the delete, 503 only when the intent append fails) and keeps the member row; hooks.after appends member.removed (memberRemovedEvent, reasonCode member_left) in a fresh transaction after better-auth's delete, or writes the legacy row when change capture is off for that organization. Deployment capture off: no lookup and no record. | +| GET | `/api/auth/organization/list` | platform | `auth.organization.list` | platform.organization | organization | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | | GET | `/api/auth/organization/list-invitations` | tenant_read | `auth.organization.invitation.list` | organization.read | invitation | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.before (audit/better-auth.ts): org = query organizationId (get-full-organization: organizationSlug lookup), else the session's activeOrganizationId, used only once the cookie session user's active membership in it is confirmed; actor = session user + memberId. | | GET | `/api/auth/organization/list-members` | tenant_read | `auth.organization.member.list` | organization.read | member | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.before (audit/better-auth.ts): org = query organizationId (get-full-organization: organizationSlug lookup), else the session's activeOrganizationId, used only once the cookie session user's active membership in it is confirmed; actor = session user + memberId. | | GET | `/api/auth/organization/list-team-members` | tenant_read | `auth.organization.team_member.list` | organization.read | team | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.before (audit/better-auth.ts): org = query organizationId (get-full-organization: organizationSlug lookup), else the session's activeOrganizationId, used only once the cookie session user's active membership in it is confirmed; actor = session user + memberId. | | GET | `/api/auth/organization/list-teams` | tenant_read | `auth.organization.team.list` | organization.read | team | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.before (audit/better-auth.ts): org = query organizationId (get-full-organization: organizationSlug lookup), else the session's activeOrganizationId, used only once the cookie session user's active membership in it is confirmed; actor = session user + memberId. | -| GET | `/api/auth/organization/list-user-invitations` | platform | `auth.organization.user_invitation.list` | platform.organization | invitation | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | -| GET | `/api/auth/organization/list-user-teams` | platform | `auth.organization.user_team.list` | platform.organization | team | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| GET | `/api/auth/organization/list-user-invitations` | platform | `auth.organization.user_invitation.list` | platform.organization | invitation | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| GET | `/api/auth/organization/list-user-teams` | platform | `auth.organization.user_team.list` | platform.organization | team | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | | POST | `/api/auth/organization/reject-invitation` | tenant_change | `auth.organization.invitation.reject` | invitation.management | invitation | handler | requested before handler; succeeded/attempted after (request) | audit/domain/invitations.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Invitee (not yet a member) rejects. hooks.before verifies the pending invitation and better-auth's own check (cookie session email equals the invitation email, case-insensitive) and attributes to the invitation's organization (intent before; actor = the invitee user, no memberId). On success hooks.after appends invitation.rejected (status pending → rejected only; never the email or join token) in a fresh transaction. Anything unverified stays platform evidence. | -| POST | `/api/auth/organization/remove-member` | tenant_change | `auth.organization.member.remove` | platform.auth.raw_mutation | member | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den DELETE /v1/members/:memberId performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/organization/remove-team` | tenant_change | `auth.organization.team.remove` | platform.auth.raw_mutation | team | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den DELETE /v1/teams/:teamId performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/organization/remove-team-member` | tenant_change | `auth.organization.team_member.remove` | platform.auth.raw_mutation | team | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den PATCH /v1/teams/:teamId (organizationHooks also deny team mutations) performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/organization/remove-member` | tenant_change | `auth.organization.member.remove` | platform.auth.raw_mutation | member | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den DELETE /v1/members/:memberId performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/organization/remove-team` | tenant_change | `auth.organization.team.remove` | platform.auth.raw_mutation | team | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den DELETE /v1/teams/:teamId performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/organization/remove-team-member` | tenant_change | `auth.organization.team_member.remove` | platform.auth.raw_mutation | team | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den PATCH /v1/teams/:teamId (organizationHooks also deny team mutations) performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | | POST | `/api/auth/organization/set-active` | tenant_change | `auth.organization.active.set` | organization.session | organization | handler | requested before handler; succeeded/attempted after (request) | audit/domain/sessions.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Alternate route to POST /v1/me/active-organization. hooks.before attributes once the session user's active membership in body.organizationId (or organizationSlug) is confirmed; clearing the active organization (null) is not attributed. On success hooks.after appends session.organization_entered (via active_organization) in the destination organization only, inside the request operation when it is captured; the previous organization is never recorded and an unchanged organization records nothing. | -| POST | `/api/auth/organization/set-active-team` | platform | `auth.organization.active_team.set` | platform.organization | team | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Session preference. On success hooks.after appends session.organization_entered (via active_team, team related) in the team's organization once the user's active membership is verified; unchanged team or clearing it records nothing. The previous team/organization is never recorded. | -| POST | `/api/auth/organization/update` | tenant_change | `auth.organization.update` | platform.auth.raw_mutation | organization | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den PATCH /v1/org performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/organization/update-member-role` | tenant_change | `auth.organization.member.role.update` | platform.auth.raw_mutation | member | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/members/:memberId/role performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/organization/set-active-team` | platform | `auth.organization.active_team.set` | platform.organization | team | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Session preference. On success hooks.after appends session.organization_entered (via active_team, team related) in the team's organization once the user's active membership is verified; unchanged team or clearing it records nothing. The previous team/organization is never recorded. | +| POST | `/api/auth/organization/update` | tenant_change | `auth.organization.update` | platform.auth.raw_mutation | organization | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den PATCH /v1/org performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/organization/update-member-role` | tenant_change | `auth.organization.member.role.update` | platform.auth.raw_mutation | member | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/members/:memberId/role performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | | POST | `/api/auth/organization/update-role` | tenant_change | `auth.organization.role.update` | platform.auth.raw_mutation | role | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Den-shadowed raw better-auth route; custom organization roles are not supported (permission sets are managed through /v1/permissions). Refused for every caller (403; 401 without a session). The refused attempt is attributed (attribution refusal: no .requested intent, so audit can never change the 403) to the organization the body names (organizationId), else the session's active organization, ONLY when the session user is an active member there: .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. Otherwise, including a foreign organization, it stays platform evidence. | -| POST | `/api/auth/organization/update-team` | tenant_change | `auth.organization.team.update` | platform.auth.raw_mutation | team | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den PATCH /v1/teams/:teamId performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/refresh-token` | platform | `auth.account.access_token.refresh` | platform.account | account | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. Refreshes the linked provider's tokens. Same account.provider_token.accessed fan-out (reasonCode refreshed). | -| POST | `/api/auth/request-password-reset` | platform | `auth.password_reset.request` | platform.account | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| POST | `/api/auth/reset-password` | platform | `auth.password_reset.complete` | platform.account | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts, audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Unauthenticated protocol endpoint; actor unknown unless a session exists. hooks.before resolves the account the reset token names (lookup only; the token is never stored). Success: account.password_changed (method reset_password) fans out to every organization where the user is an active member (own operation per organization, shared request id). Failure with a valid token (password length): session.sign_in_failed as below; an invalid token names no account and records nothing. On failure for an EXISTING account, hooks.after appends session.sign_in_failed (security; actor unknown; target the account's user; reasonCode from the better-auth error code) to every active membership, detached after the response is decided (status, body and timing unchanged; nothing for unknown emails; the submitted email/password/OTP is never stored). Trade-off: anyone guessing a member's email can generate these events (no rate limit). | -| GET | `/api/auth/reset-password/:token` | platform | `auth.password_reset.callback` | platform.account | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. :token is a secret and deliberately not used as the resource id. | -| POST | `/api/auth/revoke-other-sessions` | platform | `auth.session.revoke_others` | platform.session | session | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. databaseHooks.session.delete.after appends session.revoked per deleted session (bulk deletes fire per row; expired rows swept on read are skipped) in that session's organization once the owner's active membership is verified, or every active membership when the session had no organization. | -| POST | `/api/auth/revoke-session` | platform | `auth.session.revoke` | platform.session | session | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. databaseHooks.session.delete.after appends session.revoked per deleted session (bulk deletes fire per row; expired rows swept on read are skipped) in that session's organization once the owner's active membership is verified, or every active membership when the session had no organization. | -| POST | `/api/auth/revoke-sessions` | platform | `auth.session.revoke_all` | platform.session | session | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. databaseHooks.session.delete.after appends session.revoked per deleted session (bulk deletes fire per row; expired rows swept on read are skipped) in that session's organization once the owner's active membership is verified, or every active membership when the session had no organization. | +| POST | `/api/auth/organization/update-team` | tenant_change | `auth.organization.team.update` | platform.auth.raw_mutation | team | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den PATCH /v1/teams/:teamId performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/refresh-token` | platform | `auth.account.access_token.refresh` | platform.account | account | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. Refreshes the linked provider's tokens. Same account.provider_token.accessed fan-out (reasonCode refreshed). | +| POST | `/api/auth/request-password-reset` | platform | `auth.password_reset.request` | platform.account | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| POST | `/api/auth/reset-password` | platform | `auth.password_reset.complete` | platform.account | user | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts, audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Unauthenticated protocol endpoint; actor unknown unless a session exists. hooks.before resolves the account the reset token names (lookup only; the token is never stored). Success: account.password_changed (method reset_password) fans out to every organization where the user is an active member (own operation per organization, shared request id). Failure with a valid token (password length): session.sign_in_failed as below; an invalid token names no account and records nothing. On failure for an EXISTING account, hooks.after appends session.sign_in_failed (security; actor unknown; target the account's user; reasonCode from the better-auth error code) to every active membership, detached after the response is decided (status, body and timing unchanged; nothing for unknown emails; the submitted email/password/OTP is never stored). Trade-off: anyone guessing a member's email can generate these events (no rate limit). | +| GET | `/api/auth/reset-password/:token` | platform | `auth.password_reset.callback` | platform.account | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. :token is a secret and deliberately not used as the resource id. | +| POST | `/api/auth/revoke-other-sessions` | platform | `auth.session.revoke_others` | platform.session | session | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. databaseHooks.session.delete.after appends session.revoked per deleted session (bulk deletes fire per row; expired rows swept on read are skipped) in that session's organization once the owner's active membership is verified, or every active membership when the session had no organization. | +| POST | `/api/auth/revoke-session` | platform | `auth.session.revoke` | platform.session | session | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. databaseHooks.session.delete.after appends session.revoked per deleted session (bulk deletes fire per row; expired rows swept on read are skipped) in that session's organization once the owner's active membership is verified, or every active membership when the session had no organization. | +| POST | `/api/auth/revoke-sessions` | platform | `auth.session.revoke_all` | platform.session | session | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. databaseHooks.session.delete.after appends session.revoked per deleted session (bulk deletes fire per row; expired rows swept on read are skipped) in that session's organization once the owner's active membership is verified, or every active membership when the session had no organization. | | POST | `/api/auth/scim/delete-provider-connection` | tenant_change | `scim_raw.provider_connection.delete` | platform.auth.raw_mutation | scim_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Den-shadowed raw better-auth route; use DELETE /v1/scim. Refused for every caller (403; 401 without a session). The refused attempt is attributed (attribution refusal: no .requested intent, so audit can never change the 403) to the organization the body names (organizationId), else the session's active organization, ONLY when the session user is an active member there: .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. Otherwise, including a foreign organization, it stays platform evidence. | | POST | `/api/auth/scim/generate-token` | tenant_change | `scim_raw.token.generate` | platform.auth.raw_mutation | scim_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Den-shadowed raw better-auth route; use /v1/scim/token. Refused for every caller (403; 401 without a session). The refused attempt is attributed (attribution refusal: no .requested intent, so audit can never change the 403) to the organization the body names (organizationId), else the session's active organization, ONLY when the session user is an active member there: .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. Otherwise, including a foreign organization, it stays platform evidence. | -| GET | `/api/auth/scim/get-provider-connection` | platform | `scim_raw.provider_connection.read` | platform.auth | scim_connection | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Blocked: always 401/403. | -| GET | `/api/auth/scim/list-provider-connections` | platform | `scim_raw.provider_connection.list` | platform.auth | scim_connection | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Blocked: always 401/403. | +| GET | `/api/auth/scim/get-provider-connection` | platform | `scim_raw.provider_connection.read` | platform.auth | scim_connection | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Blocked: always 401/403. | +| GET | `/api/auth/scim/list-provider-connections` | platform | `scim_raw.provider_connection.list` | platform.auth | scim_connection | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Blocked: always 401/403. | | ALL | `/api/auth/scim/v2/*` | support | `scim.diagnostics` | request.pipeline | middleware | none | app.use middleware entry, not an endpoint | | none | SCIM diagnostics middleware (timing logs only); concrete SCIM v2 routes are declared in ./org.ts. | | GET | `/api/auth/scim/v2/Groups` | tenant_read | `scim_group.list` | scim.provisioning | scim_group | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). | | POST | `/api/auth/scim/v2/Groups` | tenant_change | `scim_group.create` | scim.provisioning | scim_group | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). | @@ -309,51 +309,51 @@ When tenant capture is active the domain emitter appends the new change event in | PATCH | `/api/auth/scim/v2/Groups/:groupId` | tenant_change | `scim_group.patch` | scim.provisioning | scim_group (:groupId) | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). | | PUT | `/api/auth/scim/v2/Groups/:groupId` | tenant_change | `scim_group.replace` | scim.provisioning | scim_group (:groupId) | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). | | GET | `/api/auth/scim/v2/ResourceTypes` | tenant_read | `scim.resource_type.list` | scim.provisioning | scim_metadata | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). | -| GET | `/api/auth/scim/v2/ResourceTypes/:resourceTypeId` | platform | `auth.scim.resource_type.read` | platform.scim | scim_metadata (:resourceTypeId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Static unauthenticated SCIM metadata (Den serves ResourceTypes/Group itself). | +| GET | `/api/auth/scim/v2/ResourceTypes/:resourceTypeId` | platform | `auth.scim.resource_type.read` | platform.scim | scim_metadata (:resourceTypeId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Static unauthenticated SCIM metadata (Den serves ResourceTypes/Group itself). | | GET | `/api/auth/scim/v2/ResourceTypes/Group` | tenant_read | `scim.resource_type.group.read` | scim.provisioning | scim_metadata | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). | | GET | `/api/auth/scim/v2/Schemas` | tenant_read | `scim.schema.list` | scim.provisioning | scim_metadata | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). | -| GET | `/api/auth/scim/v2/Schemas/:schemaId` | platform | `auth.scim.schema.read` | platform.scim | scim_metadata (:schemaId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Static unauthenticated SCIM metadata (Den serves the Group schema URN itself). | +| GET | `/api/auth/scim/v2/Schemas/:schemaId` | platform | `auth.scim.schema.read` | platform.scim | scim_metadata (:schemaId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Static unauthenticated SCIM metadata (Den serves the Group schema URN itself). | | GET | `/api/auth/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group` | tenant_read | `scim.schema.group.read` | scim.provisioning | scim_metadata | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). | -| GET | `/api/auth/scim/v2/ServiceProviderConfig` | platform | `auth.scim.service_provider_config.read` | platform.scim | scim_metadata | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Static unauthenticated SCIM metadata. | -| GET | `/api/auth/scim/v2/Users` | tenant_read | `scim_user.list` | scim.provisioning | scim_user | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.before: the organization part of the SCIM bearer only selects the audit flag read; for a flagged organization the token is verified through resolveScimProviderFromBearerToken -> provider.organizationId (must match); actor service scim:. Invalid tokens stay unattributed (platform store). | +| GET | `/api/auth/scim/v2/ServiceProviderConfig` | platform | `auth.scim.service_provider_config.read` | platform.scim | scim_metadata | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Static unauthenticated SCIM metadata. | +| GET | `/api/auth/scim/v2/Users` | tenant_read | `scim_user.list` | scim.provisioning | scim_user | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.before: the organization part of the SCIM bearer only selects the organization lookup; for an existing organization the token is verified through resolveScimProviderFromBearerToken -> provider.organizationId (must match); actor service scim:. Invalid tokens stay unattributed (platform store). | | POST | `/api/auth/scim/v2/Users` | tenant_change | `scim_user.create` | scim.provisioning | scim_user | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). Membership sync adds the member without Stripe hooks. | | DELETE | `/api/auth/scim/v2/Users/:userId` | tenant_external | `scim_user.deprovision` | scim.provisioning | scim_user (:userId) | handler | requested before handler; confirmed/attempted, 5xx unknown (request) | audit/domain/members.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: stripe (seat/web/inference quantity sync via runPostOrganizationMemberChangeHooks); Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). Deprovisions via removeOrganizationMember (members emitter) and writes a tombstone. | -| GET | `/api/auth/scim/v2/Users/:userId` | tenant_read | `scim_user.read` | scim.provisioning | scim_user (:userId) | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.before: the organization part of the SCIM bearer only selects the audit flag read; for a flagged organization the token is verified through resolveScimProviderFromBearerToken -> provider.organizationId (must match); actor service scim:. Invalid tokens stay unattributed (platform store). | +| GET | `/api/auth/scim/v2/Users/:userId` | tenant_read | `scim_user.read` | scim.provisioning | scim_user (:userId) | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.before: the organization part of the SCIM bearer only selects the organization lookup; for an existing organization the token is verified through resolveScimProviderFromBearerToken -> provider.organizationId (must match); actor service scim:. Invalid tokens stay unattributed (platform store). | | PATCH | `/api/auth/scim/v2/Users/:userId` | tenant_external | `scim_user.patch` | scim.provisioning | scim_user (:userId) | handler | requested before handler; confirmed/attempted, 5xx unknown (request) | audit/domain/members.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: stripe (seat/web/inference quantity sync via runPostOrganizationMemberChangeHooks); Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). active=false deprovisions via removeOrganizationMember (members emitter). | | PUT | `/api/auth/scim/v2/Users/:userId` | tenant_external | `scim_user.replace` | scim.provisioning | scim_user (:userId) | handler | requested before handler; confirmed/attempted, 5xx unknown (request) | audit/domain/members.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: stripe (seat/web/inference quantity sync via runPostOrganizationMemberChangeHooks); Org proof: SCIM bearer token resolved by resolveRequestScimProvider() -> ScimProvider.organizationId (actor service scim:); invalid tokens 401 before attribution (platform store). active=false deprovisions via removeOrganizationMember (members emitter). | -| POST | `/api/auth/send-verification-email` | platform | `auth.email_verification.send` | platform.account | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | -| POST | `/api/auth/sign-in/email` | platform | `auth.sign_in.email` | platform.auth | session | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. On failure for an EXISTING account, hooks.after appends session.sign_in_failed (security; actor unknown; target the account's user; reasonCode from the better-auth error code) to every active membership, detached after the response is decided (status, body and timing unchanged; nothing for unknown emails; the submitted email/password/OTP is never stored). Trade-off: anyone guessing a member's email can generate these events (no rate limit). | -| POST | `/api/auth/sign-in/email-otp` | platform | `auth.sign_in.email_otp` | platform.auth | session | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. On failure for an EXISTING account, hooks.after appends session.sign_in_failed (security; actor unknown; target the account's user; reasonCode from the better-auth error code) to every active membership, detached after the response is decided (status, body and timing unchanged; nothing for unknown emails; the submitted email/password/OTP is never stored). Trade-off: anyone guessing a member's email can generate these events (no rate limit). | -| POST | `/api/auth/sign-in/social` | platform | `auth.sign_in.social` | platform.auth | session | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts, audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. Usually starts the social OAuth redirect (the session is created at /callback/:id); the idToken variant creates the session here: databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. Implicit account linking appends account.identity_linked (databaseHooks.account.create.after), which fans out to every organization where the user is an active member (own operation per organization, shared request id). | -| POST | `/api/auth/sign-in/sso` | platform | `auth.sign_in.sso.start` | platform.sso | sso_connection | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. Org/provider named by an unauthenticated caller (hooks.before authorizeOrganizationSsoSignIn); tenant attribution happens at the validated callback. | -| POST | `/api/auth/sign-out` | platform | `auth.sign_out` | platform.session | session | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. databaseHooks.session.delete.after appends session.revoked per deleted session (bulk deletes fire per row; expired rows swept on read are skipped) in that session's organization once the owner's active membership is verified, or every active membership when the session had no organization. | -| POST | `/api/auth/sign-up/email` | platform | `auth.sign_up.email` | platform.auth | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Creates a user and session; may complete the initial-admin bootstrap. Auto sign-in: databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. A brand-new user usually has no membership yet, so nothing is stored. | +| POST | `/api/auth/send-verification-email` | platform | `auth.email_verification.send` | platform.account | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. | +| POST | `/api/auth/sign-in/email` | platform | `auth.sign_in.email` | platform.auth | session | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. On failure for an EXISTING account, hooks.after appends session.sign_in_failed (security; actor unknown; target the account's user; reasonCode from the better-auth error code) to every active membership, detached after the response is decided (status, body and timing unchanged; nothing for unknown emails; the submitted email/password/OTP is never stored). Trade-off: anyone guessing a member's email can generate these events (no rate limit). | +| POST | `/api/auth/sign-in/email-otp` | platform | `auth.sign_in.email_otp` | platform.auth | session | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. On failure for an EXISTING account, hooks.after appends session.sign_in_failed (security; actor unknown; target the account's user; reasonCode from the better-auth error code) to every active membership, detached after the response is decided (status, body and timing unchanged; nothing for unknown emails; the submitted email/password/OTP is never stored). Trade-off: anyone guessing a member's email can generate these events (no rate limit). | +| POST | `/api/auth/sign-in/social` | platform | `auth.sign_in.social` | platform.auth | session | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts, audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. Usually starts the social OAuth redirect (the session is created at /callback/:id); the idToken variant creates the session here: databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. Implicit account linking appends account.identity_linked (databaseHooks.account.create.after), which fans out to every organization where the user is an active member (own operation per organization, shared request id). | +| POST | `/api/auth/sign-in/sso` | platform | `auth.sign_in.sso.start` | platform.sso | sso_connection | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. Org/provider named by an unauthenticated caller (hooks.before authorizeOrganizationSsoSignIn); tenant attribution happens at the validated callback. | +| POST | `/api/auth/sign-out` | platform | `auth.sign_out` | platform.session | session | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. databaseHooks.session.delete.after appends session.revoked per deleted session (bulk deletes fire per row; expired rows swept on read are skipped) in that session's organization once the owner's active membership is verified, or every active membership when the session had no organization. | +| POST | `/api/auth/sign-up/email` | platform | `auth.sign_up.email` | platform.auth | user | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Creates a user and session; may complete the initial-admin bootstrap. Auto sign-in: databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. A brand-new user usually has no membership yet, so nothing is stored. | | GET | `/api/auth/sso/callback` | tenant_change | `auth.sso.oidc.callback_shared` | sso.sign_in | sso_connection | handler | requested before handler; succeeded/attempted after (request) | audit/domain/sessions.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Security-relevant: issues credentials; token values are never recorded. Shared redirect URI: the provider is only inside better-auth's validated state, which the hook does not see, so it is not attributed and records in the platform store (limitation). The created session still records session.created (method sso) in its own organization. | | GET | `/api/auth/sso/callback/:providerId` | tenant_change | `auth.sso.oidc.callback` | sso.sign_in | sso_connection (:providerId) | handler | requested before handler; succeeded/attempted after (request) | audit/domain/sessions.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Security-relevant: issues credentials; token values are never recorded. hooks.after, phase after, only when ctx.context.newSession exists: org = sso_provider.organizationId of the :providerId that validated the assertion; actor = newSession user (+ member id once JIT provisioning made one). Failed callbacks stay platform (provider id is caller-controlled until validated). The created session also records session.created (method sso) through databaseHooks.session.create.after in the session's own activeOrganizationId (its own operation; may differ from the provider's organization). hooks.after also completes SSO test intents here. | -| POST | `/api/auth/sso/delete-provider` | tenant_change | `auth.sso.provider.delete` | platform.auth.raw_mutation | sso_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den DELETE /v1/sso performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/sso/delete-provider` | tenant_change | `auth.sso.provider.delete` | platform.auth.raw_mutation | sso_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den DELETE /v1/sso performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | | GET | `/api/auth/sso/get-provider` | tenant_read | `auth.sso.provider.read` | sso.configuration | sso_connection | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | hooks.after on success: org = returned provider's organizationId once the session user's active membership is confirmed; actor = session user + memberId. Den GET /v1/sso is the primary surface. | -| GET | `/api/auth/sso/providers` | platform | `auth.sso.provider.list` | platform.sso | sso_connection | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Lists providers across every org the caller administers. | -| POST | `/api/auth/sso/register` | tenant_change | `auth.sso.provider.register` | platform.auth.raw_mutation | sso_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/sso/oidc \| /v1/sso/saml (auth.api.registerSSOProvider) performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/sso/request-domain-verification` | tenant_change | `auth.sso.domain_verification.request` | platform.auth.raw_mutation | sso_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/sso/request-domain-verification performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | +| GET | `/api/auth/sso/providers` | platform | `auth.sso.provider.list` | platform.sso | sso_connection | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. Lists providers across every org the caller administers. | +| POST | `/api/auth/sso/register` | tenant_change | `auth.sso.provider.register` | platform.auth.raw_mutation | sso_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/sso/oidc \| /v1/sso/saml (auth.api.registerSSOProvider) performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/sso/request-domain-verification` | tenant_change | `auth.sso.domain_verification.request` | platform.auth.raw_mutation | sso_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/sso/request-domain-verification performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | | ALL | `/api/auth/sso/saml2/callback/*` | support | `sso.saml_callback.policy` | request.pipeline | middleware | none | app.use middleware entry, not an endpoint | | none | samlResponsePolicyMiddleware before better-auth; the endpoint itself is declared with the better-auth endpoints. | -| POST | `/api/auth/sso/saml2/logout/:providerId` | platform | `auth.sso.saml.logout.initiate` | platform.sso | sso_connection (:providerId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. SP-initiated SAML logout. Kept platform: Den does not set saml.enableSingleLogout, so better-auth answers 400 SINGLE_LOGOUT_NOT_ENABLED before it reads or validates the provider; the provider id is caller-controlled and unvalidated, so attributing it would let anyone write into any organization's log. If SLO is enabled later, the ended sessions record session.revoked (reasonCode saml_logout) through databaseHooks.session.delete.after. | +| POST | `/api/auth/sso/saml2/logout/:providerId` | platform | `auth.sso.saml.logout.initiate` | platform.sso | sso_connection (:providerId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. SP-initiated SAML logout. Kept platform: Den does not set saml.enableSingleLogout, so better-auth answers 400 SINGLE_LOGOUT_NOT_ENABLED before it reads or validates the provider; the provider id is caller-controlled and unvalidated, so attributing it would let anyone write into any organization's log. If SLO is enabled later, the ended sessions record session.revoked (reasonCode saml_logout) through databaseHooks.session.delete.after. | | GET | `/api/auth/sso/saml2/sp/acs/:providerId` | tenant_change | `auth.sso.saml.acs.redirect_binding` | sso.sign_in | sso_connection (:providerId) | handler | requested before handler; succeeded/attempted after (request) | audit/domain/sessions.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Security-relevant: issues credentials; token values are never recorded. hooks.after, phase after, only when ctx.context.newSession exists: org = sso_provider.organizationId of the :providerId that validated the assertion; actor = newSession user (+ member id once JIT provisioning made one). Failed callbacks stay platform (provider id is caller-controlled until validated). The created session also records session.created (method sso) through databaseHooks.session.create.after in the session's own activeOrganizationId (its own operation; may differ from the provider's organization). | | POST | `/api/auth/sso/saml2/sp/acs/:providerId` | tenant_change | `auth.sso.saml.acs.post_binding` | sso.sign_in | sso_connection (:providerId) | handler | requested before handler; succeeded/attempted after (request) | audit/domain/sessions.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Security-relevant: issues credentials; token values are never recorded. samlResponsePolicyMiddleware runs first. hooks.after, phase after, only when ctx.context.newSession exists: org = sso_provider.organizationId of the :providerId that validated the assertion; actor = newSession user (+ member id once JIT provisioning made one). Failed callbacks stay platform (provider id is caller-controlled until validated). The created session also records session.created (method sso) through databaseHooks.session.create.after in the session's own activeOrganizationId (its own operation; may differ from the provider's organization). hooks.after also completes SSO test intents here. | | ALL | `/api/auth/sso/saml2/sp/acs/*` | support | `sso.saml_acs.policy` | request.pipeline | middleware | none | app.use middleware entry, not an endpoint | | none | samlResponsePolicyMiddleware before better-auth ACS; the endpoint itself is declared with the better-auth endpoints. | -| GET | `/api/auth/sso/saml2/sp/metadata` | platform | `auth.sso.saml.sp_metadata.read` | platform.sso | sso_connection | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Unauthenticated protocol endpoint; actor unknown unless a session exists. | -| GET | `/api/auth/sso/saml2/sp/slo/:providerId` | platform | `auth.sso.saml.slo.redirect_binding` | platform.sso | sso_connection (:providerId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. Kept platform: Den does not set saml.enableSingleLogout, so better-auth answers 400 SINGLE_LOGOUT_NOT_ENABLED before it reads or validates the provider; the provider id is caller-controlled and unvalidated, so attributing it would let anyone write into any organization's log. If SLO is enabled later, the ended sessions record session.revoked (reasonCode saml_logout) through databaseHooks.session.delete.after. | -| POST | `/api/auth/sso/saml2/sp/slo/:providerId` | platform | `auth.sso.saml.slo.post_binding` | platform.sso | sso_connection (:providerId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. Kept platform: Den does not set saml.enableSingleLogout, so better-auth answers 400 SINGLE_LOGOUT_NOT_ENABLED before it reads or validates the provider; the provider id is caller-controlled and unvalidated, so attributing it would let anyone write into any organization's log. If SLO is enabled later, the ended sessions record session.revoked (reasonCode saml_logout) through databaseHooks.session.delete.after. | -| POST | `/api/auth/sso/update-provider` | tenant_change | `auth.sso.provider.update` | platform.auth.raw_mutation | sso_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/sso/oidc \| /v1/sso/saml performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| POST | `/api/auth/sso/verify-domain` | tenant_change | `auth.sso.domain.verify` | platform.auth.raw_mutation | sso_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/sso/verify-domain performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unflagged organization or one the user is not a member of: platform evidence only. | -| GET | `/api/auth/token` | platform | `auth.jwt.issue` | platform.auth | jwt | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. The JWT plugin's session token carries the user payload and no organization claim (getDenJwtOptions defines no payload), so there is no org to attribute. | -| POST | `/api/auth/unlink-account` | platform | `auth.account.unlink` | platform.account | account | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. databaseHooks.account.delete.after appends account.identity_unlinked (providerId) and fans out to every organization where the user is an active member (own operation per organization, shared request id). | -| POST | `/api/auth/update-session` | platform | `auth.session.update` | platform.session | session | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. No session.updated event: Den declares no session field with input enabled (activeOrganizationId/activeTeamId are input: false), so this endpoint can only answer 400 "No fields to update" or touch updatedAt. | -| POST | `/api/auth/update-user` | platform | `auth.user.update` | platform.account | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Alternate route to PATCH /v1/me/profile. databaseHooks.user.update.before/after append account.profile_updated (name before/after, image as a changed marker) and fans out to every organization where the user is an active member (own operation per organization, shared request id). | -| GET | `/api/auth/verify-email` | platform | `auth.email_verification.verify` | platform.account | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts, audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. Verification token is never recorded. Confirming a change-email link updates the email: account.email_changed (marker only) fans out to every organization where the user is an active member (own operation per organization, shared request id). Auto sign-in: databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. | -| POST | `/api/auth/verify-password` | platform | `auth.user.password.verify` | platform.account | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Metadata scope "server" but HTTP-reachable. | -| GET | `/docs` | platform | `api_docs.read` | platform.discovery | api_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | +| GET | `/api/auth/sso/saml2/sp/metadata` | platform | `auth.sso.saml.sp_metadata.read` | platform.sso | sso_connection | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Unauthenticated protocol endpoint; actor unknown unless a session exists. | +| GET | `/api/auth/sso/saml2/sp/slo/:providerId` | platform | `auth.sso.saml.slo.redirect_binding` | platform.sso | sso_connection (:providerId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. Kept platform: Den does not set saml.enableSingleLogout, so better-auth answers 400 SINGLE_LOGOUT_NOT_ENABLED before it reads or validates the provider; the provider id is caller-controlled and unvalidated, so attributing it would let anyone write into any organization's log. If SLO is enabled later, the ended sessions record session.revoked (reasonCode saml_logout) through databaseHooks.session.delete.after. | +| POST | `/api/auth/sso/saml2/sp/slo/:providerId` | platform | `auth.sso.saml.slo.post_binding` | platform.sso | sso_connection (:providerId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: revokes sessions. Kept platform: Den does not set saml.enableSingleLogout, so better-auth answers 400 SINGLE_LOGOUT_NOT_ENABLED before it reads or validates the provider; the provider id is caller-controlled and unvalidated, so attributing it would let anyone write into any organization's log. If SLO is enabled later, the ended sessions record session.revoked (reasonCode saml_logout) through databaseHooks.session.delete.after. | +| POST | `/api/auth/sso/update-provider` | tenant_change | `auth.sso.provider.update` | platform.auth.raw_mutation | sso_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/sso/oidc \| /v1/sso/saml performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| POST | `/api/auth/sso/verify-domain` | tenant_change | `auth.sso.domain.verify` | platform.auth.raw_mutation | sso_connection | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Refused by hooks.before (getRawBetterAuthMutationDenial, 403 for every external caller); Den POST /v1/sso/verify-domain performs this server-side. Before the refusal, hooks.before (auditBetterAuthRefusal) attributes the attempt to the organization the body names (organizationId), else the cookie session's active organization, ONLY when the session user is an active member there (attribution refusal: no .requested intent is written, so audit can never turn the 403 into a 503): .attempted, outcome denied, reasonCode raw_endpoint_refused, category security when selected. No session, an unknown organization or one the user is not a member of: platform evidence only. | +| GET | `/api/auth/token` | platform | `auth.jwt.issue` | platform.auth | jwt | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant: issues credentials; token values are never recorded. User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. The JWT plugin's session token carries the user payload and no organization claim (getDenJwtOptions defines no payload), so there is no org to attribute. | +| POST | `/api/auth/unlink-account` | platform | `auth.account.unlink` | platform.account | account | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. databaseHooks.account.delete.after appends account.identity_unlinked (providerId) and fans out to every organization where the user is an active member (own operation per organization, shared request id). | +| POST | `/api/auth/update-session` | platform | `auth.session.update` | platform.session | session | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | User-scoped: actor is the authenticated user when a session exists; never attributed to an organization. No session.updated event: Den declares no session field with input enabled (activeOrganizationId/activeTeamId are input: false), so this endpoint can only answer 400 "No fields to update" or touch updatedAt. | +| POST | `/api/auth/update-user` | platform | `auth.user.update` | platform.account | user | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Alternate route to PATCH /v1/me/profile. databaseHooks.user.update.before/after append account.profile_updated (name before/after, image as a changed marker) and fans out to every organization where the user is an active member (own operation per organization, shared request id). | +| GET | `/api/auth/verify-email` | platform | `auth.email_verification.verify` | platform.account | user | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts, audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Unauthenticated protocol endpoint; actor unknown unless a session exists. Verification token is never recorded. Confirming a change-email link updates the email: account.email_changed (marker only) fans out to every organization where the user is an active member (own operation per organization, shared request id). Auto sign-in: databaseHooks.session.create.after appends session.created (method, expiresAt) in the new session's activeOrganizationId once the user's active membership there is verified; a session without an organization (a member of several organizations) can act in all of them, so it fans out to every organization where the user is an active member. | +| POST | `/api/auth/verify-password` | platform | `auth.user.password.verify` | platform.account | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Security-relevant credential event. Metadata scope "server" but HTTP-reachable. | +| GET | `/docs` | platform | `api_docs.read` | platform.discovery | api_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | | GET | `/health` | excluded_operational | `health.check` | platform.operational | service | none | excluded: operational probe (OPERATIONAL_EXCLUSIONS); no audit record | | none | Liveness/readiness probes and service identity; not organization or user activity; must not depend on the database. | -| POST | `/internal/audit/usage/refresh` | platform | `audit.usage.refresh` | platform.maintenance | audit_usage | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Called by the den-audit-usage cron (Helm CronJob, Render cron job). Recomputes every organization's audit_state totals from stored rows; no organization activity, so platform evidence only. | +| POST | `/internal/audit/usage/refresh` | platform | `audit.usage.refresh` | platform.maintenance | audit_usage | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Called by the den-audit-usage cron (Helm CronJob, Render cron job). Recomputes every organization's audit_state totals from stored rows; no organization activity, so platform evidence only. | | ALL | `/mcp` | mcp_consumption | `mcp.transport` | mcp.transport | mcp_transport | none | excluded: MCP consumption transport | | none | Streamable HTTP MCP transport for desktop agents; tool calls re-enter covered REST routes with origin mcp. | | GET | `/mcp/.well-known/oauth-protected-resource` | mcp_consumption | `mcp.protected_resource.read` | mcp.transport | mcp_transport | none | excluded: MCP consumption transport | | none | Static OAuth protected-resource metadata MCP clients fetch to consume /mcp. | | ALL | `/mcp/admin` | mcp_consumption | `mcp_admin.transport` | mcp.transport | mcp_transport | none | excluded: MCP consumption transport | | none | Platform-admin MCP transport consumed by agents; its org-mutating tools are audited at the service layer. | @@ -361,33 +361,33 @@ When tenant capture is active the domain emitter appends the new change event in | ALL | `/mcp/agent` | mcp_consumption | `mcp_agent.transport` | mcp.transport | mcp_transport | none | excluded: MCP consumption transport | | none | Agent MCP transport (search_capabilities/execute_capability) for desktop, external OAuth MCP clients and headless runs; direct service mutations are audited at the service layer. | | GET | `/mcp/agent/.well-known/oauth-protected-resource` | mcp_consumption | `mcp_agent.protected_resource.read` | mcp.transport | mcp_transport | none | excluded: MCP consumption transport | | none | Static OAuth protected-resource metadata MCP clients fetch to consume /mcp/agent. | | ALL | `/mcp/agent/connections/:connectionId` | mcp_consumption | `mcp_agent.connection.proxy` | mcp.transport | mcp_transport | none | excluded: MCP consumption transport | | none | Per-connection MCP proxy through which agents consume an external MCP server or an authored App's MCP server. | -| GET | `/oauth/client-metadata.json` | platform | `discovery.oauth_client_metadata.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | -| GET | `/openapi.json` | platform | `api_docs.openapi.read` | platform.discovery | api_document | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | +| GET | `/oauth/client-metadata.json` | platform | `discovery.oauth_client_metadata.read` | platform.discovery | discovery_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | +| GET | `/openapi.json` | platform | `api_docs.openapi.read` | platform.discovery | api_document | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | | GET | `/ready` | excluded_operational | `readiness.check` | platform.operational | service | none | excluded: operational probe (OPERATIONAL_EXCLUSIONS); no audit record | | none | Liveness/readiness probes and service identity; not organization or user activity; must not depend on the database. | -| POST | `/register` | platform | `oauth_client.register_root` | platform.auth | oauth_client | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Root alias of /api/auth/oauth2/register (handled directly, not app.fetch). | +| POST | `/register` | platform | `oauth_client.register_root` | platform.auth | oauth_client | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Root alias of /api/auth/oauth2/register (handled directly, not app.fetch). | | ALL | `/v1/*` | support | `request.preclaim_scope` | request.pipeline | middleware | none | app.use middleware entry, not an endpoint | | none | preclaimScopeMiddleware: denies provisional agent users outside their bootstrap scope. | -| POST | `/v1/admin/admins` | platform | `platform.admin_allowlist.add` | platform.admin | admin_allowlist | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | -| DELETE | `/v1/admin/admins/:adminId` | platform | `platform.admin_allowlist.remove` | platform.admin | admin_allowlist (:adminId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | -| GET | `/v1/admin/features` | platform | `platform.feature.list` | platform.admin | feature | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | -| PUT | `/v1/admin/features/:key` | platform | `platform.feature.rollout.update` | platform.admin | feature (:key) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. Turns one feature on or off for everyone on the deployment and/or sets its kill switch; no organization's data changes, so platform evidence only (target the feature key). The admin MCP tool den_set_feature_rollout makes the same change and writes the same kind of platform evidence (method SERVICE, route service:feature.rollout.set). | -| GET | `/v1/admin/free-auto/usage` | platform | `platform.free_auto_usage.read` | platform.admin | platform_report | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | -| GET | `/v1/admin/metrics` | platform | `platform.metrics.read` | platform.admin | platform_report | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | -| GET | `/v1/admin/organizations` | platform | `platform.organization.list` | platform.admin | organization | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | +| POST | `/v1/admin/admins` | platform | `platform.admin_allowlist.add` | platform.admin | admin_allowlist | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | +| DELETE | `/v1/admin/admins/:adminId` | platform | `platform.admin_allowlist.remove` | platform.admin | admin_allowlist (:adminId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | +| GET | `/v1/admin/features` | platform | `platform.feature.list` | platform.admin | feature | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | +| PUT | `/v1/admin/features/:key` | platform | `platform.feature.rollout.update` | platform.admin | feature (:key) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. Turns one feature on or off for everyone on the deployment and/or sets its kill switch; no organization's data changes, so platform evidence only (target the feature key). The admin MCP tool den_set_feature_rollout makes the same change and writes the same kind of platform evidence (method SERVICE, route service:feature.rollout.set). | +| GET | `/v1/admin/free-auto/usage` | platform | `platform.free_auto_usage.read` | platform.admin | platform_report | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | +| GET | `/v1/admin/metrics` | platform | `platform.metrics.read` | platform.admin | platform_report | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | +| GET | `/v1/admin/organizations` | platform | `platform.organization.list` | platform.admin | organization | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | | GET | `/v1/admin/organizations/:organizationId/capabilities` | tenant_read | `organization.capabilities.read` | platform.admin.organization | organization (:organizationId) | path:organizationId | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | -| PUT | `/v1/admin/organizations/:organizationId/capabilities` | tenant_change | `organization.capabilities.update` | platform.admin.organization | organization (:organizationId) | path:organizationId | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Sets or clears this organization's feature overrides (organization_feature, features registry). Can turn auditLogs itself on or off: capture is decided by the feature read before the write; when the request turns it off, the outcome falls back to platform evidence (audit_policy_changed). The admin MCP tool den_set_org_capability makes the same change (service action organization.capability.set). | +| PUT | `/v1/admin/organizations/:organizationId/capabilities` | tenant_change | `organization.capabilities.update` | platform.admin.organization | organization (:organizationId) | path:organizationId | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Sets or clears this organization's feature overrides (organization_feature, features registry). The admin MCP tool den_set_org_capability makes the same change (service action organization.capability.set). | | PATCH | `/v1/admin/organizations/:organizationId/dpa` | tenant_change | `organization.dpa.update` | platform.admin.organization | organization (:organizationId) | path:organizationId | requested before handler; succeeded/attempted after (request) | audit/domain/organization-settings.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Legacy organization.dpa_signed.updated; reason text is not snapshotted (reasonProvided only). | | PATCH | `/v1/admin/organizations/:organizationId/free-seats` | tenant_external | `organization.free_seats.update` | platform.admin.organization | organization (:organizationId) | path:organizationId | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: stripe (seat subscription quantity resync) | | PUT | `/v1/admin/organizations/:organizationId/openwork-web-access` | tenant_change | `organization.openwork_web_access.update` | platform.admin.organization | organization (:organizationId) | path:organizationId | requested before handler; succeeded/attempted after (request) | audit/domain/organization-settings.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Legacy organization.openwork_web.complimentary_access_granted / _revoked; reason text is not snapshotted. | | PATCH | `/v1/admin/organizations/:organizationId/plan` | tenant_change | `organization.plan.update` | platform.admin.organization | organization (:organizationId) | path:organizationId | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | | -| GET | `/v1/admin/overview` | platform | `platform.overview.read` | platform.admin | platform_report | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | -| GET | `/v1/admin/users` | platform | `platform.user.list` | platform.admin | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | -| DELETE | `/v1/admin/users/:userId` | platform | `platform.user.delete` | platform.admin | user (:userId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. Deletes the user across orgs (Google revoke, Stripe seat sync per org). Each affected organization also gets member.removed change evidence (origin platform_admin, actor the admin) through the members emitter, appended after the soft removal commits (routes/admin/index.ts). | -| GET | `/v1/admin/users/:userId/inference-usage` | platform | `platform.user.inference_usage.read` | platform.admin | user (:userId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | -| POST | `/v1/admin/users/:userId/inference-usage/reset` | platform | `platform.user.inference_usage.reset` | platform.admin | user (:userId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | +| GET | `/v1/admin/overview` | platform | `platform.overview.read` | platform.admin | platform_report | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | +| GET | `/v1/admin/users` | platform | `platform.user.list` | platform.admin | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | +| DELETE | `/v1/admin/users/:userId` | platform | `platform.user.delete` | platform.admin | user (:userId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. Deletes the user across orgs (Google revoke, Stripe seat sync per org). Each affected organization also gets member.removed change evidence (origin platform_admin, actor the admin) through the members emitter, appended after the soft removal commits (routes/admin/index.ts). | +| GET | `/v1/admin/users/:userId/inference-usage` | platform | `platform.user.inference_usage.read` | platform.admin | user (:userId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | +| POST | `/v1/admin/users/:userId/inference-usage/reset` | platform | `platform.user.inference_usage.reset` | platform.admin | user (:userId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | adminRoute(): platform admin acting across tenants; no single tenant. | | GET | `/v1/api-keys` | tenant_read | `api_key.list` | api_key.management | api_key | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | POST | `/v1/api-keys` | tenant_change | `api_key.create` | api_key.management | api_key | org_context | requested before handler; succeeded/attempted after (request) | audit/domain/api-keys.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Legacy organization.api_key.created. better-auth createApiKey write: after-snapshot appended in a fresh tx; plaintext key only in the response, never captured. | | DELETE | `/v1/api-keys/:apiKeyId` | tenant_change | `api_key.delete` | api_key.management | api_key (:apiKeyId) | org_context | requested before handler; succeeded/attempted after (request) | audit/domain/api-keys.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Legacy organization.api_key.deleted. | -| GET | `/v1/app-version` | platform | `app_version.read` | platform.discovery | app_version | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | +| GET | `/v1/app-version` | platform | `app_version.read` | platform.discovery | app_version | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | | GET | `/v1/apps` | tenant_read | `app.list` | app.management | artifact_view | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | GET | `/v1/apps/:appId` | tenant_access | `app.open` | app.access | artifact_view (:appId) | org_context | requested before handler; served before release (access) | request evidence only | intent or served: up to 3 attempts on transient database errors, then 503, no content | Live apps execute their workflow (tool calls, run receipt write); response carries workflow result data and compiled HTML. | | POST | `/v1/apps/:appId/dashboard` | tenant_change | `app.dashboard.update` | app.management | artifact_view (:appId) | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | | @@ -401,15 +401,15 @@ When tenant capture is active the domain emitter appends the new change event in | GET | `/v1/audit/operations/:operationId/events` | domain_audit | `audit.events` | audit.access | audit_operation (:operationId) | org_context | audit emitter (src/routes/org/audit.ts) | | up to 3 attempts on transient database errors, then fails closed with 503 | routes/org/audit.ts:serveAudit records requested before and served before release; fails closed with 503. | | PATCH | `/v1/audit/settings` | domain_audit | `audit.capture` | audit.policy | audit_policy | org_context | audit emitter (src/routes/org/audit.ts) | routes/org/audit.ts:updateAuditCapture -> den-db setAuditCaptureState | up to 3 attempts on transient database errors, then fails closed with 503 | Policy change and lifecycle evidence commit atomically; matching no-ops record nothing. | | GET | `/v1/audit/usage` | domain_audit | `audit.usage` | audit.access | audit_collection | org_context | audit emitter (src/routes/org/audit.ts) | | up to 3 attempts on transient database errors, then fails closed with 503 | routes/org/audit.ts:serveAudit records requested before and served before release; fails closed with 503. | -| GET | `/v1/auth/bootstrap/status` | platform | `auth.bootstrap.status.read` | platform.discovery | deployment | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | -| POST | `/v1/auth/bootstrap/verify` | platform | `auth.bootstrap.verify` | platform.auth | deployment | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | One-time deployment bootstrap code. | +| GET | `/v1/auth/bootstrap/status` | platform | `auth.bootstrap.status.read` | platform.discovery | deployment | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | +| POST | `/v1/auth/bootstrap/verify` | platform | `auth.bootstrap.verify` | platform.auth | deployment | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | One-time deployment bootstrap code. | | POST | `/v1/auth/desktop-handoff` | tenant_change | `auth.desktop_handoff.create` | session.lifecycle | desktop_handoff_grant | handler | requested before handler; succeeded/attempted after (request) | audit/domain/sessions.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Hands over the caller's web session. Org = the session's active organization (or the organization approving the Cloud web return URL) once the user's active membership there is verified: the handler attributes before any write (intent fails closed) and appends desktop_handoff.created (target the handed-over session id, expiresAt, returnUrlApproved; never the grant, which is a bearer secret) into the request operation. A session without an organization keeps platform request evidence and desktop_handoff.created fans out to every active membership. | | ALL | `/v1/auth/desktop-handoff/exchange` | support | `desktop_handoff.cors` | request.pipeline | middleware | none | app.use middleware entry, not an endpoint | | none | Reflecting CORS middleware for the grant exchange (only when !env.corsHandledByEdge). | -| POST | `/v1/auth/desktop-handoff/exchange` | platform | `auth.desktop_handoff.exchange` | platform.auth | desktop_handoff_grant | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Exchanges a one-time grant for the EXISTING web session (no new session row, so no second session.created): session.handed_off (method desktop_handoff, the shared session id) in the session's organization once the user's active membership there is verified, or every active membership when the session has none. The grant and session token are never recorded. | -| POST | `/v1/auth/desktop-handoff/status` | platform | `auth.desktop_handoff.status.read` | platform.auth | desktop_handoff_grant | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public poll by grant (a bearer secret, never recorded); returns pending/consumed/unknown only. Not marked readOnly: it is a POST and increments the handoff-status rate-limit counter. No tenant is trustworthy before the grant is consumed. | -| GET | `/v1/auth/device/:userCode` | platform | `auth.device_code.lookup` | platform.auth | device_code | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | :userCode is the device-flow verification code (a short-lived secret) and is deliberately not used as the resource id. | +| POST | `/v1/auth/desktop-handoff/exchange` | platform | `auth.desktop_handoff.exchange` | platform.auth | desktop_handoff_grant | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Exchanges a one-time grant for the EXISTING web session (no new session row, so no second session.created): session.handed_off (method desktop_handoff, the shared session id) in the session's organization once the user's active membership there is verified, or every active membership when the session has none. The grant and session token are never recorded. | +| POST | `/v1/auth/desktop-handoff/status` | platform | `auth.desktop_handoff.status.read` | platform.auth | desktop_handoff_grant | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public poll by grant (a bearer secret, never recorded); returns pending/consumed/unknown only. Not marked readOnly: it is a POST and increments the handoff-status rate-limit counter. No tenant is trustworthy before the grant is consumed. | +| GET | `/v1/auth/device/:userCode` | platform | `auth.device_code.lookup` | platform.auth | device_code | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | :userCode is the device-flow verification code (a short-lived secret) and is deliberately not used as the resource id. | | POST | `/v1/auth/device/decision` | tenant_access | `auth.device_code.decide` | auth.device | device_code | handler | requested before handler; served before release (access) | request evidence only | intent or served: up to 3 attempts on transient database errors, then 503, no content | Credential issuance: approval binds the CLI session to the chosen organization. Org proof: decideDeviceUserCode verifies the session user's active membership in body organizationId, then (beforeEffect) attributes org + user + member before the code is decided (fails closed). Denials and approvals without an organization stay in the platform store (user-scoped). | -| GET | `/v1/auth/login-options` | platform | `auth.login_options.read` | platform.auth | login_options | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public, bot-protected; reveals account sign-in methods. | +| GET | `/v1/auth/login-options` | platform | `auth.login_options.read` | platform.auth | login_options | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public, bot-protected; reveals account sign-in methods. | | PUT | `/v1/automation-runner/inventory` | tenant_signal | `automation_runner.inventory.update` | automation.runner | automation_runner | handler | one outcome after handler: observed/attempted (category read, off by default; denials use security when selected); no intent | request evidence only | never fail-closed: capture-start failure served without capture; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Runner-token route: authenticateRunner() verifies the token, then attributes actor automation-runner: for the owner member. High-frequency device inventory; read by MCP remote-session:targets. | | GET | `/v1/automation-runner/work` | tenant_signal | `automation_runner.work.poll` | automation.runner | automation_runner | handler | one outcome after handler: observed/attempted (category read, off by default; denials use security when selected); no intent | request evidence only | never fail-closed: capture-start failure served without capture; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Runner-token route: authenticateRunner() verifies the token, then attributes actor automation-runner: for the owner member. Frequent poll; writes last_seen_at presence. Lists pending automation runs and remote-session commands/requests. | | GET | `/v1/automation-runners` | tenant_read | `automation_runner.list` | automation.runner | automation_runner | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Read may prune stale runner and notification rows. | @@ -440,7 +440,7 @@ When tenant capture is active the domain emitter appends the new change event in | GET | `/v1/bootstrap/claim-codes/:userCode` | tenant_read | `workspace_claim_code.lookup` | workspace.bootstrap | workspace_bootstrap | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Org proof: unexpired claim code -> provisional org; actor = session user (not yet a member). :userCode is a bearer claim code and is never used as the resource id. | | POST | `/v1/bootstrap/claim-codes/accept` | tenant_change | `workspace_claim_code.accept` | workspace.bootstrap | organization | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Org proof: claim code lookup -> provisional org, attributed before the transfer transaction; session user (no member id yet) becomes owner (alternate member-creation/ownership path). | | POST | `/v1/bootstrap/claims/accept` | tenant_change | `workspace_claim.accept` | workspace.bootstrap | organization | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Org proof: pending claim token row -> provisional org (unlocked pre-read), attributed before the locking transfer transaction; session user becomes owner (alternate ownership path). | -| POST | `/v1/bootstrap/workspace` | platform | `workspace.bootstrap.create` | workspace.bootstrap | workspace_bootstrap | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public, rate limited; creates a provisional org and returns a preclaim assertion. | +| POST | `/v1/bootstrap/workspace` | platform | `workspace.bootstrap.create` | workspace.bootstrap | workspace_bootstrap | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public, rate limited; creates a provisional org and returns a preclaim assertion. | | GET | `/v1/bootstrap/workspace/:bootstrapId/claim` | tenant_read | `workspace_claim.read` | workspace.bootstrap | workspace_bootstrap (:bootstrapId) | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Org proof: preclaim assertion (Bearer JWT, readPreclaimAssertion) bound to :bootstrapId -> provisional org; actor = the setup agent user + setup member. | | POST | `/v1/bootstrap/workspace/:bootstrapId/claim` | tenant_change | `workspace_claim_code.create` | workspace.bootstrap | workspace_bootstrap (:bootstrapId) | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Org proof: preclaim assertion bound to :bootstrapId -> provisional org; actor = setup agent user + setup member. Returns a one-time claim user code. | | GET | `/v1/brand-assets/:organizationId/:kind/:version` | tenant_read | `brand_asset.download` | organization.branding | brand_asset (:version) | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Public capability URL: the HMAC signature binds organizationId, kind, version and extension, so the handler attributes the path organization only after verifyBrandAssetSignature; actor is the anonymous signed-URL holder. Public, immutable, CDN-cached logo (login pages, emails): read category, not access. Invalid signatures 404 before attribution (platform). | @@ -579,8 +579,8 @@ When tenant capture is active the domain emitter appends the new change event in | DELETE | `/v1/desktop-policies/by-key/:externalKey` | tenant_change | `desktop_policy.delete_by_key` | desktop_policy.management | desktop_policy (:externalKey) | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | | | GET | `/v1/desktop-policies/by-key/:externalKey` | tenant_read | `desktop_policy.read_by_key` | desktop_policy.management | desktop_policy (:externalKey) | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | PUT | `/v1/desktop-policies/by-key/:externalKey` | tenant_change | `desktop_policy.upsert_by_key` | desktop_policy.management | desktop_policy (:externalKey) | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | | -| GET | `/v1/dev/emails` | platform | `dev_email.list` | platform.dev | dev_email | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | devMode only (404 otherwise); exposes OTP codes and links. | -| GET | `/v1/dev/emails/last` | platform | `dev_email.last.read` | platform.dev | dev_email | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | devMode only (404 otherwise). | +| GET | `/v1/dev/emails` | platform | `dev_email.list` | platform.dev | dev_email | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | devMode only (404 otherwise); exposes OTP codes and links. | +| GET | `/v1/dev/emails/last` | platform | `dev_email.last.read` | platform.dev | dev_email | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | devMode only (404 otherwise). | | GET | `/v1/diagnostics/egress` | tenant_read | `diagnostics.egress.read` | diagnostics | diagnostic | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | POST | `/v1/diagnostics/egress` | tenant_external | `diagnostics.egress.run` | diagnostics | diagnostic | org_context | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: diagnostics_origin (egress probe) | | PUT | `/v1/diagnostics/egress/token` | tenant_change | `diagnostics.egress_token.update` | diagnostics | diagnostic_credential | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Stores a diagnostics credential; never snapshotted. | @@ -588,9 +588,9 @@ When tenant capture is active the domain emitter appends the new change event in | POST | `/v1/direct-uploads/google-workspace/drive-files` | tenant_external | `capability.google_drive.file.upload` | capability.google_workspace | google_drive_file | org_context | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: Google Workspace API (member's delegated account); cloudTransportRoute (MCP OAuth token, org from token); host file transport, bytes not echoed. Conditional token refresh write. | | POST | `/v1/direct-uploads/google-workspace/drive-upload-sessions` | tenant_external | `capability.google_drive.host_upload.prepare` | capability.google_workspace | google_drive_file | org_context | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: Google Workspace API (member's delegated account); cloudTransportRoute; host prepares a selected-account resumable session, bytes bypass Den. Session URL is not audit evidence. | | POST | `/v1/direct-uploads/google-workspace/gmail-drafts` | tenant_external | `capability.gmail.draft.attachments.create` | capability.google_workspace | gmail_draft | org_context | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: Google Workspace API (member's delegated account); cloudTransportRoute (MCP OAuth token, org from token); creates a draft with attachments, never sends. Conditional token refresh write. | -| GET | `/v1/email/unsubscribe` | platform | `email_preference.unsubscribe_link` | platform.user | email_preference | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | HMAC token link; performs the unsubscribe (not a read). User-level. | -| POST | `/v1/email/unsubscribe` | platform | `email_preference.unsubscribe` | platform.user | email_preference | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | HMAC token; user-level. | -| GET | `/v1/features` | platform | `feature.deployment.list` | platform.discovery | feature | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Public deployment-wide feature state (kill switch and operator locks, never organization overrides) for people without an organization; side-effect free. | +| GET | `/v1/email/unsubscribe` | platform | `email_preference.unsubscribe_link` | platform.user | email_preference | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | HMAC token link; performs the unsubscribe (not a read). User-level. | +| POST | `/v1/email/unsubscribe` | platform | `email_preference.unsubscribe` | platform.user | email_preference | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | HMAC token; user-level. | +| GET | `/v1/features` | platform | `feature.deployment.list` | platform.discovery | feature | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Public deployment-wide feature state (kill switch and operator locks, never organization overrides) for people without an organization; side-effect free. | | GET | `/v1/gateway/usage-limit-policies` | tenant_read | `gateway_usage_limit.policy.list` | gateway_usage_limit.management | gateway_usage_limit_policy | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | POST | `/v1/gateway/usage-limit-policies` | tenant_change | `gateway_usage_limit.policy.create` | gateway_usage_limit.management | gateway_usage_limit_policy | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | | | PATCH | `/v1/gateway/usage-limit-policies/:policyId` | tenant_change | `gateway_usage_limit.policy.update` | gateway_usage_limit.management | gateway_usage_limit_policy (:policyId) | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | | @@ -641,7 +641,7 @@ When tenant capture is active the domain emitter appends the new change event in | POST | `/v1/inference-providers/migrate-from-llm-provider` | tenant_change | `inference_provider.llm_provider.migrate` | inference_provider.configuration | provider | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Not covered by provider emitter (providerUncoveredRoutes). | | GET | `/v1/inference-providers/model-management` | tenant_read | `inference_provider.model_management.list` | inference_provider.configuration | provider | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | POST | `/v1/inference-providers/oauth/browser-aws-poll` | tenant_external | `inference_provider.member_oauth.browser_aws_poll` | inference_provider.member_oauth | provider_credential | handler | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: AWS IAM Identity Center (CreateToken, GetRoleCredentials, Logout) and STS GetCallerIdentity; Public poll. Attributed like browser-aws-start once the cookie user matches the attempt; at most one AWS token request per polling interval. On approval verifies role credentials and identity, then stores the member credential and consumes the attempt. | -| POST | `/v1/inference-providers/oauth/browser-aws-start` | tenant_external | `inference_provider.member_oauth.browser_aws_start` | inference_provider.member_oauth | provider_credential_set | handler | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: AWS IAM Identity Center OIDC (RegisterClient, StartDeviceAuthorization); Public route. Attributed (attributeMemberOAuthEntry) like browser-start once the live signed session cookie matches the entry's initiating user and the provider/set binding and gatewayCloudSignIn feature are rechecked; before the device authorization and the state rotation write. | +| POST | `/v1/inference-providers/oauth/browser-aws-start` | tenant_external | `inference_provider.member_oauth.browser_aws_start` | inference_provider.member_oauth | provider_credential_set | handler | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: AWS IAM Identity Center OIDC (RegisterClient, StartDeviceAuthorization); Public route. Attributed (attributeMemberOAuthEntry) like browser-start once the live signed session cookie matches the entry's initiating user and the provider/set binding is rechecked; before the device authorization and the state rotation write. | | POST | `/v1/inference-providers/oauth/browser-litellm-check` | tenant_external | `inference_provider.member_oauth.browser_litellm_check` | inference_provider.member_oauth | provider_credential | handler | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: Organization's LiteLLM proxy (admin API: key creation/deletion in issued mode); Public route. Attributed like browser-litellm-key once the cookie user matches the entry and the provider/set binding and litellm feature are rechecked; before LiteLLM key creation for that member. | | POST | `/v1/inference-providers/oauth/browser-litellm-key` | tenant_change | `inference_provider.member_oauth.browser_litellm_key` | inference_provider.member_oauth | provider_credential | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Public route. Attributed (attributeMemberOAuthEntry) like browser-start once the live signed session cookie matches the entry's initiating user and the provider/set binding and litellm feature are rechecked; before key verification and the credential write that consumes the entry. | | GET | `/v1/inference-providers/oauth/browser-start` | tenant_change | `inference_provider.member_oauth.browser_start` | inference_provider.member_oauth | provider_credential_set | handler | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Public route. Attributed (attributeMemberOAuthEntry) once the live signed session cookie matches the entry's initiating user and the provider/set/client binding is rechecked: org = provider.organization_id, actor = that user + entry member; before the state rotation write. 302 to Google. | @@ -746,35 +746,35 @@ When tenant capture is active the domain emitter appends the new change event in | GET | `/v1/mcp-connections/presets` | tenant_read | `mcp_connection.preset.list` | mcp_connection.configuration | mcp_connection_preset | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | POST | `/v1/mcp-connections/resolve` | tenant_external | `mcp_connection.resolve` | mcp_connection.configuration | mcp_connection | org_context | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: Candidate MCP server URLs; Outbound probes of candidate URLs; no local write. | | POST | `/v1/mcp/token` | tenant_access | `mcp_token.mint` | mcp_token.issuance | oauth_access_token | org_context | requested before handler; served before release (access) | request evidence only | intent or served: up to 3 attempts on transient database errors, then 503, no content | Mints and returns MCP access tokens (token, appHostToken). | -| GET | `/v1/me` | platform | `user.read` | platform.user | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | -| POST | `/v1/me/active-organization` | platform | `session.active_organization.update` | platform.user | auth_session | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | The handler appends session.organization_entered in the destination organization only (membership verified); the previous organization is never recorded and an unchanged organization records nothing. Automatic active-organization repair (GET /v1/me/orgs, resolveUserOrganizationsMiddleware) is not a user switch and records nothing. | +| GET | `/v1/me` | platform | `user.read` | platform.user | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | +| POST | `/v1/me/active-organization` | platform | `session.active_organization.update` | platform.user | auth_session | user_memberships | platform_audit_event after handler with target | audit/domain/sessions.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | The handler appends session.organization_entered in the destination organization only (membership verified); the previous organization is never recorded and an unchanged organization records nothing. Automatic active-organization repair (GET /v1/me/orgs, resolveUserOrganizationsMiddleware) is not a user switch and records nothing. | | GET | `/v1/me/dashboards` | tenant_read | `dashboard.my.list` | dashboard.access | dashboard | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | GET | `/v1/me/desktop-config` | tenant_read | `desktop_policy.effective.read` | desktop_policy.management | desktop_policy | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | GET | `/v1/me/install-config` | tenant_read | `install_config.read` | install.distribution | organization | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | GET | `/v1/me/install/:platform` | tenant_access | `install_artifact.download` | install.distribution | install_artifact (:platform) | org_context | requested before handler; served before release (access) | request evidence only | intent or served: up to 3 attempts on transient database errors, then 503, no content | | | GET | `/v1/me/library` | tenant_read | `library.list` | library.access | plugin | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | -| GET | `/v1/me/orgs` | platform | `user.organization.list` | platform.user | organization | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Lists the caller's memberships; may hydrate the session active org. | +| GET | `/v1/me/orgs` | platform | `user.organization.list` | platform.user | organization | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Lists the caller's memberships; may hydrate the session active org. | | GET | `/v1/me/plugin-access` | tenant_read | `plugin_access.effective.list` | library.access | plugin | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | -| PATCH | `/v1/me/profile` | platform | `user.profile.update` | platform.user | user | user_memberships | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Direct database update (no better-auth hook): the handler appends account.profile_updated (name before/after) to every organization where the user is an active member. | -| POST | `/v1/me/send-download-link` | platform | `user.download_link.send` | platform.user | user | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Sends a download link email to the caller (Resend). | +| PATCH | `/v1/me/profile` | platform | `user.profile.update` | platform.user | user | user_memberships | platform_audit_event after handler with target | audit/domain/account.ts | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Direct database update (no better-auth hook): the handler appends account.profile_updated (name before/after) to every organization where the user is an active member. | +| POST | `/v1/me/send-download-link` | platform | `user.download_link.send` | platform.user | user | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Sends a download link email to the caller (Resend). | | DELETE | `/v1/members/:memberId` | tenant_external | `member.remove` | member.management | member (:memberId) | org_context | requested before handler; confirmed/attempted, 5xx unknown (request) | audit/domain/members.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: stripe (seat/web/inference quantity sync via runPostOrganizationMemberChangeHooks); google credential revocation; Legacy organization.member.removed. removeOrganizationMember commits locally, then awaits Stripe quantity hooks; a 5xx may follow a committed removal. | | GET | `/v1/members/:memberId/permissions` | tenant_read | `member.permissions.read` | permission.management | member (:memberId) | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | POST | `/v1/members/:memberId/role` | tenant_change | `member.role.update` | member.management | member (:memberId) | org_context | requested before handler; succeeded/attempted after (request) | audit/domain/members.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Legacy organization.member.role_updated (only when changed); implicit API key revocation follows (api_key.revoked), session revocation is not snapshotted. | | POST | `/v1/members/:memberId/transfer-ownership` | tenant_change | `member.ownership.transfer` | member.management | member (:memberId) | org_context | requested before handler; succeeded/attempted after (request) | audit/domain/members.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Legacy organization.member.ownership_transferred; one event lists every member whose role changed. Implicit API key revocation follows (api_key.revoked). | -| GET | `/v1/memory` | platform | `memory.list` | deprecated.memory | memory | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Removed feature stub (authenticatedRoute); no data access. | -| POST | `/v1/memory` | platform | `memory.create` | deprecated.memory | memory | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 Gone stub. | -| DELETE | `/v1/memory/:id` | platform | `memory.delete` | deprecated.memory | memory (:id) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Removed feature stub (authenticatedRoute); no state change. | -| GET | `/v1/memory/search` | platform | `memory.search` | deprecated.memory | memory | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature); Removed feature stub (authenticatedRoute); no data access. | +| GET | `/v1/memory` | platform | `memory.list` | deprecated.memory | memory | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Removed feature stub (authenticatedRoute); no data access. | +| POST | `/v1/memory` | platform | `memory.create` | deprecated.memory | memory | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 Gone stub. | +| DELETE | `/v1/memory/:id` | platform | `memory.delete` | deprecated.memory | memory (:id) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Removed feature stub (authenticatedRoute); no state change. | +| GET | `/v1/memory/search` | platform | `memory.search` | deprecated.memory | memory | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only; Removed feature stub (authenticatedRoute); no data access. | | GET | `/v1/oauth-providers/:providerId/client` | tenant_read | `oauth_provider.client.read` | oauth_provider.configuration | oauth_client (:providerId) | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Client id only. | | POST | `/v1/oauth-providers/:providerId/client` | tenant_change | `oauth_provider.client.update` | oauth_provider.configuration | oauth_client (:providerId) | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Client secret write-only. | | GET | `/v1/oauth-providers/:providerId/connect/callback` | tenant_external | `oauth_provider.connect.complete` | oauth_provider.connection | oauth_provider_connection (:providerId) | handler | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: native OAuth provider (Google / Microsoft token exchange); Attributed after the HMAC-signed state, member credential resolution and pending verifier are verified, before the token exchange: org = state organization, actor = the active initiating member's user. | | GET | `/v1/oauth-providers/:providerId/connect/start` | tenant_change | `oauth_provider.connect.start` | oauth_provider.connection | oauth_provider_connection (:providerId) | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Writes OAuth state; returns authorize URL. | | POST | `/v1/oauth-providers/:providerId/disconnect` | tenant_change | `oauth_provider.connection.disconnect` | oauth_provider.connection | oauth_provider_connection (:providerId) | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Removes stored credential; no upstream revocation. | | GET | `/v1/oauth-providers/:providerId/status` | tenant_read | `oauth_provider.connection.status` | oauth_provider.connection | oauth_provider_connection (:providerId) | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | -| DELETE | `/v1/org` | platform | `organization.delete` | organization.settings | organization | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Evidence is a platform_audit_event row (success and failure), written before the response is released, because the purge deletes the organization's tenant audit history (a failed insert is logged [platform-audit-lost] and does not fail the deletion): actor = authenticated user (+ API key), target = organization from the verified organizationContext (handler addAuditRequestResource before the purge; never request input). External effects: stripe (cancelOrganizationSubscriptions), linear (deletion ticket). No tenant intent is recorded. | +| DELETE | `/v1/org` | platform | `organization.delete` | organization.settings | organization | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Evidence is a platform_audit_event row (success and failure), written before the response is released, because the purge deletes the organization's tenant audit history (a failed insert is logged [platform-audit-lost] and does not fail the deletion): actor = authenticated user (+ API key), target = organization from the verified organizationContext (handler addAuditRequestResource before the purge; never request input). External effects: stripe (cancelOrganizationSubscriptions), linear (deletion ticket). No tenant intent is recorded. | | GET | `/v1/org` | tenant_read | `organization.read` | organization.settings | organization | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | PATCH | `/v1/org` | tenant_change | `organization.settings.update` | organization.settings | organization | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Includes requireSso/allowedEmailDomains; brand icon URL validation fetch is incidental. | -| POST | `/v1/org` | platform | `organization.create` | platform.user | organization | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Creates a new org; no tenant exists before the handler and the new org has no audit rollout. | +| POST | `/v1/org` | platform | `organization.create` | platform.user | organization | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Creates a new org; no tenant exists before the handler and the new org has no audit rollout. | | POST | `/v1/org/brand-assets` | tenant_change | `organization.brand_assets.update` | organization.settings | organization | org_context | requested before handler; succeeded/attempted after (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | | | GET | `/v1/org/web-origins` | tenant_read | `web_origin.list` | web_origin.management | web_origin | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | POST | `/v1/org/web-origins` | tenant_change | `web_origin.approve` | web_origin.management | web_origin | org_context | requested before handler; succeeded/attempted after (request) | audit/domain/web-origins.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Legacy organization.web_origin.approved. | @@ -785,8 +785,8 @@ When tenant capture is active the domain emitter appends the new change event in | ALL | `/v1/orgs/:orgId/*` | proxy | `legacy_org.proxy` | request.proxy | organization (:orgId) | none | destination route records | | none | delegatedRoute legacy org proxy re-dispatches through app.fetch; the destination route records. | | POST | `/v1/orgs/invitations/accept` | tenant_external | `invitation.accept` | invitation.management | invitation | handler | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: stripe (seat/web/inference quantity sync via runPostOrganizationMemberChangeHooks); Org proof: pending invitation row whose email matches the verified session user -> invitation.organizationId; attributed through acceptInvitationForUser beforeEffect (before any write). Actor = invitee user without a member id (not yet a member). | | GET | `/v1/orgs/invitations/preview` | tenant_read | `invitation.preview` | invitation.read | invitation | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Public preview by invitation id/token. The token proves the invitation's organization: once the invitation is found the handler attributes the served read there (category read, off by default), actor unknown. The token is never recorded (no resource id). Unknown tokens (404) stay platform evidence. | -| GET | `/v1/orgs/sso/resolve` | platform | `auth.sso.resolve` | platform.auth | login_options | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public, bot-protected email -> SSO routing. | -| GET | `/v1/orgs/sso/singleton` | platform | `auth.sso_singleton.read` | platform.discovery | deployment | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only (successes need the platformAuditReads feature) | +| GET | `/v1/orgs/sso/resolve` | platform | `auth.sso.resolve` | platform.auth | login_options | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public, bot-protected email -> SSO routing. | +| GET | `/v1/orgs/sso/singleton` | platform | `auth.sso_singleton.read` | platform.discovery | deployment | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | read-only | | GET | `/v1/permissions/catalog` | tenant_read | `permission.catalog.read` | permission.management | permission | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | GET | `/v1/permissions/keys/:permissionKey` | tenant_read | `permission.read` | permission.management | permission (:permissionKey) | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | GET | `/v1/permissions/sets` | tenant_read | `permission_set.list` | permission.management | permission_set | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | @@ -826,16 +826,16 @@ When tenant capture is active the domain emitter appends the new change event in | POST | `/v1/scim/reconcile` | tenant_change | `scim.reconcile` | scim.configuration | scim_connection | org_context | requested before handler; succeeded/attempted after (request) | audit/domain/scim.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Legacy organization.scim.reconciliation_run (not an alert action); synchronous repairs. | | POST | `/v1/scim/token` | tenant_change | `scim.token.rotate` | scim.configuration | scim_connection | org_context | requested before handler; succeeded/attempted after (request) | audit/domain/scim.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Legacy organization.scim.token_rotated. better-auth generateSCIMToken write; plaintext token only in the response. | | PUT | `/v1/session-runners/inventory` | tenant_signal | `automation_runner.inventory.update` | automation.runner | automation_runner | handler | one outcome after handler: observed/attempted (category read, off by default; denials use security when selected); no intent | request evidence only | never fail-closed: capture-start failure served without capture; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Runner-token route: authenticateRunner() verifies the token, then attributes actor automation-runner: for the owner member. Reports the opted-in client's approved workspaces and models for remote-session:targets. | -| POST | `/v1/session-runners/token` | tenant_access | `automation_runner.token.issue` | automation.runner | automation_runner | org_context | requested before handler; served before release (access) | request evidence only | intent or served: up to 3 attempts on transient database errors, then 503, no content | Registers a remote-only session target behind remoteSessionTargets and issues an audience-bound runner credential. Never snapshot the token. Blocked from MCP. | +| POST | `/v1/session-runners/token` | tenant_access | `automation_runner.token.issue` | automation.runner | automation_runner | org_context | requested before handler; served before release (access) | request evidence only | intent or served: up to 3 attempts on transient database errors, then 503, no content | Registers a remote-only session target and issues an audience-bound runner credential. Never snapshot the token. Blocked from MCP. | | GET | `/v1/session-runners/work` | tenant_signal | `automation_runner.work.poll` | automation.runner | automation_runner | handler | one outcome after handler: observed/attempted (category read, off by default; denials use security when selected); no intent | request evidence only | never fail-closed: capture-start failure served without capture; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Runner-token route: authenticateRunner() verifies the token, then attributes actor automation-runner: for the owner member. Owner-scoped remote command/request discovery and sticky recovery; updates presence, never lists scheduled Automations. | -| GET | `/v1/skill-hubs` | platform | `skill_hub.list` | deprecated.skill_hub | skill_hub | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | -| POST | `/v1/skill-hubs` | platform | `skill_hub.create` | deprecated.skill_hub | skill_hub | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | -| DELETE | `/v1/skill-hubs/:skillHubId` | platform | `skill_hub.delete` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | -| PATCH | `/v1/skill-hubs/:skillHubId` | platform | `skill_hub.update` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | -| POST | `/v1/skill-hubs/:skillHubId/access` | platform | `skill_hub.access_grant.create` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | -| DELETE | `/v1/skill-hubs/:skillHubId/access/:accessId` | platform | `skill_hub.access_grant.revoke` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | -| POST | `/v1/skill-hubs/:skillHubId/skills` | platform | `skill_hub.skill.add` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | -| DELETE | `/v1/skill-hubs/:skillHubId/skills/:skillId` | platform | `skill_hub.skill.remove` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target; successful readOnly reads only with the platformAuditReads feature | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | +| GET | `/v1/skill-hubs` | platform | `skill_hub.list` | deprecated.skill_hub | skill_hub | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | +| POST | `/v1/skill-hubs` | platform | `skill_hub.create` | deprecated.skill_hub | skill_hub | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | +| DELETE | `/v1/skill-hubs/:skillHubId` | platform | `skill_hub.delete` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | +| PATCH | `/v1/skill-hubs/:skillHubId` | platform | `skill_hub.update` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | +| POST | `/v1/skill-hubs/:skillHubId/access` | platform | `skill_hub.access_grant.create` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | +| DELETE | `/v1/skill-hubs/:skillHubId/access/:accessId` | platform | `skill_hub.access_grant.revoke` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | +| POST | `/v1/skill-hubs/:skillHubId/skills` | platform | `skill_hub.skill.add` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | +| DELETE | `/v1/skill-hubs/:skillHubId/skills/:skillId` | platform | `skill_hub.skill.remove` | deprecated.skill_hub | skill_hub (:skillHubId) | none | platform_audit_event after handler with target | | written before the response is released; up to 3 attempts on transient database errors, then a failed insert never fails the request ([platform-audit-lost]) | Public 410 stub; use plugins. | | DELETE | `/v1/sso` | tenant_change | `sso_connection.delete` | sso.configuration | sso_connection | org_context | requested before handler; succeeded/attempted after (request) | audit/domain/sso.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Legacy organization.sso.connection_deleted. | | GET | `/v1/sso` | tenant_read | `sso_connection.read` | sso.configuration | sso_connection | org_context | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | | | POST | `/v1/sso/disable` | tenant_change | `sso_connection.disable` | sso.configuration | sso_connection | org_context | requested before handler; succeeded/attempted after (request) | audit/domain/sso.ts | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Legacy organization.sso.connection_disabled. | @@ -862,9 +862,9 @@ When tenant capture is active the domain emitter appends the new change event in | POST | `/v1/telemetry/ingest` | tenant_signal | `telemetry.events.ingest` | telemetry.adoption | telemetry_event | org_context | one outcome after handler: observed/attempted (category read, off by default; denials use security when selected); no intent | request evidence only | never fail-closed: capture-start failure served without capture; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | Desktop activity and session/task lifecycle pings for the calling member; identifiers and timings only. High volume. | | POST | `/v1/webhooks/connectors/github` | tenant_job | `github_connector.webhook.receive` | connector.webhook | connector_instance | handler | requested before handler; accepted/attempted after (execution) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | job outcome: connector_sync.completed per enqueued sync event via src/workers/github-sync.ts:processDueGithubSyncEvents (job operation jobRunId = connector_sync_event id; see src/audit/job-outcomes.ts); Org proof: x-hub-signature-256 verified against the app secret, then installation id -> stored github ConnectorAccount rows -> org; attributed before enqueue/updates (actor service github, origin webhook). One installation linked to several organizations has no single tenant (the delivery fans out per org), so it stays in the platform store, as do ignored or unmapped deliveries. | | POST | `/v1/webhooks/stripe` | tenant_external | `billing.webhook.apply` | billing.webhook | billing_subscription | handler | requested before handler; confirmed/attempted, 5xx unknown (request) | request evidence only | intent: up to 3 attempts on transient database errors, then 503, handler not run; outcome: up to 3 attempts on transient database errors, then logged [audit-outcome-lost] | external: stripe (subscription re-fetch; web quantity sync); Org proof: stripe-signature verified (constructEvent), then our org_subscriptions row for the event's subscription (or the seat row matching the setup checkout's customer) -> row.organization_id; attributed before the event is applied. A first checkout creates that row, so it is attributed phase after. Actor service stripe, origin webhook. Events with no stored mapping go to the platform store. | -| GET | `/v1/workbot/calendar/*` | proxy | `workbot.calendar.read` | workbot.execution | automation | handler | destination route records | | none | Handler verifies the MCP OAuth token, membership and the workbot + workbotCalendar features, then re-dispatches an allowlisted read (the member's Automations, runs, run receipts, native calendar events, and usable LLM providers trimmed to names and model IDs) through app.fetch with a signed internal principal; the destination route records. | -| PATCH | `/v1/workbot/calendar/*` | proxy | `workbot.calendar.schedule` | workbot.execution | automation | handler | destination route records | | none | Handler verifies the MCP OAuth token (write scope), membership and the workbot + workbotCalendar features, accepts a body limited to `name`, `schedule`, `instructions` and `model` (never where it runs), then re-dispatches PATCH /v1/automations/:id through app.fetch as the member; the destination route records. | -| POST | `/v1/workbot/calendar/*` | proxy | `workbot.calendar.action` | workbot.execution | automation | handler | destination route records | | none | Handler verifies the MCP OAuth token (write scope), membership and the workbot + workbotCalendar features, then re-dispatches POST /v1/automations/:id/{activate\|deactivate\|run} or POST /v1/cloud-automations (create, body forwarded) through app.fetch as the member; the destination route records. | +| GET | `/v1/workbot/calendar/*` | proxy | `workbot.calendar.read` | workbot.execution | automation | handler | destination route records | | none | Handler verifies the MCP OAuth token, membership and the workbot feature, then re-dispatches an allowlisted read (the member's Automations, runs, run receipts, native calendar events, and usable LLM providers trimmed to names and model IDs) through app.fetch with a signed internal principal; the destination route records. | +| PATCH | `/v1/workbot/calendar/*` | proxy | `workbot.calendar.schedule` | workbot.execution | automation | handler | destination route records | | none | Handler verifies the MCP OAuth token (write scope), membership and the workbot feature, accepts a body limited to `name`, `schedule`, `instructions` and `model` (never where it runs), then re-dispatches PATCH /v1/automations/:id through app.fetch as the member; the destination route records. | +| POST | `/v1/workbot/calendar/*` | proxy | `workbot.calendar.action` | workbot.execution | automation | handler | destination route records | | none | Handler verifies the MCP OAuth token (write scope), membership and the workbot feature, then re-dispatches POST /v1/automations/:id/{activate\|deactivate\|run} or POST /v1/cloud-automations (create, body forwarded) through app.fetch as the member; the destination route records. | | GET | `/v1/workbot/connections` | tenant_read | `workbot.connection.list` | workbot.execution | mcp_connection | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Handler verifies the MCP OAuth token and active membership, returns 403 while the workbot feature is off, then lists the Gmail/Google Workspace, Slack and Microsoft 365 connections this member may use (id, name, readiness, Den connect URL; no secrets). | | POST | `/v1/workbot/run-token` | tenant_access | `workbot.run_token.issue` | workbot.execution | oauth_access_token | handler | requested before handler; served before release (access) | request evidence only | intent or served: up to 3 attempts on transient database errors, then 503, no content | Handler verifies the MCP OAuth token, membership and workbot capability, then mints a ≤1h headless-run MCP token (credential issuance; plaintext returned once, never snapshot). mintHeadlessRunMcpToken is also called by the headless automation executor without an HTTP route. | | GET | `/v1/workbot/session` | tenant_read | `workbot.session.read` | workbot.execution | workbot_session | handler | served before release (category read, off by default) | request evidence only | capture-start failure: served without capture; served append after capture started: up to 3 attempts on transient database errors, then 503, no content | Handler verifies the MCP OAuth token (verifyMcpRequest) and active membership, then attributes the token's org and user. | diff --git a/ee/apps/den-api/src/audit/better-auth.ts b/ee/apps/den-api/src/audit/better-auth.ts index 7d64ec24a0c..d42ca6aef9a 100644 --- a/ee/apps/den-api/src/audit/better-auth.ts +++ b/ee/apps/den-api/src/audit/better-auth.ts @@ -12,7 +12,7 @@ import { memberRemovedEvent } from "./domain/members.js" import { oauthTokenIssuedEvent, oauthTokenRevokedEvent } from "./domain/oauth-tokens.js" import { findAuditUserIdByEmail, recordSessionOrganizationEntered, recordSignInFailed } from "./domain/sessions.js" import { appendDomainChangesAfterCommit, finishLegacyAuditAction } from "./domain/legacy.js" -import { attributeCurrentAuditRequest, auditLogsFeatureEnabled, auditServiceAttribution, auditSessionUserAttribution, currentAuditChangeCapture, currentAuditRequestId, currentAuditRequestKey, type AuditRequestAttribution } from "./request-capture.js" +import { attributeCurrentAuditRequest, auditServiceAttribution, auditSessionUserAttribution, currentAuditChangeCapture, currentAuditRequestId, currentAuditRequestKey, type AuditRequestAttribution } from "./request-capture.js" // Tenant attribution for the org-attributable better-auth endpoints declared in // ./routes/auth.ts (served by the `* /api/auth/*` catch-all). hooks.before @@ -21,9 +21,8 @@ import { attributeCurrentAuditRequest, auditLogsFeatureEnabled, auditServiceAttr // where the organization is known from the endpoint's own verified result // (SSO callbacks/ACS, sso/get-provider) with phase "after". // Cost and availability: nothing runs while deployment capture is off; the -// candidate organization's auditLogs feature is read first and member, -// consent-owner and SCIM-token lookups only run for an organization with the -// feature on (attribution then rereads it fresh for that organization). A lookup error means "no attribution" (platform store, logged +// candidate organization is looked up first and member, consent-owner and +// SCIM-token lookups only run for an existing organization. A lookup error means "no attribution" (platform store, logged // at warn); only a failed intent append refuses the endpoint (503). // The organization is never taken from a request value alone: a body/query // organization id counts only once the session user's active membership in it @@ -36,11 +35,11 @@ type HookInput = Readonly<{ request: Request | undefined body: unknown query: unknown - /** Lazy: only read once a flagged organization needs the session user. */ + /** Lazy: only read once an existing organization needs the session user. */ session: () => Promise }> type HookResult = { ok: true } | { ok: false } -type FlaggedOrganization = Readonly<{ id: string }> +type ExistingOrganization = Readonly<{ id: string }> const logger = appLogger.child({ component: "better_auth_audit" }) const ORG_READS = new Set([ @@ -79,10 +78,10 @@ function errorName(error: unknown) { return error instanceof Error ? error.name : typeof error } -/** The existing organization (by id or slug) when its auditLogs feature is on. */ -async function flaggedOrganization(where: Readonly<{ id: string | null }> | Readonly<{ slug: string | null }>): Promise { +/** The existing organization (by id or slug). */ +async function existingOrganization(where: Readonly<{ id: string | null }> | Readonly<{ slug: string | null }>): Promise { const columns = { id: OrganizationTable.id } - let row: FlaggedOrganization | undefined + let row: ExistingOrganization | undefined if ("id" in where) { const id = organizationIdOrNull(where.id) if (!id) return null @@ -91,7 +90,7 @@ async function flaggedOrganization(where: Readonly<{ id: string | null }> | Read if (!where.slug) return null ;[row] = await db.select(columns).from(OrganizationTable).where(eq(OrganizationTable.slug, where.slug)).limit(1) } - return row && await auditLogsFeatureEnabled(row.id) ? row : null + return row ?? null } async function activeMember(organizationId: string | null, userId: string) { @@ -107,13 +106,13 @@ async function activeMember(organizationId: string | null, userId: string) { return member ?? null } -async function attribute(organization: FlaggedOrganization, input: Pick): Promise { +async function attribute(organization: ExistingOrganization, input: Pick): Promise { const result = await attributeCurrentAuditRequest({ organizationId: organization.id, ...input }) return result.ok ? { ok: true } : { ok: false } } -/** Attribute to a flagged organization only when the session user is an active member of it. */ -async function attributeVerifiedMember(organization: FlaggedOrganization | null, userId: string, phase: "before" | "after" = "before") { +/** Attribute to an organization only when the session user is an active member of it. */ +async function attributeVerifiedMember(organization: ExistingOrganization | null, userId: string, phase: "before" | "after" = "before") { if (!organization) return { result: NO_ATTRIBUTION, member: null } const member = await activeMember(organization.id, userId) if (!member) return { result: NO_ATTRIBUTION, member: null } @@ -131,9 +130,9 @@ async function attributeBefore(input: HookInput): Promise { const { path } = input if (SCIM_READS.has(path)) { const token = bearerToken(input.request) - // The token's organization part only selects the flag read; the provider + // The token's organization part only selects the organization; the provider // lookup verifies the token before it names the tenant. - const organization = token ? await flaggedOrganization({ id: scimTokenOrganizationId(token) }) : null + const organization = token ? await existingOrganization({ id: scimTokenOrganizationId(token) }) : null if (!token || !organization) return NO_ATTRIBUTION const { resolveScimProviderFromBearerToken } = await import("../scim.js") const provider = await resolveScimProviderFromBearerToken(token) @@ -141,7 +140,7 @@ async function attributeBefore(input: HookInput): Promise { return attribute(organization, auditServiceAttribution("scim", provider.providerId)) } if (path === "/organization/leave") { - const organization = await flaggedOrganization({ id: stringField(input.body, "organizationId") }) + const organization = await existingOrganization({ id: stringField(input.body, "organizationId") }) const session = organization ? await input.session() : null if (!session) return NO_ATTRIBUTION const { result, member } = await attributeVerifiedMember(organization, session.userId) @@ -157,7 +156,7 @@ async function attributeBefore(input: HookInput): Promise { try { id = normalizeDenTypeId("invitation", invitationId ?? "") } catch { return NO_ATTRIBUTION } const [invitation] = await db.select({ id: InvitationTable.id, organizationId: InvitationTable.organizationId, email: InvitationTable.email, status: InvitationTable.status }) .from(InvitationTable).innerJoin(OrganizationTable, eq(OrganizationTable.id, InvitationTable.organizationId)).where(eq(InvitationTable.id, id)).limit(1) - if (!invitation || invitation.status !== "pending" || !(await auditLogsFeatureEnabled(invitation.organizationId))) return NO_ATTRIBUTION + if (!invitation || invitation.status !== "pending") return NO_ATTRIBUTION const session = await input.session() if (!session?.email || session.email.toLowerCase() !== invitation.email.toLowerCase()) return NO_ATTRIBUTION const attribution = auditSessionUserAttribution(session.userId, null) @@ -168,7 +167,7 @@ async function attributeBefore(input: HookInput): Promise { } if (path === "/organization/set-active") { const organizationId = stringField(input.body, "organizationId") - const organization = await flaggedOrganization(organizationId ? { id: organizationId } : { slug: stringField(input.body, "organizationSlug") }) + const organization = await existingOrganization(organizationId ? { id: organizationId } : { slug: stringField(input.body, "organizationSlug") }) const session = organization ? await input.session() : null return session ? (await attributeVerifiedMember(organization, session.userId)).result : NO_ATTRIBUTION } @@ -176,7 +175,7 @@ async function attributeBefore(input: HookInput): Promise { const organizationId = stringField(input.query, "organizationId") const slug = path === "/organization/get-full-organization" ? stringField(input.query, "organizationSlug") : null const session = organizationId || slug ? null : await input.session() - const organization = await flaggedOrganization(organizationId ? { id: organizationId } : slug ? { slug } : { id: session?.activeOrganizationId ?? null }) + const organization = await existingOrganization(organizationId ? { id: organizationId } : slug ? { slug } : { id: session?.activeOrganizationId ?? null }) const user = organization ? session ?? await input.session() : null return user ? (await attributeVerifiedMember(organization, user.userId)).result : NO_ATTRIBUTION } @@ -184,7 +183,7 @@ async function attributeBefore(input: HookInput): Promise { // consentReferenceId (auth.ts postLogin) is the session's active organization. const session = await input.session() if (!session) return NO_ATTRIBUTION - return (await attributeVerifiedMember(await flaggedOrganization({ id: session.activeOrganizationId }), session.userId)).result + return (await attributeVerifiedMember(await existingOrganization({ id: session.activeOrganizationId }), session.userId)).result } if (CONSENT_BY_ID.has(path)) { const consentId = stringField(input.body, "id") @@ -192,10 +191,10 @@ async function attributeBefore(input: HookInput): Promise { let id: ReturnType> try { id = normalizeDenTypeId("oauthConsent", consentId) } catch { return NO_ATTRIBUTION } // The consent's organization is unknown before the consent row: one joined - // read returns its owner and existing organization, then its feature state. + // read returns its owner and existing organization. const [consent] = await db.select({ userId: OAuthConsentTable.userId, organizationId: OrganizationTable.id }) .from(OAuthConsentTable).innerJoin(OrganizationTable, eq(OrganizationTable.id, OAuthConsentTable.referenceId)).where(eq(OAuthConsentTable.id, id)).limit(1) - if (!consent || !(await auditLogsFeatureEnabled(consent.organizationId))) return NO_ATTRIBUTION + if (!consent) return NO_ATTRIBUTION const session = await input.session() if (!session || consent.userId !== session.userId) return NO_ATTRIBUTION return (await attributeVerifiedMember({ id: consent.organizationId }, session.userId)).result @@ -253,7 +252,7 @@ export async function auditBetterAuthAfter(input: HookInput & Readonly<{ params: if (!providerId) return const [provider] = await db.select({ organizationId: OrganizationTable.id }) .from(SsoProviderTable).innerJoin(OrganizationTable, eq(OrganizationTable.id, SsoProviderTable.organizationId)).where(eq(SsoProviderTable.providerId, providerId)).limit(1) - if (!provider || !(await auditLogsFeatureEnabled(provider.organizationId))) return + if (!provider) return // The provider that validated the assertion names the organization; the // new session's user is the actor (a member once JIT provisioning ran). const member = await activeMember(provider.organizationId, input.newSessionUserId) @@ -262,7 +261,7 @@ export async function auditBetterAuthAfter(input: HookInput & Readonly<{ params: return } if (path === "/sso/get-provider" && !isFailure(input.returned)) { - const organization = await flaggedOrganization({ id: stringField(input.returned, "organizationId") }) + const organization = await existingOrganization({ id: stringField(input.returned, "organizationId") }) const session = organization ? await input.session() : null if (session) await attributeVerifiedMember(organization, session.userId, "after") } @@ -422,7 +421,7 @@ function failedResult(returned: unknown): boolean { async function verifiedTokenMember(referenceId: string | null, userId: string | null) { if (!referenceId || !userId) return null - const organization = await flaggedOrganization({ id: referenceId }) + const organization = await existingOrganization({ id: referenceId }) if (!organization) return null const member = await activeMember(organization.id, userId) const attribution = member ? auditSessionUserAttribution(userId, member.id) : null @@ -467,11 +466,11 @@ async function auditOAuthAfter(path: string, returned: unknown): Promise { export const RAW_ENDPOINT_REFUSED = "raw_endpoint_refused" -/** The refusal attribution, or null (no session, unflagged organization, not an active member, lookup failure). */ +/** The refusal attribution, or null (no session, unknown organization, not an active member, lookup failure). */ export async function rawRefusalAttribution(input: Readonly<{ userId: string | null; requestedOrganizationId: string | null; activeOrganizationId: string | null }>): Promise { if (!env.auditCaptureEnabled || !input.userId) return null try { - const organization = await flaggedOrganization({ id: input.requestedOrganizationId ?? input.activeOrganizationId }) + const organization = await existingOrganization({ id: input.requestedOrganizationId ?? input.activeOrganizationId }) if (!organization) return null const member = await activeMember(organization.id, input.userId) const attribution = member ? auditSessionUserAttribution(input.userId, member.id) : null diff --git a/ee/apps/den-api/src/audit/capture.ts b/ee/apps/den-api/src/audit/capture.ts index cd5e176ae91..ad2696c9b44 100644 --- a/ee/apps/den-api/src/audit/capture.ts +++ b/ee/apps/den-api/src/audit/capture.ts @@ -3,8 +3,6 @@ import { eq, sql } from "@openwork-ee/den-db/drizzle" import { AuditPolicyTable, OrganizationTable } from "@openwork-ee/den-db/schema" import { normalizeDenTypeId } from "@openwork-ee/utils/typeid" import type { AuditEntitlement } from "@openwork/types/den/audit" -import { organizationFeatureEnabled } from "../features.js" -import { AuditReadError } from "./cursors.js" import { PILOT_DEFAULT_CATEGORIES } from "./pilot-policy.js" // Temporary server-owned declarations, not an enforced cap or a billing product. @@ -14,26 +12,19 @@ export const PROVISIONAL_AUDIT_ALLOWANCE = 6_000_000 export const PROVISIONAL_AUDIT_ATTACHMENT_WINDOW_SECONDS = 300 export const PROVISIONAL_AUDIT_SYSTEM_ACTOR = "den-api.audit-defaults" -/** One fresh, unlocked organization read; the rollout flag never changes the entitlement. */ +/** One fresh, unlocked organization read of the audit entitlement. */ export async function readAuditAvailability(database: AuditDatabase | AuditTx, organizationId: string) { const [organization] = await database.select({ metadata: OrganizationTable.metadata }).from(OrganizationTable) .where(eq(OrganizationTable.id, normalizeDenTypeId("organization", organizationId))).limit(1) if (!organization) throw new AuditLogError("audit_storage_inconsistent") const { getAuditEntitlement } = await import("../entitlements.js") - const featureEnabled = await organizationFeatureEnabled(organizationId, "auditLogs", { database }) - return { featureEnabled, entitlement: getAuditEntitlement(organization.metadata) } + return { entitlement: getAuditEntitlement(organization.metadata) } } export async function readAuditEntitlement(database: AuditDatabase | AuditTx, organizationId: string): Promise { return (await readAuditAvailability(database, organizationId)).entitlement } -export async function requireAuditFeature(database: AuditDatabase | AuditTx, organizationId: string) { - const availability = await readAuditAvailability(database, organizationId) - if (!availability.featureEnabled) throw new AuditReadError("audit_feature_disabled") - return availability -} - /** * Lazily creates the default policy for a ready organization. No locks: the * policy primary key decides a concurrent first initialization, and only the @@ -43,7 +34,7 @@ export async function initializeAuditPolicyInTx(tx: AuditTx, inputOrganizationId if (!captureAvailable) return { policy: null, initialized: false } const organizationId = normalizeDenTypeId("organization", inputOrganizationId) const availability = await readAuditAvailability(tx, organizationId) - if (!availability.featureEnabled || !availability.entitlement.enabled) return { policy: null, initialized: false } + if (!availability.entitlement.enabled) return { policy: null, initialized: false } const existing = await readAuditPolicy(tx, organizationId) if (existing) return { policy: existing, initialized: false } @@ -96,5 +87,5 @@ export async function readEffectiveAuditPolicy(database: AuditDatabase | AuditTx /** Fresh, unlocked recheck that the organization still has audit capture available. */ export async function recheckAuditEntitlement(database: AuditDatabase | AuditTx, organizationId: string): Promise { const availability = await readAuditAvailability(database, organizationId) - if (!availability.featureEnabled || !availability.entitlement.enabled) throw new AuditLogError("audit_policy_changed") + if (!availability.entitlement.enabled) throw new AuditLogError("audit_policy_changed") } diff --git a/ee/apps/den-api/src/audit/coverage.ts b/ee/apps/den-api/src/audit/coverage.ts index c4047d444c5..66152cda894 100644 --- a/ee/apps/den-api/src/audit/coverage.ts +++ b/ee/apps/den-api/src/audit/coverage.ts @@ -43,7 +43,7 @@ export const providerUncoveredRoutes = [ "PUT /v1/inference-providers/:inferenceProviderId/litellm/member-key", "POST /v1/inference-providers/oauth/browser-litellm-key", "POST /v1/inference-providers/oauth/browser-litellm-check", - // AWS IAM Identity Center member sign-in (gatewayCloudSignIn): request evidence only. + // AWS IAM Identity Center member sign-in: request evidence only. "POST /v1/inference-providers/oauth/browser-aws-start", "POST /v1/inference-providers/oauth/browser-aws-poll", ] @@ -54,7 +54,7 @@ export const providerCoverage: AuditCoverageDeclaration = { ...[...new Set([...providerCoveredRoutes.map(({ step }) => step), ...providerBackgroundSteps])].flatMap((step) => ["committed", "attempted"].map((outcome) => `provider.configuration.${step}.${outcome}`)), ], categories: ["change", "request", "security", "execution"], - capturePolicy: "auditLogs feature on for the organization (read fresh) AND (Enterprise plan OR explicit self-hosted installation entitlement) AND default-on deployment capture switch AND enabled stored policy AND selected category; missing policies initialize with temporary server defaults before snapshot reads. Unlocked reads; the policy identity is rechecked before each append. Existing OFF stays OFF. Generic DB writer semantics are unchanged.", + capturePolicy: "(Enterprise plan OR explicit self-hosted installation entitlement) AND default-on deployment capture switch AND enabled stored policy AND selected category; missing policies initialize with temporary server defaults before snapshot reads. Unlocked reads; the policy identity is rechecked before each append. Existing OFF stays OFF. Generic DB writer semantics are unchanged.", resources: [...providerCoveredResources.map(({ type }) => type), "organization", "member", "team"], snapshotPolicy: "Per-resource allowlisted before/after and changed fields; secret/configuration changes use markers, never secret values or comparison hashes; oversize rejects.", emitter: "src/audit/provider.ts:providerAuditMutation; recordProviderAttempt; src/llm/gateway-matrix.ts:refreshGatewayCatalog", @@ -64,7 +64,7 @@ export const providerCoverage: AuditCoverageDeclaration = { export const auditReadCoverage: AuditCoverageDeclaration = { status: "implemented_scoped", operationKinds: ["audit.access"], actions: auditReadCoveredRoutes.flatMap(({ action }) => [`audit.${action}.requested`, `audit.${action}.served`]), - categories: ["access", "read"], capturePolicy: "Admin + fresh org auditLogs feature + default-on visibility gate; capture also requires fresh Enterprise/installation entitlement AND default-on capture switch AND enabled policy; unlocked feature/policy reads, access preferred, read fallback.", + categories: ["access", "read"], capturePolicy: "Admin + default-on visibility gate; capture also requires fresh Enterprise/installation entitlement AND default-on capture switch AND enabled policy; unlocked policy reads, access preferred, read fallback.", resources: ["audit_collection", "audit_operation"], snapshotPolicy: "Scope and requested/served outcome only; no content, response body or historical snapshots.", emitter: "src/routes/org/audit.ts:serveAudit", failurePolicy: "Durable request intent before read and served event before response release, each up to 3 attempts on transient database errors (idempotent replay by request id), then required capture failure returns 503 without content.", limitations: "Only declared audit endpoints; served means response prepared, not human viewed. Authorization/visibility denials occur before capture. Legacy payloads excluded. Visibility does not itself require an enabled capture policy; no continuous drain.", @@ -79,7 +79,7 @@ export const pilotPolicyCoverage: AuditCoverageDeclaration = { export const auditCaptureCoverage: AuditCoverageDeclaration = { status: "implemented_scoped", operationKinds: ["audit.policy"], actions: ["audit.capture.enabled", "audit.capture.disabled"], categories: ["lifecycle"], - capturePolicy: "Fresh org auditLogs feature, visibility, administrator authorization and live role fence; ON requires fresh Enterprise or explicit installation entitlement and capture rollout; OFF remains allowed after entitlement loss while flagged. Missing ready policy initializes with server defaults; expected revision zero accepted only by its initializer, with explicit OFF applied atomically.", + capturePolicy: "Visibility, administrator authorization and live role fence; ON requires fresh Enterprise or explicit installation entitlement and capture rollout; OFF remains allowed after entitlement loss. Missing ready policy initializes with server defaults; expected revision zero accepted only by its initializer, with explicit OFF applied atomically.", resources: ["audit_policy"], snapshotPolicy: "Only captureOn, revision and effectiveAt before/after; fixed immutable control evidence independent of new policy enabled state and lifecycle category selection.", emitter: "src/routes/org/audit.ts:updateAuditCapture; den-db/audit-log.ts:setAuditCaptureState", failurePolicy: "Live member/team authority read, then an optimistic revision-guarded policy update (no row locks); required evidence failure rolls back setting. Stale revisions reject; matching no-ops create no operation.", @@ -111,7 +111,7 @@ export const genericRequestCoverage: AuditCoverageDeclaration = { operationKinds: [...new Set(tenantRouteDeclarations.map(({ kind }) => kind))].sort(), actions: routeAuditEventTypes, categories: ["read", "access", "request", "execution", "security"], - capturePolicy: "Every den-api route and HTTP-reachable better-auth endpoint carries a declaration (src/audit/routes, enforced by scripts/check-audit-route-coverage.ts). Tenant classes record only when the trusted organization has the auditLogs feature on (features registry, src/features.ts; never organization metadata), deployment capture is on, a fresh enabled policy exists (lazy default allowed) and the class category is selected: read=read (off by default), access=access, change/external=request, job=execution; 401/403 use security when selected. With DEN_AUDIT_CAPTURE_ENABLED=false nothing is read. Organization-context routes reuse the features getOrganizationContextForUser reads in the same request (in parallel with the membership query; no audit query). Every other tenant path resolves the verified organization's feature itself with one fresh organizationFeatureEnabled read, only after the deployment capture switch passes: handler/token routes (worker heartbeat and compatibility tokens, runner routes, SCIM, Slack webhooks and install callback, install links and connect grants, workbot, brand-asset downloads, device-code decisions, bootstrap credentials, invitation acceptance, member OAuth entries/callbacks, Stripe/GitHub webhooks), useUserOrganizations routes (the active organization, once), platform-admin path routes (plus an existence check when the feature is on), MCP service actions and job outcomes; callers never pass the flag. User fan-out reads every membership organization's features in one batched read. The better-auth hooks check the candidate organization's feature first and only look up the member or verify the SCIM token when it is on. Appends recheck the feature with a fresh, unlocked read (readAuditAvailability).", + capturePolicy: "Every den-api route and HTTP-reachable better-auth endpoint carries a declaration (src/audit/routes, enforced by scripts/check-audit-route-coverage.ts). Tenant classes record only when deployment capture is on, a fresh enabled policy exists (lazy default allowed) and the class category is selected: read=read (off by default), access=access, change/external=request, job=execution; 401/403 use security when selected. With DEN_AUDIT_CAPTURE_ENABLED=false nothing is read. Organization-context routes use the organization getOrganizationContextForUser resolved (no audit query). Every other tenant path attributes to the verified organization only after the deployment capture switch passes: handler/token routes (worker heartbeat and compatibility tokens, runner routes, SCIM, Slack webhooks and install callback, install links and connect grants, workbot, brand-asset downloads, device-code decisions, bootstrap credentials, invitation acceptance, member OAuth entries/callbacks, Stripe/GitHub webhooks), useUserOrganizations routes (the active organization, once), platform-admin path routes (plus an organization existence check), MCP service actions and job outcomes. User fan-out records into every active membership organization. The better-auth hooks check the candidate organization exists before looking up the member or verifying the SCIM token. Appends recheck the entitlement with a fresh, unlocked read (readAuditAvailability).", resources: [...new Set(tenantRouteDeclarations.map(({ resource }) => resource.type))].sort(), snapshotPolicy: "Request evidence only: declared resource type, validated path-parameter id (else unparseable) or collection:, parent organization, method, route template and status. Never bodies, query strings, headers, IPs or secrets. Before/after snapshots come only from named domain emitters (changeEvidence) through appendAuditChanges inside the business transaction.", emitter: "src/audit/request-capture.ts:auditRequestMiddleware, beginAuditRequest, attributeAuditRequest, appendAuditChanges; src/audit/service-capture.ts:runAuditedServiceAction", @@ -124,7 +124,7 @@ function jobOutcomes(eventTypes: readonly string[], limitations: string): AuditC const declarations = auditJobOutcomeDeclarations.filter((declaration) => eventTypes.includes(declaration.eventType)) return { status: "implemented_scoped", operationKinds: [...new Set(declarations.map(({ kind }) => kind))].sort(), actions: declarations.map(({ eventType }) => eventType), categories: ["execution"], - capturePolicy: "The organization's auditLogs feature (one fresh read after the deployment capture switch), deployment capture switch, enabled policy (lazy default allowed) and the execution category. One job operation per job row (jobRunId = row id, requestId null) shared by its start and terminal outcome.", + capturePolicy: "Deployment capture switch, enabled policy (lazy default allowed) and the execution category. One job operation per job row (jobRunId = row id, requestId null) shared by its start and terminal outcome.", resources: [...new Set(declarations.flatMap(({ target, parents }) => [target, ...parents]))].sort(), snapshotPolicy: "Status, reason code and counts only; never job input, output, prompts, repository content or error messages.", emitter: [...new Set(declarations.flatMap(({ emitters }) => emitters))].join("; "), @@ -270,7 +270,7 @@ export const organizationBoundAuthEvents: readonly OrganizationBoundAuthEvent[] { eventType: "oauth_token.issued", category: "change", organization: "MCP consent referenceId (the access token's org claim), after the user's active membership there is verified", actor: "the token's user + member id", chokepoints: "POST /api/auth/oauth2/token (authorization_code, refresh_token): access-token claims extension notes the issuance; hooks.after attributes (phase after) and appends", evidence: "after: clientId, scopes, grantType, resource; target the MCP grant (oauth_consent) or the client. Never token values, authorization codes, client secrets or hashes. Tokens without an organization (non-MCP OIDC) stay platform evidence." }, { eventType: "oauth_token.revoked", category: "change", organization: "the revoked token row's referenceId, membership verified", actor: "the token's user + member id", chokepoints: "POST /api/auth/oauth2/revoke: hooks.before looks the stored refresh/opaque access token up by its storage hash (nothing kept); hooks.after attributes (phase after) and appends on success", evidence: "before: tokenType, clientId, scopes. JWT access tokens are stateless and not looked up (platform evidence)." }, { eventType: "invitation.rejected", category: "change", organization: "the invitation's organization", actor: "the invitee user (no member id)", chokepoints: "POST /api/auth/organization/reject-invitation: hooks.before verifies the pending invitation and the session email = invitation email, attributes (intent before); hooks.after appends on success", evidence: "status pending → rejected only; never the email or join token." }, - { eventType: ".attempted (raw_endpoint_refused)", category: "security", organization: "body organizationId, else the session's active organization, only when the session user is an active member there", actor: "the session user + member id", chokepoints: "hooks.before getRawBetterAuthMutationDenial (20 raw organization/api-key/sso endpoints) and the Den-shadowed POST /api/auth/scim/generate-token, /api/auth/scim/delete-provider-connection, /api/auth/organization/create-role, /api/auth/organization/update-role, /api/auth/organization/delete-role", evidence: "Request evidence only: outcome denied, reasonCode raw_endpoint_refused (category request when security is not selected). No .requested intent (attribution refusal), so audit never changes the 403; a foreign or unflagged organization records nothing there (platform evidence)." }, + { eventType: ".attempted (raw_endpoint_refused)", category: "security", organization: "body organizationId, else the session's active organization, only when the session user is an active member there", actor: "the session user + member id", chokepoints: "hooks.before getRawBetterAuthMutationDenial (20 raw organization/api-key/sso endpoints) and the Den-shadowed POST /api/auth/scim/generate-token, /api/auth/scim/delete-provider-connection, /api/auth/organization/create-role, /api/auth/organization/update-role, /api/auth/organization/delete-role", evidence: "Request evidence only: outcome denied, reasonCode raw_endpoint_refused (category request when security is not selected). No .requested intent (attribution refusal), so audit never changes the 403; a foreign organization records nothing there (platform evidence)." }, { eventType: "oauth.authorize.succeeded / auth.oauth.authorize.post.succeeded", category: "request", organization: "postLogin.consentReferenceId (the session's active organization), membership verified", actor: "the session user + member id", chokepoints: "GET (Den route) and POST /api/auth/oauth2/authorize: hooks.after, phase after", evidence: "Request evidence only; the authorization code is never recorded." }, { eventType: "invitation.preview.served", category: "read", organization: "the invitation's organization (the token proves it)", actor: "unknown", chokepoints: "GET /v1/orgs/invitations/preview handler after the invitation is found", evidence: "Request evidence only (category read, off by default); the token is never recorded." }, ] @@ -284,7 +284,7 @@ export const auditExclusions = [ export const auditRolloutStatus = { stage: "pilot", comprehensiveCoverage: true, coverageScope: "Every den-api route and HTTP-reachable better-auth endpoint is declared and checked in CI; only MCP consumption transport and the operational probes (GET /, /health, /ready) are excluded; other repository surfaces are declared boundaries.", - legacyBridge: "single_writer", platformStore: "platform_audit_event", platformReadsFeature: "platformAuditReads", + legacyBridge: "single_writer", platformStore: "platform_audit_event", legacyBackfill: false, paidOverage: false, billing: "disabled", retention: "preview_only", deletionEnabled: false, scheduledCleanup: false, jobContextPropagation: false, drains: "not_configured", diff --git a/ee/apps/den-api/src/audit/cursors.ts b/ee/apps/den-api/src/audit/cursors.ts index 23adc02a678..61bd2cc5ab1 100644 --- a/ee/apps/den-api/src/audit/cursors.ts +++ b/ee/apps/den-api/src/audit/cursors.ts @@ -29,12 +29,11 @@ const cursorSchema = z.object({ export type AuditCursor = z.infer export type AuditCursorBinding = Pick export class AuditReadError extends Error { - constructor(readonly code: "audit_feature_disabled" | "audit_invalid_query" | "audit_invalid_cursor" | "audit_cursor_expired" | "audit_operation_not_found" | "audit_storage_inconsistent") { + constructor(readonly code: "audit_invalid_query" | "audit_invalid_cursor" | "audit_cursor_expired" | "audit_operation_not_found" | "audit_storage_inconsistent") { super(code) this.name = "AuditReadError" } get status() { - if (this.code === "audit_feature_disabled") return 403 if (this.code === "audit_cursor_expired") return 410 if (this.code === "audit_operation_not_found") return 404 if (this.code === "audit_storage_inconsistent") return 503 diff --git a/ee/apps/den-api/src/audit/domain/sessions.ts b/ee/apps/den-api/src/audit/domain/sessions.ts index c4d65da5fd9..6e0c0b9afb7 100644 --- a/ee/apps/den-api/src/audit/domain/sessions.ts +++ b/ee/apps/den-api/src/audit/domain/sessions.ts @@ -272,7 +272,7 @@ async function recordSignInFailedNow(input: AuditSignInFailure, requestId: strin /** * hooks.after once the failure response is decided: runs detached so neither * the status, the body nor the response timing depends on whether the account - * exists or is a member of a flagged organization. Known trade-off: anyone who + * exists or is a member of an organization. Known trade-off: anyone who * guesses a member's email can append these events (no rate limit). */ export function recordSignInFailed(input: AuditSignInFailure): void { diff --git a/ee/apps/den-api/src/audit/fanout.ts b/ee/apps/den-api/src/audit/fanout.ts index 96083d72dcd..ee8d0ccd706 100644 --- a/ee/apps/den-api/src/audit/fanout.ts +++ b/ee/apps/den-api/src/audit/fanout.ts @@ -6,8 +6,7 @@ import type { AuditCategory } from "@openwork/types/den/audit" import { db } from "../db.js" import { env } from "../env.js" import { appLogger } from "../observability/logger.js" -import { getFeaturesForOrganizations } from "../features.js" -import { appendAuditEventInOwnTransaction, readEffectiveAuditPolicyWithRetry, auditOperationOutcome, auditUserPrincipalKey, currentAuditChangeCapture, auditLogsFeatureEnabled, currentAuditRequestId, logAuditOutcomeLost, type AuditChangeCapture } from "./request-capture.js" +import { appendAuditEventInOwnTransaction, readEffectiveAuditPolicyWithRetry, auditOperationOutcome, auditUserPrincipalKey, currentAuditChangeCapture, currentAuditRequestId, logAuditOutcomeLost, type AuditChangeCapture } from "./request-capture.js" // Events about a USER (sessions, account security, failed sign-ins) that have // no single request tenant (attribution "user_memberships", src/audit/routes). @@ -16,8 +15,7 @@ import { appendAuditEventInOwnTransaction, readEffectiveAuditPolicyWithRetry, au // session belongs to (`organizationId`). Each organization gets its own // operation and transaction (entitlement recheck + policy-current fence) and // shares the request id, so the organization sees the request reference. -// Only organizations with the auditLogs feature (features registry), deployment capture, an -// enabled policy (lazy default allowed; entitlement checked there) and the +// Only organizations with deployment capture, an enabled policy (lazy default allowed; entitlement checked there) and the // event category store anything. A failure in one organization logs // [audit-outcome-lost] and never fails the user's request or other organizations. @@ -76,14 +74,12 @@ function fanoutActor(actor: UserMembershipsAuditInput["actor"], membership: Audi /** * Own-operation capture in one organization for code outside its request - * operation: null unless deployment capture, the auditLogs feature - * (`auditLogs` when already resolved in this call, else read), an enabled + * operation: null unless deployment capture, an enabled * policy (lazy default allowed) and the category hold. Throws on lookup errors * (callers log [audit-outcome-lost]). */ export async function standaloneOrganizationAuditCapture(input: Readonly<{ organizationId: string - auditLogs?: boolean actor: AuditActor principalKey: string origin?: AuditOrigin @@ -95,8 +91,6 @@ export async function standaloneOrganizationAuditCapture(input: Readonly<{ if (!env.auditCaptureEnabled) return null const organizationId = typeIdOrNull("organization", input.organizationId) if (!organizationId) return null - const auditLogs = input.auditLogs ?? await auditLogsFeatureEnabled(organizationId) - if (!auditLogs) return null const policy = await readEffectiveAuditPolicyWithRetry(organizationId) if (!policy || !policy.categories.includes(input.category)) return null return { @@ -117,18 +111,14 @@ export async function recordAuditForUserMemberships(input: UserMembershipsAuditI if (input.organizationId !== undefined && !onlyOrganizationId) return 0 const requestId = input.requestId === undefined ? currentAuditRequestId() : input.requestId let memberships: Awaited> - let features: Awaited> try { memberships = await activeMemberships(userId, onlyOrganizationId) - // Every membership organization's features in one batched read. - features = await getFeaturesForOrganizations([...new Set(memberships.map((row) => row.organizationId))]) } catch (error) { logger.warn("audit membership lookup failed; user event not recorded", { request_id: requestId, audit_action: input.action, error_name: errorName(error) }) return 0 } let stored = 0 for (const row of memberships) { - if (!features.get(row.organizationId)?.auditLogs) continue const membership: AuditUserMembership = { organizationId: row.organizationId, memberId: row.memberId, userId } const outcome = input.outcome ?? "succeeded" try { @@ -139,7 +129,7 @@ export async function recordAuditForUserMemberships(input: UserMembershipsAuditI if (!capture) { const { actor, principalKey } = fanoutActor(input.actor, membership) capture = await standaloneOrganizationAuditCapture({ - organizationId: membership.organizationId, auditLogs: true, actor, principalKey, origin: input.origin, requestId, + organizationId: membership.organizationId, actor, principalKey, origin: input.origin, requestId, kind: input.kind, scope: input.scope ?? userId, category: input.category, }) if (!capture) continue diff --git a/ee/apps/den-api/src/audit/job-capture.ts b/ee/apps/den-api/src/audit/job-capture.ts index 4014e793d55..e5b41a2c5fd 100644 --- a/ee/apps/den-api/src/audit/job-capture.ts +++ b/ee/apps/den-api/src/audit/job-capture.ts @@ -2,12 +2,11 @@ import type { AuditActor, AuditEventInput, AuditOrigin } from "@openwork-ee/den- import { normalizeDenTypeId } from "@openwork-ee/utils/typeid" import { env } from "../env.js" import type { AuditJobOutcomeEventType } from "./job-outcomes.js" -import { appendAuditEventInOwnTransaction, readEffectiveAuditPolicyWithRetry, auditLogsFeatureEnabled, auditOperationOutcome, logAuditOutcomeLost } from "./request-capture.js" +import { appendAuditEventInOwnTransaction, readEffectiveAuditPolicyWithRetry, auditOperationOutcome, logAuditOutcomeLost } from "./request-capture.js" // Background job outcomes (src/audit/job-outcomes.ts). Never throws into the // job: a lost outcome is logged [audit-outcome-lost] and the job continues. -// No-op unless the organization has the auditLogs feature (features registry), capture is -// on, an enabled policy exists (lazy default allowed) and "execution" is selected. +// No-op unless capture is on, an enabled policy exists (lazy default allowed) and "execution" is selected. export type AuditJobOutcome = Readonly<{ organizationId: string @@ -38,7 +37,6 @@ export async function recordAuditJobOutcome(input: AuditJobOutcome): Promise