drafted 1.14.28 → 1.14.30

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`
@@ -230,7 +245,9 @@ BREADCRUMBS: When a frame you write or read corresponds to a file in the user's
230
245
  CONTEXT RULES (follow these before every action):
231
246
  - WIKI CHECK: Before acting on any request, search the org wiki for relevant conventions, existing designs, and prior decisions. Use wiki(action="search") with relevant keywords.
232
247
  - LAYER CONTEXT: Before reading or mutating a frame, read all anchored frames in the same layer. Anchored frames are per-layer required reading (style guides, design systems, conventions). The server enforces this mechanically for writes/edits/deletes/moves — but proactively reading anchored frames before any frame operation prevents wasted work.
233
- IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use read(path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.`,
248
+ IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use read(path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.
249
+
250
+ LINKING: link the user to the narrowest thing you touched, never the project as a stand-in. Wrote or edited ONE frame → give its \`frameUrl\` (from the write/read/ls response). Touched a whole lane → its \`laneUrl\`; a whole layer → its \`layerUrl\` (both on the matching \`ls\` entries). Only when the work spans the project is the project URL the right link. A project link where a frame link was available makes the user hunt the surface for what you just did.`,
234
251
  }, {
235
252
  // Initialize instructions: the agent-identity contract, so an agent learns its own
236
253
  // tab name + the right way to read it WITHOUT having to "think to" call a tool.
@@ -1245,6 +1262,10 @@ function withFrameBreadcrumb(result, { hint = false } = {}) {
1245
1262
  const out = { ...result, breadcrumb: `drafted:${result.id}` };
1246
1263
  if (hint) {
1247
1264
  out.breadcrumbHint = `If this frame maps to a file in the user's codebase, paste "drafted:${result.id}" as a comment in that file so future agents discover the link.`;
1265
+ // The write path is where the project URL keeps getting substituted for the frame
1266
+ // the agent just wrote. Name the right link at the moment it's produced.
1267
+ const link = out.frameUrl || `${getServerUrl()}/f/${result.id}`;
1268
+ out.linkHint = `Link the user to THIS frame — ${link} — not to the project URL.`;
1248
1269
  }
1249
1270
  return out;
1250
1271
  }
@@ -3383,7 +3404,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
3383
3404
 
3384
3405
  tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="open") to see layers, workflow, and confirm you\'re in the right project.', {
3385
3406
  projectId: PROJECT_OVERRIDE_PARAM,
3386
- path: z.string().optional().default('/').describe('Directory path: / (layers), /{layer} (lanes), /{layer}/{lane} (frames). Frame entries include frameUrl (canvas deep link) and id (frame UUID).'),
3407
+ path: z.string().optional().default('/').describe('Directory path: / (layers), /{layer} (lanes), /{layer}/{lane} (frames). Frame entries include frameUrl (canvas deep link) and id (frame UUID); layer entries include layerUrl and lane entries laneUrl — relay those to the user instead of the project URL when your work was scoped to one layer or lane.'),
3387
3408
  recursive: z.boolean().optional().describe('List contents of subdirectories. When true, forces summary mode (metadata only, no full content) to keep results under the 25k token cap.'),
3388
3409
  summary: z.boolean().optional().describe('Include size, updatedAt, title for frames'),
3389
3410
  pattern: z.string().optional().describe('Glob pattern to filter filenames (e.g. "*.html")'),
@@ -4127,8 +4148,11 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
4127
4148
  // reports the session's INHERITED org, so echoing it made a correctly-placed write
4128
4149
  // look misfiled — and an agent trusting that echo would "fix" a page that was fine.
4129
4150
  const working = workingOrgId();
4151
+ // Cached for 30s, so this is a cache hit on all but the first wiki call in a
4152
+ // session — cheap enough to always have on hand for the receipt below.
4153
+ const orgList = await getOrgList();
4130
4154
  let orgCtx = working
4131
- ? ((await getOrgList()).find(o => o.id === working) || { id: working, name: null })
4155
+ ? (orgList.find(o => o.id === working) || { id: working, name: null })
4132
4156
  : await getCurrentOrgContext();
4133
4157
  if (args.org) {
4134
4158
  const d = await api('GET', '/api/orgs');
@@ -4141,7 +4165,17 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
4141
4165
  const orgId = orgCtx?.id || null;
4142
4166
  // Mutation responses include `org` so the agent always sees where the
4143
4167
  // write landed — eliminates silent cross-org confusion.
4144
- const withOrg = (result) => ({ ...result, org: orgCtx });
4168
+ //
4169
+ // A UUID-addressed resource (pageId=…) SELF-DERIVES its org server-side, so
4170
+ // for those the write can land in an org this session isn't bound to. Echoing
4171
+ // the session's working org there is the same defect the URL builder had: the
4172
+ // receipt would name one org while `url` pointed at another. Prefer the org
4173
+ // carried on the returned row, and fall back to the session context only when
4174
+ // the response doesn't name one.
4175
+ const withOrg = (result, resourceOrgId) => ({
4176
+ ...result,
4177
+ org: receiptOrg({ resourceOrgId: resourceOrgId || result?.orgId, sessionOrg: orgCtx, orgList }),
4178
+ });
4145
4179
  // Org-qualify every browser URL this tool emits (shadows the module fn for
4146
4180
  // all call sites below) so links are portable across the viewer's orgs.
4147
4181
  // 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.30",
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": [