Skip to content

feat(api): product list, create, view, edit and delete - #651

Draft
alukach wants to merge 4 commits into
mainfrom
feat/api-products
Draft

alukach wants to merge 4 commits into
mainfrom
feat/api-products

Conversation

@alukach

@alukach alukach commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Closes #590. Part of #588, on the operations layer from #642.

What

The product lifecycle runs through one module, src/lib/operations/products.ts, which the server actions and the /api/v1 routes both call and only adapt — the same split memberships got in #642.

Endpoint Before Now
GET /products?q=&tags=&featured=&cursor=&limit= — public products, paged; the /products page reads the same listing
GET /products/{account_id}?cursor=&limit= {products}, cut off at 50 {items, next_cursor}, paged
POST /products/{account_id} 501 creates, 201
GET /products/{account_id}/{product_id} product product
PATCH /products/{account_id}/{product_id} (a commented-out PUT) edits title, description, visibility, disabled
DELETE /products/{account_id}/{product_id}?preserve_data= rewrote the product unchanged deletes, with the website's preserve_data policy

Lists return { "items": [...], "next_cursor": ... } over the existing base64 LastEvaluatedKey cursor; limit is 1–100, default 20. All six are documented at /api/docs from the schemas the operations parse, and off the route-coverage allowlist. The route segment [repository_id] is now [product_id]; no URL changes, and the featured, members and permissions sub-routes moved with it.

