@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.
Files changed (105) 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 +5 -3
  18. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +23 -2
  19. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +30 -8
  20. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +22 -2
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +49 -2
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +57 -3
  24. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +23 -0
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +44 -2
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +67 -0
  27. package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +43 -0
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +54 -5
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +10 -1
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +19 -5
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +19 -2
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +8 -2
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +28 -5
  34. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +195 -6
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +193 -10
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +4 -3
  37. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  38. package/payload/platform/plugins/email/PLUGIN.md +5 -1
  39. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts +2 -0
  40. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts.map +1 -0
  41. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js +50 -0
  42. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js.map +1 -0
  43. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts +2 -0
  44. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js +51 -0
  46. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js.map +1 -0
  47. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts +2 -0
  48. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts.map +1 -0
  49. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js +89 -0
  50. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js.map +1 -0
  51. package/payload/platform/plugins/email/mcp/dist/index.js +28 -0
  52. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  53. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts +22 -0
  54. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts.map +1 -0
  55. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js +55 -0
  56. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js.map +1 -0
  57. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +32 -0
  58. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  59. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +75 -1
  60. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  61. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts +19 -0
  62. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts.map +1 -0
  63. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js +49 -0
  64. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js.map +1 -0
  65. package/payload/platform/plugins/email/references/email-reference.md +7 -1
  66. package/payload/platform/plugins/memory/references/schema-construction.md +18 -12
  67. package/payload/platform/plugins/memory/references/schema-estate-agent.md +11 -6
  68. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.d.ts +2 -0
  69. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.d.ts.map +1 -0
  70. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.js +12 -0
  71. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.js.map +1 -0
  72. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.d.ts +8 -0
  73. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.d.ts.map +1 -0
  74. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.js +12 -0
  75. package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.js.map +1 -0
  76. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js +12 -2
  77. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js.map +1 -1
  78. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +75 -6
  79. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +111 -22
  80. package/payload/platform/scripts/lib/provision-account-dir.sh +8 -1
  81. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +39 -0
  82. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -0
  83. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +315 -0
  84. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -0
  85. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  86. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  87. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  88. package/payload/platform/services/claude-session-manager/dist/index.js +15 -0
  89. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  90. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  91. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +4 -0
  92. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  93. package/payload/platform/templates/account-schema/SCHEMA.md +17 -0
  94. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  95. package/payload/server/{chunk-XGNRSM57.js → chunk-HN4HFTXE.js} +6 -0
  96. package/payload/server/maxy-edge.js +1 -1
  97. package/payload/server/public/assets/{admin-rScqRq-R.js → admin-BoVCP4Mw.js} +1 -1
  98. package/payload/server/public/assets/{data-TOWuhMFb.js → data-CtOxa94P.js} +1 -1
  99. package/payload/server/public/assets/{graph-labels-Axa04w8A.js → graph-labels-C8-sIKuv.js} +1 -1
  100. package/payload/server/public/assets/{graph-BEGs_P2m.js → graph-mJ9ky5dc.js} +1 -1
  101. package/payload/server/public/assets/{page-6CZ8R57f.js → page-Cv0qUwMG.js} +1 -1
  102. package/payload/server/public/data.html +3 -3
  103. package/payload/server/public/graph.html +2 -2
  104. package/payload/server/public/index.html +3 -3
  105. package/payload/server/server.js +231 -103
@@ -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.
@@ -71,16 +71,18 @@ The setup-done claim only fires when `curl -I https://<hostname>` issued from ou
71
71
 
72
72
  ## Data-portal folder index
73
73
 
