@rubytech/create-maxy-code 0.1.478 → 0.1.479

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md +1244 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +462 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +321 -0
  5. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +2177 -0
  6. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +201 -0
  7. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +217 -0
  8. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +80 -0
  9. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +163 -0
  10. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  11. package/payload/platform/lib/graph-style/dist/index.js +22 -0
  12. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  13. package/payload/platform/lib/graph-style/src/__tests__/parity.test.ts +6 -0
  14. package/payload/platform/lib/graph-style/src/index.ts +20 -0
  15. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +13 -1
  16. package/payload/platform/plugins/cloudflare/PLUGIN.md +26 -1
  17. package/payload/platform/plugins/cloudflare/bin/d1-http.mjs +80 -0
  18. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +17 -2
  19. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +357 -0
  20. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +103 -0
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts +96 -0
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +120 -0
  24. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +59 -4
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +176 -0
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-state-contract.test.ts +75 -0
  27. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +131 -0
  28. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +68 -0
  29. package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +88 -0
  30. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +39 -0
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +68 -1
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +30 -1
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +87 -0
  34. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +8 -4
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +10 -0
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +107 -1
  37. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +82 -2
  38. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -0
  39. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +56 -0
  40. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +98 -2
  41. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +12 -0
  42. package/payload/platform/plugins/docs/references/cloudflare.md +12 -0
  43. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts +2 -0
  44. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js +66 -0
  46. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js.map +1 -0
  47. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts +41 -0
  48. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts.map +1 -0
  49. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js +75 -0
  50. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js.map +1 -0
  51. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +122 -0
  52. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  53. package/payload/server/public/activity.html +8 -6
  54. package/payload/server/public/assets/{AdminLoginScreens-DR5eLF-a.js → AdminLoginScreens-DkxQZnvB.js} +1 -1
  55. package/payload/server/public/assets/AdminShell-Cv_nIFGe.js +2 -0
  56. package/payload/server/public/assets/Checkbox-CsTWK8mm.js +1 -0
  57. package/payload/server/public/assets/activity-BbnTiVBT.js +1 -0
  58. package/payload/server/public/assets/{admin-D2KLZnQW.js → admin-BZY3i_Ff.js} +1 -1
  59. package/payload/server/public/assets/{browser-CDWqEiCD.js → browser-BbIiSFKr.js} +1 -1
  60. package/payload/server/public/assets/{calendar-BG7fOpbB.js → calendar-BvxeTHvQ.js} +1 -1
  61. package/payload/server/public/assets/chat-DUKxufRk.js +1 -0
  62. package/payload/server/public/assets/chevron-left-BtfJD6LL.js +1 -0
  63. package/payload/server/public/assets/data-hl1wa-Ax.js +1 -0
  64. package/payload/server/public/assets/{graph-DRloNic4.js → graph-CDSGRoeb.js} +1 -1
  65. package/payload/server/public/assets/graph-labels-C5qLwnWA.js +1 -0
  66. package/payload/server/public/assets/{maximize-2-CzDaBugs.js → maximize-2-CAzH8dED.js} +1 -1
  67. package/payload/server/public/assets/{operator-BZeeyait.js → operator-BK7r8DAB.js} +1 -1
  68. package/payload/server/public/assets/{page-ef-6rRPL.js → page-B5YngOrK.js} +1 -1
  69. package/payload/server/public/assets/{page-B0obcg3b.js → page-BsCS0Dmc.js} +1 -1
  70. package/payload/server/public/assets/{public-9D3MMOkC.js → public-C1Pz-IBs.js} +1 -1
  71. package/payload/server/public/assets/{rotate-ccw-DXEKUhXP.js → rotate-ccw-UDln4Tou.js} +1 -1
  72. package/payload/server/public/assets/{tasks-BB0L7e5a.js → tasks-B-cLsn3p.js} +1 -1
  73. package/payload/server/public/assets/{time-entry-format-CsYAjplS.js → time-entry-format-DPOBIE18.js} +1 -1
  74. package/payload/server/public/assets/{triangle-alert-0lSBIUg-.js → triangle-alert-Dz-CZKo5.js} +1 -1
  75. package/payload/server/public/assets/{useCopyFeedback-jTBQ2h0V.js → useCopyFeedback-wtUHGohk.js} +1 -1
  76. package/payload/server/public/assets/{useSelectionMode-BooArySZ.js → useSelectionMode-sph7AQT5.js} +1 -1
  77. package/payload/server/public/assets/useSubAccountSwitcher-BRIluIKf.css +1 -0
  78. package/payload/server/public/assets/{useVoiceRecorder-PUT8q_sm.js → useVoiceRecorder-DN4jYQ0A.js} +1 -1
  79. package/payload/server/public/brand-defaults.css +1 -0
  80. package/payload/server/public/browser.html +5 -5
  81. package/payload/server/public/calendar.html +6 -6
  82. package/payload/server/public/chat.html +12 -12
  83. package/payload/server/public/data.html +11 -11
  84. package/payload/server/public/graph.html +11 -11
  85. package/payload/server/public/index.html +14 -14
  86. package/payload/server/public/operator.html +14 -14
  87. package/payload/server/public/privacy.html +1 -0
  88. package/payload/server/public/public.html +12 -12
  89. package/payload/server/public/tasks.html +6 -5
  90. package/payload/server/public/vnc-popout.html +1 -0
  91. package/payload/server/server.js +741 -366
  92. package/payload/server/public/assets/AdminShell-D9zaUxPd.js +0 -2
  93. package/payload/server/public/assets/Checkbox-DcbbAIe5.js +0 -1
  94. package/payload/server/public/assets/activity-C9P5NjEZ.js +0 -1
  95. package/payload/server/public/assets/chat-RyH-WYQC.js +0 -1
  96. package/payload/server/public/assets/chevron-left-DmXCr6nB.js +0 -1
  97. package/payload/server/public/assets/data-BaY_LkLR.js +0 -1
  98. package/payload/server/public/assets/graph-labels-DD-GqS4v.js +0 -1
  99. package/payload/server/public/assets/useSubAccountSwitcher-D9knHOKq.css +0 -1
  100. /package/payload/server/public/assets/{useSubAccountSwitcher-BZnzc5C3.js → useSubAccountSwitcher-v8LyBzZN.js} +0 -0
