drafted 1.14.27 → 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/active-project-store.mjs +27 -9
- package/mcp/server.mjs +99 -16
- package/mcp/test-org-guards.mjs +114 -1
- package/package.json +3 -2
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
* Persisting it lets the long-lived stdio process rehydrate transparently after
|
|
8
8
|
* a restart.
|
|
9
9
|
*
|
|
10
|
-
* Keyed by cwd so two concurrent same-machine agent sessions (each
|
|
11
|
-
* stdio MCP, same launch config) don't clobber each other's active
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Keyed by SERVER URL + cwd so two concurrent same-machine agent sessions (each
|
|
11
|
+
* its own stdio MCP, same launch config) don't clobber each other's active
|
|
12
|
+
* project. The server is part of the key because project and org ids are only
|
|
13
|
+
* meaningful within one server's DATABASE: this repo registers both a prod
|
|
14
|
+
* (`https://drafted.live`) and a local-dev (`http://localhost:3477`) stdio MCP,
|
|
15
|
+
* and with a cwd-only key the local MCP's project+boundOrgId were rehydrated by
|
|
16
|
+
* the prod MCP at boot — which pinned the prod session's working org to an org
|
|
17
|
+
* id that exists only in the local dev DB (`get_org` reported a workingOrg with
|
|
18
|
+
* name:null that was in no membership, and every project-less call failed with
|
|
19
|
+
* `not a member of org "<local-uuid>"`). Cross-database state must never share a
|
|
20
|
+
* key. Only the stdio process persists here; HTTP/remote user sessions are keyed
|
|
21
|
+
* by a real Drafted session id and never call into this store.
|
|
14
22
|
*
|
|
15
23
|
* Side-effect-free on import (no network, no eager writes) so it can be unit
|
|
16
24
|
* tested against the real code without booting the MCP server.
|
|
@@ -25,10 +33,18 @@ function defaultCwd() {
|
|
|
25
33
|
try { return process.cwd(); } catch { return '__default__'; }
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
|
|
36
|
+
// serverUrl first: a URL never contains "|", so the composite key is unambiguous.
|
|
37
|
+
function stateKey(cwd, serverUrl) {
|
|
38
|
+
return serverUrl ? `${serverUrl}|${cwd}` : cwd;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadPersistedProject({ file = DEFAULT_FILE, cwd = defaultCwd(), serverUrl = '' } = {}) {
|
|
29
42
|
try {
|
|
30
43
|
const all = JSON.parse(readFileSync(file, 'utf8'));
|
|
31
|
-
|
|
44
|
+
// Pre-composite-key entries (bare cwd) are NOT read back: they carry no record of
|
|
45
|
+
// which server minted their ids, and adopting one is exactly the cross-DB bleed
|
|
46
|
+
// above. Worst case the agent re-opens its project once.
|
|
47
|
+
const e = all && all[stateKey(cwd, serverUrl)];
|
|
32
48
|
if (e && e.activeProjectId) {
|
|
33
49
|
return {
|
|
34
50
|
activeProjectId: e.activeProjectId,
|
|
@@ -40,18 +56,20 @@ export function loadPersistedProject({ file = DEFAULT_FILE, cwd = defaultCwd() }
|
|
|
40
56
|
return null;
|
|
41
57
|
}
|
|
42
58
|
|
|
43
|
-
export function savePersistedProject(entry, { file = DEFAULT_FILE, cwd = defaultCwd() } = {}) {
|
|
59
|
+
export function savePersistedProject(entry, { file = DEFAULT_FILE, cwd = defaultCwd(), serverUrl = '' } = {}) {
|
|
44
60
|
try {
|
|
45
61
|
let all = {};
|
|
46
62
|
try { all = JSON.parse(readFileSync(file, 'utf8')) || {}; } catch { /* recreate */ }
|
|
63
|
+
const key = stateKey(cwd, serverUrl);
|
|
64
|
+
if (key !== cwd) delete all[cwd]; // drop the un-namespaced legacy entry for this cwd
|
|
47
65
|
if (entry && entry.activeProjectId) {
|
|
48
|
-
all[
|
|
66
|
+
all[key] = {
|
|
49
67
|
activeProjectId: entry.activeProjectId,
|
|
50
68
|
activeProjectMeta: entry.activeProjectMeta || null,
|
|
51
69
|
boundOrgId: entry.boundOrgId || null,
|
|
52
70
|
};
|
|
53
71
|
} else {
|
|
54
|
-
delete all[
|
|
72
|
+
delete all[key];
|
|
55
73
|
}
|
|
56
74
|
mkdirSync(dirname(file), { recursive: true });
|
|
57
75
|
writeFileSync(file, JSON.stringify(all), { mode: 0o600 });
|
package/mcp/server.mjs
CHANGED
|
@@ -189,6 +189,34 @@ export function projectlessMutationNeedsOrg({ explicitOrg, boundOrgId, activePro
|
|
|
189
189
|
return (orgCount || 0) > 1;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
// Did the server reject the working org THIS session injected as X-Drafted-Org?
|
|
193
|
+
// A working org the caller isn't a member of can only be stale or foreign — state
|
|
194
|
+
// rehydrated from another server's database, or the user removed from the org —
|
|
195
|
+
// and because api() addresses EVERY request with it, it poisons every project-less
|
|
196
|
+
// call ("not a member of org <uuid>") until the process restarts. Pure + exported so
|
|
197
|
+
// the truth table is assertable in isolation (mcp/test-org-guards.mjs). An explicitly
|
|
198
|
+
// passed org is the CALLER's address, never ours to drop — surface that error as-is.
|
|
199
|
+
export function boundOrgRejected({ message, boundOrgId, hasExplicitOrg }) {
|
|
200
|
+
if (!boundOrgId || hasExplicitOrg) return false;
|
|
201
|
+
const msg = String(message || '');
|
|
202
|
+
return /not a member of org/i.test(msg) && msg.includes(boundOrgId);
|
|
203
|
+
}
|
|
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
|
+
|
|
192
220
|
export function createMcpServer(transport) {
|
|
193
221
|
// Remote transports (hosted HTTP MCP for claude.ai / ChatGPT) run on the
|
|
194
222
|
// server, not the user's machine, so local-filesystem params like `file_path`
|
|
@@ -499,7 +527,10 @@ const PENDING_AUTH_FILE = process.env.DRAFTED_PENDING_AUTH_FILE || `${AUTH_FILE}
|
|
|
499
527
|
// bucket (boundOrgId → the X-Drafted-Org per-request scope). No-op when the
|
|
500
528
|
// file is absent (fresh install, or the hosted server process).
|
|
501
529
|
(function rehydrateStdioActiveProject() {
|
|
502
|
-
|
|
530
|
+
// serverUrl namespaces the entry: ids from a different server's database (the
|
|
531
|
+
// local dev MCP registered alongside the prod one in this repo) must never
|
|
532
|
+
// rehydrate here — that is what pinned boundOrgId to a non-existent org.
|
|
533
|
+
const p = loadPersistedProject({ serverUrl: getServerUrl() });
|
|
503
534
|
if (!p) { getOrCreateSessionState(null); return; }
|
|
504
535
|
standaloneState.projectId = p.activeProjectId;
|
|
505
536
|
standaloneState.projectMeta = p.activeProjectMeta || null;
|
|
@@ -945,7 +976,7 @@ function workingOrgId() {
|
|
|
945
976
|
return session.boundOrgId || session.activeProjectMeta?.orgId || getState().projectMeta?.orgId || null;
|
|
946
977
|
}
|
|
947
978
|
|
|
948
|
-
async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
979
|
+
async function api(method, path, body, extraHeaders = {}, _retried = false, _orgHealed = false) {
|
|
949
980
|
await ensureSession();
|
|
950
981
|
const pid = getState().projectId;
|
|
951
982
|
const sep = path.includes('?') ? '&' : '?';
|
|
@@ -1010,7 +1041,7 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
|
1010
1041
|
getState().sessionId = null;
|
|
1011
1042
|
const approved = await consumePendingDeviceCode();
|
|
1012
1043
|
if (!approved) await cloneSession();
|
|
1013
|
-
return api(method, path, body, extraHeaders, true);
|
|
1044
|
+
return api(method, path, body, extraHeaders, true, _orgHealed);
|
|
1014
1045
|
}
|
|
1015
1046
|
|
|
1016
1047
|
let data;
|
|
@@ -1032,6 +1063,31 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
|
1032
1063
|
.join('\n');
|
|
1033
1064
|
msg += `\n${detail}`;
|
|
1034
1065
|
}
|
|
1066
|
+
// The org we addressed this request to isn't one the caller belongs to. Drop it
|
|
1067
|
+
// and retry ONCE unaddressed rather than let a bogus id stay the silent default:
|
|
1068
|
+
// the project-less guards then ask for an explicit org instead of guessing.
|
|
1069
|
+
if (!_orgHealed && boundOrgRejected({ message: msg, boundOrgId: boundOrg, hasExplicitOrg })) {
|
|
1070
|
+
const sess = getSessionState();
|
|
1071
|
+
sess.boundOrgId = null;
|
|
1072
|
+
sess.cachedOrgs = null;
|
|
1073
|
+
// activeProjectMeta.orgId is the next rung of workingOrgId(), so a project from
|
|
1074
|
+
// the same foreign org would simply re-inject it on the retry.
|
|
1075
|
+
if (sess.activeProjectMeta?.orgId === boundOrg) {
|
|
1076
|
+
sess.activeProjectId = null;
|
|
1077
|
+
sess.activeProjectMeta = null;
|
|
1078
|
+
getState().projectId = null;
|
|
1079
|
+
getState().projectMeta = null;
|
|
1080
|
+
}
|
|
1081
|
+
if (sess._stdio) {
|
|
1082
|
+
savePersistedProject(
|
|
1083
|
+
sess.activeProjectId
|
|
1084
|
+
? { activeProjectId: sess.activeProjectId, activeProjectMeta: sess.activeProjectMeta, boundOrgId: null }
|
|
1085
|
+
: null,
|
|
1086
|
+
{ serverUrl: getServerUrl() }
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
return api(method, path, body, extraHeaders, _retried, true);
|
|
1090
|
+
}
|
|
1035
1091
|
// The active project no longer resolves in the current org context.
|
|
1036
1092
|
// This is the silent-drift bug: project.open set a project, then
|
|
1037
1093
|
// something switched the active org (parallel agent, browser tab, or
|
|
@@ -1048,7 +1104,7 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
|
1048
1104
|
const sess = getSessionState();
|
|
1049
1105
|
sess.activeProjectId = null;
|
|
1050
1106
|
sess.activeProjectMeta = null;
|
|
1051
|
-
if (sess._stdio) savePersistedProject(null);
|
|
1107
|
+
if (sess._stdio) savePersistedProject(null, { serverUrl: getServerUrl() });
|
|
1052
1108
|
throw new Error(
|
|
1053
1109
|
`${msg} — the active project (${meta?.slug || pid}) is no longer in the current org. ` +
|
|
1054
1110
|
`Active project cleared. Call project(action="open") to set a new one, or proceed without one for org-scoped tools (wiki, skill).`
|
|
@@ -1243,7 +1299,10 @@ function setMcpActiveProject(projectId, meta = null) {
|
|
|
1243
1299
|
if (meta?.orgId) sess.boundOrgId = meta.orgId;
|
|
1244
1300
|
// Persist for the stdio process so an MCP restart rehydrates this project.
|
|
1245
1301
|
if (sess._stdio) {
|
|
1246
|
-
savePersistedProject(
|
|
1302
|
+
savePersistedProject(
|
|
1303
|
+
projectId ? { activeProjectId: projectId, activeProjectMeta: meta, boundOrgId: sess.boundOrgId } : null,
|
|
1304
|
+
{ serverUrl: getServerUrl() }
|
|
1305
|
+
);
|
|
1247
1306
|
}
|
|
1248
1307
|
}
|
|
1249
1308
|
|
|
@@ -2773,8 +2832,14 @@ tool('get_org', {
|
|
|
2773
2832
|
// It's the per-session boundOrgId — set by an open project, a get_org(action="use"),
|
|
2774
2833
|
// or (remote) the connection's org — not the shared session cursor. Announce it so an
|
|
2775
2834
|
// agent can self-verify without guessing (invariant: "announced, never opaque").
|
|
2776
|
-
|
|
2777
|
-
|
|
2835
|
+
// Never REPORT a working org the caller isn't a member of. The old fallback
|
|
2836
|
+
// fabricated `{ id, name: null }` for an unknown id, which is how a stale/foreign
|
|
2837
|
+
// binding read as a real (if nameless) org instead of as the defect it is. If it
|
|
2838
|
+
// isn't in the memberships we just fetched, it can't address anything — clear it
|
|
2839
|
+
// so the session falls back to asking for an explicit org.
|
|
2840
|
+
const sess = getSessionState();
|
|
2841
|
+
const workingOrg = sess.boundOrgId ? (orgs.find(o => o.id === sess.boundOrgId) || null) : null;
|
|
2842
|
+
if (sess.boundOrgId && !workingOrg) sess.boundOrgId = null;
|
|
2778
2843
|
|
|
2779
2844
|
const googleDrive = await getGoogleDriveAvailability();
|
|
2780
2845
|
const mcpUpdate = await getCachedMcpUpdateMetadata();
|
|
@@ -4077,8 +4142,11 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4077
4142
|
// reports the session's INHERITED org, so echoing it made a correctly-placed write
|
|
4078
4143
|
// look misfiled — and an agent trusting that echo would "fix" a page that was fine.
|
|
4079
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();
|
|
4080
4148
|
let orgCtx = working
|
|
4081
|
-
? (
|
|
4149
|
+
? (orgList.find(o => o.id === working) || { id: working, name: null })
|
|
4082
4150
|
: await getCurrentOrgContext();
|
|
4083
4151
|
if (args.org) {
|
|
4084
4152
|
const d = await api('GET', '/api/orgs');
|
|
@@ -4091,10 +4159,25 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4091
4159
|
const orgId = orgCtx?.id || null;
|
|
4092
4160
|
// Mutation responses include `org` so the agent always sees where the
|
|
4093
4161
|
// write landed — eliminates silent cross-org confusion.
|
|
4094
|
-
|
|
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
|
+
});
|
|
4095
4173
|
// Org-qualify every browser URL this tool emits (shadows the module fn for
|
|
4096
4174
|
// all call sites below) so links are portable across the viewer's orgs.
|
|
4097
|
-
|
|
4175
|
+
// A UUID-addressed page SELF-DERIVES its org server-side, so its URL must be
|
|
4176
|
+
// built from the org on the returned row (`resourceOrg`), not from this session's
|
|
4177
|
+
// working org — otherwise `wiki(action="read", pageId=…)` hands back a link into
|
|
4178
|
+
// whatever org the session happens to be bound to, for a page that lives elsewhere.
|
|
4179
|
+
// Path-addressed calls have no resource org and correctly fall back to `orgId`.
|
|
4180
|
+
const wikiBrowserUrl = (p, resourceOrg) => wikiPageUrl(p, resourceOrg || orgId);
|
|
4098
4181
|
|
|
4099
4182
|
// ── Skill gate: all mutation actions ──────────────────────────
|
|
4100
4183
|
// Ensure the wiki-maintainer skill is attached to this org BEFORE the
|
|
@@ -4217,7 +4300,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4217
4300
|
lastEditedBy: page.updatedBy,
|
|
4218
4301
|
lastEditedAt: page.updatedAt,
|
|
4219
4302
|
backlinkCount,
|
|
4220
|
-
url: wikiBrowserUrl(page.path),
|
|
4303
|
+
url: wikiBrowserUrl(page.path, page.orgId),
|
|
4221
4304
|
});
|
|
4222
4305
|
}
|
|
4223
4306
|
|
|
@@ -4331,7 +4414,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4331
4414
|
source = await api('POST', '/api/wiki/sources', { contentHash: citeHash, filename: citeFilename }, orgHeader);
|
|
4332
4415
|
} catch { /* source registration is best-effort */ }
|
|
4333
4416
|
}
|
|
4334
|
-
return ok(withOrg({ cited: true, n, entry, path: page.path, id: page.id, sourceId: source?.id, url: wikiBrowserUrl(page.path) }));
|
|
4417
|
+
return ok(withOrg({ cited: true, n, entry, path: page.path, id: page.id, sourceId: source?.id, url: wikiBrowserUrl(page.path, page.orgId) }));
|
|
4335
4418
|
}
|
|
4336
4419
|
|
|
4337
4420
|
// ── health ──────────────────────────────────────────────────
|
|
@@ -4429,10 +4512,10 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4429
4512
|
try {
|
|
4430
4513
|
const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`, undefined, orgHeader);
|
|
4431
4514
|
const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body, orgHeader);
|
|
4432
|
-
return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path), note: logNote }));
|
|
4515
|
+
return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path, result.orgId), note: logNote }));
|
|
4433
4516
|
} catch {
|
|
4434
4517
|
const result = await api('POST', '/api/wiki/pages', body, orgHeader);
|
|
4435
|
-
return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path), note: logNote }));
|
|
4518
|
+
return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path, result.orgId), note: logNote }));
|
|
4436
4519
|
}
|
|
4437
4520
|
}
|
|
4438
4521
|
|
|
@@ -4450,7 +4533,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4450
4533
|
id = page.id;
|
|
4451
4534
|
}
|
|
4452
4535
|
const result = await api('POST', `/api/wiki/pages/${id}/edit`, { operations: editOps });
|
|
4453
|
-
return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied, url: wikiBrowserUrl(result.path) }));
|
|
4536
|
+
return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied, url: wikiBrowserUrl(result.path, result.orgId) }));
|
|
4454
4537
|
}
|
|
4455
4538
|
|
|
4456
4539
|
// ── mv ──────────────────────────────────────────────────────
|
|
@@ -4474,7 +4557,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4474
4557
|
|
|
4475
4558
|
// Server-side cascade: /move rewrites referrers in one transaction
|
|
4476
4559
|
const moved = await api('PATCH', `/api/wiki/pages/${id}/move`, { path: toPath });
|
|
4477
|
-
return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0, url: wikiBrowserUrl(moved.path) }));
|
|
4560
|
+
return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0, url: wikiBrowserUrl(moved.path, moved.orgId) }));
|
|
4478
4561
|
}
|
|
4479
4562
|
|
|
4480
4563
|
// ── rm ──────────────────────────────────────────────────────
|
package/mcp/test-org-guards.mjs
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
// Run: `node mcp/test-org-guards.mjs`. No framework — asserts the single pure
|
|
3
3
|
// decision core that the org guard delegates to, for BOTH creates and forks.
|
|
4
4
|
import assert from 'node:assert/strict';
|
|
5
|
-
import {
|
|
5
|
+
import { mkdtempSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { projectlessMutationNeedsOrg, boundOrgRejected, receiptOrg } from './server.mjs';
|
|
9
|
+
import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
|
|
6
10
|
|
|
7
11
|
// One rule governs create AND fork (a fork is a create). A write proceeds when its
|
|
8
12
|
// org is a real root — explicit org=, a bound/active project, or a single-org user's
|
|
@@ -32,6 +36,115 @@ assert.equal(projectlessMutationNeedsOrg(incident), false, 'incident state: fork
|
|
|
32
36
|
// The genuinely ambiguous case still errors for a fork, exactly as for a create:
|
|
33
37
|
assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org fork with nothing bound → BLOCK before any copy is created');
|
|
34
38
|
|
|
39
|
+
// ── Persisted stdio state must not cross DATABASES ────────────────────────────
|
|
40
|
+
// The 2026-07-31 incident: this repo registers a prod stdio MCP (drafted.live) and a
|
|
41
|
+
// local-dev one (localhost:3477) that run in the SAME cwd. Keyed by cwd alone, the
|
|
42
|
+
// local MCP's activeProject + boundOrgId were rehydrated by the prod MCP at boot, so
|
|
43
|
+
// the prod session addressed every request to an org id that exists only in the local
|
|
44
|
+
// dev DB: get_org reported `workingOrg {id: <local-uuid>, name: null}` in no membership
|
|
45
|
+
// list, and project-less calls failed `not a member of org "<local-uuid>"`.
|
|
46
|
+
const file = join(mkdtempSync(join(tmpdir(), 'drafted-state-')), 'mcp-state.json');
|
|
47
|
+
const cwd = '/Users/x/GitHub/drafted.live';
|
|
48
|
+
const PROD = 'https://drafted.live';
|
|
49
|
+
const LOCAL = 'http://localhost:3477';
|
|
50
|
+
|
|
51
|
+
savePersistedProject(
|
|
52
|
+
{ activeProjectId: 'local-project', boundOrgId: 'b111302a-local-db-only' },
|
|
53
|
+
{ file, cwd, serverUrl: LOCAL }
|
|
54
|
+
);
|
|
55
|
+
assert.equal(
|
|
56
|
+
loadPersistedProject({ file, cwd, serverUrl: PROD }),
|
|
57
|
+
null,
|
|
58
|
+
'prod MCP must NOT rehydrate the local dev MCP state saved in the same cwd'
|
|
59
|
+
);
|
|
60
|
+
assert.equal(
|
|
61
|
+
loadPersistedProject({ file, cwd, serverUrl: LOCAL })?.boundOrgId,
|
|
62
|
+
'b111302a-local-db-only',
|
|
63
|
+
'same server + same cwd still rehydrates (the feature this persistence exists for)'
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
savePersistedProject({ activeProjectId: 'prod-project', boundOrgId: 'org-prod' }, { file, cwd, serverUrl: PROD });
|
|
67
|
+
assert.equal(loadPersistedProject({ file, cwd, serverUrl: PROD })?.activeProjectId, 'prod-project');
|
|
68
|
+
assert.equal(
|
|
69
|
+
loadPersistedProject({ file, cwd, serverUrl: LOCAL })?.activeProjectId,
|
|
70
|
+
'local-project',
|
|
71
|
+
'the two servers keep independent entries for one cwd'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// A pre-fix (bare-cwd) entry carries no record of which server minted its ids, so it is
|
|
75
|
+
// never adopted — that entry IS the poisoned state.
|
|
76
|
+
savePersistedProject({ activeProjectId: 'legacy', boundOrgId: 'b111302a-local-db-only' }, { file, cwd });
|
|
77
|
+
assert.equal(loadPersistedProject({ file, cwd, serverUrl: PROD })?.activeProjectId, 'prod-project',
|
|
78
|
+
'a legacy un-namespaced entry never leaks into a server-scoped read');
|
|
79
|
+
|
|
80
|
+
// ── A non-member working org can never stay the silent default ────────────────
|
|
81
|
+
const stale = 'b111302a-937c-489b-8cec-a422c853ce85';
|
|
82
|
+
assert.equal(
|
|
83
|
+
boundOrgRejected({ message: `not a member of org "${stale}"`, boundOrgId: stale }),
|
|
84
|
+
true,
|
|
85
|
+
'server rejected the org WE addressed the request to → drop it and retry unaddressed'
|
|
86
|
+
);
|
|
87
|
+
assert.equal(
|
|
88
|
+
boundOrgRejected({ message: `not a member of org "${stale}"`, boundOrgId: stale, hasExplicitOrg: true }),
|
|
89
|
+
false,
|
|
90
|
+
'an explicit org= is the CALLER\'s address — surface the error, never silently drop it'
|
|
91
|
+
);
|
|
92
|
+
assert.equal(
|
|
93
|
+
boundOrgRejected({ message: 'not a member of org "Drafted"', boundOrgId: stale }),
|
|
94
|
+
false,
|
|
95
|
+
'a rejection naming a DIFFERENT org is not ours to heal'
|
|
96
|
+
);
|
|
97
|
+
assert.equal(boundOrgRejected({ message: 'Project not found', boundOrgId: stale }), false, 'unrelated error → no heal');
|
|
98
|
+
assert.equal(boundOrgRejected({ message: `not a member of org "${stale}"` }), false, 'nothing bound → nothing to drop');
|
|
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
|
+
|
|
35
148
|
console.log('org-guard policy OK');
|
|
36
149
|
// Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
|
|
37
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": [
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"deploy:check:google": "node scripts/check-google-drive-deploy.mjs",
|
|
37
37
|
"build:excalidraw": "node scripts/build-excalidraw-editor.mjs",
|
|
38
38
|
"build:office-viewer": "node scripts/build-office-viewer.mjs",
|
|
39
|
-
"prepublishOnly": "node scripts/check-npm-package.mjs"
|
|
39
|
+
"prepublishOnly": "node scripts/check-npm-package.mjs",
|
|
40
|
+
"test:marketing": "node server/lib/marketing/test-marketing.mjs"
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
43
|
"@aws-sdk/client-s3": "^3.1007.0",
|