@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,120 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { processFiles, entriesUnder } from '../../skills/data-portal/template/functions/api/files'
3
+ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
4
+
5
+ const DIRECTORY = [
6
+ { accountId: 'acc-a', relPath: 'output/report.pdf', sizeBytes: 10, modifiedAt: '2026-07-20T10:00:00.000Z' },
7
+ { accountId: 'acc-a', relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z' },
8
+ { accountId: 'acc-a', relPath: 'quotes/CR2974/quote.pdf', sizeBytes: 30, modifiedAt: '2026-07-20T12:00:00.000Z' },
9
+ { accountId: 'acc-b', relPath: 'output/secret.pdf', sizeBytes: 40, modifiedAt: '2026-07-20T13:00:00.000Z' },
10
+ ]
11
+
12
+ function makeEnv(sessions: Record<string, { ownerId: string; accountId: string }>) {
13
+ return {
14
+ DB: {
15
+ prepare(query: string) {
16
+ let bound: unknown[] = []
17
+ const stmt = {
18
+ bind(...v: unknown[]) {
19
+ bound = v
20
+ return stmt
21
+ },
22
+ async run() {
23
+ return { meta: { changes: 0 } }
24
+ },
25
+ async all<T>() {
26
+ if (/FROM directory/i.test(query)) {
27
+ return { results: DIRECTORY.filter((d) => d.accountId === bound[0]) as T[] }
28
+ }
29
+ return { results: [] as T[] }
30
+ },
31
+ async first<T>() {
32
+ if (/FROM sessions/i.test(query)) return (sessions[bound[0] as string] ?? null) as T | null
33
+ return null as T | null
34
+ },
35
+ }
36
+ return stmt
37
+ },
38
+ },
39
+ } as unknown as PortalEnv
40
+ }
41
+
42
+ const SESSIONS = { 'sess-a': { ownerId: 'alice', accountId: 'acc-a' } }
43
+ type Entry = { name: string; kind: string; relPath: string; sizeBytes: number | null; modifiedAt: string | null }
44
+
45
+ describe('entriesUnder', () => {
46
+ const rows = DIRECTORY.filter((d) => d.accountId === 'acc-a')
47
+
48
+ it('synthesises top-level folders from row paths', () => {
49
+ const e = entriesUnder(rows, '')
50
+ expect(e.map((x) => x.name)).toEqual(['output', 'quotes'])
51
+ expect(e.every((x) => x.kind === 'directory')).toBe(true)
52
+ })
53
+
54
+ it('lists folders inside a prefix', () => {
55
+ expect(entriesUnder(rows, 'quotes').map((x) => x.name)).toEqual(['CR2969', 'CR2974'])
56
+ })
57
+
58
+ it('lists files inside a leaf folder with size and time', () => {
59
+ expect(entriesUnder(rows, 'quotes/CR2969')).toEqual([
60
+ {
61
+ name: 'quote.pdf',
62
+ kind: 'file',
63
+ relPath: 'quotes/CR2969/quote.pdf',
64
+ sizeBytes: 20,
65
+ modifiedAt: '2026-07-20T11:00:00.000Z',
66
+ },
67
+ ])
68
+ })
69
+
70
+ it('puts folders before files and files newest first', () => {
71
+ const mixed = [
72
+ { relPath: 'output/old.pdf', sizeBytes: 1, modifiedAt: '2026-01-01T00:00:00.000Z' },
73
+ { relPath: 'output/new.pdf', sizeBytes: 1, modifiedAt: '2026-09-01T00:00:00.000Z' },
74
+ { relPath: 'output/sub/x.pdf', sizeBytes: 1, modifiedAt: '2026-05-01T00:00:00.000Z' },
75
+ ]
76
+ expect(entriesUnder(mixed, 'output').map((x) => x.name)).toEqual(['sub', 'new.pdf', 'old.pdf'])
77
+ })
78
+
79
+ // A prefix must match a whole segment: `quote` must not reach `quotes/`.
80
+ it('does not match a partial segment as a prefix', () => {
81
+ expect(entriesUnder(rows, 'quote')).toEqual([])
82
+ })
83
+ })
84
+
85
+ describe('processFiles directory listing', () => {
86
+ it('returns synthesised folders for the session account', async () => {
87
+ const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
88
+ expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['output', 'quotes'])
89
+ })
90
+
91
+ it('never leaks another account rows', async () => {
92
+ const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
93
+ expect(JSON.stringify(r.payload)).not.toContain('secret.pdf')
94
+ })
95
+
96
+ it('returns an empty entries list when the person has no account bound', async () => {
97
+ const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: '' } })
98
+ const r = await processFiles('sess-a', env, () => {}, 0, '')
99
+ expect(r.status).toBe(200)
100
+ expect(r.payload.entries).toEqual([])
101
+ })
102
+
103
+ it('still denies without a session', async () => {
104
+ const r = await processFiles('nope', makeEnv(SESSIONS), () => {}, 0, '')
105
+ expect(r.status).toBe(401)
106
+ })
107
+
108
+ it('logs the account and the prefix', async () => {
109
+ const lines: string[] = []
110
+ await processFiles('sess-a', makeEnv(SESSIONS), (l) => lines.push(l), 0, 'quotes')
111
+ // Free-text log fields are quoted by `q()` — these values come off the wire.
112
+ expect(lines.some((l) => l.includes('account="acc-a"') && l.includes('prefix="quotes"'))).toBe(true)
113
+ })
114
+
115
+ it('keeps the uploads list alongside the folder entries', async () => {
116
+ const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
117
+ expect(r.payload).toHaveProperty('files')
118
+ expect(r.payload).toHaveProperty('entries')
119
+ })
120
+ })
@@ -12,7 +12,12 @@ import {
12
12
  const run = promisify(execFile)
13
13
  const SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), '../../bin/portal-enrol.mjs')
