@rubytech/create-maxy-code 0.1.484 → 0.1.486
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 +2 -0
- package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +23 -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-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__/template-config.test.ts +26 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +42 -3
- 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 +44 -13
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +220 -6
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +216 -17
- 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/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 +299 -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 +125 -34
|
@@ -144,3 +144,29 @@ describe('device binding', () => {
|
|
|
144
144
|
expect(text).toMatch(/accountId\s+TEXT NOT NULL DEFAULT ''/)
|
|
145
145
|
})
|
|
146
146
|
})
|
|
147
|
+
|
|
148
|
+
const portalJs = () => readFileSync(join(TEMPLATE, 'portal.js'), 'utf8')
|
|
149
|
+
const indexHtml = () => readFileSync(join(TEMPLATE, 'index.html'), 'utf8')
|
|
150
|
+
|
|
151
|
+
describe('login error surfacing and passcode reveal (Task 1872)', () => {
|
|
152
|
+
it('maps each auth failure to a distinct message, not one opaque string', () => {
|
|
153
|
+
const js = portalJs()
|
|
154
|
+
expect(js).not.toMatch(/That did not work\./)
|
|
155
|
+
expect(js).toContain('Too many attempts. Try again later.')
|
|
156
|
+
expect(js).toContain('Enter both your name and your passcode.')
|
|
157
|
+
expect(js).toContain('That name and passcode did not match.')
|
|
158
|
+
expect(js).toContain('Something went wrong. Please try again in a moment.')
|
|
159
|
+
expect(js).toContain('Could not reach the server. Check your connection and try again.')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('logs the status and error on the auth failure path', () => {
|
|
163
|
+
expect(portalJs()).toMatch(/console\.error\(\s*'\[data-portal\] auth failed'/)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('ships a reveal control bound to the passcode field', () => {
|
|
167
|
+
const html = indexHtml()
|
|
168
|
+
expect(html).toMatch(/id="dp-reveal"/)
|
|
169
|
+
expect(html).toMatch(/aria-controls="dp-passcode"/)
|
|
170
|
+
expect(portalJs()).toMatch(/dp-reveal/)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -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(""))')"
|
|
@@ -177,6 +194,28 @@ the folders you expect:
|
|
|
177
194
|
`exposed=none` on an account that has deliverables means the schema was missing or its ontology
|
|
178
195
|
region was absent — not that the walk found nothing.
|
|
179
196
|
|
|
197
|
+
## How a client browses, downloads and uploads
|
|
198
|
+
|
|
199
|
+
Signed in, the panel has a pinned header that stays put while the file lists
|
|
200
|
+
scroll under it. The header carries the folder breadcrumbs, an up control that
|
|
201
|
+
moves to the parent folder (disabled at the root), and an upload icon. Filenames
|
|
202
|
+
render on one line: the stem truncates with an ellipsis and the extension stays
|
|
203
|
+
pinned, so `.pdf` and `.html` on two same-named orders are still told apart.
|
|
204
|
+
|
|
205
|
+
A shared (device-held) file downloads three ways, all through the same signed
|
|
206
|
+
`/api/download?path=` route: a left click, a right-click that opens a one-item
|
|
207
|
+
Download menu, and a drag out of the browser onto the desktop. The drag uses
|
|
208
|
+
`/api/download?path=...&redirect=1`, which 302s to the signed link so the browser
|
|
209
|
+
saves the file rather than a JSON body. The three download methods share the one
|
|
210
|
+
`op=fetch-through` log line, so a broken method shows as a missing line under the
|
|
211
|
+
same signal.
|
|
212
|
+
|
|
213
|
+
Upload opens from the header icon into a dismissable modal holding the file
|
|
214
|
+
picker and Upload button. It posts to `/api/upload` exactly as before; a kept
|
|
215
|
+
upload refreshes the list and closes the modal. Dropping a file onto the modal's
|
|
216
|
+
upload area uploads the same way. The modal dismisses on the close button, a
|
|
217
|
+
click on the backdrop, or Escape.
|
|
218
|
+
|
|
180
219
|
## Deploy through site-deploy
|
|
181
220
|
|
|
182
221
|
`storage-pages-deploy` refuses a project with no owner on record, so record the ownership first. In
|
|
@@ -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 } }
|
|
@@ -18,22 +18,39 @@
|
|
|
18
18
|
<label class="dp-label" for="dp-owner">Your name</label>
|
|
19
19
|
<input class="dp-input" id="dp-owner" name="owner" autocomplete="username" required />
|
|
20
20
|
<label class="dp-label" for="dp-passcode">Passcode</label>
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
<div class="dp-field">
|
|
22
|
+
<input
|
|
23
|
+
class="dp-input"
|
|
24
|
+
id="dp-passcode"
|
|
25
|
+
name="passcode"
|
|
26
|
+
type="password"
|
|
27
|
+
autocomplete="current-password"
|
|
28
|
+
required
|
|
29
|
+
/>
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
class="dp-reveal"
|
|
33
|
+
id="dp-reveal"
|
|
34
|
+
aria-controls="dp-passcode"
|
|
35
|
+
>Show passcode</button>
|
|
36
|
+
</div>
|
|
29
37
|
<button class="dp-btn" type="submit">Sign in</button>
|
|
30
38
|
</form>
|
|
31
39
|
|
|
32
40
|
<section id="dp-panel" class="dp-panel" hidden>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
|
|
37
54
|
<ul id="dp-files" class="dp-files"></ul>
|
|
38
55
|
|
|
39
56
|
<!-- Files we have prepared for you, published from the device. Hidden
|
|
@@ -41,9 +58,23 @@
|
|
|
41
58
|
shows the upload list alone rather than an empty second heading. -->
|
|
42
59
|
<section id="dp-shared" class="dp-shared" hidden>
|
|
43
60
|
<h2 class="dp-subtitle">Shared with you</h2>
|
|
44
|
-
<nav id="dp-crumbs" class="dp-crumbs" aria-label="Folder path"></nav>
|
|
45
61
|
<ul id="dp-entries" class="dp-files"></ul>
|
|
46
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>
|
|
47
78
|
</section>
|
|
48
79
|
</main>
|
|
49
80
|
<script src="/portal.js"></script>
|
|
@@ -29,11 +29,11 @@ body {
|
|
|
29
29
|
padding: 2rem 1rem;
|
|
30
30
|
background: var(--dp-paper);
|
|
31
31
|
color: var(--dp-ink);
|
|
32
|
-
font:
|
|
32
|
+
font: 14px/1.5 system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
.dp-card {
|
|
36
|
-
max-width:
|
|
36
|
+
max-width: 56rem;
|
|
37
37
|
margin: 0 auto;
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -98,16 +98,45 @@ body {
|
|
|
98
98
|
border-bottom: 1px solid var(--dp-rule);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/* One line per name, never wrapped. The name splits into a stem that
|
|
102
|
+
ellipsis-truncates and an extension that never shrinks, so `.pdf` vs `.html`
|
|
103
|
+
on two same-named orders stays visible. flex:1 + min-width:0 lets the name
|
|
104
|
+
give up width to the Delete button beside it. */
|
|
101
105
|
.dp-file-name {
|
|
102
106
|
color: var(--dp-accent);
|
|
103
107
|
text-decoration: none;
|
|
104
|
-
|
|
108
|
+
display: flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
gap: 0.5rem;
|
|
111
|
+
flex: 1;
|
|
112
|
+
min-width: 0;
|
|
113
|
+
overflow: hidden;
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
.dp-file-name:hover {
|
|
108
117
|
text-decoration: underline;
|
|
109
118
|
}
|
|
110
119
|
|
|
120
|
+
/* The two-span name block. No gap between stem and extension so they read as
|
|
121
|
+
one filename; the stem clips with an ellipsis, the extension is pinned. */
|
|
122
|
+
.dp-name {
|
|
123
|
+
display: flex;
|
|
124
|
+
min-width: 0;
|
|
125
|
+
overflow: hidden;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.dp-name-main {
|
|
129
|
+
overflow: hidden;
|
|
130
|
+
text-overflow: ellipsis;
|
|
131
|
+
white-space: nowrap;
|
|
132
|
+
min-width: 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.dp-name-ext {
|
|
136
|
+
flex: none;
|
|
137
|
+
white-space: nowrap;
|
|
138
|
+
}
|
|
139
|
+
|
|
111
140
|
.dp-del {
|
|
112
141
|
border: 1px solid var(--dp-rule);
|
|
113
142
|
border-radius: 0.375rem;
|
|
@@ -140,10 +169,15 @@ body {
|
|
|
140
169
|
color: var(--dp-ink);
|
|
141
170
|
}
|
|
142
171
|
|
|
172
|
+
/* In the pinned header the crumbs are the flexible middle: they take the space
|
|
173
|
+
the up/upload buttons leave and clip on one line when a path runs deep. */
|
|
143
174
|
.dp-crumbs {
|
|
144
175
|
font-size: 13px;
|
|
145
176
|
color: var(--dp-muted);
|
|
146
|
-
|
|
177
|
+
flex: 1;
|
|
178
|
+
min-width: 0;
|
|
179
|
+
overflow: hidden;
|
|
180
|
+
white-space: nowrap;
|
|
147
181
|
}
|
|
148
182
|
|
|
149
183
|
/* Crumbs and folder rows are buttons (navigation is a fetch, not a page load),
|
|
@@ -166,10 +200,19 @@ body {
|
|
|
166
200
|
|
|
167
201
|
.dp-folder-name {
|
|
168
202
|
font-weight: 600;
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
gap: 0.5rem;
|
|
206
|
+
flex: 1;
|
|
207
|
+
min-width: 0;
|
|
208
|
+
overflow: hidden;
|
|
169
209
|
}
|
|
170
210
|
|
|
171
|
-
/* The file rows inside the tree are buttons too — same reset
|
|
172
|
-
|
|
211
|
+
/* The file rows inside the tree are buttons too — same reset. The layout (flex,
|
|
212
|
+
flex:1, min-width:0, overflow) comes from the base .dp-file-name, so these
|
|
213
|
+
truncate the same way; only the button chrome and left-align are added here.
|
|
214
|
+
The row-kind icon plus its colour (below) is what tells a folder from a file,
|
|
215
|
+
so these no longer match .dp-folder-name's weight. */
|
|
173
216
|
.dp-shared .dp-file-name {
|
|
174
217
|
background: none;
|
|
175
218
|
border: 0;
|
|
@@ -178,3 +221,174 @@ body {
|
|
|
178
221
|
cursor: pointer;
|
|
179
222
|
text-align: left;
|
|
180
223
|
}
|
|
224
|
+
|
|
225
|
+
/* Row-kind icon: sized to the text, never shrinks, stroke follows the span's
|
|
226
|
+
colour. Folder takes the accent, file the muted grey — both existing vars, so
|
|
227
|
+
the :root rewrite in portal-brand-css.mjs leaves the distinction intact. */
|
|
228
|
+
.dp-icon {
|
|
229
|
+
flex: none;
|
|
230
|
+
display: inline-flex;
|
|
231
|
+
width: 1em;
|
|
232
|
+
height: 1em;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.dp-icon svg {
|
|
236
|
+
width: 1em;
|
|
237
|
+
height: 1em;
|
|
238
|
+
display: block;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.dp-icon-folder {
|
|
242
|
+
color: var(--dp-accent);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.dp-icon-file {
|
|
246
|
+
color: var(--dp-muted);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* Passcode field with its reveal control. The passcode is transcribed out of
|
|
250
|
+
band, so being able to check what was typed is the point of the toggle.
|
|
251
|
+
Reuses existing variables and declares no new :root value, which
|
|
252
|
+
portal-brand-css.mjs would drop. */
|
|
253
|
+
.dp-field {
|
|
254
|
+
display: flex;
|
|
255
|
+
gap: 0.5rem;
|
|
256
|
+
align-items: stretch;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.dp-field .dp-input {
|
|
260
|
+
flex: 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.dp-reveal {
|
|
264
|
+
border: 1px solid var(--dp-rule);
|
|
265
|
+
border-radius: 0.375rem;
|
|
266
|
+
background: transparent;
|
|
267
|
+
color: var(--dp-muted);
|
|
268
|
+
padding: 0.375rem 0.625rem;
|
|
269
|
+
font: inherit;
|
|
270
|
+
cursor: pointer;
|
|
271
|
+
white-space: nowrap;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* Right-click Download menu for a shared file. Reuses existing variables and
|
|
275
|
+
declares no new :root value, which portal-brand-css.mjs would drop. */
|
|
276
|
+
.dp-menu {
|
|
277
|
+
position: fixed;
|
|
278
|
+
z-index: 10;
|
|
279
|
+
background: var(--dp-paper);
|
|
280
|
+
border: 1px solid var(--dp-rule);
|
|
281
|
+
border-radius: 0.375rem;
|
|
282
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
|
283
|
+
padding: 0.25rem;
|
|
284
|
+
min-width: 8.75rem;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.dp-menu-item {
|
|
288
|
+
display: block;
|
|
289
|
+
width: 100%;
|
|
290
|
+
text-align: left;
|
|
291
|
+
padding: 0.375rem 0.625rem;
|
|
292
|
+
border: 0;
|
|
293
|
+
background: transparent;
|
|
294
|
+
border-radius: 0.25rem;
|
|
295
|
+
cursor: pointer;
|
|
296
|
+
font: inherit;
|
|
297
|
+
color: var(--dp-ink);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.dp-menu-item:hover,
|
|
301
|
+
.dp-menu-item:focus {
|
|
302
|
+
background: var(--dp-rule);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/* The upload area while a file is dragged over it. */
|
|
306
|
+
.dp-upload.dp-dropping {
|
|
307
|
+
outline: 2px dashed var(--dp-accent);
|
|
308
|
+
outline-offset: 3px;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* Pinned nav row. position:sticky keeps it in flow, so it reserves its own
|
|
312
|
+
height and never overlaps the first file row (which position:fixed would). An
|
|
313
|
+
opaque paper background hides the rows that scroll under it. Reuses existing
|
|
314
|
+
variables and declares no new :root value, which portal-brand-css.mjs drops. */
|
|
315
|
+
.dp-header {
|
|
316
|
+
position: sticky;
|
|
317
|
+
top: 0;
|
|
318
|
+
z-index: 5;
|
|
319
|
+
display: flex;
|
|
320
|
+
align-items: center;
|
|
321
|
+
gap: 0.5rem;
|
|
322
|
+
padding: 0.5rem 0;
|
|
323
|
+
margin-bottom: 0.5rem;
|
|
324
|
+
background: var(--dp-paper);
|
|
325
|
+
border-bottom: 1px solid var(--dp-rule);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* The up and upload controls that flank the crumbs. Square, muted chrome so the
|
|
329
|
+
filenames stay the loudest thing on the row. */
|
|
330
|
+
.dp-iconbtn {
|
|
331
|
+
flex: none;
|
|
332
|
+
display: inline-flex;
|
|
333
|
+
align-items: center;
|
|
334
|
+
justify-content: center;
|
|
335
|
+
width: 2rem;
|
|
336
|
+
height: 2rem;
|
|
337
|
+
padding: 0.375rem;
|
|
338
|
+
border: 1px solid var(--dp-rule);
|
|
339
|
+
border-radius: 0.375rem;
|
|
340
|
+
background: transparent;
|
|
341
|
+
color: var(--dp-muted);
|
|
342
|
+
cursor: pointer;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.dp-iconbtn svg {
|
|
346
|
+
width: 1.1em;
|
|
347
|
+
height: 1.1em;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* Disabled up control at the tree root: dimmed and inert, so it reads as "no
|
|
351
|
+
parent from here" rather than a dead button. */
|
|
352
|
+
.dp-iconbtn:disabled {
|
|
353
|
+
opacity: 0.4;
|
|
354
|
+
cursor: default;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* Upload modal. [hidden]{display:none!important} keeps this off screen until the
|
|
358
|
+
header icon clears the attribute; display:flex then centres the panel. The
|
|
359
|
+
backdrop is a non-themed literal, same as .dp-menu's shadow. */
|
|
360
|
+
.dp-modal {
|
|
361
|
+
position: fixed;
|
|
362
|
+
inset: 0;
|
|
363
|
+
z-index: 20;
|
|
364
|
+
display: flex;
|
|
365
|
+
align-items: center;
|
|
366
|
+
justify-content: center;
|
|
367
|
+
padding: 1rem;
|
|
368
|
+
background: rgba(0, 0, 0, 0.4);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.dp-modal-panel {
|
|
372
|
+
position: relative;
|
|
373
|
+
width: 100%;
|
|
374
|
+
max-width: 24rem;
|
|
375
|
+
padding: 1.5rem;
|
|
376
|
+
background: var(--dp-paper);
|
|
377
|
+
border: 1px solid var(--dp-rule);
|
|
378
|
+
border-radius: 0.5rem;
|
|
379
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.dp-modal-close {
|
|
383
|
+
position: absolute;
|
|
384
|
+
top: 0.5rem;
|
|
385
|
+
right: 0.5rem;
|
|
386
|
+
padding: 0.25rem 0.5rem;
|
|
387
|
+
border: 0;
|
|
388
|
+
background: transparent;
|
|
389
|
+
color: var(--dp-muted);
|
|
390
|
+
font: inherit;
|
|
391
|
+
font-size: 1.25rem;
|
|
392
|
+
line-height: 1;
|
|
393
|
+
cursor: pointer;
|
|
394
|
+
}
|