@rubytech/create-maxy-code 0.1.477 → 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 (204) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1704-data-portal-standing-audit.md +673 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md +1244 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +462 -0
  5. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +321 -0
  6. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +2177 -0
  7. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1704-data-portal-standing-audit-design.md +177 -0
  8. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +201 -0
  9. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +217 -0
  10. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +80 -0
  11. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1820-cpu-triage-admin-tools-design.md +97 -0
  12. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +163 -0
  13. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  14. package/payload/platform/lib/graph-style/dist/index.js +22 -0
  15. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  16. package/payload/platform/lib/graph-style/src/__tests__/parity.test.ts +6 -0
  17. package/payload/platform/lib/graph-style/src/index.ts +20 -0
  18. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +44 -0
  19. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
  20. package/payload/platform/lib/storage-broker/dist/audit.d.ts +56 -0
  21. package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
  22. package/payload/platform/lib/storage-broker/dist/audit.js +34 -0
  23. package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
  24. package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +70 -1
  25. package/payload/platform/lib/storage-broker/src/audit.ts +111 -0
  26. package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
  27. package/payload/platform/plugins/admin/PLUGIN.md +7 -1
  28. package/payload/platform/plugins/admin/mcp/dist/index.js +72 -0
  29. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  30. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.d.ts +94 -0
  31. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.d.ts.map +1 -0
  32. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.js +229 -0
  33. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.js.map +1 -0
  34. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +14 -2
  35. package/payload/platform/plugins/cloudflare/PLUGIN.md +26 -1
  36. package/payload/platform/plugins/cloudflare/bin/d1-http.mjs +80 -0
  37. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +17 -2
  38. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +357 -0
  39. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +103 -0
  40. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  41. package/payload/platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts +96 -0
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +120 -0
  43. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +59 -4
  44. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +176 -0
  45. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-state-contract.test.ts +75 -0
  46. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +131 -0
  47. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +68 -0
  48. package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +88 -0
  49. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +39 -0
  50. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +68 -1
  51. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +30 -1
  52. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +87 -0
  53. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +8 -4
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +10 -0
  55. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +107 -1
  56. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +82 -2
  57. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -0
  58. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +56 -0
  59. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +98 -2
  60. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +12 -0
  61. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  62. package/payload/platform/plugins/docs/references/cloudflare.md +12 -0
  63. package/payload/platform/plugins/email/PLUGIN.md +2 -1
  64. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.d.ts +2 -0
  65. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.d.ts.map +1 -0
  66. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.js +92 -0
  67. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.js.map +1 -0
  68. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-send.test.js +112 -12
  69. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-send.test.js.map +1 -1
  70. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.d.ts +2 -0
  71. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.d.ts.map +1 -0
  72. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.js +54 -0
  73. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.js.map +1 -0
  74. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-drafts.test.js +170 -1
  75. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-drafts.test.js.map +1 -1
  76. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.d.ts +2 -0
  77. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.d.ts.map +1 -0
  78. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.js +86 -0
  79. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.js.map +1 -0
  80. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.d.ts +2 -0
  81. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.d.ts.map +1 -0
  82. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.js +164 -0
  83. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.js.map +1 -0
  84. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.d.ts +2 -0
  85. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.d.ts.map +1 -0
  86. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.js +82 -0
  87. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.js.map +1 -0
  88. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +95 -0
  89. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  90. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +223 -0
  91. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  92. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.d.ts +18 -0
  93. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.d.ts.map +1 -0
  94. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.js +59 -0
  95. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.js.map +1 -0
  96. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.d.ts +48 -0
  97. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.d.ts.map +1 -0
  98. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.js +79 -0
  99. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.js.map +1 -0
  100. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.d.ts +44 -0
  101. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.d.ts.map +1 -0
  102. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.js +63 -0
  103. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.js.map +1 -0
  104. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.d.ts +2 -0
  105. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.d.ts.map +1 -0
  106. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.js +55 -0
  107. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.js.map +1 -0
  108. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.d.ts.map +1 -1
  109. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js +64 -13
  110. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js.map +1 -1
  111. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.d.ts.map +1 -1
  112. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js +11 -1
  113. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js.map +1 -1
  114. package/payload/platform/plugins/email/mcp/dist/tools/email-send.d.ts.map +1 -1
  115. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js +16 -3
  116. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js.map +1 -1
  117. package/payload/platform/plugins/email/references/email-reference.md +36 -0
  118. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts +2 -0
  119. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts.map +1 -0
  120. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js +66 -0
  121. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js.map +1 -0
  122. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.d.ts +2 -0
  123. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.d.ts.map +1 -0
  124. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.js +18 -0
  125. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.js.map +1 -0
  126. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts +41 -0
  127. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts.map +1 -0
  128. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js +75 -0
  129. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js.map +1 -0
  130. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.d.ts +13 -0
  131. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.d.ts.map +1 -0
  132. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.js +10 -0
  133. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.js.map +1 -0
  134. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +175 -0
  135. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  136. package/payload/platform/scripts/cpu-triage-run.sh +117 -0
  137. package/payload/platform/scripts/cpu-triage.sh +23 -4
  138. package/payload/platform/services/claude-session-manager/dist/activity-range.d.ts +40 -0
  139. package/payload/platform/services/claude-session-manager/dist/activity-range.d.ts.map +1 -0
  140. package/payload/platform/services/claude-session-manager/dist/activity-range.js +68 -0
  141. package/payload/platform/services/claude-session-manager/dist/activity-range.js.map +1 -0
  142. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts +40 -8
  143. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts.map +1 -1
  144. package/payload/platform/services/claude-session-manager/dist/activity-rows.js +74 -7
  145. package/payload/platform/services/claude-session-manager/dist/activity-rows.js.map +1 -1
  146. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  147. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +2 -0
  148. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  149. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  150. package/payload/platform/services/claude-session-manager/dist/http-server.js +9 -0
  151. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  152. package/payload/server/{chunk-56WJMBQQ.js → chunk-3XLLTG6R.js} +14 -0
  153. package/payload/server/{chunk-JECAP3Z2.js → chunk-XGNRSM57.js} +3 -0
  154. package/payload/server/maxy-edge.js +1 -1
  155. package/payload/server/public/activity.html +8 -6
  156. package/payload/server/public/assets/{AdminLoginScreens-mEKP4pDi.js → AdminLoginScreens-DkxQZnvB.js} +1 -1
  157. package/payload/server/public/assets/AdminShell-Cv_nIFGe.js +2 -0
  158. package/payload/server/public/assets/Checkbox-CsTWK8mm.js +1 -0
  159. package/payload/server/public/assets/activity-BbnTiVBT.js +1 -0
  160. package/payload/server/public/assets/{admin-BVhgfgs1.js → admin-BZY3i_Ff.js} +1 -1
  161. package/payload/server/public/assets/{browser-D-HMmHcM.js → browser-BbIiSFKr.js} +1 -1
  162. package/payload/server/public/assets/{calendar-tGWvFyFn.js → calendar-BvxeTHvQ.js} +1 -1
  163. package/payload/server/public/assets/chat-DUKxufRk.js +1 -0
  164. package/payload/server/public/assets/chevron-left-BtfJD6LL.js +1 -0
  165. package/payload/server/public/assets/data-hl1wa-Ax.js +1 -0
  166. package/payload/server/public/assets/{graph-zEw610xK.js → graph-CDSGRoeb.js} +1 -1
  167. package/payload/server/public/assets/graph-labels-C5qLwnWA.js +1 -0
  168. package/payload/server/public/assets/{maximize-2-BlTjXT_Y.js → maximize-2-CAzH8dED.js} +1 -1
  169. package/payload/server/public/assets/{operator-C7oIw2PG.js → operator-BK7r8DAB.js} +1 -1
  170. package/payload/server/public/assets/{page-CTP7OFZa.js → page-B5YngOrK.js} +1 -1
  171. package/payload/server/public/assets/{page-DL6Zsdvk.js → page-BsCS0Dmc.js} +1 -1
  172. package/payload/server/public/assets/{public-r1A9dqR_.js → public-C1Pz-IBs.js} +1 -1
  173. package/payload/server/public/assets/{rotate-ccw-CzkkKx4-.js → rotate-ccw-UDln4Tou.js} +1 -1
  174. package/payload/server/public/assets/tasks-B-cLsn3p.js +1 -0
  175. package/payload/server/public/assets/{time-entry-format-j669DgXj.js → time-entry-format-DPOBIE18.js} +1 -1
  176. package/payload/server/public/assets/{triangle-alert-BPgCgIef.js → triangle-alert-Dz-CZKo5.js} +1 -1
  177. package/payload/server/public/assets/{useCopyFeedback-BmLa2aes.js → useCopyFeedback-wtUHGohk.js} +1 -1
  178. package/payload/server/public/assets/{useSelectionMode-DnnM7A8-.js → useSelectionMode-sph7AQT5.js} +1 -1
  179. package/payload/server/public/assets/useSubAccountSwitcher-BRIluIKf.css +1 -0
  180. package/payload/server/public/assets/{useVoiceRecorder-u2dwZfen.js → useVoiceRecorder-DN4jYQ0A.js} +1 -1
  181. package/payload/server/public/brand-defaults.css +1 -0
  182. package/payload/server/public/browser.html +5 -5
  183. package/payload/server/public/calendar.html +6 -6
  184. package/payload/server/public/chat.html +12 -12
  185. package/payload/server/public/data.html +11 -11
  186. package/payload/server/public/graph.html +11 -11
  187. package/payload/server/public/index.html +14 -14
  188. package/payload/server/public/operator.html +14 -14
  189. package/payload/server/public/privacy.html +1 -0
  190. package/payload/server/public/public.html +12 -12
  191. package/payload/server/public/tasks.html +6 -5
  192. package/payload/server/public/vnc-popout.html +1 -0
  193. package/payload/server/server.js +894 -384
  194. package/payload/server/{src-JYPKMWJR.js → src-XQ63FPRE.js} +3 -1
  195. package/payload/server/public/assets/AdminShell-BwSaZ88d.js +0 -2
  196. package/payload/server/public/assets/Checkbox-CN4uQ80w.js +0 -1
  197. package/payload/server/public/assets/activity-CIQ8ozNZ.js +0 -1
  198. package/payload/server/public/assets/chat-DkttVxAz.js +0 -1
  199. package/payload/server/public/assets/chevron-left-CZ4ez9G5.js +0 -1
  200. package/payload/server/public/assets/data-P-mcmnNi.js +0 -1
  201. package/payload/server/public/assets/graph-labels-qnBleOE6.js +0 -1
  202. package/payload/server/public/assets/tasks-fIJwYFWG.js +0 -1
  203. package/payload/server/public/assets/useSubAccountSwitcher-C_E8h07P.css +0 -1
  204. /package/payload/server/public/assets/{useSubAccountSwitcher-CFa6ZAT1.js → useSubAccountSwitcher-v8LyBzZN.js} +0 -0
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { resolveExposedDirs } from '../../bin/schema-exposed-dirs.mjs'
3
+
4
+ const ALLOWED = ['projects', 'contacts', 'documents', 'output', 'uploads', 'quotes', 'sites']
5
+
6
+ function schema(opts: { allowed?: string[]; ontology?: string; pluginOwned?: string } = {}): string {
7
+ const allowed = opts.allowed ?? ALLOWED
8
+ return [
9
+ '# Account schema',
10
+ '',
11
+ '```allowed-top-level',
12
+ ...allowed,
13
+ '```',
14
+ '',
15
+ opts.pluginOwned ?? '',
16
+ '',
17
+ opts.ontology ?? '',
18
+ '',
19
+ ].join('\n')
20
+ }
21
+
22
+ // The generator writes this region with an ASCII hyphen separator
23
+ // (account-schema-owned-dirs.py:294).
24
+ const ONTOLOGY = [
25
+ '<!-- ontology-buckets:start -->',
26
+ '## Domain entity buckets (from the graph ontology)',
27
+ '',
28
+ '- `quotes/` - one folder per Quotation record.',
29
+ '<!-- ontology-buckets:end -->',
30
+ ].join('\n')
31
+
32
+ // The plugin-owned region uses an EM DASH (account-schema-owned-dirs.py:281).
33
+ const PLUGIN_OWNED = [
34
+ '<!-- plugin-owned-dirs:start -->',
35
+ '## Plugin-owned top-level workspaces',
36
+ '',
37
+ '- `sites/` — Owned by the cloudflare plugin.',
38
+ '<!-- plugin-owned-dirs:end -->',
39
+ ].join('\n')
40
+
41
+ describe('resolveExposedDirs', () => {
42
+ it('exposes ontology buckets plus output', () => {
43
+ const r = resolveExposedDirs(schema({ ontology: ONTOLOGY }))
44
+ expect(r.exposed).toEqual(['output', 'quotes'])
45
+ expect(r.schemaPresent).toBe(true)
46
+ })
47
+
48
+ it('exposes output alone when there is no ontology region', () => {
49
+ expect(resolveExposedDirs(schema()).exposed).toEqual(['output'])
50
+ })
51
+
52
+ it('drops a bucket that is absent from the allowed block', () => {
53
+ const r = resolveExposedDirs(schema({ allowed: ['output', 'documents'], ontology: ONTOLOGY }))
54
+ expect(r.exposed).toEqual(['output'])
55
+ })
56
+
57
+ // The contract that fails silently if got wrong: an em dash in the ontology
58
+ // region must NOT parse, because the generator never writes one there.
59
+ it('does not parse an em-dash separator as a domain bucket', () => {
60
+ const emDash = ONTOLOGY.replace('- one folder per', '— one folder per')
61
+ expect(resolveExposedDirs(schema({ ontology: emDash })).domainBuckets).toEqual([])
62
+ })
63
+
64
+ it('never exposes operator-data or upload buckets', () => {
65
+ const r = resolveExposedDirs(schema({ ontology: ONTOLOGY }))
66
+ for (const d of ['documents', 'contacts', 'projects', 'uploads']) {
67
+ expect(r.exposed).not.toContain(d)
68
+ }
69
+ })
70
+
71
+ it('reports a plugin-owned collision and keeps the dir out of exposed', () => {
72
+ const collide = ONTOLOGY.replace('`quotes/`', '`sites/`').replace('Quotation', 'Site')
73
+ const r = resolveExposedDirs(schema({ ontology: collide, pluginOwned: PLUGIN_OWNED }))
74
+ expect(r.collisions).toEqual(['sites'])
75
+ expect(r.exposed).toEqual(['output'])
76
+ })
77
+
78
+ it('fails closed on a missing schema', () => {
79
+ const r = resolveExposedDirs(null)
80
+ expect(r.schemaPresent).toBe(false)
81
+ expect(r.exposed).toEqual([])
82
+ })
83
+
84
+ it('fails closed when the allowed block is absent', () => {
85
+ const r = resolveExposedDirs('# no fence here')
86
+ expect(r.exposed).toEqual([])
87
+ })
88
+ })
@@ -105,3 +105,42 @@ describe('template layout matches what the deploy publishes', () => {
105
105
  ])
