@rubytech/create-maxy-code 0.1.487 → 0.1.489

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 (130) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1899-reconcile-report-only.md +748 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md +121 -0
  4. package/payload/platform/lib/active-rules/package-lock.json +12 -0
  5. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js +51 -0
  6. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js.map +1 -1
  7. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts +2 -2
  8. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts.map +1 -1
  9. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js +20 -4
  10. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js.map +1 -1
  11. package/payload/platform/lib/graph-mcp/src/__tests__/schema-cypher-parser.test.ts +55 -0
  12. package/payload/platform/lib/graph-mcp/src/schema-cypher-parser.ts +19 -4
  13. package/payload/platform/lib/graph-style/dist/index.d.ts +24 -0
  14. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  15. package/payload/platform/lib/graph-style/dist/index.js +25 -1
  16. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  17. package/payload/platform/lib/graph-style/src/__tests__/additional-base-labels.test.ts +22 -0
  18. package/payload/platform/lib/graph-style/src/index.ts +27 -0
  19. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +15 -0
  20. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
  21. package/payload/platform/lib/storage-broker/dist/audit.d.ts +5 -0
  22. package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
  23. package/payload/platform/lib/storage-broker/dist/audit.js +3 -2
  24. package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
  25. package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +21 -0
  26. package/payload/platform/lib/storage-broker/src/audit.ts +8 -2
  27. package/payload/platform/neo4j/schema.cypher +105 -0
  28. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +8 -2
  29. package/payload/platform/plugins/cloudflare/PLUGIN.md +22 -2
  30. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +30 -8
  31. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +75 -13
  32. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +77 -9
  33. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +38 -2
  34. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +72 -0
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +43 -7
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +22 -7
  37. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +14 -2
  38. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +23 -5
  39. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +3 -0
  40. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +46 -5
  41. package/payload/platform/plugins/docs/references/calendar-booking.md +5 -1
  42. package/payload/platform/plugins/docs/references/internals.md +2 -0
  43. package/payload/platform/plugins/memory/PLUGIN.md +1 -0
  44. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js +61 -31
  45. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js.map +1 -1
  46. package/payload/platform/plugins/memory/references/schema-construction.md +13 -0
  47. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  48. package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
  49. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  50. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +70 -5
  51. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
  52. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js +31 -6
  53. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js.map +1 -1
  54. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +31 -4
  55. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
  56. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +18 -7
  57. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
  58. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts +15 -6
  59. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts.map +1 -1
  60. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js +24 -11
  61. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js.map +1 -1
  62. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +84 -5
  63. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  64. package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -0
  65. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +22 -2
  66. package/payload/platform/scripts/__tests__/quarantine-restore.test.sh +143 -0
  67. package/payload/platform/scripts/quarantine-restore.mjs +175 -0
  68. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +24 -7
  69. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
  70. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +55 -72
  71. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/index.js +11 -8
  73. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  74. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +32 -0
  75. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  76. package/payload/platform/services/whatsapp-channel/dist/notification.js +32 -2
  77. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  78. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +6 -1
  79. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
  80. package/payload/platform/services/whatsapp-channel/dist/targets.js +6 -0
  81. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
  82. package/payload/platform/templates/account-schema/SCHEMA.md +7 -4
  83. package/payload/server/{chunk-REWXLAPC.js → chunk-UFXRUKXO.js} +4 -2
  84. package/payload/server/public/activity.html +6 -6
  85. package/payload/server/public/assets/{AdminLoginScreens-DF9S5Rca.js → AdminLoginScreens-Dic_fPt6.js} +1 -1
  86. package/payload/server/public/assets/{AdminShell-B0PNDCAb.js → AdminShell-JN7rZ8aG.js} +1 -1
  87. package/payload/server/public/assets/{Checkbox-s5X6dso4.js → Checkbox-CB74kINA.js} +1 -1
  88. package/payload/server/public/assets/{activity-BRBsaalV.js → activity-DbfpHg3i.js} +1 -1
  89. package/payload/server/public/assets/{admin-BoVCP4Mw.js → admin-DvQPTxqe.js} +1 -1
  90. package/payload/server/public/assets/{bot-BVO9Uqr_.js → bot-DnuyRsLS.js} +1 -1
  91. package/payload/server/public/assets/{browser-BVW4FFii.js → browser-q4EFqKvR.js} +1 -1
  92. package/payload/server/public/assets/{calendar-CWsD3uLk.js → calendar-C-q9kWmA.js} +1 -1
  93. package/payload/server/public/assets/chat-DcefIV9q.js +1 -0
  94. package/payload/server/public/assets/chevron-down-Blma_CKl.js +1 -0
  95. package/payload/server/public/assets/chevron-left-xeYDxFiC.js +1 -0
  96. package/payload/server/public/assets/chevron-right-BonbtjqJ.js +1 -0
  97. package/payload/server/public/assets/data-Bu-5SUmR.js +1 -0
  98. package/payload/server/public/assets/{graph-mJ9ky5dc.js → graph-Cyh6FWaV.js} +1 -1
  99. package/payload/server/public/assets/graph-labels-CUQtXFKW.js +1 -0
  100. package/payload/server/public/assets/{maximize-2-yucCepu_.js → maximize-2-k3Fu5hdP.js} +1 -1
  101. package/payload/server/public/assets/{operator-911z_Fy_.js → operator-8NVpNLeD.js} +1 -1
  102. package/payload/server/public/assets/{page-Cv0qUwMG.js → page-Cno8NNMk.js} +1 -1
  103. package/payload/server/public/assets/{page-CgSgj8Cy.js → page-DhwPG5Zf.js} +1 -1
  104. package/payload/server/public/assets/{public-BzwveFwz.js → public-BJ5Oo2Fw.js} +1 -1
  105. package/payload/server/public/assets/{rotate-ccw-BH51cf2Q.js → rotate-ccw-D-ov5jM4.js} +1 -1
  106. package/payload/server/public/assets/tasks-DHMxTZVD.js +1 -0
  107. package/payload/server/public/assets/{time-entry-format-DPdGkp1C.js → time-entry-format-EmV5lz6o.js} +1 -1
  108. package/payload/server/public/assets/{triangle-alert-DO2rjjfV.js → triangle-alert-CivyPfAL.js} +1 -1
  109. package/payload/server/public/assets/{useCopyFeedback-HMSM7-Ir.js → useCopyFeedback-hbnPS-r9.js} +1 -1
  110. package/payload/server/public/assets/{useSubAccountSwitcher-2ojOOYNo.css → useSubAccountSwitcher-kwhG-aJj.css} +1 -1
  111. package/payload/server/public/assets/{useVoiceRecorder-BcukQIza.js → useVoiceRecorder-QN9_GqB_.js} +1 -1
  112. package/payload/server/public/browser.html +4 -4
  113. package/payload/server/public/calendar.html +6 -6
  114. package/payload/server/public/chat.html +13 -13
  115. package/payload/server/public/data.html +10 -10
  116. package/payload/server/public/graph.html +11 -11
  117. package/payload/server/public/index.html +16 -16
  118. package/payload/server/public/operator.html +15 -15
  119. package/payload/server/public/public.html +13 -13
  120. package/payload/server/public/tasks.html +6 -6
  121. package/payload/server/server.js +1036 -784
  122. package/payload/server/{src-JJ6PHO5I.js → src-YJF3UJNR.js} +1 -1
  123. package/payload/server/public/assets/chat-DKGREuIO.js +0 -1
  124. package/payload/server/public/assets/chevron-down-m581tl1J.js +0 -1
  125. package/payload/server/public/assets/chevron-left-BAY0xfmR.js +0 -1
  126. package/payload/server/public/assets/chevron-right-Dy_REdYZ.js +0 -1
  127. package/payload/server/public/assets/data-CtOxa94P.js +0 -1
  128. package/payload/server/public/assets/graph-labels-C8-sIKuv.js +0 -1
  129. package/payload/server/public/assets/tasks-21egY7A6.js +0 -1
  130. /package/payload/server/public/assets/{useSubAccountSwitcher-DLvqvfoc.js → useSubAccountSwitcher-DotTrvX3.js} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, unlinkSync } from 'node:fs'
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, unlinkSync, utimesSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { walkExposed, pushAccount } from '../../bin/portal-index-push.mjs'
@@ -12,6 +12,10 @@ function file(rel: string, body = 'x') {
12
12
  writeFileSync(abs, body)
13
13
  }
