Repository navigation
feat(api)!: one endpoint per account: DELETE only deletes; profile and flags fold into /accounts/{account_id} - #675
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
|
Claude finished @alukach's task in 20s —— View job ✅ No blocking issues — safe to merge. I read the operations, exchange route, action and key-table changes. I did not run the type-check or jest. The PR says they haven't been run since the rebase, so CI is the check. Non-blocking notes:
Simplify (ponytail)
DocsThe description names the docs.source.coop page and the data-proxy ADRs it checked, and 💰 Estimated review cost: $0.15 · 0m19s · 6 turns |
alukach
force-pushed
the
feat/api-delete-only-deletes
branch
from
October 16, 2026 02:31
d6ca7ca to
810d43e
Compare
alukach
force-pushed
the
feat/api-patch-account
branch
from
October 16, 2026 02:31
2fbecad to
d455f8d
Compare
alukach
added a commit
that referenced
this pull request
Oct 16, 2026
Follows #667, now merged. Writes down the API conventions this round of PRs settled on, so the next endpoint follows them without anyone reconstructing them from the code. ## What - **`API.md`** at the repository root, beside `CONTRIBUTING.md`: - **One operation, thin adapters:** what an operation decides, what a route and a server action may do, and the exceptions for routes no session can call. - **Documented where it's defined:** `registerPath` beside the handler, the coverage test and its `UNREGISTERED` allowlist, published prose, explicit `HEAD`, `bearer` and `optionalBearer`, `internal`, and `deprecated`. - **Resources and methods:** URL and field naming, and a table of what each method means and answers. Deactivating is `PATCH { "disabled": true }`, never `DELETE`, and one resource has one edit endpoint. - **Lists:** `{items, next_cursor}`, never a bare array. - **Errors:** the shared shape and when each status applies, the 500 `internal` error and the trust exchange's `{trusted: false}` 403, authorizing before validating where the permission doesn't depend on the input, and answering 404 for things the caller may not know of. - **Writes:** carry only the fields that changed, and what `accountsTable.update` always writes. - **Compatibility:** the data proxy is pinned by a test, the CLI follows the reference, and anyone else gets a traffic check, then deprecation rather than removal. - **`CLAUDE.md`** (and `AGENTS.md`, which links to it) gets a short **API** section telling an agent to read `API.md` before touching `/api/v1`. - **`CONTRIBUTING.md`**'s "Server Actions & API Routes" paragraph said both call `isAuthorized()` themselves. It now describes them as adapters over one operation and points at `API.md`. ## Where it lives A root `API.md` rather than more of `CLAUDE.md`: these are conventions for whoever writes an endpoint, person or agent, and much of it is visible in the code once you know where to look, which is what `CLAUDE.md` says it isn't for. `CLAUDE.md` carries the pointer, so agents still load it at the right moment. `CONTRIBUTING.md` links to it. ## Merge order The guide describes the API as it stands once the open PRs from this round merge, and names things they add: - `optionalBearer`, `internal` and `deprecated` endpoints (#667) - `data-proxy-contract.test.ts` (#671) - explicit `HEAD` (#672) - `pageSchema` and `onePage` (#673) - one `PATCH` per account (#674) - `DELETE` that only deletes (#675) Merge it after them, or trim the line that names whichever of them doesn't land. ## Where the code doesn't follow the guide yet The guide states the conventions, not the code's progress toward them. Known gaps, tracked outside the docs: - The `/data-connections` routes and the data-connection actions still read tables and call `isAuthorized()` directly: #593. - The credentials, profile-image and `createAccount` actions still call `isAuthorized()` directly: #592 and #594. - `updateProduct`, `deleteProduct` and `updateMembership` validate the body before authorizing, so a caller who may not act sees field errors instead of a 403. - `accountsTable.update` always writes `name`, `type` and whole metadata maps, so `updateAccount` can undo a concurrent edit. ## Docs, ADRs and `llms.txt` Repository-internal guidance only: no user-facing flow changes, so docs.source.coop and `llms.txt` are unaffected, and it records no decision shared with the data proxy beyond the existing contract test, so no data.source.coop ADR changes. ## Testing Documentation only; no code changed. Each claim in `API.md` was checked against the code after a review pass found several the code didn't hold to (`route-coverage.test.ts`, `accountsTable.update`, `withApiSession`'s error handling, the revocations and trust-exchange routes). Checked that `AGENTS.md` still resolves to `CLAUDE.md` and that the prose in `API.md` wraps at 80, as the repository's Markdown does. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A person or an organization is disabled with PATCH { disabled: true }, and DELETE refuses them with a 409; a service account is deleted, by whoever manages its owner, with its memberships, its trusts and every key it still had, which is revoked. deleteAccount runs on the operations layer, so the endpoint is documented, and the settings page's delete goes through it too, so it revokes the keys it used to leave behind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deleteAccount disables the service account before tearing it down, so no grant, key or trust can be added and no key exchanges while it runs. It then deletes the memberships and the key rows in parallel, and finally the account row. Keys are deleted rather than revoked, so a service account that later takes the same id doesn't show its predecessor's keys, and a key that vanishes mid-delete no longer aborts the delete halfway with a ConditionalCheckFailedException.
The key exchange now refuses a key whose created_at is earlier than its account's. That ties a key to the account it was issued to, so a key the cleanup misses (a race, GSI lag, a delete path that skips deleteAccount) can't sign in as a re-created account with the same id.
setProductAccess refuses a disabled service account, as issueApiKey and addGithubTrust already do.
Authorization now comes before the distinctions an unauthorized caller shouldn't see: a service account the caller can't manage is a 404, as in getAccountProfile, and the 409 that says to PATCH { "disabled": true } goes only to a caller who may disable that person or organization.
deleteAccount returns OperationResult<ServiceAccount>, so the action's isServiceAccount check after the delete is gone. The action shows its own message on a refusal rather than the API's, and the deleted account's settings page is revalidated along with its owner's list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_id}
GET /accounts/{account_id} answers the whole record to whoever may read it and, in place of GET /accounts/{account_id}/profile, the public profile to everyone else, signed in or not. A disabled or service account stays a 404 to whoever may not read it, as the profile route answered.
GET /accounts/{account_id}/flags is gone: the flags are already in the record GET /accounts/{account_id} answers, to the same readers. PUT /accounts/{account_id}/flags becomes a `flags` field on PATCH /accounts/{account_id}, taking PutAccountFlags the way `disabled` takes DisableAccount, and still refusing `admin` to a service account. The account-flags form's server action calls updateAccount. getAccountProfile, getAccountFlags and updateAccountFlags are gone from the operations layer.
public/llms.txt points its public-profile line at /accounts/{account_id}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach
force-pushed
the
feat/api-delete-only-deletes
branch
from
October 16, 2026 04:09
810d43e to
3f45711
Compare
…wers anyone
API.md's rule for published summaries and descriptions now spells out how to write them: lead with a verb, don't drop words, one rule per sentence, name who gets what actively, 25 words or fewer a sentence, and address the caller as "you". The rules are borrowed from ASD-STE100 without its controlled vocabulary. The GET, PATCH and DELETE descriptions on /accounts/{account_id} are rewritten to them; DELETE's no longer says keys are revoked, and now names its 404 and 403.
GET /accounts/{account_id} declares optionalBearer rather than bearer, since it answers anonymous callers with the public profile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…GET's 404
setProductAccess no longer refuses a disabled service account outright. serviceAccountGrantProblem already refuses a new grant or a role change to one, and the extra guard also refused `none`, so a manager couldn't pull a disabled account's product access without re-enabling it first.
GET /accounts/{account_id}'s description said a service account's manager reads its record; only an admin passes GetAccount for a service account, so it now says a service account is a 404 unless you are an admin. deleteAccount's comment no longer claims to match it.
deleteAccount's disable writes only `disabled`, not the record it read, as updateAccount writes only what it changes.
API.md's methods table keeps its PUT row, with PUT /data-connections/{id} as the example, since that endpoint is still served.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…like
GET /accounts/{account_id} answers whoever may not read the whole record with a PublicAccount: account_id, type, name, disabled, metadata_public and the computed profile_image. It replaces the flattened AccountProfileResponse, which had no account_id, so an account embedded in a product or a list couldn't be linked to.
Products embed that same view. shown() already cut a product's account down for whoever may not read it, but cast the result back to Account, so the OpenAPI document promised flags, created_at and updated_at it didn't send. ProductResponseSchema now takes the Product name in the document and declares `account` as Account | PublicAccount; the Product type the app reads from the database is unchanged.
The public view carries profile_image because it has no emails left to find a Gravatar with. avatarUrl uses it, and the account links, hover card and avatar take Account | PublicAccount, so a signed-out reader of /products sees an individual's Gravatar in the hover card. AccountLinks gets a PublicView story.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach
marked this pull request as ready for review
October 16, 2026 04:37
alukach
added a commit
that referenced
this pull request
Oct 16, 2026
main's updateAccount and deleteAccount (#674, #675) now edit and delete service accounts, so this branch drops its own updateServiceAccount, deleteServiceAccount and UpdateServiceAccountSchema, and PATCH/DELETE /accounts/{account_id} are main's. The rename and disable actions call updateAccount. Grants use product_id (#680). setProductAccess keeps going through the membership operations, which refuse a disabled service account through serviceAccountGrantProblem (#666). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach
added a commit
that referenced
this pull request
Oct 17, 2026
…665) Closes #594. Part of #588. ## What Service accounts and their API keys over `/api/v1`, documented at `/api/docs`: | Endpoint | Operation | |---|---| | `GET /accounts/{owner}/service-accounts` | `listServiceAccounts` → `{items, next_cursor: null}` | | `POST /accounts/{owner}/service-accounts` | `createServiceAccount` — `local_id`, `name`, `github_subjects[]`, `grants[{product_id, role}]`, optional `key{label, expires_at}`; the response carries the issued key when one is asked for | | `GET /accounts/{sa}/trusts` | `listTrusts` → `{items, next_cursor: null}` | | `POST /accounts/{sa}/trusts` | `addGithubTrust` — `{subject}` | | `DELETE /accounts/{sa}/trusts?issuer=&subject=` | `removeTrust` — 404 for a trust the account doesn't have; returns the trust as it was | | `GET /accounts/{sa}/keys` | `listKeys` → `{items, next_cursor: null}`, revoked and expired keys included, never a hash | | `POST /accounts/{sa}/keys` | `issueKey` — `{label, expires_at}` → 201 with `key`, the only time it appears | | `PATCH /accounts/{sa}/keys/{key_id}` | `updateKey` — `{expires_at?, revoked?: true}` | `src/lib/operations/service-accounts.ts` holds the rules the issue names: `GITHUB_ACTIONS_SUBJECT_REGEX` (as a Zod schema shared with the OpenAPI doc), the read/write-only grant roles, `managedServiceAccount` (every "not yours / missing / not a service account" answers 404, keeping the single answer it gave before), and the rule that a service account can't own another. The server actions in `src/lib/actions/service-accounts.ts` now adapt FormData into those operations, and the service-accounts settings page lists through `listServiceAccounts`. Editing and deleting a service account are main's `PATCH` and `DELETE /accounts/{account_id}` (#674, #675): rename and disable call `updateAccount`, and delete calls `deleteAccount`, so this PR adds no operation of its own for them. Trusts are returned through `AccountTrustSchema`, so the table's `identity` sort key stays out of responses. `setProductAccess` now calls `inviteMember` / `updateMembership` / `revokeMembership`, and creation grants each product through `inviteMember` too, so every grant passes the same authorization, duplicate check and audit log; so product access over the API is the product members endpoints from #589 with role `read_data` or `write_data`, and gets no endpoint of its own. API keys were first left to #570 and #580–#582, which built the keys and their settings UI but no API; with those merged, this PR adds the API for them (scope noted on #594). `issueApiKey`, `revokeApiKey`, `setApiKeyExpiry` and the create action now map form fields onto `issueKey`, `updateKey` and `createServiceAccount`, so no action touches the keys table, and the key rules (hash-only storage, the six-character hint, refusing a disabled account, the expiry range) are tested once, on the operations. ## Worth a look - Revoking is `PATCH {"revoked": true}`, not `DELETE`: the record stays, with `revoked_at`, `revoked_via` and `revoked_by`, as disabling an account is a `PATCH`. `revoked: false` is refused with 400, since a revoked key can't be restored; revoking one twice is 409, and a PATCH with neither field is 400. - `expires_at` is an ISO time, at most 3650 days away, or `null` for a key that lasts until revoked. It is required on issue, so a caller chooses no-expiry deliberately. The settings form still takes days and converts them. - A key requested with `POST /service-accounts` is validated with the rest of the body, before anything is written, and stored after the grants and trusts. - `createServiceAccount` checks every grant against `InviteMembership` before it writes the account, so a grant the caller may not make leaves nothing behind and a retry with the same `local_id` succeeds. `inviteMember` can refuse nothing else afterwards: the account is new and enabled, and the grants are deduplicated first. - Deleting a service account is main's `deleteAccount` on both the API and the settings page. It deletes the account's API keys rather than revoking them, and the key exchange also refuses a key older than its account, so a key from a deleted account can't sign in as the next account to take its id. ADR 013 still holds. - `setProductAccess` refuses a new grant to a disabled service account (#666) through `inviteMember` and `updateMembership`, which both check `serviceAccountGrantProblem`. Revoking still works while the account is disabled. - Service-account lists use the `{items, next_cursor}` page shape, as products and data connections do, so pagination can arrive later without a breaking change. ## Docs, ADRs, llms.txt - docs.source.coop `docs/using-source/upload/with-service-account.md` walks through the settings UI, which is unchanged, so it still holds; it could later mention issuing a key over the API, which #595's reference covers meanwhile. The API reference is #595's to publish. - data.source.coop ADRs: I checked 005 (authorization), 010 (account-owned roles) and 013 (API keys). ADR 013 has source.coop generate the key, store only its hash, show it once, refuse a disabled service account and keep expiry editable, with `key_id` as the public handle; the key endpoints do exactly that, so this PR implements existing decisions and moves none. - `public/llms.txt`: no change. These endpoints all need auth, so they can't go on the file's no-auth URL list, and its existing "API reference" line points at `/api/openapi`, which now includes them. ## Storybook No component changed. The settings page now gets its list from the operation, and the key actions keep their form fields and state shapes, so `IssueApiKeyDialog` and the rest are untouched. ## Testing - `npm run type-check`: clean. - `npx jest`: 96 suites, 1068 tests pass. That includes the service-account action tests, now run through the operations; the key action tests, now checking only the form mapping; new operation tests (list authz, JSON create, the 404 on an unknown trust, a refused grant or bad key writing nothing, issuing, listing, expiring and revoking keys); and the OpenAPI validity and route-coverage tests. - Not exercised against a running server. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows #674. Items 3 and on from the follow-ups discussed on #667:
/accounts/{account_id}becomes the one endpoint for an account.DELETEonly deletes, and the/profileand/flagssub-routes fold into itsGETandPATCH. #665 needs to rebase onto it (see below).What
GET /accounts/{account_id}PublicAccountview to everyone else, signed in or notGET /accounts/{account_id}/profileGET /accounts/{account_id}answers itGET /accounts/{account_id}/flagsGET /accounts/{account_id}answers, to the same readersPUT /accounts/{account_id}/flagsPATCH /accounts/{account_id}takesflags(admin)DELETE /accounts/{account_id}, service accountdeleteAccountDELETE /accounts/{account_id}, person or organizationPATCH { "disabled": true }; a person or an organization is never deletedGETanswers two shapesgetAccountabsorbsgetAccountProfile:GetAccountgets theAccountrecord, elseGetAccountProfilegets aPublicAccount, else a disabled or service account is a 404 and anything else is refused. The reference documents the 200 asanyOfthe two, and the route declaresoptionalBearer, since it answers anonymous callers. Who sees a 404 is unchanged from the profile route.PublicAccountis the record without its private fields:account_id,type,name,disabledandmetadata_public, plus a computedprofile_image. It replaces the flattenedAccountProfileResponse, which had noaccount_id, so it couldn't serve as the account embedded in a product or a list.Products embed the same view.
shown()already cut a product's account down for a caller who may not read it, but cast the result toAccount, so the reference promisedflags,created_atandupdated_atthat weren't sent.ProductResponseSchemanow carries theProductname in the reference and declaresaccountasAccount | PublicAccount. TheProducttype the app reads from the database is unchanged.flagsonPATCHflagsis one more field onUpdateAccount, and takesPutAccountFlagsthe waydisabledtakesDisableAccount: a caller may hold any of the three permissions alone, and is refused only for the fields they send. A service account still can't holdadmin(400, namingflags). Only the flags are written, so a concurrent profile edit survives. The account-flags form'supdateAccountFlagsserver action keeps its name and callsupdateAccount.API.mdalready said one resource is edited through one endpoint, which the flags route contradicted. Its methods table now citesPUT /data-connections/{id}as thePUTexample in place of/flags.Deleting a service account
deleteAccount:disabled, so no key exchanges and nothing new can be granted, issued or trusted while it's torn down (granting, issuing a key and adding a trust each already refuse a disabled service account);Keys are deleted rather than revoked, so a service account that later takes the same ID starts with no keys, instead of listing its predecessor's. As a backstop, the key exchange refuses a key whose
created_atis earlier than its account's. That ties a key to the account it was issued to, so a key the cleanup misses (a race, GSI lag, a delete path that skipsdeleteAccount) can't sign in as a re-created account with the same ID. A key minted with an account at creation is never older than it: the account's timestamp is taken first.Known gap: disabling first only stops requests that read the account after the disable. A grant or trust from a request that read it just before can still land after the cleanup lists memberships, and survive the delete. A service account re-created with the same ID would then inherit it. Keys have the "older than its account" backstop, but memberships and trusts have none yet. Closing that needs conditional writes or the same check on memberships and trusts, which is a follow-up.
Authorization comes before anything an unauthorized caller shouldn't learn: a service account the caller can't manage is a 404, as
GETanswers, and the 409 pointing atPATCHgoes only to a caller who may disable that person or organization.The service-account settings page's
deleteServiceAccountaction goes through the same operation, so deleting from the UI cleans up keys too; onmainit left them live. On a refusal it shows its own message rather than the API's.#665
#665 makes the same
DELETEchange through its owndeleteServiceAccountoperation, but still disables any other account. Rebased onto this, it can drop that operation and itsDELETEroute, and keep the rest of its service-account and trust endpoints.Nothing calls these yet
No caller in the app's API clients, the CLI, the data proxy or the other repos in the org (code search for
/profileand/flags). The account-flags form uses its server action, not the route. No production traffic in the last day forDELETE /accounts/{account_id}.UI
The settings page's delete button and the account-flags form call the same server actions as before, which now adapt
deleteAccountandupdateAccount; their stories stub the actions, so they are unchanged.One visual change: a signed-out reader of
/productsgets each product's account as aPublicAccount, which has no emails, so the hover card couldn't find an individual's Gravatar. The public view carries theprofile_imageworked out before the emails were dropped,avatarUrluses it, and the account links, hover card and avatar acceptAccount | PublicAccount. New story: AccountLinks › Public View. No screenshot: headless Chromium didn't get past Storybook's loader locally.Docs, ADRs and
llms.txtpublic/llms.txt's public-profile line now points at/api/v1/accounts/{account_id}, which answers anonymously.API.md's methods table citesPUT /data-connections/{id}instead of the removed/flagsroute.API.md's rule for publishedsummaryanddescriptiontext now says how to write it: lead with a verb, don't drop words, one rule per sentence, name who gets what actively, 25 words or fewer a sentence, and address the caller as "you". These are ASD-STE100's writing rules without its controlled vocabulary, which would read stiffly in developer docs and which nothing here could enforce. TheGET,PATCHandDELETEdescriptions on/accounts/{account_id}are rewritten to it. Descriptions on other routes keep the older style until someone touches them.using-source/upload/with-service-account.mdnames only the key-revocation endpoint and says a service account can be deleted without saying what goes with it, so it still holds. No page names the profile or flags endpoints.Testing
At
b06dbf94:npm run type-checkpasses, and the fullnpx jestpasses (94 suites, 1,017 tests).operations/accounts.test.ts:getAccountanswering the record or aPublicAccountwithout emails, flags or private metadata, and 404 for a hidden service or disabled account;flagsonupdateAccount(takesPutAccountFlags, noadminfor a service account, writes only the flags, rejects an unknown flag);deleteAccountdisabling first and deleting memberships and keys, the 409 only to a caller who may disable, the 404 to whoever may not manage a service account.exchanges/route.test.ts: a key older than its account answers inactive.actions/service-accounts.test.ts: the action redirects on success and shows its own message on refusal;setProductAccessgrants nothing to a disabled service account but still revokes its grant.🤖 Generated with Claude Code