feat(api,auth): self-serve workspace deletion + orphaned auth-org sweep (#249, #250)#255
Conversation
…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.
Deploying with
|
| 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 |
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe 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. ChangesWorkspace lifecycle management
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
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Deploying with
|
| 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 |
There was a problem hiding this comment.
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
📒 Files selected for processing (12)
apps/api/src/org-workspaces.test.tsapps/api/src/org-workspaces.tsapps/api/src/retention-sweep.tsapps/api/src/routes/admin.tsapps/api/src/routes/workspaces.test.tsapps/api/src/routes/workspaces.tsapps/api/src/workspace.tsapps/api/test/retention-sweep.test.tsapps/auth/src/internal-routes.test.tsapps/auth/src/internal-routes.tsdocs/deletion.mddocs/ops.md
| 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(), | ||
| }); | ||
| } |
There was a problem hiding this comment.
🗄️ 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.
| 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.
| 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 }); |
There was a problem hiding this comment.
🗄️ 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) => { |
There was a problem hiding this comment.
🔒 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
| | ------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | 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. | |
There was a problem hiding this comment.
📐 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'
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 — stampsdeletedAt/purgeAt(14-day grace) and denies access at the record layer. 403not_ownerunless the record isselfServeandcreatedByUserIdmatches the caller; communal workspace excluded; 404 for unknown/purged names; 409already_deletedon repeat.POST /v1/workspaces/:name/restore: same ownership gate; mirrors the admin restore semantics exactly (409not_deleted, 410grace_expired; unparseablepurgeAtstays restorable).stampSoftDelete/isPastGrace/stampRestorehelpers used by both the admin and self-serve paths so they can't drift.Orphaned auth-org sweep (#250)
GET /orgs(id+slug) and?force=1onDELETE /internal/orgs/:slugto bypass the multi-member 409 (member rows deleted first). Internal surface only (service-binding gated).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 (orgsSwepton the sweep result).Notes
GET /internal/orgsis unpaginated — fine at current org counts; flag if that should cursor now rather than later.@uploads/api/@uploads/authare 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
Bug Fixes
Documentation