Skip to content

feat(api): accounts, whoami and the internal routes on the ops layer - #667

Merged
alukach merged 4 commits into
mainfrom
feat/api-accounts-and-remaining-routes
Oct 16, 2026
Merged

alukach merged 4 commits into
mainfrom
feat/api-accounts-and-remaining-routes

Conversation

@alukach

@alukach alukach commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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 feat(api): operations layer, generated OpenAPI docs, and the memberships API #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

Every /api/v1 endpoint not already claimed by an open PR now runs through the operations layer from #642 and #651 and is documented at /api/docs from the schemas it parses. The route-coverage allowlist is down to the five data-connection endpoints (#657) and DELETE /accounts/{account_id} (#665).

Accounts (part of #592): getAccount, getAccountProfile, updateAccountProfile, getAccountFlags, updateAccountFlags and whoami live in src/lib/operations/accounts.ts, and the profile and flags server actions only adapt them. PATCH /accounts/{account_id}/profile replaces PUT. GET /accounts/{account_id}/flags reads the account in the path rather than the caller's. An organization's profile can be read, and a profile carries its name. /whoami no longer returns the Ory session. HEAD /accounts/{account_id} is documented, and the coverage test now counts HEAD handlers.

GET /products/{account_id}/{product_id}/permissions runs through a getProductPermissions operation and keeps the bare list the data proxy reads. GET /products/featured (which listed the first ten public products, featured or not) and PUT /products/{account_id}/{product_id}/featured (always 501) are removed; GET /products?featured=true is the listing.

The data proxy's trust and API-key exchanges and GitHub's secret-scanning callback are documented under an Internal tag, marked x-internal and described as not for public use. Their statuses and success bodies are unchanged; their error bodies take the shared shape. POST /service-account-keys/revocations is documented as a public endpoint.

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 15, 2026 11:28pm UTC
source-cooperative Ready Ready Preview Oct 15, 2026 11:28pm UTC

Request Review

@claude

claude Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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


✅ No blocking issues — safe to merge.

I read the new src/lib/operations/accounts.ts, the featured route and avatar.ts against accountsTable.update and the authz actions. I didn't run the tests.

  • Partial writes: accountsTable.update only sets emails, flags, disabled and metadata_public when they are defined. The profile and flag edits therefore don't overwrite each other's fields, as the description says.
  • Authorization: the check runs before body validation, and a 403 goes to a signed-in caller while a 401 goes to an anonymous one. A disabled or service account's profile is a 404, and PATCH has the same 404 for a service account.
  • Per-type identifiers: ORCID on an organization and ROR on a person are rejected with a 400. The email edit only applies to organizations.
  • Hidden Internal operations: the x-internal operations stay in the public document, which is intended. They are tagged and listed last.

Description is stale (advisory)

Commit 2847a05 keeps GET /products/featured as a deprecated route, backed by listProducts({ featured: "true", limit: "10" }) and with a new route.test.ts. The description still says the following:

  • The table row for GET /products/featured says "removed".
  • The "Behavior that changed" section says "Both featured endpoints are gone".
  • The docs section says "Removing GET /products/featured lets that path fall through to GET /products/{account_id}".

It also still says 27 operations, which probably needs recounting. CLAUDE.md says a description is read as the spec, so please rewrite those lines. The route is now documented as deprecated, and its response is { "products": [...] } for the first ten featured products. PUT .../featured is still removed.

Simplify (ponytail)

  • src/lib/operations/accounts.ts:105: accountExists is a one-line wrapper over fetchAccount(...). If it has a single caller, call the table directly.
  • src/lib/operations/accounts.ts:52: clearable is used by only one schema. Inlining .or(z.literal("")) is fine, but this one is borderline, so keep or cut as you prefer.

Docs

  • The description names the docs.source.coop page it checked (using-source/upload/with-service-account.md) and says why it still holds.
  • It also names the data.source.coop ADRs 005, 007, 013 and 014 and says why they hold.
  • It covers the public/llms.txt line.
  • Nothing to flag, apart from re-checking the featured text above, since the featured change affects what it says about llms.txt and the proxy.

💰 Estimated review cost: $0.16 · 0m19s · 7 turns

The profile and flags operations write only the fields they change, so a profile edit no longer writes stale flags or `disabled` back over a concurrent admin change, and a flags change no longer rewrites the profile. Both log the change and who made it, as the actions they replaced did.

PATCH /accounts/{id}/profile on a service account is a 404, as GET is; an ORCID iD on an organization or a ROR ID on a person is refused; and an empty account ID is a missing account rather than a DynamoDB error.

The avatar order (uploaded image, else an individual's identicon Gravatar) lives in one module, src/lib/avatar.ts, which ProfileAvatar, OGAvatar and the profile operation share, so the API's profile_image matches the site's.

GET /accounts/{id} declares its bearer token, and GET .../permissions declares one as optional, so Swagger UI's Try it out sends it. The utils module header no longer mentions the Gravatar helper it lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alukach alukach changed the title feat(api)!: accounts, whoami and the internal routes on the ops layer feat(api): accounts, whoami and the internal routes on the ops layer Oct 15, 2026
… line

Swagger UI and Redoc list the tags a document declares first, in declared order, and only Internal was declared, so it rendered at the top. generateDocument now declares every tag its routes use, in registration order, with Internal last, so a tag a later route adds lands above it without anyone listing it. The API description no longer spells out the error shape; the Error schema documents it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Production saw 18 requests to it in a day, from browsers, curl and GeoLibre Desktop, none of them in the org's code, so removing it would break them. It runs on listProducts and answers its old { products } shape, now with products that are actually featured (it used to list the first ten public ones), and the reference marks it deprecated in favor of GET /products?featured=true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alukach
alukach marked this pull request as ready for review October 16, 2026 02:29
@alukach
alukach merged commit cdc1258 into main Oct 16, 2026
10 checks passed
@alukach
alukach deleted the feat/api-accounts-and-remaining-routes branch October 16, 2026 02:29
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>
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
Follows #667, now merged. Item 4 of the follow-ups discussed there.

## What

The web app checks whether a product ID is taken with `HEAD
/api/v1/products/{account_id}/{product_id}`
(`src/hooks/useIdValidation.ts`, used by the product creation form).
Next answers a `HEAD` from the route's `GET` when no `HEAD` is exported,
so the endpoint worked, but neither the reference at `/api/docs` nor the
route-coverage test knew about it.

- **Documented.** The product route exports `HEAD = GET`, so the
coverage test counts it, and registers it with what each status means:
200 when the caller may read the product, 401 or 403 when it exists but
they may not, and 404 when no product holds the ID (or a deactivated one
is hidden from them).
- **The ID check reads those statuses correctly.** It used to treat any
non-2xx answer as "available", so a product the caller couldn't read was
offered as free and creating it then failed with a 409 (noted in #651).
It now frees an ID only on a 404, calls it taken on a 200, 401 or 403,
and reports "Failed to check product ID availability" on anything else,
where it used to say "available" on a 500.

`HEAD` keeps `GET`'s semantics rather than becoming a bare existence
check like `HEAD /accounts/{id}`. That would tell anyone that a
restricted product exists under a given ID, which `GET` already tells
only signed-in callers (403) and anonymous ones (401). The two answer
the same question; this documents the one that is there.

## UI

[`ProductCreationForm` › Id
Taken](https://source-coop-ui-git-feat-api-document-product-head-radiantearth.vercel.app/?path=/story/features-settings-details-productcreationform--id-taken)
is a new story: it answers the check's `HEAD` with a 403 and types an
ID, so the form shows "Product ID is not available". No other story
calls the check, since none stubs `fetch`.

I couldn't render the story here (no browser available to this session),
so there's no screenshot yet and the story's `play` function hasn't been
run; it type-checks, and the hook behind it is covered by
`useIdValidation.test.ts`. Please check it on the branch deploy.

## Docs, ADRs and `llms.txt`

Checked docs.source.coop's `using-source/create-a-data-product.md`: it
gives the product ID's format and says nothing about the availability
check, so it still holds. The data proxy doesn't call `HEAD`, so no ADR
is affected. No `llms.txt` line: the endpoint is the app's own ID check,
not something an agent needs.

## Testing

- `npm run type-check` passes.
- Full `npx jest`: 94 suites, 1,007 tests pass.
- New `src/hooks/useIdValidation.test.ts`: a 404 frees the ID; 200, 401
and 403 take it; a 500 reports a failed check.
- The route-coverage test now finds and requires `HEAD
/products/{account_id}/{product_id}`.

🤖 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>

This branch was successfully deployed

2 active deployments
Preview – source-cooperative — 2847a054 Deployed Oct 15, 2026 by vercel[bot]
Preview – source-coop-ui — 2847a054 Deployed Oct 15, 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.

1 participant