74
- The portal shows a sub-account's deliverable folders alongside what the client uploaded. Which folders those are is never a list in the code: it is derived per account from that account's own `SCHEMA.md` — the buckets in its `<!-- ontology-buckets -->` region plus `output`, intersected with its `allowed-top-level` block. A brand on a different vertical exposes different folders with no code change. `bin/schema-exposed-dirs.mjs` is the only place that decision is made.
74
+ The portal shows a sub-account's deliverable folders alongside what the client uploaded. Which folders those are is never a list in the code: it is derived per account from that account's own `SCHEMA.md` — the buckets in its `<!-- ontology-buckets -->` region plus `output`, intersected with its `allowed-top-level` block. A brand on a different vertical exposes different folders with no code change. `bin/schema-exposed-dirs.mjs` is the only place that decision is made. An operator can add non-ontology folders (a workflow folder such as `inbound-invoices`) by listing them in `exposeFolders` in the account's `data-portal.json`; each must match `[A-Za-z0-9_-]+` and is never a system folder (`agents`, `secrets`, `specialists`, `state`, `memory`, `cache`, `logs`, `tmp`) or dot-prefixed. Those extras union on top of the schema-derived set and, unlike ontology buckets, are not intersected with `allowed-top-level`; empty or absent behaves as before.
75
75
 
76
76
  `bin/portal-index-push.mjs` walks those folders and full-replaces the account's rows in the portal's D1 `directory` table each cycle. Metadata only — names, sizes, times — so listing survives the device being offline, and a deleted or renamed file converges on the next cycle without a tombstone. The scheduling heartbeat runs it hourly for every account carrying a `data-portal.json`; it is also runnable standalone, which is how it stays operable while the heartbeat spawn path is unproven on a device.
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
  ```
83
- [portal-index] op=resolve account=<id> schemaPresent=<bool> allowed=<n> domainBuckets=<dirs|none> exposed=<dirs|none>
85
+ [portal-index] op=resolve account=<id> schemaPresent=<bool> allowed=<n> domainBuckets=<dirs|none> extraFolders=<n> exposed=<dirs|none>
84
86
  [portal-index] op=replace account=<id> deleted=all inserted=<n>
85
87
  [portal-index] op=verify account=<id> rowsAfter=<n> expected=<n>
86
88
  [portal-index] op=collision account=<id> dir=<dir> claimedBy=plugin-owned
@@ -88,7 +90,7 @@ What to watch, on the device:
88
90
  [portal-index] op=audit accounts=<n> stale=<n> never-ran=<n>
89
91
  ```
90
92
 
91
- `exposed=none` where deliverables exist means the schema was missing or carried no ontology region — not that the walk found nothing. `rowsAfter` disagreeing with `expected` is a half-applied replace. An `op=collision` line explains a folder that has silently vanished from the client's view, which nothing else would report. `op=exposed-changed` fires only on a delta, so it is rare and always worth reading; a `removed=` naming a populated bucket is the client-visible regression.
93
+ `exposed=none` where deliverables exist means the schema was missing or carried no ontology region — not that the walk found nothing. `rowsAfter` disagreeing with `expected` is a half-applied replace. An `op=collision` line explains a folder that has silently vanished from the client's view, which nothing else would report. `op=exposed-changed` fires only on a delta, so it is rare and always worth reading; a `removed=` naming a populated bucket is the client-visible regression. `extraFolders=<n>` counts the folders the operator requested in `exposeFolders`; a non-zero `extraFolders` with none of those names in `exposed=` means the guard dropped every one.
92
94
 
93
95
  `op=audit` is the standing check, and it is the only one that speaks when the push does **not**. It runs in-process inside the heartbeat rather than as a spawn, and reads only local state, so it cannot be blinded by the same failure it is watching for. It fires hourly even when everything is healthy — an audit that goes quiet on success is indistinguishable from an audit that stopped running. `result=never-ran` on an account with a `data-portal.json` means no cycle has ever succeeded for it; `result=stale` with `ageMin` means cycles have stopped. Its input is written only after `op=verify`, so a stale entry means the push stopped succeeding rather than merely stopped logging.
94
96
 
@@ -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.
@@ -42,6 +42,20 @@ function readEnvFile(path) {
42
42
  return out
43
43
  }
44
44
 
