@rubytech/create-maxy-code 0.1.485 → 0.1.487

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 (105) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-21-graph-caption-resolver.md +124 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1877-bash-schema-enforcement.md +107 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1887-reconcile-allowed-top-level.md +205 -0
  5. package/payload/platform/lib/graph-style/dist/index.d.ts +4 -1
  6. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  7. package/payload/platform/lib/graph-style/dist/index.js +109 -1
  8. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  9. package/payload/platform/lib/graph-style/src/__tests__/caption-coverage.test.ts +117 -0
  10. package/payload/platform/lib/graph-style/src/index.ts +81 -1
  11. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh +159 -0
  12. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +18 -6
  13. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh +141 -0
  14. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-pre.sh +66 -0
  15. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +33 -1
  16. package/payload/platform/plugins/admin/skills/professional-document/SKILL.md +3 -3
  17. package/payload/platform/plugins/cloudflare/PLUGIN.md +5 -3
  18. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +23 -2
  19. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +30 -8
  20. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +22 -2
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +49 -2
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +57 -3
  24. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +23 -0
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +44 -2
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +67 -0
  27. package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +43 -0
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +54 -5
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +10 -1
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +19 -5
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +19 -2
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +8 -2
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +28 -5
  34. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +195 -6
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +193 -10
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +4 -3
  37. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  38. package/payload/platform/plugins/email/PLUGIN.md +5 -1
  39. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts +2 -0
  40. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts.map +1 -0
  41. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js +50 -0
  42. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js.map +1 -0
  43. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts +2 -0
  44. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js +51 -0
  46. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js.map +1 -0
  47. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts +2 -0
  48. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts.map +1 -0
  49. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js +89 -0
  50. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js.map +1 -0
  51. package/payload/platform/plugins/email/mcp/dist/index.js +28 -0
  52. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  53. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts +22 -0
  54. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts.map +1 -0
  55. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js +55 -0
  56. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js.map +1 -0
  57. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +32 -0
  58. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  59. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +75 -1
  60. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  61. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts +19 -0
  62. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts.map +1 -0
  63. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js +49 -0
  64. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js.map +1 -0
  65. package/payload/platform/plugins/email/references/email-reference.md +7 -1
  66. package/payload/platform/plugins/memory/references/schema-construction.md +18 -12
  67. package/payload/platform/plugins/memory/references/schema-estate-agent.md +11 -6
  68. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.d.ts +2 -0
  69. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.d.ts.map +1 -0
  70. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.js +12 -0
  71. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.js.map +1 -0
  72. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.d.ts +8 -0
  73. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.d.ts.map +1 -0
  74. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.js +12 -0
  75. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.js.map +1 -0
  76. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js +12 -2
  77. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js.map +1 -1
  78. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +75 -6
  79. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +111 -22
  80. package/payload/platform/scripts/lib/provision-account-dir.sh +8 -1
  81. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +39 -0
  82. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -0
  83. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +315 -0
  84. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -0
  85. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  86. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  87. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  88. package/payload/platform/services/claude-session-manager/dist/index.js +15 -0
  89. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  90. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  91. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +4 -0
  92. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  93. package/payload/platform/templates/account-schema/SCHEMA.md +17 -0
  94. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  95. package/payload/server/{chunk-XGNRSM57.js → chunk-HN4HFTXE.js} +6 -0
  96. package/payload/server/maxy-edge.js +1 -1
  97. package/payload/server/public/assets/{admin-rScqRq-R.js → admin-BoVCP4Mw.js} +1 -1
  98. package/payload/server/public/assets/{data-TOWuhMFb.js → data-CtOxa94P.js} +1 -1
  99. package/payload/server/public/assets/{graph-labels-Axa04w8A.js → graph-labels-C8-sIKuv.js} +1 -1
  100. package/payload/server/public/assets/{graph-BEGs_P2m.js → graph-mJ9ky5dc.js} +1 -1
  101. package/payload/server/public/assets/{page-6CZ8R57f.js → page-Cv0qUwMG.js} +1 -1
  102. package/payload/server/public/data.html +3 -3
  103. package/payload/server/public/graph.html +2 -2
  104. package/payload/server/public/index.html +3 -3
  105. package/payload/server/server.js +231 -103
@@ -31,7 +31,7 @@ function parse(r: { stdout: string; stderr: string }) {
31
31
  }
