aimeat 1.20.0 → 1.20.2

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.
Files changed (48) hide show
  1. package/dist/locales/en.json +42 -1
  2. package/dist/locales/fi.json +42 -1
  3. package/dist/public/css/views/profile.css +81 -5
  4. package/dist/public/css/views/public-workspace-viewer.css +206 -0
  5. package/dist/public/js/services/organisms.js +122 -1
  6. package/dist/public/llms-template.txt +7 -2
  7. package/dist/public/spa.html +2 -0
  8. package/dist/public/views/profile/organisms-tab.js +157 -9
  9. package/dist/public/views/public-workspace-viewer.js +128 -0
  10. package/dist/src/cli/connect/mcp/tools/workspaces.d.ts.map +1 -1
  11. package/dist/src/cli/connect/mcp/tools/workspaces.js +15 -9
  12. package/dist/src/cli/connect/mcp/tools/workspaces.js.map +1 -1
  13. package/dist/src/cli/connect/tool-call.d.ts.map +1 -1
  14. package/dist/src/cli/connect/tool-call.js +10 -6
  15. package/dist/src/cli/connect/tool-call.js.map +1 -1
  16. package/dist/src/generated/api-types.d.ts +204 -0
  17. package/dist/src/generated/api-types.d.ts.map +1 -1
  18. package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
  19. package/dist/src/mcp/catalog/definitions.js +3 -2
  20. package/dist/src/mcp/catalog/definitions.js.map +1 -1
  21. package/dist/src/mcp/workspaces.d.ts +19 -0
  22. package/dist/src/mcp/workspaces.d.ts.map +1 -1
  23. package/dist/src/mcp/workspaces.js +147 -51
  24. package/dist/src/mcp/workspaces.js.map +1 -1
  25. package/dist/src/middleware/workspace-access.d.ts +5 -0
  26. package/dist/src/middleware/workspace-access.d.ts.map +1 -1
  27. package/dist/src/middleware/workspace-access.js +52 -15
  28. package/dist/src/middleware/workspace-access.js.map +1 -1
  29. package/dist/src/routes/organisms.d.ts +11 -0
  30. package/dist/src/routes/organisms.d.ts.map +1 -1
  31. package/dist/src/routes/organisms.js +357 -31
  32. package/dist/src/routes/organisms.js.map +1 -1
  33. package/dist/src/routes/portal.d.ts.map +1 -1
  34. package/dist/src/routes/portal.js +1 -0
  35. package/dist/src/routes/portal.js.map +1 -1
  36. package/dist/src/services/handbooks/agent.d.ts +3 -1
  37. package/dist/src/services/handbooks/agent.d.ts.map +1 -1
  38. package/dist/src/services/handbooks/agent.js +3 -1
  39. package/dist/src/services/handbooks/agent.js.map +1 -1
  40. package/dist/src/services/handbooks/appdev.d.ts +3 -1
  41. package/dist/src/services/handbooks/appdev.d.ts.map +1 -1
  42. package/dist/src/services/handbooks/appdev.js +19 -0
  43. package/dist/src/services/handbooks/appdev.js.map +1 -1
  44. package/dist/src/services/workspace-meta.d.ts +10 -0
  45. package/dist/src/services/workspace-meta.d.ts.map +1 -1
  46. package/dist/src/services/workspace-meta.js +50 -3
  47. package/dist/src/services/workspace-meta.js.map +1 -1
  48. package/package.json +1 -1
