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 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
- ? ((await getOrgList()).find(o => o.id === working) || { id: working, name: null })
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
- 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
+ });
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
@@ -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.28",
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": [