32
32
 
33
33
  const valuesOf = (sql: string) =>
34
- /VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)', '([^']*)'\)/.exec(
34
+ /VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)', '([^']*)', '([^']*)'\)/.exec(
35
35
  sql,
36
36
  )!
37
37
 
@@ -127,14 +127,17 @@ describe('portal-enrol', () => {
127
127
  it('writes the row the schema declares, in order', async () => {
128
128
  const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
129
129
  const { sql } = parse(r)
130
- expect(sql).toContain('INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId)')
131
- const [, owner, name, salt, hash, createdAt, accountId] = valuesOf(sql!)
130
+ expect(sql).toContain(
131
+ 'INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId, folders)',
132
+ )
133
+ const [, owner, name, salt, hash, createdAt, accountId, folders] = valuesOf(sql!)
132
134
  expect(owner).toBe('alice')
133
135
  expect(name).toBe('Alice')
134
136
  expect(salt).toMatch(/^[0-9a-f]{32}$/)
135
137
  expect(hash).toMatch(/^[0-9a-f]{64}$/)
136
138
  expect(Number.isNaN(Date.parse(createdAt))).toBe(false)
137
139
  expect(accountId).toBe('acc-1')
140
+ expect(folders).toBe('')
138
141
  })
139
142
 
140
143
  it('refuses enrolment with no --account', async () => {
@@ -250,6 +253,57 @@ describe('portal-enrol', () => {
250
253
  expect(await db.query("SELECT relPath FROM directory WHERE accountId='a';")).toBe('output/x.pdf')
251
254
  })
252
255
 
256
+ it('lands a folders grant', async () => {
257
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'quotes,output'])
258
+ const [, , , , , , , folders] = valuesOf(parse(r).sql!)
259
+ expect(folders).toBe('quotes,output')
260
+ })
261
+
262
+ it('defaults folders to empty when the flag is absent', async () => {
263
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
264
+ const [, , , , , , , folders] = valuesOf(parse(r).sql!)
265
+ expect(folders).toBe('')
266
+ })
267
+
268
+ it('dedupes repeated folder names', async () => {
269
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'quotes,quotes,output'])
270
+ const [, , , , , , , folders] = valuesOf(parse(r).sql!)
271
+ expect(folders).toBe('quotes,output')
272
+ })
273
+
274
+ it('rejects a folder name outside the segment charset', async () => {
275
+ await expect(
276
+ enrol(['--owner', 'alice', '--name', 'Alice', '--folders', '../etc']),
277
+ ).rejects.toThrow()
278
+ await expect(
279
+ enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'a/b']),
280
+ ).rejects.toThrow()
281
+ await expect(
282
+ enrol(['--owner', 'alice', '--name', 'Alice', '--folders', "a'b"]),
283
+ ).rejects.toThrow()
284
+ })
285
+
286
+ it('re-enrolment replaces the grant', async () => {
287
+ const db = await d1('enrol-folders-replace-')
288
+ await db.exec(
289
+ parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'quotes'])).sql!,
290
+ )
291
+ expect(await db.query("SELECT folders FROM people WHERE ownerId='alice';")).toBe('quotes')
292
+ await db.exec(
293
+ parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'output'])).sql!,
294
+ )
295
+ expect(await db.query("SELECT folders FROM people WHERE ownerId='alice';")).toBe('output')
296
+ expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('1')
297
+ })
298
+
299
+ it('declares people.folders defaulting to empty', async () => {
300
+ const db = await d1('enrol-folders-col-')
301
+ await db.exec(
302
+ "INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId) VALUES ('alice','Alice','aa','bb','t','acc-a');",
303
+ )
304
+ expect(await db.query("SELECT folders FROM people WHERE ownerId='alice';")).toBe('')
305
+ })
306
+
253
307
  it('stores a crafted name as data rather than executing it', async () => {
254
308
  const db = await d1('enrol-inject-')
255
309
  const r = await enrol(['--owner', 'alice', '--name', "x'); DROP TABLE people; --"])
@@ -173,4 +173,27 @@ describe('pushAccount', () => {
173
173
  await push(fakeClient(), { log: (l: string) => lines.push(l) })
174
174
  expect(lines.some((l) => l.includes('schemaPresent=false') && l.includes('exposed=none'))).toBe(true)
175
175
  })
176
+
177
+ it('op=resolve carries extraFolders and the walk includes an operator folder', async () => {
178
+ // inbound-invoices is NOT in the fixture allowed block, so this also proves
179
+ // an extra bypasses the ontology/allowed intersection.
180
+ file('inbound-invoices/INV-42.pdf')
181
+ const lines: string[] = []
182
+ const c = fakeClient()
183
+ await push(c, { log: (l: string) => lines.push(l), exposeFolders: ['inbound-invoices'] })
184
+ expect(lines.some((l) => /op=resolve .*extraFolders=1/.test(l))).toBe(true)
185
+ expect(lines.some((l) => /exposed=.*inbound-invoices/.test(l))).toBe(true)
186
+ const inserted = c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).map((x) => String(x.params[1]))
187
+ expect(inserted).toContain('inbound-invoices/INV-42.pdf')
188
+ })
189
+
190
+ it('with no exposeFolders the operator folder is not walked and extraFolders=0', async () => {
191
+ file('inbound-invoices/INV-42.pdf')
192
+ const lines: string[] = []
193
+ const c = fakeClient()
194
+ await push(c, { log: (l: string) => lines.push(l) })
195
+ const inserted = c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).map((x) => String(x.params[1]))
196
+ expect(inserted).not.toContain('inbound-invoices/INV-42.pdf')
197
+ expect(lines.some((l) => /op=resolve .*extraFolders=0/.test(l))).toBe(true)
198
+ })
176
199
  })