106
106
  })
107
107
  })
108
+
109
+ describe('device binding', () => {
110
+ it('declares the install origin as a var placeholder', () => {
111
+ const text = wrangler()
112
+ expect(text).toMatch(/\[vars\]/)
113
+ expect(text).toMatch(/PORTAL_INSTALL_ORIGIN\s*=\s*"__PORTAL_INSTALL_ORIGIN__"/)
114
+ })
115
+
116
+ // The secret must never be ASSIGNED in wrangler.toml: that file is committed
117
+ // to the account's assembled tree, and the deploy publishes the directory it
118
+ // is handed, so a secret there is a secret in a repo and one deploy from
119
+ // being served. Naming it in a comment is the opposite — that comment is what
120
+ // stops someone adding the assignment later — so the rule is on assignment,
121
+ // not on the word.
122
+ it('never assigns the fetch secret in wrangler.toml', () => {
123
+ const assignments = wrangler()
124
+ .split('\n')
125
+ .filter((l) => !l.trim().startsWith('#'))
126
+ .join('\n')
127
+ expect(assignments).not.toMatch(/PORTAL_FETCH_SECRET\s*=/)
128
+ })
129
+
130
+ it('SKILL.md tells the assembler how to set both, and to publish the index', () => {
131
+ const skill = readFileSync(join(SKILL, 'SKILL.md'), 'utf8')
132
+ expect(skill).toContain('PORTAL_FETCH_SECRET')
133
+ expect(skill).toContain('PORTAL_INSTALL_ORIGIN')
134
+ expect(skill).toContain('portal-index-push.mjs')
135
+ // Enrolment gained a required flag; the documented command must carry it or
136
+ // every enrolment the skill prescribes fails.
137
+ expect(skill).toContain('--account')
138
+ })
139
+
140
+ it('schema.sql declares the directory table the portal reads', () => {
141
+ const text = schema()
142
+ expect(text).toMatch(/CREATE TABLE IF NOT EXISTS directory/)
143
+ expect(text).toMatch(/UNIQUE \(accountId, relPath\)/)
144
+ expect(text).toMatch(/accountId\s+TEXT NOT NULL DEFAULT ''/)
145
+ })
146
+ })
@@ -48,7 +48,7 @@ For each person:
48
48
 
