11.3.3 The v1 PROJECT resource schema + `GET /api/v1/projects` and `GET /api/v1/projects/{projectKey}`
The entry point of the whole planning journey: an integration that holds a PAT knows its workspace but not what is in it. These two endpoints answer "which projects can I reach, and what is this one?" — and every other path in 11.3 and 11.2 is scoped by the projectKey they return.
Ships the project resource schema module as well as the two routes — per the ADR's Amendment 2, 11.3 owns the project, sprint, backlog and ready-set schemas in its own per-resource modules, on the pattern lib/api/v1/workItems/schema.ts set.
What to build
lib/api/v1/projects/schema.ts— thezodresponse schema for a project, plus its mapper. Field by field, never a spread, exactly as 11.2.2 established:ProjectDTOis an internal shape that changes whenever a page needs it to, and §8's additive-only promise cannot ride something nobody promised to keep still. Decide field by field what is public;accessLevelis a closed vocabulary and takes the samesatisfies+AssertTotalcompile-time totality guard the work-item vocabularies use, so a value added to the DTO union breaks the build here rather than shipping as a response the schema rejects at runtime.GET /api/v1/projects— the token workspace's browsable projects, cursor-paged via 11.3.2.projectsService.listProjects(workspaceId, actorUserId)already applies the browse-access checks and returns only non-archived rows; the route pages over that bounded read in memory, the wayGET /api/v1/workspacesdoes — this is a workspace's own project list, not a 1800-row collection. Note the ordering constraint that forced the generalized cursor:ProjectDTO.createdAtis optional and NOT loaded on this path, so the position must be one the list read actually carries.GET /api/v1/projects/{projectKey}— one project.projectsService.getDetails(key, ctx)is the read that loads the details-path fields (createdAt,previousKeys);getByKeyis the hot read that does not. Pick one deliberately and say which fields the choice makes available — the two return the SAME DTO type with different fields populated, which is exactly the kind of difference a spread would hide.
The project identifier (MOTIR) is what the path takes and what the response leads with; the internal cuid does not cross the wire (ADR §7).
Scope
read on both. This card ships no project WRITE — create, rename, archive, key change, membership and access-level are privileged administration the story's boundary excludes and no card here defers work to them.
Acceptance criteria
- Both endpoints exist, declare
scope: 'read', go throughwithV1Route, and carry the request-id and rate-limit headers on every exit path (inherited from the wrapper — asserted by 11.1's shipped tree-wide guard, not re-tested here). - Every response body is produced by the declared
zodschema, not shaped inline, and carries no internal cuid for the project. - The project vocabulary (
accessLevel) is TOTAL over its DTO union by a COMPILE-time guard, not a runtime test. GET /api/v1/projectspages by cursor over a position the list read actually carries, defaults to 50 and clamps at 100; the last page reportsnextCursor: nullrather than requiring an extra empty round trip; an empty workspace is 200 with emptyitems, never a 404.- A project the token owner cannot browse does not appear in the list, and reading it by key is a 404, not a 403 — asserted for both the cross-tenant case and the same-tenant-not-browsable case, since those are different code paths that must give the same answer.
- An unknown
projectKeyis a 404 with{ code, error }, not a 500. - The detail response's field set is justified against the read chosen (
getDetailsvsgetByKey), so a field is never null merely because the hot read did not load it. - Unit tests ship with the routes; every new file holds the ≥90% per-file coverage floor.
- ONE PR against
motir-core.
Context refs
lib/services/projectsService.ts—listProjects(workspaceId, actorUserId),getByKey(key, ctx),getDetails(key, ctx), andProjectNotFoundError.lib/dto/projects.ts—ProjectDTO(notecreatedAt?andpreviousKeys?are load-path-dependent) andPreviousKeyDTO.lib/api/v1/workItems/schema.ts— the module pattern to mirror: field-by-field mappers, thesatisfies+AssertTotalvocabulary guards, and the recorded reasoning for every deliberate omission.app/api/v1/workspaces/route.ts— the precedent for paging a bounded, already-read collection.app/api/v1/projects/[projectKey]/work-items/route.ts— the shipped project-scoped route shape (parse first, resolve the key, present through a schema).- Blockers: 11.3.1 (the schema + cursor contract), 11.3.2 (the cursor primitive). Pattern precedent: 11.2.2. Parent story: 11.3.