Skip to content

feat(api): workspace-governance token scopes for the org-admin tier (#262)#264

Merged
Zach Dunn (zachdunn) merged 6 commits into
mainfrom
claude/262-workspace-governance-scopes
Jul 18, 2026
Merged

feat(api): workspace-governance token scopes for the org-admin tier (#262)#264
Zach Dunn (zachdunn) merged 6 commits into
mainfrom
claude/262-workspace-governance-scopes

Conversation

@zachdunn

@zachdunn Zach Dunn (zachdunn) commented Jul 18, 2026

Copy link
Copy Markdown
Member

Implements #262: gives the org-admin tier its own credential — workspace-bounded governance scopes workspace:invite and workspace:manage — completing the scope taxonomy started in #257/#261 (files:* for members, workspace:* for org admins, operator:* for platform operators).

What changed

  • Minting (apps/api/src/routes/tokens.ts, auth-db.ts) — POST /v1/tokens accepts opt-in workspace:invite / workspace:manage only when the minting session user holds org role admin/owner in the target workspace; otherwise 400 invalid_scopes. Platform-admin/operator status does not bypass the org-role gate (operators already have /admin-ui). Mixed requests (operator:* + workspace:*) must pass both gates independently. Defaults unchanged.
  • Workspace-bounded enforcement (workspace.ts workspaceGovernanceAuth) — unlike operator:* (global by design), a workspace:* token authorizes actions only on the workspace embedded in it; foreign-workspace, revoked, expired, file-only, and operator-only tokens are rejected fail-closed.
  • Token-authed invites — new POST /v1/workspaces/:name/invites (requires workspace:invite), mirroring the session invite endpoint. Invites are attributed to the token's minting_user_id, and the auth worker re-checks that user's current org role at invite time — a token minted by a since-demoted admin can no longer invite. No auth-worker changes were needed (its internal invite route already took an inviter id).
  • Self-serve token governance — new GET/DELETE /v1/workspaces/:name/tokens (redacted listing / revoke by hashPrefix or label, mirroring the /admin/tokens contracts), dual-authed: org admin/owner session or a workspace:manage token bound to :name. Closes the gap where only platform operators could list/revoke a workspace's tokens.
  • CLI + docsTokenScope alias widened in the published package (patch changeset targeting only @buildinternet/uploads); docs/admin-tokens.md gains a "Workspace-governance scopes" section.

Deliberate choices

  • Fail-closed bearer precedence: on the dual-auth routes, an up_-shaped bearer is authoritative — an invalid one is rejected without falling back to a valid session cookie (documented at the decision point and pinned by a regression test).
  • Zero file-plane access: as with operator:*, any token carrying a governance scope gets no file-route access (file-route scope parsing fails closed) — mint separate tokens per plane.
  • Deletion stays creator-gated: the feat(api): self-serve workspace deletion (soft-delete only) #249 creator-only deletion gate is deliberately untouched; role-gated deletion remains an open question from the issue.
  • Guardrail held: apps/auth has a zero diff — governance scopes never enter OAUTH_SCOPES or any DCR-registerable set.

Testing

pnpm test at root: 134 files / 1519 tests passing; tsc --noEmit clean. New coverage includes: org-role mint gating (member/admin/owner/platform-admin-no-bypass), both-gates mixed requests, foreign-workspace rejection, demoted-minter invite rejection, no-minting-user branch, shaped-but-invalid-bearer-with-valid-session composition, revocation flipping revoked_at, and redacted listing never exposing token values.

Closes #262

Summary by CodeRabbit

  • New Features

    • Added workspace-governance token scopes for sending invites and managing workspace tokens.
    • Added workspace-bounded endpoints to create invites, list active tokens, and revoke tokens.
    • Added support for requesting governance scopes through token creation.
  • Documentation

    • Documented governance scopes, authorization requirements, workspace boundaries, and file-access behavior.
  • Bug Fixes

    • Governance tokens now fail closed when scopes are invalid, expired, revoked, or used outside their workspace.

Add WORKSPACE_SCOPES (workspace:invite, workspace:manage) alongside
OPERATOR_SCOPES in auth-db.ts, with validateScopes(..., { allowOperator,
allowWorkspace }) gating each independently. The token mint route now
resolves the caller's org role in the target workspace (from the
membership lookup already used for the workspace_forbidden check) and
requires admin/owner before accepting any workspace:* scope in a
grant — platform-admin role does not bypass this. parseScopes (file
routes) is unaffected: a governance-scoped token still has zero file
access.
Add workspaceGovernanceAuth(scope) — a D1-backed workspace:*-scoped bearer
token guard that rejects revoked/expired/foreign-workspace/file-only/
operator-only tokens — and mount POST /v1/workspaces/:name/invites on it.
The route acts as the token's minting_user_id; the auth worker's existing
POST /internal/invite already re-checks that user's org role server-side,
so no apps/auth changes were needed.
Add GET/DELETE /v1/workspaces/:name/tokens, dual-authed via a session user
with org admin/owner role in :name or a workspace:manage-scoped D1 token
bound to :name. Mirrors the redacted GET/DELETE /admin/tokens contract,
scoped to one workspace's active tokens.

Also removes an unused beforeAll import and adds coverage for the
workspaceGovernanceAuth guard's no_minting_user 403 branch, both deferred
from Task 2.
…262)

