drafted 1.14.28 → 1.14.29
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 +30 -2
- package/mcp/test-org-guards.mjs +49 -1
- package/package.json +1 -1
package/mcp/server.mjs
CHANGED
|
@@ -202,6 +202,21 @@ export function boundOrgRejected({ message, boundOrgId, hasExplicitOrg }) {
|
|
|
202
202
|
return /not a member of org/i.test(msg) && msg.includes(boundOrgId);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// Which org should a mutation receipt name?
|
|
206
|
+
//
|
|
207
|
+
// A UUID-addressed resource (pageId=…) self-derives its org SERVER-side, so a
|
|
208
|
+
// write can land in an org this session isn't bound to. Echoing the session's
|
|
209
|
+
// working org in that case reproduces the bug the URL builder had: `org:` names
|
|
210
|
+
// one org while `url:` points at another. Prefer the org carried on the returned
|
|
211
|
+
// row; fall back to the session context only when the response doesn't name one.
|
|
212
|
+
// Pure + exported so the truth table is assertable (mcp/test-org-guards.mjs).
|
|
213
|
+
export function receiptOrg({ resourceOrgId, sessionOrg, orgList }) {
|
|
214
|
+
const rid = resourceOrgId || null;
|
|
215
|
+
if (!rid || rid === sessionOrg?.id) return sessionOrg;
|
|
216
|
+
const found = (orgList || []).find((o) => o.id === rid);
|
|
217
|
+
return found || { id: rid, name: null };
|
|
218
|
+
}
|
|
219
|
+
|
|
205
220
|
export function createMcpServer(transport) {
|
|
206
221
|
// Remote transports (hosted HTTP MCP for claude.ai / ChatGPT) run on the
|
|
207
222
|
// server, not the user's machine, so local-filesystem params like `file_path`
|
|
@@ -4127,8 +4142,11 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4127
4142
|
// reports the session's INHERITED org, so echoing it made a correctly-placed write
|
|
4128
4143
|
// look misfiled — and an agent trusting that echo would "fix" a page that was fine.
|
|
4129
4144
|
const working = workingOrgId();
|
|
4145
|
+
// Cached for 30s, so this is a cache hit on all but the first wiki call in a
|
|
4146
|
+
// session — cheap enough to always have on hand for the receipt below.
|
|
4147
|
+
const orgList = await getOrgList();
|
|
4130
4148
|
let orgCtx = working
|
|
4131
|
-
? (
|
|
4149
|
+
? (orgList.find(o => o.id === working) || { id: working, name: null })
|
|
4132
4150
|
: await getCurrentOrgContext();
|
|
4133
4151
|
if (args.org) {
|
|
4134
4152
|
const d = await api('GET', '/api/orgs');
|
|
@@ -4141,7 +4159,17 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4141
4159
|
const orgId = orgCtx?.id || null;
|
|
4142
4160
|
// Mutation responses include `org` so the agent always sees where the
|
|
4143
4161
|
// write landed — eliminates silent cross-org confusion.
|
|
4144
|
-
|
|
4162
|
+
//
|
|
4163
|
+
// A UUID-addressed resource (pageId=…) SELF-DERIVES its org server-side, so
|
|
4164
|
+
// for those the write can land in an org this session isn't bound to. Echoing
|
|
4165
|
+
// the session's working org there is the same defect the URL builder had: the
|
|
4166
|
+
// receipt would name one org while `url` pointed at another. Prefer the org
|
|
4167
|
+
// carried on the returned row, and fall back to the session context only when
|
|
4168
|
+
// the response doesn't name one.
|
|
4169
|
+
const withOrg = (result, resourceOrgId) => ({
|
|
4170
|
+
...result,
|
|
4171
|
+
org: receiptOrg({ resourceOrgId: resourceOrgId || result?.orgId, sessionOrg: orgCtx, orgList }),
|
|
4172
|
+
});
|
|
4145
4173
|
// Org-qualify every browser URL this tool emits (shadows the module fn for
|
|
4146
4174
|
// all call sites below) so links are portable across the viewer's orgs.
|
|
4147
4175
|
// A UUID-addressed page SELF-DERIVES its org server-side, so its URL must be
|
package/mcp/test-org-guards.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import assert from 'node:assert/strict';
|
|
|
5
5
|
import { mkdtempSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
|
-
import { projectlessMutationNeedsOrg, boundOrgRejected } from './server.mjs';
|
|
8
|
+
import { projectlessMutationNeedsOrg, boundOrgRejected, receiptOrg } from './server.mjs';
|
|
9
9
|
import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
|
|
10
10
|
|
|
11
11
|
// One rule governs create AND fork (a fork is a create). A write proceeds when its
|
|
@@ -97,6 +97,54 @@ assert.equal(
|
|
|
97
97
|
assert.equal(boundOrgRejected({ message: 'Project not found', boundOrgId: stale }), false, 'unrelated error → no heal');
|
|
98
98
|
assert.equal(boundOrgRejected({ message: `not a member of org "${stale}"` }), false, 'nothing bound → nothing to drop');
|
|
99
99
|
|
|
100
|
+
// ── receiptOrg: the mutation receipt must name where the write LANDED ─────────
|
|
101
|
+
// Regression for the sibling of the foreign-org bug: a UUID-addressed page
|
|
102
|
+
// self-derives its org server-side, so echoing the session's working org made
|
|
103
|
+
// `org:` disagree with the (correct) `url:` on a cross-org edit.
|
|
104
|
+
{
|
|
105
|
+
const ORGS = [
|
|
106
|
+
{ id: 'org-a', name: 'Alpha' },
|
|
107
|
+
{ id: 'org-b', name: 'Bravo' },
|
|
108
|
+
];
|
|
109
|
+
const SESSION = { id: 'org-a', name: 'Alpha' };
|
|
110
|
+
|
|
111
|
+
// resource lives in the session's own org → session context, unchanged
|
|
112
|
+
assert.deepEqual(
|
|
113
|
+
receiptOrg({ resourceOrgId: 'org-a', sessionOrg: SESSION, orgList: ORGS }),
|
|
114
|
+
SESSION,
|
|
115
|
+
'same-org write should echo the session org',
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// response carries no org (path-addressed) → fall back to session context
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
receiptOrg({ resourceOrgId: null, sessionOrg: SESSION, orgList: ORGS }),
|
|
121
|
+
SESSION,
|
|
122
|
+
'no resource org should fall back to the session org',
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// THE BUG: resource lives elsewhere → must name the resource's org, not the session's
|
|
126
|
+
assert.deepEqual(
|
|
127
|
+
receiptOrg({ resourceOrgId: 'org-b', sessionOrg: SESSION, orgList: ORGS }),
|
|
128
|
+
{ id: 'org-b', name: 'Bravo' },
|
|
129
|
+
'cross-org write must echo the org the write landed in',
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// resource org not in the membership list → still name it, honestly, rather
|
|
133
|
+
// than silently substituting the session org
|
|
134
|
+
assert.deepEqual(
|
|
135
|
+
receiptOrg({ resourceOrgId: 'org-z', sessionOrg: SESSION, orgList: ORGS }),
|
|
136
|
+
{ id: 'org-z', name: null },
|
|
137
|
+
'unknown resource org should be named with a null name, not swapped out',
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// no session org at all (unbound) and a resource org present
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
receiptOrg({ resourceOrgId: 'org-b', sessionOrg: null, orgList: ORGS }),
|
|
143
|
+
{ id: 'org-b', name: 'Bravo' },
|
|
144
|
+
'unbound session should still name the resource org',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
100
148
|
console.log('org-guard policy OK');
|
|
101
149
|
// Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
|
|
102
150
|
// loop that keeps the event loop alive. Assertions are done — exit deterministically.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.29",
|
|
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": [
|