# Two-Way Portal File Exchange Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Turn the client data portal from two disjoint one-way stores into a two-way folder exchange: a client drops a file onto any folder they can see and it lands there on the device within 60 seconds, and a device-side change is visible to them within 60 seconds.

**Architecture:** The `manifest` row gains a three-state lifecycle (in transit in R2, moved to a device path, withdrawn) driven by three new columns. The R2 key carries the chosen folder so two folders never collide. The device re-derives the legal folder set itself rather than trusting the portal, and the index push moves from an hourly heartbeat spawn to a 60 second loop with an atomic generation flip.

**Tech Stack:** Cloudflare Pages Functions (TypeScript, no bundler), Cloudflare D1 over HTTP with bound parameters, Cloudflare R2 (binding + multipart), Node 22 / Hono on the device, Vitest, dependency-free ES5-style browser JS.

## Global Constraints

- Task files nest to `##` only, never `###`.
- Every log field carrying a remote string goes through `q()` (`_lib/log.ts`, `portal-fetch.ts:196`). Object keys, filenames and passcodes are never logged.
- Device-side containment never trusts a portal-supplied path. Every path decision re-derives the exposed set locally.
- Failures fail closed and stay retryable: a refused or failed row keeps `ingested=0` and is reported, never silently rerouted.
- Verified post-conditions, never "we called it": re-read the state before logging an outcome.
- Fakes must model the store's constraints (`UNIQUE`, `NOT NULL`), not just its happy path — Task 1704's lesson.
- `npm run typecheck:templates` in `platform/plugins/cloudflare/mcp` is the only thing that type-checks the template; esbuild strips types without checking them.
- Portal template source is `skills/data-portal/template/`. The `packages/create-maxy-code/payload/…` copy is generated at publish time and gitignored — never edit it.
- Paths below are relative to `maxy-code/`.

## File Structure

| File | Responsibility | Task |
|---|---|---|
| `platform/plugins/cloudflare/skills/data-portal/schema.sql` | manifest columns, directory generation, pointer table | 1 |
| `…/template/functions/api/_lib/target.ts` (new) | one definition of a legal target path, portal side | 2 |
| `…/template/functions/api/upload.ts` | target validation, single-shot upload, multipart routes | 2, 3 |
| `…/template/functions/api/files.ts` | the uploads-panel listing rule | 4 |
| `…/template/functions/api/delete.ts` | withdraw vs delete by moved state | 5 |
| `…/template/functions/api/download.ts` | moved rows 302 to an own-upload link | 6 |
| `…/template/functions/api/_lib/portal-sign.mjs` | own-upload signed message | 6 |
| `platform/ui/server/routes/portal-fetch.ts` | own-upload admission, device side | 7 |
| `platform/ui/server/portal-uploads-pull.ts` | routing, collision, move, withdrawal, bound params | 8 |
| `platform/plugins/cloudflare/bin/portal-index-push.mjs` | atomic generation replace | 9 |
| `platform/ui/server/portal-index-loop.ts` (new) | 60s push loop | 10 |
| `platform/plugins/scheduling/mcp/src/lib/check-due-events.ts` | retire hourly push + its audit | 10 |
| `platform/ui/server/routes/storage-broker.ts` | four audit fields, config-absent union | 11 |
| `…/template/portal.js`, `index.html`, `portal.css` | drop targets, multi-file, drag-out, progress, poll | 12 |

---

### Task 1: Schema — manifest lifecycle columns and directory generations

**Files:**
- Modify: `platform/plugins/cloudflare/skills/data-portal/schema.sql`
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts` (create)

**Interfaces:**
- Produces: `manifest.targetPath TEXT NOT NULL DEFAULT ''`, `manifest.devicePath TEXT NOT NULL DEFAULT ''`, `manifest.deleted INTEGER NOT NULL DEFAULT 0`, `directory.generation INTEGER NOT NULL DEFAULT 0`, table `directory_state(accountId TEXT PRIMARY KEY, currentGeneration INTEGER NOT NULL)`.

- [ ] **Step 1: Write the failing test**

```ts
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'

const SQL = readFileSync(
  join(__dirname, '../../skills/data-portal/schema.sql'),
  'utf8',
)

