# storage-pages-create Implementation Plan

> **For agentic workers:** Implement task-by-task, TDD, commit at the end. Spec: `.tasks/1852-storage-broker-has-no-client-callable-pages-create-so-a-sub-account-cannot-stand-up-a-new-booking-page.md`.

**Goal:** Add a client-callable `storage-pages-create` (MCP tool + `POST /api/storage/pages/create` route) that creates a brand-new Cloudflare Pages project and records the caller as its owner in one operation, mirroring `storage-d1-create`.

**Architecture:** New Hono route in the storage-broker route file, modelled on `/d1/create` with an added Cloudflare-existence check so a pre-existing name is refused (`exists-unregistered`) rather than self-claimed. New thin MCP client tool forwarding to it. Ownership recorded via the existing takeover-safe `registerResource` MERGE. Deploy and adopt contracts unchanged.

**Tech Stack:** TypeScript, Hono, Neo4j (registry), vitest.

## Global Constraints

- Production branch is hard-fixed to `'main'`, never a parameter (a create/deploy branch split would create on one branch and publish on another). State this in a code comment.
- `registerResource` is takeover-safe (MERGE on `(kind,name)`, `accountId` ON CREATE only). Pages `cfResourceId` is the project name.
- Deny before the house Pages credential is constructed: `cross-account` and idempotent-owner cases must return before `makeHousePagesExec` is called (tripwire).
- Correlated `[pages-broker] op=request` / `op=create` log lines (caller, project, existedOnCf, created, decision) — a create must be diagnosable from the log alone, same idiom as deploy.
- Registration surfaces in the same commit: MCP `index.ts`, `PLUGIN.md`, regenerated `canonical-tool-names.generated.ts`. `storage-pages-deploy` is in no `ADMIN_CORE_TOOLS` or specialist template, so neither is touched.
- `mcp/dist/` is gitignored (build artifact, not a committed twin): build + grep at verify time, do not commit dist.
- Shipped skill markdown: zero em-dashes; outcome statements, not command recipes.

---

### Task 1: `POST /pages/create` route + route tests

**Files:**
- Modify: `platform/ui/server/routes/storage-broker.ts` (add route after `/pages/adopt`)
- Test: `platform/ui/server/__tests__/storage-broker-pages-routes.test.ts` (extend the Neo4j mock to reflect a MERGE; add a `describe('POST /pages/create')` block)

**Interfaces:**
- Consumes: `caller`, `readJsonBody`, `nameBodyLimit('pages')`, `resolveOwner`, `registerResource`, `makeHousePagesExec(platformRoot())` → `{ pagesProjectList, pagesProjectCreate }`, `log`.
- Produces: `POST /pages/create` accepting `{project}`; responses `{project,created:true|false}` 200, `{error:'no-caller'}` 403, `{error:'project-required'}` 400, `{error:'cross-account'}` 403, `{error:'exists-unregistered'}` 409, `{error:<str>}` 500.

Route behaviour (ordering preserves the deny-before-credential tripwire):
1. `account = caller(c)`; null → log deny `no-caller`, 403.
2. `readJsonBody`; require `project` non-empty string → 400 `project-required`.
3. `op=request` log line (caller, project).
4. open session; `resolveOwner(session,'pages',project)`:
   - owner !== null && owner !== account → `log('write','pages',account,project,owner,'deny')`, 403 `cross-account`.
   - owner === account → `op=create existedOnCf=? created=false` is not applicable; return `{project,created:false}` 200 (before building exec).
   - owner === null → continue.
5. build `exec = await makeHousePagesExec(platformRoot())`; `existed = (await exec.pagesProjectList()).some(p=>p.name===project)`.
   - existed → `log('write','pages',account,project,null,'deny','exists-unregistered')`, `op=create existedOnCf=true created=false`, 409 `exists-unregistered`.
   - else → `pagesProjectCreate(project,'main')`, `registerResource(session,{accountId:account,kind:'pages',name:project,cfResourceId:project})`, `log('write','pages',account,project,account,'allow')`, `op=create existedOnCf=false created=true`, 200 `{project,created:true}`.
6. catch → `{error:String(err)}` 500; finally → `session.close()`.

Test-mock change (backward compatible): add a per-test `registered = new Map()` cleared in `beforeEach`. In `sessionRun`: if cypher includes `'MERGE'`, set `registered.set(\`${params.kind}:${params.name}\`, params.accountId)`, return `{records:[]}`. If cypher includes `'RETURN r.accountId'` (resolveOwner), return the merged owner when present else the `ownerAnswer` behaviour. Existing tests resolve owner before any merge, so behaviour is unchanged.

- [ ] Step 1: Write the five failing route tests (created:true; idempotent created:false; cross-account 403; exists-unregistered 409; create→deploy handoff allow).
- [ ] Step 2: Run — expect FAIL (route 404s / handler absent).
- [ ] Step 3: Implement the route.
- [ ] Step 4: Run — expect PASS, plus the pre-existing 23 still pass.

### Task 2: `storage-pages-create` MCP tool

**Files:**
- Modify: `platform/plugins/storage-broker/mcp/src/index.ts` (add tool after `storage-pages-deploy`)

Mirror `storage-d1-create`. Description: "Create a new Cloudflare Pages project owned by your account, so you can publish a site to it." Arg `project`. Call `callApi("storage-pages-create","/api/storage/pages/create","POST",{project})`. Map errors:
- `cross-account` → `Creating the Pages project failed: "<project>" belongs to another account.`
- `exists-unregistered` → `The Pages project "<project>" already exists on Cloudflare but has no owner on record. A pre-existing project cannot be self-claimed; ask the house admin to record it to your account.`
- other → `Creating the Pages project failed: <err>`
- success `created===false` → `The Pages project <project> is already yours.`
- success else → `Created Pages project <project>. You can now publish to it.`

- [ ] Verify with an ephemeral message-mapping check (discarded after).

### Task 3: Registration surfaces (same commit)

**Files:**
- Modify: `platform/plugins/storage-broker/PLUGIN.md` — add `storage-pages-create` to the `tools:` block (publicAllowlist:false, adminAllowlist:true) and the `## Tools` list; correct the line-72 sentence so a client-created brand-new project is distinguished from house-recorded ownership of a pre-existing project.
- Regenerate: `platform/services/claude-session-manager/src/canonical-tool-names.generated.ts` via `cd platform && npm run gen:canonical-tools`.
- Build + grep (verify only, not committed): `platform/plugins/storage-broker/mcp/dist/index.js` carries `storage-pages-create`.

### Task 4: Skill docs (same commit)

**Files:**
- Modify: `platform/plugins/cloudflare/skills/site-deploy/SKILL.md:36` — after the `storage-pages-deploy`/`storage-pages-adopt` sentence, add that for a brand-new project the client first creates and claims it with `storage-pages-create`, then deploys; adopt remains only for a project that already exists on Cloudflare the client did not create.
- Modify: `platform/plugins/cloudflare/skills/calendar-site/SKILL.md:59` — add one sentence naming the client new-project path (`storage-pages-create` then `storage-pages-deploy`), outcome register, no command recipe.

### Verify (Phase 4)

- Route test file passes (28 tests). Ephemeral MCP message check. `check-canonical-tool-names.mjs` and typecheck pass. Build MCP dist, grep for the tool. Full `vitest run` for the ui package (no regressions in the changed area).
