Skip to content

feat(api): manage service accounts, their trusts and their API keys - #665

Merged
alukach merged 12 commits into
mainfrom
feat/api-service-accounts
Oct 17, 2026
Merged

alukach merged 12 commits into
mainfrom
feat/api-service-accounts

Conversation

@alukach

@alukach alukach commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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 (fix(service-accounts): refuse new product access while disabled #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 API: publish the generated reference and retire api.docs.source.coop #595's reference covers meanwhile. The API reference is API: publish the generated reference and retire api.docs.source.coop #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

Service accounts can be listed, created, edited, deleted and trusted by GitHub Actions workflows over /api/v1, so they can be provisioned from a script or CI. The rules that decide whether a change may happen — the GitHub subject pattern, the read/write-only grant roles, managedServiceAccount, and that a service account can't own another — live in src/lib/operations/service-accounts.ts, and the server actions and routes are adapters over it. Product access goes through the membership operations, so it needs no endpoint of its own.

DELETE /accounts/{id} deletes a service account and disables any other account, as before, and now requires a bearer token like every other mutation.

Closes #594

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
source-coop-ui Ready Ready Preview Oct 17, 2026 4:14am UTC
source-cooperative Ready Ready Preview Oct 17, 2026 4:14am UTC

Request Review

@claude

claude Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 16s —— View job


✅ No blocking issues — safe to merge.

I read src/lib/operations/service-accounts.ts in full. I did not read the route files, the action rewrites or the tests, and I did not run anything. For the operations file:

  • Authorization: every operation goes through managed() or canManageAccountServiceAccounts, and 404s don't reveal whether an account exists.
  • Create ordering: createServiceAccount checks the owner, then each grant, before it writes the account. A refused grant therefore leaves nothing behind.
  • Key storage: keys are stored by hash, the plaintext is returned only on issue, and publicKey strips hashes from lists.
  • Disabled accounts: both issueKey and addGithubTrust refuse a disabled account.

Non-blocking observations:

  • createServiceAccount is not atomic after the account row is written. If a later inviteMember, trust create or key store throws, the account stays, and a retry with the same local_id gets 409. The grants are pre-checked, so this only happens on infrastructure errors. Say so in the docs, or delete the account on failure.
  • updateKey can set expires_at on a key that is already revoked or expired. That makes an expired key valid again, which is intended per the PR text ("extended"). A revoked key stays refused regardless, since the revocation check is separate.
  • updateKey still allows extending a key on a disabled account. That is fine as long as the exchange refuses disabled accounts, which ADR 013 says it does.

Docs

The PR description names docs.source.coop with-service-account.md (still holds), ADRs 005, 010 and 013 (implements existing decisions), and llms.txt (no change, with a reason). That satisfies the requirement, so there is nothing to flag.

Simplify (ponytail)

  • service-accounts.ts publicTrust and the managed wrapper are each used in several places. Keep both; no cut.
  • updateKey builds the returned record by hand with spreads (...(revoked && {...})). Re-fetching the key, or having set/revoke return the updated record, would remove the duplicated field logic and any drift from what was stored.
  • Page, onePage and pageSchema for next_cursor: null add a speculative pagination shape. It is deliberate and matches products, so I'd keep it.

💰 Estimated review cost: $0.14 · 0m15s · 4 turns

alukach and others added 6 commits October 14, 2026 23:06
Deleting a service account removed its grants and trusts but left its API keys live. Service-account ids are deterministic, so a key from a deleted account signed in as whatever account later took the same id. Delete now revokes every unrevoked key, recorded as revoked by the owner who deleted it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The trust table stores each row under an `identity` sort key. It came back with the trusts from GET and DELETE /accounts/{id}/trusts, which the published AccountTrust schema doesn't define. Trusts are now parsed through that schema, which drops it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After each operation the action looked the service account up again only to learn its owner for revalidatePath, which cost a session read and two account reads per submit. A service account's id starts with its owner's, so the owner now comes from the id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The create action derived the new account's id itself, from the owner and a lowercased local id, to mint its API key, then overwrote it with the id the operation returned. The key is now minted with an empty id that the operation's id replaces, so the id rule lives only in the operation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Creating a service account wrote its product grants straight to the memberships table, while grants made afterwards go through inviteMember, with its authorization, duplicate check and audit line. Creation now calls inviteMember for each grant too, so every rule on granting a product applies to both paths. Products are still checked before anything is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit that referenced this pull request Oct 15, 2026
A disabled service account can still be granted a product, or have its
grant raised from `read_data` to `write_data`, through `inviteMember`
and `updateMembership`. That reaches it from the service-account page
(`setProductAccess`) and from `POST /accounts/{owner}/members` and `POST
/products/{account}/{product}/members`. Enabling the account again
brings that access with it, although disabling is meant to freeze it,
which is how adding a trust or issuing an API key already treats it.

`serviceAccountGrantProblem`, which every grant and role change already
asks, now returns "That service account is disabled" for a disabled
service account (a 400 from the API). Revoking access still works, so a
disabled account can still be stripped down. People and organizations
are unaffected.

Found reviewing #665, which documents the rule in its
`UpdateServiceAccount` schema. This PR stands on its own, off `main`.

## Docs, ADRs, llms.txt

- docs.source.coop `docs/using-source/upload/with-service-account.md`: I
checked it, and it doesn't describe granting access to a disabled
account, so it still holds.
- data.source.coop ADRs: I checked 005 (authorization). It covers what a
grant permits, not when one may be made, so it still holds.
- `public/llms.txt`: no line covers this.

## Storybook

No component changed. The service-account page shows the refusal through
the action-state message it already renders.

## Testing

- `npm run type-check`: clean.
- `npx jest src/lib/accounts src/lib/operations
src/lib/actions/service-accounts.test.ts
src/lib/actions/memberships.test.ts`: 119 tests pass, including a new
test that a disabled service account is refused `read_data` and
`write_data` and that other accounts aren't held to the rule.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit that referenced this pull request Oct 16, 2026
…667)

Part of #588. Takes the account endpoints of #592 that exist today, plus
every other `/api/v1` route not already claimed by an open PR, onto the
operations layer from #642 and #651, so `/api/docs` documents them from
the schemas they parse.

## What

`/api/docs` listed 15 of the 34 handlers under `/api/v1`. With this
branch it lists 27 operations, and the route-coverage allowlist holds
only the six endpoints open PRs already port: the five data-connection
endpoints (#657) and `DELETE /accounts/{account_id}` (#665). Once those
merge, the allowlist is empty and the reference describes every deployed
endpoint.

| Endpoint | Before | Now |
|---|---|---|
| `GET /accounts/{account_id}` | undocumented | `getAccount`; 403 (was
401) to a signed-in caller who may not read it |
| `HEAD /accounts/{account_id}` | undocumented, and not counted by the
coverage test | documented; the coverage test now counts `HEAD` |
| `GET /accounts/{account_id}/profile` | 401 for any organization |
`getAccountProfile`: organizations too; a disabled or service account is
a 404; the profile carries `name` |
| `PUT /accounts/{account_id}/profile` | overwrote `metadata_public`
wholesale | **replaced by `PATCH`** (`updateAccountProfile`), which the
edit-profile form also uses: fields given change, `""` clears one; a
service account's is a 404, as `GET` is |
| `GET /accounts/{account_id}/flags` | returned the *caller's* flags,
whatever the path said | `getAccountFlags` reads the account named |
| `PUT /accounts/{account_id}/flags` | | `updateAccountFlags`, shared
with the admin form |
| `GET /whoami` | the whole session, Ory session included |
`identity_id`, `account`, `memberships` |
| `GET /products/{a}/{p}/permissions` | | `getProductPermissions`; still
a bare `["read","write"]` list |
| `GET /products/featured` | the first ten public products, featured or
not | up to ten actually featured products, same `{products}` shape,
through `listProducts`; documented as **deprecated** in favor of `GET
/products?featured=true` |
| `PUT /products/{a}/{p}/featured` | always 501 | **removed** |
| `POST /accounts/{id}/trusts/exchanges` | undocumented | documented
under **Internal** |
| `POST /service-account-keys/exchanges` | undocumented | documented
under **Internal** |
| `POST /secret-scanning/github` | undocumented | documented under
**Internal** |
| `POST /service-account-keys/revocations` | undocumented | documented,
public |

### Internal endpoints

The data proxy's two exchanges and GitHub's secret-scanning callback are
in the reference so it matches what is deployed, but no client can
authenticate as the proxy or as GitHub. They carry an `Internal` tag,
whose description opens with **Not for public use**, and `x-internal:
true`, which tools such as Redocly use to hide an operation from a
public build. The document declares every tag, in the order routes
register, with `Internal` last, so Swagger UI and Redoc, which list
declared tags first and in order, render it at the bottom of the page.
Their statuses and success bodies are unchanged. The proxy reads only
the status and, on a 200, `trusted` or `active`/`account_id`/`key_id`
(checked in `data.source.coop/src/source_api/cache.rs`), so a `403
{"trusted": false}` and a `200 {"active": false}` for an unknown key
stay as they are.

### Behavior that changed

- **Error bodies** on every endpoint here are `{ "error": { "code",
"message", "field_errors"? } }`, as everywhere since #642. The CLI
already parses that shape (`source-coop-cli/src/api.rs`), so its
messages improve; the data proxy never reads error bodies.
- **A malformed body is a 400** with field errors, not the 500 a thrown
`ZodError` produced.
- **`PUT /accounts/{id}/profile` is gone.** No caller in the org's
repositories uses it; the web form goes through the same operation as
`PATCH`.
- **`PUT /products/{a}/{p}/featured` is gone.** It always answered 501.
`GET /products/featured` stays: no code in the org calls it, but
production saw 18 requests to it in a day, from browsers, curl and
GeoLibre Desktop.
- **`GET /whoami` drops `orySession`.** The CLI reads only `account` and
`memberships`.
- **A profile's `profile_image`** is the uploaded image, else an
individual's identicon Gravatar, from `src/lib/avatar.ts`, which
`ProfileAvatar` and `OGAvatar` now share with it, rather than a Gravatar
built from an Ory admin lookup. `getEmail` and `getProfileImage` had no
other caller and are removed.
- **Profile and flag edits write only the fields they change.** The old
route and action read the account and wrote the whole record back, so a
profile save could restore flags or `disabled` that an admin changed in
the meantime. Both edits are logged with who made them.
- **An ORCID iD on an organization, or a ROR ID on a person, is a 400.**
The forms only ever send the one that fits.
- **Saving a profile keeps an unchanged website's record**, verification
state included, rather than recreating every website as unverified.

The stale `@openapi` JSDoc blocks on the routes touched here are
removed; nothing read them since #642.

## Not here

The rest of #592: account search, `POST /accounts` for organizations,
and `DELETE /accounts/{id}` (#665).

## Docs, ADRs and `llms.txt`

- `public/llms.txt` gains a line for the public profile, now that an
organization's can be read.
- Checked docs.source.coop:
`using-source/upload/with-service-account.md` shows `POST
/service-account-keys/revocations` and promises a 204 for any
well-formed key, which still holds. No other page names these endpoints.
- Reviewed data.source.coop at `8d02d47` for anything this branch could
break. The proxy calls six source.coop endpoints
(`src/source_api/cache.rs`), all through `cached_fetch`, which reads
only the status (200 succeeds, 404 is a missing bucket, 401/403 are
`AccessDenied`, anything else is an internal error) and parses a 200
body. Three are touched here, and each keeps what the proxy relies on:
`GET .../permissions` still answers its proxy-signed GET, through the
same `getApiSession`, with a bare `["read","write"]` list or a 404;
`POST /service-account-keys/exchanges` still answers 200 with `{active,
account_id?, key_id?}` for every well-formed hash; `POST
/accounts/{id}/trusts/exchanges` still answers 200 `{trusted: true}`,
403 for no and 401 for an unresolved account. Only their error bodies
change, which the proxy never reads. The other three, `GET
/products/{a}/{p}`, `GET /products/{a}?limit=100` and `GET
/data-connections/{id}`, are untouched. - Checked the data-proxy ADRs
that name these endpoints: 005 and 007 (`/permissions` as the write
gate, answered with the caller's identity), 013
(`service-account-keys/exchanges` as the proxy itself, `{account_id,
key_id, active}`, `active: false` for an unknown hash) and 014
(`trusts/exchanges` as the account). This branch keeps each of those
contracts, so all four still hold.

## UI

No visual change. The edit-profile and account-flags forms call the same
server actions, which now adapt the operations. `ProfileAvatar` and
`OGAvatar` take their URL from `avatarUrl` and render the same image as
before, so their stories are unchanged.

## Testing

- `npm run type-check` passes.
- Full `npx jest`: 93 suites, 1,001 tests pass.
- `route-coverage.test.ts` validates the generated document as OpenAPI
3, checks every handler but the six allowlisted ones is in it, and
checks every tag is declared with `Internal` last.
- New `src/lib/operations/accounts.test.ts` takes over the
organization-email tests from `actions/account.test.ts`, and adds tests
for the flags path-id fix, the profile `PATCH` semantics and websites,
both edits writing only their own fields, the per-type identifiers, a
service account's missing profile, and `whoami` dropping the Ory
session; `getProductPermissions` is tested in `products.test.ts`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit that referenced this pull request Oct 16, 2026
Follows #667, now merged. Item 2 of the follow-ups discussed there; #665
needs to rebase onto it (see below).

## What

Products are edited with `PATCH /products/{account_id}/{product_id}`,
which answers the product. Accounts had two ways to change: #667's
`PATCH /accounts/{account_id}/profile` for people and organizations, and
#665's `PATCH /accounts/{account_id}` for service accounts. This PR
makes accounts work the way products do: one `PATCH
/accounts/{account_id}` for every kind of account, answering the
`Account`.

| Endpoint | Before (#667) | Now |
|---|---|---|
| `PATCH /accounts/{account_id}` | — | `updateAccount`: profile fields
and `disabled`, for any account |
| `PATCH /accounts/{account_id}/profile` | edits profile fields, answers
the profile | **removed** |
| `GET /accounts/{account_id}/profile` | the public profile | unchanged;
its description points at `PATCH /accounts/{account_id}` |

`updateAccount` takes `UpdateAccount`: everything `UpdateAccountProfile`
took (`name`, `bio`, `location`, `orcid`, `ror_id`, `websites`, `email`)
plus `disabled`. Editing and disabling are separate permissions, as they
already were in `authz.ts`:

- **Fields other than `disabled`** need `PutAccountProfile`: the person
themselves, an organization's owners and maintainers, or whoever manages
a service account's owner.
- **`disabled`** needs `DisableAccount`: an organization's owners, a
service account's managers, or an admin. Resubmitting the current value
changes nothing and needs nothing.
- **A caller with neither** is refused before the body is read.
- **A service account takes only `name`** (and `disabled`); a profile
field is a 400 naming it.
- **A disabled account** can be re-enabled by whoever may disable it,
but `PutAccountProfile` refuses other edits to it until then, except by
an admin.

The write carries only what changed: `name` only when given
(`accountsTable.update` no longer sets it unconditionally), `emails`
only when the address changes, `metadata_public` only when a profile
field is given, `disabled` only when it differs from the stored value. A
rename can't write back a stale profile or `disabled`, a disable can't
write back a stale profile or name, and a caller without
`DisableAccount` who resubmits the current `disabled` can't undo a
concurrent disable.

The edit-profile form's server action, `updateAccountProfile`, keeps its
name and now calls `updateAccount`.

## #665

#665's `updateServiceAccount` and its `PATCH /accounts/{account_id}` do
for service accounts what `updateAccount` now does for every account,
`name` and `disabled` with the same permissions. When #665 rebases onto
this, it can drop both and point its service-account settings at
`updateAccount`. Its `audit` entry can move into `updateAccount`'s log
line, which records the fields changed and who changed them. Its
`DELETE` change is item 3, stacked on this PR.

## Nothing calls these yet

No caller in the app's API clients, the CLI or the data proxy, and no
production traffic in the last day (the window Vercel's metrics allow)
for `PATCH /accounts/{id}/profile` or `PATCH /accounts/{id}`. The web
form goes through the server action, not the route.

## Docs, ADRs and `llms.txt`

- `public/llms.txt` names `GET /accounts/{account_id}/profile` (from
#667), which is unchanged.
- Checked docs.source.coop: `using-source/create-an-account.md`
describes setting up a profile in the UI, and
`using-source/upload/with-service-account.md` describes the service
account's **Disable** button; neither names an endpoint, and both flows
still work as described.
- The data proxy doesn't call these endpoints, so no ADR is affected.

## Testing

- `npm run type-check` passes at `bafc5340`.
- Full `npx jest` passed (93 suites, 1,003 tests) before `bafc5340`; at
`bafc5340`, `operations/accounts.test.ts` and `clients/database/` pass
(9 suites, 63 tests).
- `operations/accounts.test.ts` covers the split permissions (disable
with `DisableAccount` alone; edit without it, which refuses a change to
`disabled` but accepts resubmitting it unchanged), a service account's
rename and refused profile field, and the narrowed write (no `emails`
key when the address is unchanged, no `name` key when it isn't given, no
`disabled` key when it's resubmitted unchanged).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit that referenced this pull request Oct 16, 2026
Item 1 of the follow-ups discussed on #667: the membership lists answer
the same paged shape as every other list.

## What

Since #651, a list answers `{ "items": [...], "next_cursor": ... }`, and
a client follows `next_cursor` until it is null. The three membership
lists from #642 still answered a bare array:

| Endpoint | Before | Now |
|---|---|---|
| `GET /memberships` | `Membership[]` | `MembershipPage` |
| `GET /accounts/{account_id}/members` | `Membership[]` |
`MembershipPage` |
| `GET /products/{account_id}/{product_id}/members` | `Membership[]` |
`MembershipPage` |

A bare array can't grow a cursor later without breaking whoever reads
it, and nothing reads these yet: no caller in the app's API clients, the
CLI (`main` or its open PRs) or the data proxy, and no production
traffic in the last day (the window Vercel's metrics allow). The CLI is
being built against the API now, so this is the cheapest moment.

Every item still comes back on one page, with `next_cursor: null`, and
the routes take no `cursor` or `limit` yet. The membership tables are
read whole today, as they were. That is the same step #665 takes for its
service-account and trust lists: the shape lets a client follow
`next_cursor` now, so real pages can come later without breaking it.

- **`Page<T>`, `pageSchema(name, item)` and `onePage(items)`** live in
`src/lib/operations/result.ts`, beside the other result helpers, so a
list that is read whole says so in one call. #665 defines the same
`page` helper locally in `operations/service-accounts.ts`; whichever
merges second can use this one. `ProductPage` is built from it too, so
the products and membership lists share one definition of the page
contract.
- **`getPendingInvitation`**, the server action behind the invitation
banner, reads `value.items`.

## Docs, ADRs and `llms.txt`

Checked `public/llms.txt`: it names no membership endpoint. Checked
docs.source.coop: no page names these endpoints. The data proxy doesn't
call them, so no ADR is affected.

## Testing

- `npm run type-check` passes.
- Full `npx jest`: 91 suites, 966 tests pass.
- `operations/memberships.test.ts` expects the page shape from
`listMembers` and `listMemberships`; `actions/memberships.test.ts` feeds
`getPendingInvitation` a page.
- The route-coverage test validates the regenerated document as OpenAPI
3.
- After the review follow-up (d9354fb): `npm run type-check` passes,
and `npx jest src/lib/operations src/app/api/v1` passes 12 suites, 127
tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit that referenced this pull request Oct 16, 2026
… flags fold into /accounts/{account_id} (#675)

Follows #674. Items 3 and on from the follow-ups discussed on #667:
`/accounts/{account_id}` becomes the one endpoint for an account.
`DELETE` only deletes, and the `/profile` and `/flags` sub-routes fold
into its `GET` and `PATCH`. #665 needs to rebase onto it (see below).

## What

| Endpoint | Before | Now |
|---|---|---|
| `GET /accounts/{account_id}` | the whole record to whoever may read
it; 401/403 to everyone else | the whole record to whoever may read it,
**its `PublicAccount` view to everyone else**, signed in or not |
| `GET /accounts/{account_id}/profile` | the public profile |
**removed**; `GET /accounts/{account_id}` answers it |
| `GET /accounts/{account_id}/flags` | the account's flags |
**removed**; they're in the record `GET /accounts/{account_id}` answers,
to the same readers |
| `PUT /accounts/{account_id}/flags` | replaces the flags (admin) |
**removed**; `PATCH /accounts/{account_id}` takes `flags` (admin) |
| `DELETE /accounts/{account_id}`, service account | disabled it |
**deletes it**, through `deleteAccount` |
| `DELETE /accounts/{account_id}`, person or organization | disabled it
| **409** to a caller who may disable it, saying to `PATCH { "disabled":
true }`; a person or an organization is never deleted |

### `GET` answers two shapes

`getAccount` absorbs `getAccountProfile`: `GetAccount` gets the
`Account` record, else `GetAccountProfile` gets a `PublicAccount`, else
a disabled or service account is a 404 and anything else is refused. The
reference documents the 200 as `anyOf` the two, and the route declares
`optionalBearer`, since it answers anonymous callers. Who sees a 404 is
unchanged from the profile route.

`PublicAccount` is the record without its private fields: `account_id`,
`type`, `name`, `disabled` and `metadata_public`, plus a computed
`profile_image`. It replaces the flattened `AccountProfileResponse`,
which had no `account_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 to `Account`,
so the reference promised `flags`, `created_at` and `updated_at` that
weren't sent. `ProductResponseSchema` now carries the `Product` name in
the reference and declares `account` as `Account | PublicAccount`. The
`Product` type the app reads from the database is unchanged.

### `flags` on `PATCH`

`flags` is one more field on `UpdateAccount`, and takes
`PutAccountFlags` the way `disabled` takes `DisableAccount`: 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 hold `admin` (400,
naming `flags`). Only the flags are written, so a concurrent profile
edit survives. The account-flags form's `updateAccountFlags` server
action keeps its name and calls `updateAccount`. `API.md` already said
one resource is edited through one endpoint, which the flags route
contradicted. Its methods table now cites `PUT /data-connections/{id}`
as the `PUT` example in place of `/flags`.

### Deleting a service account

`deleteAccount`:

1. **disables the account first**, writing only `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);
2. deletes every membership and **every key row** it held, in parallel;
3. deletes the account row, whose trusts go with it.

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_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. 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 `GET`
answers, and the 409 pointing at `PATCH` goes only to a caller who may
disable that person or organization.

The service-account settings page's `deleteServiceAccount` action goes
through the same operation, so deleting from the UI cleans up keys too;
on `main` it left them live. On a refusal it shows its own message
rather than the API's.

## #665

#665 makes the same `DELETE` change through its own
`deleteServiceAccount` operation, but still disables any other account.
Rebased onto this, it can drop that operation and its `DELETE` route,
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 `/profile` and `/flags`). The
account-flags form uses its server action, not the route. No production
traffic in the last day for `DELETE /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 `deleteAccount` and
`updateAccount`; their stories stub the actions, so they are unchanged.

One visual change: a signed-out reader of `/products` gets each
product's account as a `PublicAccount`, which has no emails, so the
hover card couldn't find an individual's Gravatar. The public view
carries the `profile_image` worked out before the emails were dropped,
`avatarUrl` uses it, and the account links, hover card and avatar accept
`Account | PublicAccount`. New story: [AccountLinks › Public
View](https://source-coop-ui-git-feat-api-delete-only-deletes-radiantearth.vercel.app/?path=/story/components-accounts-accountlinks--public-view).
No screenshot: headless Chromium didn't get past Storybook's loader
locally.

## Docs, ADRs and `llms.txt`

- `public/llms.txt`'s public-profile line now points at
`/api/v1/accounts/{account_id}`, which answers anonymously.
- `API.md`'s methods table cites `PUT /data-connections/{id}` instead of
the removed `/flags` route.
- `API.md`'s rule for published `summary` and `description` text 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. The `GET`, `PATCH` and
`DELETE` descriptions on `/accounts/{account_id}` are rewritten to it.
Descriptions on other routes keep the older style until someone touches
them.
- Checked docs.source.coop:
`using-source/upload/with-service-account.md` names 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.
- Checked the data-proxy ADRs: 013 records that a revoked key stops new
exchanges within the proxy's cache TTL. A deleted key, or one older than
its account, answers inactive the same way, so 013 and 014 still hold.

## Testing

At `b06dbf94`: `npm run type-check` passes, and the full `npx jest`
passes (94 suites, 1,017 tests).

- `operations/accounts.test.ts`: `getAccount` answering the record or a
`PublicAccount` without emails, flags or private metadata, and 404 for a
hidden service or disabled account; `flags` on `updateAccount` (takes
`PutAccountFlags`, no `admin` for a service account, writes only the
flags, rejects an unknown flag); `deleteAccount` disabling 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; `setProductAccess` grants nothing
to a disabled service account but still revokes its grant.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
createServiceAccount wrote the account and then granted products through inviteMember, so a grant the caller may not make returned an error with the account already created, and a retry with the same local_id was refused as taken. Each grant now asks inviteMember's InviteMembership check while nothing is written. The account is new, so inviteMember's other refusals (a duplicate, a disabled account) cannot arise afterwards; the grants are still deduplicated first for that reason. The page schema and single-page result now come from the shared helpers in result.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds GET and POST /accounts/{account_id}/keys and PATCH /accounts/{account_id}/keys/{key_id} on new listKeys, issueKey and updateKey operations, and an optional key on POST /accounts/{account_id}/service-accounts, so a script can provision a service account and its key in one call as the settings form does. PATCH takes expires_at and revoked: true; revoking keeps the record, so it is an edit rather than a DELETE, as disabling an account is. Expiry is an ISO time at most 3650 days away, or null. The key actions and the create action now map form fields onto these operations, so neither touches the keys table, and their tests check only that mapping; the rules are tested on the operations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alukach alukach changed the title feat(api): manage service accounts and their trusts feat(api): manage service accounts, their trusts and their API keys Oct 17, 2026
@alukach
alukach marked this pull request as ready for review October 17, 2026 02:20
PATCH /accounts/{account_id}/keys/{key_id} answered 200 to {} without changing anything; it now answers 400 unless it gets expires_at, revoked, or both. The 3650-day expiry limit and the form's range message live once, in lib/accounts/service-account-keys, and the operation's schema, the form parser and both actions use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alukach
alukach merged commit 8713872 into main Oct 17, 2026
8 checks passed
@alukach
alukach deleted the feat/api-service-accounts branch October 17, 2026 04:56

This branch was successfully deployed

2 active deployments
Preview – source-cooperative — 1789fe74 Deployed Oct 17, 2026 by vercel[bot]
Preview – source-coop-ui — 1789fe74 Deployed Oct 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

API: service accounts and their trusts

1 participant