Review follow-up: document that an up_-shaped bearer is authoritative (no
session fallback when invalid) and add a regression test pinning
shaped-but-invalid bearer + valid admin session -> 401.
#262)

Add workspace:invite/workspace:manage to the exported TokenScope alias,
patch changeset scoped to @buildinternet/uploads only, and document the
new scopes' minting gate, workspace-boundedness, and zero file-route
access alongside the existing operator-scopes docs.
@zachdunn Zach Dunn (zachdunn) added the coderabbit:review Trigger CodeRabbit review for the PR. label Jul 18, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 18, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
uploads-api 23cb998 Commit Preview URL

Branch Preview URL
Jul 18 2026, 09:39 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 18, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
uploads-auth 01b1447 Commit Preview URL

Branch Preview URL
Jul 18 2026, 09:27 PM

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8e5045cb-3c31-4372-99ad-3cf532e44e87

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Workspace governance scopes are added to token minting, with workspace-bounded authentication for invites and token management. Routes support invite creation, active-token listing, and revocation, with fail-closed parsing and expanded authorization tests and documentation.

Changes

Workspace governance

Layer / File(s) Summary
Scope contracts and token minting
apps/api/src/auth-db.ts, apps/api/src/routes/tokens.ts, packages/uploads/src/client.ts, apps/api/src/auth-db.test.ts, apps/api/src/routes/tokens.test.ts
Adds workspace:invite and workspace:manage, validates them using workspace membership and admin roles, persists them in tokens, and keeps governance scopes out of file access parsing.
Governance authentication
apps/api/src/workspace.ts, apps/api/src/routes/me.ts, apps/api/src/routes/workspaces.ts
Adds workspace-token middleware, exports the workspace admin authorization helper, and separates fail-closed bearer-token authorization from session authentication.
Invite and token-management routes
apps/api/src/routes/workspaces.ts, docs/admin-tokens.md
Adds workspace invite creation plus active-token listing and revocation with workspace binding, redaction, validation, and documented scope behavior.
Governance route validation
apps/api/src/routes/workspace-governance-invite.test.ts, apps/api/src/routes/workspace-governance-tokens.test.ts
Tests invite attribution, scope enforcement, token lifecycle failures, workspace isolation, token redaction, and revocation selector handling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • buildinternet/uploads issue 262 — Directly covers the workspace-governance scopes, minting gates, fail-closed parsing, and management endpoints.
  • buildinternet/uploads issue 257 — Uses the same token-minting and scope-validation path extended here for workspace-governance scopes.

Possibly related PRs

  • buildinternet/uploads#112 — Extends the same token-minting and minting_user_id flow with workspace governance authorization and endpoints.
  • buildinternet/uploads#261 — Shares the auth-db.ts and POST /v1/tokens scope-plumbing changes for additional non-file scopes.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant GovernanceAuth
  participant TokenStore
  participant WorkspaceRoutes
  participant AuthService
  Client->>GovernanceAuth: Send workspace bearer token
  GovernanceAuth->>TokenStore: Load active token and scopes
  TokenStore-->>GovernanceAuth: Return workspace-bound governance token
  GovernanceAuth->>WorkspaceRoutes: Set minting user context
  WorkspaceRoutes->>AuthService: Create workspace invitation
  AuthService-->>WorkspaceRoutes: Return invitation data
  WorkspaceRoutes-->>Client: Return invitation response
