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.
- package/dist/locales/en.json +42 -1
- package/dist/locales/fi.json +42 -1
- package/dist/public/css/views/profile.css +81 -5
- package/dist/public/css/views/public-workspace-viewer.css +206 -0
- package/dist/public/js/services/organisms.js +122 -1
- package/dist/public/llms-template.txt +7 -2
- package/dist/public/spa.html +2 -0
- package/dist/public/views/profile/organisms-tab.js +157 -9
- package/dist/public/views/public-workspace-viewer.js +128 -0
- package/dist/src/cli/connect/mcp/tools/workspaces.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/workspaces.js +8 -4
- package/dist/src/cli/connect/mcp/tools/workspaces.js.map +1 -1
- package/dist/src/cli/connect/tool-call.d.ts.map +1 -1
- package/dist/src/cli/connect/tool-call.js +4 -1
- package/dist/src/cli/connect/tool-call.js.map +1 -1
- package/dist/src/generated/api-types.d.ts +204 -0
- package/dist/src/generated/api-types.d.ts.map +1 -1
- package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
- package/dist/src/mcp/catalog/definitions.js +3 -2
- package/dist/src/mcp/catalog/definitions.js.map +1 -1
- package/dist/src/mcp/workspaces.d.ts +12 -0
- package/dist/src/mcp/workspaces.d.ts.map +1 -1
- package/dist/src/mcp/workspaces.js +107 -49
- package/dist/src/mcp/workspaces.js.map +1 -1
- package/dist/src/middleware/workspace-access.d.ts +3 -0
- package/dist/src/middleware/workspace-access.d.ts.map +1 -1
- package/dist/src/middleware/workspace-access.js +31 -20
- package/dist/src/middleware/workspace-access.js.map +1 -1
- package/dist/src/routes/organisms.d.ts +11 -0
- package/dist/src/routes/organisms.d.ts.map +1 -1
- package/dist/src/routes/organisms.js +356 -34
- package/dist/src/routes/organisms.js.map +1 -1
- package/dist/src/routes/portal.d.ts.map +1 -1
- package/dist/src/routes/portal.js +1 -0
- package/dist/src/routes/portal.js.map +1 -1
- package/dist/src/services/handbooks/agent.d.ts +3 -1
- package/dist/src/services/handbooks/agent.d.ts.map +1 -1
- package/dist/src/services/handbooks/agent.js +3 -1
- package/dist/src/services/handbooks/agent.js.map +1 -1
- package/dist/src/services/handbooks/appdev.d.ts +3 -1
- package/dist/src/services/handbooks/appdev.d.ts.map +1 -1
- package/dist/src/services/handbooks/appdev.js +19 -0
- package/dist/src/services/handbooks/appdev.js.map +1 -1
- package/dist/src/services/workspace-meta.d.ts +10 -0
- package/dist/src/services/workspace-meta.d.ts.map +1 -1
- package/dist/src/services/workspace-meta.js +50 -3
- package/dist/src/services/workspace-meta.js.map +1 -1
- 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
|
-
//
|
|
502
|
-
//
|
|
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
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
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:
|
|
975
|
+
return { requester, message: v.message ?? '', created_at: v.createdAt, status: roles.has(requester) ? 'approved' : 'pending', role: roles.get(requester) ?? null };
|
|
854
976
|
});
|
|
855
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|