Behavior that changed

  • Listing filters what the caller may see. The /products page used to show every product in the public index, deactivated ones included; it now filters with ListRepository, so a deactivated public product disappears for everyone but admins. An account's listing uses canListOnProfile, the rule the profile pages use, so owners and maintainers also see their deactivated products there.
  • Creating over an existing product is a 409. productsTable.create is an unconditional put, and the ID check in the form (useIdValidation) calls a product it can't see "available", so creating could silently overwrite someone's product. Creating under an account that doesn't exist is a 404.
  • GET of a product the caller may not see: 401 with no credentials as before, but 403 (was 401) when signed in. A deactivated product the caller can't see is still a 404, and now PATCH and DELETE answer it with 404 too rather than a 403 that tells it exists.
  • Error bodies are { "error": { "code", "message", "field_errors"? } }, as everywhere since feat(api): operations layer, generated OpenAPI docs, and the memberships API #642.
  • Delete mints storage credentials for the session's own identity rather than reading the cookie cache, which belongs to the browser's session and not necessarily the bearer token a request carries. A caller with no identity asked to delete stored objects gets a 403 rather than a 500.
  • A forged cursor (another account's, the other listing's, or extra attributes) is a 400, not a DynamoDB 500. On the /products page a stale cursor starts over at page one, as it used to.
  • The product's and account's cached pages are revalidated by the operations, not the server actions, so a create, edit or delete over the API refreshes them too.
  • PATCH refuses an empty title (the edit form, which leaves an empty field as it was, can't send one) and answers with the product as GET does, account included.
  • Server-action failures from DynamoDB in updateProduct now reach the error boundary instead of "Failed to update product. Please try again.", matching feat(api): operations layer, generated OpenAPI docs, and the memberships API #642; deleteProduct still surfaces the underlying reason.
  • OrganizationalAccount.identity_id and ServiceAccount.identity_id are documented as never present, since Product.account brings Account into the OpenAPI document and the generator can't express z.undefined() on its own.

Deploy order

The consumers of these endpoints across the org were audited; one breaks. The data proxy lists an account's products for s3://{account}/ by reading .products, so data.source.coop#253 has to deploy first. It reads either shape and follows next_cursor, so it is safe to ship ahead of this. The proxy treats 401 and 403 alike, so the GET change doesn't affect it. Elsewhere, metadata-catalog-pipeline's scripts/lib/seed.py guards against getting a list back with "products" in body; it only matters when a product segment is empty, and checking "product_id" not in body alone fixes it. docs.source.coop's existence check, ops-source's crawler and the CLI read nothing that changes.

Not done here

  • Delete mints storage credentials through Ory and STS on every call rather than reusing the cookie cache, which belongs to the browser and not necessarily to a bearer token's owner. That costs a UI delete a few seconds; passing the action's cached credentials into the operation would win them back.

  • getFeaturedProducts (homepage) still shows featured public products without the ListRepository filter, so a deactivated featured product can show there.

  • With q or tags, listPublic reads ahead 10× and DynamoDB returns every match it read, so a search page can hold more than limit; the docs say so. Honoring limit exactly means building the cursor from the last returned item.

  • Actions.DisableRepository has no callers now that the old DELETE is gone.

  • Unchanged from before but now in the published schema: Product.account is the whole account record, including emails and metadata_private, on every anonymous read. ops-source's crawler reads account.emails, so trimming it is its own change.

Docs

  • public/llms.txt: the account-listing line now describes the page shape and cursor, and a line for searching all public products is added.
  • docs.source.coop: checked using-source/; creating, editing and deleting a product work the same way in the UI, so nothing there is dated.
  • data.source.coop ADRs: 005 (authorization) still describes the model, since every product permission check is unchanged; 007 lists the product lookups by URL, which don't change (#253 explains why it stays as is).
  • No UI changed, so no stories.

Testing

At 5a2f691c: npm run type-check clean; npm run lint passes; npx jest --forceExit passes 88 suites, 938 tests. src/lib/operations/products.test.ts carries the rules the old action tests checked (authorization before lookups, connection checks, visibility, preserve_data, deletion through the proxy) plus listing, cursors, the 409, and blank fields; src/lib/actions/products.test.ts tests the adapters; the two route suites keep the OIDC org-account cases against the real authorization rules, now asserting the page shape and 403. The route-coverage test validates the generated document.

🤖 Generated with Claude Code

alukach and others added 2 commits October 9, 2026 00:43
The product lifecycle now runs through one operations module (src/lib/operations/products.ts) that both the server actions and /api/v1 routes call, the way memberships do since #642. GET /products (new) and GET /products/{account_id} return {items, next_cursor} pages over the existing base64 LastEvaluatedKey cursor and filter what the caller may see; the /products page uses the same listing. POST /products/{account_id} replaces the 501, PATCH replaces the commented-out PUT, and DELETE actually deletes, with preserve_data. Creating over an existing product is now a 409 rather than a silent overwrite. The [repository_id] route segment is now [product_id]; URLs are unchanged.

Closes #590.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving the edit form with an empty description sent `description: undefined` through Zod and the spread to DynamoDB, which rejects the update; only the fields actually given now change. Delete minted storage credentials from the cookie cache, which is bound to the browser's session rather than the bearer token a request carries; it now always mints for the session's own identity, and a caller with none (a service account) gets a 403 instead of a 500. A cursor naming other attributes or another partition is now a 400 rather than a DynamoDB 500. An update with an empty product ID is not found without a lookup, the create schema no longer offers search_text, and the docs say a search page can hold more than limit.

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

vercel Bot commented Oct 9, 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 9, 2026 1:49pm UTC
source-cooperative Ready Ready Preview Oct 9, 2026 1:49pm UTC

Request Review

@claude

claude Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

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


✅ No blocking issues — safe to merge.

I read src/lib/operations/products.ts, the [product_id] route and the /products page. I didn't run the tests or type-check.

  • The cursor handling holds up. Cursors are strict-parsed per listing, and a forged one returns 400.
  • Authorization runs before any lookup in createProduct, and refuse() returns 404 for a deactivated product the caller can't see.
  • deleteProduct mints credentials for session.identity_id rather than the cookie cache.

Description is stale (advisory)

  • The "Behavior that changed" bullet says a hand-edited /products?cursor= that isn't a valid cursor is a 404 page. src/app/(app)/products/page.tsx:47-51 now retries from page one and only calls notFound() for other invalid queries. Update the bullet to match.
  • The Testing section cites 31430273, but HEAD is 5a2f691. Re-run the checks and update the SHA.

Simplify (ponytail)

  • operations/products.ts:99-108: accountCursor and publicCursor are two near-identical strict schemas. They're fine as written; skip this if you prefer the explicitness.
  • operations/products.ts:316-318: the Object.entries(...).filter(v !== undefined) strip could be replaced by a spread of only the defined keys. It's already minimal, so no change needed.

Docs


💰 Estimated review cost: $0.16 · 0m14s · 6 turns

The build's lint step rejects an empty block (no-empty).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Revalidation of the product's and account's pages moves from the server actions into the operations, so creates, edits and deletes over the API drop cached pages too. PATCH and DELETE answer a deactivated product the caller can't see with 404, as GET does, rather than a 403 that tells it exists. PATCH refuses an empty title, and answers with the product as GET does (account included). Delete tolerates a product with no metadata. Create's three lookups run at once. A stale cursor on /products starts over at page one instead of a 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit to source-cooperative/source-coop-cli that referenced this pull request Oct 9, 2026
Adds `source-coop product`, the first gh-style command group from #21, over the `/api/v1/products` endpoints from source-cooperative/source.coop#651. A small API client sends the request and turns the API's `{"error": {code, message, field_errors}}` body into a message; the CLI does no validation of its own. Requests carry the login session's access token (or `SOURCE_TOKEN`, if set), and run signed out without one, which is enough to read public products.

`create` and `edit` take fields from flags, from a JSON file (`--from-file`), or both. When stdin and stderr are terminals, whatever is still missing is prompted for with defaults: a title from the product ID, the data connections the account can use, and the visibilities the chosen one allows. `edit` with no flags prompts from the product's current values and sends only what changed. `delete` asks whether to keep the data and for the name to be typed back; without a terminal it needs `--yes`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017poyHYEyxWEJct9X8Q71qF

This branch was successfully deployed

2 active deployments
Preview – source-cooperative — 5a2f691c Deployed Oct 9, 2026 by vercel[bot]
Preview – source-coop-ui — 5a2f691c Deployed Oct 9, 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: product list, create, view, edit and delete

1 participant