45
+ /** Operator-named extra folders for an account, from its data-portal.json.
46
+ * Absent file, absent key, or a non-array value all yield []. The resolver
47
+ * applies the charset-and-system guard; this reader does not pre-filter, so the
48
+ * `extraFolders=<n>` count reflects what the operator asked for.
49
+ * @param {string} accountDir @returns {Promise<string[]>} */
50
+ export async function readExposeFolders(accountDir) {
51
+ try {
52
+ const cfg = JSON.parse(await readFile(join(accountDir, 'data-portal.json'), 'utf8'))
53
+ return Array.isArray(cfg?.exposeFolders) ? cfg.exposeFolders.filter((f) => typeof f === 'string') : []
54
+ } catch {
55
+ return []
56
+ }
57
+ }
58
+
45
59
  /**
46
60
  * Files under `exposed` dirs, at any depth. Dot-prefixed entries are skipped at
47
61
  * every level: `.uploads-tmp/` holds in-flight upload partials (files.ts:25) and
@@ -85,7 +99,7 @@ export async function walkExposed(accountDir, exposed) {
85
99
  * @param {{ accountDir: string, accountId: string, dbName: string,
86
100
  * client: { query: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]> },
87
101
  * log: (line: string) => void, nowIso: string,
88
- * previousExposed?: string[] }} opts
102
+ * exposeFolders?: string[], previousExposed?: string[] }} opts
89
103
  * @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean }>}
90
104
  */
