aimeat 1.20.1 → 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 +8 -4
  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 +4 -1
  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 +12 -0
  22. package/dist/src/mcp/workspaces.d.ts.map +1 -1
  23. package/dist/src/mcp/workspaces.js +107 -49
  24. package/dist/src/mcp/workspaces.js.map +1 -1
  25. package/dist/src/middleware/workspace-access.d.ts +3 -0
  26. package/dist/src/middleware/workspace-access.d.ts.map +1 -1
  27. package/dist/src/middleware/workspace-access.js +31 -20
  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 +356 -34
  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
@@ -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,30 +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
- // Own records pass directly. A SAME-OWNER record (the workspace was created by this caller's
507
- // owner, or by a sibling agent of the same owner) also passes: an agent is its owner's tool, so
508
- // it sees the owner's workspace exactly as the owner does — and the owner sees its agents' writes.
509
- // Cross-owner records still go through the shared read guard (creator-gated content).
510
- if (rec.ownerGaii === callerGaii || isSameOwner(rec.ownerGaii, callerGaii)) {
511
- readable.push(rec);
512
- 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;
513
528
  }
514
- const decision = await authorizeRead(storage, config, {
515
- ownerGaii: rec.ownerGaii,
516
- accessorGaii: callerGaii,
517
- resourceKey: rec.key,
518
- visibility: rec.visibility,
519
- groupId: rec.groupId,
520
- action: 'read',
521
- });
522
- if (decision.allowed)
523
- readable.push(rec);
524
529
  }
530
+ const readable = canReadWorkspace ? items : [];
525
531
  const byKey = new Map(readable.map(r => [r.key, r]));
526
532
  const manifestRec = byKey.get(`${nsRoot}meta.manifest`);
527
533
  const manifest = manifestRec?.value ?? null;
@@ -729,6 +735,123 @@ export function organismsRouter(config, storage) {
729
735
  const now = new Date().toISOString();
730
736
  await storage.createConsent({ id: uuidv4(), ownerGaii, dataPattern, recipient, purpose, scope: 'private', expires: null, status: 'active', grantedAt: now, revokedAt: null });
731
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
+ };
732
855
  /* ── GET /v1/organisms/:id/workspaces — discover every workspace in the org (membership-gated,
733
856
  * names + creator + your access status). Discovery is open to members; content stays gated. ── */
734
857
  router.get('/v1/organisms/:id/workspaces', requireAuth(), async (req, res) => {
@@ -844,15 +967,15 @@ export function organismsRouter(config, storage) {
844
967
  return;
845
968
  }
846
969
  const creatorGhii = `${createdBy}@${config.nodeId}`;
847
- const grants = (await storage.listConsents(creatorGhii, { status: 'active' })).filter(c => c.dataPattern === `organism.${id}.w.${ws}.**` && c.purpose === 'workspace-access');
848
- const approved = new Set(grants.map(c => c.recipient));
970
+ const roles = await memberRolesForWs(creatorGhii, id, ws); // owner → 'viewer' | 'contributor'
849
971
  const { items } = await storage.listAllMemory({ prefix: `organism.${id}.w.${ws}.access.request.`, limit: 1000 });
850
972
  const requests = items.map(r => {
851
973
  const v = r.value;
852
974
  const requester = v.requester ?? bareOwner(r.ownerGaii);
853
- 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 };
854
976
  });
855
- 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 }));
856
979
  });
857
980
  /* ── POST /v1/organisms/:id/workspace-access/decision — the workspace creator approves or denies a
858
981
  * request. Approve → grant the requester read access to the workspace's content. Body:
@@ -885,23 +1008,23 @@ export function organismsRouter(config, storage) {
885
1008
  res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the workspace creator or an org admin can decide access'));
886
1009
  return;
887
1010
  }
888
- const creatorGhii = resolveIdentity(req.auth, config.nodeId);
889
- const recipient = `ghii:${requester}@${config.nodeId}`;
890
- 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}`;
891
1015
  if (decision === 'approve') {
892
- await ensureConsent(creatorGhii, pattern, recipient, 'workspace-access');
1016
+ const role = (req.body?.role === 'viewer' ? 'viewer' : 'contributor');
1017
+ await setWorkspaceRole(wsCreatorGhii, id, ws, requester, role);
893
1018
  await notify(storage, `${requester}@${config.nodeId}`, {
894
1019
  type: 'workspace_access_approved',
895
- title: `Your access to "${entry.name ?? ws}" was approved`,
1020
+ title: `Your access to "${entry.name ?? ws}" was approved (${role})`,
896
1021
  link: '/v1/profile#organisms',
897
1022
  });
898
1023
  emitChange('notifications');
899
- res.json(success(config.nodeId, { status: 'approved', ws, requester }));
1024
+ res.json(success(config.nodeId, { status: 'approved', ws, requester, role }));
900
1025
  }
901
1026
  else {
902
- const grants = (await storage.listConsents(creatorGhii, { status: 'active' })).filter(c => c.dataPattern === pattern && c.recipient === recipient && c.purpose === 'workspace-access');
903
- for (const g of grants)
904
- await storage.updateConsent(g.id, { status: 'revoked', revokedAt: new Date().toISOString() });
1027
+ await revokeWorkspaceRole(wsCreatorGhii, id, ws, requester);
905
1028
  await notify(storage, `${requester}@${config.nodeId}`, {
906
1029
  type: 'workspace_access_denied',
907
1030
  title: `Your access request to "${entry.name ?? ws}" was declined`,
@@ -911,6 +1034,75 @@ export function organismsRouter(config, storage) {
911
1034
  res.json(success(config.nodeId, { status: 'denied', ws, requester }));
912
1035
  }
913
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
+ });
914
1106
  /* ── GET /v1/organisms/:id/workspace/activity?ws= — deterministic activity feed for a workspace,
915
1107
  * derived from the version history: who did what, in which space, draft-edit vs publish, when.
916
1108
  * Each .version.N = a publish event; each .draft = an edit event. Member-gated; access-filtered. ── */
@@ -1079,6 +1271,7 @@ export function organismsRouter(config, storage) {
1079
1271
  orgId: id, ws, callerOwner: req.auth.owner,
1080
1272
  isAdmin: role === 'admin' || role === 'creator',
1081
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),
1082
1275
  manifest: req.body?.manifest, schemas: req.body?.schemas,
1083
1276
  });
1084
1277
  emitChange('organisms');
@@ -1093,6 +1286,135 @@ export function organismsRouter(config, storage) {
1093
1286
  res.status(500).json(error(config.nodeId, 'UPDATE_FAILED', e.message || 'Could not update the workspace'));
1094
1287
  }
1095
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
+ });
1096
1418
  /* ── GET /v1/organisms/:id/workspace/export?ws= — download a full-fidelity ZIP backup of a
1097
1419
  * workspace (workspace.json + images/). The workspace creator (or an org admin) only. ── */
1098
1420
  router.get('/v1/organisms/:id/workspace/export', requireAuth(), async (req, res) => {