Skip to content

feat(api): POST /v1/projects — create a project, owned by its creator - #22

Open
marcorivm wants to merge 1 commit into
feat/projects-listfrom
feat/projects-create
Open

feat(api): POST /v1/projects — create a project, owned by its creator#22
marcorivm wants to merge 1 commit into
feat/projects-listfrom
feat/projects-create

Conversation

@marcorivm

Copy link
Copy Markdown
Member

Slice 2 of #20, stacked on #21. 4 files, ~230 lines.

Why

Projects could only appear implicitly — bootstrapOrganization, joinSharedOrganization, ensureMemberDefaultProject. There was no way to deliberately make one, which is half of why groups and project access have felt inert.

The bit that matters: the owner binding is atomic

accessBindings: { create: { userId, role: "owner" } }

A nested create, not a follow-up write, so it lands in the same statement as the project row. That's Guard G's precondition — a project with no owner binding can only ever be managed by an org admin, so a create that succeeded with a binding that failed would strand an orphan nobody could rename or delete.

A test proves the consequence end-to-end rather than just asserting the row exists:

it("makes the new project immediately manageable by its creator")

A plain member creates a project and can immediately rename it, no admin involved.

Slug collisions resolve silently

Project names are deliberately non-unique (projectNameSchema says so explicitly, because ensureMemberDefaultProject names every member's project "Default"). So two projects called "Alpha" must both be creatable — only the slug has to differ. alpha is taken by the seeded project, so they become alpha-2 and alpha-3. A P2002 from a concurrent create retries once with a random tail.

Surfacing a 409 here would have been wrong: it would make a naming detail a user-facing error for a name the schema explicitly permits.

Cap

MAX_PROJECTS_PER_ORG = 100. Count-then-create with no lock, so a concurrent burst can overshoot by a few — deliberate, and documented in the constant's comment: the cap bounds runaway growth rather than being exact, and a lock on a cold path costs more than the overshoot. A project isn't a cheap row (it seeds an API key, an agent and a policy generation).

Authorization

Lives in the service, not the route — there's no resource to resolve yet. Any active member may create; no role means refused.

Worth knowing: that gate is defence-in-depth, not route-reachable. A suspended session resolves neither project nor org, and an org key whose user lost membership fails key authentication outright (the org-key branch re-checks role >= admin per request) — both 401 before the handler runs. I originally asserted 403, found it was 401, and the test now asserts the real behaviour with the reasoning in a comment. Same pattern as the equivalent finding in #21.

Tests

12 new cases (88 in the file, 1,168 across @onecli/api). Owner seeding, immediate manageability, appearing in the creator's list, slug derivation and collision, the !!!project fallback, 422 on empty/missing/overlong name, the cap counting only this org, audit metadata, and the gateway org-cache flush — ProjectAccess is authorization data the Rust engine reads, so a missed flush is a stale authorization decision.

Verification

pnpm --filter @onecli/api test    # 1168 passed, 0 failed
pnpm run check                    # clean

Slice 2 of docs/project-lifecycle.md. Projects could only come into existence
implicitly — bootstrapOrganization, joinSharedOrganization and
ensureMemberDefaultProject — so a user could never deliberately make one.

The owner binding is a NESTED create, not a follow-up write, so it lands in
the same statement as the project row. That is Guard G's precondition: a
project with no owner binding can only ever be managed by an org admin, so a
create that succeeded with a binding that failed would strand an orphan. A
test proves the end-to-end consequence — a plain member creates a project and
can immediately rename it, with no admin involved.

Seeded like every other creation site (defaultProjectSeed: an API key and a
default agent) so the project is usable the moment it exists, with best-effort
policy seeding for the same reason those sites treat it as best-effort.

Slug collisions are resolved silently, not surfaced: project NAMES are
deliberately non-unique (projectNameSchema), so two projects called "Alpha"
must both be creatable and only the slug has to differ — "alpha" is taken,
so they become alpha-2 and alpha-3. A P2002 from a concurrent create retries
once with a random tail.

MAX_PROJECTS_PER_ORG caps growth at 100. Enforced count-then-create without a
lock, so a concurrent burst can overshoot by a few — deliberate: the cap
bounds runaway growth rather than being exact, and a lock on a cold path
costs more than the overshoot.

Authorization lives in the service, not the route, because there is no
resource to resolve yet: any active member may create, and a caller with no
role is refused. Note that gate is defence-in-depth rather than
route-reachable — a suspended session resolves no context and an org key
whose user lost membership fails key auth, so both 401 before the handler.
The test asserts that real behaviour rather than the 403 I first expected.

12 new cases; 1168 passing across @onecli/api.
@marcorivm
marcorivm force-pushed the feat/projects-create branch from 264960f to 23a391d Compare August 8, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant