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.
- 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 +15 -9
- 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 +10 -6
- 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 +19 -0
- package/dist/src/mcp/workspaces.d.ts.map +1 -1
- package/dist/src/mcp/workspaces.js +147 -51
- package/dist/src/mcp/workspaces.js.map +1 -1
- package/dist/src/middleware/workspace-access.d.ts +5 -0
- package/dist/src/middleware/workspace-access.d.ts.map +1 -1
- package/dist/src/middleware/workspace-access.js +52 -15
- 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 +357 -31
- 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
|
@@ -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
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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":"
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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:
|
|
975
|
+
return { requester, message: v.message ?? '', created_at: v.createdAt, status: roles.has(requester) ? 'approved' : 'pending', role: roles.get(requester) ?? null };
|
|
850
976
|
});
|
|
851
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|