@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
|
@@ -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.
|
|
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,
|
|
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.**
|
|
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(
|
|
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(
|
|
131
|
-
|
|
130
|
+
expect(sql).toContain(
|
|
131
|
+
'INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId, folders)',
|
|
132
|
+
)
|
|
133
|
+
const [, owner, name, salt, hash, createdAt, accountId, folders] = valuesOf(sql!)
|
|
132
134
|
expect(owner).toBe('alice')
|
|
133
135
|
expect(name).toBe('Alice')
|
|
134
136
|
expect(salt).toMatch(/^[0-9a-f]{32}$/)
|
|
135
137
|
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
136
138
|
expect(Number.isNaN(Date.parse(createdAt))).toBe(false)
|
|
137
139
|
expect(accountId).toBe('acc-1')
|
|
140
|
+
expect(folders).toBe('')
|
|
138
141
|
})
|
|
139
142
|
|
|
140
143
|
it('refuses enrolment with no --account', async () => {
|
|
@@ -250,6 +253,57 @@ describe('portal-enrol', () => {
|
|
|
250
253
|
expect(await db.query("SELECT relPath FROM directory WHERE accountId='a';")).toBe('output/x.pdf')
|
|
251
254
|
})
|
|
252
255
|
|
|
256
|
+
it('lands a folders grant', async () => {
|
|
257
|
+
const r = await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'quotes,output'])
|
|
258
|
+
const [, , , , , , , folders] = valuesOf(parse(r).sql!)
|
|
259
|
+
expect(folders).toBe('quotes,output')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('defaults folders to empty when the flag is absent', async () => {
|
|
263
|
+
const r = await enrol(['--owner', 'alice', '--name', 'Alice'])
|
|
264
|
+
const [, , , , , , , folders] = valuesOf(parse(r).sql!)
|
|
265
|
+
expect(folders).toBe('')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('dedupes repeated folder names', async () => {
|
|
269
|
+
const r = await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'quotes,quotes,output'])
|
|
270
|
+
const [, , , , , , , folders] = valuesOf(parse(r).sql!)
|
|
271
|
+
expect(folders).toBe('quotes,output')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('rejects a folder name outside the segment charset', async () => {
|
|
275
|
+
await expect(
|
|
276
|
+
enrol(['--owner', 'alice', '--name', 'Alice', '--folders', '../etc']),
|
|
277
|
+
).rejects.toThrow()
|
|
278
|
+
await expect(
|
|
279
|
+
enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'a/b']),
|
|
280
|
+
).rejects.toThrow()
|
|
281
|
+
await expect(
|
|
282
|
+
enrol(['--owner', 'alice', '--name', 'Alice', '--folders', "a'b"]),
|
|
283
|
+
).rejects.toThrow()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('re-enrolment replaces the grant', async () => {
|
|
287
|
+
const db = await d1('enrol-folders-replace-')
|
|
288
|
+
await db.exec(
|
|
289
|
+
parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'quotes'])).sql!,
|
|
290
|
+
)
|
|
291
|
+
expect(await db.query("SELECT folders FROM people WHERE ownerId='alice';")).toBe('quotes')
|
|
292
|
+
await db.exec(
|
|
293
|
+
parse(await enrol(['--owner', 'alice', '--name', 'Alice', '--folders', 'output'])).sql!,
|
|
294
|
+
)
|
|
295
|
+
expect(await db.query("SELECT folders FROM people WHERE ownerId='alice';")).toBe('output')
|
|
296
|
+
expect(await db.query('SELECT COUNT(*) FROM people;')).toBe('1')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('declares people.folders defaulting to empty', async () => {
|
|
300
|
+
const db = await d1('enrol-folders-col-')
|
|
301
|
+
await db.exec(
|
|
302
|
+
"INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId) VALUES ('alice','Alice','aa','bb','t','acc-a');",
|
|
303
|
+
)
|
|
304
|
+
expect(await db.query("SELECT folders FROM people WHERE ownerId='alice';")).toBe('')
|
|
305
|
+
})
|
|
306
|
+
|
|
253
307
|
it('stores a crafted name as data rather than executing it', async () => {
|
|
254
308
|
const db = await d1('enrol-inject-')
|
|
255
309
|
const r = await enrol(['--owner', 'alice', '--name', "x'); DROP TABLE people; --"])
|
|
@@ -5,9 +5,16 @@ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/
|
|
|
5
5
|
const PATH = 'quotes/CR2969/quote.pdf'
|
|
6
6
|
|
|
7
7
|
function makeEnv(
|
|
8
|
-
opts: {
|
|
8
|
+
opts: {
|
|
9
|
+
accountId?: string
|
|
10
|
+
secret?: string
|
|
11
|
+
origin?: string
|
|
12
|
+
paths?: string[]
|
|
13
|
+
folders?: string
|
|
14
|
+
} = {},
|
|
9
15
|
) {
|
|
10
16
|
const { accountId = 'acc-a', paths = [PATH] } = opts
|
|
17
|
+
const folders = 'folders' in opts ? opts.folders : ''
|
|
11
18
|
return {
|
|
12
19
|
DB: {
|
|
13
20
|
prepare(query: string) {
|
|
@@ -25,7 +32,9 @@ function makeEnv(
|
|
|
25
32
|
},
|
|
26
33
|
async first<T>() {
|
|
27
34
|
if (/FROM sessions/i.test(query)) {
|
|
28
|
-
return (bound[0] === 'sess-a'
|
|
35
|
+
return (bound[0] === 'sess-a'
|
|
36
|
+
? { ownerId: 'alice', accountId, folders }
|
|
37
|
+
: null) as T | null
|
|
29
38
|
}
|
|
30
39
|
if (/FROM directory/i.test(query)) {
|
|
31
40
|
return (paths.includes(bound[1] as string) ? { relPath: bound[1] } : null) as T | null
|
|
@@ -128,4 +137,37 @@ describe('processIndexedDownload', () => {
|
|
|
128
137
|
await processIndexedDownload('sess-a', PATH, makeEnv(), (l) => lines.push(l), 1000, okHead as never)
|
|
129
138
|
expect(lines.some((l) => l.includes('op=fetch-through') && l.includes('result=ok'))).toBe(true)
|
|
130
139
|
})
|
|
140
|
+
|
|
141
|
+
it('refuses a path outside the grant with 404 and no device probe', async () => {
|
|
142
|
+
let touched = false
|
|
143
|
+
const spy = async () => {
|
|
144
|
+
touched = true
|
|
145
|
+
return { ok: true, status: 200 }
|
|
146
|
+
}
|
|
147
|
+
const lines: string[] = []
|
|
148
|
+
const env = makeEnv({ folders: 'quotes' })
|
|
149
|
+
const r = await processIndexedDownload(
|
|
150
|
+
'sess-a',
|
|
151
|
+
'documents/x.pdf',
|
|
152
|
+
env,
|
|
153
|
+
(l) => lines.push(l),
|
|
154
|
+
1000,
|
|
155
|
+
spy as never,
|
|
156
|
+
)
|
|
157
|
+
expect(r.status).toBe(404)
|
|
158
|
+
expect(touched).toBe(false)
|
|
159
|
+
expect(lines.some((l) => l.includes('result=not-granted'))).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('allows an in-grant path', async () => {
|
|
163
|
+
const env = makeEnv({ folders: 'quotes' })
|
|
164
|
+
const r = await processIndexedDownload('sess-a', PATH, env, () => {}, 1000, okHead as never)
|
|
165
|
+
expect(r.status).toBe(200)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('an empty grant allows any indexed path', async () => {
|
|
169
|
+
const env = makeEnv({ folders: '' })
|
|
170
|
+
const r = await processIndexedDownload('sess-a', PATH, env, () => {}, 1000, okHead as never)
|
|
171
|
+
expect(r.status).toBe(200)
|
|
172
|
+
})
|
|
131
173
|
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
grantAllows,
|
|
4
|
+
resolveSession,
|
|
5
|
+
} from '../../skills/data-portal/template/functions/api/_lib/session'
|
|
6
|
+
import type { D1Database } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
7
|
+
|
|
8
|
+
function db(row: Record<string, unknown> | null) {
|
|
9
|
+
return {
|
|
10
|
+
prepare() {
|
|
11
|
+
const stmt = {
|
|
12
|
+
bind() {
|
|
13
|
+
return stmt
|
|
14
|
+
},
|
|
15
|
+
async first<T>() {
|
|
16
|
+
return row as T | null
|
|
17
|
+
},
|
|
18
|
+
async run() {
|
|
19
|
+
return { meta: { changes: 0 } }
|
|
20
|
+
},
|
|
21
|
+
async all<T>() {
|
|
22
|
+
return { results: [] as T[] }
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
return stmt
|
|
26
|
+
},
|
|
27
|
+
} as unknown as D1Database
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('resolveSession folders', () => {
|
|
31
|
+
it('splits a comma grant into an array', async () => {
|
|
32
|
+
const s = await resolveSession(
|
|
33
|
+
db({ ownerId: 'alice', accountId: 'acc-a', folders: 'quotes,output' }),
|
|
34
|
+
'sess',
|
|
35
|
+
1,
|
|
36
|
+
)
|
|
37
|
+
expect(s?.folders).toEqual(['quotes', 'output'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns an empty array for an empty grant', async () => {
|
|
41
|
+
const s = await resolveSession(db({ ownerId: 'alice', accountId: 'acc-a', folders: '' }), 'sess', 1)
|
|
42
|
+
expect(s?.folders).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns an empty array when the column is absent', async () => {
|
|
46
|
+
const s = await resolveSession(db({ ownerId: 'alice', accountId: 'acc-a' }), 'sess', 1)
|
|
47
|
+
expect(s?.folders).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('grantAllows', () => {
|
|
52
|
+
it('admits everything when the grant is empty', () => {
|
|
53
|
+
expect(grantAllows([], 'documents/secret.pdf')).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('admits a path whose top segment is granted', () => {
|
|
57
|
+
expect(grantAllows(['quotes'], 'quotes/CR2969/quote.pdf')).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('refuses a path whose top segment is not granted', () => {
|
|
61
|
+
expect(grantAllows(['quotes'], 'documents/secret.pdf')).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('matches the whole segment, so `quote` does not admit `quotes`', () => {
|
|
65
|
+
expect(grantAllows(['quote'], 'quotes/x.pdf')).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
})
|