14
14
 
15
+ function emptyDir(rel: string) {
16
+ mkdirSync(join(dir, rel), { recursive: true })
17
+ }
18
+
15
19
  const SCHEMA = [
16
20
  '```allowed-top-level',
17
21
  'output',
@@ -71,8 +75,11 @@ const push = (client: unknown, overrides: Record<string, unknown> = {}) =>
71
75
  })
72
76
 
73
77
  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()
78
+ it('returns files from exposed dirs, at any depth', async () => {
79
+ const paths = (await walkExposed(dir, ['output', 'quotes']))
80
+ .filter((r) => !r.isDir)
81
+ .map((r) => r.relPath)
82
+ .sort()
76
83
  expect(paths).toEqual(['output/report.pdf', 'quotes/CR2969/quote.pdf', 'quotes/CR2974/quote.pdf'])
77
84
  })
78
85
 
@@ -89,29 +96,90 @@ describe('walkExposed', () => {
89
96
  })
90
97
 
91
98
  it('carries size and an iso modified time', async () => {
92
- const row = (await walkExposed(dir, ['output']))[0]
99
+ const row = (await walkExposed(dir, ['output'])).find((r) => !r.isDir)!
93
100
  expect(row.sizeBytes).toBe(1)
94
101
  expect(row.modifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
95
102
  })
103
+
104
+ // An exposed folder holding no files at any depth is the case the client
105
+ // could not see at all: nothing derived a folder name from a file prefix.
106
+ it('emits exactly one row for an exposed dir holding no files, marked isDir', async () => {
107
+ emptyDir('jobs')
108
+ const rows = await walkExposed(dir, ['jobs'])
109
+ expect(rows.length).toBe(1)
110
+ expect(rows[0].relPath).toBe('jobs')
111
+ expect(rows[0].isDir).toBe(true)
112
+ expect(rows[0].sizeBytes).toBe(0)
113
+ expect(rows[0].modifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
114
+ })
115
+
116
+ it('emits the dir row and the file row for a dir holding a nested file', async () => {
117
+ const rows = await walkExposed(dir, ['quotes'])
118
+ const byPath = new Map(rows.map((r) => [r.relPath, r]))
119
+ expect(byPath.get('quotes')?.isDir).toBe(true)
120
+ expect(byPath.get('quotes/CR2969')?.isDir).toBe(true)
121
+ expect(byPath.get('quotes/CR2969/quote.pdf')?.isDir).toBe(false)
122
+ })
123
+
124
+ // The dir row's time is the directory's own, not the walk's: a folder whose
125
+ // contents changed is what the operator reads, and `new Date()` here would
126
+ // make every row look freshly touched every cycle.
127
+ it('takes the dir mtime rather than the time of the walk', async () => {
128
+ emptyDir('jobs')
129
+ const past = new Date('2020-03-04T05:06:07.000Z')
130
+ utimesSync(join(dir, 'jobs'), past, past)
131
+ const rows = await walkExposed(dir, ['jobs'])
132
+ expect(rows[0].modifiedAt).toBe('2020-03-04T05:06:07.000Z')
133
+ })
134
+
135
+ it('emits no row for a dot-prefixed dir at any level', async () => {
136
+ const paths = (await walkExposed(dir, ['output'])).map((r) => r.relPath)
137
+ expect(paths).not.toContain('output/.uploads-tmp')
138
+ })
96
139
  })
