drafted 1.14.11 → 1.14.13
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/mcp/server.mjs +71 -22
- package/mcp/test-org-guards.mjs +9 -4
- package/package.json +1 -1
package/mcp/server.mjs
CHANGED
|
@@ -180,8 +180,11 @@ function scrubLocalPathMentions(description) {
|
|
|
180
180
|
// project is the one legitimately-kept addressing root, NOT the deleted session
|
|
181
181
|
// cursor. Awareness of a surprising destination (e.g. a fork) comes from the
|
|
182
182
|
// response receipt naming the org (orgEcho), not from hard-blocking the flow.
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
// A remote session is NOT an exemption: its session org is inherited (the user's default),
|
|
184
|
+
// not chosen, so for a multi-org user it is exactly the guess this guard exists to refuse.
|
|
185
|
+
// Single-org callers (remote or stdio) are unambiguous and proceed.
|
|
186
|
+
export function projectlessMutationNeedsOrg({ explicitOrg, boundOrgId, activeProjectId, orgCount }) {
|
|
187
|
+
if (explicitOrg || boundOrgId || activeProjectId) return false;
|
|
185
188
|
return (orgCount || 0) > 1;
|
|
186
189
|
}
|
|
187
190
|
|
|
@@ -927,6 +930,17 @@ async function serverFetch(url, opts) {
|
|
|
927
930
|
catch (e) { throw enrichFetchError(e, url); }
|
|
928
931
|
}
|
|
929
932
|
|
|
933
|
+
// The org this session is actually working in: an explicit switch, else the org of the
|
|
934
|
+
// bound project (a project belongs to exactly one org, so it IS an address). Never the
|
|
935
|
+
// server session's org — that one is INHERITED (a fresh session lands on the user's
|
|
936
|
+
// default), and trusting it is what wrote a Beoflow-bound agent's wiki page into
|
|
937
|
+
// Personal. Used both to address requests (X-Drafted-Org) and to echo where a write
|
|
938
|
+
// landed, so the two can never disagree.
|
|
939
|
+
function workingOrgId() {
|
|
940
|
+
const session = getSessionState();
|
|
941
|
+
return session.boundOrgId || session.activeProjectMeta?.orgId || getState().projectMeta?.orgId || null;
|
|
942
|
+
}
|
|
943
|
+
|
|
930
944
|
async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
931
945
|
await ensureSession();
|
|
932
946
|
const pid = getState().projectId;
|
|
@@ -951,7 +965,26 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
|
951
965
|
// per-request — exactly like the org-less /project/:slug link that already
|
|
952
966
|
// works. An explicit override in extraHeaders (e.g. the wiki/skill `org` arg)
|
|
953
967
|
// always wins, and /auth/* is left untouched so it reports true session state.
|
|
954
|
-
|
|
968
|
+
// The bound project's org is as good an address as an explicit switch — and it's the
|
|
969
|
+
// one the projectless-mutation guard already credits. Send it when boundOrgId wasn't
|
|
970
|
+
// set (an open whose meta lacked orgId, or state rehydrated from an older on-disk
|
|
971
|
+
// entry), so an org-scoped write can never fall back to the session's inherited org.
|
|
972
|
+
// A bound project whose org we don't know yet (an open whose meta lacked orgId, or an
|
|
973
|
+
// older on-disk entry rehydrated at boot) leaves workingOrgId() null — and then an
|
|
974
|
+
// org-scoped write would resolve against the session's inherited org. Learn the org
|
|
975
|
+
// from the project itself, once. The /api/projects guard stops resolveProjectRef, which
|
|
976
|
+
// calls that endpoint, from recursing back into here.
|
|
977
|
+
if (pid && !workingOrgId() && !path.startsWith('/api/projects')) {
|
|
978
|
+
try {
|
|
979
|
+
const meta = await resolveProjectRef(pid);
|
|
980
|
+
if (meta?.orgId) {
|
|
981
|
+
const s = getSessionState();
|
|
982
|
+
s.boundOrgId = meta.orgId;
|
|
983
|
+
s.activeProjectMeta = s.activeProjectMeta || meta;
|
|
984
|
+
}
|
|
985
|
+
} catch { /* best-effort; the server derives org from ?projectId anyway */ }
|
|
986
|
+
}
|
|
987
|
+
const boundOrg = workingOrgId();
|
|
955
988
|
const hasExplicitOrg = Object.keys(headers).some((k) => k.toLowerCase() === 'x-drafted-org');
|
|
956
989
|
if (boundOrg && !hasExplicitOrg && !path.startsWith('/auth/')) {
|
|
957
990
|
headers['X-Drafted-Org'] = boundOrg;
|
|
@@ -1402,28 +1435,37 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
|
|
|
1402
1435
|
if (explicitOrg) return;
|
|
1403
1436
|
const sess = getSessionState();
|
|
1404
1437
|
if (sess.boundOrgId) return; // bound via project open (org = project's org)
|
|
1405
|
-
if (getState().projectId) return; // an active project implies its org
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
|
|
1438
|
+
if (getState().projectId) return; // an active project implies its org — api() now
|
|
1439
|
+
// sends that project's org as X-Drafted-Org, so
|
|
1440
|
+
// the org the guard credits is the org used
|
|
1441
|
+
const orgs = await getOrgList();
|
|
1442
|
+
if (!orgs.length) return; // can't determine membership — don't block a legit write
|
|
1443
|
+
// A remote/web session does get its own server-side session row, but for a MULTI-ORG
|
|
1444
|
+
// user the org on it is the one the connection INHERITED (the user's default), not one
|
|
1445
|
+
// anybody chose — adopting it as a binding is how a Beoflow write landed in Personal.
|
|
1446
|
+
// A single-org user is unambiguous, so keep the zero-friction path for them.
|
|
1447
|
+
if (isRemote && orgs.length === 1) {
|
|
1414
1448
|
const ctx = await getCurrentOrgContext();
|
|
1415
1449
|
if (ctx?.id) sess.boundOrgId = ctx.id;
|
|
1416
1450
|
return;
|
|
1417
1451
|
}
|
|
1418
|
-
const orgs = await getOrgList();
|
|
1419
|
-
if (!orgs.length) return; // can't determine membership — don't block a legit write
|
|
1420
1452
|
if (projectlessMutationNeedsOrg({ orgCount: orgs.length })) {
|
|
1453
|
+
// Actionable, not a dead end: name the orgs and the ONE call that binds this session,
|
|
1454
|
+
// so the agent recovers itself instead of stalling on the human. Project-less wiki and
|
|
1455
|
+
// skill work is a first-class flow — no project required, ever. The only thing refused
|
|
1456
|
+
// is GUESSING which org, which is what silently misfiled a page into the user's
|
|
1457
|
+
// default org. (Listing the orgs is safe: they're the caller's own memberships.)
|
|
1458
|
+
const names = orgs.map(o => o.name || o.id).filter(Boolean);
|
|
1421
1459
|
throw new Error(
|
|
1422
|
-
`
|
|
1423
|
-
|
|
1424
|
-
`
|
|
1425
|
-
`
|
|
1426
|
-
`(
|
|
1460
|
+
`Which org? A project-less wiki/skill write needs one, and you belong to ${orgs.length}: ` +
|
|
1461
|
+
`${names.join(', ')}. Don't guess — an unaddressed write lands in whichever org this ` +
|
|
1462
|
+
`session inherited, which is how a page meant for one org ends up in another.\n` +
|
|
1463
|
+
`Recover in ONE call:\n` +
|
|
1464
|
+
` • get_org(action="use", org="<name>") — binds this session's working org; every ` +
|
|
1465
|
+
`later project-less wiki/skill write then just works, no project needed.\n` +
|
|
1466
|
+
` • or pass org="<name>" on this single call.\n` +
|
|
1467
|
+
` • or project(action="open") if the work belongs to a project — the org derives from it.\n` +
|
|
1468
|
+
`Pick from the conversation if the org is clear from context; ask the user only if it isn't.`
|
|
1427
1469
|
);
|
|
1428
1470
|
}
|
|
1429
1471
|
}
|
|
@@ -3516,7 +3558,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
3516
3558
|
path: z.string().optional().describe('[read_file|update_file] relative path inside skill directory (e.g. "examples/react.md")'),
|
|
3517
3559
|
offset: z.number().optional().describe('[search|list|export] skip N results for pagination; [read_file] start reading at this byte offset (default 0) — for large files (e.g. a >90KB app-frame bundle) read in chunks using the returned nextOffset until truncated=false'),
|
|
3518
3560
|
maxBytes: z.number().optional().describe('[read_file] return at most this many bytes from offset (default: whole remaining file). Response reports totalSize/offset/truncated/nextOffset.'),
|
|
3519
|
-
org: z.string().optional().describe('[add] org (id or name) the skill is born in — defaults to the open project\'s org; [list|search|load] scope to this org; [fork|push|update|export|import] resolve/fork into this org. Per-request only — nothing is switched.'),
|
|
3561
|
+
org: z.string().optional().describe('[add] org (id or name) the skill is born in — defaults to the open project\'s org; [list|search|load] scope to this org; [fork|push|update|export|import] resolve/fork into this org. Per-request only — nothing is switched. NO PROJECT IS NEEDED: for project-less skill work either pass org= here, or bind the session once with get_org(action="use", org="<name>"). A multi-org caller that addresses neither is refused rather than guessed.'),
|
|
3520
3562
|
setup: z.array(z.string()).optional().describe('[add|update] setup command(s) (in order) run on materialize to build a source-only skill, e.g. ["npm ci","npm run build"]'),
|
|
3521
3563
|
files: z.array(z.object({ path: z.string(), content: z.string() })).optional().describe('[push] source files to push (path + UTF-8 content); server strips artifacts + enforces caps. [import] OKF bundle files inline — skills/<slug>/SKILL.md dirs and Skill/Playbook/SOP/Procedure-typed .md with name + description frontmatter become org skills. Caps: 500 files, 512KB/file, 5MB total.'),
|
|
3522
3564
|
dir: z.string().optional().describe('[push|export|import] local directory. push: source tree to push instead of files[]; walked locally (heavy dirs, .skillinstall/, and .skillignore pre-filtered), server re-enforces; the dir\'s .gitignore is auto-updated to exclude .skillinstall/ (the rebuildable bundle). export: write the OKF bundle files here (default ./okf-skills-<org>). import: read the bundle from here (alternative to files[]).'),
|
|
@@ -3814,7 +3856,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
3814
3856
|
action: z.enum(['ls', 'recent', 'read', 'search', 'links', 'log', 'health', 'write', 'edit', 'mv', 'rm', 'cite', 'source-register', 'source-list', 'source-get', 'bulk-write', 'export', 'import']).describe('Operation to perform. export: the whole wiki as an OKF v0.1 bundle (local dir on stdio, download URL on remote, or format="files" for paged inline files). import: ingest an OKF bundle (files[] or local dir; dryRun supported).'),
|
|
3815
3857
|
path: z.string().optional().describe('[ls|read|links|cite] wiki path. For ls: default / (root). For read: required. For links/cite: required unless pageId given. Reading `index.md` (any level) returns the SYNTHESIZED OKF directory listing.'),
|
|
3816
3858
|
pageId: z.string().optional().describe('[read|edit|mv|rm|links] page UUID (from read/search). UUID-first: addresses the page directly, org auto-derives — no org needed and no path lookup. Preferred over path for an existing page.'),
|
|
3817
|
-
org: z.string().optional().describe('Org slug or id to scope this call to (per-request only — nothing is switched).
|
|
3859
|
+
org: z.string().optional().describe('Org slug or id to scope this call to (per-request only — nothing is switched). NO PROJECT IS NEEDED for wiki work: to write project-less, either pass org= here, or bind the session once with get_org(action="use", org="<name>") and then omit it. Multi-org callers MUST address the org one of those two ways — an unaddressed write is refused rather than guessed (it would land in whichever org the session inherited). [write] the org the page is created in. [search] restrict to this org (default: ALL your orgs). [ls|recent|read|links|log|health|edit|mv|rm|bulk-write] target this org\'s wiki instead of the open project\'s org. Ignored when a pageId is given (the page self-derives its org).'),
|
|
3818
3860
|
recursive: z.boolean().optional().describe('[ls] list recursively with depth indicators'),
|
|
3819
3861
|
limit: z.number().optional().describe('[recent|search|export] max results (recent default 10, search default 25, export files default 100)'),
|
|
3820
3862
|
offset: z.number().optional().describe('[export] pagination offset for format="files"'),
|
|
@@ -3869,7 +3911,14 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
3869
3911
|
// request, nothing is switched); otherwise the session's binding (the open
|
|
3870
3912
|
// project's org). Resolving the override here keeps the echoed `org` field
|
|
3871
3913
|
// and every emitted browser URL truthful about where the call landed.
|
|
3872
|
-
|
|
3914
|
+
// Where the write will ACTUALLY land: the working org (explicit switch, else the
|
|
3915
|
+
// bound project's org — the same address api() puts on the wire). getCurrentOrgContext
|
|
3916
|
+
// reports the session's INHERITED org, so echoing it made a correctly-placed write
|
|
3917
|
+
// look misfiled — and an agent trusting that echo would "fix" a page that was fine.
|
|
3918
|
+
const working = workingOrgId();
|
|
3919
|
+
let orgCtx = working
|
|
3920
|
+
? ((await getOrgList()).find(o => o.id === working) || { id: working, name: null })
|
|
3921
|
+
: await getCurrentOrgContext();
|
|
3873
3922
|
if (args.org) {
|
|
3874
3923
|
const d = await api('GET', '/api/orgs');
|
|
3875
3924
|
const list = (d.orgs || d || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
|
package/mcp/test-org-guards.mjs
CHANGED
|
@@ -5,14 +5,19 @@ import assert from 'node:assert/strict';
|
|
|
5
5
|
import { projectlessMutationNeedsOrg } from './server.mjs';
|
|
6
6
|
|
|
7
7
|
// One rule governs create AND fork (a fork is a create). A write proceeds when its
|
|
8
|
-
// org is a real root — explicit org=, a bound/active project, a
|
|
9
|
-
//
|
|
10
|
-
// multi-org with nothing bound.
|
|
8
|
+
// org is a real root — explicit org=, a bound/active project, or a single-org user's
|
|
9
|
+
// only org. It refuses to GUESS only when the user is multi-org with nothing bound.
|
|
11
10
|
assert.equal(projectlessMutationNeedsOrg({ explicitOrg: 'ee', orgCount: 5 }), false, 'explicit org → allow');
|
|
12
11
|
assert.equal(projectlessMutationNeedsOrg({ boundOrgId: 'causeway', orgCount: 5 }), false, 'bound project → allow (a real root, not the cursor)');
|
|
13
12
|
assert.equal(projectlessMutationNeedsOrg({ activeProjectId: 'p1', orgCount: 5 }), false, 'active project → allow');
|
|
14
|
-
assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 5 }), false, 'remote session → allow (adopts its own connection org)');
|
|
15
13
|
assert.equal(projectlessMutationNeedsOrg({ orgCount: 1 }), false, 'single org → allow');
|
|
14
|
+
|
|
15
|
+
// A remote session is NOT a root. Its session org is the org the connection INHERITED
|
|
16
|
+
// (the user's default), not one anybody chose — that inheritance wrote a Beoflow-bound
|
|
17
|
+
// agent's wiki page into Personal. Multi-org remote must name its org like anyone else;
|
|
18
|
+
// single-org remote stays frictionless (covered by the orgCount:1 case above).
|
|
19
|
+
assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 5 }), true, 'remote + multi-org → BLOCK (its session org is inherited, not chosen)');
|
|
20
|
+
assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 1 }), false, 'remote + single org → allow');
|
|
16
21
|
assert.equal(projectlessMutationNeedsOrg({ orgCount: 0 }), false, 'unknown membership → allow (never block a legit write)');
|
|
17
22
|
assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org, nothing bound → BLOCK (refuse to guess)');
|
|
18
23
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.13",
|
|
4
4
|
"description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|