describe('portal schema shape', () => {
  it('carries the manifest lifecycle columns', () => {
    expect(SQL).toMatch(/targetPath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
    expect(SQL).toMatch(/devicePath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
    expect(SQL).toMatch(/deleted\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
  })

  it('carries the directory generation and its pointer table', () => {
    expect(SQL).toMatch(/generation\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
    expect(SQL).toMatch(/CREATE TABLE IF NOT EXISTS directory_state/)
    expect(SQL).toMatch(/currentGeneration\s+INTEGER\s+NOT NULL/)
  })

  it('drops the objectKey UNIQUE that a foldered key makes wrong', () => {
    // objectKey is now ownerId/targetPath/filename. The upsert conflict target
    // is still objectKey, so the UNIQUE stays — this pins that it was not
    // removed by accident while the key shape changed.
    expect(SQL).toMatch(/objectKey\s+TEXT\s+UNIQUE\s+NOT NULL/)
  })

  it('documents the hand-run ALTERs for a portal deployed before this', () => {
    for (const col of ['targetPath', 'devicePath', 'deleted', 'generation']) {
      expect(SQL).toContain(`ALTER TABLE`)
      expect(SQL).toContain(col)
    }
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-schema-shape.test.ts`
Expected: FAIL — `targetPath` not found in schema.sql.

- [ ] **Step 3: Write the schema changes**

In `schema.sql`, replace the `manifest` block (currently lines 61-77) with:

```sql
-- One row per stored object, carrying a three-state lifecycle.
--
--   in transit  ingested=0, devicePath=''   bytes in R2
--   moved       ingested=1, devicePath set  bytes on the device, R2 object gone
--   withdrawn   deleted=1                   device file pending removal
--
-- `targetPath` is the exposed folder the client chose at upload, '' when they
-- chose none. It is part of `objectKey` (ownerId/targetPath/filename), because
-- a flat key would collide the same filename dropped into two folders: the
-- upsert would overwrite the first object, reset ingested, and the first file
-- would never land.
--
-- `devicePath` is written only after a verified device write, so its presence
-- IS the proof the move happened. A boolean would say a move occurred without
-- saying where, and the audit's unpublishedRouted check needs the path.
--
-- `deleted` marks a withdrawal of an already-moved file. The row is removed
-- outright once the device file is confirmed absent — not kept as a tombstone,
-- which would grow the manifest without bound and force every listing read to
-- filter it while describing bytes that no longer exist anywhere.
--
-- IF NOT EXISTS adds no column to an existing table. A portal deployed before
-- this needs the three hand-run once:
--   ALTER TABLE manifest ADD COLUMN targetPath TEXT NOT NULL DEFAULT '';
--   ALTER TABLE manifest ADD COLUMN devicePath TEXT NOT NULL DEFAULT '';
--   ALTER TABLE manifest ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
-- Their absence is loud: upload names targetPath in its INSERT.
CREATE TABLE IF NOT EXISTS manifest (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  fileId     TEXT UNIQUE NOT NULL,
  ownerId    TEXT NOT NULL,
  filename   TEXT NOT NULL,
  objectKey  TEXT UNIQUE NOT NULL,
  size       INTEGER NOT NULL,
  uploadedAt TEXT NOT NULL,
  ingested   INTEGER NOT NULL DEFAULT 0,
  targetPath TEXT NOT NULL DEFAULT '',
  devicePath TEXT NOT NULL DEFAULT '',
  deleted    INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS manifest_uningested ON manifest (ingested);
CREATE INDEX IF NOT EXISTS manifest_owner ON manifest (ownerId);
CREATE INDEX IF NOT EXISTS manifest_deleted ON manifest (deleted);
```

Append after the `directory` block:

```sql
-- The generation an account's directory rows belong to. The push writes the
-- next generation, flips this pointer in ONE statement, then deletes rows of
-- earlier generations. The flip is the atomic point: a listing racing a push
-- reads the old tree or the new one and never a half-built one. Cleanup after
-- the flip need not be atomic because nothing reads a superseded generation.
--
-- This replaces the delete-then-insert window recorded as backlog Task 1842.
-- At 60s cadence that window would be hit sixty times more often than hourly.
--
-- Hand-run once on a portal deployed before this:
--   ALTER TABLE directory ADD COLUMN generation INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS directory_state (
  accountId         TEXT PRIMARY KEY,
  currentGeneration INTEGER NOT NULL
);
```

And add `generation INTEGER NOT NULL DEFAULT 0,` to the `directory` table body, above the `UNIQUE (accountId, relPath)` line, changing that constraint to `UNIQUE (accountId, relPath, generation)` so two generations can hold the same path at once.

- [ ] **Step 4: Run test to verify it passes**

Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-schema-shape.test.ts`
Expected: PASS, 4 tests.

- [ ] **Step 5: Commit**

```bash
git add platform/plugins/cloudflare/skills/data-portal/schema.sql platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts
git commit -m "feat(1910): manifest lifecycle columns and directory generations"
```

---

### Task 2: Portal — one definition of a legal target, and a targeted single-shot upload

**Files:**
- Create: `platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts`
- Modify: `…/template/functions/api/upload.ts`
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts` (create), `__tests__/portal-upload-target.test.ts` (create)

**Interfaces:**
- Consumes: `grantAllows(grant: string[], relPath: string): boolean` from `_lib/session.ts`; `authorizeKey(ownerId, key): boolean` and `ownerPrefix(ownerId): string` from `_lib/authorize.ts`.
- Produces: `isSafeTargetSegment(s: string): boolean`; `type TargetCheck = { ok: true } | { ok: false; reason: 'segment' | 'grant' | 'index' }`; `checkTarget(db, accountId, grant, targetPath): Promise<TargetCheck>`; `uploadKey(ownerId: string, targetPath: string, filename: string): string`. `processUpload` gains a `targetPath: string` parameter after `filename`.

- [ ] **Step 1: Write the failing test for the target module**

Create `__tests__/portal-target.test.ts`:

```ts
import { describe, expect, it } from 'vitest'
import {
  checkTarget,
  isSafeTargetSegment,
  uploadKey,
} from '../../skills/data-portal/template/functions/api/_lib/target'

/** A D1 fake whose `first()` answers from a set of published folder rows.
 *  It models the real query's shape: only isDir=1 rows are folders. */
function fakeDb(folders: string[]) {
  return {
    prepare(sql: string) {
      return {
        bind(accountId: string, relPath: string) {
          return {
            async first() {
              if (!sql.includes('isDir = 1')) throw new Error('query must filter isDir')
              return folders.includes(relPath) ? { relPath } : null
            },
          }
        },
      }
    },
  } as never
}

describe('isSafeTargetSegment', () => {
  it('rejects traversal, separators, empties and NUL', () => {
    for (const bad of ['', '.', '..', 'a/b', 'a\\b', 'a\0b']) {
      expect(isSafeTargetSegment(bad)).toBe(false)
    }
  })
  it('accepts an ordinary folder name', () => {
    expect(isSafeTargetSegment('jobs')).toBe(true)
  })
})

describe('uploadKey', () => {
  it('collapses to owner/filename when untargeted', () => {
    expect(uploadKey('alice', '', 'a.pdf')).toBe('alice/a.pdf')
  })
  it('carries the target so two folders never collide', () => {
    expect(uploadKey('alice', 'jobs', 'a.pdf')).toBe('alice/jobs/a.pdf')
    expect(uploadKey('alice', 'jobs/2026', 'a.pdf')).toBe('alice/jobs/2026/a.pdf')
  })
})

describe('checkTarget', () => {
  it('admits the empty target without touching the index', async () => {
    const db = { prepare() { throw new Error('must not query for the empty target') } } as never
    expect(await checkTarget(db, 'acct', [], '')).toEqual({ ok: true })
  })

  it('admits a granted folder that the index publishes', async () => {
    expect(await checkTarget(fakeDb(['jobs']), 'acct', [], 'jobs')).toEqual({ ok: true })
  })

  it('refuses a folder outside the grant before reading the index', async () => {
    const db = { prepare() { throw new Error('must not query outside the grant') } } as never
    expect(await checkTarget(db, 'acct', ['output'], 'jobs')).toEqual({ ok: false, reason: 'grant' })
  })

  it('refuses a folder the index does not publish', async () => {
    expect(await checkTarget(fakeDb(['jobs']), 'acct', [], 'output')).toEqual({
      ok: false,
      reason: 'index',
    })
  })

  it('refuses a traversal segment before any lookup', async () => {
    const db = { prepare() { throw new Error('must not query a bad segment') } } as never
    expect(await checkTarget(db, 'acct', [], 'jobs/../secrets')).toEqual({
      ok: false,
      reason: 'segment',
    })
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-target.test.ts`
Expected: FAIL — cannot resolve `_lib/target`.

- [ ] **Step 3: Write `_lib/target.ts`**

```ts
import type { D1Database } from './types'
import { grantAllows } from './session'
import { ownerPrefix } from './authorize'

/**
 * One path segment of a legal target: not empty, not a traversal token, and
 * carrying no separator of either flavour. Rejected outright rather than
 * normalised, the same rule and the same reason as authorizeKey — a normaliser
 * is one more thing that can be wrong.
 *
 * Deliberately identical to the device's own safeSegment
 * (`portal-uploads-pull.ts`), which is what the parity test pins.
 */
export function isSafeTargetSegment(s: string): boolean {
  if (!s || s === '.' || s === '..') return false
  return !s.includes('/') && !s.includes('\\') && !s.includes('\0')
}

export type TargetCheck = { ok: true } | { ok: false; reason: 'segment' | 'grant' | 'index' }

/**
 * Whether this person may upload into `targetPath`.
 *
 * The rule is: every segment is safe, the person's grant admits the top-level
 * name, AND the account's published index carries a folder row for the exact
 * path. That last clause means you can only drop where you can see, which
 * introduces no new permission concept — the legal target set is exactly the
 * set of folders already rendered to this person.
 *
 * The empty target is always legal and costs no query; it is the default
 * uploads area, which is not in the index by construction.
 *
 * This is a convenience check. The security boundary is the device, which
 * re-derives the exposed set itself and refuses anything outside it. Both exist
 * for the same reason the download path has two checks: if this end is ever
 * wrong — a stale index, a bug, a compromised Pages project — the device
 * contains it.
 */
export async function checkTarget(
  db: D1Database,
  accountId: string,
  grant: string[],
  targetPath: string,
): Promise<TargetCheck> {
  if (targetPath === '') return { ok: true }
  const segments = targetPath.split('/')
  if (!segments.every(isSafeTargetSegment)) return { ok: false, reason: 'segment' }
  if (!grantAllows(grant, targetPath)) return { ok: false, reason: 'grant' }
  const row = await db
    .prepare('SELECT relPath FROM directory WHERE accountId = ? AND relPath = ? AND isDir = 1')
    .bind(accountId, targetPath)
    .first<{ relPath: string }>()
  return row ? { ok: true } : { ok: false, reason: 'index' }
}

/** `ownerId/targetPath/filename`, collapsing to `ownerId/filename` when
 *  untargeted. The target is IN the key: a flat key collides the same filename
 *  dropped into two folders, and the upsert would destroy the first object. */
export function uploadKey(ownerId: string, targetPath: string, filename: string): string {
  return targetPath
    ? `${ownerPrefix(ownerId)}${targetPath}/${filename}`
    : `${ownerPrefix(ownerId)}${filename}`
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-target.test.ts`
Expected: PASS, 8 tests.

- [ ] **Step 5: Write the failing test for the targeted upload**

Create `__tests__/portal-upload-target.test.ts`. Model the manifest with a `Map` keyed on `objectKey` so the `UNIQUE` is real (the Task 1704 lesson), and assert the emitted log lines:

```ts
import { describe, expect, it } from 'vitest'
import { processUpload } from '../../skills/data-portal/template/functions/api/upload'

function harness(opts: { folders?: string[]; grant?: string[] } = {}) {
  const folders = opts.folders ?? ['jobs']
  const objects = new Map<string, Uint8Array>()
  const rows = new Map<string, Record<string, unknown>>()
  const lines: string[] = []
  const env = {
    BUCKET: {
      async put(key: string, body: Uint8Array) { objects.set(key, body) },
      async head(key: string) { return objects.has(key) ? { size: objects.get(key)!.length } : null },
    },
    DB: {
      prepare(sql: string) {
        return {
          bind(...args: unknown[]) {
            return {
              async first() {
                if (sql.includes('FROM sessions')) {
                  return { ownerId: 'alice', accountId: 'acct', folders: (opts.grant ?? []).join(',') }
                }
                if (sql.includes('isDir = 1')) {
                  return folders.includes(String(args[1])) ? { relPath: args[1] } : null
                }
                if (sql.includes('SELECT fileId FROM manifest')) {
                  return rows.get(String(args[0])) ?? null
                }
                return null
              },
              async run() {
                const key = String(args[3])
                // Upsert on objectKey, exactly as the real UNIQUE forces.
                rows.set(key, { fileId: args[0], ownerId: args[1], filename: args[2], key,
                  size: args[4], targetPath: args[6], ingested: 0 })
              },
            }
          },
        }
      },
    },
  } as never
  let n = 0
  return { env, objects, rows, lines, newId: () => `id-${++n}`, log: (l: string) => lines.push(l) }
}

describe('targeted upload', () => {
  it('stores under the target and records it on the row', async () => {
    const h = harness()
    const res = await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1, 2]), h.env, h.log, h.newId, () => 1000)
    expect(res.status).toBe(200)
    expect([...h.objects.keys()]).toEqual(['alice/jobs/a.pdf'])
    expect(h.rows.get('alice/jobs/a.pdf')!.targetPath).toBe('jobs')
  })

  it('keeps the same filename in two folders as two distinct objects', async () => {
    const h = harness({ folders: ['jobs', 'output'] })
    await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
    await processUpload('s', 'a.pdf', 'output', new Uint8Array([2]), h.env, h.log, h.newId, () => 1000)
    expect([...h.objects.keys()].sort()).toEqual(['alice/jobs/a.pdf', 'alice/output/a.pdf'])
  })

  it('refuses an out-of-grant target and stores nothing', async () => {
    const h = harness({ grant: ['output'] })
    const res = await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
    expect(res.status).toBe(403)
    expect(h.objects.size).toBe(0)
    expect(h.lines.some((l) => l.includes('op=target-check') && l.includes('result=denied-grant'))).toBe(true)
  })

  it('refuses a target the index does not publish', async () => {
    const h = harness({ folders: [] })
    const res = await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
    expect(res.status).toBe(403)
    expect(h.lines.some((l) => l.includes('result=denied-index'))).toBe(true)
  })

  it('logs op=target-check result=allowed with the target on the happy path', async () => {
    const h = harness()
    await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
    expect(h.lines.some((l) => l.includes('op=target-check') && l.includes('result=allowed'))).toBe(true)
  })
})
```

- [ ] **Step 6: Run test to verify it fails**

Run: `npx vitest run __tests__/portal-upload-target.test.ts`
Expected: FAIL — `processUpload` takes 7 arguments, target ignored.

- [ ] **Step 7: Thread the target through `upload.ts`**

Add the import and the parameter, and replace the key derivation (currently `upload.ts:44-50`):

```ts
import { checkTarget, uploadKey } from './_lib/target'
```

`processUpload` signature becomes `(sessionId, filename, targetPath, bytes, env, log, newId, now)`. After the filename check and before the key:

```ts
  // The target is checked BEFORE any R2 call, the same ordering rule the owner
  // gate follows: a denied caller must not be able to cause a store write.
  const target = await checkTarget(env.DB, session.accountId, session.folders, targetPath)
  if (!target.ok) {
    log(
      `[data-portal] op=target-check uploadId=${uploadId} owner=${q(owner)} ` +
        `targetPath=${q(targetPath)} result=denied-${target.reason === 'segment' ? 'segment' : target.reason}`,
    )
    return { status: 403, payload: { ok: false, error: 'target not allowed' } }
  }
  log(
    `[data-portal] op=target-check uploadId=${uploadId} owner=${q(owner)} ` +
      `targetPath=${q(targetPath)} result=allowed`,
  )

  const key = uploadKey(owner, targetPath, filename)
```

Keep the existing `authorizeKey(owner, key)` guard immediately after — it still holds, because `authorizeKey` admits deeper keys and rejects `..` and a leading separator.

Extend the manifest INSERT to name `targetPath`:

```ts
    await env.DB.prepare(
      `INSERT INTO manifest (fileId, ownerId, filename, objectKey, size, uploadedAt, ingested, targetPath)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?)
         ON CONFLICT (objectKey) DO UPDATE SET
           fileId = excluded.fileId,
           size = excluded.size,
           uploadedAt = excluded.uploadedAt,
           targetPath = excluded.targetPath,
           ingested = 0,
           devicePath = '',
           deleted = 0`,
    )
      .bind(fileId, owner, filename, key, bytes.byteLength, new Date(startedMs).toISOString(), 0, targetPath)
      .run()
```

`devicePath` and `deleted` reset on conflict for the same reason `ingested` does: a replacement of an already-moved file is a new file at that path, and leaving either set would describe the previous one.

In `onRequestPost`, read the target from the form:

```ts
    filename = file.name
    targetPath = String(form.get('targetPath') ?? '')
    bytes = new Uint8Array(await file.arrayBuffer())
```

- [ ] **Step 8: Run both test files**

Run: `npx vitest run __tests__/portal-target.test.ts __tests__/portal-upload-target.test.ts`
Expected: PASS, 13 tests.

- [ ] **Step 9: Commit**

```bash
git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts \
        platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts \
        platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts \
        platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts
git commit -m "feat(1910): folder-targeted uploads, validated against grant and index"
```

---

### Task 3: Portal — multipart upload to 1 GiB

**Files:**
- Modify: `…/template/functions/api/upload.ts`
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts` (create)

**Interfaces:**
- Consumes: `checkTarget`, `uploadKey` from `_lib/target.ts`.
- Produces: `MAX_PART_BYTES = 25 * 1024 * 1024`, `MAX_TOTAL_BYTES = 1024 * 1024 * 1024`; `processMultipartCreate(sessionId, filename, targetPath, declaredSize, env, log, newId, now)`, `processMultipartPart(sessionId, uploadKeyStr, r2UploadId, partNumber, bytes, env, log)`, `processMultipartComplete(sessionId, uploadKeyStr, r2UploadId, parts, env, log, newId, now)`.

- [ ] **Step 1: Write the failing test**

```ts
import { describe, expect, it } from 'vitest'
import {
  MAX_PART_BYTES,
  MAX_TOTAL_BYTES,
  processMultipartCreate,
  processMultipartPart,
} from '../../skills/data-portal/template/functions/api/upload'

function harness() {
  const sessions = { ownerId: 'alice', accountId: 'acct', folders: '' }
  const uploads = new Map<string, { key: string; parts: Map<number, number> }>()
  const lines: string[] = []
  const env = {
    BUCKET: {
      async createMultipartUpload(key: string) {
        const id = `r2-${uploads.size + 1}`
        uploads.set(id, { key, parts: new Map() })
        return {
          uploadId: id,
          async uploadPart(n: number, body: Uint8Array) {
            uploads.get(id)!.parts.set(n, body.length)
            return { partNumber: n, etag: `etag-${n}` }
          },
        }
      },
      resumeMultipartUpload(key: string, id: string) {
        return {
          uploadId: id,
          async uploadPart(n: number, body: Uint8Array) {
            uploads.get(id)!.parts.set(n, body.length)
            return { partNumber: n, etag: `etag-${n}` }
          },
        }
      },
    },
    DB: {
      prepare(sql: string) {
        return {
          bind(...a: unknown[]) {
            return {
              async first() { return sql.includes('FROM sessions') ? sessions : null },
              async run() {},
            }
          },
        }
      },
    },
  } as never
  return { env, uploads, lines, log: (l: string) => lines.push(l), newId: () => 'uid-1' }
}

describe('multipart upload', () => {
  it('refuses a declared size over the total cap without opening a session', async () => {
    const h = harness()
    const res = await processMultipartCreate('s', 'big.zip', '', MAX_TOTAL_BYTES + 1, h.env, h.log, h.newId, () => 1)
    expect(res.status).toBe(413)
    expect(h.uploads.size).toBe(0)
  })

  it('opens a session under the cap and returns its ids', async () => {
    const h = harness()
    const res = await processMultipartCreate('s', 'big.zip', '', 200 * 1024 * 1024, h.env, h.log, h.newId, () => 1)
    expect(res.status).toBe(200)
    expect((res.payload as { r2UploadId: string }).r2UploadId).toBe('r2-1')
    expect((res.payload as { key: string }).key).toBe('alice/big.zip')
  })

  it('refuses a part over the per-part gate', async () => {
    const h = harness()
    await processMultipartCreate('s', 'big.zip', '', 200 * 1024 * 1024, h.env, h.log, h.newId, () => 1)
    const res = await processMultipartPart('s', 'alice/big.zip', 'r2-1', 1,
      new Uint8Array(MAX_PART_BYTES + 1), h.env, h.log)
    expect(res.status).toBe(413)
  })

  it('refuses a part whose key is not the caller own', async () => {
    const h = harness()
    const res = await processMultipartPart('s', 'bob/big.zip', 'r2-1', 1, new Uint8Array(4), h.env, h.log)
    expect(res.status).toBe(403)
  })

  it('accepts a part within the gate and returns its etag', async () => {
    const h = harness()
    await processMultipartCreate('s', 'big.zip', '', 200 * 1024 * 1024, h.env, h.log, h.newId, () => 1)
    const res = await processMultipartPart('s', 'alice/big.zip', 'r2-1', 1, new Uint8Array(8), h.env, h.log)
    expect(res.status).toBe(200)
    expect((res.payload as { etag: string }).etag).toBe('etag-1')
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run __tests__/portal-upload-multipart.test.ts`
Expected: FAIL — `processMultipartCreate` is not exported.

- [ ] **Step 3: Implement the three multipart stages in `upload.ts`**

```ts
/** The per-part ceiling. An upload part is read wholly into the Function's
 *  memory exactly as a single-shot body is, so this is the SAME isolate bound
 *  the single-shot path has always had (see MAX_UPLOAD_BYTES above) — the
 *  parts do not relax it, they let a large file respect it repeatedly. */
export const MAX_PART_BYTES = 25 * 1024 * 1024

/** The whole-file ceiling. Not an isolate limit: it is a storage-and-patience
 *  bound on what one client may push through one portal in one go. */
export const MAX_TOTAL_BYTES = 1024 * 1024 * 1024

export async function processMultipartCreate(
  sessionId: string, filename: string, targetPath: string, declaredSize: number,
  env: PortalEnv, log: Logger, newId: () => string, now: () => number,
): Promise<Handler> {
  const startedMs = now()
  const session = await resolveSession(env.DB, sessionId, startedMs)
  if (!session) {
    log('[data-portal] op=mp-create owner=none result=denied')
    return { status: 401, payload: { ok: false, error: 'denied' } }
  }
  const owner = session.ownerId
  const uploadId = newId()
  if (!filename || filename.length > MAX_FILENAME) {
    log(`[data-portal] op=mp-create uploadId=${uploadId} owner=${q(owner)} result=bad-filename`)
    return { status: 400, payload: { ok: false, error: 'filename required' } }
  }
  // Checked BEFORE the session is opened: an over-cap declaration must not
  // leave an orphan multipart upload accruing storage in R2.
  if (!Number.isFinite(declaredSize) || declaredSize < 0 || declaredSize > MAX_TOTAL_BYTES) {
    log(
      `[data-portal] op=mp-create uploadId=${uploadId} owner=${q(owner)} ` +
        `declared=${declaredSize} result=too-large limit=${MAX_TOTAL_BYTES}`,
    )
    return { status: 413, payload: { ok: false, error: `over the ${MAX_TOTAL_BYTES}-byte limit` } }
  }
  const target = await checkTarget(env.DB, session.accountId, session.folders, targetPath)
  if (!target.ok) {
    log(
      `[data-portal] op=target-check uploadId=${uploadId} owner=${q(owner)} ` +
        `targetPath=${q(targetPath)} result=denied-${target.reason}`,
    )
    return { status: 403, payload: { ok: false, error: 'target not allowed' } }
  }
  const key = uploadKey(owner, targetPath, filename)
  if (!authorizeKey(owner, key)) {
    log(`[data-portal] op=mp-create uploadId=${uploadId} owner=${q(owner)} result=bad-filename`)
    return { status: 400, payload: { ok: false, error: 'invalid filename' } }
  }
  const mp = await env.BUCKET.createMultipartUpload(key)
  log(
    `[data-portal] op=mp-create uploadId=${uploadId} owner=${q(owner)} key=${q(key)} ` +
      `declared=${declaredSize} parts=${Math.ceil(declaredSize / MAX_PART_BYTES)} result=ok`,
  )
  return { status: 200, payload: { ok: true, uploadId, key, r2UploadId: mp.uploadId } }
}

export async function processMultipartPart(
  sessionId: string, key: string, r2UploadId: string, partNumber: number,
  bytes: Uint8Array, env: PortalEnv, log: Logger,
): Promise<Handler> {
  const session = await resolveSession(env.DB, sessionId, Date.now())
  if (!session) {
    log('[data-portal] op=r2-put owner=none result=denied')
    return { status: 401, payload: { ok: false, error: 'denied' } }
  }
  // The key is re-authorized on EVERY part, not only at create: the client
  // supplies it on each request, and a session that may write alice/ must not
  // be able to append a part to bob/.
  if (!authorizeKey(session.ownerId, key)) {
    log(`[data-portal] op=r2-put owner=${q(session.ownerId)} result=denied reason=not-owner`)
    return { status: 403, payload: { ok: false, error: 'denied' } }
  }
  if (bytes.byteLength > MAX_PART_BYTES) {
    log(
      `[data-portal] op=r2-put owner=${q(session.ownerId)} part=${partNumber} ` +
        `bytes=${bytes.byteLength} result=too-large limit=${MAX_PART_BYTES}`,
    )
    return { status: 413, payload: { ok: false, error: 'part too large' } }
  }
  const mp = env.BUCKET.resumeMultipartUpload(key, r2UploadId)
  const part = await mp.uploadPart(partNumber, bytes)
  log(
    `[data-portal] op=r2-put owner=${q(session.ownerId)} key=${q(key)} ` +
      `part=${partNumber} bytes=${bytes.byteLength} result=ok`,
  )
  return { status: 200, payload: { ok: true, partNumber, etag: part.etag } }
}
```

`processMultipartComplete` mirrors the single-shot path from `env.BUCKET.resumeMultipartUpload(key, r2UploadId).complete(parts)` onward: it sums the part sizes, refuses over `MAX_TOTAL_BYTES` (aborting the upload so no orphan remains), writes the same upsert as Task 2, then re-reads `head(key)` and the row and logs the same `op=complete objectPresent= rowPresent=` line.

Route all three from `onRequestPost` on an `action` form field (`create`, `part`, `complete`), with no action meaning today's single-shot path so the common case stays one request.

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run __tests__/portal-upload-multipart.test.ts`
Expected: PASS, 5 tests.

- [ ] **Step 5: Add the R2 multipart types to `_lib/types.ts`**

Add `createMultipartUpload(key: string): Promise<R2MultipartUpload>` and `resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload` to the bucket interface, with `R2MultipartUpload` carrying `uploadId`, `uploadPart`, `complete` and `abort`. Run `npm run typecheck:templates` — esbuild strips types without checking them, so this is the only thing that would catch a wrong shape.

- [ ] **Step 6: Commit**

```bash
git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts \
        platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts \
        platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts
git commit -m "feat(1910): multipart upload to 1 GiB at a 25 MiB part bound"
```

---

### Task 4: Portal — the uploads-panel listing rule

**Files:**
- Modify: `…/template/functions/api/files.ts`
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts` (create)

**Interfaces:**
- Produces: `shouldListUpload(row: { devicePath: string; deleted: number }, visibleTops: Set<string>): boolean`.

- [ ] **Step 1: Write the failing test**

```ts
import { describe, expect, it } from 'vitest'
import { shouldListUpload } from '../../skills/data-portal/template/functions/api/files'

const visible = new Set(['jobs', 'output'])

describe('shouldListUpload', () => {
  it('lists a row still in transit', () => {
    expect(shouldListUpload({ devicePath: '', deleted: 0 }, visible)).toBe(true)
  })
  it('hides a moved row whose folder the client can browse', () => {
    expect(shouldListUpload({ devicePath: 'jobs/a.pdf', deleted: 0 }, visible)).toBe(false)
  })
  it('lists a moved row the client cannot reach in the tree', () => {
    expect(shouldListUpload({ devicePath: 'uploads/alice/a.pdf', deleted: 0 }, visible)).toBe(true)
  })
  it('hides a withdrawn row', () => {
    expect(shouldListUpload({ devicePath: 'uploads/alice/a.pdf', deleted: 1 }, visible)).toBe(false)
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run __tests__/portal-uploads-listing.test.ts`
Expected: FAIL — `shouldListUpload` is not exported.

- [ ] **Step 3: Implement the rule in `files.ts`**

```ts
/**
 * Whether an upload row belongs in the client's own uploads panel.
 *
 * One rule covering both halves of the move: a row is listed while its bytes
 * are still in R2, and after the move ONLY when the file is not reachable in
 * the folder tree. So a targeted file leaves the panel and appears in its
 * folder, and an untargeted file — which lands in `uploads/`, never exposed —
 * stays in the panel and downloads through the device.
 *
 * Nothing is listed twice, and nothing becomes unreachable. A withdrawn row is
 * hidden while the device removal is pending; it is deleted outright once the
 * device file is confirmed absent, so this branch is a brief state, not a
 * tombstone filter.
 */
export function shouldListUpload(
  row: { devicePath: string; deleted: number },
  visibleTops: Set<string>,
): boolean {
  if (row.deleted) return false
  if (!row.devicePath) return true
  return !visibleTops.has(row.devicePath.split('/')[0])
}
```

In `processFiles`, select the new columns, build `visibleTops` from the grant-filtered directory rows' top segments, and filter `files` through `shouldListUpload`. The manifest query becomes:

```ts
  const rows = await env.DB.prepare(
    'SELECT fileId, filename, objectKey, size, uploadedAt, ingested, targetPath, devicePath, deleted ' +
      'FROM manifest WHERE ownerId = ? ORDER BY uploadedAt DESC',
  )
```

Order matters: the directory read must happen before the filter, because `visibleTops` comes from it. Add `listed=` and `moved=` counts to the `op=list` line so an operator can see the split without reading a row.

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run __tests__/portal-uploads-listing.test.ts`
Expected: PASS, 4 tests.

- [ ] **Step 5: Commit**

```bash
git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts \
        platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts
git commit -m "feat(1910): one listing rule for in-transit and moved uploads"
```

---

### Task 5: Portal — withdraw a moved file, delete an in-transit one

**Files:**
- Modify: `…/template/functions/api/delete.ts`
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts` (create)

- [ ] **Step 1: Write the failing test**

```ts
import { describe, expect, it } from 'vitest'
import { processDelete } from '../../skills/data-portal/template/functions/api/delete'

function harness(row: { devicePath: string } | null) {
  const state = { objectDeleted: false, rowDeleted: false, marked: false }
  const lines: string[] = []
  const env = {
    BUCKET: {
      async delete() { state.objectDeleted = true },
      async head() { return state.objectDeleted ? null : {} },
    },
    DB: {
      prepare(sql: string) {
        return {
          bind() {
            return {
              async first() {
                if (sql.includes('FROM sessions')) return { ownerId: 'alice', accountId: 'a', folders: '' }
                if (sql.includes('devicePath')) return row
                if (sql.includes('SELECT fileId FROM manifest')) return state.rowDeleted ? null : row
                return null
              },
              async run() {
                if (sql.startsWith('UPDATE manifest')) state.marked = true
                if (sql.startsWith('DELETE FROM manifest')) state.rowDeleted = true
              },
            }
          },
        }
      },
    },
  } as never
  return { env, state, lines, log: (l: string) => lines.push(l) }
}

describe('delete by moved state', () => {
  it('deletes object and row outright when the file is still in transit', async () => {
    const h = harness({ devicePath: '' })
    const res = await processDelete('s', 'alice/a.pdf', h.env, h.log, () => 1)
    expect(res.status).toBe(200)
    expect(h.state.objectDeleted).toBe(true)
    expect(h.state.rowDeleted).toBe(true)
    expect(h.state.marked).toBe(false)
  })

  it('marks a moved file for withdrawal instead of touching R2', async () => {
    const h = harness({ devicePath: 'jobs/a.pdf' })
    const res = await processDelete('s', 'alice/jobs/a.pdf', h.env, h.log, () => 1)
    expect(res.status).toBe(200)
    expect(h.state.marked).toBe(true)
    expect(h.state.rowDeleted).toBe(false)
    expect(h.lines.some((l) => l.includes('op=withdraw') && l.includes('result=marked'))).toBe(true)
  })

  it('reports an unknown key as not found without writing anything', async () => {
    const h = harness(null)
    const res = await processDelete('s', 'alice/nope.pdf', h.env, h.log, () => 1)
    expect(res.status).toBe(404)
    expect(h.state.objectDeleted).toBe(false)
    expect(h.state.marked).toBe(false)
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run __tests__/portal-delete-withdraw.test.ts`
Expected: FAIL — the moved case still deletes from R2.

- [ ] **Step 3: Branch `processDelete` on `devicePath`**

After the `authorizeKey` guard, read the row first:

```ts
  const row = await env.DB.prepare('SELECT devicePath FROM manifest WHERE objectKey = ?')
    .bind(key)
    .first<{ devicePath: string }>()
  if (!row) {
    log(`${'[data-portal]'} op=delete owner=${owner} key=${q(key)} result=absent`)
    return { status: 404, payload: { ok: false, error: 'not found' } }
  }

  // A moved file's bytes are on the device and its R2 object is already gone.
  // Deleting here would report success while the client's file stayed on the
  // device forever, which is the failure this branch exists to prevent. The
  // pull removes the device file, confirms it is absent, and only then removes
  // the row — so the withdrawal is complete on BOTH sides or on neither.
  if (row.devicePath) {
    await env.DB.prepare('UPDATE manifest SET deleted = 1 WHERE objectKey = ?').bind(key).run()
    const marked = await env.DB.prepare('SELECT deleted FROM manifest WHERE objectKey = ?')
      .bind(key)
      .first<{ deleted: number }>()
    const ok = marked?.deleted === 1
    log(
      `[data-portal] op=withdraw owner=${owner} key=${q(key)} ` +
        `result=${ok ? 'marked' : 'not-marked'}`,
    )
    return { status: ok ? 200 : 500, payload: { ok, pending: true } }
  }
```

The existing R2-delete-then-row-delete path follows unchanged for the in-transit case.

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run __tests__/portal-delete-withdraw.test.ts`
Expected: PASS, 3 tests.

- [ ] **Step 5: Commit**

```bash
git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts \
        platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts
git commit -m "feat(1910): withdraw a moved upload, delete an in-transit one"
```

---

### Task 6: Portal — own-upload signed links, and moved rows that still download

**Files:**
- Modify: `…/template/functions/api/_lib/portal-sign.mjs`, `…/template/functions/api/download.ts`
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts` (extend), `__tests__/portal-moved-download.test.ts` (create)

**Interfaces:**
- Produces: `signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs): Promise<string>`, `verifyOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs, signature, nowMs): Promise<boolean>`, `buildOwnUploadUrl(origin, secret, accountId, ownerId, relPath, expiresAtMs): Promise<string>`.

- [ ] **Step 1: Write the failing test**

Add to `__tests__/portal-sign.test.ts`:

```ts
import { signOwnUpload, signPath, verifyOwnUpload } from '../../skills/data-portal/template/functions/api/_lib/portal-sign.mjs'

describe('own-upload signatures', () => {
  it('verifies a link minted for the same owner', async () => {
    const exp = Date.now() + 60_000
    const sig = await signOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp)
    expect(await verifyOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp, sig, Date.now())).toBe(true)
  })

  it('refuses the same path under a different owner', async () => {
    const exp = Date.now() + 60_000
    const sig = await signOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp)
    expect(await verifyOwnUpload('s3cret', 'acct', 'bob', 'uploads/alice/a.pdf', exp, sig, Date.now())).toBe(false)
  })

  it('is a DIFFERENT message from the exposed-folder signature', async () => {
    // The two link types must not be interchangeable: an exposed-folder
    // signature must never admit an uploads path, or the owner binding is
    // decorative.
    const exp = Date.now() + 60_000
    const own = await signOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp)
    const plain = await signPath('s3cret', 'acct', 'uploads/alice/a.pdf', exp)
    expect(own).not.toBe(plain)
  })

  it('refuses an expired link', async () => {
    const exp = Date.now() - 1
    const sig = await signOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp)
    expect(await verifyOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp, sig, Date.now())).toBe(false)
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run __tests__/portal-sign.test.ts`
Expected: FAIL — `signOwnUpload` is not exported.

- [ ] **Step 3: Add the second message shape to `portal-sign.mjs`**

```js
/**
 * The own-upload message. A SECOND shape rather than an extended first one.
 *
 * The literal 'uploads' discriminator is what stops an exposed-folder signature
 * ever admitting an uploads path, and the ownerId inside the HMAC is what stops
 * one portal minting a link into another person's upload folder.
 *
 * Two shapes rather than one because the device and the portal redeploy at
 * different times — the device through an installer publish, the portal through
 * site-deploy. A single extended message would break every existing download in
 * the window between them. An already-deployed portal never mints this type, so
 * nothing regresses.
 *
 * @param {string} accountId @param {string} ownerId @param {string} relPath
 * @param {number} expiresAtMs
 */
function ownUploadMessage(accountId, ownerId, relPath, expiresAtMs) {
  return `${accountId}\nuploads\n${ownerId}\n${relPath}\n${expiresAtMs}`
}

/** @param {string} secret @param {string} accountId @param {string} ownerId
 *  @param {string} relPath @param {number} expiresAtMs @returns {Promise<string>} */
export async function signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs) {
  const key = await keyFor(secret)
  const mac = await crypto.subtle.sign(
    'HMAC',
    key,
    new TextEncoder().encode(ownUploadMessage(accountId, ownerId, relPath, expiresAtMs)),
  )
  return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('')
}

export async function verifyOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs, signature, nowMs) {
  if (!secret || !signature) return false
  if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) return false
  return constantTimeEqual(await signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs), signature)
}

export async function buildOwnUploadUrl(origin, secret, accountId, ownerId, relPath, expiresAtMs) {
  const sig = await signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs)
  const url = new URL('/api/portal/fetch', origin)
  url.searchParams.set('accountId', accountId)
  url.searchParams.set('owner', ownerId)
  url.searchParams.set('path', relPath)
  url.searchParams.set('exp', String(expiresAtMs))
  url.searchParams.set('sig', sig)
  return url.toString()
}
```

- [ ] **Step 4: Route a moved row's download through it**

In `processDownload`, read `devicePath` before the R2 get. When it is set, mint an own-upload link and return it for a 302 rather than reading bytes that are no longer there. When it is empty, today's R2 path is unchanged. Write `__tests__/portal-moved-download.test.ts` asserting: an in-transit row streams R2 bytes, a moved row returns a `url` and status 200 with `op=download result=moved`, and a moved row belonging to another owner is still refused 403 by `authorizeKey` before anything is minted.

- [ ] **Step 5: Run the tests**

Run: `npx vitest run __tests__/portal-sign.test.ts __tests__/portal-moved-download.test.ts`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs \
        platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts \
        platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts \
        platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts
git commit -m "feat(1910): own-upload signed links so a moved file still downloads"
```

---

### Task 7: Device — admit an own-upload path, and only its own owner

**Files:**
- Modify: `platform/ui/server/routes/portal-fetch.ts`
- Test: `platform/ui/server/routes/__tests__/portal-fetch.test.ts` (extend)

**Interfaces:**
- Consumes: the own-upload message shape from Task 6.
- Produces: `verifyOwnUploadSignature(secret, accountId, ownerId, relPath, expiresAtMs, signature, nowMs): Promise<boolean>`; `resolveFetchTarget(accountDir, relPath, ownerId?)` gains a third parameter.

- [ ] **Step 1: Write the failing test**

```ts
it('serves a file under the caller own uploads folder', async () => {
  // uploads/ is never in the exposed set, so this path is admitted by the
  // owner rule alone. That is the whole point of the second link type.
  const target = await resolveFetchTarget(accountDir, 'uploads/alice/a.pdf', 'alice')
  expect(target.ok).toBe(true)
})

it('refuses another person uploads folder even with a valid signature', async () => {
  const target = await resolveFetchTarget(accountDir, 'uploads/bob/a.pdf', 'alice')
  expect(target).toEqual({ ok: false, reason: 'not-exposed' })
})

it('still refuses a non-exposed folder when an owner is supplied', async () => {
  // The owner rule admits uploads/<owner>/ ONLY. It must not become a general
  // bypass of the exposed-set check.
  const target = await resolveFetchTarget(accountDir, 'documents/private.pdf', 'alice')
  expect(target).toEqual({ ok: false, reason: 'not-exposed' })
})

it('refuses an uploads path when no owner was signed', async () => {
  const target = await resolveFetchTarget(accountDir, 'uploads/alice/a.pdf')
  expect(target).toEqual({ ok: false, reason: 'not-exposed' })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd platform/ui && npx vitest run server/routes/__tests__/portal-fetch.test.ts`
Expected: FAIL — the uploads path is refused as `not-exposed`.

- [ ] **Step 3: Add the owner admission rule**

In `resolveFetchTarget`, after the realpath containment assertion and before the exposed-set check, admit the one extra shape:

```ts
  const rel = absolute.slice(root.length + 1)
  const top = rel.split(sep)[0]

  // The ONE path outside the exposed set that is servable: the caller's own
  // upload folder. An untargeted upload moves to `uploads/<ownerId>/`, which is
  // never exposed to the folder tree — without this the client could not
  // download the file they themselves just sent.
  //
  // Scoped to the signed ownerId, so this is not a bypass: `uploads/bob/` is
  // refused for alice exactly as `documents/` is. The owner comes from inside
  // the HMAC (verifyOwnUploadSignature), never from a bare query parameter.
  if (ownerId) {
    const own = ['uploads', ownerId].join(sep) + sep
    if (rel.startsWith(own)) {
      try {
        const st = await stat(absolute)
        if (!st.isFile()) return { ok: false, reason: 'enoent' }
        return { ok: true, absolute, sizeBytes: st.size }
      } catch {
        return { ok: false, reason: 'enoent' }
      }
    }
    return { ok: false, reason: 'not-exposed' }
  }

  if (!exposed.includes(top)) return { ok: false, reason: 'not-exposed' }
```

In `handle`, read `owner` from the query. When present, verify with `verifyOwnUploadSignature` and pass the owner to `resolveFetchTarget`; when absent, today's `verifySignature` path and call are unchanged. Log `op=request` with an `owner=` field so the two link types are distinguishable in the journal.

- [ ] **Step 4: Run test to verify it passes**

Run: `cd platform/ui && npx vitest run server/routes/__tests__/portal-fetch.test.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add platform/ui/server/routes/portal-fetch.ts platform/ui/server/routes/__tests__/portal-fetch.test.ts
git commit -m "feat(1910): device admits a signed own-upload path, scoped to its owner"
```

---

### Task 8: Device — route, suffix collisions, move, and propagate withdrawals

**Files:**
- Modify: `platform/ui/server/portal-uploads-pull.ts`
- Test: `platform/ui/server/__tests__/portal-uploads-pull.test.ts` (extend)

**Interfaces:**
- Consumes: `resolveExposedDirs` from `plugins/cloudflare/bin/schema-exposed-dirs.mjs` (already imported by `portal-fetch.ts:22`).
- Produces: `PendingRow` gains `ownerId: string`, `targetPath: string`, `deleted: number`; `resolveDestination(accountDir, row, exposed: string[])` gains a third parameter and a `'bad-target'` reason; `PullCfExec` gains `r2ObjectDelete(bucket, key): Promise<void>` and `d1Exec(name, sql, params)`.

- [ ] **Step 1: Write the failing tests**

```ts
describe('targeted routing', () => {
  it('routes into an exposed folder named by the row', () => {
    const d = resolveDestination('/acct', { ...row, targetPath: 'jobs' }, ['jobs', 'output'])
    expect(d.ok && d.final).toBe('/acct/jobs/a.pdf')
  })

  it('defaults to uploads/<ownerId>, never the person display name', () => {
    // ownerId, not people.name: two people sharing a display name would
    // otherwise share one folder and overwrite each other.
    const d = resolveDestination('/acct', { ...row, ownerId: 'alice', targetPath: '' }, ['jobs'])
    expect(d.ok && d.final).toBe('/acct/uploads/alice/a.pdf')
  })

  it('refuses a target outside the device own exposed set', () => {
    // The portal already checked this. The device checks it AGAIN because the
    // portal is not the boundary — this is what contains a stale index or a
    // compromised Pages project.
    const d = resolveDestination('/acct', { ...row, targetPath: 'documents' }, ['jobs'])
    expect(d).toEqual({ ok: false, reason: 'bad-target' })
  })

  it('refuses a traversal segment in the target', () => {
    const d = resolveDestination('/acct', { ...row, targetPath: 'jobs/../../etc' }, ['jobs'])
    expect(d).toEqual({ ok: false, reason: 'bad-target' })
  })
})

describe('collisions', () => {
  it('suffixes rather than overwriting an existing file', async () => {
    // Last-write-wins would silently destroy a file the client can no longer
    // recover, since the R2 copy is deleted on move.
    await writeFile(join(dir, 'a.pdf'), 'first')
    const r = await pullAccount({ ...opts, rows: [row] })
    expect(existsSync(join(dir, 'a (2).pdf'))).toBe(true)
    expect(await readFile(join(dir, 'a.pdf'), 'utf8')).toBe('first')
  })
})

describe('move semantics', () => {
  it('deletes the R2 object only after the size verifies', async () => {
    const deleted: string[] = []
    await pullAccount({ ...opts, rows: [row], cf: { ...cf, r2ObjectDelete: async (_b, k) => { deleted.push(k) } } })
    expect(deleted).toEqual([row.objectKey])
  })

  it('leaves the object in place when the write fails', async () => {
    const deleted: string[] = []
    await pullAccount({ ...opts, rows: [{ ...row, size: 999 }],
      cf: { ...cf, r2ObjectDelete: async (_b, k) => { deleted.push(k) } } })
    expect(deleted).toEqual([])
  })

  it('records devicePath in the same claim as ingested', async () => {
    const sql: string[] = []
    await pullAccount({ ...opts, rows: [row], cf: { ...cf, d1Exec: async (_n, s) => { sql.push(s) } } })
    expect(sql.some((s) => s.includes('devicePath') && s.includes('ingested = 1'))).toBe(true)
  })
})

describe('withdrawal', () => {
  it('removes the device file then the row, in that order', async () => {
    await writeFile(join(dir, 'a.pdf'), 'x')
    const sql: string[] = []
    await pullAccount({ ...opts, rows: [{ ...row, deleted: 1, devicePath: 'jobs/a.pdf' }],
      cf: { ...cf, d1Exec: async (_n, s) => { sql.push(s) } } })
    expect(existsSync(join(dir, 'a.pdf'))).toBe(false)
    expect(sql.some((s) => s.startsWith('DELETE FROM manifest'))).toBe(true)
  })

  it('keeps the row when the device file could not be removed', async () => {
    // Removing the row while the file survives would strand it: nothing would
    // ever look at that path again.
    const sql: string[] = []
    await pullAccount({ ...opts, rows: [{ ...row, deleted: 1, devicePath: 'jobs/locked.pdf' }],
      cf: { ...cf, d1Exec: async (_n, s) => { sql.push(s) } } })
    expect(sql.some((s) => s.startsWith('DELETE FROM manifest'))).toBe(false)
  })
})

describe('bound parameters', () => {
  it('never interpolates a value into SQL', async () => {
    const calls: { sql: string; params: unknown[] }[] = []
    await pullAccount({ ...opts, rows: [row],
      cf: { ...cf, d1Exec: async (_n, sql, params) => { calls.push({ sql, params }) } } })
    for (const c of calls) expect(c.sql).not.toContain(row.fileId)
    expect(calls.some((c) => c.params.includes(row.fileId))).toBe(true)
  })
})
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd platform/ui && npx vitest run server/__tests__/portal-uploads-pull.test.ts`
Expected: FAIL — `resolveDestination` takes two arguments.

- [ ] **Step 3: Implement routing, collisions, move and withdrawal**

`resolveDestination` gains the exposed set and a target branch:

```ts
export function resolveDestination(
  accountDir: string,
  row: PendingRow,
  exposed: string[],
): DestinationResult {
  if (!UUID.test(row.fileId)) return { ok: false, reason: 'bad-fileid' }
  if (!safeSegment(row.ownerId)) return { ok: false, reason: 'bad-owner' }
  if (!safeSegment(row.filename)) return { ok: false, reason: 'bad-filename' }

  // The device re-derives the legal folder set itself. The portal checked this
  // already; that check is convenience, this one is the boundary. A stale
  // index, a bug, or a compromised Pages project stops here.
  let root: string
  let dir: string
  if (row.targetPath) {
    const segments = row.targetPath.split('/')
    if (!segments.every(safeSegment)) return { ok: false, reason: 'bad-target' }
    if (!exposed.includes(segments[0])) return { ok: false, reason: 'bad-target' }
    root = resolve(accountDir)
    dir = join(root, ...segments)
  } else {
    // ownerId, not people.name: a display name is not unique, and two people
    // sharing one would share a folder and overwrite each other.
    root = resolve(accountDir, 'uploads')
    dir = join(root, row.ownerId)
  }

  const final = join(dir, row.filename)
  if (!final.startsWith(root + sep)) return { ok: false, reason: 'bad-target' }
  return { ok: true, dir, tmp: join(dir, '.uploads-tmp', row.filename), final }
}
```

Add `nextFreePath(final)`, which returns `final` when nothing is there and otherwise `stem (2).ext`, `stem (3).ext` and so on. Last-write-wins is not survivable once the move deletes the R2 copy: the overwritten file would be unrecoverable.

After the size verification and before the claim, delete the R2 object and re-list to confirm, then claim with `devicePath` in the same statement, bound:

```ts
    await cf.r2ObjectDelete(bucketName, row.objectKey)
    // Re-read rather than assume: an object that survived its own pull is a
    // leak that emits nothing at request time, and `unmovedObjects` in the
    // audit is derived from exactly this state.
    const stillThere = await cf.r2ObjectHead(bucketName, row.objectKey)
    log(`${head} op=move r2Deleted=${stillThere === null}`)

    await cf.d1Exec(
      dbName,
      'UPDATE manifest SET ingested = 1, devicePath = ? WHERE fileId = ?',
      [relativeDevicePath, row.fileId],
    )
```

Withdrawal rows (`deleted = 1`) are handled in their own branch before the fetch: `unlink` the recorded `devicePath`, `stat` to confirm absence, and only then `DELETE FROM manifest WHERE fileId = ?`. A failed removal keeps the row so the next cycle retries.

Switch `PullCfExec` from `d1Query(name, sql)` to `d1Exec(name, sql, params)` backed by `createD1Client` from `d1-http.mjs`, so nothing is interpolated. This closes the interpolation the current code documents at `portal-uploads-pull.ts:164-167`.

Extend `PENDING_SELECT` to `WHERE m.ingested = 0 OR m.deleted = 1` and select `ownerId`, `targetPath`, `devicePath`, `deleted`.

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd platform/ui && npx vitest run server/__tests__/portal-uploads-pull.test.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add platform/ui/server/portal-uploads-pull.ts platform/ui/server/__tests__/portal-uploads-pull.test.ts
git commit -m "feat(1910): route uploads to their folder, move them, propagate withdrawals"
```

---

### Task 9: Push — atomic generation replace

**Files:**
- Modify: `platform/plugins/cloudflare/bin/portal-index-push.mjs`
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts` (extend)

- [ ] **Step 1: Write the failing test**

```ts
it('flips the pointer in one statement after every row is written', async () => {
  const order = []
  await pushAccount({ ...opts, d1: { query: async (sql) => { order.push(sql.split(' ')[0]); return { results: [] } } } })
  const flip = order.lastIndexOf('INSERT')     // the pointer upsert
  const rows = order.lastIndexOf('INSERT', flip - 1)
  expect(rows).toBeLessThan(flip)
})

it('never deletes rows before the flip', async () => {
  const order = []
  await pushAccount({ ...opts, d1: { query: async (sql) => { order.push(sql); return { results: [] } } } })
  const flipAt = order.findIndex((s) => s.includes('directory_state'))
  const deleteAt = order.findIndex((s) => s.startsWith('DELETE FROM directory'))
  expect(flipAt).toBeGreaterThanOrEqual(0)
  expect(deleteAt).toBeGreaterThan(flipAt)
})

it('writes nothing at all when the exposed set is empty', async () => {
  // Fail-closed must not delete the account rows: that would be
  // indistinguishable from a successful publish of an account with no
  // deliverables.
  const calls = []
  await pushAccount({ ...opts, schemaText: null, d1: { query: async (s) => { calls.push(s); return { results: [] } } } })
  expect(calls.filter((s) => s.startsWith('DELETE') || s.startsWith('INSERT'))).toEqual([])
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-index-push.test.ts`
Expected: FAIL — the push still issues `DELETE FROM directory` first.

- [ ] **Step 3: Replace delete-then-insert with generation-then-flip**

Read the current generation, write every row at `current + 1`, upsert `directory_state` to the new generation in one statement, then delete every row of an earlier generation. Keep the fail-closed early return ahead of all of it, unchanged.

Readers select through the pointer:

```sql
SELECT relPath, sizeBytes, modifiedAt, isDir
  FROM directory d
  JOIN directory_state s ON s.accountId = d.accountId AND s.currentGeneration = d.generation
 WHERE d.accountId = ?
```

Update the same join into `files.ts` and `download.ts` from Tasks 4 and 6.

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run __tests__/portal-index-push.test.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add platform/plugins/cloudflare/bin/portal-index-push.mjs \
        platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts \
        platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts \
        platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts
git commit -m "feat(1910): atomic generation flip for the portal index replace"
```

---

### Task 10: Push — 60s loop on the device, and retire the hourly heartbeat

**Files:**
- Create: `platform/ui/server/portal-index-loop.ts`
- Modify: `platform/ui/server/index.ts`, `platform/plugins/scheduling/mcp/src/lib/check-due-events.ts`
- Test: `platform/ui/server/__tests__/portal-index-loop.test.ts` (create)

**Interfaces:**
- Produces: `PORTAL_INDEX_PUSH_INTERVAL_MS = 60_000`, `runPortalIndexPush(root: string): Promise<string>`.

- [ ] **Step 1: Write the failing test**

```ts
it('is registered at sixty seconds', () => {
  expect(PORTAL_INDEX_PUSH_INTERVAL_MS).toBe(60_000)
})

it('returns before minting a credential when no account has a portal', async () => {
  // Most installs have neither a portal nor a house credential. Minting
  // unconditionally would make this loop report an error on every one of them
  // forever — the same rule the pull and the audit already follow.
  const note = await runPortalIndexPush(rootWithNoPortals)
  expect(note).toBe('portals=0 pushed=0')
})

it('one account failure does not stop the rest', async () => {
  const note = await runPortalIndexPush(rootWithTwoPortalsFirstBroken)
  expect(note).toContain('pushed=1')
  expect(note).toContain('failed=1')
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd platform/ui && npx vitest run server/__tests__/portal-index-loop.test.ts`
Expected: FAIL — module not found.

- [ ] **Step 3: Write the loop and register it**

`portal-index-loop.ts` enumerates portal accounts with the same `discoverPortalAccounts` the pull uses, and calls the push per account, catching per account so one failure never stops the rest. Register in `index.ts` beside the pull:

```ts
  // The index push moved here from the scheduling heartbeat (Task 1910). That
  // spawn's own header recorded it had "never been observed running on a
  // device"; this is the mechanism the pull demonstrably uses. 60s rather than
  // hourly is what makes a device-side change visible to a client in the same
  // minute — the hourly gate left the client's tree up to two hours stale.
  // First run offset past the pull's 65s so a boot does not fire three loops
  // against the same credential mint.
  registerLoop({
    name: 'portal-index-push',
    intervalMs: PORTAL_INDEX_PUSH_INTERVAL_MS,
    firstRunDelayMs: 80_000,
    run: () => runPortalIndexPush(auditPlatformRoot),
  })
```

Delete `maybeRunPortalIndexPush` and `auditPortalIndexPush` from `check-due-events.ts` along with their call sites and the `PORTAL_INDEX_INTERVAL_MS` constant, and remove the corresponding lines from `scheduling/PLUGIN.md`.

- [ ] **Step 4: Run the tests**

Run: `cd platform/ui && npx vitest run server/__tests__/portal-index-loop.test.ts` then `cd ../plugins/scheduling/mcp && npx vitest run`
Expected: PASS both, with no scheduling test still referencing the retired functions.

- [ ] **Step 5: Commit**

```bash
git add platform/ui/server/portal-index-loop.ts platform/ui/server/index.ts \
        platform/ui/server/__tests__/portal-index-loop.test.ts \
        platform/plugins/scheduling/mcp/src/lib/check-due-events.ts \
        platform/plugins/scheduling/PLUGIN.md
git commit -m "feat(1910): 60s index push loop, retiring the hourly heartbeat spawn"
```

---

### Task 11: Audit — four fields for four silent failures

**Files:**
- Modify: `platform/ui/server/routes/storage-broker.ts`
- Test: `platform/ui/server/__tests__/data-portal-audit.test.ts` (extend)

- [ ] **Step 1: Write the failing test**

```ts
it('counts an object that survived its own pull', () => {
  const r = reconcileDataPortal(
    [{ key: 'alice/a.pdf' }],
    [{ objectKey: 'alice/a.pdf', ingested: 1, devicePath: 'jobs/a.pdf', deleted: 0, uploadedAt: t }],
    now,
  )
  expect(r.unmovedObjects.length).toBe(1)
})

it('does not count an in-transit object as unmoved', () => {
  const r = reconcileDataPortal(
    [{ key: 'alice/a.pdf' }],
    [{ objectKey: 'alice/a.pdf', ingested: 0, devicePath: '', deleted: 0, uploadedAt: t }],
    now,
  )
  expect(r.unmovedObjects.length).toBe(0)
})

it('counts a withdrawal the device has not acted on', () => {
  const r = reconcileDataPortal([], [{ objectKey: 'k', ingested: 1, devicePath: 'jobs/a.pdf', deleted: 1, uploadedAt: t }], now)
  expect(r.deletePendingRows).toBe(1)
})

it('counts a routed file the index never published', () => {
  const r = reconcileDataPortal([], [{ objectKey: 'k', ingested: 1, devicePath: 'jobs/a.pdf', deleted: 0, uploadedAt: t }], now, new Set())
  expect(r.unpublishedRouted).toBe(1)
})

it('reports an account whose config vanished rather than falling silent', async () => {
  // Task 1901: enumerating by config alone made the audit go quiet in exactly
  // the incident it exists to report.
  const note = await runDataPortalAudit(rootWhereConfigVanishedButStateRemembers)
  expect(note).toContain('config-absent=1')
})

it('emits lastPushAgeSec from the push state file', async () => {
  const lines = capture(() => runDataPortalAudit(rootWithStalePushState))
  expect(lines.some((l) => l.includes('lastPushAgeSec='))).toBe(true)
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
Expected: FAIL — `unmovedObjects` is not in the reconcile result.

- [ ] **Step 3: Extend the reconcile and the line**

Add the four derivations, and union the enumeration with accounts named in `portal-index-push-state.json` so a vanished config reports `config-absent` instead of dropping the account from the set. The line becomes:

```
[data-portal-audit] account=… objects=N rows=N orphanObjects=N phantomRows=N
  oldestUningestedHrs=N pendingRows=N unmovedObjects=N deletePendingRows=N
  unpublishedRouted=N lastPushAgeSec=N
```

Counts, never keys: an object key is a real person's filename. Keep every read under its own catch so one data-source failure degrades the line rather than removing it.

- [ ] **Step 4: Run test to verify it passes**

Run: `cd platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add platform/ui/server/routes/storage-broker.ts platform/ui/server/__tests__/data-portal-audit.test.ts
git commit -m "feat(1910): audit the move, the withdrawal, the routing and the push age"
```

---

### Task 12: Client — drop onto a folder, drag out, multi-file, progress, poll

**Files:**
- Modify: `…/template/portal.js`, `…/template/index.html`, `…/template/portal.css`
- Test: manual DOM verification plus the live acceptance in Task 13. There is no server test harness for the static template; `template-config.test.ts` covers only its layout.

- [ ] **Step 1: Make folder rows and the open listing drop targets**

In `renderEntries`, for `kind === 'directory'`, add `dragover`/`dragleave`/`drop` handlers that set a `dp-drop-target` class and upload each dropped file with `e.relPath` as the target. Add the same three handlers to `entriesEl` itself so a drop on the open folder's whitespace targets the folder currently in `prefix`.

- [ ] **Step 2: Take every dropped file, not the first**

Replace both `files[0]` reads with a loop over `dt.files`, uploading in sequence so the status line reports one file at a time. Add `multiple` to `#dp-file` in `index.html`.

- [ ] **Step 3: Add drag-out to the client's own upload rows**

In `render`, mirror the `dragstart` handler `renderEntries` already has (`portal.js:227-233`), pointing at `/api/download?key=…&redirect=1` resolved absolute.

- [ ] **Step 4: Report progress and distinguish failures**

Replace the single `'Upload failed.'` with a status-derived message: 401 "Your session expired. Sign in again.", 403 "You cannot upload to that folder.", 413 "That file is too large.", network "Could not reach the server.". For a file above one part, upload through the multipart stages and update the status with the part count as each completes.

- [ ] **Step 5: Poll while the tab is visible**

```js
  /* The tree is pushed from the device every 60s (Task 1910), so a poll at the
     same cadence is what makes a device-side change appear without the client
     clicking anything. Polling was rejected in Tasks 1885 and 1900 on the
     explicit reasoning that "a poll against an hourly tree is waste" — that
     premise died with the hourly push.

     Gated on visibility so a forgotten background tab costs nothing, and
     skipped while a refresh is already in flight so a slow response cannot
     stack requests. */
  var polling = false;
  setInterval(function () {
    if (document.hidden || panelEl.hidden || polling) return;
    polling = true;
    refresh().then(function () { polling = false; });
  }, 60000);
```

- [ ] **Step 6: Verify in a browser**

Serve the template with `npx wrangler pages dev` against a seeded local D1 and drive it with the chrome-devtools MCP: drop a file on a folder row, confirm one `POST /api/upload` carrying `targetPath`, confirm the row appears, drag a file out and confirm the 302. Read console messages and network requests; both are part of the verification surface.

- [ ] **Step 7: Commit**

```bash
git add platform/plugins/cloudflare/skills/data-portal/template/portal.js \
        platform/plugins/cloudflare/skills/data-portal/template/index.html \
        platform/plugins/cloudflare/skills/data-portal/template/portal.css
git commit -m "feat(1910): drop onto a folder, drag out, multi-file, progress, 60s poll"
```

---

### Task 13: Parity test, docs, and the deploy gate

**Files:**
- Create: `platform/ui/server/__tests__/portal-target-parity.test.ts`
- Modify: `.docs/data-portal.md`, `.docs/data-portal-folder-index.md`, `skills/data-portal/SKILL.md`, `platform/plugins/docs/references/` portal guide, `.tasks/LANES.md`

- [ ] **Step 1: Write the parity test**

One fixture set of target paths, asserted to be accepted or refused identically by the portal's `checkTarget` segment rule and the device's `resolveDestination`. The two ends implement the same rule separately, so this is what pins them together — the same pattern as the existing HMAC parity test.

```ts
const FIXTURES = [
  { target: 'jobs', exposed: ['jobs'], allowed: true },
  { target: 'jobs/2026', exposed: ['jobs'], allowed: true },
  { target: 'documents', exposed: ['jobs'], allowed: false },
  { target: 'jobs/../etc', exposed: ['jobs'], allowed: false },
  { target: '../etc', exposed: ['jobs'], allowed: false },
  { target: '', exposed: ['jobs'], allowed: true },
  { target: 'jobs/', exposed: ['jobs'], allowed: false },
  { target: '.', exposed: ['jobs'], allowed: false },
]
```

- [ ] **Step 2: Run the full suites**

Run: `cd platform/ui && npx vitest run` then `cd ../plugins/cloudflare/mcp && npx vitest run && npm run typecheck:templates`
Expected: all green.

- [ ] **Step 3: Update the docs**

`.docs/data-portal.md` gains the manifest lifecycle, the two signed-link types and why there are two, and the new cadence. `.docs/data-portal-folder-index.md` gains the generation flip (noting it supersedes backlog 1842) and that `uploads/` stays unexposed while an own-upload link reaches it. `SKILL.md` and the shipped user guide gain the client-facing behaviour. Shipped skill markdown carries no em-dashes.

- [ ] **Step 4: Commit**

```bash
git add platform/ui/server/__tests__/portal-target-parity.test.ts .docs/ \
        platform/plugins/cloudflare/skills/data-portal/SKILL.md platform/plugins/docs/references/
git commit -m "test(1910): pin portal and device target rules; docs"
```

- [ ] **Step 5: Record the deploy gate**

Live acceptance needs the portal to enumerate accounts again, which is Task 1909's restore of the quarantined `data-portal.json`. Until that lands, success items 1 to 10 cannot be driven on glsmith. The hand-run `ALTER TABLE` statements from Task 1 are also required on any portal deployed before this change; their absence is loud, because the upload names `targetPath` in its INSERT.

---

## Self-Review

**Spec coverage.** Manifest state machine → Task 1, 4. Upload key → Task 2. Target validation both ends → Tasks 2, 8, 13. Serving a moved untargeted file → Tasks 6, 7. Withdrawal → Tasks 5, 8. Large uploads → Task 3. Atomic replace → Task 9. Cadence → Task 10. Audit → Task 11. Client → Task 12. Download routing → Task 6. Out-of-scope items appear in no task, as intended. Dependency on 1909 → Task 13 Step 5.

**Placeholder scan.** No TBD, no "handle edge cases", no "similar to Task N". Task 12 has no automated test and says so explicitly with the reason, rather than implying coverage that does not exist.

**Type consistency.** `checkTarget` returns `TargetCheck` in Tasks 2 and 3. `resolveDestination` takes `(accountDir, row, exposed)` in Tasks 8 and 13. `PullCfExec.d1Exec(name, sql, params)` is used in Task 8 only. `shouldListUpload(row, visibleTops)` is defined and used in Task 4. `signOwnUpload`/`verifyOwnUpload` are minted in Task 6 and verified in Task 7 with matching argument order.