97
140
 
98
141
  describe('pushAccount', () => {
142
+ // 7 rows: the 3 files plus output/, quotes/, quotes/CR2969/, quotes/CR2974/.
99
143
  it('deletes the account rows then inserts the current walk', async () => {
100
144
  const c = fakeClient()
101
145
  const r = await push(c)
102
146
  expect(r.exposed).toEqual(['output', 'quotes'])
103
- expect(r.rows).toBe(3)
147
+ expect(r.rows).toBe(7)
104
148
  expect(/DELETE FROM directory/i.test(c.calls[0].sql)).toBe(true)
105
149
  expect(c.calls[0].params).toEqual(['acc-1'])
106
- expect(c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).length).toBe(3)
150
+ expect(c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).length).toBe(7)
107
151
  })
108
152
 
153
+ // The folder itself survives its last file: emptied is not the same as gone,
154
+ // and the client still needs somewhere to put the next one.
109
155
  it('converges on a delete: a removed file leaves no stale row', async () => {
110
156
  const c = fakeClient()
111
157
  await push(c)
112
158
  unlinkSync(join(dir, 'quotes/CR2974/quote.pdf'))
159
+ // The fake accumulates calls across pushes; only the second one is the
160
+ // converged state.
161
+ const before = c.calls.length
113
162
  const second = await push(c)
114
- expect(second.rows).toBe(2)
163
+ expect(second.rows).toBe(6)
164
+ const inserted = c.calls
165
+ .slice(before)
166
+ .filter((x) => /INSERT INTO directory/i.test(x.sql))
167
+ .map((x) => String(x.params[1]))
168
+ expect(inserted).toContain('quotes/CR2974')
169
+ expect(inserted).not.toContain('quotes/CR2974/quote.pdf')
170
+ })
171
+
172
+ it('marks each inserted row as a directory or not', async () => {
173
+ const c = fakeClient()
174
+ await push(c)
175
+ const byPath = new Map(
176
+ c.calls
177
+ .filter((x) => /INSERT INTO directory/i.test(x.sql))
178
+ .map((x) => [String(x.params[1]), x.params[5]]),
179
+ )
180
+ expect(byPath.get('quotes')).toBe(1)
181
+ expect(byPath.get('quotes/CR2969')).toBe(1)
182
+ expect(byPath.get('quotes/CR2969/quote.pdf')).toBe(0)
115
183
  })
116
184
 
