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.
@@ -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 its own
11
- * stdio MCP, same launch config) don't clobber each other's active project.
12
- * Only the stdio process persists here; HTTP/remote user sessions are keyed by
13
- * a real Drafted session id and never call into this store.
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
- export function loadPersistedProject({ file = DEFAULT_FILE, cwd = defaultCwd() } = {}) {
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
- const e = all && all[cwd];
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[cwd] = {
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[cwd];
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
- const p = loadPersistedProject();
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(projectId ? { activeProjectId: projectId, activeProjectMeta: meta, boundOrgId: sess.boundOrgId } : null);
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
- const workingOrgId = getSessionState().boundOrgId || null;
2777
- const workingOrg = workingOrgId ? (orgs.find(o => o.id === workingOrgId) || { id: workingOrgId, name: null }) : null;
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
- ? ((await getOrgList()).find(o => o.id === working) || { id: working, name: null })
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
- const withOrg = (result) => ({ ...result, org: orgCtx });
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
- const wikiBrowserUrl = (p) => wikiPageUrl(p, orgId);
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 ──────────────────────────────────────────────────────
@@ -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 { projectlessMutationNeedsOrg } from './server.mjs';
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.27",
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",