14
14
 
15
- const enrol = (args: string[]) => run('node', [SCRIPT, ...args])
15
+ /** `--account` is required (it binds the person to the sub-account whose folders
16
+ * they may read), but it is irrelevant to the passcode behaviour most of these
17
+ * tests cover, so the helper supplies one unless the caller names its own. Tests
18
+ * that assert the requirement itself call `run` directly. */
19
+ const enrol = (args: string[]) =>
20
+ run('node', [SCRIPT, ...(args.includes('--account') ? args : [...args, '--account', 'acc-1'])])
16
21
 
17
22
  /** The script's contract: stdout is SQL and nothing else, so it pipes straight
18
23
  * into `wrangler d1 execute --file=-`; the passcode goes to stderr, where the
@@ -26,7 +31,9 @@ function parse(r: { stdout: string; stderr: string }) {
26
31
  }
27
32
 
28
33
  const valuesOf = (sql: string) =>
29
- /VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)'\)/.exec(sql)!
34
+ /VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)', '([^']*)'\)/.exec(
35
+ sql,
36
+ )!
30
37
 
31
38
  /** Apply the emitted SQL to a real SQLite database built from the template's own
32
39
  * schema.sql, the way `wrangler d1 execute` applies it to D1. A fake would model
@@ -120,13 +127,26 @@ describe('portal-enrol', () => {
120
127
  it('writes the row the schema declares, in order', async () => {
121
128
  const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
122
129
  const { sql } = parse(r)
123
- expect(sql).toContain('INSERT INTO people (ownerId, name, salt, hash, createdAt)')
124
- const [, owner, name, salt, hash, createdAt] = valuesOf(sql!)
130
+ expect(sql).toContain('INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId)')
131
+ const [, owner, name, salt, hash, createdAt, accountId] = valuesOf(sql!)
125
132
  expect(owner).toBe('alice')
126
133
  expect(name).toBe('Alice')
127
134
  expect(salt).toMatch(/^[0-9a-f]{32}$/)
128
135
  expect(hash).toMatch(/^[0-9a-f]{64}$/)
129
136
  expect(Number.isNaN(Date.parse(createdAt))).toBe(false)
137
+ expect(accountId).toBe('acc-1')
138
+ })
139
+
140
+ it('refuses enrolment with no --account', async () => {
141
+ // A person with no account bound resolves to an empty folder list, which
142
+ // looks exactly like a sub-account that has no deliverables. Refuse here
143
+ // rather than mint a row that fails silently at read time.
144
+ await expect(run('node', [SCRIPT, '--owner', 'alice', '--name', 'Alice'])).rejects.toThrow()
145
+ })
146
+
147
+ it('refuses an accountId that is not an account id', async () => {
148
+ await expect(enrol(['--owner', 'alice', '--name', 'Alice', '--account', '../etc'])).rejects.toThrow()
149
+ await expect(enrol(['--owner', 'alice', '--name', 'Alice', '--account', "a'b"])).rejects.toThrow()
130
150
  })
131
151
 
132
152
  it('puts SQL and only SQL on stdout, so the documented pipe works', async () => {
@@ -195,6 +215,41 @@ describe('portal-enrol', () => {
195
215
  expect(await db.query("SELECT createdAt FROM people WHERE ownerId='alice';")).toBe(createdAt)
196
216
  })
197
217
 
218
+ it('binds the person to the account, and rotation keeps the binding current', async () => {
219
+ const db = await d1('enrol-account-')
220
+ await db.exec(parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-a'])).sql!)
221
+ expect(await db.query("SELECT accountId FROM people WHERE ownerId='alice';")).toBe('acc-a')
222
+
223
+ // Re-enrolling to a different account moves the binding rather than
224
+ // leaving the person pointed at folders they no longer belong to.
225
+ await db.exec(parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-b'])).sql!)
226
+ expect(await db.query("SELECT accountId FROM people WHERE ownerId='alice';")).toBe('acc-b')
227
+ expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('1')
228
+ })
229
+
230
+ it('declares a directory table keyed uniquely on account and path', async () => {
231
+ const db = await d1('enrol-directory-')
232
+ await db.exec(
233
+ "INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt) VALUES ('a','output/x.pdf',1,'t','t');",
234
+ )
235
+ // The full-replace push relies on this: a second insert of the same path
236
+ // must not silently double the row.
237
+ await expect(
238
+ db.exec(
239
+ "INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt) VALUES ('a','output/x.pdf',1,'t','t');",
240
+ ),
241
+ ).rejects.toThrow()
242
+ expect(await db.query('SELECT COUNT(*) FROM directory;')).toBe('1')
243
+ })
244
+
245
+ it('scopes directory rows by account', async () => {
246
+ const db = await d1('enrol-directory-scope-')
247
+ await db.exec(
248
+ "INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt) VALUES ('a','output/x.pdf',1,'t','t'),('b','output/y.pdf',1,'t','t');",
249
+ )
250
+ expect(await db.query("SELECT relPath FROM directory WHERE accountId='a';")).toBe('output/x.pdf')
251
+ })
252
+
198
253
  it('stores a crafted name as data rather than executing it', async () => {
199
254
  const db = await d1('enrol-inject-')
200
255
  const r = await enrol(['--owner', 'alice', '--name', "x'); DROP TABLE people; --"])
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, unlinkSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { walkExposed, pushAccount } from '../../bin/portal-index-push.mjs'
6
+
7
+ let dir: string
8
+
9
+ function file(rel: string, body = 'x') {
10
+ const abs = join(dir, rel)
11
+ mkdirSync(join(abs, '..'), { recursive: true })
12
+ writeFileSync(abs, body)
13
+ }
14
+
15
+ const SCHEMA = [
16
+ '```allowed-top-level',
17
+ 'output',
18
+ 'quotes',
19
+ 'documents',
20
+ 'uploads',
21
+ '```',
22
+ '',
23
+ '<!-- ontology-buckets:start -->',
24
+ '- `quotes/` - one folder per Quotation record.',
25
+ '<!-- ontology-buckets:end -->',
26
+ ].join('\n')
27
+
28
+ beforeEach(() => {
29
+ dir = mkdtempSync(join(tmpdir(), 'portal-push-'))
30
+ writeFileSync(join(dir, 'SCHEMA.md'), SCHEMA)
31
+ file('output/report.pdf')
32
+ file('quotes/CR2969/quote.pdf')
33
+ file('quotes/CR2974/quote.pdf')
34
+ file('documents/private.pdf')
35
+ file('uploads/client-sent.pdf')
36
+ file('.claude/settings.json')
37
+ file('output/.uploads-tmp/partial.bin')
38
+ })
39
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
40
+
41
+ function fakeClient() {
42
+ const calls: { sql: string; params: unknown[] }[] = []
43
+ const rows = new Set<string>()
44
+ return {
45
+ calls,
46
+ async query(sql: string, params: unknown[] = []) {
47
+ calls.push({ sql, params })
48
+ if (/DELETE FROM directory/i.test(sql)) {
49
+ rows.clear()
50
+ return []
51
+ }
52
+ if (/INSERT INTO directory/i.test(sql)) {
53
+ rows.add(String(params[1]))
54
+ return []
55
+ }
56
+ if (/SELECT COUNT/i.test(sql)) return [{ n: rows.size }]
57
+ return []
58
+ },
59
+ }
60
+ }
61
+
62
+ const push = (client: unknown, overrides: Record<string, unknown> = {}) =>
63
+ pushAccount({
64
+ accountDir: dir,
65
+ accountId: 'acc-1',
66
+ dbName: 'db',
67
+ client: client as never,
68
+ log: () => {},
69
+ nowIso: '2026-07-20T00:00:00.000Z',
70
+ ...overrides,
71
+ })
72
+
73
+ describe('walkExposed', () => {
74
+ it('returns files only from exposed dirs, at any depth', async () => {
75
+ const paths = (await walkExposed(dir, ['output', 'quotes'])).map((r) => r.relPath).sort()
76
+ expect(paths).toEqual(['output/report.pdf', 'quotes/CR2969/quote.pdf', 'quotes/CR2974/quote.pdf'])
77
+ })
78
+
79
+ it('never returns operator data, uploads, dot dirs or upload temps', async () => {
80
+ const paths = (await walkExposed(dir, ['output', 'quotes'])).map((r) => r.relPath)
81
+ expect(paths.some((p) => p.startsWith('documents/'))).toBe(false)
82
+ expect(paths.some((p) => p.startsWith('uploads/'))).toBe(false)
83
+ expect(paths.some((p) => p.includes('.uploads-tmp'))).toBe(false)
84
+ expect(paths.some((p) => p.includes('.claude'))).toBe(false)
85
+ })
86
+
87
+ it('returns nothing for a dir that does not exist', async () => {
88
+ expect(await walkExposed(dir, ['nope'])).toEqual([])
89
+ })
90
+
91
+ it('carries size and an iso modified time', async () => {
92
+ const row = (await walkExposed(dir, ['output']))[0]
93
+ expect(row.sizeBytes).toBe(1)
94
+ expect(row.modifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
95
+ })
96
+ })
97
+
98
+ describe('pushAccount', () => {
99
+ it('deletes the account rows then inserts the current walk', async () => {
100
+ const c = fakeClient()
101
+ const r = await push(c)
102
+ expect(r.exposed).toEqual(['output', 'quotes'])
103
+ expect(r.rows).toBe(3)
104
+ expect(/DELETE FROM directory/i.test(c.calls[0].sql)).toBe(true)
105
+ expect(c.calls[0].params).toEqual(['acc-1'])
106
+ expect(c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).length).toBe(3)
107
+ })
108
+
109
+ it('converges on a delete: a removed file leaves no stale row', async () => {
110
+ const c = fakeClient()
111
+ await push(c)
112
+ unlinkSync(join(dir, 'quotes/CR2974/quote.pdf'))
113
+ const second = await push(c)
114
+ expect(second.rows).toBe(2)
115
+ })
116
+
117
+ it('binds every value as a param rather than interpolating it', async () => {
118
+ const c = fakeClient()
119
+ await push(c)
120
+ const insert = c.calls.find((x) => /INSERT INTO directory/i.test(x.sql))!
121
+ expect(insert.sql).not.toContain('output/')
122
+ expect(insert.params[0]).toBe('acc-1')
123
+ })
124
+
125
+ // Fail closed, but do NOT delete: an empty publish and a missing schema must
126
+ // not look the same in the table.
127
+ it('exposes nothing and writes nothing when the schema is missing', async () => {
128
+ rmSync(join(dir, 'SCHEMA.md'))
129
+ const c = fakeClient()
130
+ const r = await push(c)
131
+ expect(r.schemaPresent).toBe(false)
132
+ expect(r.exposed).toEqual([])
133
+ expect(c.calls.length).toBe(0)
134
+ })
135
+
136
+ it('logs resolve, replace and verify lines', async () => {
137
+ const lines: string[] = []
138
+ const c = fakeClient()
139
+ await push(c, { log: (l: string) => lines.push(l) })
140
+ expect(lines.some((l) => l.includes('op=resolve') && l.includes('exposed=output,quotes'))).toBe(true)
141
+ expect(lines.some((l) => l.includes('op=replace') && l.includes('inserted=3'))).toBe(true)
142
+ expect(lines.some((l) => l.includes('op=verify') && l.includes('rowsAfter=3') && l.includes('expected=3'))).toBe(true)
143
+ })
144
+
145
+ it('reports an exposed-set change only when it actually changes', async () => {
146
+ const lines: string[] = []
147
+ // Same set as the fixture resolves to: silent.
148
+ await push(fakeClient(), { log: (l: string) => lines.push(l), previousExposed: ['output', 'quotes'] })
149
+ expect(lines.some((l) => l.includes('op=exposed-changed'))).toBe(false)
150
+
151
+ // A bucket the client used to see is gone — the client-visible regression.
152
+ lines.length = 0
153
+ await push(fakeClient(), { log: (l: string) => lines.push(l), previousExposed: ['output', 'quotes', 'invoices'] })
154
+ const line = lines.find((l) => l.includes('op=exposed-changed'))!
155
+ expect(line).toContain('removed=invoices')
156
+ expect(line).toContain('added=none')
157
+
158
+ // A newly projected bucket.
159
+ lines.length = 0
160
+ await push(fakeClient(), { log: (l: string) => lines.push(l), previousExposed: ['output'] })
161
+ expect(lines.find((l) => l.includes('op=exposed-changed'))).toContain('added=quotes')
162
+ })
163
+
164
+ it('stays silent about changes on a first run, when there is no previous set', async () => {
165
+ const lines: string[] = []
166
+ await push(fakeClient(), { log: (l: string) => lines.push(l) })
167
+ expect(lines.some((l) => l.includes('op=exposed-changed'))).toBe(false)
168
+ })
169
+
170
+ it('logs schemaPresent=false so a fail-closed account is distinguishable', async () => {
171
+ rmSync(join(dir, 'SCHEMA.md'))
172
+ const lines: string[] = []
173
+ await push(fakeClient(), { log: (l: string) => lines.push(l) })
174
+ expect(lines.some((l) => l.includes('schemaPresent=false') && l.includes('exposed=none'))).toBe(true)
175
+ })
176
+ })
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { stateFilePath, readState, recordSuccess } from '../../bin/portal-index-push.mjs'
6
+ // Test-only cross-tree import. The two modules are in different plugins and
7
+ // never import each other in production; this file is the contract between
8
+ // them, and it exists because the last thing that went wrong on this feature
9
+ // was two halves that each passed their own tests and did not connect.
10
+ import {
11
+ auditPortalIndex,
12
+ formatPortalIndexAudit,
13
+ } from '../../../scheduling/mcp/src/lib/portal-index-audit.js'
14
+
15
+ let root: string
16
+ let platformRoot: string
17
+
18
+ beforeEach(() => {
19
+ root = mkdtempSync(join(tmpdir(), 'portal-state-'))
20
+ platformRoot = join(root, 'platform')
21
+ mkdirSync(platformRoot, { recursive: true })
22
+ mkdirSync(join(root, 'data'), { recursive: true })
23
+ })
24
+ afterEach(() => rmSync(root, { recursive: true, force: true }))
25
+
26
+ describe('the push writes exactly what the audit reads', () => {
27
+ it('puts the state where the heartbeat looks for it', () => {
28
+ // The audit resolves <platformRoot>/../data/portal-index-state.json
29
+ // independently. If these two ever disagree the audit reports every healthy
30
+ // account as never-ran, which is a false alarm pointing at the wrong thing.
31
+ expect(stateFilePath(platformRoot)).toBe(join(root, 'data', 'portal-index-state.json'))
32
+ })
33
+
34
+ it('round-trips an entry the audit reads as healthy', () => {
35
+ recordSuccess(platformRoot, 'acc-a', {
36
+ lastPushMs: Date.now(),
37
+ rows: 41,
38
+ exposed: ['output', 'quotes'],
39
+ })
40
+
41
+ const onDisk = JSON.parse(readFileSync(stateFilePath(platformRoot), 'utf8'))
42
+ const result = auditPortalIndex(['acc-a'], onDisk, Date.now(), 3_600_000)
43
+
44
+ expect(result.neverRan).toBe(0)
45
+ expect(result.stale).toBe(0)
46
+ expect(result.accounts[0].rows).toBe(41)
47
+ expect(result.accounts[0].exposed).toEqual(['output', 'quotes'])
48
+ expect(formatPortalIndexAudit(result)).toEqual([
49
+ '[portal-index] op=audit accounts=1 stale=0 never-ran=0',
50
+ ])
51
+ })
52
+
53
+ it('an account the push never recorded reads as never-ran', () => {
54
+ recordSuccess(platformRoot, 'acc-a', { lastPushMs: Date.now(), rows: 1, exposed: ['output'] })
55
+ const onDisk = JSON.parse(readFileSync(stateFilePath(platformRoot), 'utf8'))
56
+ const result = auditPortalIndex(['acc-a', 'acc-b'], onDisk, Date.now(), 3_600_000)
57
+ expect(result.neverRan).toBe(1)
58
+ expect(formatPortalIndexAudit(result)[0]).toContain('account=acc-b')
59
+ expect(formatPortalIndexAudit(result)[0]).toContain('result=never-ran')
60
+ })
61
+
62
+ it('recording one account does not erase another', () => {
63
+ recordSuccess(platformRoot, 'acc-a', { lastPushMs: 1, rows: 1, exposed: ['output'] })
64
+ recordSuccess(platformRoot, 'acc-b', { lastPushMs: 2, rows: 2, exposed: ['quotes'] })
65
+ const state = readState(platformRoot)
66
+ expect(Object.keys(state).sort()).toEqual(['acc-a', 'acc-b'])
67
+ })
68
+
69
+ it('an absent state file reads as an empty object, not a throw', () => {
70
+ expect(readState(platformRoot)).toEqual({})
71
+ // …and an absent file with portals enrolled is the never-ran signal, which
72
+ // is the state a device sits in before the very first push.
73
+ expect(auditPortalIndex(['acc-a'], readState(platformRoot), Date.now(), 3_600_000).neverRan).toBe(1)
74
+ })
75
+ })
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { processIndexedDownload } from '../../skills/data-portal/template/functions/api/download'
3
+ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
4
+
5
+ const PATH = 'quotes/CR2969/quote.pdf'
6
+
7
+ function makeEnv(
8
+ opts: { accountId?: string; secret?: string; origin?: string; paths?: string[] } = {},
9
+ ) {
10
+ const { accountId = 'acc-a', paths = [PATH] } = opts
11
+ return {
12
+ DB: {
13
+ prepare(query: string) {
14
+ let bound: unknown[] = []
15
+ const stmt = {
16
+ bind(...v: unknown[]) {
17
+ bound = v
18
+ return stmt
19
+ },
20
+ async run() {
21
+ return { meta: { changes: 0 } }
22
+ },
23
+ async all<T>() {
24
+ return { results: [] as T[] }
25
+ },
26
+ async first<T>() {
27
+ if (/FROM sessions/i.test(query)) {
28
+ return (bound[0] === 'sess-a' ? { ownerId: 'alice', accountId } : null) as T | null
29
+ }
30
+ if (/FROM directory/i.test(query)) {
31
+ return (paths.includes(bound[1] as string) ? { relPath: bound[1] } : null) as T | null
32
+ }
33
+ return null as T | null
34
+ },
35
+ }
36
+ return stmt
37
+ },
38
+ },
39
+ PORTAL_FETCH_SECRET: 'secret' in opts ? opts.secret : 'a-secret',
40
+ PORTAL_INSTALL_ORIGIN: 'origin' in opts ? opts.origin : 'https://install.example',
41
+ } as unknown as PortalEnv
42
+ }
43
+
44
+ const okHead = async () => ({ ok: true, status: 200 })
45
+ const deadHead = async () => {
46
+ throw new Error('ECONNREFUSED')
47
+ }
48
+ const errHead = async () => ({ ok: false, status: 502 })
49
+
50
+ describe('processIndexedDownload', () => {
51
+ it('returns a signed url for an indexed file when the device answers', async () => {
52
+ const r = await processIndexedDownload('sess-a', PATH, makeEnv(), () => {}, 1000, okHead as never)
53
+ expect(r.status).toBe(200)
54
+ const url = new URL(r.url as string)
55
+ expect(url.origin).toBe('https://install.example')
56
+ expect(url.pathname).toBe('/api/portal/fetch')
57
+ expect(url.searchParams.get('path')).toBe(PATH)
58
+ expect(url.searchParams.get('accountId')).toBe('acc-a')
59
+ expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
60
+ expect(Number(url.searchParams.get('exp'))).toBeGreaterThan(1000)
61
+ })
62
+
63
+ it('reports device-offline distinguishably when the probe throws', async () => {
64
+ const r = await processIndexedDownload('sess-a', PATH, makeEnv(), () => {}, 1000, deadHead as never)
65
+ expect(r.status).toBe(503)
66
+ expect(r.payload.error).toBe('device-offline')
67
+ expect(r.url).toBeUndefined()
68
+ })
69
+
70
+ it('reports device-offline when the probe answers non-ok', async () => {
71
+ const r = await processIndexedDownload('sess-a', PATH, makeEnv(), () => {}, 1000, errHead as never)
72
+ expect(r.status).toBe(503)
73
+ expect(r.payload.error).toBe('device-offline')
74
+ })
75
+
76
+ it('404s a path that is not in the index, without contacting the device', async () => {
77
+ let touched = false
78
+ const spy = async () => {
79
+ touched = true
80
+ return { ok: true, status: 200 }
81
+ }
82
+ const r = await processIndexedDownload(
83
+ 'sess-a',
84
+ 'documents/private.pdf',
85
+ makeEnv(),
86
+ () => {},
87
+ 1000,
88
+ spy as never,
89
+ )
90
+ expect(r.status).toBe(404)
91
+ expect(touched).toBe(false)
92
+ })
93
+
94
+ it('401s without a session', async () => {
95
+ const r = await processIndexedDownload('nope', PATH, makeEnv(), () => {}, 1000, okHead as never)
96
+ expect(r.status).toBe(401)
97
+ })
98
+
99
+ it('404s when the person has no account bound', async () => {
100
+ const r = await processIndexedDownload(
101
+ 'sess-a',
102
+ PATH,
103
+ makeEnv({ accountId: '' }),
104
+ () => {},
105
+ 1000,
106
+ okHead as never,
107
+ )
108
+ expect(r.status).toBe(404)
109
+ })
110
+
111
+ it('never mints a link when the secret or origin is unconfigured', async () => {
112
+ for (const env of [makeEnv({ secret: undefined }), makeEnv({ origin: undefined })]) {
113
+ const r = await processIndexedDownload('sess-a', PATH, env, () => {}, 1000, okHead as never)
114
+ expect(r.status).toBe(503)
115
+ expect(r.url).toBeUndefined()
116
+ }
117
+ })
118
+
119
+ it('never puts the secret in the url or the log', async () => {
120
+ const lines: string[] = []
121
+ const r = await processIndexedDownload('sess-a', PATH, makeEnv(), (l) => lines.push(l), 1000, okHead as never)
122
+ expect(r.url).not.toContain('a-secret')
123
+ expect(lines.join('\n')).not.toContain('a-secret')
124
+ })
125
+
126
+ it('logs the outcome with the path', async () => {
127
+ const lines: string[] = []
128
+ await processIndexedDownload('sess-a', PATH, makeEnv(), (l) => lines.push(l), 1000, okHead as never)
129
+ expect(lines.some((l) => l.includes('op=fetch-through') && l.includes('result=ok'))).toBe(true)
130
+ })
131
+ })
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ SIGNED_TTL_MS,
4
+ signPath,
5
+ verifyPath,
6
+ buildSignedUrl,
7
+ } from '../../skills/data-portal/template/functions/api/_lib/portal-sign.mjs'
8
+
9
+ const SECRET = 'test-secret-value'
10
+ const ACC = '098a18a3-1741-447f-9778-65470645d57d'
11
+ const NOW = 1_800_000_000_000
12
+
13
+ describe('portal-sign', () => {
14
+ it('round-trips a signature', async () => {
15
+ const exp = NOW + SIGNED_TTL_MS
16
+ const sig = await signPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp)
17
+ expect(await verifyPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp, sig, NOW)).toBe(true)
18
+ })
19
+
20
+ it('refuses an expired signature', async () => {
21
+ const exp = NOW - 1
22
+ const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
23
+ expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp, sig, NOW)).toBe(false)
24
+ })
25
+
26
+ it('refuses a tampered path', async () => {
27
+ const exp = NOW + SIGNED_TTL_MS
28
+ const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
29
+ expect(await verifyPath(SECRET, ACC, 'documents/private.pdf', exp, sig, NOW)).toBe(false)
30
+ })
31
+
32
+ it('refuses a tampered account', async () => {
33
+ const exp = NOW + SIGNED_TTL_MS
34
+ const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
35
+ expect(await verifyPath(SECRET, 'other-account', 'output/a.pdf', exp, sig, NOW)).toBe(false)
36
+ })
37
+
38
+ it('refuses a tampered expiry', async () => {
39
+ const exp = NOW + SIGNED_TTL_MS
40
+ const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
41
+ expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp + 60_000, sig, NOW)).toBe(false)
42
+ })
43
+
44
+ it('refuses a signature made with a different secret', async () => {
45
+ const exp = NOW + SIGNED_TTL_MS
46
+ const sig = await signPath('other-secret', ACC, 'output/a.pdf', exp)
47
+ expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp, sig, NOW)).toBe(false)
48
+ })
49
+
50
+ // The field separator must be load-bearing: without it, an accountId that
51
+ // absorbs the start of a path could forge a different (account, path) pair.
52
+ it('does not confuse account and path across the separator', async () => {
53
+ const exp = NOW + SIGNED_TTL_MS
54
+ const sig = await signPath(SECRET, 'acc', 'a/b.pdf', exp)
55
+ expect(await verifyPath(SECRET, 'acc\na', '/b.pdf', exp, sig, NOW)).toBe(false)
56
+ })
57
+
58
+ it('builds a url carrying account, path, expiry and signature', async () => {
59
+ const url = new URL(
60
+ await buildSignedUrl('https://install.example', SECRET, ACC, 'output/a b.pdf', NOW + 1000),
61
+ )
62
+ expect(url.pathname).toBe('/api/portal/fetch')
63
+ expect(url.searchParams.get('accountId')).toBe(ACC)
64
+ expect(url.searchParams.get('path')).toBe('output/a b.pdf')
65
+ expect(url.searchParams.get('exp')).toBe(String(NOW + 1000))
66
+ expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
67
+ })
68
+ })