Loading

Poem

A rabbit guards the workspace gate,
With tokens scoped and permissions straight.
Invites hop out, old keys retire,
Redacted lists reveal what’s required.
Fail-closed paws keep borders bright—
Governance blooms in typed moonlight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main API change: adding workspace-governance token scopes for org-admin users.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/262-workspace-governance-scopes

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/routes/workspaces.ts`:
- Around line 417-419: Update the token filter in the active-token listing
around listTokens to exclude expired records using the same expiry condition as
findActiveToken, while retaining the existing revoked_at check. Ensure only
non-revoked, non-expired tokens reach the mapping step.
- Around line 441-445: Validate that body.hashPrefix and body.label are strings
before invoking trim in the workspace route request parsing flow; non-string or
absent values should be treated as invalid or omitted according to the route’s
existing validation behavior, returning a validation error rather than throwing.
Update the selectors derived from the parsed body while preserving the JSON
parse fallback.
- Around line 76-78: Update the authorization logic around authHeader, rawToken,
and workspaceNameFromToken so any Bearer token beginning with “up_” is treated
as authoritative, even when parsing fails; return 401 instead of falling back to
session authentication, while preserving existing behavior for valid tokens and
non-up_ bearer values.
- Around line 437-452: Apply the workspace-keyed writeRateLimit middleware to
the DELETE “/:name/tokens” route alongside workspaceManageAuth, using the
route’s workspace name as the limiter key. Keep the existing selector validation
and revokeToken flow unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 10524426-e7c3-49fa-8199-cc5114f234ef

📥 Commits

Reviewing files that changed from the base of the PR and between 0d1db80 and 01b1447.

📒 Files selected for processing (12)
  • .changeset/workspace-governance-scope-widening.md
  • apps/api/src/auth-db.test.ts
  • apps/api/src/auth-db.ts
  • apps/api/src/routes/me.ts
  • apps/api/src/routes/tokens.test.ts
  • apps/api/src/routes/tokens.ts
  • apps/api/src/routes/workspace-governance-invite.test.ts
  • apps/api/src/routes/workspace-governance-tokens.test.ts
  • apps/api/src/routes/workspaces.ts
  • apps/api/src/workspace.ts
  • docs/admin-tokens.md
  • packages/uploads/src/client.ts

Comment thread apps/api/src/routes/workspaces.ts Outdated
Comment on lines +76 to +78
const authHeader = c.req.header("Authorization");
const rawToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
if (rawToken && workspaceNameFromToken(rawToken) !== undefined) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Treat every up_ bearer as authoritative.

Line 78 falls back to session auth when an explicitly supplied up_ token is malformed and cannot be parsed. A valid cookie could therefore authorize the request instead of returning 401, contrary to the documented fail-closed precedence.

Proposed fix
-    if (rawToken && workspaceNameFromToken(rawToken) !== undefined) {
+    if (rawToken?.startsWith("up_")) {

Add a valid-session test using a malformed bearer such as up_.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const authHeader = c.req.header("Authorization");
const rawToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
if (rawToken && workspaceNameFromToken(rawToken) !== undefined) {
const authHeader = c.req.header("Authorization");
const rawToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
if (rawToken?.startsWith("up_")) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/workspaces.ts` around lines 76 - 78, Update the
authorization logic around authHeader, rawToken, and workspaceNameFromToken so
any Bearer token beginning with “up_” is treated as authoritative, even when
parsing fails; return 401 instead of falling back to session authentication,
while preserving existing behavior for valid tokens and non-up_ bearer values.