@@ -0,0 +1,2177 @@
1
+ # Data portal class-derived allowlist — implementation plan
2
+
3
+ > **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.
4
+
5
+ **Goal:** Let an enrolled portal user browse and download their sub-account's deliverable folders, where "deliverable" is derived per account from that account's own `SCHEMA.md` rather than a hardcoded list.
6
+
7
+ **Architecture:** The device walks only the exposed folders and full-replaces a D1 `directory` table with metadata (no bytes), so listing survives the device being offline. Download mints a five-minute HMAC-signed link to a new public install route, which re-derives the exposed set itself rather than trusting the path it was handed.
8
+
9
+ **Tech Stack:** TypeScript, vitest, Cloudflare Pages Functions + D1, Node 22 ESM (`.mjs` with JSDoc types), Hono.
10
+
11
+ **Spec:** [`../specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md`](../specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md)
12
+ **Task:** [`../../../../.tasks/1831-data-portal-exposes-only-uploads-not-the-sub-accounts-folder-tree.md`](../../../../.tasks/1831-data-portal-exposes-only-uploads-not-the-sub-accounts-folder-tree.md)
13
+
14
+ ## Global Constraints
15
+
16
+ - **Node 22 required.** Every command below assumes `export PATH="$HOME/.nvm/versions/node/v22.22.0/bin:$PATH"`.
17
+ - **Test runner is vitest**, invoked from the plugin dir: `cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run <file>`.
18
+ - **Baseline is 139 tests across 10 files, all green.** Never let that regress.
19
+ - **`npm run typecheck:templates` must stay exit 0.** It typechecks `template/functions/api/*.ts` and `_lib/*.mjs` under `--strict --checkJs`, so every `.mjs` needs JSDoc types.
20
+ - **`.mjs` modules in `_lib/` are the shared-code precedent** (`passcode.mjs` is imported by both `bin/portal-enrol.mjs` and the template). Follow it.
21
+ - **Never spawn `wrangler` per statement.** That is the Task 1805 defect. All D1 access from the device goes over HTTP with bound parameters.
22
+ - **No production import from `platform/ui` into `platform/plugins`.** Only a test does that today (`server/lib/calendar-ics.test.ts:12`) and this sprint does not change it.
23
+ - **Signature is not the security boundary.** The install re-derives the exposed dir set on every request. A valid signature over a non-exposed path is still refused.
24
+ - **Fail closed on a missing `SCHEMA.md`** — expose nothing. This deliberately inverts `fs-schema-guard.sh:76-78`, which fails open; every place that does it carries that reason in a comment.
25
+ - **Log tags:** `[portal-index]` device side, `[data-portal]` portal side, `[portal-fetch]` install side.
26
+
27
+ ---
28
+
29
+ ## File Structure
30
+
31
+ | File | Status | Responsibility |
32
+ |---|---|---|
33
+ | `platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs` | create | `SCHEMA.md` text → exposed dir names. Pure. |
34
+ | `platform/plugins/cloudflare/bin/d1-http.mjs` | create | D1 over HTTP with bound params |
35
+ | `platform/plugins/cloudflare/bin/portal-index-push.mjs` | create | walk exposed dirs, full-replace `directory` rows |
36
+ | `platform/plugins/cloudflare/bin/portal-enrol.mjs` | modify | write `accountId` on the `people` row |
37
+ | `.../data-portal/schema.sql` | modify | `directory` table, `people.accountId` |
38
+ | `.../template/functions/api/_lib/portal-sign.mjs` | create | mint + verify signed links (portal side) |
39
+ | `.../template/functions/api/_lib/types.ts` | modify | `PORTAL_FETCH_SECRET`, `PORTAL_INSTALL_ORIGIN` on `PortalEnv` |
40
+ | `.../template/functions/api/_lib/session.ts` | modify | `resolveSession` also returns `accountId` |
41
+ | `.../template/functions/api/files.ts` | modify | return `entries` beside `files` |
42
+ | `.../template/functions/api/download.ts` | modify | indexed-file branch: HEAD pre-flight, then signed link |
43
+ | `.../template/portal.js`, `index.html` | modify | breadcrumb folder navigation |
44
+ | `platform/ui/server/routes/portal-fetch.ts` | create | verify signature, re-resolve exposure, serve bytes |
45
+ | `platform/ui/server/index.ts` | modify | register the subapp |
46
+ | `.../scheduling/mcp/src/scripts/check-due-events.ts` | modify | spawn the push hourly |
47
+
48
+ ---
49
+
50
+ ### Task 1: Exposed-dir resolver
51
+
52
+ The one place that decides which folders a client may see. Pure text in, names out — no filesystem, so it is trivially testable.
53
+
54
+ **Files:**
55
+ - Create: `platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs`
56
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts`
57
+
58
+ **Interfaces:**
59
+ - Produces: `resolveExposedDirs(schemaText: string | null): { schemaPresent: boolean, allowed: string[], domainBuckets: string[], pluginOwned: string[], collisions: string[], exposed: string[] }`
60
+
61
+ - [ ] **Step 1: Write the failing test**
62
+
63
+ ```ts
64
+ // platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts
65
+ import { describe, it, expect } from 'vitest'
66
+ import { resolveExposedDirs } from '../../bin/schema-exposed-dirs.mjs'
67
+
68
+ const ALLOWED = ['projects', 'contacts', 'documents', 'output', 'uploads', 'quotes', 'sites']
69
+
70
+ function schema(opts: { allowed?: string[]; ontology?: string; pluginOwned?: string } = {}): string {
71
+ const allowed = opts.allowed ?? ALLOWED
72
+ return [
73
+ '# Account schema',
74
+ '',
75
+ '```allowed-top-level',
76
+ ...allowed,
77
+ '```',
78
+ '',
79
+ opts.pluginOwned ?? '',
80
+ '',
81
+ opts.ontology ?? '',
82
+ '',
83
+ ].join('\n')
84
+ }
85
+
86
+ // The generator writes this region with an ASCII hyphen separator
87
+ // (account-schema-owned-dirs.py:294).
88
+ const ONTOLOGY = [
89
+ '<!-- ontology-buckets:start -->',
90
+ '## Domain entity buckets (from the graph ontology)',
91
+ '',
92
+ '- `quotes/` - one folder per Quotation record.',
93
+ '<!-- ontology-buckets:end -->',
94
+ ].join('\n')
95
+
96
+ // The plugin-owned region uses an EM DASH (account-schema-owned-dirs.py:281).
97
+ const PLUGIN_OWNED = [
98
+ '<!-- plugin-owned-dirs:start -->',
99
+ '## Plugin-owned top-level workspaces',
100
+ '',
101
+ '- `sites/` — Owned by the cloudflare plugin.',
102
+ '<!-- plugin-owned-dirs:end -->',
103
+ ].join('\n')
104
+
105
+ describe('resolveExposedDirs', () => {
106
+ it('exposes ontology buckets plus output', () => {
107
+ const r = resolveExposedDirs(schema({ ontology: ONTOLOGY }))
108
+ expect(r.exposed).toEqual(['output', 'quotes'])
109
+ expect(r.schemaPresent).toBe(true)
110
+ })
111
+
112
+ it('exposes output alone when there is no ontology region', () => {
113
+ expect(resolveExposedDirs(schema()).exposed).toEqual(['output'])
114
+ })
115
+
116
+ it('drops a bucket that is absent from the allowed block', () => {
117
+ const r = resolveExposedDirs(schema({ allowed: ['output', 'documents'], ontology: ONTOLOGY }))
118
+ expect(r.exposed).toEqual(['output'])
119
+ })
120
+
121
+ // The contract that fails silently if got wrong: an em dash in the ontology
122
+ // region must NOT parse, because the generator never writes one there.
123
+ it('does not parse an em-dash separator as a domain bucket', () => {
124
+ const emDash = ONTOLOGY.replace('- one folder per', '— one folder per')
125
+ expect(resolveExposedDirs(schema({ ontology: emDash })).domainBuckets).toEqual([])
126
+ })
127
+
128
+ it('never exposes operator-data or upload buckets', () => {
129
+ const r = resolveExposedDirs(schema({ ontology: ONTOLOGY }))
130
+ for (const d of ['documents', 'contacts', 'projects', 'uploads']) {
131
+ expect(r.exposed).not.toContain(d)
132
+ }
133
+ })
134
+
135
+ it('reports a plugin-owned collision and keeps the dir out of exposed', () => {
136
+ const collide = ONTOLOGY.replace('`quotes/`', '`sites/`').replace('Quotation', 'Site')
137
+ const r = resolveExposedDirs(schema({ ontology: collide, pluginOwned: PLUGIN_OWNED }))
138
+ expect(r.collisions).toEqual(['sites'])
139
+ expect(r.exposed).toEqual(['output'])
140
+ })
141
+
142
+ it('fails closed on a missing schema', () => {
143
+ const r = resolveExposedDirs(null)
144
+ expect(r.schemaPresent).toBe(false)
145
+ expect(r.exposed).toEqual([])
146
+ })
147
+
148
+ it('fails closed when the allowed block is absent', () => {
149
+ const r = resolveExposedDirs('# no fence here')
150
+ expect(r.exposed).toEqual([])
151
+ })
152
+ })
153
+ ```
154
+
155
+ - [ ] **Step 2: Run test to verify it fails**
156
+
157
+ ```bash
158
+ export PATH="$HOME/.nvm/versions/node/v22.22.0/bin:$PATH"
159
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/schema-exposed-dirs.test.ts
160
+ ```
161
+
162
+ Expected: FAIL — `Failed to resolve import "../../bin/schema-exposed-dirs.mjs"`.
163
+
164
+ - [ ] **Step 3: Write the implementation**
165
+
166
+ ```js
167
+ // platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs
168
+ // Which folders of an account may a portal client see?
169
+ //
170
+ // Not a list in this file. The answer is derived per account from that
171
+ // account's own SCHEMA.md, which is generated by
172
+ // platform/scripts/lib/account-schema-owned-dirs.py: one bucket per top-level
173
+ // entity of the brand's vertical ontology, plus whatever plugins claim, inside
174
+ // a closed allowed-top-level set.
175
+ //
176
+ // Exposed = ontology buckets + `output`, intersected with the allowed set.
177
+ // Ontology buckets hold per-entity artifacts ("File a job's artifacts under its
178
+ // entity folder", the region's own prose); `output` is the delivery convention
179
+ // (doc-source-resolve.ts:14 calls it agent-authored). Operator-data buckets,
180
+ // uploads, plugin workspaces and platform-internal dirs are in neither source
181
+ // and so are never exposed.
182
+
183
+ const ONT_START = '<!-- ontology-buckets:start -->'
184
+ const ONT_END = '<!-- ontology-buckets:end -->'
185
+ const OWNED_START = '<!-- plugin-owned-dirs:start -->'
186
+ const OWNED_END = '<!-- plugin-owned-dirs:end -->'
187
+
188
+ /** The delivery convention, always exposed when the account allows it. */
189
+ export const ALWAYS_EXPOSED = 'output'
190
+
191
+ /**
192
+ * The generator writes the ontology region with an ASCII hyphen and the
193
+ * plugin-owned region with an em dash (account-schema-owned-dirs.py:294 vs
194
+ * :281). Matching the wrong one yields zero buckets and reads as "this account
195
+ * has no deliverables", which is why each region has its own pattern.
196
+ */
197
+ const DOMAIN_LINE = /^- `([^`/]+)\/` - one folder per /
198
+ const OWNED_LINE = /^- `([^`/]+)\/` — /
199
+
200
+ /** @param {string} text @param {string} start @param {string} end */
201
+ function region(text, start, end) {
202
+ const i = text.indexOf(start)
203
+ if (i === -1) return null
204
+ const j = text.indexOf(end, i)
205
+ return text.slice(i, j === -1 ? text.length : j)
206
+ }
207
+
208
+ /** @param {string} text @returns {string[] | null} */
209
+ function allowedBlockOrNull(text) {
210
+ const lines = text.split('\n')
211
+ const start = lines.findIndex((l) => l.trim() === '```allowed-top-level')
212
+ if (start === -1) return null
213
+ for (let j = start + 1; j < lines.length; j++) {
214
+ if (lines[j].trim() === '```') {
215
+ return lines.slice(start + 1, j).map((l) => l.trim()).filter(Boolean)
216
+ }
217
+ }
218
+ return null
219
+ }
220
+
221
+ /**
222
+ * @param {string | null} schemaText Contents of accounts/<id>/SCHEMA.md, or null when absent.
223
+ * @returns {{ schemaPresent: boolean, allowed: string[], domainBuckets: string[],
224
+ * pluginOwned: string[], collisions: string[], exposed: string[] }}
225
+ */
226
+ export function resolveExposedDirs(schemaText) {
227
+ const empty = {
228
+ schemaPresent: false,
229
+ allowed: /** @type {string[]} */ ([]),
230
+ domainBuckets: /** @type {string[]} */ ([]),
231
+ pluginOwned: /** @type {string[]} */ ([]),
232
+ collisions: /** @type {string[]} */ ([]),
233
+ exposed: /** @type {string[]} */ ([]),
234
+ }
235
+ // Fail CLOSED. fs-schema-guard.sh:76-78 fails open on a missing schema so an
236
+ // unseeded legacy account is not write-blocked; a read surface facing a
237
+ // client must do the opposite. Do not "fix" this to match the guard.
238
+ if (typeof schemaText !== 'string' || schemaText.length === 0) return empty
239
+
240
+ const allowed = allowedBlockOrNull(schemaText)
241
+ if (allowed === null) return { ...empty, schemaPresent: true }
242
+
243
+ const ont = region(schemaText, ONT_START, ONT_END)
244
+ const owned = region(schemaText, OWNED_START, OWNED_END)
245
+
246
+ /** @param {string | null} text @param {RegExp} re */
247
+ const dirsIn = (text, re) => {
248
+ if (!text) return []
249
+ /** @type {string[]} */ const out = []
250
+ for (const line of text.split('\n')) {
251
+ const m = re.exec(line.trim())
252
+ if (m && !out.includes(m[1])) out.push(m[1])
253
+ }
254
+ return out
255
+ }
256
+
257
+ const domainBuckets = dirsIn(ont, DOMAIN_LINE)
258
+ const pluginOwned = dirsIn(owned, OWNED_LINE)
259
+
260
+ // The generator omits a plugin-claimed dir from the ontology region
261
+ // (account-schema-owned-dirs.py:287), so this should be empty in practice.
262
+ // When it is not, the bucket silently vanishes from the client's view — the
263
+ // caller logs it rather than guessing which side should win.
264
+ const collisions = domainBuckets.filter((d) => pluginOwned.includes(d))
265
+
266
+ const candidates = [ALWAYS_EXPOSED, ...domainBuckets.filter((d) => !collisions.includes(d))]
267
+ const exposed = [...new Set(candidates)].filter((d) => allowed.includes(d)).sort()
268
+
269
+ return { schemaPresent: true, allowed, domainBuckets, pluginOwned, collisions, exposed }
270
+ }
271
+ ```
272
+
273
+ - [ ] **Step 4: Run test to verify it passes**
274
+
275
+ ```bash
276
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/schema-exposed-dirs.test.ts
277
+ ```
278
+
279
+ Expected: PASS, 7 tests.
280
+
281
+ - [ ] **Step 5: Commit**
282
+
283
+ ```bash
284
+ git add platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts
285
+ git commit -m "feat(data-portal): derive exposed dirs from the account's own SCHEMA.md"
286
+ ```
287
+
288
+ ---
289
+
290
+ ### Task 2: Signed-link minting and verification (portal side)
291
+
292
+ **Files:**
293
+ - Create: `platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs`
294
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts`
295
+
296
+ **Interfaces:**
297
+ - Consumes: nothing.
298
+ - Produces:
299
+ - `SIGNED_TTL_MS: number` (300000)
300
+ - `signPath(secret: string, accountId: string, relPath: string, expiresAtMs: number): Promise<string>` — hex HMAC-SHA256
301
+ - `verifyPath(secret, accountId, relPath, expiresAtMs, signature, nowMs): Promise<boolean>`
302
+ - `buildSignedUrl(origin: string, secret: string, accountId: string, relPath: string, expiresAtMs: number): Promise<string>`
303
+
304
+ - [ ] **Step 1: Write the failing test**
305
+
306
+ ```ts
307
+ // platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts
308
+ import { describe, it, expect } from 'vitest'
309
+ import {
310
+ SIGNED_TTL_MS,
311
+ signPath,
312
+ verifyPath,
313
+ buildSignedUrl,
314
+ } from '../../skills/data-portal/template/functions/api/_lib/portal-sign.mjs'
315
+
316
+ const SECRET = 'test-secret-value'
317
+ const ACC = '098a18a3-1741-447f-9778-65470645d57d'
318
+ const NOW = 1_800_000_000_000
319
+
320
+ describe('portal-sign', () => {
321
+ it('round-trips a signature', async () => {
322
+ const exp = NOW + SIGNED_TTL_MS
323
+ const sig = await signPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp)
324
+ expect(await verifyPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp, sig, NOW)).toBe(true)
325
+ })
326
+
327
+ it('refuses an expired signature', async () => {
328
+ const exp = NOW - 1
329
+ const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
330
+ expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp, sig, NOW)).toBe(false)
331
+ })
332
+
333
+ it('refuses a tampered path', async () => {
334
+ const exp = NOW + SIGNED_TTL_MS
335
+ const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
336
+ expect(await verifyPath(SECRET, ACC, 'documents/private.pdf', exp, sig, NOW)).toBe(false)
337
+ })
338
+
339
+ it('refuses a tampered account', async () => {
340
+ const exp = NOW + SIGNED_TTL_MS
341
+ const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
342
+ expect(await verifyPath(SECRET, 'other-account', 'output/a.pdf', exp, sig, NOW)).toBe(false)
343
+ })
344
+
345
+ it('refuses a tampered expiry', async () => {
346
+ const exp = NOW + SIGNED_TTL_MS
347
+ const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
348
+ expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp + 60_000, sig, NOW)).toBe(false)
349
+ })
350
+
351
+ it('refuses a signature made with a different secret', async () => {
352
+ const exp = NOW + SIGNED_TTL_MS
353
+ const sig = await signPath('other-secret', ACC, 'output/a.pdf', exp)
354
+ expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp, sig, NOW)).toBe(false)
355
+ })
356
+
357
+ it('builds a url carrying account, path, expiry and signature', async () => {
358
+ const url = new URL(await buildSignedUrl('https://install.example', SECRET, ACC, 'output/a b.pdf', NOW + 1000))
359
+ expect(url.pathname).toBe('/api/portal/fetch')
360
+ expect(url.searchParams.get('accountId')).toBe(ACC)
361
+ expect(url.searchParams.get('path')).toBe('output/a b.pdf')
362
+ expect(url.searchParams.get('exp')).toBe(String(NOW + 1000))
363
+ expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
364
+ })
365
+ })
366
+ ```
367
+
368
+ - [ ] **Step 2: Run test to verify it fails**
369
+
370
+ ```bash
371
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-sign.test.ts
372
+ ```
373
+
374
+ Expected: FAIL — module not found.
375
+
376
+ - [ ] **Step 3: Write the implementation**
377
+
378
+ ```js
379
+ // platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs
380
+ // Signed links for device-held files.
381
+ //
382
+ // The portal holds the account's folder INDEX but not its bytes, so a download
383
+ // has to reach the install. It does that with a link the install can verify
384
+ // rather than a shared secret in a header: the secret never travels, and each
385
+ // grant covers one file for five minutes.
386
+ //
387
+ // The signature proves the request came from the portal. It is NOT the security
388
+ // boundary — the install re-derives the account's exposed folder set on every
389
+ // request, so a validly-signed link for a non-exposed path is still refused.
390
+ // Keep both checks; neither is redundant.
391
+ //
392
+ // Web Crypto only. Cloudflare Workers and Node 22 both provide it, so the
393
+ // install can verify with an identical algorithm without this module crossing
394
+ // the plugin boundary into platform/ui.
395
+
396
+ /** Five minutes. Long enough for a click to become a download, short enough
397
+ * that a leaked link is worthless by the time it is found in a log. */
398
+ export const SIGNED_TTL_MS = 300_000
399
+
400
+ /** @param {string} accountId @param {string} relPath @param {number} expiresAtMs */
401
+ function message(accountId, relPath, expiresAtMs) {
402
+ // Newline-separated so no field can absorb another: an accountId ending in a
403
+ // path fragment cannot forge a different (accountId, relPath) pair.
404
+ return `${accountId}\n${relPath}\n${expiresAtMs}`
405
+ }
406
+
407
+ /** @param {string} secret */
408
+ async function keyFor(secret) {
409
+ return crypto.subtle.importKey(
410
+ 'raw',
411
+ new TextEncoder().encode(secret),
412
+ { name: 'HMAC', hash: 'SHA-256' },
413
+ false,
414
+ ['sign'],
415
+ )
416
+ }
417
+
418
+ /**
419
+ * @param {string} secret @param {string} accountId @param {string} relPath
420
+ * @param {number} expiresAtMs @returns {Promise<string>} lowercase hex
421
+ */
422
+ export async function signPath(secret, accountId, relPath, expiresAtMs) {
423
+ const key = await keyFor(secret)
424
+ const mac = await crypto.subtle.sign(
425
+ 'HMAC',
426
+ key,
427
+ new TextEncoder().encode(message(accountId, relPath, expiresAtMs)),
428
+ )
429
+ return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('')
430
+ }
431
+
432
+ /** Length-independent, value-constant comparison. A `===` on the hex would leak
433
+ * the matching prefix length through timing.
434
+ * @param {string} a @param {string} b */
435
+ function constantTimeEqual(a, b) {
436
+ if (a.length !== b.length) return false
437
+ let diff = 0
438
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
439
+ return diff === 0
440
+ }
441
+
442
+ /**
443
+ * @param {string} secret @param {string} accountId @param {string} relPath
444
+ * @param {number} expiresAtMs @param {string} signature @param {number} nowMs
445
+ * @returns {Promise<boolean>}
446
+ */
447
+ export async function verifyPath(secret, accountId, relPath, expiresAtMs, signature, nowMs) {
448
+ if (!secret || !signature) return false
449
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) return false
450
+ const expected = await signPath(secret, accountId, relPath, expiresAtMs)
451
+ return constantTimeEqual(expected, signature)
452
+ }
453
+
454
+ /**
455
+ * @param {string} origin @param {string} secret @param {string} accountId
456
+ * @param {string} relPath @param {number} expiresAtMs @returns {Promise<string>}
457
+ */
458
+ export async function buildSignedUrl(origin, secret, accountId, relPath, expiresAtMs) {
459
+ const sig = await signPath(secret, accountId, relPath, expiresAtMs)
460
+ const url = new URL('/api/portal/fetch', origin)
461
+ url.searchParams.set('accountId', accountId)
462
+ url.searchParams.set('path', relPath)
463
+ url.searchParams.set('exp', String(expiresAtMs))
464
+ url.searchParams.set('sig', sig)
465
+ return url.toString()
466
+ }
467
+ ```
468
+
469
+ - [ ] **Step 4: Run test and typecheck**
470
+
471
+ ```bash
472
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-sign.test.ts && npm run typecheck:templates
473
+ ```
474
+
475
+ Expected: PASS, 7 tests; typecheck exit 0.
476
+
477
+ - [ ] **Step 5: Commit**
478
+
479
+ ```bash
480
+ git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts
481
+ git commit -m "feat(data-portal): five-minute signed links for device-held files"
482
+ ```
483
+
484
+ ---
485
+
486
+ ### Task 3: D1 schema and enrolment binding
487
+
488
+ **Files:**
489
+ - Modify: `platform/plugins/cloudflare/skills/data-portal/schema.sql`
490
+ - Modify: `platform/plugins/cloudflare/bin/portal-enrol.mjs`
491
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts` (extend)
492
+
493
+ **Interfaces:**
494
+ - Produces: `directory(accountId, relPath, sizeBytes, modifiedAt, indexedAt)` with `UNIQUE (accountId, relPath)`; `people.accountId`; `portal-enrol.mjs --account <uuid>`.
495
+
496
+ - [ ] **Step 1: Write the failing test** (append to the existing describe block)
497
+
498
+ ```ts
499
+ // platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts — append
500
+ it('emits accountId in the INSERT and the upsert', () => {
501
+ const out = runEnrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-1'])
502
+ expect(out.stdout).toContain("INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId)")
503
+ expect(out.stdout).toContain("'acc-1'")
504
+ expect(out.stdout).toContain('accountId=excluded.accountId')
505
+ })
506
+
507
+ it('refuses enrolment with no --account, because a person with no account sees nothing', () => {
508
+ const out = runEnrol(['--owner', 'alice', '--name', 'Alice'])
509
+ expect(out.status).not.toBe(0)
510
+ expect(out.stderr).toContain('--account')
511
+ })
512
+ ```
513
+
514
+ If `runEnrol` does not already exist in that file, add it:
515
+
516
+ ```ts
517
+ import { spawnSync } from 'node:child_process'
518
+ import { resolve } from 'node:path'
519
+
520
+ function runEnrol(args: string[]) {
521
+ const script = resolve(__dirname, '../../bin/portal-enrol.mjs')
522
+ const r = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' })
523
+ return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status }
524
+ }
525
+ ```
526
+
527
+ - [ ] **Step 2: Run test to verify it fails**
528
+
529
+ ```bash
530
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-enrol.test.ts
531
+ ```
532
+
533
+ Expected: FAIL — the INSERT has no `accountId`, and a missing `--account` currently exits 0.
534
+
535
+ - [ ] **Step 3a: Add the schema**
536
+
537
+ Append to `platform/plugins/cloudflare/skills/data-portal/schema.sql`:
538
+
539
+ ```sql
540
+ -- One row per file the device has published to this portal. Metadata only: the
541
+ -- bytes stay on the device and are fetched on demand through a signed link, so
542
+ -- the folder tree still renders when the device is offline.
543
+ --
544
+ -- Written by a full replace per account (delete-all then insert), which is why
545
+ -- there is no tombstone column: a file deleted or renamed on the device simply
546
+ -- stops being inserted on the next cycle.
547
+ CREATE TABLE IF NOT EXISTS directory (
548
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
549
+ accountId TEXT NOT NULL,
550
+ relPath TEXT NOT NULL,
551
+ sizeBytes INTEGER NOT NULL,
552
+ modifiedAt TEXT NOT NULL,
553
+ indexedAt TEXT NOT NULL,
554
+ UNIQUE (accountId, relPath)
555
+ );
556
+
557
+ CREATE INDEX IF NOT EXISTS directory_account ON directory (accountId);
558
+ ```
559
+
560
+ Then change the `people` table in the same file to carry the account:
561
+
562
+ ```sql
563
+ CREATE TABLE IF NOT EXISTS people (
564
+ ownerId TEXT PRIMARY KEY,
565
+ name TEXT NOT NULL,
566
+ salt TEXT NOT NULL,
567
+ hash TEXT NOT NULL,
568
+ createdAt TEXT NOT NULL,
569
+ accountId TEXT NOT NULL DEFAULT ''
570
+ );
571
+ ```
572
+
573
+ Add this note directly above that table, matching the file's existing habit of recording migration hazards inline:
574
+
575
+ ```sql
576
+ -- `accountId` binds a person to the sub-account whose folders they may read
577
+ -- (Task 1831). It carries a DEFAULT so IF NOT EXISTS stays a no-op on a table
578
+ -- that predates it — but an existing table will NOT gain the column, and a
579
+ -- person whose row has the default empty value sees an empty folder list. Enrol
580
+ -- those people again; the upsert on ownerId makes that a re-run, not a migration.
581
+ ```
582
+
583
+ - [ ] **Step 3b: Update the enrolment script**
584
+
585
+ In `platform/plugins/cloudflare/bin/portal-enrol.mjs`, after the existing `--name` handling, add:
586
+
587
+ ```js
588
+ const accountId = arg('--account')
589
+ // A person with no account resolves to an empty folder list, which is
590
+ // indistinguishable from "this account has no deliverables". Refuse at
591
+ // enrolment rather than mint a row that fails silently later.
592
+ if (!accountId) die('--account <accountId> is required')
593
+ if (!/^[A-Za-z0-9-]+$/.test(accountId)) die('--account must be an account id')
594
+ ```
595
+
596
+ Replace the emitted statement with:
597
+
598
+ ```js
599
+ console.log(
600
+ `INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId) VALUES ('${ownerId}', '${sqlName}', '${saltHex}', '${hash}', '${createdAt}', '${accountId}') ON CONFLICT(ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, accountId=excluded.accountId;`,
601
+ )
602
+ ```
603
+
604
+ Update the usage comment at the top of the file to include `--account <accountId>`.
605
+
606
+ - [ ] **Step 4: Run tests**
607
+
608
+ ```bash
609
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-enrol.test.ts
610
+ ```
611
+
612
+ Expected: PASS, including the pre-existing enrolment tests.
613
+
614
+ - [ ] **Step 5: Commit**
615
+
616
+ ```bash
617
+ git add platform/plugins/cloudflare/skills/data-portal/schema.sql platform/plugins/cloudflare/bin/portal-enrol.mjs platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts
618
+ git commit -m "feat(data-portal): directory table and per-person account binding"
619
+ ```
620
+
621
+ ---
622
+
623
+ ### Task 4: D1 over HTTP with bound parameters
624
+
625
+ The cloudflare plugin owns Cloudflare, so its D1 client lives here. `cf-exec.ts:256` spawns `wrangler` per call and takes raw SQL — the Task 1805 defect plus an injection risk on filenames — so it is not reused. Convergence is a follow-up (Task 1834).
626
+
627
+ **Files:**
628
+ - Create: `platform/plugins/cloudflare/bin/d1-http.mjs`
629
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts`
630
+
631
+ **Interfaces:**
632
+ - Produces: `createD1Client({ accountId, token, dbName, fetchFn? }): { query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>, batch(statements: {sql: string, params?: unknown[]}[]): Promise<void>, readonly statements: number }`
633
+
634
+ - [ ] **Step 1: Write the failing test**
635
+
636
+ ```ts
637
+ // platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts
638
+ import { describe, it, expect } from 'vitest'
639
+ import { createD1Client } from '../../bin/d1-http.mjs'
640
+
641
+ type Call = { url: string; init: { method?: string; body?: string; headers?: Record<string, string> } }
642
+
643
+ function fakeFetch(calls: Call[], results: unknown[] = [{ results: [] }]) {
644
+ return async (url: string, init: Call['init']) => {
645
+ calls.push({ url, init })
646
+ if (url.includes('?name=')) {
647
+ return { ok: true, status: 200, json: async () => ({ result: [{ name: 'portal-db', uuid: 'db-uuid' }] }) }
648
+ }
649
+ return { ok: true, status: 200, json: async () => ({ result: results }) }
650
+ }
651
+ }
652
+
653
+ describe('createD1Client', () => {
654
+ it('resolves the database by exact name then queries by uuid', async () => {
655
+ const calls: Call[] = []
656
+ const c = createD1Client({ accountId: 'acc', token: 't', dbName: 'portal-db', fetchFn: fakeFetch(calls) as never })
657
+ await c.query('SELECT 1')
658
+ expect(calls[0].url).toContain('/accounts/acc/d1/database?name=portal-db')
659
+ expect(calls[1].url).toContain('/d1/database/db-uuid/query')
660
+ expect(calls[1].init.headers?.Authorization).toBe('Bearer t')
661
+ })
662
+
663
+ it('sends values as params, never interpolated into sql', async () => {
664
+ const calls: Call[] = []
665
+ const c = createD1Client({ accountId: 'acc', token: 't', dbName: 'portal-db', fetchFn: fakeFetch(calls) as never })
666
+ await c.query('INSERT INTO directory (relPath) VALUES (?)', ["o'brien.pdf"])
667
+ const body = JSON.parse(calls[1].init.body ?? '{}')
668
+ expect(body.sql).toBe('INSERT INTO directory (relPath) VALUES (?)')
669
+ expect(body.params).toEqual(["o'brien.pdf"])
670
+ })
671
+
672
+ it('caches the name lookup across statements', async () => {
673
+ const calls: Call[] = []
674
+ const c = createD1Client({ accountId: 'acc', token: 't', dbName: 'portal-db', fetchFn: fakeFetch(calls) as never })
675
+ await c.query('SELECT 1')
676
+ await c.query('SELECT 2')
677
+ expect(calls.filter((x) => x.url.includes('?name=')).length).toBe(1)
678
+ expect(c.statements).toBe(2)
679
+ })
680
+
681
+ it('throws with the response body when the api fails', async () => {
682
+ const fetchFn = async () => ({ ok: false, status: 403, json: async () => ({ errors: [{ message: 'nope' }] }) })
683
+ const c = createD1Client({ accountId: 'acc', token: 't', dbName: 'portal-db', fetchFn: fetchFn as never })
684
+ await expect(c.query('SELECT 1')).rejects.toThrow(/403|nope/)
685
+ })
686
+
687
+ it('refuses a database whose name only nearly matches', async () => {
688
+ const fetchFn = async (url: string) => ({
689
+ ok: true, status: 200,
690
+ json: async () => (url.includes('?name=') ? { result: [{ name: 'portal-db-old', uuid: 'x' }] } : { result: [] }),
691
+ })
692
+ const c = createD1Client({ accountId: 'acc', token: 't', dbName: 'portal-db', fetchFn: fetchFn as never })
693
+ await expect(c.query('SELECT 1')).rejects.toThrow(/no D1 database named/)
694
+ })
695
+ })
696
+ ```
697
+
698
+ - [ ] **Step 2: Run test to verify it fails**
699
+
700
+ ```bash
701
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/d1-http.test.ts
702
+ ```
703
+
704
+ Expected: FAIL — module not found.
705
+
706
+ - [ ] **Step 3: Write the implementation**
707
+
708
+ ```js
709
+ // platform/plugins/cloudflare/bin/d1-http.mjs
710
+ // D1 over the Cloudflare HTTP API, with bound parameters.
711
+ //
712
+ // Not `wrangler d1 execute`: that spawns a process per statement, which is the
713
+ // CPU-burn defect Task 1805 fixed, and it takes a raw SQL string, which would
714
+ // mean interpolating filenames into SQL. The index push writes one row per file,
715
+ // so both matter here.
716
+ //
717
+ // storage-broker's cf-exec.d1Query still spawns wrangler. Converging the two is
718
+ // Task 1834; this client does not depend on it.
719
+
720
+ const API = 'https://api.cloudflare.com/client/v4'
721
+
722
+ /** @param {unknown} parsed @param {number} status @param {string} label */
723
+ function assertOk(parsed, status, label) {
724
+ const errs = /** @type {{errors?: {message?: string}[]}} */ (parsed)?.errors
725
+ const detail = Array.isArray(errs) && errs.length ? errs.map((e) => e?.message).join('; ') : ''
726
+ // Never discard the body — a bare status turns every failure into the same
727
+ // unactionable line.
728
+ throw new Error(`${label} failed: HTTP ${status}${detail ? ` — ${detail}` : ''}`)
729
+ }
730
+
731
+ /**
732
+ * @param {{ accountId: string, token: string, dbName: string,
733
+ * fetchFn?: (url: string, init: object) => Promise<{ ok: boolean, status: number, json: () => Promise<unknown> }> }} opts
734
+ */
735
+ export function createD1Client(opts) {
736
+ const fetchFn = opts.fetchFn ?? /** @type {never} */ (globalThis.fetch)
737
+ const headers = { Authorization: `Bearer ${opts.token}`, 'Content-Type': 'application/json' }
738
+ /** @type {string | undefined} */ let databaseId
739
+ let statements = 0
740
+
741
+ async function resolveId() {
742
+ if (databaseId !== undefined) return databaseId
743
+ const url = `${API}/accounts/${opts.accountId}/d1/database?name=${encodeURIComponent(opts.dbName)}`
744
+ const res = await fetchFn(url, { method: 'GET', headers })
745
+ const parsed = await res.json()
746
+ if (!res.ok) assertOk(parsed, res.status, 'd1 database lookup')
747
+ const list = /** @type {{result?: {name?: string, uuid?: string}[]}} */ (parsed).result ?? []
748
+ // Exact name match, never "the first result": `?name=` is a filter whose
749
+ // matching rule is not documented, so position would risk running this
750
+ // account's statements against a near-named database.
751
+ const hit = list.find((d) => d.name === opts.dbName)
752
+ if (!hit || typeof hit.uuid !== 'string') {
753
+ throw new Error(`no D1 database named "${opts.dbName}" in account ${opts.accountId}`)
754
+ }
755
+ databaseId = hit.uuid
756
+ return databaseId
757
+ }
758
+
759
+ /** @param {string} sql @param {unknown[]} [params] */
760
+ async function query(sql, params = []) {
761
+ const id = await resolveId()
762
+ statements++
763
+ const res = await fetchFn(`${API}/accounts/${opts.accountId}/d1/database/${id}/query`, {
764
+ method: 'POST',
765
+ headers,
766
+ body: JSON.stringify({ sql, params }),
767
+ })
768
+ const parsed = await res.json()
769
+ if (!res.ok) assertOk(parsed, res.status, 'd1 query')
770
+ const first = (/** @type {{result?: {results?: unknown}[]}} */ (parsed).result ?? [])[0]
771
+ return /** @type {Record<string, unknown>[]} */ (first?.results ?? [])
772
+ }
773
+
774
+ return {
775
+ query,
776
+ /** @param {{sql: string, params?: unknown[]}[]} stmts */
777
+ async batch(stmts) {
778
+ for (const s of stmts) await query(s.sql, s.params ?? [])
779
+ },
780
+ get statements() {
781
+ return statements
782
+ },
783
+ }
784
+ }
785
+ ```
786
+
787
+ - [ ] **Step 4: Run test to verify it passes**
788
+
789
+ ```bash
790
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/d1-http.test.ts
791
+ ```
792
+
793
+ Expected: PASS, 5 tests.
794
+
795
+ - [ ] **Step 5: Commit**
796
+
797
+ ```bash
798
+ git add platform/plugins/cloudflare/bin/d1-http.mjs platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts
799
+ git commit -m "feat(data-portal): D1 http client with bound params for the index push"
800
+ ```
801
+
802
+ ---
803
+
804
+ ### Task 5: The index push
805
+
806
+ **Files:**
807
+ - Create: `platform/plugins/cloudflare/bin/portal-index-push.mjs`
808
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts`
809
+
810
+ **Interfaces:**
811
+ - Consumes: `resolveExposedDirs` (Task 1), `createD1Client` (Task 4).
812
+ - Produces: `walkExposed(accountDir: string, exposed: string[]): Promise<{relPath: string, sizeBytes: number, modifiedAt: string}[]>` and `pushAccount({ accountDir, accountId, dbName, client, log, nowIso }): Promise<{ exposed: string[], rows: number, schemaPresent: boolean }>`
813
+
814
+ - [ ] **Step 1: Write the failing test**
815
+
816
+ ```ts
817
+ // platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts
818
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
819
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
820
+ import { tmpdir } from 'node:os'
821
+ import { join } from 'node:path'
822
+ import { walkExposed, pushAccount } from '../../bin/portal-index-push.mjs'
823
+
824
+ let dir: string
825
+
826
+ function file(rel: string, body = 'x') {
827
+ const abs = join(dir, rel)
828
+ mkdirSync(join(abs, '..'), { recursive: true })
829
+ writeFileSync(abs, body)
830
+ }
831
+
832
+ const SCHEMA = [
833
+ '```allowed-top-level',
834
+ 'output', 'quotes', 'documents', 'uploads',
835
+ '```',
836
+ '',
837
+ '<!-- ontology-buckets:start -->',
838
+ '- `quotes/` - one folder per Quotation record.',
839
+ '<!-- ontology-buckets:end -->',
840
+ ].join('\n')
841
+
842
+ beforeEach(() => {
843
+ dir = mkdtempSync(join(tmpdir(), 'portal-push-'))
844
+ writeFileSync(join(dir, 'SCHEMA.md'), SCHEMA)
845
+ file('output/report.pdf')
846
+ file('quotes/CR2969/quote.pdf')
847
+ file('quotes/CR2974/quote.pdf')
848
+ file('documents/private.pdf')
849
+ file('uploads/client-sent.pdf')
850
+ file('.claude/settings.json')
851
+ file('output/.uploads-tmp/partial.bin')
852
+ })
853
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
854
+
855
+ function fakeClient() {
856
+ const calls: { sql: string; params: unknown[] }[] = []
857
+ return {
858
+ calls,
859
+ async query(sql: string, params: unknown[] = []) {
860
+ calls.push({ sql, params })
861
+ if (/SELECT COUNT/i.test(sql)) return [{ n: calls.filter((c) => /INSERT/i.test(c.sql)).length }]
862
+ return []
863
+ },
864
+ async batch(stmts: { sql: string; params?: unknown[] }[]) {
865
+ for (const s of stmts) await this.query(s.sql, s.params ?? [])
866
+ },
867
+ statements: 0,
868
+ }
869
+ }
870
+
871
+ describe('walkExposed', () => {
872
+ it('returns files only from exposed dirs, at any depth', async () => {
873
+ const rows = await walkExposed(dir, ['output', 'quotes'])
874
+ const paths = rows.map((r) => r.relPath).sort()
875
+ expect(paths).toEqual(['output/report.pdf', 'quotes/CR2969/quote.pdf', 'quotes/CR2974/quote.pdf'])
876
+ })
877
+
878
+ it('never returns operator data, uploads, dot dirs or upload temps', async () => {
879
+ const paths = (await walkExposed(dir, ['output', 'quotes'])).map((r) => r.relPath)
880
+ expect(paths.some((p) => p.startsWith('documents/'))).toBe(false)
881
+ expect(paths.some((p) => p.startsWith('uploads/'))).toBe(false)
882
+ expect(paths.some((p) => p.includes('.uploads-tmp'))).toBe(false)
883
+ expect(paths.some((p) => p.includes('.claude'))).toBe(false)
884
+ })
885
+
886
+ it('returns nothing for a dir that does not exist', async () => {
887
+ expect(await walkExposed(dir, ['nope'])).toEqual([])
888
+ })
889
+
890
+ it('carries size and an iso modified time', async () => {
891
+ const row = (await walkExposed(dir, ['output']))[0]
892
+ expect(row.sizeBytes).toBe(1)
893
+ expect(row.modifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
894
+ })
895
+ })
896
+
897
+ describe('pushAccount', () => {
898
+ it('deletes the account rows then inserts the current walk', async () => {
899
+ const c = fakeClient()
900
+ const r = await pushAccount({ accountDir: dir, accountId: 'acc-1', dbName: 'db', client: c as never, log: () => {}, nowIso: '2026-07-20T00:00:00.000Z' })
901
+ expect(r.exposed).toEqual(['output', 'quotes'])
902
+ expect(r.rows).toBe(3)
903
+ expect(/DELETE FROM directory/i.test(c.calls[0].sql)).toBe(true)
904
+ expect(c.calls[0].params).toEqual(['acc-1'])
905
+ expect(c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).length).toBe(3)
906
+ })
907
+
908
+ it('exposes nothing and writes nothing when the schema is missing', async () => {
909
+ rmSync(join(dir, 'SCHEMA.md'))
910
+ const c = fakeClient()
911
+ const r = await pushAccount({ accountDir: dir, accountId: 'acc-1', dbName: 'db', client: c as never, log: () => {}, nowIso: 'now' })
912
+ expect(r.schemaPresent).toBe(false)
913
+ expect(r.exposed).toEqual([])
914
+ expect(c.calls.length).toBe(0)
915
+ })
916
+
917
+ it('logs resolve, replace and verify lines', async () => {
918
+ const lines: string[] = []
919
+ const c = fakeClient()
920
+ await pushAccount({ accountDir: dir, accountId: 'acc-1', dbName: 'db', client: c as never, log: (l: string) => lines.push(l), nowIso: 'now' })
921
+ expect(lines.some((l) => l.includes('op=resolve') && l.includes('exposed=output,quotes'))).toBe(true)
922
+ expect(lines.some((l) => l.includes('op=replace') && l.includes('inserted=3'))).toBe(true)
923
+ expect(lines.some((l) => l.includes('op=verify'))).toBe(true)
924
+ })
925
+ })
926
+ ```
927
+
928
+ - [ ] **Step 2: Run test to verify it fails**
929
+
930
+ ```bash
931
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-index-push.test.ts
932
+ ```
933
+
934
+ Expected: FAIL — module not found.
935
+
936
+ - [ ] **Step 3: Write the implementation**
937
+
938
+ ```js
939
+ // platform/plugins/cloudflare/bin/portal-index-push.mjs
940
+ // Publish a sub-account's deliverable file INDEX to its portal's D1.
941
+ //
942
+ // Metadata only. The bytes stay on the device and are fetched on demand through
943
+ // a signed link, which is what lets the portal render the folder tree while the
944
+ // device is offline.
945
+ //
946
+ // Full replace per account rather than a diff: a delete or a rename on the
947
+ // device converges on the next cycle with no tombstone protocol to get wrong.
948
+ //
949
+ // Runnable standalone as well as from the heartbeat, because the heartbeat spawn
950
+ // this copies has never been observed running on a device (Task 1828).
951
+ //
952
+ // node portal-index-push.mjs --account-dir <dir> --account <id> --db <name> --token <cf-token>
953
+
954
+ import { readFile, readdir, stat } from 'node:fs/promises'
955
+ import { join, resolve } from 'node:path'
956
+ import { resolveExposedDirs } from './schema-exposed-dirs.mjs'
957
+ import { createD1Client } from './d1-http.mjs'
958
+
959
+ const TAG = '[portal-index]'
960
+
961
+ /**
962
+ * Files under `exposed` dirs, at any depth. Dot-prefixed entries are skipped at
963
+ * every level: `.uploads-tmp/` holds in-flight upload partials (files.ts:25) and
964
+ * must never be published as a deliverable.
965
+ * @param {string} accountDir @param {string[]} exposed
966
+ * @returns {Promise<{relPath: string, sizeBytes: number, modifiedAt: string}[]>}
967
+ */
968
+ export async function walkExposed(accountDir, exposed) {
969
+ /** @type {{relPath: string, sizeBytes: number, modifiedAt: string}[]} */
970
+ const out = []
971
+
972
+ /** @param {string} rel */
973
+ async function walk(rel) {
974
+ /** @type {import('node:fs').Dirent[]} */ let entries
975
+ try {
976
+ entries = await readdir(join(accountDir, rel), { withFileTypes: true })
977
+ } catch {
978
+ return // absent or unreadable dir contributes nothing
979
+ }
980
+ for (const e of entries) {
981
+ if (e.name.startsWith('.')) continue
982
+ const childRel = `${rel}/${e.name}`
983
+ if (e.isDirectory()) {
984
+ await walk(childRel)
985
+ } else if (e.isFile()) {
986
+ try {
987
+ const st = await stat(join(accountDir, childRel))
988
+ out.push({ relPath: childRel, sizeBytes: st.size, modifiedAt: st.mtime.toISOString() })
989
+ } catch {
990
+ // vanished mid-walk; the next cycle settles it
991
+ }
992
+ }
993
+ }
994
+ }
995
+
996
+ for (const dir of exposed) await walk(dir)
997
+ return out
998
+ }
999
+
1000
+ /**
1001
+ * @param {{ accountDir: string, accountId: string, dbName: string,
1002
+ * client: { query: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]> },
1003
+ * log: (line: string) => void, nowIso: string }} opts
1004
+ * @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean }>}
1005
+ */
1006
+ export async function pushAccount(opts) {
1007
+ const { accountDir, accountId, client, log, nowIso } = opts
1008
+
1009
+ /** @type {string | null} */ let schemaText = null
1010
+ try {
1011
+ schemaText = await readFile(join(accountDir, 'SCHEMA.md'), 'utf8')
1012
+ } catch {
1013
+ schemaText = null
1014
+ }
1015
+
1016
+ const r = resolveExposedDirs(schemaText)
1017
+ log(
1018
+ `${TAG} op=resolve account=${accountId} schemaPresent=${r.schemaPresent} allowed=${r.allowed.length} ` +
1019
+ `domainBuckets=${r.domainBuckets.join(',') || 'none'} exposed=${r.exposed.join(',') || 'none'}`,
1020
+ )
1021
+ // A dir the generator gave to a plugin never reaches the ontology region
1022
+ // (account-schema-owned-dirs.py:287), so this is normally silent. When it is
1023
+ // not, a bucket has silently vanished from the client's view and nothing else
1024
+ // would say so.
1025
+ for (const c of r.collisions) {
1026
+ log(`${TAG} op=collision account=${accountId} dir=${c} claimedBy=plugin-owned`)
1027
+ }
1028
+
1029
+ // Fail closed: no schema means expose nothing, and writing nothing is the
1030
+ // correct outcome — NOT a full delete, which would look identical to a
1031
+ // successful empty publish.
1032
+ if (!r.schemaPresent || r.exposed.length === 0) return { exposed: r.exposed, rows: 0, schemaPresent: r.schemaPresent }
1033
+
1034
+ const rows = await walkExposed(accountDir, r.exposed)
1035
+
1036
+ await client.query('DELETE FROM directory WHERE accountId = ?', [accountId])
1037
+ for (const row of rows) {
1038
+ await client.query(
1039
+ 'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt) VALUES (?, ?, ?, ?, ?)',
1040
+ [accountId, row.relPath, row.sizeBytes, row.modifiedAt, nowIso],
1041
+ )
1042
+ }
1043
+ log(`${TAG} op=replace account=${accountId} deleted=all inserted=${rows.length}`)
1044
+
1045
+ // Read back rather than trusting the write: "issued the inserts" is not "the
1046
+ // rows are there", and a half-applied batch is otherwise invisible.
1047
+ const check = await client.query('SELECT COUNT(*) AS n FROM directory WHERE accountId = ?', [accountId])
1048
+ const after = Number(check[0]?.n ?? -1)
1049
+ log(`${TAG} op=verify account=${accountId} rowsAfter=${after} expected=${rows.length}`)
1050
+
1051
+ return { exposed: r.exposed, rows: rows.length, schemaPresent: true }
1052
+ }
1053
+
1054
+ /** @param {string} flag */
1055
+ function arg(flag) {
1056
+ const i = process.argv.indexOf(flag)
1057
+ return i === -1 ? undefined : process.argv[i + 1]
1058
+ }
1059
+
1060
+ // Only runs when invoked directly, so the module stays importable by tests.
1061
+ if (process.argv[1] && resolve(process.argv[1]).endsWith('portal-index-push.mjs')) {
1062
+ const accountDir = arg('--account-dir')
1063
+ const accountId = arg('--account')
1064
+ const dbName = arg('--db')
1065
+ const token = arg('--token') ?? process.env.CLOUDFLARE_API_TOKEN
1066
+ if (!accountDir || !accountId || !dbName || !token) {
1067
+ console.error(`${TAG} op=usage --account-dir <dir> --account <id> --db <name> --token <cf-token>`)
1068
+ process.exit(1)
1069
+ }
1070
+ const client = createD1Client({ accountId, token, dbName })
1071
+ pushAccount({ accountDir, accountId, dbName, client, log: (l) => console.error(l), nowIso: new Date().toISOString() })
1072
+ .then((r) => {
1073
+ console.error(`${TAG} op=done account=${accountId} rows=${r.rows}`)
1074
+ })
1075
+ .catch((err) => {
1076
+ console.error(`${TAG} op=failed account=${accountId} err="${err instanceof Error ? err.message : String(err)}"`)
1077
+ process.exit(1)
1078
+ })
1079
+ }
1080
+ ```
1081
+
1082
+ - [ ] **Step 4: Run test to verify it passes**
1083
+
1084
+ ```bash
1085
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-index-push.test.ts
1086
+ ```
1087
+
1088
+ Expected: PASS, 7 tests.
1089
+
1090
+ - [ ] **Step 5: Commit**
1091
+
1092
+ ```bash
1093
+ git add platform/plugins/cloudflare/bin/portal-index-push.mjs platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts
1094
+ git commit -m "feat(data-portal): device-side index push over exposed dirs only"
1095
+ ```
1096
+
1097
+ ---
1098
+
1099
+ ### Task 6: Portal listing returns folder entries
1100
+
1101
+ **Files:**
1102
+ - Modify: `.../template/functions/api/_lib/session.ts` (`resolveSession` returns `accountId`)
1103
+ - Modify: `.../template/functions/api/files.ts`
1104
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts`
1105
+
1106
+ **Interfaces:**
1107
+ - Consumes: `directory` table (Task 3), `people.accountId` (Task 3).
1108
+ - Produces: `processFiles(sessionId, env, log, nowMs, prefix?)` returning payload `{ ok, files, entries }` where `entries: { name: string, kind: 'file' | 'directory', relPath: string, sizeBytes: number | null, modifiedAt: string | null }[]`.
1109
+
1110
+ - [ ] **Step 1: Write the failing test**
1111
+
1112
+ ```ts
1113
+ // platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts
1114
+ import { describe, it, expect } from 'vitest'
1115
+ import { processFiles } from '../../skills/data-portal/template/functions/api/files'
1116
+ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
1117
+
1118
+ const DIRECTORY = [
1119
+ { accountId: 'acc-a', relPath: 'output/report.pdf', sizeBytes: 10, modifiedAt: '2026-07-20T10:00:00.000Z' },
1120
+ { accountId: 'acc-a', relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z' },
1121
+ { accountId: 'acc-a', relPath: 'quotes/CR2974/quote.pdf', sizeBytes: 30, modifiedAt: '2026-07-20T12:00:00.000Z' },
1122
+ { accountId: 'acc-b', relPath: 'output/secret.pdf', sizeBytes: 40, modifiedAt: '2026-07-20T13:00:00.000Z' },
1123
+ ]
1124
+
1125
+ function makeEnv(sessions: Record<string, { ownerId: string; accountId: string }>) {
1126
+ return {
1127
+ DB: {
1128
+ prepare(query: string) {
1129
+ let bound: unknown[] = []
1130
+ const stmt = {
1131
+ bind(...v: unknown[]) { bound = v; return stmt },
1132
+ async run() { return { meta: { changes: 0 } } },
1133
+ async all<T>() {
1134
+ if (/FROM directory/i.test(query)) {
1135
+ return { results: DIRECTORY.filter((d) => d.accountId === bound[0]) as T[] }
1136
+ }
1137
+ return { results: [] as T[] }
1138
+ },
1139
+ async first<T>() {
1140
+ if (/FROM sessions/i.test(query)) return (sessions[bound[0] as string] ?? null) as T | null
1141
+ return null as T | null
1142
+ },
1143
+ }
1144
+ return stmt
1145
+ },
1146
+ },
1147
+ } as unknown as PortalEnv
1148
+ }
1149
+
1150
+ const SESSIONS = { 'sess-a': { ownerId: 'alice', accountId: 'acc-a' } }
1151
+
1152
+ describe('portal directory listing', () => {
1153
+ it('synthesises top-level folders from row paths', async () => {
1154
+ const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
1155
+ const entries = r.payload.entries as { name: string; kind: string }[]
1156
+ expect(entries.map((e) => e.name).sort()).toEqual(['output', 'quotes'])
1157
+ expect(entries.every((e) => e.kind === 'directory')).toBe(true)
1158
+ })
1159
+
1160
+ it('lists folders inside a prefix', async () => {
1161
+ const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, 'quotes')
1162
+ const entries = r.payload.entries as { name: string; kind: string }[]
1163
+ expect(entries.map((e) => e.name).sort()).toEqual(['CR2969', 'CR2974'])
1164
+ })
1165
+
1166
+ it('lists files inside a leaf folder with size and time', async () => {
1167
+ const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, 'quotes/CR2969')
1168
+ const entries = r.payload.entries as { name: string; kind: string; relPath: string; sizeBytes: number }[]
1169
+ expect(entries).toEqual([
1170
+ { name: 'quote.pdf', kind: 'file', relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z' },
1171
+ ])
1172
+ })
1173
+
1174
+ it('never leaks another account rows', async () => {
1175
+ const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
1176
+ expect(JSON.stringify(r.payload)).not.toContain('secret.pdf')
1177
+ })
1178
+
1179
+ it('returns an empty entries list when the person has no account bound', async () => {
1180
+ const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: '' } })
1181
+ const r = await processFiles('sess-a', env, () => {}, 0, '')
1182
+ expect(r.status).toBe(200)
1183
+ expect(r.payload.entries).toEqual([])
1184
+ })
1185
+
1186
+ it('logs the account and the prefix', async () => {
1187
+ const lines: string[] = []
1188
+ await processFiles('sess-a', makeEnv(SESSIONS), (l) => lines.push(l), 0, 'quotes')
1189
+ expect(lines.some((l) => l.includes('account=acc-a') && l.includes('prefix=quotes'))).toBe(true)
1190
+ })
1191
+ })
1192
+ ```
1193
+
1194
+ - [ ] **Step 2: Run test to verify it fails**
1195
+
1196
+ ```bash
1197
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-directory-listing.test.ts
1198
+ ```
1199
+
1200
+ Expected: FAIL — `processFiles` takes four arguments and returns no `entries`.
1201
+
1202
+ - [ ] **Step 3a: Widen `resolveSession`**
1203
+
1204
+ In `_lib/session.ts`, change the query and return type so the session carries the account it may read:
1205
+
1206
+ ```ts
1207
+ export async function resolveSession(
1208
+ db: D1Database,
1209
+ sessionId: string,
1210
+ nowMs: number,
1211
+ ): Promise<{ ownerId: string; accountId: string } | null> {
1212
+ if (!sessionId) return null
1213
+ const row = await db
1214
+ .prepare(
1215
+ `SELECT s.ownerId AS ownerId, p.accountId AS accountId
1216
+ FROM sessions s
1217
+ JOIN people p ON p.ownerId = s.ownerId
1218
+ WHERE s.sessionId = ?
1219
+ AND s.expiresAt > ?
1220
+ AND s.pcCheck = substr(p.hash, 1, 16)`,
1221
+ )
1222
+ .bind(sessionId, nowMs)
1223
+ .first<{ ownerId: string; accountId: string }>()
1224
+ if (!row || typeof row.ownerId !== 'string') return null
1225
+ // A row predating Task 1831 has no accountId. Treat it as "no folders", never
1226
+ // as "all folders".
1227
+ return { ownerId: row.ownerId, accountId: typeof row.accountId === 'string' ? row.accountId : '' }
1228
+ }
1229
+ ```
1230
+
1231
+ - [ ] **Step 3b: Return entries from `files.ts`**
1232
+
1233
+ Replace the body of `processFiles` and add the folder synthesis:
1234
+
1235
+ ```ts
1236
+ interface DirRow {
1237
+ relPath: string
1238
+ sizeBytes: number
1239
+ modifiedAt: string
1240
+ }
1241
+
1242
+ export interface DirEntry {
1243
+ name: string
1244
+ kind: 'file' | 'directory'
1245
+ relPath: string
1246
+ sizeBytes: number | null
1247
+ modifiedAt: string | null
1248
+ }
1249
+
1250
+ /**
1251
+ * Collapse flat `directory` rows into one level of listing under `prefix`.
1252
+ * Folders are synthesised from path segments rather than stored, so the device
1253
+ * publishes files alone and an empty folder simply does not exist.
1254
+ */
1255
+ export function entriesUnder(rows: DirRow[], prefix: string): DirEntry[] {
1256
+ const base = prefix ? `${prefix}/` : ''
1257
+ const dirs = new Map<string, DirEntry>()
1258
+ const files: DirEntry[] = []
1259
+ for (const row of rows) {
1260
+ if (base && !row.relPath.startsWith(base)) continue
1261
+ const rest = row.relPath.slice(base.length)
1262
+ if (!rest) continue
1263
+ const slash = rest.indexOf('/')
1264
+ if (slash === -1) {
1265
+ files.push({
1266
+ name: rest,
1267
+ kind: 'file',
1268
+ relPath: row.relPath,
1269
+ sizeBytes: row.sizeBytes,
1270
+ modifiedAt: row.modifiedAt,
1271
+ })
1272
+ } else {
1273
+ const name = rest.slice(0, slash)
1274
+ if (!dirs.has(name)) {
1275
+ dirs.set(name, { name, kind: 'directory', relPath: `${base}${name}`, sizeBytes: null, modifiedAt: null })
1276
+ }
1277
+ }
1278
+ }
1279
+ const dirList = [...dirs.values()].sort((a, b) => a.name.localeCompare(b.name))
1280
+ files.sort((a, b) => (b.modifiedAt ?? '').localeCompare(a.modifiedAt ?? '') || a.name.localeCompare(b.name))
1281
+ return [...dirList, ...files]
1282
+ }
1283
+
1284
+ export async function processFiles(
1285
+ sessionId: string,
1286
+ env: PortalEnv,
1287
+ log: Logger,
1288
+ nowMs: number,
1289
+ prefix = '',
1290
+ ): Promise<Handler> {
1291
+ const session = await resolveSession(env.DB, sessionId, nowMs)
1292
+ if (!session) {
1293
+ log('[data-portal] op=list owner=none result=denied')
1294
+ return { status: 401, payload: { ok: false, error: 'denied' } }
1295
+ }
1296
+ // Scoped by ownerId, not by key prefix: the owner is the authority.
1297
+ const rows = await env.DB.prepare(
1298
+ 'SELECT fileId, filename, objectKey, size, uploadedAt, ingested FROM manifest WHERE ownerId = ? ORDER BY uploadedAt DESC',
1299
+ )
1300
+ .bind(session.ownerId)
1301
+ .all<Record<string, unknown>>()
1302
+ const files = rows.results ?? []
1303
+
1304
+ // No account bound means no folders. An enrolment predating Task 1831 lands
1305
+ // here; the missing `account=` field in the log line is what says so.
1306
+ let entries: DirEntry[] = []
1307
+ if (session.accountId) {
1308
+ const dir = await env.DB.prepare(
1309
+ 'SELECT relPath, sizeBytes, modifiedAt FROM directory WHERE accountId = ?',
1310
+ )
1311
+ .bind(session.accountId)
1312
+ .all<DirRow>()
1313
+ entries = entriesUnder(dir.results ?? [], prefix)
1314
+ }
1315
+
1316
+ log(
1317
+ `[data-portal] op=list owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
1318
+ `prefix=${q(prefix)} files=${files.length} entries=${entries.length} result=ok`,
1319
+ )
1320
+ return { status: 200, payload: { ok: true, files, entries } }
1321
+ }
1322
+ ```
1323
+
1324
+ Then thread the prefix through the route handler:
1325
+
1326
+ ```ts
1327
+ export async function onRequestGet(context: PagesContext): Promise<Response> {
1328
+ const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
1329
+ const prefix = new URL(context.request.url).searchParams.get('prefix') ?? ''
1330
+ const { status, payload } = await processFiles(
1331
+ sessionId,
1332
+ context.env,
1333
+ (line) => console.log(line),
1334
+ Date.now(),
1335
+ prefix,
1336
+ )
1337
+ return Response.json(payload, { status })
1338
+ }
1339
+ ```
1340
+
1341
+ - [ ] **Step 4: Run the new test and the existing suite**
1342
+
1343
+ ```bash
1344
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run && npm run typecheck:templates
1345
+ ```
1346
+
1347
+ Expected: all files pass; the pre-existing `file-routes.test.ts` still green (its fake `first()` returns `{ownerId}` with no `accountId`, which the widened resolver tolerates); typecheck exit 0.
1348
+
1349
+ - [ ] **Step 5: Commit**
1350
+
1351
+ ```bash
1352
+ git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts
1353
+ git commit -m "feat(data-portal): list the account's indexed folders beside uploads"
1354
+ ```
1355
+
1356
+ ---
1357
+
1358
+ ### Task 7: Download branch for indexed files
1359
+
1360
+ **Files:**
1361
+ - Modify: `.../template/functions/api/_lib/types.ts`
1362
+ - Modify: `.../template/functions/api/download.ts`
1363
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts`
1364
+
1365
+ **Interfaces:**
1366
+ - Consumes: `buildSignedUrl`, `SIGNED_TTL_MS` (Task 2); `resolveSession` returning `accountId` (Task 6).
1367
+ - Produces: `processIndexedDownload(sessionId, relPath, env, log, nowMs, fetchFn): Promise<Handler & { url?: string }>`.
1368
+
1369
+ - [ ] **Step 1: Write the failing test**
1370
+
1371
+ ```ts
1372
+ // platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts
1373
+ import { describe, it, expect } from 'vitest'
1374
+ import { processIndexedDownload } from '../../skills/data-portal/template/functions/api/download'
1375
+ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
1376
+
1377
+ const ROW = { relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z' }
1378
+
1379
+ function makeEnv(rows = [ROW], accountId = 'acc-a') {
1380
+ return {
1381
+ DB: {
1382
+ prepare(query: string) {
1383
+ let bound: unknown[] = []
1384
+ const stmt = {
1385
+ bind(...v: unknown[]) { bound = v; return stmt },
1386
+ async run() { return { meta: { changes: 0 } } },
1387
+ async all<T>() { return { results: [] as T[] } },
1388
+ async first<T>() {
1389
+ if (/FROM sessions/i.test(query)) {
1390
+ return (bound[0] === 'sess-a' ? { ownerId: 'alice', accountId } : null) as T | null
1391
+ }
1392
+ if (/FROM directory/i.test(query)) {
1393
+ return (rows.find((r) => r.relPath === bound[1]) ?? null) as T | null
1394
+ }
1395
+ return null as T | null
1396
+ },
1397
+ }
1398
+ return stmt
1399
+ },
1400
+ },
1401
+ PORTAL_FETCH_SECRET: 'secret',
1402
+ PORTAL_INSTALL_ORIGIN: 'https://install.example',
1403
+ } as unknown as PortalEnv
1404
+ }
1405
+
1406
+ const okHead = async () => ({ ok: true, status: 200 })
1407
+ const deadHead = async () => { throw new Error('ECONNREFUSED') }
1408
+
1409
+ describe('processIndexedDownload', () => {
1410
+ it('returns a signed url for an indexed file when the device answers', async () => {
1411
+ const r = await processIndexedDownload('sess-a', ROW.relPath, makeEnv(), () => {}, 1000, okHead as never)
1412
+ expect(r.status).toBe(200)
1413
+ const url = new URL(r.url as string)
1414
+ expect(url.origin).toBe('https://install.example')
1415
+ expect(url.searchParams.get('path')).toBe(ROW.relPath)
1416
+ expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
1417
+ expect(Number(url.searchParams.get('exp'))).toBeGreaterThan(1000)
1418
+ })
1419
+
1420
+ it('reports device-offline distinguishably when the head fails', async () => {
1421
+ const r = await processIndexedDownload('sess-a', ROW.relPath, makeEnv(), () => {}, 1000, deadHead as never)
1422
+ expect(r.status).toBe(503)
1423
+ expect(r.payload.error).toBe('device-offline')
1424
+ expect(r.url).toBeUndefined()
1425
+ })
1426
+
1427
+ it('404s a path that is not in the index, without contacting the device', async () => {
1428
+ let touched = false
1429
+ const spy = async () => { touched = true; return { ok: true, status: 200 } }
1430
+ const r = await processIndexedDownload('sess-a', 'documents/private.pdf', makeEnv(), () => {}, 1000, spy as never)
1431
+ expect(r.status).toBe(404)
1432
+ expect(touched).toBe(false)
1433
+ })
1434
+
1435
+ it('401s without a session', async () => {
1436
+ const r = await processIndexedDownload('nope', ROW.relPath, makeEnv(), () => {}, 1000, okHead as never)
1437
+ expect(r.status).toBe(401)
1438
+ })
1439
+
1440
+ it('404s when the person has no account bound', async () => {
1441
+ const r = await processIndexedDownload('sess-a', ROW.relPath, makeEnv([ROW], ''), () => {}, 1000, okHead as never)
1442
+ expect(r.status).toBe(404)
1443
+ })
1444
+ })
1445
+ ```
1446
+
1447
+ - [ ] **Step 2: Run test to verify it fails**
1448
+
1449
+ ```bash
1450
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-indexed-download.test.ts
1451
+ ```
1452
+
1453
+ Expected: FAIL — `processIndexedDownload` is not exported.
1454
+
1455
+ - [ ] **Step 3a: Extend `PortalEnv`**
1456
+
1457
+ In `_lib/types.ts`, add to the `PortalEnv` interface:
1458
+
1459
+ ```ts
1460
+ export interface PortalEnv {
1461
+ DB: D1Database
1462
+ BUCKET: R2Bucket
1463
+ /** Shared with the install; signs download links. Never sent to the client. */
1464
+ PORTAL_FETCH_SECRET?: string
1465
+ /** The install's public origin, e.g. https://<tunnel-hostname>. */
1466
+ PORTAL_INSTALL_ORIGIN?: string
1467
+ }
1468
+ ```
1469
+
1470
+ - [ ] **Step 3b: Add the indexed branch to `download.ts`**
1471
+
1472
+ Append to `download.ts` (leaving `processDownload` untouched, so the R2 upload path cannot regress):
1473
+
1474
+ ```ts
1475
+ import { buildSignedUrl, SIGNED_TTL_MS } from './_lib/portal-sign.mjs'
1476
+
1477
+ type HeadFetch = (url: string, init: { method: string }) => Promise<{ ok: boolean; status: number }>
1478
+
1479
+ interface IndexedDownloadResult extends Handler {
1480
+ url?: string
1481
+ }
1482
+
1483
+ /**
1484
+ * Hand back a short-lived signed link to a file the DEVICE holds.
1485
+ *
1486
+ * The index is checked first, so a path the device never published costs no
1487
+ * network call — and `documents/` is not in the index by construction, which is
1488
+ * why a client cannot name their way into it.
1489
+ *
1490
+ * The device is probed before the link is minted. Without that, an offline
1491
+ * device produces an opaque browser failure and the client has no idea why;
1492
+ * with it they get "device offline" and can try later. The probe-then-fetch
1493
+ * window is real but small, and losing it degrades to exactly the opaque
1494
+ * failure this avoids in the common case.
1495
+ */
1496
+ export async function processIndexedDownload(
1497
+ sessionId: string,
1498
+ relPath: string,
1499
+ env: PortalEnv,
1500
+ log: Logger,
1501
+ nowMs: number,
1502
+ fetchFn: HeadFetch,
1503
+ ): Promise<IndexedDownloadResult> {
1504
+ const session = await resolveSession(env.DB, sessionId, nowMs)
1505
+ if (!session) {
1506
+ log('[data-portal] op=fetch-through owner=none result=denied')
1507
+ return { status: 401, payload: { ok: false, error: 'denied' } }
1508
+ }
1509
+ if (!session.accountId) {
1510
+ log(`[data-portal] op=fetch-through owner=${q(session.ownerId)} result=no-account`)
1511
+ return { status: 404, payload: { ok: false, error: 'not found' } }
1512
+ }
1513
+ const row = await env.DB.prepare(
1514
+ 'SELECT relPath FROM directory WHERE accountId = ? AND relPath = ?',
1515
+ )
1516
+ .bind(session.accountId, relPath)
1517
+ .first<{ relPath: string }>()
1518
+ if (!row) {
1519
+ log(`[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} result=absent`)
1520
+ return { status: 404, payload: { ok: false, error: 'not found' } }
1521
+ }
1522
+
1523
+ const secret = env.PORTAL_FETCH_SECRET
1524
+ const origin = env.PORTAL_INSTALL_ORIGIN
1525
+ if (!secret || !origin) {
1526
+ log(`[data-portal] op=fetch-through owner=${q(session.ownerId)} result=unconfigured`)
1527
+ return { status: 503, payload: { ok: false, error: 'device-offline' } }
1528
+ }
1529
+
1530
+ const expiresAt = nowMs + SIGNED_TTL_MS
1531
+ const url = await buildSignedUrl(origin, secret, session.accountId, relPath, expiresAt)
1532
+
1533
+ const started = Date.now()
1534
+ try {
1535
+ const head = await fetchFn(url, { method: 'HEAD' })
1536
+ if (!head.ok) {
1537
+ log(
1538
+ `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
1539
+ `status=${head.status} ms=${Date.now() - started} result=device-error`,
1540
+ )
1541
+ return { status: 503, payload: { ok: false, error: 'device-offline' } }
1542
+ }
1543
+ } catch (err) {
1544
+ log(
1545
+ `[data-portal] op=fetch-through-failed owner=${q(session.ownerId)} path=${q(relPath)} ` +
1546
+ `err=${q(err instanceof Error ? err.message : String(err))} result=device-offline`,
1547
+ )
1548
+ return { status: 503, payload: { ok: false, error: 'device-offline' } }
1549
+ }
1550
+
1551
+ log(
1552
+ `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
1553
+ `status=200 ms=${Date.now() - started} result=ok`,
1554
+ )
1555
+ return { status: 200, payload: { ok: true, url }, url }
1556
+ }
1557
+ ```
1558
+
1559
+ Extend `onRequestGet` to branch on which parameter arrived:
1560
+
1561
+ ```ts
1562
+ export async function onRequestGet(context: PagesContext): Promise<Response> {
1563
+ const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
1564
+ const params = new URL(context.request.url).searchParams
1565
+ const indexedPath = params.get('path')
1566
+ if (indexedPath) {
1567
+ const { status, payload } = await processIndexedDownload(
1568
+ sessionId,
1569
+ indexedPath,
1570
+ context.env,
1571
+ (line) => console.log(line),
1572
+ Date.now(),
1573
+ fetch as never,
1574
+ )
1575
+ return Response.json(payload, { status })
1576
+ }
1577
+ const key = params.get('key') ?? ''
1578
+ // ... existing R2 body, unchanged
1579
+ }
1580
+ ```
1581
+
1582
+ - [ ] **Step 4: Run tests and typecheck**
1583
+
1584
+ ```bash
1585
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run && npm run typecheck:templates
1586
+ ```
1587
+
1588
+ Expected: all green; typecheck exit 0.
1589
+
1590
+ - [ ] **Step 5: Commit**
1591
+
1592
+ ```bash
1593
+ git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts
1594
+ git commit -m "feat(data-portal): signed-link download for device-held files"
1595
+ ```
1596
+
1597
+ ---
1598
+
1599
+ ### Task 8: Install-side fetch route
1600
+
1601
+ **Files:**
1602
+ - Create: `platform/ui/server/routes/portal-fetch.ts`
1603
+ - Modify: `platform/ui/server/index.ts` (import + `SUBAPP_MANIFEST` entry)
1604
+ - Test: `platform/ui/server/routes/__tests__/portal-fetch.test.ts`
1605
+
1606
+ **Interfaces:**
1607
+ - Consumes: the signed-link format from Task 2 (`accountId`, `path`, `exp`, `sig`), `resolveExposedDirs` (Task 1).
1608
+ - Produces: `GET /api/portal/fetch`; exported `verifySignature(secret, accountId, relPath, expiresAtMs, sig, nowMs): Promise<boolean>` for the parity test.
1609
+
1610
+ - [ ] **Step 1: Write the failing test**
1611
+
1612
+ ```ts
1613
+ // platform/ui/server/routes/__tests__/portal-fetch.test.ts
1614
+ import { describe, it, expect, vi } from 'vitest'
1615
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
1616
+ import { tmpdir } from 'node:os'
1617
+ import { join } from 'node:path'
1618
+ import { verifySignature, resolveFetchTarget } from '../portal-fetch'
1619
+ import { signPath } from '../../../../plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs'
1620
+
1621
+ const SECRET = 'shared'
1622
+ const ACC = 'acc-a'
1623
+ const NOW = 1_800_000_000_000
1624
+
1625
+ const SCHEMA = [
1626
+ '```allowed-top-level', 'output', 'quotes', 'documents', '```', '',
1627
+ '<!-- ontology-buckets:start -->',
1628
+ '- `quotes/` - one folder per Quotation record.',
1629
+ '<!-- ontology-buckets:end -->',
1630
+ ].join('\n')
1631
+
1632
+ function accountDir() {
1633
+ const d = mkdtempSync(join(tmpdir(), 'portal-fetch-'))
1634
+ writeFileSync(join(d, 'SCHEMA.md'), SCHEMA)
1635
+ mkdirSync(join(d, 'quotes/CR2969'), { recursive: true })
1636
+ writeFileSync(join(d, 'quotes/CR2969/quote.pdf'), 'PDF')
1637
+ mkdirSync(join(d, 'documents'), { recursive: true })
1638
+ writeFileSync(join(d, 'documents/private.pdf'), 'SECRET')
1639
+ return d
1640
+ }
1641
+
1642
+ describe('portal-fetch signature parity', () => {
1643
+ // The two ends deliberately do not share a module. This is the only place a
1644
+ // drift between them could hide.
1645
+ it('accepts a signature minted by the portal module', async () => {
1646
+ const exp = NOW + 300_000
1647
+ const sig = await signPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp)
1648
+ expect(await verifySignature(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp, sig, NOW)).toBe(true)
1649
+ })
1650
+
1651
+ it('rejects an expired or tampered signature', async () => {
1652
+ const sig = await signPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', NOW - 1)
1653
+ expect(await verifySignature(SECRET, ACC, 'quotes/CR2969/quote.pdf', NOW - 1, sig, NOW)).toBe(false)
1654
+ const good = await signPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', NOW + 300_000)
1655
+ expect(await verifySignature(SECRET, ACC, 'documents/private.pdf', NOW + 300_000, good, NOW)).toBe(false)
1656
+ })
1657
+ })
1658
+
1659
+ describe('resolveFetchTarget', () => {
1660
+ it('serves a file under an exposed dir', async () => {
1661
+ const d = accountDir()
1662
+ const r = await resolveFetchTarget(d, 'quotes/CR2969/quote.pdf')
1663
+ expect(r.ok).toBe(true)
1664
+ rmSync(d, { recursive: true, force: true })
1665
+ })
1666
+
1667
+ // The security boundary: a VALID signature over a non-exposed path is still
1668
+ // refused, because the install re-derives the exposed set itself.
1669
+ it('refuses a non-exposed dir even though the file exists', async () => {
1670
+ const d = accountDir()
1671
+ const r = await resolveFetchTarget(d, 'documents/private.pdf')
1672
+ expect(r.ok).toBe(false)
1673
+ expect(r.reason).toBe('not-exposed')
1674
+ rmSync(d, { recursive: true, force: true })
1675
+ })
1676
+
1677
+ it('refuses traversal', async () => {
1678
+ const d = accountDir()
1679
+ for (const p of ['../../etc/passwd', 'quotes/../../x', '/etc/passwd']) {
1680
+ const r = await resolveFetchTarget(d, p)
1681
+ expect(r.ok).toBe(false)
1682
+ }
1683
+ rmSync(d, { recursive: true, force: true })
1684
+ })
1685
+
1686
+ it('refuses everything when the schema is missing', async () => {
1687
+ const d = accountDir()
1688
+ rmSync(join(d, 'SCHEMA.md'))
1689
+ const r = await resolveFetchTarget(d, 'quotes/CR2969/quote.pdf')
1690
+ expect(r.ok).toBe(false)
1691
+ expect(r.reason).toBe('not-exposed')
1692
+ rmSync(d, { recursive: true, force: true })
1693
+ })
1694
+
1695
+ it('misses a path that is exposed but absent', async () => {
1696
+ const d = accountDir()
1697
+ const r = await resolveFetchTarget(d, 'quotes/CR9999/nope.pdf')
1698
+ expect(r.ok).toBe(false)
1699
+ expect(r.reason).toBe('enoent')
1700
+ rmSync(d, { recursive: true, force: true })
1701
+ })
1702
+ })
1703
+ ```
1704
+
1705
+ - [ ] **Step 2: Run test to verify it fails**
1706
+
1707
+ ```bash
1708
+ export PATH="$HOME/.nvm/versions/node/v22.22.0/bin:$PATH"
1709
+ cd platform/ui && node_modules/.bin/vitest run server/routes/__tests__/portal-fetch.test.ts
1710
+ ```
1711
+
1712
+ Expected: FAIL — `../portal-fetch` does not exist.
1713
+
1714
+ - [ ] **Step 3a: Write the route**
1715
+
1716
+ ```ts
1717
+ // platform/ui/server/routes/portal-fetch.ts
1718
+ // Serve one device-held file to a data portal, on a signed link.
1719
+ //
1720
+ // The portal holds an INDEX of the account's deliverable folders but not the
1721
+ // bytes (Task 1831). When a client clicks a file, the portal mints a link
1722
+ // signed with the account's shared secret and the browser lands here.
1723
+ //
1724
+ // TWO checks, and neither is redundant:
1725
+ // 1. the signature proves the request came from the portal;
1726
+ // 2. the exposed-dir set is RE-DERIVED here from the account's own SCHEMA.md,
1727
+ // so a validly-signed link for `documents/private.pdf` is still refused.
1728
+ // The second is the security boundary. If the portal is ever wrong about what
1729
+ // it may offer, this is what contains it.
1730
+
1731
+ import { Hono } from 'hono'
1732
+ import { createReadStream } from 'node:fs'
1733
+ import { stat, readFile } from 'node:fs/promises'
1734
+ import { join, resolve, sep } from 'node:path'
1735
+ import { Readable } from 'node:stream'
1736
+ import type { AppEnv } from './_env'
1737
+ import { resolveExposedDirs } from '../../../plugins/cloudflare/bin/schema-exposed-dirs.mjs'
1738
+ import { DATA_ROOT } from '../../app/lib/data-path'
1739
+ import { detectMimeType } from '../../app/lib/attachments'
1740
+
1741
+ const app = new Hono<AppEnv>()
1742
+ const TAG = '[portal-fetch]'
1743
+
1744
+ /** Same algorithm as the portal's `_lib/portal-sign.mjs`. The two ends do not
1745
+ * share a module — no production code in platform/ui imports from
1746
+ * platform/plugins — so a parity test asserts they agree. */
1747
+ export async function verifySignature(
1748
+ secret: string,
1749
+ accountId: string,
1750
+ relPath: string,
1751
+ expiresAtMs: number,
1752
+ signature: string,
1753
+ nowMs: number,
1754
+ ): Promise<boolean> {
1755
+ if (!secret || !signature) return false
1756
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) return false
1757
+ const key = await crypto.subtle.importKey(
1758
+ 'raw',
1759
+ new TextEncoder().encode(secret),
1760
+ { name: 'HMAC', hash: 'SHA-256' },
1761
+ false,
1762
+ ['sign'],
1763
+ )
1764
+ const mac = await crypto.subtle.sign(
1765
+ 'HMAC',
1766
+ key,
1767
+ new TextEncoder().encode(`${accountId}\n${relPath}\n${expiresAtMs}`),
1768
+ )
1769
+ const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('')
1770
+ if (expected.length !== signature.length) return false
1771
+ let diff = 0
1772
+ for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i)
1773
+ return diff === 0
1774
+ }
1775
+
1776
+ export type FetchTarget =
1777
+ | { ok: true; absolute: string; sizeBytes: number }
1778
+ | { ok: false; reason: 'not-exposed' | 'enoent' }
1779
+
1780
+ /**
1781
+ * Resolve an account-relative path to a servable file, refusing anything not
1782
+ * under a currently-exposed dir. Containment is checked on the resolved path,
1783
+ * so `..` cannot escape regardless of how it is spelled.
1784
+ */
1785
+ export async function resolveFetchTarget(accountDir: string, relPath: string): Promise<FetchTarget> {
1786
+ let schemaText: string | null = null
1787
+ try {
1788
+ schemaText = await readFile(join(accountDir, 'SCHEMA.md'), 'utf8')
1789
+ } catch {
1790
+ schemaText = null // fail closed — see resolveExposedDirs
1791
+ }
1792
+ const { exposed } = resolveExposedDirs(schemaText)
1793
+ if (exposed.length === 0) return { ok: false, reason: 'not-exposed' }
1794
+
1795
+ const absolute = resolve(accountDir, relPath)
1796
+ const root = resolve(accountDir)
1797
+ if (absolute !== root && !absolute.startsWith(root + sep)) return { ok: false, reason: 'not-exposed' }
1798
+
1799
+ const rel = absolute.slice(root.length + 1)
1800
+ const top = rel.split(sep)[0]
1801
+ if (!exposed.includes(top)) return { ok: false, reason: 'not-exposed' }
1802
+
1803
+ try {
1804
+ const st = await stat(absolute)
1805
+ if (!st.isFile()) return { ok: false, reason: 'enoent' }
1806
+ return { ok: true, absolute, sizeBytes: st.size }
1807
+ } catch {
1808
+ return { ok: false, reason: 'enoent' }
1809
+ }
1810
+ }
1811
+
1812
+ async function handle(c: Parameters<Parameters<typeof app.get>[1]>[0], headOnly: boolean) {
1813
+ const accountId = c.req.query('accountId') ?? ''
1814
+ const relPath = c.req.query('path') ?? ''
1815
+ const exp = Number(c.req.query('exp') ?? '0')
1816
+ const sig = c.req.query('sig') ?? ''
1817
+ console.log(`${TAG} op=request account=${accountId} path="${relPath}"`)
1818
+
1819
+ const secret = process.env.PORTAL_FETCH_SECRET ?? ''
1820
+ if (!(await verifySignature(secret, accountId, relPath, exp, sig, Date.now()))) {
1821
+ console.error(`${TAG} op=denied account=${accountId} reason=secret`)
1822
+ return c.json({ ok: false, error: 'denied' }, 401)
1823
+ }
1824
+
1825
+ const accountDir = resolve(DATA_ROOT, 'accounts', accountId)
1826
+ const target = await resolveFetchTarget(accountDir, relPath)
1827
+ if (!target.ok) {
1828
+ console.error(`${TAG} op=${target.reason === 'enoent' ? 'miss' : 'denied'} account=${accountId} reason=${target.reason}`)
1829
+ return c.json({ ok: false, error: 'not found' }, 404)
1830
+ }
1831
+ if (headOnly) {
1832
+ console.log(`${TAG} op=served account=${accountId} bytes=0 head=true`)
1833
+ return c.body(null, 200)
1834
+ }
1835
+ console.log(`${TAG} op=served account=${accountId} bytes=${target.sizeBytes}`)
1836
+ return new Response(Readable.toWeb(createReadStream(target.absolute)) as ReadableStream, {
1837
+ status: 200,
1838
+ headers: {
1839
+ 'content-type': detectMimeType(target.absolute) ?? 'application/octet-stream',
1840
+ 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(relPath.split('/').pop() ?? 'file')}`,
1841
+ 'x-content-type-options': 'nosniff',
1842
+ 'cache-control': 'private, no-store',
1843
+ },
1844
+ })
1845
+ }
1846
+
1847
+ app.get('/fetch', (c) => handle(c, false))
1848
+ app.on('HEAD', '/fetch', (c) => handle(c, true))
1849
+
1850
+ export default app
1851
+ ```
1852
+
1853
+ - [ ] **Step 3b: Register the subapp**
1854
+
1855
+ In `platform/ui/server/index.ts`, add the import beside the other route imports:
1856
+
1857
+ ```ts
1858
+ import portalFetchRoutes from './routes/portal-fetch'
1859
+ ```
1860
+
1861
+ and the manifest entry inside `SUBAPP_MANIFEST` (after the `/api/calendar` line):
1862
+
1863
+ ```ts
1864
+ { prefix: '/api/portal', file: 'server/routes/portal-fetch.ts', subapp: portalFetchRoutes },
1865
+ ```
1866
+
1867
+ - [ ] **Step 4: Run tests**
1868
+
1869
+ ```bash
1870
+ cd platform/ui && node_modules/.bin/vitest run server/routes/__tests__/portal-fetch.test.ts server/__tests__/shell-route-registration.test.ts
1871
+ ```
1872
+
1873
+ Expected: PASS. If `shell-route-registration.test.ts` asserts an exhaustive manifest list, add `/api/portal` to it.
1874
+
1875
+ - [ ] **Step 5: Commit**
1876
+
1877
+ ```bash
1878
+ git add platform/ui/server/routes/portal-fetch.ts platform/ui/server/index.ts platform/ui/server/routes/__tests__/portal-fetch.test.ts
1879
+ git commit -m "feat(data-portal): install route serving device files on a signed link"
1880
+ ```
1881
+
1882
+ ---
1883
+
1884
+ ### Task 9: Portal folder navigation
1885
+
1886
+ **Files:**
1887
+ - Modify: `.../data-portal/template/portal.js`
1888
+ - Modify: `.../data-portal/template/index.html`
1889
+
1890
+ No test: the portal front end is a static page with no harness in this repo. It is exercised by hand at deploy time, which the SKILL's done-gate already covers.
1891
+
1892
+ - [ ] **Step 1: Add the breadcrumb container to `index.html`**
1893
+
1894
+ Insert directly above the existing file-list element:
1895
+
1896
+ ```html
1897
+ <nav id="crumbs" class="crumbs" aria-label="Folder path"></nav>
1898
+ <ul id="entries" class="entries"></ul>
1899
+ ```
1900
+
1901
+ - [ ] **Step 2: Add navigation to `portal.js`**
1902
+
1903
+ ```js
1904
+ // Folder navigation over the account's published index (Task 1831). `prefix`
1905
+ // is the folder currently open; '' is the root.
1906
+ let prefix = ''
1907
+
1908
+ function renderCrumbs() {
1909
+ const parts = prefix ? prefix.split('/') : []
1910
+ const crumbs = document.getElementById('crumbs')
1911
+ crumbs.replaceChildren()
1912
+ const mk = (label, target) => {
1913
+ const a = document.createElement('button')
1914
+ a.type = 'button'
1915
+ a.className = 'crumb'
1916
+ a.textContent = label
1917
+ a.addEventListener('click', () => { prefix = target; void loadEntries() })
1918
+ return a
1919
+ }
1920
+ crumbs.append(mk('Files', ''))
1921
+ parts.forEach((part, i) => {
1922
+ crumbs.append(document.createTextNode(' / '))
1923
+ crumbs.append(mk(part, parts.slice(0, i + 1).join('/')))
1924
+ })
1925
+ }
1926
+
1927
+ async function loadEntries() {
1928
+ const res = await fetch(`/api/files?prefix=${encodeURIComponent(prefix)}`, { credentials: 'same-origin' })
1929
+ const data = await res.json()
1930
+ renderCrumbs()
1931
+ const list = document.getElementById('entries')
1932
+ list.replaceChildren()
1933
+ for (const entry of data.entries ?? []) {
1934
+ const li = document.createElement('li')
1935
+ const btn = document.createElement('button')
1936
+ btn.type = 'button'
1937
+ btn.textContent = entry.kind === 'directory' ? `📁 ${entry.name}` : entry.name
1938
+ btn.addEventListener('click', () => {
1939
+ if (entry.kind === 'directory') { prefix = entry.relPath; void loadEntries() }
1940
+ else void openIndexedFile(entry.relPath)
1941
+ })
1942
+ li.append(btn)
1943
+ list.append(li)
1944
+ }
1945
+ }
1946
+
1947
+ // The portal checks the device is reachable before handing over the link, so a
1948
+ // down device says so plainly instead of failing as a broken download.
1949
+ async function openIndexedFile(relPath) {
1950
+ const res = await fetch(`/api/download?path=${encodeURIComponent(relPath)}`, { credentials: 'same-origin' })
1951
+ const data = await res.json()
1952
+ if (res.status === 503) {
1953
+ showError('That file lives on the device, which is not reachable right now. Try again shortly.')
1954
+ return
1955
+ }
1956
+ if (!res.ok || !data.url) {
1957
+ showError('That file could not be opened.')
1958
+ return
1959
+ }
1960
+ window.location.assign(data.url)
1961
+ }
1962
+ ```
1963
+
1964
+ Call `void loadEntries()` wherever the existing upload list is first loaded, so both render on sign-in. If `showError` does not already exist in `portal.js`, reuse whatever the upload flow calls to surface a failure message rather than adding a second mechanism.
1965
+
1966
+ - [ ] **Step 3: Verify the template still typechecks**
1967
+
1968
+ ```bash
1969
+ cd platform/plugins/cloudflare/mcp && npm run typecheck:templates
1970
+ ```
1971
+
1972
+ Expected: exit 0 (the script covers `functions/api`, not `portal.js`; this confirms nothing else broke).
1973
+
1974
+ - [ ] **Step 4: Commit**
1975
+
1976
+ ```bash
1977
+ git add platform/plugins/cloudflare/skills/data-portal/template/portal.js platform/plugins/cloudflare/skills/data-portal/template/index.html
1978
+ git commit -m "feat(data-portal): folder navigation in the portal ui"
1979
+ ```
1980
+
1981
+ ---
1982
+
1983
+ ### Task 10: Assembly, heartbeat wiring, and docs
1984
+
1985
+ **Files:**
1986
+ - Modify: `.../data-portal/SKILL.md`
1987
+ - Modify: `.../data-portal/template/wrangler.toml`
1988
+ - Modify: `platform/plugins/cloudflare/PLUGIN.md`
1989
+ - Modify: `platform/plugins/scheduling/mcp/src/scripts/check-due-events.ts`
1990
+ - Modify: `.docs/` and `platform/plugins/docs/references/`
1991
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts` (extend)
1992
+
1993
+ - [ ] **Step 1: Write the failing test**
1994
+
1995
+ ```ts
1996
+ // platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts — append
1997
+ it('declares the two portal-fetch bindings in wrangler.toml', () => {
1998
+ const toml = readFileSync(
1999
+ resolve(__dirname, '../../skills/data-portal/template/wrangler.toml'),
2000
+ 'utf8',
2001
+ )
2002
+ expect(toml).toContain('PORTAL_INSTALL_ORIGIN')
2003
+ expect(toml).toContain('__PORTAL_INSTALL_ORIGIN__')
2004
+ })
2005
+
2006
+ it('SKILL.md tells the assembler to set the fetch secret and origin', () => {
2007
+ const skill = readFileSync(resolve(__dirname, '../../skills/data-portal/SKILL.md'), 'utf8')
2008
+ expect(skill).toContain('PORTAL_FETCH_SECRET')
2009
+ expect(skill).toContain('portal-index-push.mjs')
2010
+ })
2011
+ ```
2012
+
2013
+ - [ ] **Step 2: Run test to verify it fails**
2014
+
2015
+ ```bash
2016
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/template-config.test.ts
2017
+ ```
2018
+
2019
+ Expected: FAIL on both.
2020
+
2021
+ - [ ] **Step 3a: Add the origin placeholder to `wrangler.toml`**
2022
+
2023
+ ```toml
2024
+ [vars]
2025
+ PORTAL_INSTALL_ORIGIN = "__PORTAL_INSTALL_ORIGIN__"
2026
+ ```
2027
+
2028
+ `PORTAL_FETCH_SECRET` is deliberately NOT in `wrangler.toml` — it is a secret, set with `wrangler pages secret put`, never committed to the assembled tree.
2029
+
2030
+ - [ ] **Step 3b: Document assembly in `SKILL.md`**
2031
+
2032
+ Add a section after "Assemble the canonical tree":
2033
+
2034
+ ````markdown
2035
+ ## Bind the portal to the device
2036
+
2037
+ The portal shows the account's deliverable folders, which means it needs two
2038
+ things the template cannot know: where the install is, and a secret it shares
2039
+ with it.
2040
+
2041
+ Fill `__PORTAL_INSTALL_ORIGIN__` in `wrangler.toml` with the install's public
2042
+ origin (`https://<tunnel-hostname>`), then set the shared secret on both ends:
2043
+
2044
+ ```bash
2045
+ SECRET=$(node -e 'console.log(crypto.randomUUID().replace(/-/g,"")+crypto.randomUUID().replace(/-/g,""))')
2046
+ # device side — the install reads it from its own environment
2047
+ echo "PORTAL_FETCH_SECRET=$SECRET" >> <accountDir>/secrets.env
2048
+ # portal side
2049
+ npx wrangler pages secret put PORTAL_FETCH_SECRET --project-name <project>
2050
+ ```
2051
+
2052
+ The secret never travels. The portal signs one file path with a five-minute
2053
+ expiry and the install verifies it. The install then re-derives which folders
2054
+ are exposed from the account's own `SCHEMA.md`, so a signed link for a folder
2055
+ that is not a deliverable is refused regardless — the signature is not the
2056
+ security boundary, that check is.
2057
+
2058
+ Enrolment now binds each person to the account whose folders they may read:
2059
+
2060
+ ```bash
2061
+ node platform/plugins/cloudflare/bin/portal-enrol.mjs --owner <ownerId> \
2062
+ --name <name> --account <accountId> | npx wrangler d1 execute <portalDbName> --remote --file=-
2063
+ ```
2064
+
2065
+ Anyone enrolled before this existed has no account bound and sees an empty
2066
+ folder list. Re-run enrolment for them; the upsert makes it a re-run, not a
2067
+ migration.
2068
+
2069
+ ## Publish the folder index
2070
+
2071
+ The device publishes file metadata — names, sizes, times, never bytes — so the
2072
+ tree still renders when the device is offline:
2073
+
2074
+ ```bash
2075
+ node platform/plugins/cloudflare/bin/portal-index-push.mjs \
2076
+ --account-dir <accountDir> --account <accountId> --db <portalDbName> --token <cfToken>
2077
+ ```
2078
+
2079
+ The heartbeat runs this hourly. Run it by hand after assembly so the portal has
2080
+ something to show on the client's first sign-in.
2081
+ ````
2082
+
2083
+ - [ ] **Step 3c: Wire the heartbeat**
2084
+
2085
+ In `check-due-events.ts`, beside `maybeRunSentSweep`, add the same shape:
2086
+
2087
+ ```ts
2088
+ const PORTAL_INDEX_STATE_FILE = resolve(CONFIG_DIR, "portal-index-push-state.json");
2089
+ const PORTAL_INDEX_INTERVAL_MS = 3_600_000;
2090
+
2091
+ /**
2092
+ * Publish each portal-serving account's folder index, at most hourly.
2093
+ *
2094
+ * Spawned, not imported, for the same reason as the sent-copy sweep: this
2095
+ * script lives in the cloudflare plugin and reaches Cloudflare's API, which
2096
+ * does not belong inside the scheduling build.
2097
+ *
2098
+ * Only accounts carrying a data-portal.json have a portal, so the absence of
2099
+ * that file is the enrolment signal — not a config flag to keep in sync.
2100
+ */
2101
+ function maybeRunPortalIndexPush(platformRoot: string): void {
2102
+ let prev: { lastRunMs?: number } | undefined;
2103
+ try {
2104
+ prev = JSON.parse(readFileSync(PORTAL_INDEX_STATE_FILE, "utf8"));
2105
+ } catch {
2106
+ prev = undefined;
2107
+ }
2108
+ const now = Date.now();
2109
+ if (prev?.lastRunMs && now - prev.lastRunMs < PORTAL_INDEX_INTERVAL_MS) return;
2110
+
2111
+ const script = resolve(platformRoot, "plugins/cloudflare/bin/portal-index-push.mjs");
2112
+ if (!existsSync(script)) {
2113
+ console.error(`[portal-index] skipped: ${script} not found`);
2114
+ return;
2115
+ }
2116
+ try {
2117
+ writeFileSync(PORTAL_INDEX_STATE_FILE, JSON.stringify({ lastRunMs: now }));
2118
+ } catch (err) {
2119
+ console.error(`[portal-index] state write failed: ${err instanceof Error ? err.message : String(err)}`);
2120
+ return;
2121
+ }
2122
+ spawn(process.execPath, [script, "--all-accounts"], {
2123
+ env: { ...process.env, PLATFORM_ROOT: platformRoot },
2124
+ stdio: ["ignore", "inherit", "inherit"],
2125
+ detached: true,
2126
+ }).unref();
2127
+ }
2128
+ ```
2129
+
2130
+ and call it in the same guarded block as the sent sweep:
2131
+
2132
+ ```ts
2133
+ try {
2134
+ maybeRunPortalIndexPush(process.env.PLATFORM_ROOT!);
2135
+ } catch (err) {
2136
+ console.error(`[portal-index] spawn failed: ${err instanceof Error ? err.message : String(err)}`);
2137
+ }
2138
+ ```
2139
+
2140
+ Then add `--all-accounts` handling to `portal-index-push.mjs`'s CLI block: enumerate `DATA_ROOT/accounts/*/data-portal.json`, and for each, read `portalDbName` from it and call `pushAccount`. Accounts with no `data-portal.json` are skipped silently — they have no portal.
2141
+
2142
+ - [ ] **Step 3d: Documentation**
2143
+
2144
+ - `platform/plugins/cloudflare/PLUGIN.md` — describe the index push, the two bindings, and the `[portal-index]` / `[portal-fetch]` log tags.
2145
+ - `.docs/` — a page describing the class-derived allowlist and why the resolver is the single decision point.
2146
+ - `platform/plugins/docs/references/` — the client-facing description of what the portal shows. **No task numbers in this file**: the leak gate refuses task citations anywhere under `platform/plugins`, which is Task 1830.
2147
+
2148
+ - [ ] **Step 4: Run the whole surface**
2149
+
2150
+ ```bash
2151
+ export PATH="$HOME/.nvm/versions/node/v22.22.0/bin:$PATH"
2152
+ cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run && npm run typecheck:templates
2153
+ cd ../../../ui && node_modules/.bin/vitest run server/routes/__tests__/portal-fetch.test.ts
2154
+ cd ../plugins/scheduling/mcp && ../../../node_modules/.bin/tsc --noEmit -p tsconfig.json
2155
+ ```
2156
+
2157
+ Expected: all green; typecheck exit 0.
2158
+
2159
+ - [ ] **Step 5: Commit**
2160
+
2161
+ ```bash
2162
+ git add platform/plugins/cloudflare platform/plugins/scheduling .docs
2163
+ git commit -m "feat(data-portal): assembly bindings, hourly index push, docs"
2164
+ ```
2165
+
2166
+ ---
2167
+
2168
+ ## Self-Review
2169
+
2170
+ **Spec coverage.** Resolver → Task 1. Signed links → Task 2. D1 schema and enrolment binding → Task 3. D1 transport → Task 4. Index push and walker → Task 5. Listing → Task 6. Download branch with offline pre-flight → Task 7. Install route with re-derived exposure → Task 8. Portal UI → Task 9. Assembly, heartbeat, docs → Task 10. Every spec section maps to a task.
2171
+
2172
+ **Type consistency.** `resolveExposedDirs` returns the same six-field object in Tasks 1, 5 and 8. `signPath`/`verifyPath` parameter order is identical in Tasks 2, 7 and 8. `DirEntry` is defined once in Task 6 and consumed by Task 9. `pushAccount` and `walkExposed` signatures match between Task 5's implementation and its tests.
2173
+
2174
+ **Known gaps, deliberate.**
2175
+ - Task 9 has no automated test. The portal front end has no harness in this repo and adding one is a larger change than the feature.
2176
+ - The heartbeat spawn in Task 10 copies a mechanism that has never been observed running on a device (Task 1828). The push is runnable standalone so it stays operable regardless, and the SKILL tells the assembler to run it by hand once.
2177
+ - `cf-exec.d1Query` still spawns `wrangler`. Converging it with the new client is a follow-up, to be filed at land as Task 1834 alongside Task 1812.