@rubytech/create-maxy-code 0.1.485 → 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.
Files changed (91) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-21-graph-caption-resolver.md +124 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1877-bash-schema-enforcement.md +107 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1887-reconcile-allowed-top-level.md +205 -0
  5. package/payload/platform/lib/graph-style/dist/index.d.ts +4 -1
  6. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  7. package/payload/platform/lib/graph-style/dist/index.js +109 -1
  8. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  9. package/payload/platform/lib/graph-style/src/__tests__/caption-coverage.test.ts +117 -0
  10. package/payload/platform/lib/graph-style/src/index.ts +81 -1
  11. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh +159 -0
  12. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +18 -6
  13. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh +141 -0
  14. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-pre.sh +66 -0
  15. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +33 -1
  16. package/payload/platform/plugins/admin/skills/professional-document/SKILL.md +3 -3
  17. package/payload/platform/plugins/cloudflare/PLUGIN.md +2 -0
  18. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +23 -2
  19. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  20. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +49 -2
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +57 -3
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +44 -2
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +67 -0
  24. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +42 -3
  25. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +10 -1
  26. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +19 -5
  27. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +19 -2
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +8 -2
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +28 -5
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +195 -6
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +193 -10
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +4 -3
  33. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  34. package/payload/platform/plugins/email/PLUGIN.md +5 -1
  35. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts +2 -0
  36. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts.map +1 -0
  37. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js +50 -0
  38. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js.map +1 -0
  39. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts +2 -0
  40. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts.map +1 -0
  41. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js +51 -0
  42. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js.map +1 -0
  43. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts +2 -0
  44. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js +89 -0
  46. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js.map +1 -0
  47. package/payload/platform/plugins/email/mcp/dist/index.js +28 -0
  48. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  49. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts +22 -0
  50. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts.map +1 -0
  51. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js +55 -0
  52. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js.map +1 -0
  53. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +32 -0
  54. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  55. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +75 -1
  56. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  57. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts +19 -0
  58. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts.map +1 -0
  59. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js +49 -0
  60. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js.map +1 -0
  61. package/payload/platform/plugins/email/references/email-reference.md +7 -1
  62. package/payload/platform/plugins/memory/references/schema-construction.md +18 -12
  63. package/payload/platform/plugins/memory/references/schema-estate-agent.md +11 -6
  64. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +75 -6
  65. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +111 -22
  66. package/payload/platform/scripts/lib/provision-account-dir.sh +8 -1
  67. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +39 -0
  68. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -0
  69. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +299 -0
  70. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -0
  71. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  73. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  74. package/payload/platform/services/claude-session-manager/dist/index.js +15 -0
  75. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  76. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  77. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +4 -0
  78. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  79. package/payload/platform/templates/account-schema/SCHEMA.md +17 -0
  80. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  81. package/payload/server/{chunk-XGNRSM57.js → chunk-HN4HFTXE.js} +6 -0
  82. package/payload/server/maxy-edge.js +1 -1
  83. package/payload/server/public/assets/{admin-rScqRq-R.js → admin-BoVCP4Mw.js} +1 -1
  84. package/payload/server/public/assets/{data-TOWuhMFb.js → data-CtOxa94P.js} +1 -1
  85. package/payload/server/public/assets/{graph-labels-Axa04w8A.js → graph-labels-C8-sIKuv.js} +1 -1
  86. package/payload/server/public/assets/{graph-BEGs_P2m.js → graph-mJ9ky5dc.js} +1 -1
  87. package/payload/server/public/assets/{page-6CZ8R57f.js → page-Cv0qUwMG.js} +1 -1
  88. package/payload/server/public/data.html +3 -3
  89. package/payload/server/public/graph.html +2 -2
  90. package/payload/server/public/index.html +3 -3
  91. package/payload/server/server.js +84 -1
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env bash
2
+ # fs-schema-guard-bash-post — PostToolUse Bash stray-folder detection.
3
+ #
4
+ # The other half of the Bash-channel net (see fs-schema-guard-bash-pre.sh). After
5
+ # a Bash command runs, this hook re-lists the account root, parses the account's
6
+ # allowed-top-level set, and flags entries that are present NOW, absent from the
7
+ # pre-snapshot, and not allowed — the strays THIS command created. For each it
8
+ # emits exit 2 with a schema-citing message so the agent self-corrects within the
9
+ # same turn.
10
+ #
11
+ # No filesystem mutation: PostToolUse runs after the command executed, so it
12
+ # cannot prevent the mkdir, and auto-deleting a just-created folder risks
13
+ # destroying legitimate in-flight work (auto-repair was rejected). The standing
14
+ # reconcile (account-dir-schema-reconcile.ts) is the net for anything the agent
15
+ # ignores or that predates the hook.
16
+ #
17
+ # Diffing against the pre-snapshot (not flagging every non-allowed entry) is what
18
+ # keeps the hook from firing on the legacy backlog on every unrelated Bash call.
19
+ #
20
+ # Exit codes: 0 = allow, 2 = feedback to the agent (stderr shown). Fail open
21
+ # (exit 0) when the snapshot is missing or the allowed set is empty. Every block
22
+ # logs: [fs-guard-bash] stray path=<name> reason=<top-level|bad-name>
23
+ # No task numbers / internal refs in any operator-visible string.
24
+
25
+ set -uo pipefail
26
+
27
+ # No stdin envelope (tty) — cannot inspect. Fail open (post hooks never block on
28
+ # an un-inspectable call; a false block on every terminal-run Bash is worse than
29
+ # a missed stray the reconcile still catches).
30
+ [ -t 0 ] && exit 0
31
+ INPUT=$(cat)
32
+ [ -z "$INPUT" ] && exit 0
33
+
34
+ field() {
35
+ printf '%s' "$INPUT" | python3 -c "
36
+ import sys, json
37
+ try:
38
+ d = json.load(sys.stdin)
39
+ print(d.get('$1', '') or '')
40
+ except Exception:
41
+ print('')
42
+ " 2>/dev/null || echo ""
43
+ }
44
+
45
+ TOOL_NAME=$(field tool_name)
46
+ [ "$TOOL_NAME" = "Bash" ] || exit 0
47
+
48
+ SESSION_ID=$(field session_id)
49
+ [ -z "$SESSION_ID" ] && exit 0
50
+
51
+ if [ -n "${FS_GUARD_BASH_TMPDIR:-}" ]; then
52
+ TMP_DIR="$FS_GUARD_BASH_TMPDIR"
53
+ else
54
+ TMP_DIR="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"
55
+ TMP_DIR="${TMP_DIR%/}"
56
+ fi
57
+ SID_SAN=$(printf '%s' "$SESSION_ID" | sed 's/[^A-Za-z0-9_-]/_/g')
58
+ SNAP="$TMP_DIR/maxy-fsguard-snapshot-${SID_SAN}.txt"
59
+
60
+ # Missing pre-snapshot -> fail open (the pre hook could not run or failed open).
61
+ [ -f "$SNAP" ] || exit 0
62
+
63
+ ACCOUNT_DIR="$PWD"
64
+
65
+ # Parse the allowed top-level set from the account's seeded SCHEMA.md — the same
66
+ # awk extraction fs-schema-guard.sh uses. Empty/absent -> fail open.
67
+ ALLOWED=""
68
+ if [ -f "$ACCOUNT_DIR/SCHEMA.md" ]; then
69
+ ALLOWED=$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCOUNT_DIR/SCHEMA.md")
70
+ fi
71
+ [ -z "$ALLOWED" ] && exit 0
72
+
73
+ # Compute this command's findings, each tagged `reason<TAB>name`:
74
+ # top-level : an account-root entry present now, absent from the pre-snapshot,
75
+ # not in the allowed set (the original stray check).
76
+ # bad-name : an immediate child of an operator-data bucket (projects/contacts/
77
+ # documents) that is NEW this command whose name breaks the
78
+ # convention. A bucket absent from the pre-snapshot is new, so all
79
+ # its children are this command's doing; flagging them cannot
80
+ # re-spam the legacy backlog, which lives only in pre-existing
81
+ # buckets. Bad names under a pre-existing bucket are the standing
82
+ # reconcile's job (it can distinguish backlog from fresh; this hook
83
+ # cannot, with a top-level-only snapshot). python3 does the set
84
+ # arithmetic and regex (names may contain spaces).
85
+ FINDINGS=$(ACCOUNT_DIR="$ACCOUNT_DIR" SNAP="$SNAP" ALLOWED="$ALLOWED" python3 -c '
86
+ import os, sys, re
87
+ acct = os.environ["ACCOUNT_DIR"]
88
+ try:
89
+ now = set(os.listdir(acct))
90
+ except Exception:
91
+ sys.exit(1)
92
+ with open(os.environ["SNAP"]) as f:
93
+ before = set(l for l in f.read().split("\n") if l != "")
94
+ allowed = set(l.strip() for l in os.environ["ALLOWED"].split("\n") if l.strip())
95
+
96
+ out = []
97
+ for name in sorted((now - before) - allowed):
98
+ out.append("top-level\t" + name)
99
+
100
+ folder_re = re.compile(r"^[a-z0-9-]+\Z")
101
+ file_re = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*\Z")
102
+ for bucket in ("projects", "contacts", "documents"):
103
+ if bucket in before or bucket not in now:
104
+ continue # pre-existing bucket, or not created this command
105
+ bpath = os.path.join(acct, bucket)
106
+ try:
107
+ children = os.listdir(bpath)
108
+ except Exception:
109
+ continue
110
+ for child in sorted(children):
111
+ if child.startswith("."):
112
+ continue # dot-prefixed entries are skipped at every level
113
+ rx = folder_re if os.path.isdir(os.path.join(bpath, child)) else file_re
114
+ if not rx.match(child):
115
+ out.append("bad-name\t" + child)
116
+
117
+ sys.stdout.write("\n".join(out))
118
+ ' 2>/dev/null) || exit 0
119
+
120
+ [ -z "$FINDINGS" ] && exit 0
121
+
122
+ # One log line per finding, then a schema-citing feedback block per reason seen.
123
+ # exit 2 feeds stderr back to the agent.
124
+ TOPLEVEL=""
125
+ BADNAME=""
126
+ {
127
+ while IFS=$'\t' read -r reason name; do
128
+ [ -z "$reason" ] && continue
129
+ case "$reason" in
130
+ top-level) echo "[fs-guard-bash] stray path=$name reason=top-level"; TOPLEVEL="$TOPLEVEL $name" ;;
131
+ bad-name) echo "[fs-guard-bash] stray path=$name reason=bad-name"; BADNAME="$BADNAME $name" ;;
132
+ esac
133
+ done <<< "$FINDINGS"
134
+ if [ -n "$TOPLEVEL" ]; then
135
+ echo "Blocked: this Bash command created top-level entries that are not allowed in this account's data directory:$TOPLEVEL. The layout is fixed by SCHEMA.md — only the entries listed there are permitted at the top level. Relocate this content under projects/, contacts/, or documents/, and route any reorganisation through the data-manager specialist."
136
+ fi
137
+ if [ -n "$BADNAME" ]; then
138
+ echo "Blocked: this Bash command created names that break the account's naming rule:$BADNAME. Folder and file names under projects/, contacts/, and documents/ use lowercase letters, digits, and hyphens only, with a lowercase file extension. Rename them, or route a reorganisation through the data-manager specialist."
139
+ fi
140
+ } >&2
141
+ exit 2
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # fs-schema-guard-bash-pre — PreToolUse Bash account-root snapshot.
3
+ #
4
+ # The Write/Edit/NotebookEdit path guard (fs-schema-guard.sh) does not see Bash:
5
+ # a folder created through mkdir/mv/cp/redirect bypasses it entirely. This hook
6
+ # is half of the Bash-channel net: it records the account root's immediate
7
+ # top-level entry names BEFORE the Bash command runs, so the PostToolUse twin
8
+ # (fs-schema-guard-bash-post.sh) can diff and flag only the strays THIS command
9
+ # newly created — never the pre-existing legacy backlog.
10
+ #
11
+ # The hook inspects account-root STATE, never the command string (command-string
12
+ # parsing was rejected as brittle and evadable). $PWD is the account dir.
13
+ #
14
+ # Fail open on every error (no session id, no python3, unlistable dir, unwritable
15
+ # tmp): a snapshot that cannot be taken must not block the command. exit 0 always
16
+ # — a PreToolUse snapshot never blocks; blocking is the post hook's job.
17
+
18
+ set -uo pipefail
19
+
20
+ # No stdin envelope (tty) — nothing to snapshot. Fail open.
21
+ [ -t 0 ] && exit 0
22
+ INPUT=$(cat)
23
+ [ -z "$INPUT" ] && exit 0
24
+
25
+ field() {
26
+ printf '%s' "$INPUT" | python3 -c "
27
+ import sys, json
28
+ try:
29
+ d = json.load(sys.stdin)
30
+ print(d.get('$1', '') or '')
31
+ except Exception:
32
+ print('')
33
+ " 2>/dev/null || echo ""
34
+ }
35
+
36
+ TOOL_NAME=$(field tool_name)
37
+ [ "$TOOL_NAME" = "Bash" ] || exit 0
38
+
39
+ SESSION_ID=$(field session_id)
40
+ [ -z "$SESSION_ID" ] && exit 0
41
+
42
+ # Resolve the tmpdir the same way the manager's Node process does (libuv
43
+ # uv_os_tmpdir): TMPDIR, then TMP, then TEMP, then /tmp; strip a trailing slash.
44
+ # FS_GUARD_BASH_TMPDIR overrides for tests only — never set in production.
45
+ if [ -n "${FS_GUARD_BASH_TMPDIR:-}" ]; then
46
+ TMP_DIR="$FS_GUARD_BASH_TMPDIR"
47
+ else
48
+ TMP_DIR="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"
49
+ TMP_DIR="${TMP_DIR%/}"
50
+ fi
51
+ SID_SAN=$(printf '%s' "$SESSION_ID" | sed 's/[^A-Za-z0-9_-]/_/g')
52
+ SNAP="$TMP_DIR/maxy-fsguard-snapshot-${SID_SAN}.txt"
53
+
54
+ # List the account root's immediate top-level entry names, one per line. python3
55
+ # os.listdir gives names only (no recursion). Any failure -> fail open.
56
+ ACCOUNT_DIR="$PWD" python3 -c '
57
+ import os, sys
58
+ acct = os.environ["ACCOUNT_DIR"]
59
+ try:
60
+ names = os.listdir(acct)
61
+ except Exception:
62
+ sys.exit(1)
63
+ sys.stdout.write("\n".join(names))
64
+ ' > "$SNAP" 2>/dev/null || { rm -f "$SNAP" 2>/dev/null; exit 0; }
65
+
66
+ exit 0
@@ -14,7 +14,7 @@
14
14
  #
