drafted 1.14.11 → 1.14.12

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
@@ -180,8 +180,11 @@ function scrubLocalPathMentions(description) {
180
180
  // project is the one legitimately-kept addressing root, NOT the deleted session
181
181
  // cursor. Awareness of a surprising destination (e.g. a fork) comes from the
182
182
  // response receipt naming the org (orgEcho), not from hard-blocking the flow.
183
- export function projectlessMutationNeedsOrg({ explicitOrg, boundOrgId, activeProjectId, isRemote, orgCount }) {
184
- if (explicitOrg || boundOrgId || activeProjectId || isRemote) return false;
183
+ // A remote session is NOT an exemption: its session org is inherited (the user's default),
184
+ // not chosen, so for a multi-org user it is exactly the guess this guard exists to refuse.
185
+ // Single-org callers (remote or stdio) are unambiguous and proceed.
186
+ export function projectlessMutationNeedsOrg({ explicitOrg, boundOrgId, activeProjectId, orgCount }) {
187
+ if (explicitOrg || boundOrgId || activeProjectId) return false;
185
188
  return (orgCount || 0) > 1;
186
189
  }
187
190
 
@@ -927,6 +930,17 @@ async function serverFetch(url, opts) {
927
930
  catch (e) { throw enrichFetchError(e, url); }
928
931
  }
929
932
 
933
+ // The org this session is actually working in: an explicit switch, else the org of the
934
+ // bound project (a project belongs to exactly one org, so it IS an address). Never the
935
+ // server session's org — that one is INHERITED (a fresh session lands on the user's
936
+ // default), and trusting it is what wrote a Beoflow-bound agent's wiki page into
937
+ // Personal. Used both to address requests (X-Drafted-Org) and to echo where a write
938
+ // landed, so the two can never disagree.
939
+ function workingOrgId() {
940
+ const session = getSessionState();
941
+ return session.boundOrgId || session.activeProjectMeta?.orgId || getState().projectMeta?.orgId || null;
942
+ }
943
+
930
944
  async function api(method, path, body, extraHeaders = {}, _retried = false) {
931
945
  await ensureSession();
932
946
  const pid = getState().projectId;
@@ -951,7 +965,26 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
951
965
  // per-request — exactly like the org-less /project/:slug link that already
952
966
  // works. An explicit override in extraHeaders (e.g. the wiki/skill `org` arg)
953
967
  // always wins, and /auth/* is left untouched so it reports true session state.
954
- const boundOrg = getSessionState().boundOrgId;
968
+ // The bound project's org is as good an address as an explicit switch — and it's the
969
+ // one the projectless-mutation guard already credits. Send it when boundOrgId wasn't
970
+ // set (an open whose meta lacked orgId, or state rehydrated from an older on-disk
971
+ // entry), so an org-scoped write can never fall back to the session's inherited org.
972
+ // A bound project whose org we don't know yet (an open whose meta lacked orgId, or an
973
+ // older on-disk entry rehydrated at boot) leaves workingOrgId() null — and then an
974
+ // org-scoped write would resolve against the session's inherited org. Learn the org
975
+ // from the project itself, once. The /api/projects guard stops resolveProjectRef, which
976
+ // calls that endpoint, from recursing back into here.
977
+ if (pid && !workingOrgId() && !path.startsWith('/api/projects')) {
978
+ try {
979
+ const meta = await resolveProjectRef(pid);
980
+ if (meta?.orgId) {
981
+ const s = getSessionState();
982
+ s.boundOrgId = meta.orgId;
983
+ s.activeProjectMeta = s.activeProjectMeta || meta;
984
+ }
985
+ } catch { /* best-effort; the server derives org from ?projectId anyway */ }
986
+ }
987
+ const boundOrg = workingOrgId();
955
988
  const hasExplicitOrg = Object.keys(headers).some((k) => k.toLowerCase() === 'x-drafted-org');
956
989
  if (boundOrg && !hasExplicitOrg && !path.startsWith('/auth/')) {
957
990
  headers['X-Drafted-Org'] = boundOrg;
@@ -1402,21 +1435,20 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
1402
1435
  if (explicitOrg) return;
1403
1436
  const sess = getSessionState();
1404
1437
  if (sess.boundOrgId) return; // bound via project open (org = project's org)
1405
- if (getState().projectId) return; // an active project implies its org
1406
- // Remote/web sessions are isolated per connection: each one gets its OWN
1407
- // server-side session row with its own org_id set on connect, so the session
1408
- // org IS this session's binding — there's no shared, long-lived session to
1409
- // confuse here the way stdio has. Adopt that org automatically instead of
1410
- // forcing the agent to open a project before a project-less
1411
- // wiki or skill write. The DRAFT-36 Phase 4 "refuse to guess" guard below
1412
- // therefore applies to stdio only.
1413
- if (isRemote) {
1438
+ if (getState().projectId) return; // an active project implies its org — api() now
1439
+ // sends that project's org as X-Drafted-Org, so
1440
+ // the org the guard credits is the org used
1441
+ const orgs = await getOrgList();
1442
+ if (!orgs.length) return; // can't determine membership don't block a legit write
1443
+ // A remote/web session does get its own server-side session row, but for a MULTI-ORG
1444
+ // user the org on it is the one the connection INHERITED (the user's default), not one
1445
+ // anybody chose adopting it as a binding is how a Beoflow write landed in Personal.
1446
+ // A single-org user is unambiguous, so keep the zero-friction path for them.
1447
+ if (isRemote && orgs.length === 1) {
1414
1448
  const ctx = await getCurrentOrgContext();
1415
1449
  if (ctx?.id) sess.boundOrgId = ctx.id;
1416
1450
  return;
1417
1451
  }
1418
- const orgs = await getOrgList();
1419
- if (!orgs.length) return; // can't determine membership — don't block a legit write
1420
1452
  if (projectlessMutationNeedsOrg({ orgCount: orgs.length })) {
1421
1453
  throw new Error(
1422
1454
  `Refusing to guess the org: no project is open and no explicit org was ` +
@@ -3869,7 +3901,14 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3869
3901
  // request, nothing is switched); otherwise the session's binding (the open
3870
3902
  // project's org). Resolving the override here keeps the echoed `org` field
3871
3903
  // and every emitted browser URL truthful about where the call landed.
3872
- let orgCtx = await getCurrentOrgContext();
3904
+ // Where the write will ACTUALLY land: the working org (explicit switch, else the
3905
+ // bound project's org — the same address api() puts on the wire). getCurrentOrgContext
3906
+ // reports the session's INHERITED org, so echoing it made a correctly-placed write
3907
+ // look misfiled — and an agent trusting that echo would "fix" a page that was fine.
3908
+ const working = workingOrgId();
3909
+ let orgCtx = working
3910
+ ? ((await getOrgList()).find(o => o.id === working) || { id: working, name: null })
3911
+ : await getCurrentOrgContext();
3873
3912
  if (args.org) {
3874
3913
  const d = await api('GET', '/api/orgs');
3875
3914
  const list = (d.orgs || d || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
@@ -5,14 +5,19 @@ import assert from 'node:assert/strict';
5
5
  import { projectlessMutationNeedsOrg } from './server.mjs';
6
6
 
7
7
  // One rule governs create AND fork (a fork is a create). A write proceeds when its
8
- // org is a real root — explicit org=, a bound/active project, a remote-adopted org,
9
- // or a single-org user's only org. It refuses to GUESS only when the user is
10
- // multi-org with nothing bound.
8
+ // org is a real root — explicit org=, a bound/active project, or a single-org user's
9
+ // only org. It refuses to GUESS only when the user is multi-org with nothing bound.
11
10
  assert.equal(projectlessMutationNeedsOrg({ explicitOrg: 'ee', orgCount: 5 }), false, 'explicit org → allow');
12
11
  assert.equal(projectlessMutationNeedsOrg({ boundOrgId: 'causeway', orgCount: 5 }), false, 'bound project → allow (a real root, not the cursor)');
13
12
  assert.equal(projectlessMutationNeedsOrg({ activeProjectId: 'p1', orgCount: 5 }), false, 'active project → allow');
14
- assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 5 }), false, 'remote session → allow (adopts its own connection org)');
15
13
  assert.equal(projectlessMutationNeedsOrg({ orgCount: 1 }), false, 'single org → allow');
14
+
15
+ // A remote session is NOT a root. Its session org is the org the connection INHERITED
16
+ // (the user's default), not one anybody chose — that inheritance wrote a Beoflow-bound
17
+ // agent's wiki page into Personal. Multi-org remote must name its org like anyone else;
18
+ // single-org remote stays frictionless (covered by the orgCount:1 case above).
19
+ assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 5 }), true, 'remote + multi-org → BLOCK (its session org is inherited, not chosen)');
20
+ assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 1 }), false, 'remote + single org → allow');
16
21
  assert.equal(projectlessMutationNeedsOrg({ orgCount: 0 }), false, 'unknown membership → allow (never block a legit write)');
17
22
  assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org, nothing bound → BLOCK (refuse to guess)');
18
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.14.11",
3
+ "version": "1.14.12",
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": [