117
185
  it('binds every value as a param rather than interpolating it', async () => {
@@ -138,8 +206,8 @@ describe('pushAccount', () => {
138
206
  const c = fakeClient()
139
207
  await push(c, { log: (l: string) => lines.push(l) })
140
208
  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)
209
+ expect(lines.some((l) => l.includes('op=replace') && l.includes('inserted=7'))).toBe(true)
210
+ expect(lines.some((l) => l.includes('op=verify') && l.includes('rowsAfter=7') && l.includes('expected=7'))).toBe(true)
143
211
  })
144
212
 
145
213
  it('reports an exposed-set change only when it actually changes', async () => {
@@ -10,14 +10,17 @@ function makeEnv(
10
10
  secret?: string
11
11
  origin?: string
12
12
  paths?: string[]
13
+ dirPaths?: string[]
13
14
  folders?: string
15
+ onQuery?: (query: string) => void
14
16
  } = {},
15
17
  ) {
16
- const { accountId = 'acc-a', paths = [PATH] } = opts
18
+ const { accountId = 'acc-a', paths = [PATH], dirPaths = [], onQuery = () => {} } = opts
17
19
  const folders = 'folders' in opts ? opts.folders : ''
18
20
  return {
19
21
  DB: {
20
22
  prepare(query: string) {
23
+ onQuery(query)
21
24
  let bound: unknown[] = []
22
25
  const stmt = {
23
26
  bind(...v: unknown[]) {
@@ -37,7 +40,9 @@ function makeEnv(
37
40
  : null) as T | null
38
41
  }
39
42
  if (/FROM directory/i.test(query)) {
40
- return (paths.includes(bound[1] as string) ? { relPath: bound[1] } : null) as T | null
43
+ const rel = bound[1] as string
44
+ if (dirPaths.includes(rel)) return { relPath: rel, isDir: 1 } as T
45
+ return (paths.includes(rel) ? { relPath: rel, isDir: 0 } : null) as T | null
41
46
  }
42
47
  return null as T | null
43
48
  },
@@ -100,6 +105,37 @@ describe('processIndexedDownload', () => {
100
105
  expect(touched).toBe(false)
101
106
  })
102
107
 
108
+ // Folders are in the index now, so a client can name one here. A folder has
109
+ // no bytes and must never mint a signed link. result=is-directory separates
110
+ // it from result=not-granted and result=absent, the only readings before.
111
+ it('refuses a directory rather than signing a link for it', async () => {
112
+ let touched = false
113
+ const spy = async () => {
114
+ touched = true
115
+ return { ok: true, status: 200 }
116
+ }
117
+ const lines: string[] = []
118
+ const r = await processIndexedDownload(
119
+ 'sess-a',
120
+ 'quotes/CR2969',
121
+ makeEnv({ paths: [PATH], dirPaths: ['quotes/CR2969'] }),
122
+ (l) => lines.push(l),
123
+ 1000,
124
+ spy as never,
125
+ )
126
+ expect(r.status).toBe(404)
127
+ expect(r.url).toBeUndefined()
128
+ expect(touched).toBe(false)
129
+ expect(lines.some((l) => l.includes('result=is-directory'))).toBe(true)
130
+ })
131
+
132
+ it('reads isDir in the directory select', async () => {
133
+ const queries: string[] = []
134
+ const env = makeEnv({ onQuery: (q) => queries.push(q) })
135
+ await processIndexedDownload('sess-a', PATH, env, () => {}, 1000, okHead as never)
136
+ expect(queries.some((q) => /FROM directory/i.test(q) && /isDir/.test(q))).toBe(true)
137
+ })
138
+
103
139
  it('401s without a session', async () => {
104
140
  const r = await processIndexedDownload('nope', PATH, makeEnv(), () => {}, 1000, okHead as never)
105
141
  expect(r.status).toBe(401)
@@ -143,6 +143,16 @@ describe('device binding', () => {
143
143
  expect(text).toMatch(/UNIQUE \(accountId, relPath\)/)
144
144
  expect(text).toMatch(/accountId\s+TEXT NOT NULL DEFAULT ''/)
145
145
  })
146
+
147
+ // IF NOT EXISTS is a no-op on an existing table, so a portal deployed before
148
+ // this column will NOT gain it from a re-apply. The push names isDir in its
149
+ // INSERT, so such a table fails loudly rather than silently publishing
150
+ // nothing — the ALTER has to be findable at the point that happens.
151
+ it('declares isDir and carries the ALTER for a table that predates it', () => {
152
+ const text = schema()
153
+ expect(text).toMatch(/isDir\s+INTEGER NOT NULL DEFAULT 0/)
154
+ expect(text).toContain('ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0')
155
+ })
146
156
  })
147
157
 
148
158
  const portalJs = () => readFileSync(join(TEMPLATE, 'portal.js'), 'utf8')
@@ -170,3 +180,65 @@ describe('login error surfacing and passcode reveal (Task 1872)', () => {
170
180
  expect(portalJs()).toMatch(/dp-reveal/)
171
181
  })
172
182
  })
183
+
184
+ // Task 1900. The device pushes a new tree hourly, so a client who stays signed
185
+ // in reads an index frozen at sign-in. These assertions pin the control that
186
+ // re-reads it: the button in the pinned header, and the click that drives it.
187
+ describe('refresh control', () => {
188
+ it('puts a refresh button inside the pinned header', () => {
189
+ const header = indexHtml().match(/<header class="dp-header">[\s\S]*?<\/header>/)
190
+ expect(header).not.toBeNull()
191
+ expect(header![0]).toMatch(/id="dp-refresh"/)
192
+ expect(header![0]).toMatch(/class="dp-iconbtn"/)
193
+ expect(header![0]).toMatch(/aria-label="Refresh"/)
194
+ })
195
+
196
+ it('sits between the crumbs and the upload control', () => {
197
+ const text = indexHtml()
198
+ expect(text.indexOf('id="dp-crumbs"')).toBeLessThan(text.indexOf('id="dp-refresh"'))
199
+ expect(text.indexOf('id="dp-refresh"')).toBeLessThan(text.indexOf('id="dp-upload-open"'))
200
+ })
201
+
202
+ it('binds a click on it', () => {
203
+ expect(portalJs()).toMatch(/refreshEl\.addEventListener\('click'/)
204
+ })
205
+
206
+ it('drives refresh() rather than reloading the page', () => {
207
+ expect(portalJs()).not.toMatch(/location\.reload/)
208
+ })
209
+
210
+ // The status line has to survive a failed re-read. refresh() catches its own
211
+ // failure, so the promise resolves either way; only a reported outcome tells
212
+ // the handler whether clearing the status would erase an error.
213
+ it('has refresh() report its outcome on all three exits', () => {
214
+ const body = portalJs().match(/function refresh\(\)[\s\S]*?\n }\n/)
215
+ expect(body).not.toBeNull()
216
+ expect(body![0]).toMatch(/return true/)
217
+ expect((body![0].match(/return false/g) || []).length).toBe(2)
218
+ })
219
+
220
+ // No timer, no focus handler. A poll against an hourly tree is waste.
221
+ it('never polls', () => {
222
+ expect(portalJs()).not.toMatch(/setInterval|visibilitychange/)
223
+ })
224
+ })
225
+
226
+ // refresh() now writes its own failure text, which would bury the outcome of
227
+ // the delete or upload the client just asked for. Both callers compose instead,
228
+ // so a stale list is reported without losing what happened to the file.
229
+ describe('an action that outlives its re-list', () => {
230
+ it('keeps the delete and upload outcome when the re-list fails', () => {
231
+ const text = portalJs()
232
+ expect((text.match(/The list could not be reloaded\./g) || []).length).toBe(2)
233
+ })
234
+
235
+ it('has both callers read the boolean refresh() returns', () => {
236
+ const text = portalJs()
237
+ const removeBody = text.match(/function remove\(key\)[\s\S]*?\n }\n/)
238
+ const uploadBody = text.match(/function uploadFile\(file\)[\s\S]*?\n }\n/)
239
+ expect(removeBody).not.toBeNull()
240
+ expect(uploadBody).not.toBeNull()
241
+ expect(removeBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/)
242
+ expect(uploadBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/)
243
+ })
244
+ })
@@ -13,9 +13,11 @@ references with `plugin-read` before issuing their commands.
13
13
  ## What this skill produces, and what it does not
14
14
 
15
15
  It produces a Pages project on a custom domain, an R2 bucket holding one prefix per enrolled person,
16
- and a D1 database holding the manifest, the enrolment records, and the sessions. It does not ingest
17
- the dropped files: a separate device-side sweep reads un-ingested manifest rows and pulls them into
18
- the graph. This skill delivers the manifest that sweep consumes.
16
+ and a D1 database holding the manifest, the enrolment records, and the sessions. It does not carry a
17
+ dropped file onto the device: the `portal-uploads-pull` loop in the admin server does that, every
18
+ minute, writing each un-ingested manifest row's object to `uploads/<ownerName>/` and setting
19
+ `ingested = 1` only once the bytes are on disk at the right size. This skill delivers the manifest
20
+ that loop consumes. Nothing here or there ingests a pulled file into the graph.
19
21
 
20
22
  ## Why the portal is on Pages and not the install
21
23
 
@@ -110,6 +112,17 @@ with the same `bin/cf-store-name.sh` scheme as the bucket, and apply
110
112
  `platform/plugins/cloudflare/skills/data-portal/schema.sql` to the remote database from there. It is
111
113
  `IF NOT EXISTS` throughout, so re-applying is safe.
112
114
 
115
+ Safe, but not a migration: `IF NOT EXISTS` adds no column to a table that already exists. A portal
116
+ whose `directory` table predates the `isDir` column keeps failing every index push, because the push
117
+ names the column in its `INSERT`. Add it once by hand against that database:
118
+
119
+ ```bash
120
+ npx wrangler d1 execute <portalDbName> --remote \
121
+ --command "ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0"
122
+ ```
123
+
124
+ No backfill follows it. The push replaces every row for the account each cycle.
125
+
113
126
  The copied `portal.css` carries the template's default palette, which is nobody's brand. Apply the
114
127
  install's before handing the tree to the deploy:
115
128
 
@@ -164,7 +177,7 @@ anyway. That check is what contains a stale index or a compromised project, and
164
177
 
165
178
  ## Publish the folder index
166
179
 
167
- The device publishes file metadata — names, sizes, times, never bytes:
180
+ The device publishes file and folder metadata — names, sizes, times, never bytes:
168
181
 
169
182
  ```bash
170
183
  node platform/plugins/cloudflare/bin/portal-index-push.mjs \
@@ -178,6 +191,13 @@ brand with a different vertical publishes different folders with no change to th
178
191
  data (`documents/`, `contacts/`, `projects/`), the client's own `uploads/`, plugin workspaces and
179
192
  everything platform-internal are in neither source and are never published.
180
193
 
194
+ Every walked folder gets a row of its own, not only the files inside it. An exposed folder holding
195
+ nothing at any depth is therefore a real entry: it renders in the tree and opens to an empty listing,
196
+ rather than reading as absent to the person who has to put the first file into it. A folder row
197
+ carries no bytes, so the download path refuses it and logs `result=is-directory`. A folder named in
198
+ the schema but absent from the device's disk still publishes nothing, so the tree never offers a
199
+ folder the device does not have.
200
+
181
201
  An account with no `SCHEMA.md` publishes nothing at all. That is deliberate and it is the opposite of
182
202
  what the write-path guard does — the guard fails open so an unseeded account is not write-blocked,
183
203
  while a surface facing a client must fail closed.
@@ -208,9 +228,25 @@ operator requested in `exposeFolders`; a non-zero `extraFolders` with none of th
208
228
 
209
229
  Signed in, the panel has a pinned header that stays put while the file lists
210
230
  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.
231
+ moves to the parent folder (disabled at the root), a refresh control, and an
232
+ upload icon. Filenames render on one line: the stem truncates with an ellipsis
233
+ and the extension stays pinned, so `.pdf` and `.html` on two same-named orders
234
+ are still told apart.
235
+
236
+ Refresh re-reads the index in the folder that is already open, so a client who
237
+ stays signed in sees files the device pushed after they arrived without
238
+ reloading the page. It is a click and never a timer: the device pushes hourly,
239
+ so polling would be waste. The button is inert while its request is in flight,
240
+ and the status line clears on success or keeps the failure message on screen.
241
+ Each click is one further `op=list` line carrying the same account and prefix,
242
+ so a click with no line is the handler not firing and two lines is the in-flight
243
+ guard not holding.
244
+
245
+ Delete and upload both re-read the list when they finish. If that re-read
246
+ fails, the status keeps the outcome and adds to it, so a client reads
247
+ `Deleted. The list could not be reloaded.` rather than a bare load error that
248
+ would leave them unsure whether the file went. The rows on screen are then
249
+ known to be stale, and refresh is how they clear that.
214
250
 
215
251
  A shared (device-held) file downloads three ways, all through the same signed
216
252
  `/api/download?path=` route: a left click, a right-click that opens a one-item
@@ -58,9 +58,10 @@ CREATE TABLE IF NOT EXISTS sessions (
58
58
 
59
59
  CREATE INDEX IF NOT EXISTS sessions_owner ON sessions (ownerId);
60
60
 
61
- -- One row per stored object. `ingested` is the claim flag the device-side
62
- -- sweep (a separate task) reads and sets; a row stays 0 until its file has
63
- -- been pulled into the graph, so the backlog survives the device being offline.
61
+ -- One row per stored object. `ingested` is the claim flag the admin server's
62
+ -- `portal-uploads-pull` loop reads and sets; a row stays 0 until its object is
63
+ -- on the device at the size recorded here, so the backlog survives the device
64
+ -- being offline and a half-finished transfer never reads as delivered.
64
65
  CREATE TABLE IF NOT EXISTS manifest (
65
66
  id INTEGER PRIMARY KEY AUTOINCREMENT,
66
67
  fileId TEXT UNIQUE NOT NULL,
@@ -88,16 +89,29 @@ CREATE TABLE IF NOT EXISTS auth_attempts (
88
89
 
89
90
  CREATE UNIQUE INDEX IF NOT EXISTS auth_attempts_scope_window ON auth_attempts (scope, windowStart);
90
91
 
91
- -- One row per file the device has published to this portal. Metadata only: the
92
- -- bytes stay on the device and are fetched on demand through a short-lived
93
- -- signed link, which is what lets the folder tree render while the device is
94
- -- offline.
92
+ -- One row per file OR folder the device has published to this portal. Metadata
93
+ -- only: the bytes stay on the device and are fetched on demand through a
94
+ -- short-lived signed link, which is what lets the folder tree render while the
95
+ -- device is offline.
95
96
  --
96
97
  -- Written by a full replace per account (delete-all, then insert the current
97
98
  -- walk), which is why there is no tombstone column: a file deleted or renamed on
98
99
  -- the device simply stops being inserted on the next cycle. The UNIQUE key is
99
100
  -- what makes that replace safe to retry — a double insert of the same path
100
101
  -- fails loudly instead of silently doubling the row.
102
+ --
103
+ -- `isDir` marks a folder row. Folders are stored rather than derived from file
104
+ -- path prefixes, because a folder holding no files at any depth has no prefix
105
+ -- to derive it from and would not exist to the client at all. A folder row
106
+ -- carries sizeBytes 0 and the folder's own modified time, and the download path
107
+ -- refuses to mint a signed link for one.
108
+ --
109
+ -- Like `people.accountId` and `people.folders`, IF NOT EXISTS will NOT add this
110
+ -- column to a `directory` table that predates it, and the push names `isDir` in
111
+ -- its INSERT, so such a table fails loudly on the next cycle. Add it by hand in
112
+ -- that case:
113
+ -- ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0;
114
+ -- No backfill follows it: the push full-replaces every row each cycle.
101
115
  CREATE TABLE IF NOT EXISTS directory (
102
116
  id INTEGER PRIMARY KEY AUTOINCREMENT,
103
117
  accountId TEXT NOT NULL,
@@ -105,6 +119,7 @@ CREATE TABLE IF NOT EXISTS directory (
105
119
  sizeBytes INTEGER NOT NULL,
106
120
  modifiedAt TEXT NOT NULL,
107
121
  indexedAt TEXT NOT NULL,
122
+ isDir INTEGER NOT NULL DEFAULT 0,
108
123
  UNIQUE (accountId, relPath)
109
124
  );
110
125
 
@@ -83,15 +83,27 @@ export async function processIndexedDownload(
83
83
  return { status: 404, payload: { ok: false, error: 'not found' } }
84
84
  }
85
85
 
86
- const row = await env.DB.prepare('SELECT relPath FROM directory WHERE accountId = ? AND relPath = ?')
86
+ const row = await env.DB.prepare(
87
+ 'SELECT relPath, isDir FROM directory WHERE accountId = ? AND relPath = ?',
88
+ )
87
89
  .bind(session.accountId, relPath)
88
- .first<{ relPath: string }>()
90
+ .first<{ relPath: string; isDir: number }>()
89
91
  if (!row) {
90
92
  log(
91
93
  `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} result=absent`,
92
94
  )
93
95
  return { status: 404, payload: { ok: false, error: 'not found' } }
94
96
  }
97
+ if (row.isDir) {
98
+ // Folders are in the index, so a client can name one here. A folder has no
99
+ // bytes: minting a link would send the device a path it cannot serve, and
100
+ // the browser an opaque failure. Refused before the probe, and reported
101
+ // distinguishably from not-granted and absent.
102
+ log(
103
+ `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} result=is-directory`,
104
+ )
105
+ return { status: 404, payload: { ok: false, error: 'not found' } }
106
+ }
95
107
 
96
108
  const secret = env.PORTAL_FETCH_SECRET
97
109
  const origin = env.PORTAL_INSTALL_ORIGIN
@@ -6,6 +6,7 @@ interface DirRow {
6
6
  relPath: string
7
7
  sizeBytes: number
8
8
  modifiedAt: string
9
+ isDir: number
9
10
  }
10
11
 
11
12
  export interface DirEntry {
@@ -19,10 +20,12 @@ export interface DirEntry {
19
20
  /**
20
21
  * Collapse the flat `directory` rows into one level of listing under `prefix`.
21
22
  *
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.
23
+ * The device publishes a row per folder as well as per file, so a folder
24
+ * holding no files at any depth is a real entry that opens and reads as empty.
25
+ * Folders are ALSO derived from the path segments of deeper rows, and the two
26
+ * sources collapse onto one entry per name. The prefix matches on a whole
27
+ * segment (`quotes/`, not `quotes`), so a partial name can never reach a
28
+ * sibling folder's contents.
26
29
  */
27
30
  export function entriesUnder(rows: DirRow[], prefix: string): DirEntry[] {
28
31
  const base = prefix ? `${prefix}/` : ''
@@ -34,6 +37,21 @@ export function entriesUnder(rows: DirRow[], prefix: string): DirEntry[] {
34
37
  if (!rest) continue
35
38
  const slash = rest.indexOf('/')
36
39
  if (slash === -1) {
40
+ // A folder's own row has nothing left after the prefix, exactly like a
41
+ // file's. `isDir` is the only thing separating them; without it a folder
42
+ // lands in the file list and a click tries to download it.
43
+ if (row.isDir) {
44
+ if (!dirs.has(rest)) {
45
+ dirs.set(rest, {
46
+ name: rest,
47
+ kind: 'directory',
48
+ relPath: row.relPath,
49
+ sizeBytes: null,
50
+ modifiedAt: null,
51
+ })
52
+ }
53
+ continue
54
+ }
37
55
  files.push({
38
56
  name: rest,
39
57
  kind: 'file',
@@ -89,7 +107,7 @@ export async function processFiles(
89
107
  const grant = session.folders
90
108
  if (session.accountId) {
91
109
  const dir = await env.DB.prepare(
92
- 'SELECT relPath, sizeBytes, modifiedAt FROM directory WHERE accountId = ?',
110
+ 'SELECT relPath, sizeBytes, modifiedAt, isDir FROM directory WHERE accountId = ?',
93
111
  )
94
112
  .bind(session.accountId)
95
113
  .all<DirRow>()
@@ -46,6 +46,9 @@
46
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
47
  </button>
48
48
  <nav id="dp-crumbs" class="dp-crumbs" aria-label="Folder path"></nav>
49
+ <button class="dp-iconbtn" id="dp-refresh" type="button" aria-label="Refresh">
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="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
51
+ </button>
49
52
  <button class="dp-iconbtn" id="dp-upload-open" type="button" aria-label="Upload a file">
50
53
  <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
54
  </button>
@@ -20,6 +20,7 @@
20
20
  var crumbsEl = document.getElementById('dp-crumbs');
21
21
  var entriesEl = document.getElementById('dp-entries');
22
22
  var upEl = document.getElementById('dp-up');
23
+ var refreshEl = document.getElementById('dp-refresh');
23
24
  var uploadOpenEl = document.getElementById('dp-upload-open');
24
25
  var modalEl = document.getElementById('dp-modal');
25
26
  var modalCloseEl = document.getElementById('dp-modal-close');
@@ -269,13 +270,22 @@
269
270
  });
270
271
  }
271
272
 
273
+ /* Resolves true when the list was re-rendered, false when it was not. The
274
+ boolean exists for the refresh control: this function catches its own
275
+ failure, so the promise resolves either way, and a caller that cleared the
276
+ status on resolve would erase the message written a moment earlier. The
277
+ `!d.ok` branch used to return silently, which left a failed re-list
278
+ indistinguishable from an unchanged one. */
272
279
  function refresh() {
273
280
  return api('/api/files?prefix=' + encodeURIComponent(prefix))
274
281
  .then(function (r) {
275
282
  return r.json();
276
283
  })
277
284
  .then(function (d) {
278
- if (!d.ok) return;
285
+ if (!d.ok) {
286
+ say('Could not load your files.');
287
+ return false;
288
+ }
279
289
  render(d.files || []);
280
290
  var entries = d.entries || [];
281
291
  // Hidden at the root only: once inside a folder the section stays put,
@@ -283,9 +293,11 @@
283
293
  // vanishing.
284
294
  sharedEl.hidden = prefix === '' && entries.length === 0;
285
295
  renderEntries(entries);
296
+ return true;
286
297
  })
287
298
  .catch(function () {
288
299
  say('Could not load your files.');
300
+ return false;
289
301
  });
290
302
  }
291
303
 
@@ -300,8 +312,15 @@
300
312
  return r.json();
301
313
  })
302
314
  .then(function (d) {
303
- say(d.ok ? 'Deleted.' : 'Could not delete that.');
304
- return refresh();
315
+ var done = d.ok ? 'Deleted.' : 'Could not delete that.';
316
+ say(done);
317
+ // refresh() writes its own failure text, which would otherwise bury what
318
+ // just happened to the file. Compose the two, so a list that did not
319
+ // reload is reported without the client losing the delete's outcome and
320
+ // reading a load error as though the delete itself had failed.
321
+ return refresh().then(function (ok) {
322
+ if (!ok) { say(done + ' The list could not be reloaded.'); }
323
+ });
305
324
  })
306
325
  .catch(function () {
307
326
  say('Could not delete that.');
@@ -370,6 +389,21 @@
370
389
  refresh();
371
390
  });
372
391
 
392
+ /* Re-read the index in the folder that is already open. refresh() reads the
393
+ current prefix, so this does not reset to the root. The status clears only
394
+ on a reported success, otherwise refresh()'s own error text stays up.
395
+ refresh() returns false from its catch rather than rejecting, so the button
396
+ cannot be left stuck disabled and needs no catch here. `disabled` in flight
397
+ is what stops a double click issuing two reads. */
398
+ refreshEl.addEventListener('click', function () {
399
+ refreshEl.disabled = true;
400
+ say('Refreshing…');
401
+ refresh().then(function (ok) {
402
+ refreshEl.disabled = false;
403
+ if (ok) { say(''); }
404
+ });
405
+ });
406
+
373
407
  /* The upload modal. Hidden via the `hidden` attribute so
374
408
  [hidden]{display:none!important} stays authoritative over .dp-modal's
375
409
  display:flex. It dismisses three ways: the close button, a click on the
@@ -406,8 +440,15 @@
406
440
  return r.json();
407
441
  })
408
442
  .then(function (d) {
409
- say(d.ok ? 'Uploaded.' : 'Upload failed.');
410
- return refresh().then(function () { return { responded: true, ok: !!d.ok }; });
443
+ var done = d.ok ? 'Uploaded.' : 'Upload failed.';
444
+ say(done);
445
+ // Same composition as remove(): a list that did not reload must not cost
446
+ // the client the upload's own outcome. The resolved shape is unchanged,
447
+ // so the submit and drop handlers still read `responded` and `ok`.
448
+ return refresh().then(function (ok) {
449
+ if (!ok) { say(done + ' The list could not be reloaded.'); }
450
+ return { responded: true, ok: !!d.ok };
451
+ });
411
452
  })
412
453
  .catch(function () {
413
454
  say('Upload failed.');
@@ -49,10 +49,14 @@ This usually completes within a couple of minutes of the booking.
49
49
  Bookings are reconciled on a cycle and the system audits itself each run. In the device logs:
50
50
 
51
51
  - `[calendar-booking]` lines trace one booking through submit, write, notify, and done.
52
- - `[calendar-reconcile]` lines report three health numbers per account: `eligibleExamined` (bookings the check looked at), `pendingUnreconciled` (bookings captured but not yet turned into meetings) and `orphanMeetings` (booking meetings with no matching saved booking). Entries that only block out a slot are not counted — they are not bookings and never become meetings.
52
+ - `[calendar-reconcile]` lines report four health numbers per account: `eligibleExamined` (bookings the check looked at), `pendingUnreconciled` (bookings captured but not yet turned into meetings), `pendingOldestAgeMin` (how many minutes the longest-waiting of those has been waiting) and `orphanMeetings` (booking meetings with no matching saved booking). Entries that only block out a slot are not counted — they are not bookings and never become meetings.
53
53
 
54
54
  A `pendingUnreconciled` number that stays above zero across several runs means those bookings are failing to turn into meetings, and the `attempt=` number on the matching `[calendar-booking]` lines says how many times each has been tried. A booking whose meeting is deleted is picked up again automatically on the next run. To read them: `journalctl --user -u <brand-service> | grep -E '\[calendar-booking\]|\[calendar-reconcile\]'`, then filter by one booking id for its full story. The edge half of a booking (before your device sees it) is in the Cloudflare Pages function logs.
55
55
 
56
+ A count on its own cannot tell a short lag from a long one: five bookings waiting two minutes and five waiting nine hours are both `pendingUnreconciled=5`. `pendingOldestAgeMin` is what separates them. It reads `0` when nothing is waiting, and also when something is waiting but for under a minute, so read it together with the count beside it.
57
+
58
+ A separate `op=liveness` line is printed every ten minutes by your device's own server, whether or not anything is wrong. `stale=false` means the booking check has run recently. `stale=true` means it has not run in over six minutes, which is the case nothing else can tell you about: a check that has stopped cannot report that it has stopped. `lastSkipReason` names the last account whose check finished without looking at any bookings, and reads `none` when every account it got to was checked. If you run more than one account, a check can both look at one account and skip another, so seeing a recent check time next to a skip reason is normal and means two different accounts.
59
+
56
60
  ## When your real calendar lives elsewhere
57
61
 
58
62
  The booking page publishes open slots from the internal calendar, the one Maxy keeps in its own graph.