49
49
  ```bash
50
50
  node platform/plugins/cloudflare/bin/portal-enrol.mjs --owner <ownerId> --name <name> \
51
- | npx wrangler d1 execute <portalDbName> --remote --file=-
51
+ --account <accountId> | npx wrangler d1 execute <portalDbName> --remote --file=-
52
52
  ```
53
53
 
54
54
  The script mints the passcode and prints it **once to stderr**, so it reaches the operator's terminal
@@ -60,6 +60,12 @@ the isolation gate would reject, so a bad name fails here rather than after the
60
60
  The statement is an upsert on `ownerId`, which is what makes rotation a re-run rather than a
61
61
  constraint error.
62
62
 
63
+ `--account` binds the person to the sub-account whose deliverable folders they may read, and it is
64
+ required. A person with no account resolves to an empty folder list, which is indistinguishable from
65
+ a sub-account that genuinely has nothing to share, so the script refuses rather than mint a row that
66
+ fails silently later. Anyone enrolled before this existed has no account bound and sees nothing; run
67
+ enrolment again for them, which the upsert makes a re-run rather than a migration.
68
+
63
69
  **A passcode is never stored in plaintext** — not in `data-portal.json`, not in a log line, nowhere
64
70
  on the device. The operator conveys it to the person out of band. Rotating a passcode is a re-run of
