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