Skip to content

feat(api,auth): self-serve workspace deletion + orphaned auth-org sweep (#249, #250)#255

Merged
Zach Dunn (zachdunn) merged 2 commits into
mainfrom
claude/self-serve-delete-orphan-sweep
Jul 18, 2026
Merged

feat(api,auth): self-serve workspace deletion + orphaned auth-org sweep (#249, #250)#255
Zach Dunn (zachdunn) merged 2 commits into
mainfrom
claude/self-serve-delete-orphan-sweep

Conversation

@zachdunn

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

Copy link
Copy Markdown
Member

Closes #249, closes #250. Builds on the deletion policy from #247 (docs/deletion.md, PR #248).

Self-serve workspace deletion (#249)

  • DELETE /v1/workspaces/:name (session-authed): soft delete only — stamps deletedAt/purgeAt (14-day grace) and denies access at the record layer. 403 not_owner unless the record is selfServe and createdByUserId matches the caller; communal workspace excluded; 404 for unknown/purged names; 409 already_deleted on repeat.
  • POST /v1/workspaces/:name/restore: same ownership gate; mirrors the admin restore semantics exactly (409 not_deleted, 410 grace_expired; unparseable purgeAt stays restorable).
  • No hard/force mode and no slug-freeing on the member surface, per policy. The stamp/restore logic is factored into shared stampSoftDelete/isPastGrace/stampRestore helpers used by both the admin and self-serve paths so they can't drift.
  • Web console UI deferred — API only in this PR.

Orphaned auth-org sweep (#250)

  • Auth worker: new internal GET /orgs (id+slug) and ?force=1 on DELETE /internal/orgs/:slug to bypass the multi-member 409 (member rows deleted first). Internal surface only (service-binding gated).
  • The daily retention sweep now cross-references every org slug against ws:<slug>: orgs with no KV key or only a purged tombstone are force-deleted. Soft-deleted workspaces still in grace are never treated as orphans (restore stays intact); the communal slug is skipped; AUTH outages and per-org failures are isolated and logged (orgsSwept on the sweep result).

Notes

  • GET /internal/orgs is unpaginated — fine at current org counts; flag if that should cursor now rather than later.
  • No D1 migrations; @uploads/api/@uploads/auth are changeset-ignored, so no changeset.

Tests

pnpm test: 126 files, 1400 tests passing (+38 new across self-serve routes, org helpers, sweep, and auth internal routes). Typecheck and oxfmt clean.

Summary by CodeRabbit

  • New Features

    • Added self-service workspace deletion with a grace period and owner-only access.
    • Added workspace restoration during the grace period.
    • Added orphaned organization cleanup during retention sweeps.
    • Added support for force-deleting organizations when required.
  • Bug Fixes

    • Standardized deletion and restoration behavior across administrative and self-service workflows.
  • Documentation

    • Documented workspace deletion, restoration, organization cleanup, and retention behavior.

…ep (#249, #250)

Self-serve owners can soft-delete and restore their own workspaces
(session-authed DELETE/POST /v1/workspaces/:name[/restore]) — soft only,
14-day grace, never slug-freeing, sharing stampSoftDelete/stampRestore
helpers with the admin path. The daily retention sweep now also removes
auth-side orgs whose workspace KV key is gone or a purged tombstone,
via a new internal GET /orgs and force org delete on the auth worker.
@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-auth c4b0161 Commit Preview URL

Branch Preview URL
Jul 18 2026, 06:23 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: 08a994d4-36ab-4e27-a2ac-588804b2fdde

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

The PR adds shared workspace soft-delete helpers, self-serve deletion and restore endpoints, and an orphan-organization retention sweep backed by new auth-worker listing and forced-deletion APIs. Tests and operational documentation cover lifecycle behavior, error handling, and cleanup outcomes.

Changes

Workspace lifecycle management

Layer / File(s) Summary
Shared soft-delete semantics
apps/api/src/workspace.ts, apps/api/src/routes/admin.ts, docs/deletion.md
Workspace deletion, grace validation, and restoration use shared stamping helpers across admin flows.
Self-serve workspace deletion and restore
apps/api/src/routes/workspaces.ts, apps/api/src/routes/workspaces.test.ts, docs/ops.md, docs/deletion.md
Owner-gated endpoints soft-delete and restore self-serve workspaces with communal-workspace, ownership, tombstone, and grace-period checks.
Orphan organization retention sweep
apps/api/src/org-workspaces.ts, apps/api/src/retention-sweep.ts, apps/auth/src/internal-routes.ts, apps/api/test/retention-sweep.test.ts, apps/auth/src/internal-routes.test.ts
Auth organizations can be listed and force-deleted; the retention sweep removes organizations lacking active workspace records and reports isolated failures.

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

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant WorkspaceRoutes
  participant WorkspaceHelpers
  participant Registry
  Client->>WorkspaceRoutes: DELETE /v1/workspaces/:name
  WorkspaceRoutes->>Registry: load ws:<name>
  Registry-->>WorkspaceRoutes: owned self-serve record
  WorkspaceRoutes->>WorkspaceHelpers: stampSoftDelete(record)
  WorkspaceHelpers-->>WorkspaceRoutes: deletedAt and purgeAt
  WorkspaceRoutes->>Registry: write ws:<name>
  WorkspaceRoutes-->>Client: soft-delete response
  Client->>WorkspaceRoutes: POST /v1/workspaces/:name/restore
  WorkspaceRoutes->>WorkspaceHelpers: isPastGrace(purgeAt)
  WorkspaceHelpers-->>WorkspaceRoutes: grace status
  WorkspaceRoutes->>WorkspaceHelpers: stampRestore(record)
  WorkspaceRoutes->>Registry: write restored ws:<name>
  WorkspaceRoutes-->>Client: restored workspace response
Loading

Poem

A rabbit stamps the dates just right,
Then hops through grace by day and night.
Lost orgs are found and swept away,
While tombstones keep their names in play.
“Restore!” I cheer—then munch a byte.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% 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 summarizes the main changes: self-serve workspace deletion and the orphaned auth-org sweep across api/auth.
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/self-serve-delete-orphan-sweep

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

@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 c4b0161 Commit Preview URL

Branch Preview URL
Jul 18 2026, 06:23 PM

@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/org-workspaces.ts`:
- Around line 139-146: Update deleteOrg to capture the response from
env.AUTH.fetch, validate response.ok, and throw an appropriate AppError such as
ServiceUnavailableError when the auth deletion returns an unsuccessful HTTP
status; preserve the existing DELETE request and successful completion behavior.

In `@apps/api/src/retention-sweep.ts`:
- Around line 152-164: Prevent the retention sweep from deleting newly
provisioned organizations during the creation window. In the org iteration
around listOrgs and deleteOrg, include each org’s createdAt and only
force-delete orphan records after the configured orphan-age threshold has
elapsed or a durable provisioning marker confirms provisioning is complete;
preserve existing tombstone and communalWorkspace exclusions.

In `@apps/api/src/routes/workspaces.ts`:
- Line 164: Apply the workspace-keyed writeRateLimit middleware to both
lifecycle routes: the workspaces.delete route and the restoration route near the
second referenced location. Keep the existing authentication and handler
behavior unchanged while ensuring each mutating route includes writeRateLimit in
its middleware chain.

In `@docs/deletion.md`:
- Line 23: Update the “Slug reuse” policy label in the documentation table to
clarify that slugs are never freed by finalization, while preserving the
explanation that admin hard deletion remains the deliberate escape hatch.
🪄 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: f78060d7-48f8-4b8d-9bd3-fcd251b598b9

📥 Commits

Reviewing files that changed from the base of the PR and between e0cc4c9 and 1423d7f.

📒 Files selected for processing (12)
  • apps/api/src/org-workspaces.test.ts
  • apps/api/src/org-workspaces.ts
  • apps/api/src/retention-sweep.ts
  • apps/api/src/routes/admin.ts
  • apps/api/src/routes/workspaces.test.ts
  • apps/api/src/routes/workspaces.ts
  • apps/api/src/workspace.ts
  • apps/api/test/retention-sweep.test.ts
  • apps/auth/src/internal-routes.test.ts
  • apps/auth/src/internal-routes.ts
  • docs/deletion.md
  • docs/ops.md

Comment on lines +139 to 146
export async function deleteOrg(env: Env, slug: string, opts?: { force?: boolean }): Promise<void> {
const url = new URL(`${INTERNAL_ORIGIN}/internal/orgs/${encodeURIComponent(slug)}`);
if (opts?.force) url.searchParams.set("force", "1");
await env.AUTH.fetch(url, {
method: "DELETE",
headers: internalHeaders(),
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject unsuccessful auth deletion responses.

fetch() resolves for HTTP errors, so the sweep currently records deleted: true after a 409 or 500. Check response.ok and throw an AppError such as ServiceUnavailableError.

Proposed fix
-  await env.AUTH.fetch(url, {
+  const response = await env.AUTH.fetch(url, {
     method: "DELETE",
     headers: internalHeaders(),
   });
+  if (!response.ok) {
+    throw new ServiceUnavailableError("auth service failed to delete organization", {
+      code: "auth_delete_failed",
+      details: { status: response.status },
+    });
+  }
📝 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
export async function deleteOrg(env: Env, slug: string, opts?: { force?: boolean }): Promise<void> {
const url = new URL(`${INTERNAL_ORIGIN}/internal/orgs/${encodeURIComponent(slug)}`);
if (opts?.force) url.searchParams.set("force", "1");
await env.AUTH.fetch(url, {
method: "DELETE",
headers: internalHeaders(),
});
}
export async function deleteOrg(env: Env, slug: string, opts?: { force?: boolean }): Promise<void> {
const url = new URL(`${INTERNAL_ORIGIN}/internal/orgs/${encodeURIComponent(slug)}`);
if (opts?.force) url.searchParams.set("force", "1");
const response = await env.AUTH.fetch(url, {
method: "DELETE",
headers: internalHeaders(),
});
if (!response.ok) {
throw new ServiceUnavailableError("auth service failed to delete organization", {
code: "auth_delete_failed",
details: { status: response.status },
});
}
}
🤖 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/org-workspaces.ts` around lines 139 - 146, Update deleteOrg to
capture the response from env.AUTH.fetch, validate response.ok, and throw an
appropriate AppError such as ServiceUnavailableError when the auth deletion
returns an unsuccessful HTTP status; preserve the existing DELETE request and
successful completion behavior.

Comment on lines +152 to +164
const orgs = await listOrgs(env);
for (const org of orgs) {
if (org.slug === communalWorkspace) continue;
try {
const record = await env.REGISTRY.get<WorkspaceRecord | PurgedTombstone>(
`ws:${org.slug}`,
"json",
);
const isOrphan = !record || isPurgedTombstone(record);
if (!isOrphan) continue;

await deleteOrg(env, org.slug, { force: true });
orgsSwept.push({ slug: org.slug, deleted: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not delete newly provisioned organizations immediately.

Workspace creation performs org creation before the KV write, so this pass can observe that in-flight org as orphaned, force-delete it, and leave a live workspace without an auth organization. Include createdAt and require an orphan-age threshold or durable provisioning marker before deletion.

🤖 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/retention-sweep.ts` around lines 152 - 164, Prevent the
retention sweep from deleting newly provisioned organizations during the
creation window. In the org iteration around listOrgs and deleteOrg, include
each org’s createdAt and only force-delete orphan records after the configured
orphan-age threshold has elapsed or a durable provisioning marker confirms
provisioning is complete; preserve existing tombstone and communalWorkspace
exclusions.

* record back — never a hard/force mode, never slug-freeing. See
* docs/deletion.md: member-facing deletes are soft, always.
*/
workspaces.delete("/:name", sessionAuth, requireSessionUser, async (c) => {

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

Apply writeRateLimit to both lifecycle routes.

Deletion and restoration are mutating operations but omit the required workspace-keyed write limiter.

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

Also applies to: 206-206

🤖 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` at line 164, Apply the workspace-keyed
writeRateLimit middleware to both lifecycle routes: the workspaces.delete route
and the restoration route near the second referenced location. Keep the existing
authentication and handler behavior unchanged while ensuring each mutating route
includes writeRateLimit in its middleware chain.

Source: Coding guidelines

Comment thread docs/deletion.md Outdated
| ------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Workspace deletion (`DELETE /admin/workspaces/:name`) | **Soft** by default, 14-day grace | Tombstones the `ws:<name>` KV record; access denied immediately; data (R2, metadata, galleries) retained; `POST /admin/workspaces/:name/restore` undeletes within the window. The daily retention cron finalizes past `purgeAt` with the full hard teardown. |
| Workspace deletion, break-glass (`?hard=1`) | **Hard** | Immediate permanent teardown (R2 objects, file_metadata, galleries, auth org, KV record). Non-empty workspaces additionally require `force=1`. The only path that frees a slug. |
| Slug reuse | **Never freed** | After finalization the `ws:<name>` key retains a permanent `{ status: "purged" }` tombstone, so registration keeps rejecting the name. This closes the squatting vector where old public `storage.uploads.sh/<name>/…` URLs embedded in merged PRs could be re-registered by a different owner. Admin hard delete is the deliberate escape hatch. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Qualify the “Never freed” policy.

Admin hard deletion explicitly frees the slug, so label this “Never freed by finalization” or similar.

🤖 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 `@docs/deletion.md` at line 23, Update the “Slug reuse” policy label in the
documentation table to clarify that slugs are never freed by finalization, while
preserving the explanation that admin hard deletion remains the deliberate
escape hatch.

- deleteOrg surfaces non-ok auth responses (404 stays idempotent-success)
  so the sweep can't report an org deleted when the delete failed
- orphan sweep age-gates orgs (24h, via createdAt on GET /internal/orgs)
  so a sweep landing between org provisioning and the ws: KV write can't
  delete a brand-new org mid-signup; unparseable/missing createdAt skips
- self-serve delete/restore routes gain the shared WRITE_LIMITER guard
- docs: slug-reuse label clarified to 'never freed by finalization'
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.

chore(auth): sweep orphaned auth orgs left behind by workspace teardown feat(api): self-serve workspace deletion (soft-delete only)

1 participant