65
71
  the script for that person; there is no separate editor.
@@ -110,6 +116,67 @@ anything if the stylesheet or a value is not what it expects, and leaves the tem
110
116
  place for a brand that declares no palette — which is the case a hand-edit turns into an empty
111
117
  declaration nobody sees until the page loads.
112
118
 
119
+ ## Bind the portal to the device
120
+
121
+ The portal shows the sub-account's deliverable folders as well as what the client uploaded. The
122
+ folder names and sizes live in D1, so the tree renders whether or not the device is up; the bytes do
123
+ not, so opening a file has to reach the install. That needs two things the template cannot know.
124
+
125
+ Fill `__PORTAL_INSTALL_ORIGIN__` in `wrangler.toml` with the install's public origin
126
+ (`https://<tunnel-hostname>`), then mint one secret and set it on both ends:
127
+
128
+ ```bash
129
+ SECRET="$(node -e 'console.log([...crypto.getRandomValues(new Uint8Array(32))].map(b=>b.toString(16).padStart(2,"0")).join(""))')"
130
+ mkdir -p <accountDir>/secrets
131
+ ( umask 077; printf 'PORTAL_FETCH_SECRET=%s\n' "$SECRET" >> <accountDir>/secrets/data-portal.env )
132
+ printf '%s' "$SECRET" | npx wrangler pages secret put PORTAL_FETCH_SECRET --project-name <project>
133
+ ```
134
+
135
+ The device reads `<accountDir>/secrets/data-portal.env` per request, so the secret is **per account**
136
+ rather than one value for the whole install. That is what makes the account binding inside the
137
+ signature mean anything: a shared key would let any portal sign for any other account on the same
138
+ install. Rotating it is a re-run of both lines, with no restart.
139
+
140
+ The secret never travels. The portal signs one account, one file path and a five-minute expiry with
141
+ it, and the install verifies that signature. It is a Pages **secret**, never a `[vars]` entry: the
142
+ assembled tree is published as-is, so a secret in `wrangler.toml` is one deploy from being served.
143
+
144
+ The signature is not the security boundary. The install re-derives which folders are exposed from the
145
+ account's own `SCHEMA.md` on every request, so a correctly signed link for `documents/` is refused
146
+ anyway. That check is what contains a stale index or a compromised project, and it is why both exist.
147
+
148
+ ## Publish the folder index
149
+
150
+ The device publishes file metadata — names, sizes, times, never bytes:
151
+
152
+ ```bash
153
+ node platform/plugins/cloudflare/bin/portal-index-push.mjs \
154
+ --account-dir <accountDir> --account <accountId> --db <portalDbName>
155
+ ```
156
+
157
+ Which folders it publishes is not configured here and is not a list in the code. It is derived from
158
+ that account's own `accounts/<accountId>/SCHEMA.md`: the buckets named in its
159
+ `<!-- ontology-buckets -->` region, plus `output`, intersected with its `allowed-top-level` block. A
160
+ brand with a different vertical publishes different folders with no change to this skill. Operator
161
+ data (`documents/`, `contacts/`, `projects/`), the client's own `uploads/`, plugin workspaces and
162
+ everything platform-internal are in neither source and are never published.
163
+
164
+ An account with no `SCHEMA.md` publishes nothing at all. That is deliberate and it is the opposite of
165
+ what the write-path guard does — the guard fails open so an unseeded account is not write-blocked,
166
+ while a surface facing a client must fail closed.
167
+
168
+ The heartbeat runs this hourly for every account carrying a `data-portal.json`. Run it once by hand
169
+ after assembly so the client has something to see on their first sign-in, and check the output names
170
+ the folders you expect:
171
+
172
+ ```
173
+ [portal-index] op=resolve account=<id> schemaPresent=true allowed=22 domainBuckets=quotes exposed=output,quotes
174
+ [portal-index] op=verify account=<id> rowsAfter=41 expected=41
175
+ ```
176
+
177
+ `exposed=none` on an account that has deliverables means the schema was missing or its ontology
178
+ region was absent — not that the walk found nothing.
179
+
113
180
  ## Deploy through site-deploy