91
105
  export async function pushAccount(opts) {
@@ -98,10 +112,15 @@ export async function pushAccount(opts) {
98
112
  schemaText = null
99
113
  }
100
114
 
101
- const r = resolveExposedDirs(schemaText)
115
+ const exposeFolders = Array.isArray(opts.exposeFolders) ? opts.exposeFolders : []
116
+ const r = resolveExposedDirs(schemaText, exposeFolders)
117
+ // extraFolders is the REQUESTED count, not the accepted count: a non-zero
118
+ // extraFolders with none of those names in exposed= means the guard dropped
119
+ // every one, which is distinct from none configured (extraFolders=0).
102
120
  log(
103
121
  `${TAG} op=resolve account=${accountId} schemaPresent=${r.schemaPresent} allowed=${r.allowed.length} ` +
104
- `domainBuckets=${r.domainBuckets.join(',') || 'none'} exposed=${r.exposed.join(',') || 'none'}`,
122
+ `domainBuckets=${r.domainBuckets.join(',') || 'none'} extraFolders=${exposeFolders.length} ` +
123
+ `exposed=${r.exposed.join(',') || 'none'}`,
105
124
  )
106
125
  // The generator gives a plugin-claimed dir to the plugin-owned region only
107
126
  // (account-schema-owned-dirs.py:287), so this is normally silent. When it is
@@ -212,10 +231,10 @@ function arg(flag) {
212
231
  * Every account carrying a data-portal.json has a portal. The file's presence is
213
232
  * the enrolment signal — there is no separate flag to keep in sync with it.
214
233
  * @param {string} accountsRoot
215
- * @returns {Promise<{accountDir: string, accountId: string, dbName: string}[]>}
234
+ * @returns {Promise<{accountDir: string, accountId: string, dbName: string, exposeFolders: string[]}[]>}
216
235
  */
217
236
  async function portalAccounts(accountsRoot) {
218
- /** @type {{accountDir: string, accountId: string, dbName: string}[]} */ const out = []
237
+ /** @type {{accountDir: string, accountId: string, dbName: string, exposeFolders: string[]}[]} */ const out = []
219
238
  /** @type {string[]} */ let names
220
239
  try {
221
240
  names = await readdir(accountsRoot)
@@ -227,7 +246,10 @@ async function portalAccounts(accountsRoot) {
227
246
  try {
228
247
  const cfg = JSON.parse(await readFile(join(accountDir, 'data-portal.json'), 'utf8'))
229
248
  if (typeof cfg?.portalDbName === 'string' && cfg.portalDbName) {
230
- out.push({ accountDir, accountId, dbName: cfg.portalDbName })
249
+ const exposeFolders = Array.isArray(cfg?.exposeFolders)
250
+ ? cfg.exposeFolders.filter((f) => typeof f === 'string')
251
+ : []
252
+ out.push({ accountDir, accountId, dbName: cfg.portalDbName, exposeFolders })
231
253
  } else {
232
254
  console.error(`${TAG} op=skip account=${accountId} reason=no-portalDbName`)
233
255
  }
@@ -292,7 +314,7 @@ async function main() {
292
314
  process.exit(1)
293
315
  }
294
316
 
295
- /** @type {{accountDir: string, accountId: string, dbName: string}[]} */ let targets
317
+ /** @type {{accountDir: string, accountId: string, dbName: string, exposeFolders: string[]}[]} */ let targets
296
318
  if (process.argv.includes('--all-accounts')) {
297
319
  // Same derivation as the platform's own DATA_ROOT (data-path.ts:19), from
298
320
  // the PLATFORM_ROOT the heartbeat already passes.
@@ -307,7 +329,7 @@ async function main() {
307
329
  console.error(`${TAG} op=usage --account-dir <dir> --account <id> --db <name>`)
308
330
  process.exit(1)
309
331
  }
310
- targets = [{ accountDir, accountId, dbName }]
332
+ targets = [{ accountDir, accountId, dbName, exposeFolders: await readExposeFolders(accountDir) }]
311
333
  }
312
334
 
313
335
  const state = readState(platformRoot)
@@ -21,6 +21,18 @@ const OWNED_END = '<!-- plugin-owned-dirs:end -->'
21
21
  /** The delivery convention, always exposed when the account allows it. */
22
22
  export const ALWAYS_EXPOSED = 'output'
23
23
 
24
+ /** Folders that must never be named into exposure, even by an operator. The
25
+ * dot-prefixed `.git`/`.claude` are already excluded by the charset guard
26
+ * below. This is the not-system half of the extra-folder bound. */
27
+ const SYSTEM_DIRS = new Set(['agents', 'secrets', 'specialists', 'state', 'memory', 'cache', 'logs', 'tmp'])
28
+
29
+ /** An operator-named extra folder is accepted only as a single safe segment
30
+ * (the same charset the per-person `--folders` grant enforces) and never a
31
+ * system dir. This drops dot-prefixed names, slashes, and traversal such as
32
+ * `jobs/../secrets`, which would otherwise let the push walk index files under
33
+ * a system dir that the fetch route would refuse to serve. */
34
+ const SAFE_FOLDER = /^[A-Za-z0-9_-]+$/
35
+
24
36
  /**
25
37
  * The generator writes the ontology region with an ASCII hyphen and the
26
38
  * plugin-owned region with an em dash (account-schema-owned-dirs.py:294 vs
@@ -53,10 +65,11 @@ function allowedBlockOrNull(text) {
53
65
 
54
66
  /**
55
67
  * @param {string | null} schemaText Contents of accounts/<id>/SCHEMA.md, or null when absent.
68
+ * @param {string[]} [exposeFolders] Operator-named extra top-level folders. Empty or absent = today.
56
69
  * @returns {{ schemaPresent: boolean, allowed: string[], domainBuckets: string[],
57
70
  * pluginOwned: string[], collisions: string[], exposed: string[] }}
58
71
  */
59
- export function resolveExposedDirs(schemaText) {
72
+ export function resolveExposedDirs(schemaText, exposeFolders = []) {
60
73
  const empty = {
61
74
  schemaPresent: false,
62
75
  allowed: /** @type {string[]} */ ([]),
@@ -97,7 +110,14 @@ export function resolveExposedDirs(schemaText) {
97
110
  const collisions = domainBuckets.filter((d) => pluginOwned.includes(d))
98
111
 
99
112
  const candidates = [ALWAYS_EXPOSED, ...domainBuckets.filter((d) => !collisions.includes(d))]
100
- const exposed = [...new Set(candidates)].filter((d) => allowed.includes(d)).sort()
113
+ const ontologyExposed = [...new Set(candidates)].filter((d) => allowed.includes(d))
114
+ // Extras are unioned AFTER the ontology step and are deliberately NOT
115
+ // intersected with `allowed`: a workflow folder is legitimately outside the
116
+ // ontology set. The charset-plus-deny guard is the whole bound.
117
+ const extras = (Array.isArray(exposeFolders) ? exposeFolders : []).filter(
118
+ (f) => typeof f === 'string' && SAFE_FOLDER.test(f) && !SYSTEM_DIRS.has(f),
119
+ )
120
+ const exposed = [...new Set([...ontologyExposed, ...extras])].sort()
101
121
 
102
122
  return { schemaPresent: true, allowed, domainBuckets, pluginOwned, collisions, exposed }
103
123
  }
@@ -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
  })