Comment on lines +417 to +419
const tokens = (await listTokens(c.env.DB, name))
.filter((token) => token.revoked_at === null)
.map((token) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exclude expired tokens from the active-token listing.

Lines 417–419 only filter revoked records, so expired tokens are still returned as active. Apply the same expiry condition used by findActiveToken.

Proposed fix
+  const now = new Date().toISOString();
   const tokens = (await listTokens(c.env.DB, name))
-    .filter((token) => token.revoked_at === null)
+    .filter(
+      (token) =>
+        token.revoked_at === null &&
+        (token.expires_at === null || token.expires_at > now),
+    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const tokens = (await listTokens(c.env.DB, name))
.filter((token) => token.revoked_at === null)
.map((token) => ({
const now = new Date().toISOString();
const tokens = (await listTokens(c.env.DB, name))
.filter(
(token) =>
token.revoked_at === null &&
(token.expires_at === null || token.expires_at > now),
)
.map((token) => ({
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/workspaces.ts` around lines 417 - 419, Update the token
filter in the active-token listing around listTokens to exclude expired records
using the same expiry condition as findActiveToken, while retaining the existing
revoked_at check. Ensure only non-revoked, non-expired tokens reach the mapping
step.

Comment on lines +437 to +452
workspaces.delete("/:name/tokens", workspaceManageAuth(), async (c) => {
const name = c.req.param("name");
requireWorkspaceName(name);

const body = await c.req
.json<{ hashPrefix?: string; label?: string }>()
.catch(() => ({}) as { hashPrefix?: string; label?: string });
const hashPrefix = body.hashPrefix?.trim();
const label = body.label?.trim();
if (!hashPrefix && !label) {
throw new ValidationError("hashPrefix or label required", {
code: "hash_prefix_or_label_required",
});
}

const result = await revokeToken(c.env.DB, name, { hashPrefix, label });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Rate-limit token revocation by workspace.

This mutating endpoint reaches D1 without the workspace write limiter, allowing unbounded selector probes and revoke attempts.

Proposed fix
   const name = c.req.param("name");
   requireWorkspaceName(name);
+  if (!(await allowWrite(c.env, name))) {
+    throw new RateLimitedError("rate limit exceeded");
+  }

As per coding guidelines, “Mutating routes must use the writeRateLimit middleware keyed by workspace.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/workspaces.ts` around lines 437 - 452, Apply the
workspace-keyed writeRateLimit middleware to the DELETE “/:name/tokens” route
alongside workspaceManageAuth, using the route’s workspace name as the limiter
key. Keep the existing selector validation and revokeToken flow unchanged.

Source: Coding guidelines

Comment thread apps/api/src/routes/workspaces.ts Outdated
Comment on lines +441 to +445
const body = await c.req
.json<{ hashPrefix?: string; label?: string }>()
.catch(() => ({}) as { hashPrefix?: string; label?: string });
const hashPrefix = body.hashPrefix?.trim();
const label = body.label?.trim();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate selector types before calling .trim().

JSON is untrusted despite the generic annotation. Values such as { "hashPrefix": 123 } throw at Line 444 and return a 500 instead of a validation error.

Proposed fix
-  const hashPrefix = body.hashPrefix?.trim();
-  const label = body.label?.trim();
+  const hashPrefix =
+    typeof body.hashPrefix === "string" ? body.hashPrefix.trim() : undefined;
+  const label = typeof body.label === "string" ? body.label.trim() : undefined;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const body = await c.req
.json<{ hashPrefix?: string; label?: string }>()
.catch(() => ({}) as { hashPrefix?: string; label?: string });
const hashPrefix = body.hashPrefix?.trim();
const label = body.label?.trim();
const body = await c.req
.json<{ hashPrefix?: string; label?: string }>()
.catch(() => ({}) as { hashPrefix?: string; label?: string });
const hashPrefix =
typeof body.hashPrefix === "string" ? body.hashPrefix.trim() : undefined;
const label = typeof body.label === "string" ? body.label.trim() : undefined;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/workspaces.ts` around lines 441 - 445, Validate that
body.hashPrefix and body.label are strings before invoking trim in the workspace
route request parsing flow; non-string or absent values should be treated as
invalid or omitted according to the route’s existing validation behavior,
returning a validation error rather than throwing. Update the selectors derived
from the parsed body while preserving the JSON parse fallback.

Excludes expired-but-unrevoked tokens from self-serve GET /:name/tokens,
validates DELETE /:name/tokens body fields as strings before trimming
(avoids a 500 on non-string input), treats any up_-shaped bearer as
authoritative even when it fails to parse (no session fallback), and
adds the missing write rate limiter to the token-revoke route.
@zachdunn
Zach Dunn (zachdunn) merged commit dd388a9 into main Jul 18, 2026
4 checks passed
@zachdunn
Zach Dunn (zachdunn) deleted the claude/262-workspace-governance-scopes branch July 18, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

coderabbit:review Trigger CodeRabbit review for the PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Workspace-governance token scopes for the org-admin tier (workspace:invite / workspace:manage)

1 participant