114
181
 
115
182
  `storage-pages-deploy` refuses a project with no owner on record, so record the ownership first. In
@@ -18,12 +18,19 @@
18
18
  -- has the column, this file will NOT remove it — IF NOT EXISTS is a no-op on an
19
19
  -- existing table — and enrolment's INSERT will fail on `iterations NOT NULL`.
20
20
  -- Drop the column by hand in that case; do not add it back here.
21
+ --
22
+ -- `accountId` binds a person to the sub-account whose deliverable folders they
23
+ -- may read. It carries a DEFAULT so this file stays a no-op on a table that
24
+ -- predates it — but an existing table will NOT gain the column, and a person
25
+ -- whose row has the empty default sees an empty folder list. Re-enrol those
26
+ -- people; the upsert on ownerId makes that a re-run, not a migration.
21
27
  CREATE TABLE IF NOT EXISTS people (
22
28
  ownerId TEXT PRIMARY KEY,
23
29
  name TEXT NOT NULL,
24
30
  salt TEXT NOT NULL,
25
31
  hash TEXT NOT NULL,
26
- createdAt TEXT NOT NULL
32
+ createdAt TEXT NOT NULL,
33
+ accountId TEXT NOT NULL DEFAULT ''
27
34
  );
28
35
 
29
36
  -- `pcCheck` is a short digest of the person's passcode hash at mint time, and
@@ -71,3 +78,25 @@ CREATE TABLE IF NOT EXISTS auth_attempts (
71
78
  );
72
79
 
73
80
  CREATE UNIQUE INDEX IF NOT EXISTS auth_attempts_scope_window ON auth_attempts (scope, windowStart);
