drafted 1.14.27 → 1.14.28

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,19 @@ 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
+
192
205
  export function createMcpServer(transport) {
193
206
  // Remote transports (hosted HTTP MCP for claude.ai / ChatGPT) run on the
194
207
  // server, not the user's machine, so local-filesystem params like `file_path`
@@ -499,7 +512,10 @@ const PENDING_AUTH_FILE = process.env.DRAFTED_PENDING_AUTH_FILE || `${AUTH_FILE}
499
512
  // bucket (boundOrgId → the X-Drafted-Org per-request scope). No-op when the
500
513
  // file is absent (fresh install, or the hosted server process).
501
514
  (function rehydrateStdioActiveProject() {
502
- const p = loadPersistedProject();
515
+ // serverUrl namespaces the entry: ids from a different server's database (the
516
+ // local dev MCP registered alongside the prod one in this repo) must never
517
+ // rehydrate here — that is what pinned boundOrgId to a non-existent org.
518
+ const p = loadPersistedProject({ serverUrl: getServerUrl() });
503
519
  if (!p) { getOrCreateSessionState(null); return; }
504
520
  standaloneState.projectId = p.activeProjectId;
505
521
  standaloneState.projectMeta = p.activeProjectMeta || null;
@@ -945,7 +961,7 @@ function workingOrgId() {
945
961
  return session.boundOrgId || session.activeProjectMeta?.orgId || getState().projectMeta?.orgId || null;
946
962
  }
947
963
 
948
- async function api(method, path, body, extraHeaders = {}, _retried = false) {
964
+ async function api(method, path, body, extraHeaders = {}, _retried = false, _orgHealed = false) {
949
965
  await ensureSession();
950
966
  const pid = getState().projectId;
951
967
  const sep = path.includes('?') ? '&' : '?';
@@ -1010,7 +1026,7 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
1010
1026
  getState().sessionId = null;
1011
1027
  const approved = await consumePendingDeviceCode();
1012
1028
  if (!approved) await cloneSession();
1013
- return api(method, path, body, extraHeaders, true);
1029
+ return api(method, path, body, extraHeaders, true, _orgHealed);
1014
1030
  }
1015
1031
 
1016
1032
  let data;
@@ -1032,6 +1048,31 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
1032
1048
  .join('\n');
1033
1049
  msg += `\n${detail}`;
1034
1050
  }
1051
+ // The org we addressed this request to isn't one the caller belongs to. Drop it
1052
+ // and retry ONCE unaddressed rather than let a bogus id stay the silent default:
1053
+ // the project-less guards then ask for an explicit org instead of guessing.
1054
+ if (!_orgHealed && boundOrgRejected({ message: msg, boundOrgId: boundOrg, hasExplicitOrg })) {
1055
+ const sess = getSessionState();
1056
+ sess.boundOrgId = null;
1057
+ sess.cachedOrgs = null;
1058
+ // activeProjectMeta.orgId is the next rung of workingOrgId(), so a project from
1059
+ // the same foreign org would simply re-inject it on the retry.
1060
+ if (sess.activeProjectMeta?.orgId === boundOrg) {
1061
+ sess.activeProjectId = null;
1062
+ sess.activeProjectMeta = null;
1063
+ getState().projectId = null;
1064
+ getState().projectMeta = null;
1065
+ }
1066
+ if (sess._stdio) {
1067
+ savePersistedProject(
1068
+ sess.activeProjectId
1069
+ ? { activeProjectId: sess.activeProjectId, activeProjectMeta: sess.activeProjectMeta, boundOrgId: null }
1070
+ : null,
1071
+ { serverUrl: getServerUrl() }
1072
+ );
1073
+ }
1074
+ return api(method, path, body, extraHeaders, _retried, true);
1075
+ }
1035
1076
  // The active project no longer resolves in the current org context.
1036
1077
  // This is the silent-drift bug: project.open set a project, then
1037
1078
  // something switched the active org (parallel agent, browser tab, or
@@ -1048,7 +1089,7 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
1048
1089
  const sess = getSessionState();
1049
1090
  sess.activeProjectId = null;
1050
1091
  sess.activeProjectMeta = null;
1051
- if (sess._stdio) savePersistedProject(null);
1092
+ if (sess._stdio) savePersistedProject(null, { serverUrl: getServerUrl() });
1052
1093
  throw new Error(
1053
1094
  `${msg} — the active project (${meta?.slug || pid}) is no longer in the current org. ` +
1054
1095
  `Active project cleared. Call project(action="open") to set a new one, or proceed without one for org-scoped tools (wiki, skill).`
@@ -1243,7 +1284,10 @@ function setMcpActiveProject(projectId, meta = null) {
1243
1284
  if (meta?.orgId) sess.boundOrgId = meta.orgId;
1244
1285
  // Persist for the stdio process so an MCP restart rehydrates this project.
1245
1286
  if (sess._stdio) {
1246
- savePersistedProject(projectId ? { activeProjectId: projectId, activeProjectMeta: meta, boundOrgId: sess.boundOrgId } : null);
1287
+ savePersistedProject(
1288
+ projectId ? { activeProjectId: projectId, activeProjectMeta: meta, boundOrgId: sess.boundOrgId } : null,
1289
+ { serverUrl: getServerUrl() }
1290
+ );
1247
1291
  }
1248
1292
  }
1249
1293
 
@@ -2773,8 +2817,14 @@ tool('get_org', {
2773
2817
  // It's the per-session boundOrgId — set by an open project, a get_org(action="use"),
2774
2818
  // or (remote) the connection's org — not the shared session cursor. Announce it so an
2775
2819
  // 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;
2820
+ // Never REPORT a working org the caller isn't a member of. The old fallback
2821
+ // fabricated `{ id, name: null }` for an unknown id, which is how a stale/foreign
2822
+ // binding read as a real (if nameless) org instead of as the defect it is. If it
2823
+ // isn't in the memberships we just fetched, it can't address anything — clear it
2824
+ // so the session falls back to asking for an explicit org.
2825
+ const sess = getSessionState();
2826
+ const workingOrg = sess.boundOrgId ? (orgs.find(o => o.id === sess.boundOrgId) || null) : null;
2827
+ if (sess.boundOrgId && !workingOrg) sess.boundOrgId = null;
2778
2828
 
2779
2829
  const googleDrive = await getGoogleDriveAvailability();
2780
2830
  const mcpUpdate = await getCachedMcpUpdateMetadata();
@@ -4094,7 +4144,12 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
4094
4144
  const withOrg = (result) => ({ ...result, org: orgCtx });
4095
4145
  // Org-qualify every browser URL this tool emits (shadows the module fn for
4096
4146
  // all call sites below) so links are portable across the viewer's orgs.
4097
- const wikiBrowserUrl = (p) => wikiPageUrl(p, orgId);
4147
+ // A UUID-addressed page SELF-DERIVES its org server-side, so its URL must be
4148
+ // built from the org on the returned row (`resourceOrg`), not from this session's
4149
+ // working org — otherwise `wiki(action="read", pageId=…)` hands back a link into
4150
+ // whatever org the session happens to be bound to, for a page that lives elsewhere.
4151
+ // Path-addressed calls have no resource org and correctly fall back to `orgId`.
4152
+ const wikiBrowserUrl = (p, resourceOrg) => wikiPageUrl(p, resourceOrg || orgId);
4098
4153
 
4099
4154
  // ── Skill gate: all mutation actions ──────────────────────────
4100
4155
  // Ensure the wiki-maintainer skill is attached to this org BEFORE the
@@ -4217,7 +4272,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
4217
4272
  lastEditedBy: page.updatedBy,
4218
4273
  lastEditedAt: page.updatedAt,
4219
4274
  backlinkCount,
4220
- url: wikiBrowserUrl(page.path),
4275
+ url: wikiBrowserUrl(page.path, page.orgId),
4221
4276
  });
4222
4277
  }
4223
4278
 
@@ -4331,7 +4386,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
4331
4386
  source = await api('POST', '/api/wiki/sources', { contentHash: citeHash, filename: citeFilename }, orgHeader);
4332
4387
  } catch { /* source registration is best-effort */ }
4333
4388
  }
4334
- return ok(withOrg({ cited: true, n, entry, path: page.path, id: page.id, sourceId: source?.id, url: wikiBrowserUrl(page.path) }));
4389
+ return ok(withOrg({ cited: true, n, entry, path: page.path, id: page.id, sourceId: source?.id, url: wikiBrowserUrl(page.path, page.orgId) }));
4335
4390
  }
4336
4391
 
4337
4392
  // ── health ──────────────────────────────────────────────────
@@ -4429,10 +4484,10 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
4429
4484
  try {
4430
4485
  const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`, undefined, orgHeader);
4431
4486
  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 }));
4487
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path, result.orgId), note: logNote }));
4433
4488
  } catch {
4434
4489
  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 }));
4490
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path, result.orgId), note: logNote }));
4436
4491
  }
4437
4492
  }
4438
4493
 
@@ -4450,7 +4505,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
4450
4505
  id = page.id;
4451
4506
  }
4452
4507
  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) }));
4508
+ return ok(withOrg({ path: result.path, id: result.id, updated: true, applied: result.applied, url: wikiBrowserUrl(result.path, result.orgId) }));
4454
4509
  }
4455
4510
 
4456
4511
  // ── mv ──────────────────────────────────────────────────────
@@ -4474,7 +4529,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
4474
4529
 
4475
4530
  // Server-side cascade: /move rewrites referrers in one transaction
4476
4531
  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) }));
4532
+ return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0, url: wikiBrowserUrl(moved.path, moved.orgId) }));
4478
4533
  }
4479
4534
 
4480
4535
  // ── 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 } 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,67 @@ 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
+
35
100
  console.log('org-guard policy OK');
36
101
  // Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
37
102
  // 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.28",
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",