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