81
+
82
+ -- One row per file the device has published to this portal. Metadata only: the
83
+ -- bytes stay on the device and are fetched on demand through a short-lived
84
+ -- signed link, which is what lets the folder tree render while the device is
85
+ -- offline.
86
+ --
87
+ -- Written by a full replace per account (delete-all, then insert the current
88
+ -- walk), which is why there is no tombstone column: a file deleted or renamed on
89
+ -- the device simply stops being inserted on the next cycle. The UNIQUE key is
90
+ -- what makes that replace safe to retry — a double insert of the same path
91
+ -- fails loudly instead of silently doubling the row.
92
+ CREATE TABLE IF NOT EXISTS directory (
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ accountId TEXT NOT NULL,
95
+ relPath TEXT NOT NULL,
96
+ sizeBytes INTEGER NOT NULL,
97
+ modifiedAt TEXT NOT NULL,
98
+ indexedAt TEXT NOT NULL,
99
+ UNIQUE (accountId, relPath)
100
+ );
101
+
102
+ CREATE INDEX IF NOT EXISTS directory_account ON directory (accountId);
@@ -0,0 +1,87 @@
1
+ // Signed links for device-held files.
2
+ //
3
+ // The portal holds the account's folder INDEX but not its bytes, so a download
4
+ // has to reach the install. It does that with a link the install can verify
5
+ // rather than a shared secret in a header: the secret never travels, and each
6
+ // grant covers one file for five minutes.
7
+ //
8
+ // The signature proves the request came from the portal. It is NOT the security
9
+ // boundary — the install re-derives the account's exposed folder set on every
10
+ // request, so a validly-signed link for a non-exposed path is still refused.
11
+ // Keep both checks; neither is redundant.
12
+ //
13
+ // Web Crypto only. Cloudflare Workers and Node 22 both provide it, so the
14
+ // install can verify with an identical algorithm without this module crossing
15
+ // the plugin boundary into platform/ui.
16
+
17
+ /** Five minutes. Long enough for a click to become a download, short enough
18
+ * that a leaked link is worthless by the time it is found in a log. */
19
+ export const SIGNED_TTL_MS = 300_000
20
+
21
+ /** @param {string} accountId @param {string} relPath @param {number} expiresAtMs */
22
+ function message(accountId, relPath, expiresAtMs) {
23
+ // Newline-separated so no field can absorb another: an accountId ending in a
24
+ // path fragment cannot forge a different (accountId, relPath) pair.
25
+ return `${accountId}\n${relPath}\n${expiresAtMs}`
26
+ }
27
+
28
+ /** @param {string} secret */
29
+ async function keyFor(secret) {
30
+ return crypto.subtle.importKey(
31
+ 'raw',
32
+ new TextEncoder().encode(secret),
33
+ { name: 'HMAC', hash: 'SHA-256' },
34
+ false,
35
+ ['sign'],
36
+ )
37
+ }
38
+
39
+ /**
40
+ * @param {string} secret @param {string} accountId @param {string} relPath
41
+ * @param {number} expiresAtMs @returns {Promise<string>} lowercase hex
42
+ */
43
+ export async function signPath(secret, accountId, relPath, expiresAtMs) {
44
+ const key = await keyFor(secret)
45
+ const mac = await crypto.subtle.sign(
46
+ 'HMAC',
47
+ key,
48
+ new TextEncoder().encode(message(accountId, relPath, expiresAtMs)),
49
+ )
50
+ return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('')
51
+ }
52
+
53
+ /** Length-independent, value-constant comparison. A `===` on the hex would leak
54
+ * the matching prefix length through timing.
55
+ * @param {string} a @param {string} b */
56
+ function constantTimeEqual(a, b) {
57
+ if (a.length !== b.length) return false
58
+ let diff = 0
59
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
60
+ return diff === 0
61
+ }
62
+
63
+ /**
64
+ * @param {string} secret @param {string} accountId @param {string} relPath
65
+ * @param {number} expiresAtMs @param {string} signature @param {number} nowMs
66
+ * @returns {Promise<boolean>}
67
+ */
68
+ export async function verifyPath(secret, accountId, relPath, expiresAtMs, signature, nowMs) {
69
+ if (!secret || !signature) return false
70
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) return false
71
+ const expected = await signPath(secret, accountId, relPath, expiresAtMs)
72
+ return constantTimeEqual(expected, signature)
73
+ }
74
+
75
+ /**
76
+ * @param {string} origin @param {string} secret @param {string} accountId
77
+ * @param {string} relPath @param {number} expiresAtMs @returns {Promise<string>}
78
+ */
79
+ export async function buildSignedUrl(origin, secret, accountId, relPath, expiresAtMs) {
80
+ const sig = await signPath(secret, accountId, relPath, expiresAtMs)
81
+ const url = new URL('/api/portal/fetch', origin)
82
+ url.searchParams.set('accountId', accountId)
83
+ url.searchParams.set('path', relPath)
84
+ url.searchParams.set('exp', String(expiresAtMs))
85
+ url.searchParams.set('sig', sig)
86
+ return url.toString()
87
+ }
@@ -54,11 +54,11 @@ export async function resolveSession(
54
54
  db: D1Database,
55
55
  sessionId: string,
56
56
  nowMs: number,
57
- ): Promise<{ ownerId: string } | null> {
57
+ ): Promise<{ ownerId: string; accountId: string } | null> {
58
58
  if (!sessionId) return null
59
59
  const row = await db
60
60
  .prepare(
61
- `SELECT s.ownerId AS ownerId
61
+ `SELECT s.ownerId AS ownerId, p.accountId AS accountId
62
62
  FROM sessions s
63
63
  JOIN people p ON p.ownerId = s.ownerId
64
64
  WHERE s.sessionId = ?
@@ -66,8 +66,12 @@ export async function resolveSession(
66
66
  AND s.pcCheck = substr(p.hash, 1, 16)`,
67
67
  )
68
68
  .bind(sessionId, nowMs)
69
- .first<{ ownerId: string }>()
70
- return row && typeof row.ownerId === 'string' ? { ownerId: row.ownerId } : null
69
+ .first<{ ownerId: string; accountId: string }>()
70
+ if (!row || typeof row.ownerId !== 'string') return null
71
+ // A `people` row predating the accountId column resolves it as absent. Treat
72
+ // that as "no folders", never as "all folders" — the empty string reaches the
73
+ // listing, which returns nothing rather than falling back to some default.
74
+ return { ownerId: row.ownerId, accountId: typeof row.accountId === 'string' ? row.accountId : '' }
71
75
  }
72
76
 
73
77
  export function readSessionCookie(header: string | null): string | null {
@@ -34,6 +34,16 @@ export interface R2Bucket {
34
34
  export interface PortalEnv {
35
35
  DB: D1Database
36
36
  BUCKET: R2Bucket
37
+ /**
38
+ * Shared with the install; signs download links for device-held files. A
39
+ * Pages SECRET, never a var in wrangler.toml, and never sent to the client.
40
+ * Optional because a portal assembled before this existed still serves
41
+ * uploads — its indexed downloads report device-offline instead of minting an
42
+ * unverifiable link.
43
+ */
44
+ PORTAL_FETCH_SECRET?: string
45
+ /** The install's public origin, e.g. `https://<tunnel-hostname>`. */
46
+ PORTAL_INSTALL_ORIGIN?: string
37
47
  }
38
48
 
39
49
  export type Logger = (line: string) => void
@@ -2,6 +2,7 @@ import type { Handler, Logger, PortalEnv, R2ObjectBody } from './_lib/types'
2
2
  import { readSessionCookie, resolveSession } from './_lib/session'
3
3
  import { q } from './_lib/log'
4
4
  import { authorizeKey } from './_lib/authorize'
5
+ import { buildSignedUrl, SIGNED_TTL_MS } from './_lib/portal-sign.mjs'
5
6
 
6
7
  interface DownloadResult extends Handler {
7
8
  body?: R2ObjectBody | null
@@ -33,6 +34,93 @@ export async function processDownload(
33
34
  return { status: 200, payload: { ok: true }, body: obj }
34
35
  }
35
36
 
37
+ type HeadFetch = (url: string, init: { method: string }) => Promise<{ ok: boolean; status: number }>
38
+
39
+ interface IndexedDownloadResult extends Handler {
40
+ url?: string
41
+ }
42
+
43
+ /**
44
+ * Hand back a short-lived signed link to a file the DEVICE holds.
45
+ *
46
+ * The index is consulted first, so a path the device never published costs no
47
+ * network call — and `documents/` is never in the index by construction, which
48
+ * is why a client cannot name their way into it from here.
49
+ *
50
+ * The device is probed before the link is minted. Without that, an offline
51
+ * device produces an opaque browser failure and the client cannot tell it from
52
+ * a missing file. The gap between probe and fetch is real but small, and losing
53
+ * it degrades to exactly the opaque failure this avoids the rest of the time.
54
+ *
55
+ * The secret never appears in the link or the log: only the signature derived
56
+ * from it does.
57
+ */
58
+ export async function processIndexedDownload(
59
+ sessionId: string,
60
+ relPath: string,
61
+ env: PortalEnv,
62
+ log: Logger,
63
+ nowMs: number,
64
+ fetchFn: HeadFetch,
65
+ ): Promise<IndexedDownloadResult> {
66
+ const session = await resolveSession(env.DB, sessionId, nowMs)
67
+ if (!session) {
68
+ log('[data-portal] op=fetch-through owner=none result=denied')
69
+ return { status: 401, payload: { ok: false, error: 'denied' } }
70
+ }
71
+ if (!session.accountId) {
72
+ log(`[data-portal] op=fetch-through owner=${q(session.ownerId)} result=no-account`)
73
+ return { status: 404, payload: { ok: false, error: 'not found' } }
74
+ }
75
+
76
+ const row = await env.DB.prepare('SELECT relPath FROM directory WHERE accountId = ? AND relPath = ?')
77
+ .bind(session.accountId, relPath)
78
+ .first<{ relPath: string }>()
79
+ if (!row) {
80
+ log(
81
+ `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} result=absent`,
82
+ )
83
+ return { status: 404, payload: { ok: false, error: 'not found' } }
84
+ }
85
+
86
+ const secret = env.PORTAL_FETCH_SECRET
87
+ const origin = env.PORTAL_INSTALL_ORIGIN
88
+ if (!secret || !origin) {
89
+ // Assembled without the device binding. Reported as offline rather than as
90
+ // a server error: from the client's side the file genuinely is unreachable,
91
+ // and the operator's signal is this line, not a 500.
92
+ log(`[data-portal] op=fetch-through owner=${q(session.ownerId)} result=unconfigured`)
93
+ return { status: 503, payload: { ok: false, error: 'device-offline' } }
94
+ }
95
+
96
+ const expiresAt = nowMs + SIGNED_TTL_MS
97
+ const url = await buildSignedUrl(origin, secret, session.accountId, relPath, expiresAt)
98
+
99
+ const started = Date.now()
100
+ try {
101
+ const head = await fetchFn(url, { method: 'HEAD' })
102
+ if (!head.ok) {
103
+ log(
104
+ `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
105
+ `status=${head.status} ms=${Date.now() - started} result=device-error`,
106
+ )
107
+ return { status: 503, payload: { ok: false, error: 'device-offline' } }
108
+ }
109
+ } catch (err) {
110
+ log(
111
+ `[data-portal] op=fetch-through-failed owner=${q(session.ownerId)} path=${q(relPath)} ` +
112
+ `err=${q(err instanceof Error ? err.message : String(err))} result=device-offline`,
113
+ )
114
+ return { status: 503, payload: { ok: false, error: 'device-offline' } }
115
+ }
116
+
117
+ log(
118
+ `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
119
+ `status=200 ms=${Date.now() - started} result=ok`,
120
+ )
121
+ return { status: 200, payload: { ok: true, url }, url }
122
+ }
123
+
36
124
  interface PagesContext {
37
125
  request: Request
38
126
  env: PortalEnv
@@ -40,7 +128,25 @@ interface PagesContext {
40
128
 
41
129
  export async function onRequestGet(context: PagesContext): Promise<Response> {
42
130
  const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
43
- const key = new URL(context.request.url).searchParams.get('key') ?? ''
131
+ const params = new URL(context.request.url).searchParams
132
+
133
+ // `path` names a device-held file from the published index; `key` names an
134
+ // R2 object the client uploaded. Two stores, two resolutions — the R2 path
135
+ // below is untouched.
136
+ const indexedPath = params.get('path')
137
+ if (indexedPath) {
138
+ const { status, payload } = await processIndexedDownload(
139
+ sessionId,
140
+ indexedPath,
141
+ context.env,
142
+ (line) => console.log(line),
143
+ Date.now(),
144
+ fetch as unknown as HeadFetch,
145
+ )
146
+ return Response.json(payload, { status })
147
+ }
148
+
149
+ const key = params.get('key') ?? ''
44
150
  const { status, payload, body } = await processDownload(
45
151
  sessionId,
46
152
  key,
@@ -2,11 +2,72 @@ import type { Handler, Logger, PortalEnv } from './_lib/types'
2
2
  import { readSessionCookie, resolveSession } from './_lib/session'
3
3
  import { q } from './_lib/log'
4
4
 
5
+ interface DirRow {
6
+ relPath: string
7
+ sizeBytes: number
8
+ modifiedAt: string
9
+ }
10
+
11
+ export interface DirEntry {
12
+ name: string
13
+ kind: 'file' | 'directory'
14
+ relPath: string
15
+ sizeBytes: number | null
16
+ modifiedAt: string | null
17
+ }
18
+
19
+ /**
20
+ * Collapse the flat `directory` rows into one level of listing under `prefix`.
21
+ *
22
+ * Folders are synthesised from path segments rather than stored, so the device
23
+ * publishes files alone and an empty folder simply does not exist. The prefix
24
+ * matches on a whole segment (`quotes/`, not `quotes`), so a partial name can
25
+ * never reach a sibling folder's contents.
26
+ */
27
+ export function entriesUnder(rows: DirRow[], prefix: string): DirEntry[] {
28
+ const base = prefix ? `${prefix}/` : ''
29
+ const dirs = new Map<string, DirEntry>()
30
+ const files: DirEntry[] = []
31
+ for (const row of rows) {
32
+ if (base && !row.relPath.startsWith(base)) continue
33
+ const rest = row.relPath.slice(base.length)
34
+ if (!rest) continue
35
+ const slash = rest.indexOf('/')
36
+ if (slash === -1) {
37
+ files.push({
38
+ name: rest,
39
+ kind: 'file',
40
+ relPath: row.relPath,
41
+ sizeBytes: row.sizeBytes,
42
+ modifiedAt: row.modifiedAt,
43
+ })
44
+ } else {
45
+ const name = rest.slice(0, slash)
46
+ if (!dirs.has(name)) {
47
+ dirs.set(name, {
48
+ name,
49
+ kind: 'directory',
50
+ relPath: `${base}${name}`,
51
+ sizeBytes: null,
52
+ modifiedAt: null,
53
+ })
54
+ }
55
+ }
56
+ }
57
+ const dirList = [...dirs.values()].sort((a, b) => a.name.localeCompare(b.name))
58
+ // Newest first within the file group: the primary use is seeing what changed.
59
+ files.sort(
60
+ (a, b) => (b.modifiedAt ?? '').localeCompare(a.modifiedAt ?? '') || a.name.localeCompare(b.name),
61
+ )
62
+ return [...dirList, ...files]
63
+ }
64
+
5
65
  export async function processFiles(
6
66
  sessionId: string,
7
67
  env: PortalEnv,
8
68
  log: Logger,
9
69
  nowMs: number,
70
+ prefix = '',
10
71
  ): Promise<Handler> {
11
72
  const session = await resolveSession(env.DB, sessionId, nowMs)
12
73
  if (!session) {
@@ -20,8 +81,25 @@ export async function processFiles(
20
81
  .bind(session.ownerId)
21
82
  .all<Record<string, unknown>>()
22
83
  const files = rows.results ?? []
23
- log(`[data-portal] op=list owner=${q(session.ownerId)} result=ok files=${files.length}`)
24
- return { status: 200, payload: { ok: true, files } }
84
+
85
+ // The account's published folder index, scoped by accountId. No account bound
86
+ // means no folders: an enrolment predating that column lands here, and the
87
+ // empty `account=` field in the log line is what says so.
88
+ let entries: DirEntry[] = []
89
+ if (session.accountId) {
90
+ const dir = await env.DB.prepare(
91
+ 'SELECT relPath, sizeBytes, modifiedAt FROM directory WHERE accountId = ?',
92
+ )
93
+ .bind(session.accountId)
94
+ .all<DirRow>()
95
+ entries = entriesUnder(dir.results ?? [], prefix)
96
+ }
97
+
98
+ log(
99
+ `[data-portal] op=list owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
100
+ `prefix=${q(prefix)} files=${files.length} entries=${entries.length} result=ok`,
101
+ )
102
+ return { status: 200, payload: { ok: true, files, entries } }
25
103
  }
26
104
 
27
105
  interface PagesContext {
@@ -31,11 +109,13 @@ interface PagesContext {
31
109
 
32
110
  export async function onRequestGet(context: PagesContext): Promise<Response> {
33
111
  const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
112
+ const prefix = new URL(context.request.url).searchParams.get('prefix') ?? ''
34
113
  const { status, payload } = await processFiles(
35
114
  sessionId,
36
115
  context.env,
37
116
  (line) => console.log(line),
38
117
  Date.now(),
118
+ prefix,
39
119
  )
40
120
  return Response.json(payload, { status })
41
121
  }
@@ -35,6 +35,15 @@
35
35
  <button class="dp-btn" type="submit">Upload</button>
36
36
  </form>
37
37
  <ul id="dp-files" class="dp-files"></ul>
38
+
39
+ <!-- Files we have prepared for you, published from the device. Hidden
40
+ until there is something in it, so an account with no deliverables
41
+ shows the upload list alone rather than an empty second heading. -->
42
+ <section id="dp-shared" class="dp-shared" hidden>
43
+ <h2 class="dp-subtitle">Shared with you</h2>
44
+ <nav id="dp-crumbs" class="dp-crumbs" aria-label="Folder path"></nav>
45
+ <ul id="dp-entries" class="dp-files"></ul>
46
+ </section>
38
47
  </section>
39
48
  </main>
40
49
  <script src="/portal.js"></script>