@@ -75,23 +75,60 @@ export function workspaceAccessMiddleware(config, storage) {
75
75
  // agent sessions below still require a matching grant. This is what lets a logged-in human use
76
76
  // their own project workspace from the UI without provisioning an agent-owned consent.
77
77
  const isHumanOwnerSession = (req.auth?.roles ?? []).includes('owner') && !(req.auth?.roles ?? []).includes('agent');
78
- // Consent needed unless writing your own member namespace, or you're the human owner-principal.
79
- const needsConsent = !isOwnMemberNamespace && !isHumanOwnerSession;
80
- if (needsConsent) {
81
- // Look up all agents for this owner
82
- const agents = await storage.getAgentsByOwner(ownerName);
83
- if (agents.length === 0) {
84
- res.status(403).json(error(config.nodeId, 'CONSENT_REQUIRED', 'No agent found — consent cannot be verified'));
85
- return;
78
+ // An AGENT of the workspace's OWN creator (same owner) is the creator's tool — like the human owner
79
+ // session, it does not consent to itself. Without this, a same-owner sub-agent (e.g. a CrewAI crew)
80
+ // could not write to its owner's organism workspace at all. Cross-owner member agents still need a
81
+ // granted 'workspace-contribution' consent. Scoped to workspace keys (organism.{id}.w.{ws}.*).
82
+ let isOwnWorkspaceAgent = false;
83
+ let wsCreator = null;
84
+ const wsMatch = key.match(/^organism\.[^.]+\.w\.([^.]+)\./);
85
+ const wsId = wsMatch ? wsMatch[1] : null;
86
+ if (wsId && !isHumanOwnerSession) {
87
+ const regKey = `organism.${organismId}.meta.workspaces`;
88
+ const { items } = await storage.listAllMemory({ prefix: regKey, limit: 1000 });
89
+ for (const rec of items) {
90
+ if (rec.key !== regKey)
91
+ continue;
92
+ const list = rec.value?.workspaces ?? [];
93
+ const entry = list.find(w => w.id === wsId);
94
+ if (entry) {
95
+ wsCreator = entry.createdBy ?? (rec.ownerGaii.includes('#') ? rec.ownerGaii.split('#')[1] : rec.ownerGaii).split('@')[0];
96
+ if (wsCreator === ownerName)
97
+ isOwnWorkspaceAgent = true;
98
+ break;
99
+ }
86
100
  }
87
- // Check if ANY of the owner's agents have a matching active consent
88
- const accessorPattern = `organism.${organismId}`;
101
+ }
102
+ // Consent needed unless writing your own member namespace, you're the human owner-principal, or
103
+ // you're an agent of the workspace's own creator.
104
+ const needsConsent = !isOwnMemberNamespace && !isHumanOwnerSession && !isOwnWorkspaceAgent;
105
+ if (needsConsent) {
89
106
  let hasConsent = false;
90
- for (const agent of agents) {
91
- const matching = await storage.findMatchingConsents(agent.gaii, key, accessorPattern);
92
- if (matching.length > 0) {
93
- hasConsent = true;
94
- break;
107
+ if (wsId) {
108
+ // Workspace CONTENT (organism.{id}.w.{ws}.*): write requires the creator's 'contributor' ROLE
109
+ // grant — and only that, so the creator can revoke write by removing the grant. A 'viewer' grant
110
+ // gives read only (not matched here). The role is granted to the OWNER (recipient ghii:owner@node),
111
+ // so all the owner's agents inherit it. (Unlike the writer's own request-time consent, a
112
+ // creator-owned grant is revocable by the creator.)
113
+ if (wsCreator) {
114
+ const creatorGhii = `${wsCreator}@${config.nodeId}`;
115
+ const recipient = `ghii:${ownerName}@${config.nodeId}`;
116
+ const pattern = `organism.${organismId}.w.${wsId}.**`;
117
+ const grants = await storage.listConsents(creatorGhii, { status: 'active' });
118
+ hasConsent = grants.some(c => c.purpose === 'workspace-contributor' && c.dataPattern === pattern && c.recipient === recipient);
119
+ }
120
+ }
121
+ else {
122
+ // Flat organism namespace (organism.{id}.shared.* etc.): the writer's own contribution consent,
123
+ // owned by the member's OWNER GHII or one of their agents (whichever made the grant).
124
+ const ownerGhii = `${ownerName}@${config.nodeId}`;
125
+ const agents = await storage.getAgentsByOwner(ownerName);
126
+ const accessorPattern = `organism.${organismId}`;
127
+ for (const accessor of [ownerGhii, ...agents.map(a => a.gaii)]) {
128
+ if ((await storage.findMatchingConsents(accessor, key, accessorPattern)).length > 0) {
129
+ hasConsent = true;
130
+ break;
131
+ }
95
132
  }
96
133
  }
97
134
  if (!hasConsent) {
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-access.js","sourceRoot":"","sources":["../../../src/middleware/workspace-access.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAEtC;;;;;;;;;;GAUG;AACH,MAAM,UAAU,yBAAyB,CACvC,MAAoB,EACpB,OAAgB;IAEhB,OAAO,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QAC9B,kCAAkC;QAClC,4DAA4D;QAC5D,MAAM,GAAG,GAAI,GAAG,CAAC,MAAiC,CAAC,GAAG,CAAC;QACvD,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,EAAE,CAAC;QAExB,2CAA2C;QAC3C,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC,sBAAsB,CAAC,CAAC;QAChD,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,EAAE,CAAC;QAE1B,MAAM,UAAU,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC;QAElC,sDAAsD;QACtD,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,eAAe,EAAE,8CAA8C,CAAC,CAAC,CAAC;YAC5G,OAAO;QACT,CAAC;QAED,uBAAuB;QACvB,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,WAAW,CAAC,UAAU,CAAC,CAAC;QACvD,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,EAAE,uBAAuB,UAAU,EAAE,CAAC,CAAC,CAAC;YAC7F,OAAO;QACT,CAAC;QAED,iDAAiD;QACjD,IAAI,GAAG,CAAC,IAAI,EAAE,GAAG,IAAI,QAAQ,CAAC,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;YAChE,+DAA+D;YAC/D,IAAI,GAAG,CAAC,UAAU,CAAC,YAAY,UAAU,UAAU,CAAC,EAAE,CAAC;gBACrD,OAAO,IAAI,EAAE,CAAC;YAChB,CAAC;YACD,iCAAiC;YACjC,IAAI,GAAG,CAAC,UAAU,CAAC,YAAY,UAAU,QAAQ,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC3F,OAAO,IAAI,EAAE,CAAC;YAChB,CAAC;YACD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,eAAe,EAAE,mDAAmD,CAAC,CAAC,CAAC;YACjH,OAAO;QACT,CAAC;QAED,sDAAsD;QACtD,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC;QAEzD,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,eAAe,EAAE,+BAA+B,CAAC,CAAC,CAAC;YAC7F,OAAO;QACT,CAAC;QAED,qFAAqF;QACrF,wFAAwF;QACxF,mFAAmF;QACnF,MAAM,UAAU,GAAG,MAAM,OAAO,CAAC,aAAa,CAAC,UAAU,EAAE,SAAS,CAAC,CAAC;QACtE,IAAI,CAAC,UAAU,IAAI,UAAU,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YAClD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,eAAe,EAAE,uCAAuC,CAAC,CAAC,CAAC;YACrG,OAAO;QACT,CAAC;QAED,oCAAoC;QACpC,uDAAuD;QACvD,+BAA+B;QAC/B,4EAA4E;QAC5E,2BAA2B;QAC3B,oDAAoD;QACpD,4DAA4D;QAC5D,MAAM,eAAe,GAAG,YAAY,UAAU,WAAW,SAAS,GAAG,CAAC;QACtE,MAAM,oBAAoB,GAAG,GAAG,CAAC,UAAU,CAAC,eAAe,CAAC,CAAC;QAE7D,6FAA6F;QAC7F,+FAA+F;QAC/F,+FAA+F;QAC/F,uFAAuF;QACvF,MAAM,mBAAmB,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;QAEpH,gGAAgG;QAChG,MAAM,YAAY,GAAG,CAAC,oBAAoB,IAAI,CAAC,mBAAmB,CAAC;QAEnE,IAAI,YAAY,EAAE,CAAC;YACjB,oCAAoC;YACpC,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC;YAEzD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACxB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,kBAAkB,EAAE,6CAA6C,CAAC,CAAC,CAAC;gBAC9G,OAAO;YACT,CAAC;YAED,oEAAoE;YACpE,MAAM,eAAe,GAAG,YAAY,UAAU,EAAE,CAAC;YACjD,IAAI,UAAU,GAAG,KAAK,CAAC;YAEvB,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;gBAC3B,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,oBAAoB,CAAC,KAAK,CAAC,IAAI,EAAE,GAAG,EAAE,eAAe,CAAC,CAAC;gBACtF,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBACxB,UAAU,GAAG,IAAI,CAAC;oBAClB,MAAM;gBACR,CAAC;YACH,CAAC;YAED,IAAI,CAAC,UAAU,EAAE,CAAC;gBAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,kBAAkB,EAC1D,+DAA+D,GAAG,GAAG,CAAC,CAAC,CAAC;gBAC1E,OAAO;YACT,CAAC;QACH,CAAC;QAED,iDAAiD;QACjD,IAAI,GAAG,CAAC,UAAU,CAAC,YAAY,UAAU,QAAQ,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAC5F,IAAI,UAAU,CAAC,IAAI,KAAK,OAAO,IAAI,UAAU,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;gBACjE,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,eAAe,EAAE,0CAA0C,CAAC,CAAC,CAAC;gBACxG,OAAO;YACT,CAAC;QACH,CAAC;QAED,uDAAuD;QACvD,MAAM,WAAW,GAAG,GAAG,CAAC,KAAK,CAAC,qCAAqC,CAAC,CAAC;QACrE,IAAI,WAAW,IAAI,CAAC,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YACzD,IAAI,WAAW,CAAC,CAAC,CAAC,KAAK,SAAS,EAAE,CAAC;gBACjC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,eAAe,EAAE,6CAA6C,CAAC,CAAC,CAAC;gBAC3G,OAAO;YACT,CAAC;QACH,CAAC;QAED,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"workspace-access.js","sourceRoot":"","sources":["../../../src/middleware/workspace-access.ts"],"names":[],"mappings":"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"}
@@ -26,6 +26,17 @@
26
26
  * { zip_base64 }). See services/workspace-export.ts + workspace-import.ts.
27
27
  * v1.7.0 -- 2026-06-09 -- Organism-level bundle: GET /:id/export (whole organism + all workspaces)
28
28
  * + POST /organisms/import (restore as a NEW organism). See services/organism-export+import.ts.
29
+ * v1.8.0 -- 2026-06-09 -- Document-space public sharing: meta.share record + GET/PUT
30
+ * /:id/workspace/share (member reads, creator/admin writes) and NO-AUTH GET
31
+ * /:id/workspace/public/documents + /public/document (?format=md), serving only PUBLISHED docs
32
+ * the share meta marks public. Backs the public HTML markdown viewer.
33
+ * v1.9.0 -- 2026-06-09 -- Creator-managed workspace member roles: viewer (read) | contributor
34
+ * (read+write), as creator-owned consents. POST /:id/workspace-access/grant + /revoke; decide now
35
+ * assigns a role; GET /:id/workspace-access returns members + roles. Write requires the contributor
36
+ * role (revocable), so a creator can manage who writes.
37
+ * v1.10.0 -- 2026-06-09 -- Workspace-level read: GET /:id/workspace authorizes once on the manifest
38
+ * (creator / same-owner / a viewer|contributor grant) and then returns ALL content, not per record —
39
+ * so a contributor's writes are visible to the creator + every member of a shared workspace.
29
40
  */
30
41
  import { Router } from 'express';
31
42
  import type { AimeatConfig } from '../config.js';
@@ -1 +1 @@
1
- {"version":3,"file":"organisms.d.ts","sourceRoot":"","sources":["../../../src/routes/organisms.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,EAAE,MAAM,EAAO,MAAM,SAAS,CAAC;AAEtC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,KAAK,EAAE,OAAO,EAAuC,MAAM,yBAAyB,CAAC;AA0B5F,wBAAgB,eAAe,CAAC,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CA80C9E"}
1
+ {"version":3,"file":"organisms.d.ts","sourceRoot":"","sources":["../../../src/routes/organisms.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,OAAO,EAAE,MAAM,EAAoC,MAAM,SAAS,CAAC;AAEnE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,KAAK,EAAE,OAAO,EAAuC,MAAM,yBAAyB,CAAC;AA0B5F,wBAAgB,eAAe,CAAC,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAqlD9E"}
@@ -26,6 +26,17 @@
26
26
  * { zip_base64 }). See services/workspace-export.ts + workspace-import.ts.
27
27
  * v1.7.0 -- 2026-06-09 -- Organism-level bundle: GET /:id/export (whole organism + all workspaces)
28
28
  * + POST /organisms/import (restore as a NEW organism). See services/organism-export+import.ts.
29
+ * v1.8.0 -- 2026-06-09 -- Document-space public sharing: meta.share record + GET/PUT
30
+ * /:id/workspace/share (member reads, creator/admin writes) and NO-AUTH GET
31
+ * /:id/workspace/public/documents + /public/document (?format=md), serving only PUBLISHED docs
32
+ * the share meta marks public. Backs the public HTML markdown viewer.
33
+ * v1.9.0 -- 2026-06-09 -- Creator-managed workspace member roles: viewer (read) | contributor
34
+ * (read+write), as creator-owned consents. POST /:id/workspace-access/grant + /revoke; decide now
35
+ * assigns a role; GET /:id/workspace-access returns members + roles. Write requires the contributor
36
+ * role (revocable), so a creator can manage who writes.
37
+ * v1.10.0 -- 2026-06-09 -- Workspace-level read: GET /:id/workspace authorizes once on the manifest
38
+ * (creator / same-owner / a viewer|contributor grant) and then returns ALL content, not per record —
39
+ * so a contributor's writes are visible to the creator + every member of a shared workspace.
29
40
  */
30
41
  import { Router, raw } from 'express';
31
42
  import { v4 as uuidv4 } from 'uuid';
@@ -498,26 +509,25 @@ export function organismsRouter(config, storage) {
498
509
  // (No ws → legacy organism-level root, kept only so an un-scoped call still reads something.)
499
510
  const ws = typeof req.query.ws === 'string' ? req.query.ws : undefined;
500
511
  const nsRoot = ws ? `organism.${id}.w.${ws}.` : `organism.${id}.`;
501
- // Enumerate the whole workspace by key prefix across all member identities, then
502
- // access-filter: own records pass directly; others go through the shared read guard.
512
+ // A workspace is SHARED: authorization is at the workspace level, not per record. If the caller can
513
+ // read the manifest (they created it, are a same-owner agent, or hold a viewer/contributor grant —
514
+ // see authorizeRead/the workspace-role consents), they see ALL of the workspace's content, whoever
515
+ // wrote it — so a contributor's writes are visible to the creator + other members. If not, they see
516
+ // nothing (org membership alone is discovery-only). The manifest is the single gate record.
503
517
  const { items } = await storage.listAllMemory({ prefix: nsRoot, limit: 5000 });
504
- const readable = [];
505
- for (const rec of items) {
506
- if (rec.ownerGaii === callerGaii) {
507
- readable.push(rec);
508
- continue;
518
+ const manRec = items.find(r => r.key === `${nsRoot}meta.manifest`);
519
+ let canReadWorkspace = false;
520
+ if (manRec) {
521
+ canReadWorkspace = manRec.ownerGaii === callerGaii || isSameOwner(manRec.ownerGaii, callerGaii);
522
+ if (!canReadWorkspace) {
523
+ const decision = await authorizeRead(storage, config, {
524
+ ownerGaii: manRec.ownerGaii, accessorGaii: callerGaii, resourceKey: manRec.key,
525
+ visibility: manRec.visibility, groupId: manRec.groupId, action: 'read',
526
+ });
527
+ canReadWorkspace = decision.allowed;
509
528
  }
510
- const decision = await authorizeRead(storage, config, {
511
- ownerGaii: rec.ownerGaii,
512
- accessorGaii: callerGaii,
513
- resourceKey: rec.key,
514
- visibility: rec.visibility,
515
- groupId: rec.groupId,
516
- action: 'read',
517
- });
518
- if (decision.allowed)
519
- readable.push(rec);
520
529
  }
530
+ const readable = canReadWorkspace ? items : [];
521
531
  const byKey = new Map(readable.map(r => [r.key, r]));
522
532
  const manifestRec = byKey.get(`${nsRoot}meta.manifest`);
523
533
  const manifest = manifestRec?.value ?? null;
@@ -712,7 +722,7 @@ export function organismsRouter(config, storage) {
712
722
  const man = items.find(r => r.key === mkey);
713
723
  if (!man)
714
724
  return false;
715
- if (man.ownerGaii === callerGaii)
725
+ if (man.ownerGaii === callerGaii || isSameOwner(man.ownerGaii, callerGaii))
716
726
  return true;
717
727
  const d = await authorizeRead(storage, config, { ownerGaii: man.ownerGaii, accessorGaii: callerGaii, resourceKey: man.key, visibility: man.visibility, groupId: man.groupId, action: 'read' });
718
728
  return d.allowed;
@@ -725,6 +735,123 @@ export function organismsRouter(config, storage) {
725
735
  const now = new Date().toISOString();
726
736
  await storage.createConsent({ id: uuidv4(), ownerGaii, dataPattern, recipient, purpose, scope: 'private', expires: null, status: 'active', grantedAt: now, revokedAt: null });
727
737
  };
738
+ /* ══ Workspace member roles (creator-managed) ══
739
+ * A workspace creator grants approved members one of two roles, as a consent the creator OWNS on
740
+ * organism.{id}.w.{ws}.** (so reads resolve via the creator + revoking is clean):
741
+ * - viewer → read only (purpose 'workspace-viewer')
742
+ * - contributor → read + write (purpose 'workspace-contributor')
743
+ * Reads honor ANY grant (viewer/contributor + the legacy 'workspace-access'); writes honor contributor
744
+ * (the write gate also accepts the legacy requester-owned 'workspace-contribution'). Members are an
745
+ * OWNER (+ their agents): the recipient is `ghii:owner@node`, so all the owner's agents inherit. */
746
+ const WS_ROLE_PURPOSES = ['workspace-viewer', 'workspace-contributor', 'workspace-access'];
747
+ const wsPattern = (id, ws) => `organism.${id}.w.${ws}.**`;
748
+ const recipientOf = (grantee) => `ghii:${parseGaiiLoose(grantee).owner || grantee}@${config.nodeId}`;
749
+ /** Set a member's role: revoke any prior workspace-role consent for them, then grant the new one. */
750
+ const setWorkspaceRole = async (creatorGhii, id, ws, grantee, role) => {
751
+ const recipient = recipientOf(grantee);
752
+ const pattern = wsPattern(id, ws);
753
+ const now = new Date().toISOString();
754
+ const prior = (await storage.listConsents(creatorGhii, { status: 'active' })).filter(c => c.dataPattern === pattern && c.recipient === recipient && WS_ROLE_PURPOSES.includes(c.purpose));
755
+ for (const g of prior)
756
+ await storage.updateConsent(g.id, { status: 'revoked', revokedAt: now });
757
+ await storage.createConsent({ id: uuidv4(), ownerGaii: creatorGhii, dataPattern: pattern, recipient, purpose: role === 'contributor' ? 'workspace-contributor' : 'workspace-viewer', scope: 'private', expires: null, status: 'active', grantedAt: now, revokedAt: null });
758
+ return recipient;
759
+ };
760
+ const revokeWorkspaceRole = async (creatorGhii, id, ws, grantee) => {
761
+ const recipient = recipientOf(grantee);
762
+ const pattern = wsPattern(id, ws);
763
+ const now = new Date().toISOString();
764
+ const grants = (await storage.listConsents(creatorGhii, { status: 'active' })).filter(c => c.dataPattern === pattern && c.recipient === recipient && WS_ROLE_PURPOSES.includes(c.purpose));
765
+ for (const g of grants)
766
+ await storage.updateConsent(g.id, { status: 'revoked', revokedAt: now });
767
+ return grants.length;
768
+ };
769
+ /** Map the creator's active grants → each member's current role for a workspace. */
770
+ const memberRolesForWs = async (creatorGhii, id, ws) => {
771
+ const pattern = wsPattern(id, ws);
772
+ const byOwner = new Map();
773
+ for (const c of await storage.listConsents(creatorGhii, { status: 'active' })) {
774
+ if (c.dataPattern !== pattern || !WS_ROLE_PURPOSES.includes(c.purpose))
775
+ continue;
776
+ const owner = c.recipient.replace(/^ghii:/, '').split('@')[0];
777
+ if (c.purpose === 'workspace-contributor')
778
+ byOwner.set(owner, 'contributor');
779
+ else if (!byOwner.has(owner))
780
+ byOwner.set(owner, 'viewer');
781
+ }
782
+ return byOwner;
783
+ };
784
+ const readShareMeta = async (id, ws) => {
785
+ const key = `organism.${id}.w.${ws}.meta.share`;
786
+ const { items } = await storage.listAllMemory({ prefix: key, limit: 10 });
787
+ const v = items.find(r => r.key === key)?.value ?? {};
788
+ return { public: !!v.public, spaces: v.spaces ?? {}, docs: v.docs ?? {} };
789
+ };
790
+ const isDocPublic = (share, typeName, docId) => {
791
+ const docKey = `${typeName}/${docId}`;
792
+ if (docKey in share.docs)
793
+ return !!share.docs[docKey];
794
+ if (typeName in share.spaces)
795
+ return !!share.spaces[typeName];
796
+ return !!share.public;
797
+ };
798
+ /** Read a workspace's manifest value regardless of which member owns it (public path — no auth). */
799
+ const readWsManifestValue = async (id, ws) => {
800
+ const key = `organism.${id}.w.${ws}.meta.manifest`;
801
+ const { items } = await storage.listAllMemory({ prefix: key, limit: 10 });
802
+ return items.find(r => r.key === key)?.value ?? null;
803
+ };
804
+ /** Collect the PUBLISHED (.latest) document-space pages that the share meta marks public. An optional
805
+ * filter narrows to one {type,id}. Drafts/versions are never included. */
806
+ const collectPublicDocs = async (id, ws, share, filter) => {
807
+ const manifest = await readWsManifestValue(id, ws);
808
+ if (!manifest)
809
+ return [];
810
+ const objectTypes = manifest.objectTypes ?? [];
811
+ const root = `organism.${id}.w.${ws}`;
812
+ const out = [];
813
+ for (const ot of objectTypes) {
814
+ const name = typeof ot.name === 'string' ? ot.name : undefined;
815
+ const namespace = typeof ot.namespace === 'string' ? ot.namespace : undefined;
816
+ if (!name || !namespace || ot.mode !== 'document')
817
+ continue;
818
+ if (filter && filter.type !== name)
819
+ continue;
820
+ const nsPrefix = `${root}.${namespace}.`;
821
+ const { items } = await storage.listAllMemory({ prefix: nsPrefix, limit: 5000 });
822
+ for (const r of items) {
823
+ if (!r.key.startsWith(nsPrefix))
824
+ continue;
825
+ const parts = r.key.slice(nsPrefix.length).split('.');
826
+ const docId = parts[0];
827
+ if (parts.slice(1).join('.') !== 'latest')
828
+ continue; // only published
829
+ if (filter && filter.id !== docId)
830
+ continue;
831
+ if (!isDocPublic(share, name, docId))
832
+ continue;
833
+ const v = r.value;
834
+ out.push({
835
+ type: name, id: docId,
836
+ title: (v && typeof v.title === 'string') ? v.title : docId,
837
+ markdown: (v && typeof v.markdown === 'string') ? v.markdown : '',
838
+ });
839
+ }
840
+ }
841
+ return out;
842
+ };
843
+ /** Render a list of public docs as a single markdown document (for ?format=md). */
844
+ const docsToMarkdown = (wsName, docs) => {
845
+ const parts = [];
846
+ if (wsName)
847
+ parts.push(`# ${wsName}\n`);
848
+ for (const d of docs) {
849
+ parts.push(`## ${d.title}\n`);
850
+ parts.push(d.markdown.trim());
851
+ parts.push('\n---\n');
852
+ }
853
+ return parts.join('\n');
854
+ };
728
855
  /* ── GET /v1/organisms/:id/workspaces — discover every workspace in the org (membership-gated,
729
856
  * names + creator + your access status). Discovery is open to members; content stays gated. ── */
730
857
  router.get('/v1/organisms/:id/workspaces', requireAuth(), async (req, res) => {
@@ -840,15 +967,15 @@ export function organismsRouter(config, storage) {
840
967
  return;
841
968
  }
842
969
  const creatorGhii = `${createdBy}@${config.nodeId}`;
843
- const grants = (await storage.listConsents(creatorGhii, { status: 'active' })).filter(c => c.dataPattern === `organism.${id}.w.${ws}.**` && c.purpose === 'workspace-access');
844
- const approved = new Set(grants.map(c => c.recipient));
970
+ const roles = await memberRolesForWs(creatorGhii, id, ws); // owner → 'viewer' | 'contributor'
845
971
  const { items } = await storage.listAllMemory({ prefix: `organism.${id}.w.${ws}.access.request.`, limit: 1000 });
846
972
  const requests = items.map(r => {
847
973
  const v = r.value;
848
974
  const requester = v.requester ?? bareOwner(r.ownerGaii);
849
- return { requester, message: v.message ?? '', created_at: v.createdAt, status: approved.has(`ghii:${requester}@${config.nodeId}`) ? 'approved' : 'pending' };
975
+ return { requester, message: v.message ?? '', created_at: v.createdAt, status: roles.has(requester) ? 'approved' : 'pending', role: roles.get(requester) ?? null };
850
976
  });
851
- res.json(success(config.nodeId, { ws, requests }));
977
+ const members = [...roles.entries()].map(([owner, role]) => ({ owner, role }));
978
+ res.json(success(config.nodeId, { ws, requests, members }));
852
979
  });
853
980
  /* ── POST /v1/organisms/:id/workspace-access/decision — the workspace creator approves or denies a
854
981
  * request. Approve → grant the requester read access to the workspace's content. Body:
@@ -881,23 +1008,23 @@ export function organismsRouter(config, storage) {
881
1008
  res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the workspace creator or an org admin can decide access'));
882
1009
  return;
883
1010
  }
884
- const creatorGhii = resolveIdentity(req.auth, config.nodeId);
885
- const recipient = `ghii:${requester}@${config.nodeId}`;
886
- const pattern = `organism.${id}.w.${ws}.**`;
1011
+ // Grants are owned by the WORKSPACE CREATOR (not the deciding admin), so reads resolve via the
1012
+ // creator who owns the content. Approve assigns a role (default contributor; the body may ask for
1013
+ // 'viewer'); deny revokes every workspace-role grant for the requester.
1014
+ const wsCreatorGhii = `${createdBy}@${config.nodeId}`;
887
1015
  if (decision === 'approve') {
888
- await ensureConsent(creatorGhii, pattern, recipient, 'workspace-access');
1016
+ const role = (req.body?.role === 'viewer' ? 'viewer' : 'contributor');
1017
+ await setWorkspaceRole(wsCreatorGhii, id, ws, requester, role);
889
1018
  await notify(storage, `${requester}@${config.nodeId}`, {
890
1019
  type: 'workspace_access_approved',
891
- title: `Your access to "${entry.name ?? ws}" was approved`,
1020
+ title: `Your access to "${entry.name ?? ws}" was approved (${role})`,
892
1021
  link: '/v1/profile#organisms',
893
1022
  });
894
1023
  emitChange('notifications');
895
- res.json(success(config.nodeId, { status: 'approved', ws, requester }));
1024
+ res.json(success(config.nodeId, { status: 'approved', ws, requester, role }));
896
1025
  }
897
1026
  else {
898
- const grants = (await storage.listConsents(creatorGhii, { status: 'active' })).filter(c => c.dataPattern === pattern && c.recipient === recipient && c.purpose === 'workspace-access');
899
- for (const g of grants)
900
- await storage.updateConsent(g.id, { status: 'revoked', revokedAt: new Date().toISOString() });
1027
+ await revokeWorkspaceRole(wsCreatorGhii, id, ws, requester);
901
1028
  await notify(storage, `${requester}@${config.nodeId}`, {
902
1029
  type: 'workspace_access_denied',
903
1030
  title: `Your access request to "${entry.name ?? ws}" was declined`,
@@ -907,6 +1034,75 @@ export function organismsRouter(config, storage) {
907
1034
  res.json(success(config.nodeId, { status: 'denied', ws, requester }));
908
1035
  }
909
1036
  });
1037
+ /** Shared gate for the grant/revoke routes — returns the workspace creator's name, or sends the
1038
+ * error response and returns null. Only the workspace creator or an org admin may manage access. */
1039
+ const requireWsManager = async (req, res, id, ws) => {
1040
+ const organism = await storage.getOrganism(id);
1041
+ if (!organism) {
1042
+ res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
1043
+ return null;
1044
+ }
1045
+ const callerRole = await memberRole(req, organism, id);
1046
+ if (!callerRole) {
1047
+ res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Not an active member of this organism'));
1048
+ return null;
1049
+ }
1050
+ if (!ws || typeof ws !== 'string') {
1051
+ res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'ws is required'));
1052
+ return null;
1053
+ }
1054
+ const entry = await findWsEntry(id, ws);
1055
+ if (!entry) {
1056
+ res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Workspace not found'));
1057
+ return null;
1058
+ }
1059
+ const createdBy = entry.createdBy ?? bareOwner(entry.ownerGaii);
1060
+ if (createdBy !== req.auth.owner && callerRole !== 'creator' && callerRole !== 'admin') {
1061
+ res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the workspace creator or an org admin can manage access'));
1062
+ return null;
1063
+ }
1064
+ return createdBy;
1065
+ };
1066
+ /* ── POST /v1/organisms/:id/workspace-access/grant — creator/admin DIRECTLY adds a member with a role
1067
+ * (no prior request). Body: { ws, grantee, role: 'viewer' | 'contributor' }. grantee may be an owner
1068
+ * name, GHII, or GAII — the grant applies to that OWNER (so all their agents inherit it). ── */
1069
+ router.post('/v1/organisms/:id/workspace-access/grant', requireAuth(), async (req, res) => {
1070
+ const id = req.params.id;
1071
+ const { ws, grantee, role } = req.body ?? {};
1072
+ if (!grantee || typeof grantee !== 'string' || (role !== 'viewer' && role !== 'contributor')) {
1073
+ res.status(400).json(error(config.nodeId, 'INVALID_INPUT', "grantee and role ('viewer' | 'contributor') are required"));
1074
+ return;
1075
+ }
1076
+ const createdBy = await requireWsManager(req, res, id, ws);
1077
+ if (!createdBy)
1078
+ return;
1079
+ const granteeOwner = parseGaiiLoose(grantee).owner || grantee;
1080
+ if (granteeOwner === createdBy) {
1081
+ res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'The creator already has full access to their workspace'));
1082
+ return;
1083
+ }
1084
+ await setWorkspaceRole(`${createdBy}@${config.nodeId}`, id, ws, grantee, role);
1085
+ await notify(storage, `${granteeOwner}@${config.nodeId}`, {
1086
+ type: 'workspace_access_granted', title: `You were added to "${ws}" as ${role}`, link: '/v1/profile#organisms',
1087
+ });
1088
+ emitChange('notifications');
1089
+ res.json(success(config.nodeId, { ws, grantee: granteeOwner, role }));
1090
+ });
1091
+ /* ── POST /v1/organisms/:id/workspace-access/revoke — creator/admin removes a member's access.
1092
+ * Body: { ws, grantee }. ── */
1093
+ router.post('/v1/organisms/:id/workspace-access/revoke', requireAuth(), async (req, res) => {
1094
+ const id = req.params.id;
1095
+ const { ws, grantee } = req.body ?? {};
1096
+ if (!grantee || typeof grantee !== 'string') {
1097
+ res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'grantee is required'));
1098
+ return;
1099
+ }
1100
+ const createdBy = await requireWsManager(req, res, id, ws);
1101
+ if (!createdBy)
1102
+ return;
1103
+ const revoked = await revokeWorkspaceRole(`${createdBy}@${config.nodeId}`, id, ws, grantee);
1104
+ res.json(success(config.nodeId, { ws, grantee: parseGaiiLoose(grantee).owner || grantee, revoked }));
1105
+ });
910
1106
  /* ── GET /v1/organisms/:id/workspace/activity?ws= — deterministic activity feed for a workspace,
911
1107
  * derived from the version history: who did what, in which space, draft-edit vs publish, when.
912
1108
  * Each .version.N = a publish event; each .draft = an edit event. Member-gated; access-filtered. ── */
@@ -1075,6 +1271,7 @@ export function organismsRouter(config, storage) {
1075
1271
  orgId: id, ws, callerOwner: req.auth.owner,
1076
1272
  isAdmin: role === 'admin' || role === 'creator',
1077
1273
  name: req.body?.name, readme: req.body?.readme,
1274
+ addObjectTypes: Array.isArray(req.body?.add_object_types) ? req.body.add_object_types : (Array.isArray(req.body?.add_spaces) ? req.body.add_spaces : undefined),
1078
1275
  manifest: req.body?.manifest, schemas: req.body?.schemas,
1079
1276
  });
1080
1277
  emitChange('organisms');
@@ -1089,6 +1286,135 @@ export function organismsRouter(config, storage) {
1089
1286
  res.status(500).json(error(config.nodeId, 'UPDATE_FAILED', e.message || 'Could not update the workspace'));
1090
1287
  }
1091
1288
  });
1289
+ /* ── GET /v1/organisms/:id/workspace/public/documents?ws=&space=&format= — NO AUTH. The published
1290
+ * document-space pages a workspace has marked public (via meta.share). ?space= limits to one space;
1291
+ * ?format=md returns a single concatenated markdown document. 404 (no disclosure) if nothing public. ── */
1292
+ router.get('/v1/organisms/:id/workspace/public/documents', async (req, res) => {
1293
+ const id = req.params.id;
1294
+ const ws = typeof req.query.ws === 'string' ? req.query.ws : '';
1295
+ const space = typeof req.query.space === 'string' ? req.query.space : undefined;
1296
+ const organism = await storage.getOrganism(id);
1297
+ if (!organism || !ws) {
1298
+ res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Not found'));
1299
+ return;
1300
+ }
1301
+ const share = await readShareMeta(id, ws);
1302
+ let docs = await collectPublicDocs(id, ws, share);
1303
+ if (space)
1304
+ docs = docs.filter(d => d.type === space);
1305
+ if (docs.length === 0) {
1306
+ res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'No public documents'));
1307
+ return;
1308
+ }
1309
+ if (req.query.format === 'md') {
1310
+ const entry = await findWsEntry(id, ws);
1311
+ res.type('text/markdown; charset=utf-8').send(docsToMarkdown(entry?.name, docs));
1312
+ return;
1313
+ }
1314
+ res.json(success(config.nodeId, { organism_id: id, ws, documents: docs }));
1315
+ });
1316
+ /* ── GET /v1/organisms/:id/workspace/public/document?ws=&type=&id=&format= — NO AUTH. A single
1317
+ * published+public document-space page. ?format=md returns its raw markdown. 404 otherwise. ── */
1318
+ router.get('/v1/organisms/:id/workspace/public/document', async (req, res) => {
1319
+ const id = req.params.id;
1320
+ const ws = typeof req.query.ws === 'string' ? req.query.ws : '';
1321
+ const type = typeof req.query.type === 'string' ? req.query.type : '';
1322
+ const docId = typeof req.query.id === 'string' ? req.query.id : '';
1323
+ const organism = await storage.getOrganism(id);
1324
+ if (!organism || !ws || !type || !docId) {
1325
+ res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Not found'));
1326
+ return;
1327
+ }
1328
+ const share = await readShareMeta(id, ws);
1329
+ const docs = await collectPublicDocs(id, ws, share, { type, id: docId });
1330
+ if (docs.length === 0) {
1331
+ res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Document not found or not public'));
1332
+ return;
1333
+ }
1334
+ const doc = docs[0];
1335
+ if (req.query.format === 'md') {
1336
+ res.type('text/markdown; charset=utf-8').send(`# ${doc.title}\n\n${doc.markdown.trim()}\n`);
1337
+ return;
1338
+ }
1339
+ res.json(success(config.nodeId, { organism_id: id, ws, document: doc }));
1340
+ });
1341
+ /* ── GET /v1/organisms/:id/workspace/share?ws= — the current share state (for the UI toggles).
1342
+ * Any active member may read it. ── */
1343
+ router.get('/v1/organisms/:id/workspace/share', requireAuth(), async (req, res) => {
1344
+ const id = req.params.id;
1345
+ const ws = typeof req.query.ws === 'string' ? req.query.ws : '';
1346
+ const organism = await storage.getOrganism(id);
1347
+ if (!organism) {
1348
+ res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
1349
+ return;
1350
+ }
1351
+ if (!ws) {
1352
+ res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'ws is required'));
1353
+ return;
1354
+ }
1355
+ const role = await memberRole(req, organism, id);
1356
+ if (!role) {
1357
+ res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Not an active member of this organism'));
1358
+ return;
1359
+ }
1360
+ res.json(success(config.nodeId, { organism_id: id, ws, share: await readShareMeta(id, ws) }));
1361
+ });
1362
+ /* ── PUT /v1/organisms/:id/workspace/share?ws= — set the share state. Body { public?, spaces?, docs? }
1363
+ * is MERGED into the existing meta.share. The workspace creator or an org admin only. The record is
1364
+ * stored under the workspace creator's GHII so there is exactly one canonical share record. ── */
1365
+ router.put('/v1/organisms/:id/workspace/share', requireAuth(), async (req, res) => {
1366
+ const id = req.params.id;
1367
+ const ws = typeof req.query.ws === 'string' ? req.query.ws : '';
1368
+ const organism = await storage.getOrganism(id);
1369
+ if (!organism) {
1370
+ res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
1371
+ return;
1372
+ }
1373
+ if (!ws) {
1374
+ res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'ws is required'));
1375
+ return;
1376
+ }
1377
+ const role = await memberRole(req, organism, id);
1378
+ if (!role) {
1379
+ res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Not an active member of this organism'));
1380
+ return;
1381
+ }
1382
+ const entry = await findWsEntry(id, ws);
1383
+ if (!entry) {
1384
+ res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Workspace not found'));
1385
+ return;
1386
+ }
1387
+ const createdBy = entry.createdBy ?? bareOwner(entry.ownerGaii);
1388
+ if (createdBy !== req.auth.owner && role !== 'creator' && role !== 'admin') {
1389
+ res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the workspace creator or an org admin can change sharing'));
1390
+ return;
1391
+ }
1392
+ const body = (req.body ?? {});
1393
+ const isBoolMap = (m) => !!m && typeof m === 'object' && !Array.isArray(m) && Object.values(m).every(v => typeof v === 'boolean');
1394
+ if (body.spaces !== undefined && !isBoolMap(body.spaces)) {
1395
+ res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'spaces must be a map of name → boolean'));
1396
+ return;
1397
+ }
1398
+ if (body.docs !== undefined && !isBoolMap(body.docs)) {
1399
+ res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'docs must be a map of key → boolean'));
1400
+ return;
1401
+ }
1402
+ const prev = await readShareMeta(id, ws);
1403
+ const next = {
1404
+ public: typeof body.public === 'boolean' ? body.public : prev.public,
1405
+ spaces: { ...prev.spaces, ...(body.spaces ?? {}) },
1406
+ docs: { ...prev.docs, ...(body.docs ?? {}) },
1407
+ };
1408
+ const key = `organism.${id}.w.${ws}.meta.share`;
1409
+ const existing = (await storage.listAllMemory({ prefix: key, limit: 10 })).items.find(r => r.key === key);
1410
+ const now = new Date().toISOString();
1411
+ await storage.setMemory({
1412
+ key, ownerGaii: entry.ownerGaii, value: next, visibility: 'private', tags: ['share'], ttlHours: null,
1413
+ version: existing ? existing.version + 1 : 1, createdAt: existing?.createdAt ?? now, updatedAt: now,
1414
+ });
1415
+ emitChange('organisms');
1416
+ res.json(success(config.nodeId, { organism_id: id, ws, share: next }));
1417
+ });
1092
1418
  /* ── GET /v1/organisms/:id/workspace/export?ws= — download a full-fidelity ZIP backup of a
1093
1419
  * workspace (workspace.json + images/). The workspace creator (or an org admin) only. ── */
1094
1420
  router.get('/v1/organisms/:id/workspace/export', requireAuth(), async (req, res) => {