15
15
  # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail closed when
16
16
  # the tool call cannot be inspected (tty or empty stdin). Every block logs
17
- # [fs-guard] blocked path=<rel> reason=<top-level|over-deep>
17
+ # [fs-guard] blocked path=<rel> reason=<top-level|over-deep|bad-name>
18
18
  # No task numbers / internal refs in any operator-visible string.
19
19
 
20
20
  set -uo pipefail
@@ -105,4 +105,36 @@ case "$SEG0" in
105
105
  ;;
106
106
  esac
107
107
 
108
+ # Name-convention check for the operator-data buckets, after top-level and depth
109
+ # pass. The operator-authored segments carry a character rule: folders are
110
+ # [a-z0-9-]; the write target (last segment, always a file) is lowercase
111
+ # dot-segments so a lowercase extension is allowed. The bucket segment (top-level
112
+ # rule) and dot-prefixed entries (skipped at every level system-wide) are exempt.
113
+ # Space-/case-/underscore-bearing names break shell paths, data-portal URL
114
+ # minting, and case-sensitive matching.
115
+ case "$SEG0" in
116
+ projects|contacts|documents)
117
+ BADSEG=$(REL="$REL" python3 -c '
118
+ import os, re
119
+ segs = os.environ["REL"].split("/")
120
+ folder_re = re.compile(r"^[a-z0-9-]+\Z")
121
+ file_re = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*\Z")
122
+ bad = ""
123
+ for i, s in enumerate(segs):
124
+ if i == 0 or s.startswith("."):
125
+ continue # bucket segment (top-level rule), or a dot-prefixed entry
126
+ ok = file_re.match(s) if i == len(segs) - 1 else folder_re.match(s)
127
+ if not ok:
128
+ bad = s
129
+ break
130
+ print(bad)
131
+ ' 2>/dev/null || echo "")
132
+ if [ -n "$BADSEG" ]; then
133
+ echo "[fs-guard] blocked path=$REL reason=bad-name" >&2
134
+ echo "Blocked: '$BADSEG' is not a valid name in this account's data directory. Folder and file names under projects/, contacts/, and documents/ use lowercase letters, digits, and hyphens only, with a lowercase file extension. Rename it to fit, or route a reorganisation through the data-manager specialist." >&2
135
+ exit 2
136
+ fi
137
+ ;;
138
+ esac
139
+
108
140
  exit 0
@@ -145,7 +145,7 @@ Emit `[professional-document] render → a4-print-documents`.
145
145
 
146
146
  ### 8. Ask the operator for the output path
147
147
 
148
- Before saving the PDF, ask the operator where they want it saved. The operator names the path (file name and directory). Do not invent the path. The path the operator names is where the PDF is written and what the observability line records.
148
+ Before saving the PDF, ask the operator where they want it saved. When the document is a deliverable for a known Job (its scope of works, budget estimate, warranty, valuation, or completion certificate), propose `jobs/<jobNumber>/<file>` as the default path, where `<jobNumber>` is the job's canonical numeric id, the same key the quote path uses at `quoting/jobs/<jobId>/`. If no `jobs/<jobNumber>/` folder exists yet, it is created; `jobs/` is an ontology entity bucket, so the deliverable sits directly under the job's folder, not in `output/`. For a document tied to no Job record, ask without proposing a default. The operator can always confirm the default or name a different path, and the path the operator confirms is where the PDF is written and what the observability line records. Do not write to a path the operator has not confirmed.
149
149
 
150
150
  ### 9. Save the PDF
151
151
 
@@ -155,13 +155,13 @@ Emit `[professional-document] pdf-saved path=<operator-named-path> bytes=<n>`.
155
155
 
156
156
  ### 10. Deliver
157
157
 
158
- The PDF you saved is the deliverable. How you hand it over depends on the surface this turn arrived on. On a message channel (web chat, WhatsApp, Telegram), call `SendUserFile` with the saved path and the platform forwards the PDF to that conversation as a real attachment. On the admin dashboard, a path under `accounts/<accountId>/output/` appears in the Artefacts panel (downloadable) and is reachable on the file share. Either way, state the complete path in your reply, never a vague pointer. See the `file-presentation` skill for the wording.
158
+ The PDF you saved is the deliverable. How you hand it over depends on the surface this turn arrived on. On a message channel (web chat, WhatsApp, Telegram), call `SendUserFile` with the saved path and the platform forwards the PDF to that conversation as a real attachment. On the admin dashboard, any saved path under `accounts/<accountId>/` (a job's `jobs/<jobNumber>/` folder, `output/`, or elsewhere in the account tree) appears in the Artefacts panel (downloadable) and is reachable on the file share. Either way, state the complete path in your reply, never a vague pointer. See the `file-presentation` skill for the wording.
159
159
 
160
160
  ## Doctrine
161
161
 
162
162
  - **Compose, do not duplicate.** `a4-print-documents` owns the print-CSS rules; this skill loads it and follows it. `prose-craft` owns sentence-level review; this skill invokes it when present. `memory-search` owns graph reads; this skill calls it for grounding. The seam is "this skill is the workflow; the loaded skills carry the rules."
163
163
  - **Refuse before composing.** Every brief runs through the refusal taxonomy first. A skill that drafts something it should have refused is a routing defect, not a quality issue.
164
- - **Operator-named output path.** The skill never invents the path. The operator names where the PDF lands. This matches how `investor-data-room` and the property composers work.
164
+ - **Operator-named output path.** For a deliverable tied to a known Job, the skill proposes that job's `jobs/<jobNumber>/` entity folder as the default; for anything else it proposes no path. The operator confirms the default or names a different path, and the skill never writes to a path the operator has not confirmed. This matches how `investor-data-room` and the property composers work.
165
165
  - **Approve the outline before drafting.** Long documents whose structure is wrong cost more to re-draft than they cost to re-outline. The approval turn is cheap; the wasted draft is expensive.
166
166
  - **No invented facts.** Every quantitative or named claim grounds in `memory-search` or a `research-assistant` brief-back. If the graph does not carry the fact and the operator has not asked for research, draft a one-line gap statement in place ("[GAP: <description>]") and surface it in the outline-approval turn.
167
167
  - **No live web research from this skill.** If a fact is missing, ask the admin agent to dispatch `research-assistant`. This skill does not call `WebFetch` or `WebSearch` directly.
@@ -77,6 +77,8 @@ The portal shows a sub-account's deliverable folders alongside what the client u
77
77
 
78
78
  Opening a file reaches the install: the portal probes it, then mints an `/api/portal/fetch` link signed with the account's `PORTAL_FETCH_SECRET` and valid for five minutes. The install verifies the signature **and independently re-derives the exposed folder set**, so a correctly signed link for a non-exposed path is still refused. That second check is the security boundary, not the signature.
79
79
 
80
+ Exposure is narrowed twice. The install re-derives the account's exposed set from `SCHEMA.md` on every request and refuses anything outside it, which is the account-isolation boundary. On top of that, a person's `people.folders` grant (written at enrolment, empty means the full set) narrows them to named top-level folders inside that set. The grant lives only in the portal's D1, so a portal compromise widens a person back to the account's exposed set and never beyond it. `op=list` carries `granted=<n|full>` and an out-of-grant download logs `op=fetch-through result=not-granted`, distinct from `result=absent`.
81
+
80
82
  What to watch, on the device:
81
83
 
82
84
  ```
@@ -71,6 +71,26 @@ if (!/^[A-Za-z0-9-]+$/.test(/** @type {string} */ (accountId))) {
71
71
  die('--account must match ^[A-Za-z0-9-]+$ — an account id, not a path')
72
72
  }
73
73
 
74
+ // Optional per-person folder allowlist: comma-separated top-level names that
75
+ // narrow what this person sees below the account's exposed set. Absent means
76
+ // the full set. Each name is validated to the dir-segment charset, so a grant
77
+ // can never carry a path separator, a comma inside a name, or a SQL quote: the
78
+ // value goes into the statement as a plain comma-joined literal.
79
+ const foldersArg = arg('--folders')
80
+ let folders = ''
81
+ if (foldersArg !== undefined) {
82
+ const names = foldersArg
83
+ .split(',')
84
+ .map((s) => s.trim())
85
+ .filter(Boolean)
86
+ for (const n of names) {
87
+ if (!/^[A-Za-z0-9_-]+$/.test(n)) {
88
+ die(`--folders entry ${JSON.stringify(n)} must match ^[A-Za-z0-9_-]+$ — a folder name, not a path`)
89
+ }
90
+ }
91
+ folders = [...new Set(names)].join(',')
92
+ }
93
+
74
94
  const saltBytes = new Uint8Array(16)
75
95
  crypto.getRandomValues(saltBytes)
76
96
  const saltHex = randomSaltHex(saltBytes)
@@ -98,9 +118,10 @@ const createdAt = new Date().toISOString()
98
118
  //
99
119
  // `accountId` IS in the DO UPDATE: re-enrolling someone against a different
100
120
  // sub-account has to move them, or they keep reading folders they no longer
101
- // belong to.
121
+ // belong to. `folders` IS too, for the same reason: re-enrolment replaces the
122
+ // grant, so a narrower or wider set on re-run takes effect rather than sticking.
102
123
  console.log(
103
- `INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId) VALUES ('${ownerId}', '${sqlName}', '${saltHex}', '${hash}', '${createdAt}', '${accountId}') ON CONFLICT(ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, accountId=excluded.accountId;`,
124
+ `INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId, folders) VALUES ('${ownerId}', '${sqlName}', '${saltHex}', '${hash}', '${createdAt}', '${accountId}', '${folders}') ON CONFLICT(ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, accountId=excluded.accountId, folders=excluded.folders;`,
104
125
  )
105
126
 
106
127
  // stderr: everything for the human. Never stdout — see the note at the top.
@@ -67,7 +67,7 @@ describe('sessions', () => {
67
67
  it('mints a session resolvable to its owner', async () => {
68
68
  const { db } = fakeDb()
69
69
  await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
70
- expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice', accountId: '' })
70
+ expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice', accountId: '', folders: [] })
71
71
  })
72
72
 
73
73
  it('does not resolve an expired session', async () => {
@@ -97,7 +97,7 @@ describe('session revocation — the property the design claims', () => {
97
97
  // by construction, not by remembering to delete anything.
98
98
  const { db, peopleRows } = fakeDb()
99
99
  await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
100
- expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice', accountId: '' })
100
+ expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice', accountId: '', folders: [] })
101
101
 
102
102
  peopleRows[0].hash = 'HASH-v2-rotated0' // operator re-runs enrolment
103
103
 
@@ -9,7 +9,9 @@ const DIRECTORY = [
9
9
  { accountId: 'acc-b', relPath: 'output/secret.pdf', sizeBytes: 40, modifiedAt: '2026-07-20T13:00:00.000Z' },
10
10
  ]
11
11
 
12
- function makeEnv(sessions: Record<string, { ownerId: string; accountId: string }>) {
12
+ function makeEnv(
13
+ sessions: Record<string, { ownerId: string; accountId: string; folders?: string }>,
14
+ ) {
13
15
  return {
14
16
  DB: {
15
17
  prepare(query: string) {
@@ -39,7 +41,7 @@ function makeEnv(sessions: Record<string, { ownerId: string; accountId: string }
39
41
  } as unknown as PortalEnv
40
42
  }
41
43
 
42
- const SESSIONS = { 'sess-a': { ownerId: 'alice', accountId: 'acc-a' } }
44
+ const SESSIONS = { 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: '' } }
43
45
  type Entry = { name: string; kind: string; relPath: string; sizeBytes: number | null; modifiedAt: string | null }
44
46
 
45
47
  describe('entriesUnder', () => {
@@ -117,4 +119,49 @@ describe('processFiles directory listing', () => {
117
119
  expect(r.payload).toHaveProperty('files')
118
120
  expect(r.payload).toHaveProperty('entries')
119
121
  })
122
+
123
+ it('shows only granted top-level folders', async () => {
124
+ const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'quotes' } })
125
+ const r = await processFiles('sess-a', env, () => {}, 0, '')
126
+ expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['quotes'])
127
+ })
128
+
129
+ it('an empty grant shows the full set', async () => {
130
+ const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: '' } })
131
+ const r = await processFiles('sess-a', env, () => {}, 0, '')
132
+ expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['output', 'quotes'])
133
+ })
134
+
135
+ it('a grant naming an absent folder shows nothing', async () => {
136
+ const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'jobs' } })
137
+ const r = await processFiles('sess-a', env, () => {}, 0, '')
138
+ expect(r.payload.entries).toEqual([])
139
+ })
140
+
141
+ it('the grant holds when browsing into a granted folder', async () => {
142
+ const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'quotes' } })
143
+ const r = await processFiles('sess-a', env, () => {}, 0, 'quotes')
144
+ expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['CR2969', 'CR2974'])
145
+ })
146
+
147
+ it('logs granted=1 for a one-folder grant and granted=full for an empty grant', async () => {
148
+ const lines: string[] = []
149
+ await processFiles(
150
+ 'sess-a',
151
+ makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'quotes' } }),
152
+ (l) => lines.push(l),
153
+ 0,
154
+ '',
155
+ )
156
+ expect(lines.some((l) => l.includes('granted=1'))).toBe(true)
157
+ const full: string[] = []
158
+ await processFiles(
159
+ 'sess-a',
160
+ makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: '' } }),
161
+ (l) => full.push(l),
162
+ 0,
163
+ '',
164
+ )
165
+ expect(full.some((l) => l.includes('granted=full'))).toBe(true)
166
+ })
120
167
  })
@@ -31,7 +31,7 @@ function parse(r: { stdout: string; stderr: string }) {
31
31
  }
32
32
 
33
33
  const valuesOf = (sql: string) =>
34
- /VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)', '([^']*)'\)/.exec(
34
+ /VALUES \('([^']*)', '((?:[^']|'')*)', '([0-9a-f]+)', '([0-9a-f]+)', '([^']*)', '([^']*)', '([^']*)'\)/.exec(
35
35
  sql,
36
36
  )!
37
37
 
@@ -127,14 +127,17 @@ describe('portal-enrol', () => {
127
127
  it('writes the row the schema declares, in order', async () => {
128
128
  const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
129
129
  const { sql } = parse(r)
130
- expect(sql).toContain('INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId)')
131
- const [, owner, name, salt, hash, createdAt, accountId] = valuesOf(sql!)
130
+ expect(sql).toContain(
131
+ 'INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId, folders)',
132
+ )
133
+ const [, owner, name, salt, hash, createdAt, accountId, folders] = valuesOf(sql!)
132
134
  expect(owner).toBe('alice')
133
135
  expect(name).toBe('Alice')
134
136
  expect(salt).toMatch(/^[0-9a-f]{32}$/)
135
137
  expect(hash).toMatch(/^[0-9a-f]{64}$/)
136
138
  expect(Number.isNaN(Date.parse(createdAt))).toBe(false)
137
139
  expect(accountId).toBe('acc-1')
140
+ expect(folders).toBe('')
138
141
  })
139
142
 
140
143
  it('refuses enrolment with no --account', async () => {
@@ -250,6 +253,57 @@ describe('portal-enrol', () => {
250
253
  expect(await db.query("SELECT relPath FROM directory WHERE accountId='a';")).toBe('output/x.pdf')
251
254
  })
252
255
 
256
+ it('lands a folders grant', async () => {
257
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'quotes,output'])
258
+ const [, , , , , , , folders] = valuesOf(parse(r).sql!)
259
+ expect(folders).toBe('quotes,output')
260
+ })
261
+
262
+ it('defaults folders to empty when the flag is absent', async () => {
263
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
264
+ const [, , , , , , , folders] = valuesOf(parse(r).sql!)
265
+ expect(folders).toBe('')
266
+ })
267
+
268
+ it('dedupes repeated folder names', async () => {
269
+ const r = await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'quotes,quotes,output'])
270
+ const [, , , , , , , folders] = valuesOf(parse(r).sql!)
271
+ expect(folders).toBe('quotes,output')
272
+ })
273
+
274
+ it('rejects a folder name outside the segment charset', async () => {
275
+ await expect(
276
+ enrol(['--owner', 'alice', '--name', 'Alice', '--folders', '../etc']),
277
+ ).rejects.toThrow()
278
+ await expect(
279
+ enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'a/b']),
280
+ ).rejects.toThrow()
281
+ await expect(
282
+ enrol(['--owner', 'alice', '--name', 'Alice', '--folders', "a'b"]),
283
+ ).rejects.toThrow()
284
+ })
285
+
286
+ it('re-enrolment replaces the grant', async () => {
287
+ const db = await d1('enrol-folders-replace-')
288
+ await db.exec(
289
+ parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'quotes'])).sql!,
290
+ )
291
+ expect(await db.query("SELECT folders FROM people WHERE ownerId='alice';")).toBe('quotes')
292
+ await db.exec(
293
+ parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'output'])).sql!,
294
+ )
295
+ expect(await db.query("SELECT folders FROM people WHERE ownerId='alice';")).toBe('output')
296
+ expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('1')
297
+ })
298
+
299
+ it('declares people.folders defaulting to empty', async () => {
300
+ const db = await d1('enrol-folders-col-')
301
+ await db.exec(
302
+ "INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId) VALUES ('alice','Alice','aa','bb','t','acc-a');",
303
+ )
304
+ expect(await db.query("SELECT folders FROM people WHERE ownerId='alice';")).toBe('')
305
+ })
306
+
253
307
  it('stores a crafted name as data rather than executing it', async () => {
254
308
  const db = await d1('enrol-inject-')
255
309
  const r = await enrol(['--owner', 'alice', '--name', "x'); DROP TABLE people; --"])
@@ -5,9 +5,16 @@ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/
5
5
  const PATH = 'quotes/CR2969/quote.pdf'
6
6
 
7
7
  function makeEnv(
8
- opts: { accountId?: string; secret?: string; origin?: string; paths?: string[] } = {},
8
+ opts: {
9
+ accountId?: string
10
+ secret?: string
11
+ origin?: string
12
+ paths?: string[]
13
+ folders?: string
14
+ } = {},
9
15
  ) {
10
16
  const { accountId = 'acc-a', paths = [PATH] } = opts
17
+ const folders = 'folders' in opts ? opts.folders : ''
11
18
  return {
12
19
  DB: {
13
20
  prepare(query: string) {
@@ -25,7 +32,9 @@ function makeEnv(
25
32
  },
26
33
  async first<T>() {
27
34
  if (/FROM sessions/i.test(query)) {
28
- return (bound[0] === 'sess-a' ? { ownerId: 'alice', accountId } : null) as T | null
35
+ return (bound[0] === 'sess-a'
36
+ ? { ownerId: 'alice', accountId, folders }
37
+ : null) as T | null
29
38
  }
30
39
  if (/FROM directory/i.test(query)) {
31
40
  return (paths.includes(bound[1] as string) ? { relPath: bound[1] } : null) as T | null
@@ -128,4 +137,37 @@ describe('processIndexedDownload', () => {
128
137
  await processIndexedDownload('sess-a', PATH, makeEnv(), (l) => lines.push(l), 1000, okHead as never)
129
138
  expect(lines.some((l) => l.includes('op=fetch-through') && l.includes('result=ok'))).toBe(true)
130
139
  })
140
+
141
+ it('refuses a path outside the grant with 404 and no device probe', async () => {
142
+ let touched = false
143
+ const spy = async () => {
144
+ touched = true
145
+ return { ok: true, status: 200 }
146
+ }
147
+ const lines: string[] = []
148
+ const env = makeEnv({ folders: 'quotes' })
149
+ const r = await processIndexedDownload(
150
+ 'sess-a',
151
+ 'documents/x.pdf',
152
+ env,
153
+ (l) => lines.push(l),
154
+ 1000,
155
+ spy as never,
156
+ )
157
+ expect(r.status).toBe(404)
158
+ expect(touched).toBe(false)
159
+ expect(lines.some((l) => l.includes('result=not-granted'))).toBe(true)
160
+ })
161
+
162
+ it('allows an in-grant path', async () => {
163
+ const env = makeEnv({ folders: 'quotes' })
164
+ const r = await processIndexedDownload('sess-a', PATH, env, () => {}, 1000, okHead as never)
165
+ expect(r.status).toBe(200)
166
+ })
167
+
168
+ it('an empty grant allows any indexed path', async () => {
169
+ const env = makeEnv({ folders: '' })
170
+ const r = await processIndexedDownload('sess-a', PATH, env, () => {}, 1000, okHead as never)
171
+ expect(r.status).toBe(200)
172
+ })
131
173
  })
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ grantAllows,
4
+ resolveSession,
5
+ } from '../../skills/data-portal/template/functions/api/_lib/session'
6
+ import type { D1Database } from '../../skills/data-portal/template/functions/api/_lib/types'
7
+
8
+ function db(row: Record<string, unknown> | null) {
9
+ return {
10
+ prepare() {
11
+ const stmt = {
12
+ bind() {
13
+ return stmt
14
+ },
15
+ async first<T>() {
16
+ return row as T | null
17
+ },
18
+ async run() {
19
+ return { meta: { changes: 0 } }
20
+ },
21
+ async all<T>() {
22
+ return { results: [] as T[] }
23
+ },
24
+ }
25
+ return stmt
26
+ },
27
+ } as unknown as D1Database
28
+ }
29
+
30
+ describe('resolveSession folders', () => {
31
+ it('splits a comma grant into an array', async () => {
32
+ const s = await resolveSession(
33
+ db({ ownerId: 'alice', accountId: 'acc-a', folders: 'quotes,output' }),
34
+ 'sess',
35
+ 1,
36
+ )
37
+ expect(s?.folders).toEqual(['quotes', 'output'])
38
+ })
39
+
40
+ it('returns an empty array for an empty grant', async () => {
41
+ const s = await resolveSession(db({ ownerId: 'alice', accountId: 'acc-a', folders: '' }), 'sess', 1)
42
+ expect(s?.folders).toEqual([])
43
+ })
44
+
45
+ it('returns an empty array when the column is absent', async () => {
46
+ const s = await resolveSession(db({ ownerId: 'alice', accountId: 'acc-a' }), 'sess', 1)
47
+ expect(s?.folders).toEqual([])
48
+ })
49
+ })
50
+
51
+ describe('grantAllows', () => {
52
+ it('admits everything when the grant is empty', () => {
53
+ expect(grantAllows([], 'documents/secret.pdf')).toBe(true)
54
+ })
55
+
56
+ it('admits a path whose top segment is granted', () => {
57
+ expect(grantAllows(['quotes'], 'quotes/CR2969/quote.pdf')).toBe(true)
58
+ })
59
+
60
+ it('refuses a path whose top segment is not granted', () => {
61
+ expect(grantAllows(['quotes'], 'documents/secret.pdf')).toBe(false)
62
+ })
63
+
64
+ it('matches the whole segment, so `quote` does not admit `quotes`', () => {
65
+ expect(grantAllows(['quote'], 'quotes/x.pdf')).toBe(false)
66
+ })
67
+ })