@@ -5,9 +5,16 @@ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/
5
5
  const PATH = 'quotes/CR2969/quote.pdf'
6
6
 
7
7
  function makeEnv(
8
- opts: { accountId?: string; secret?: string; origin?: string; paths?: string[] } = {},
8
+ opts: {
9
+ accountId?: string
10
+ secret?: string
11
+ origin?: string
12
+ paths?: string[]
13
+ folders?: string
14
+ } = {},
9
15
  ) {
10
16
  const { accountId = 'acc-a', paths = [PATH] } = opts
17
+ const folders = 'folders' in opts ? opts.folders : ''
11
18
  return {
12
19
  DB: {
13
20
  prepare(query: string) {
@@ -25,7 +32,9 @@ function makeEnv(
25
32
  },
26
33
  async first<T>() {
27
34
  if (/FROM sessions/i.test(query)) {
28
- return (bound[0] === 'sess-a' ? { ownerId: 'alice', accountId } : null) as T | null
35
+ return (bound[0] === 'sess-a'
36
+ ? { ownerId: 'alice', accountId, folders }
37
+ : null) as T | null
29
38
  }
30
39
  if (/FROM directory/i.test(query)) {
31
40
  return (paths.includes(bound[1] as string) ? { relPath: bound[1] } : null) as T | null
@@ -128,4 +137,37 @@ describe('processIndexedDownload', () => {
128
137
  await processIndexedDownload('sess-a', PATH, makeEnv(), (l) => lines.push(l), 1000, okHead as never)
129
138
  expect(lines.some((l) => l.includes('op=fetch-through') && l.includes('result=ok'))).toBe(true)
130
139
  })
140
+
141
+ it('refuses a path outside the grant with 404 and no device probe', async () => {
142
+ let touched = false
143
+ const spy = async () => {
144
+ touched = true
145
+ return { ok: true, status: 200 }
146
+ }
147
+ const lines: string[] = []
148
+ const env = makeEnv({ folders: 'quotes' })
149
+ const r = await processIndexedDownload(
150
+ 'sess-a',
151
+ 'documents/x.pdf',
152
+ env,
153
+ (l) => lines.push(l),
154
+ 1000,
155
+ spy as never,
156
+ )
157
+ expect(r.status).toBe(404)
158
+ expect(touched).toBe(false)
159
+ expect(lines.some((l) => l.includes('result=not-granted'))).toBe(true)
160
+ })
161
+
162
+ it('allows an in-grant path', async () => {
163
+ const env = makeEnv({ folders: 'quotes' })
164
+ const r = await processIndexedDownload('sess-a', PATH, env, () => {}, 1000, okHead as never)
165
+ expect(r.status).toBe(200)
166
+ })
167
+
168
+ it('an empty grant allows any indexed path', async () => {
169
+ const env = makeEnv({ folders: '' })
170
+ const r = await processIndexedDownload('sess-a', PATH, env, () => {}, 1000, okHead as never)
171
+ expect(r.status).toBe(200)
172
+ })
131
173
  })
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ grantAllows,
4
+ resolveSession,
5
+ } from '../../skills/data-portal/template/functions/api/_lib/session'
6
+ import type { D1Database } from '../../skills/data-portal/template/functions/api/_lib/types'
7
+
8
+ function db(row: Record<string, unknown> | null) {
9
+ return {
10
+ prepare() {
11
+ const stmt = {
12
+ bind() {
13
+ return stmt
14
+ },
15
+ async first<T>() {
16
+ return row as T | null
17
+ },
18
+ async run() {
19
+ return { meta: { changes: 0 } }
20
+ },
21
+ async all<T>() {
22
+ return { results: [] as T[] }
23
+ },
24
+ }
25
+ return stmt
26
+ },
27
+ } as unknown as D1Database
28
+ }
29
+
30
+ describe('resolveSession folders', () => {
31
+ it('splits a comma grant into an array', async () => {
32
+ const s = await resolveSession(
33
+ db({ ownerId: 'alice', accountId: 'acc-a', folders: 'quotes,output' }),
34
+ 'sess',
35
+ 1,
36
+ )
37
+ expect(s?.folders).toEqual(['quotes', 'output'])
38
+ })
39
+
40
+ it('returns an empty array for an empty grant', async () => {
41
+ const s = await resolveSession(db({ ownerId: 'alice', accountId: 'acc-a', folders: '' }), 'sess', 1)
42
+ expect(s?.folders).toEqual([])
43
+ })
44
+
45
+ it('returns an empty array when the column is absent', async () => {
46
+ const s = await resolveSession(db({ ownerId: 'alice', accountId: 'acc-a' }), 'sess', 1)
47
+ expect(s?.folders).toEqual([])
48
+ })
49
+ })
50
+
51
+ describe('grantAllows', () => {
52
+ it('admits everything when the grant is empty', () => {
53
+ expect(grantAllows([], 'documents/secret.pdf')).toBe(true)
54
+ })
55
+
56
+ it('admits a path whose top segment is granted', () => {
57
+ expect(grantAllows(['quotes'], 'quotes/CR2969/quote.pdf')).toBe(true)
58
+ })
59
+
60
+ it('refuses a path whose top segment is not granted', () => {
61
+ expect(grantAllows(['quotes'], 'documents/secret.pdf')).toBe(false)
62
+ })
63
+
64
+ it('matches the whole segment, so `quote` does not admit `quotes`', () => {
65
+ expect(grantAllows(['quote'], 'quotes/x.pdf')).toBe(false)
66
+ })
67
+ })
@@ -85,4 +85,47 @@ describe('resolveExposedDirs', () => {
85
85
  const r = resolveExposedDirs('# no fence here')
86
86
  expect(r.exposed).toEqual([])
87
87
  })
88
+
89
+ it('unions an operator-named folder into exposed', () => {
90
+ const r = resolveExposedDirs(schema({ ontology: ONTOLOGY }), ['inbound-invoices'])
91
+ expect(r.exposed).toEqual(['inbound-invoices', 'output', 'quotes'])
92
+ })
93
+
94
+ it('exposes an extra folder that is absent from the allowed block', () => {
95
+ // inbound-invoices is deliberately NOT in ALLOWED — extras bypass that intersection.
96
+ const r = resolveExposedDirs(schema({ ontology: ONTOLOGY }), ['inbound-invoices'])
97
+ expect(r.exposed).toContain('inbound-invoices')
98
+ })
99
+
100
+ it('drops a system folder named as an extra', () => {
101
+ const r = resolveExposedDirs(schema({ ontology: ONTOLOGY }), ['secrets'])
102
+ expect(r.exposed).not.toContain('secrets')
103
+ expect(r.exposed).toEqual(['output', 'quotes'])
104
+ })
105
+
106
+ it('drops a traversal or dotted extra', () => {
107
+ const r = resolveExposedDirs(schema({ ontology: ONTOLOGY }), ['jobs/../secrets', '.git', '..'])
108
+ expect(r.exposed).toEqual(['output', 'quotes'])
109
+ })
110
+
111
+ it('an empty extras list equals today', () => {
112
+ const base = resolveExposedDirs(schema({ ontology: ONTOLOGY }))
113
+ expect(resolveExposedDirs(schema({ ontology: ONTOLOGY }), []).exposed).toEqual(base.exposed)
114
+ })
115
+
116
+ it('never applies extras when the schema is missing', () => {
117
+ const r = resolveExposedDirs(null, ['inbound-invoices'])
118
+ expect(r.exposed).toEqual([])
119
+ expect(r.schemaPresent).toBe(false)
120
+ })
121
+
122
+ it('never applies extras when the allowed block is absent', () => {
123
+ const r = resolveExposedDirs('# no fence here', ['inbound-invoices'])
124
+ expect(r.exposed).toEqual([])
125
+ })
126
+
127
+ it('dedupes an extra that is already an ontology bucket', () => {
128
+ const r = resolveExposedDirs(schema({ ontology: ONTOLOGY }), ['quotes'])
129
+ expect(r.exposed).toEqual(['output', 'quotes'])
130
+ })
88
131
  })
@@ -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
- --account <accountId> | npx wrangler d1 execute <portalDbName> --remote --file=-
51
+ --account <accountId> [--folders <a,b,c>] | 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
@@ -66,6 +66,15 @@ a sub-account that genuinely has nothing to share, so the script refuses rather
66
66
  fails silently later. Anyone enrolled before this existed has no account bound and sees nothing; run
67
67
  enrolment again for them, which the upsert makes a re-run rather than a migration.
68
68
 
69
+ `--folders` is optional and narrows what one person sees to a named set of top-level folders, comma
70
+ separated (`--folders quotes,output`). Each name is a folder, never a path, and must match
71
+ `[A-Za-z0-9_-]+`. Omitting it, or passing an empty value, grants the account's full exposed set,
72
+ which is the existing behaviour, so people enrolled before this existed are unaffected. Two people on
73
+ one account can hold different grants and each sees only their own. Re-enrolment replaces the grant,
74
+ the same upsert that rotates the passcode. The grant lives only in the portal's D1, so it narrows
75
+ below the account's exposed set and never widens past it: a portal compromise returns a person to the
76
+ account set, which the install re-derives and enforces regardless.
77
+
69
78
  **A passcode is never stored in plaintext** — not in `data-portal.json`, not in a log line, nowhere
70
79
  on the device. The operator conveys it to the person out of band. Rotating a passcode is a re-run of
71
80
  the script for that person; there is no separate editor.
@@ -122,8 +131,16 @@ The portal shows the sub-account's deliverable folders as well as what the clien
122
131
  folder names and sizes live in D1, so the tree renders whether or not the device is up; the bytes do
123
132
  not, so opening a file has to reach the install. That needs two things the template cannot know.
124
133
 
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:
134
+ The shared tree has two row kinds. `renderEntries` in `portal.js` gives a `directory` entry a folder
135
+ icon in the accent colour and a file entry a document icon in the muted grey, both inline lucide SVG,
136
+ so a client tells a folder from a file at a glance.
137
+
138
+ Fill `__PORTAL_INSTALL_ORIGIN__` in `wrangler.toml` with the install's PUBLIC origin
139
+ (`https://<public-tunnel-hostname>`), never the admin or operator hostname. The admin
140
+ origin is behind the remote-auth gate, so the edge's signed HEAD probe of a device file
141
+ gets a 401 there and every download 503s. The public host admits `/api/portal/fetch` by
142
+ allowlist and the route verifies the signature itself. Then mint one secret and set it on
143
+ both ends:
127
144
 
128
145
  ```bash
129
146
  SECRET="$(node -e 'console.log([...crypto.getRandomValues(new Uint8Array(32))].map(b=>b.toString(16).padStart(2,"0")).join(""))')"
@@ -165,17 +182,49 @@ An account with no `SCHEMA.md` publishes nothing at all. That is deliberate and
165
182
  what the write-path guard does — the guard fails open so an unseeded account is not write-blocked,
166
183
  while a surface facing a client must fail closed.
167
184
 
185
+ An operator can publish extra folders that are not ontology buckets by listing them in
186
+ `exposeFolders` in the account's `data-portal.json`, for example a workflow folder like
187
+ `inbound-invoices`. Each name must be a single folder matching `[A-Za-z0-9_-]+` and is refused if it
188
+ is a system folder (`agents`, `secrets`, `specialists`, `state`, `memory`, `cache`, `logs`, `tmp`) or
189
+ dot-prefixed. These extras are added on top of the schema-derived set and, unlike ontology buckets,
190
+ are not intersected with the `allowed-top-level` block. An empty or absent list behaves exactly as
191
+ before.
192
+
168
193
  The heartbeat runs this hourly for every account carrying a `data-portal.json`. Run it once by hand
169
194
  after assembly so the client has something to see on their first sign-in, and check the output names
170
195
  the folders you expect:
171
196
 
172
197
  ```
173
- [portal-index] op=resolve account=<id> schemaPresent=true allowed=22 domainBuckets=quotes exposed=output,quotes
198
+ [portal-index] op=resolve account=<id> schemaPresent=true allowed=22 domainBuckets=quotes extraFolders=1 exposed=inbound-invoices,output,quotes
174
199
  [portal-index] op=verify account=<id> rowsAfter=41 expected=41
175
200
  ```
176
201
 
177
202
  `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.
203
+ region was absent — not that the walk found nothing. `extraFolders=<n>` counts the folders the
204
+ operator requested in `exposeFolders`; a non-zero `extraFolders` with none of those names in
205
+ `exposed=` means the guard dropped every one (a system or dot name, or a bad spelling).
206
+
207
+ ## How a client browses, downloads and uploads
208
+
209
+ Signed in, the panel has a pinned header that stays put while the file lists
210
+ scroll under it. The header carries the folder breadcrumbs, an up control that
211
+ moves to the parent folder (disabled at the root), and an upload icon. Filenames
212
+ render on one line: the stem truncates with an ellipsis and the extension stays
213
+ pinned, so `.pdf` and `.html` on two same-named orders are still told apart.
214
+
215
+ A shared (device-held) file downloads three ways, all through the same signed
216
+ `/api/download?path=` route: a left click, a right-click that opens a one-item
217
+ Download menu, and a drag out of the browser onto the desktop. The drag uses
218
+ `/api/download?path=...&redirect=1`, which 302s to the signed link so the browser
219
+ saves the file rather than a JSON body. The three download methods share the one
220
+ `op=fetch-through` log line, so a broken method shows as a missing line under the
221
+ same signal.
222
+
223
+ Upload opens from the header icon into a dismissable modal holding the file
224
+ picker and Upload button. It posts to `/api/upload` exactly as before; a kept
225
+ upload refreshes the list and closes the modal. Dropping a file onto the modal's
226
+ upload area uploads the same way. The modal dismisses on the close button, a
227
+ click on the backdrop, or Escape.
179
228
 
180
229
  ## Deploy through site-deploy
181
230
 
@@ -24,13 +24,22 @@
24
24
  -- predates it — but an existing table will NOT gain the column, and a person
25
25
  -- whose row has the empty default sees an empty folder list. Re-enrol those
26
26
  -- people; the upsert on ownerId makes that a re-run, not a migration.
27
+ --
28
+ -- `folders` is a per-person allowlist of top-level folder names, comma
29
+ -- separated, that narrows what this person sees below the account's exposed
30
+ -- set. Empty (the default) means the full set, so an existing person keeps
31
+ -- today's behaviour. Like `accountId`, IF NOT EXISTS will NOT add this column
32
+ -- to a `people` table that predates it, and enrolment's INSERT names `folders`,
33
+ -- so it fails loudly on such a table. Add it by hand in that case:
34
+ -- ALTER TABLE people ADD COLUMN folders TEXT NOT NULL DEFAULT '';
27
35
  CREATE TABLE IF NOT EXISTS people (
28
36
  ownerId TEXT PRIMARY KEY,
29
37
  name TEXT NOT NULL,
30
38
  salt TEXT NOT NULL,
31
39
  hash TEXT NOT NULL,
32
40
  createdAt TEXT NOT NULL,
33
- accountId TEXT NOT NULL DEFAULT ''
41
+ accountId TEXT NOT NULL DEFAULT '',
42
+ folders TEXT NOT NULL DEFAULT ''
34
43
  );
35
44
 
36
45
  -- `pcCheck` is a short digest of the person's passcode hash at mint time, and
@@ -54,11 +54,11 @@ export async function resolveSession(
54
54
  db: D1Database,
55
55
  sessionId: string,
56
56
  nowMs: number,
57
- ): Promise<{ ownerId: string; accountId: string } | null> {
57
+ ): Promise<{ ownerId: string; accountId: string; folders: string[] } | null> {
58
58
  if (!sessionId) return null
59
59
  const row = await db
60
60
  .prepare(
61
- `SELECT s.ownerId AS ownerId, p.accountId AS accountId
61
+ `SELECT s.ownerId AS ownerId, p.accountId AS accountId, p.folders AS folders
62
62
  FROM sessions s
63
63
  JOIN people p ON p.ownerId = s.ownerId
64
64
  WHERE s.sessionId = ?
@@ -66,12 +66,26 @@ 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; accountId: string }>()
69
+ .first<{ ownerId: string; accountId: string; folders: string }>()
70
70
  if (!row || typeof row.ownerId !== 'string') return null
71
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
72
+ // that as "no folders", never as "all folders": the empty string reaches the
73
73
  // listing, which returns nothing rather than falling back to some default.
74
- return { ownerId: row.ownerId, accountId: typeof row.accountId === 'string' ? row.accountId : '' }
74
+ const accountId = typeof row.accountId === 'string' ? row.accountId : ''
75
+ // `folders` is a comma-separated allowlist of top-level names. Absent or empty
76
+ // means no grant, which the two client surfaces read as no filter (full set).
77
+ const folders =
78
+ typeof row.folders === 'string' ? row.folders.split(',').map((s) => s.trim()).filter(Boolean) : []
79
+ return { ownerId: row.ownerId, accountId, folders }
80
+ }
81
+
82
+ /** Whether a person's folder grant admits `relPath`. An empty grant admits
83
+ * everything (the full set, today's behaviour); otherwise the path's top-level
84
+ * segment must be named in the grant. The test is on the whole segment, so
85
+ * `quote` never matches `quotes`. One definition so the listing and the
86
+ * download surface can never disagree on what is granted. */
87
+ export function grantAllows(grant: string[], relPath: string): boolean {
88
+ return grant.length === 0 || grant.includes(relPath.split('/')[0])
75
89
  }
76
90
 
77
91
  export function readSessionCookie(header: string | null): string | null {
@@ -1,5 +1,5 @@
1
1
  import type { Handler, Logger, PortalEnv, R2ObjectBody } from './_lib/types'
2
- import { readSessionCookie, resolveSession } from './_lib/session'
2
+ import { grantAllows, readSessionCookie, resolveSession } from './_lib/session'
3
3
  import { q } from './_lib/log'
4
4
  import { authorizeKey } from './_lib/authorize'
5
5
  import { buildSignedUrl, SIGNED_TTL_MS } from './_lib/portal-sign.mjs'
@@ -73,6 +73,16 @@ export async function processIndexedDownload(
73
73
  return { status: 404, payload: { ok: false, error: 'not found' } }
74
74
  }
75
75
 
76
+ if (!grantAllows(session.folders, relPath)) {
77
+ // Narrowed below the account set by the person's grant. Refused before the
78
+ // directory read and the device probe, so an out-of-grant path costs no
79
+ // work and is distinguishable in the log from a path simply not indexed.
80
+ log(
81
+ `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} result=not-granted`,
82
+ )
83
+ return { status: 404, payload: { ok: false, error: 'not found' } }
84
+ }
85
+
76
86
  const row = await env.DB.prepare('SELECT relPath FROM directory WHERE accountId = ? AND relPath = ?')
77
87
  .bind(session.accountId, relPath)
78
88
  .first<{ relPath: string }>()
@@ -135,7 +145,7 @@ export async function onRequestGet(context: PagesContext): Promise<Response> {
135
145
  // below is untouched.
136
146
  const indexedPath = params.get('path')
137
147
  if (indexedPath) {
138
- const { status, payload } = await processIndexedDownload(
148
+ const { status, payload, url } = await processIndexedDownload(
139
149
  sessionId,
140
150
  indexedPath,
141
151
  context.env,
@@ -143,6 +153,13 @@ export async function onRequestGet(context: PagesContext): Promise<Response> {
143
153
  Date.now(),
144
154
  fetch as unknown as HeadFetch,
145
155
  )
156
+ // Drag-out cannot read a JSON body: the browser follows a DownloadURL to a
157
+ // real response. When the file resolved, 302 the drag to the signed url so
158
+ // the browser downloads it; otherwise fall through to the same JSON the
159
+ // click path returns. Same op=fetch-through line for every method.
160
+ if (params.get('redirect') === '1' && status === 200 && url) {
161
+ return Response.redirect(url, 302)
162
+ }
146
163
  return Response.json(payload, { status })
147
164
  }
148
165
 
@@ -1,5 +1,5 @@
1
1
  import type { Handler, Logger, PortalEnv } from './_lib/types'
2
- import { readSessionCookie, resolveSession } from './_lib/session'
2
+ import { grantAllows, readSessionCookie, resolveSession } from './_lib/session'
3
3
  import { q } from './_lib/log'
4
4
 
5
5
  interface DirRow {
@@ -86,17 +86,23 @@ export async function processFiles(
86
86
  // means no folders: an enrolment predating that column lands here, and the
87
87
  // empty `account=` field in the log line is what says so.
88
88
  let entries: DirEntry[] = []
89
+ const grant = session.folders
89
90
  if (session.accountId) {
90
91
  const dir = await env.DB.prepare(
91
92
  'SELECT relPath, sizeBytes, modifiedAt FROM directory WHERE accountId = ?',
92
93
  )
93
94
  .bind(session.accountId)
94
95
  .all<DirRow>()
95
- entries = entriesUnder(dir.results ?? [], prefix)
96
+ // The grant narrows below the account's exposed set: keep only rows the
97
+ // grant admits. An empty grant admits everything, which is the full set and
98
+ // today's behaviour.
99
+ const rows = (dir.results ?? []).filter((row) => grantAllows(grant, row.relPath))
100
+ entries = entriesUnder(rows, prefix)
96
101
  }
97
102
 
98
103
  log(
99
104
  `[data-portal] op=list owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
105
+ `granted=${grant.length === 0 ? 'full' : grant.length} ` +
100
106
  `prefix=${q(prefix)} files=${files.length} entries=${entries.length} result=ok`,
101
107
  )
102
108
  return { status: 200, payload: { ok: true, files, entries } }
@@ -38,10 +38,19 @@
38
38
  </form>
39
39
 
40
40
  <section id="dp-panel" class="dp-panel" hidden>
41
- <form id="dp-upload" class="dp-upload">
42
- <input class="dp-file" id="dp-file" type="file" required />
43
- <button class="dp-btn" type="submit">Upload</button>
44
- </form>
41
+ <!-- Pinned nav. Stays put while the lists below scroll, so the folder
42
+ path and the upload control are always in reach. The crumbs live
43
+ here rather than inside #dp-shared so the header is one row. -->
44
+ <header class="dp-header">
45
+ <button class="dp-iconbtn" id="dp-up" type="button" aria-label="Up one level" disabled>
46
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>
47
+ </button>
48
+ <nav id="dp-crumbs" class="dp-crumbs" aria-label="Folder path"></nav>
49
+ <button class="dp-iconbtn" id="dp-upload-open" type="button" aria-label="Upload a file">
50
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
51
+ </button>
52
+ </header>
53
+
45
54
  <ul id="dp-files" class="dp-files"></ul>
46
55
 
47
56
  <!-- Files we have prepared for you, published from the device. Hidden
@@ -49,9 +58,23 @@
49
58
  shows the upload list alone rather than an empty second heading. -->
50
59
  <section id="dp-shared" class="dp-shared" hidden>
51
60
  <h2 class="dp-subtitle">Shared with you</h2>
52
- <nav id="dp-crumbs" class="dp-crumbs" aria-label="Folder path"></nav>
53
61
  <ul id="dp-entries" class="dp-files"></ul>
54
62
  </section>
63
+
64
+ <!-- Upload moved off the always-inline row into a modal opened by the
65
+ header icon, so browsing is not crowded by the picker. Hidden via
66
+ the `hidden` attribute, which [hidden]{display:none!important} keeps
67
+ authoritative over .dp-modal's display:flex. -->
68
+ <div class="dp-modal" id="dp-modal" hidden>
69
+ <div class="dp-modal-panel" role="dialog" aria-modal="true" aria-label="Upload a file">
70
+ <button class="dp-modal-close" id="dp-modal-close" type="button" aria-label="Close">&times;</button>
71
+ <h2 class="dp-subtitle">Upload a file</h2>
72
+ <form id="dp-upload" class="dp-upload">
73
+ <input class="dp-file" id="dp-file" type="file" required />
74
+ <button class="dp-btn" type="submit">Upload</button>
75
+ </form>
76
+ </div>
77
+ </div>
55
78
  </section>
56
79
  </main>
57
80
  <script src="/portal.js"></script>