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.
- package/mcp/active-project-store.mjs +27 -9
- package/mcp/server.mjs +69 -14
- package/mcp/test-org-guards.mjs +66 -1
- package/package.json +3 -2
|
@@ -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
|
|
11
|
-
* stdio MCP, same launch config) don't clobber each other's active
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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[
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2777
|
-
|
|
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
|
-
|
|
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 ──────────────────────────────────────────────────────
|
package/mcp/test-org-guards.mjs
CHANGED
|
@@ -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 {
|
|
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.
|
|
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",
|