aimeat 1.20.2 → 1.20.4
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/README.md +1 -1
- package/dist/locales/en.json +60 -0
- package/dist/locales/fi.json +60 -0
- package/dist/public/css/views/agents-detail.css +4 -0
- package/dist/public/css/views/profile.css +9 -0
- package/dist/public/css/views/public-workspace-viewer.css +23 -3
- package/dist/public/js/services/organisms.js +79 -3
- package/dist/public/views/profile/agents-tab.js +1 -1
- package/dist/public/views/profile/organisms-tab.js +355 -3
- package/dist/public/views/public-workspace-viewer.js +74 -11
- package/dist/src/cli/connect/auth.js +1 -1
- package/dist/src/cli/connect/auth.js.map +1 -1
- package/dist/src/cli/connect/config.d.ts +1 -1
- package/dist/src/cli/connect/config.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/server.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/server.js +5 -1
- package/dist/src/cli/connect/mcp/server.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/agent-management.js +1 -1
- package/dist/src/cli/connect/mcp/tools/agent-management.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/index.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/index.js +2 -0
- package/dist/src/cli/connect/mcp/tools/index.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/organisms.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/organisms.js +57 -0
- package/dist/src/cli/connect/mcp/tools/organisms.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/schedules.d.ts +15 -0
- package/dist/src/cli/connect/mcp/tools/schedules.d.ts.map +1 -0
- package/dist/src/cli/connect/mcp/tools/schedules.js +66 -0
- package/dist/src/cli/connect/mcp/tools/schedules.js.map +1 -0
- package/dist/src/cli/connect/tool-call.d.ts.map +1 -1
- package/dist/src/cli/connect/tool-call.js +81 -2
- package/dist/src/cli/connect/tool-call.js.map +1 -1
- package/dist/src/cli/init-wizard.d.ts.map +1 -1
- package/dist/src/cli/init-wizard.js +11 -3
- package/dist/src/cli/init-wizard.js.map +1 -1
- package/dist/src/generated/api-types.d.ts +540 -0
- package/dist/src/generated/api-types.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/mcp/agent-management.d.ts.map +1 -1
- package/dist/src/mcp/agent-management.js +4 -3
- package/dist/src/mcp/agent-management.js.map +1 -1
- package/dist/src/mcp/annotations.d.ts.map +1 -1
- package/dist/src/mcp/annotations.js +6 -0
- package/dist/src/mcp/annotations.js.map +1 -1
- package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
- package/dist/src/mcp/catalog/definitions.js +67 -2
- package/dist/src/mcp/catalog/definitions.js.map +1 -1
- package/dist/src/mcp/catalog/scopes.d.ts +3 -2
- package/dist/src/mcp/catalog/scopes.d.ts.map +1 -1
- package/dist/src/mcp/catalog/scopes.js +4 -2
- package/dist/src/mcp/catalog/scopes.js.map +1 -1
- package/dist/src/mcp/catalog/surfaces.js +6 -6
- package/dist/src/mcp/catalog/surfaces.js.map +1 -1
- package/dist/src/mcp/organisms.d.ts.map +1 -1
- package/dist/src/mcp/organisms.js +145 -0
- package/dist/src/mcp/organisms.js.map +1 -1
- package/dist/src/models/agent-onboarding-schemas.d.ts +8 -1
- package/dist/src/models/agent-onboarding-schemas.d.ts.map +1 -1
- package/dist/src/models/agent-onboarding-schemas.js +30 -1
- package/dist/src/models/agent-onboarding-schemas.js.map +1 -1
- package/dist/src/models/schemas.d.ts +1 -0
- package/dist/src/models/schemas.d.ts.map +1 -1
- package/dist/src/models/schemas.js +1 -1
- package/dist/src/models/schemas.js.map +1 -1
- package/dist/src/routes/agents.d.ts.map +1 -1
- package/dist/src/routes/agents.js +4 -3
- package/dist/src/routes/agents.js.map +1 -1
- package/dist/src/routes/organisms.d.ts +3 -0
- package/dist/src/routes/organisms.d.ts.map +1 -1
- package/dist/src/routes/organisms.js +475 -0
- package/dist/src/routes/organisms.js.map +1 -1
- package/dist/src/services/handbooks/admin.d.ts +1 -1
- package/dist/src/services/handbooks/admin.d.ts.map +1 -1
- package/dist/src/services/handbooks/admin.js +1 -1
- package/dist/src/services/organism-comments.d.ts +30 -0
- package/dist/src/services/organism-comments.d.ts.map +1 -0
- package/dist/src/services/organism-comments.js +74 -0
- package/dist/src/services/organism-comments.js.map +1 -0
- package/dist/src/services/organism-search.d.ts +27 -0
- package/dist/src/services/organism-search.d.ts.map +1 -0
- package/dist/src/services/organism-search.js +84 -0
- package/dist/src/services/organism-search.js.map +1 -0
- package/dist/src/storage/interface.d.ts +13 -4
- package/dist/src/storage/interface.d.ts.map +1 -1
- package/dist/src/storage/interface.js.map +1 -1
- package/dist/src/storage/providers/sqlite/index.js.map +1 -1
- package/dist/src/storage/providers/sqlite/schema.js +1 -1
- package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
- package/package.json +1 -1
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
* v1.10.0 -- 2026-06-09 -- Workspace-level read: GET /:id/workspace authorizes once on the manifest
|
|
38
38
|
* (creator / same-owner / a viewer|contributor grant) and then returns ALL content, not per record —
|
|
39
39
|
* so a contributor's writes are visible to the creator + every member of a shared workspace.
|
|
40
|
+
* v1.11.0 -- 2026-06-09 -- Organism-level member management: a join request now notifies the
|
|
41
|
+
* creator/admins (and the requester on review decision); new DELETE /:id/members/:ghii lets a
|
|
42
|
+
* creator/admin revoke a member's access (notifies the removed member).
|
|
40
43
|
*/
|
|
41
44
|
import { Router, raw } from 'express';
|
|
42
45
|
import { v4 as uuidv4 } from 'uuid';
|
|
@@ -53,6 +56,8 @@ import { exportWorkspace } from '../services/workspace-export.js';
|
|
|
53
56
|
import { importWorkspace } from '../services/workspace-import.js';
|
|
54
57
|
import { exportOrganism } from '../services/organism-export.js';
|
|
55
58
|
import { importOrganism } from '../services/organism-import.js';
|
|
59
|
+
import { searchOrganismContent } from '../services/organism-search.js';
|
|
60
|
+
import { canAccessWorkspaceComments, addComment, listComments, commentPrefix } from '../services/organism-comments.js';
|
|
56
61
|
import { ZipSecurityError } from '../services/safe-zip.js';
|
|
57
62
|
import { recordSecurityIncident } from '../services/security-incident.js';
|
|
58
63
|
import { updateWorkspaceMeta, WorkspaceMetaError } from '../services/workspace-meta.js';
|
|
@@ -241,6 +246,10 @@ export function organismsRouter(config, storage) {
|
|
|
241
246
|
}
|
|
242
247
|
// Check if already a member
|
|
243
248
|
const existing = await storage.getMembership(id, ghii);
|
|
249
|
+
if (existing && existing.status === 'banned') {
|
|
250
|
+
res.status(403).json(error(config.nodeId, 'BANNED', 'You have been blocked from this organism'));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
244
253
|
if (existing && existing.status === 'active') {
|
|
245
254
|
res.status(409).json(error(config.nodeId, 'ALREADY_MEMBER', 'You are already a member'));
|
|
246
255
|
return;
|
|
@@ -249,6 +258,10 @@ export function organismsRouter(config, storage) {
|
|
|
249
258
|
res.status(409).json(error(config.nodeId, 'ALREADY_PENDING', 'You already have a pending join request'));
|
|
250
259
|
return;
|
|
251
260
|
}
|
|
261
|
+
if (existing && existing.status === 'invited') {
|
|
262
|
+
res.status(409).json(error(config.nodeId, 'ALREADY_INVITED', 'You have a pending invitation — accept it instead of requesting to join'));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
252
265
|
// Check capacity
|
|
253
266
|
const members = await storage.listMembers(id, { status: 'active' });
|
|
254
267
|
if (members.length >= organism.maxMembers) {
|
|
@@ -284,6 +297,18 @@ export function organismsRouter(config, storage) {
|
|
|
284
297
|
status: 'pending',
|
|
285
298
|
createdAt: now,
|
|
286
299
|
});
|
|
300
|
+
// Notify the creator + admins so the request doesn't go unnoticed (mirrors the
|
|
301
|
+
// workspace-access request path). admins includes the creator, so dedupe.
|
|
302
|
+
const approvers = [...new Set([organism.creatorGhii, ...organism.admins])];
|
|
303
|
+
for (const approver of approvers) {
|
|
304
|
+
await notify(storage, `${approver}@${config.nodeId}`, {
|
|
305
|
+
type: 'organism_join_request',
|
|
306
|
+
title: `${ghii} requested to join "${organism.name}"`,
|
|
307
|
+
body: typeof message === 'string' ? message : '',
|
|
308
|
+
link: '/v1/profile#organisms',
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
emitChange('notifications');
|
|
287
312
|
res.status(202).json(success(config.nodeId, { join_request: request, status: 'pending' }));
|
|
288
313
|
emitChange('organisms');
|
|
289
314
|
}
|
|
@@ -403,6 +428,15 @@ export function organismsRouter(config, storage) {
|
|
|
403
428
|
updatedAt: now,
|
|
404
429
|
});
|
|
405
430
|
}
|
|
431
|
+
// Notify the requester of the decision so it surfaces in their bell.
|
|
432
|
+
await notify(storage, `${request.ghii}@${config.nodeId}`, {
|
|
433
|
+
type: decision === 'approved' ? 'organism_join_approved' : 'organism_join_rejected',
|
|
434
|
+
title: decision === 'approved'
|
|
435
|
+
? `You were approved to join "${organism.name}"`
|
|
436
|
+
: `Your request to join "${organism.name}" was declined`,
|
|
437
|
+
link: '/v1/profile#organisms',
|
|
438
|
+
});
|
|
439
|
+
emitChange('notifications');
|
|
406
440
|
res.json(success(config.nodeId, { decision, request_id: requestId }));
|
|
407
441
|
emitChange('organisms');
|
|
408
442
|
});
|
|
@@ -472,6 +506,324 @@ export function organismsRouter(config, storage) {
|
|
|
472
506
|
res.json(success(config.nodeId, { demoted: targetGhii, role: 'member' }));
|
|
473
507
|
emitChange('organisms');
|
|
474
508
|
});
|
|
509
|
+
/* ── DELETE /v1/organisms/:id/members/:ghii — Remove (revoke) a member ──
|
|
510
|
+
* Creator/admin removes another member's organism access entirely: deletes the
|
|
511
|
+
* membership and drops them from members[] (+ admins[] if applicable). The creator
|
|
512
|
+
* cannot be removed (they must delete the organism); an admin can only be removed by
|
|
513
|
+
* the creator. The removed member is notified. To leave voluntarily, use /leave. */
|
|
514
|
+
router.delete('/v1/organisms/:id/members/:ghii', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
515
|
+
const callerGhii = req.auth.owner;
|
|
516
|
+
const id = req.params.id;
|
|
517
|
+
const targetGhii = decodeURIComponent(req.params.ghii);
|
|
518
|
+
const organism = await storage.getOrganism(id);
|
|
519
|
+
if (!organism) {
|
|
520
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const callerIsCreator = organism.creatorGhii === callerGhii;
|
|
524
|
+
const callerIsAdmin = callerIsCreator || organism.admins.includes(callerGhii);
|
|
525
|
+
if (!callerIsAdmin) {
|
|
526
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the creator or an admin can remove members'));
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (targetGhii === organism.creatorGhii) {
|
|
530
|
+
res.status(400).json(error(config.nodeId, 'CANNOT_REMOVE_CREATOR', 'The creator cannot be removed. Delete the organism instead.'));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// Only the creator can remove an admin.
|
|
534
|
+
if (organism.admins.includes(targetGhii) && !callerIsCreator) {
|
|
535
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the creator can remove an admin'));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const membership = await storage.getMembership(id, targetGhii);
|
|
539
|
+
if (!membership || membership.status !== 'active') {
|
|
540
|
+
res.status(404).json(error(config.nodeId, 'NOT_MEMBER', 'Target is not an active member'));
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
// `?ban=1` (or body { ban: true }) blocks the member from re-joining / being re-invited:
|
|
544
|
+
// keep the membership row but flip it to `banned` instead of deleting it. Plain remove just
|
|
545
|
+
// deletes the row (they can request to join again).
|
|
546
|
+
const ban = req.query.ban === '1' || req.query.ban === 'true' || req.body?.ban === true;
|
|
547
|
+
const now = new Date().toISOString();
|
|
548
|
+
if (ban) {
|
|
549
|
+
await storage.updateMembership(membership.id, { status: 'banned', role: 'member' });
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
await storage.deleteMembership(membership.id);
|
|
553
|
+
}
|
|
554
|
+
await storage.updateOrganism(id, {
|
|
555
|
+
members: organism.members.filter(m => m !== targetGhii),
|
|
556
|
+
admins: organism.admins.filter(a => a !== targetGhii),
|
|
557
|
+
updatedAt: now,
|
|
558
|
+
});
|
|
559
|
+
// Let the removed member know their access was revoked.
|
|
560
|
+
await notify(storage, `${targetGhii}@${config.nodeId}`, {
|
|
561
|
+
type: ban ? 'organism_member_banned' : 'organism_member_removed',
|
|
562
|
+
title: ban
|
|
563
|
+
? `You were blocked from "${organism.name}"`
|
|
564
|
+
: `Your access to "${organism.name}" was revoked`,
|
|
565
|
+
link: '/v1/profile#organisms',
|
|
566
|
+
});
|
|
567
|
+
emitChange('notifications');
|
|
568
|
+
res.json(success(config.nodeId, { removed: targetGhii, banned: ban }));
|
|
569
|
+
emitChange('organisms');
|
|
570
|
+
});
|
|
571
|
+
/* ── POST /v1/organisms/:id/members/:ghii/unban — Lift a ban ──
|
|
572
|
+
* Creator/admin removes the block on a previously-banned owner by deleting the banned
|
|
573
|
+
* membership row, so they can request to join (or be invited) again. */
|
|
574
|
+
router.post('/v1/organisms/:id/members/:ghii/unban', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
575
|
+
const callerGhii = req.auth.owner;
|
|
576
|
+
const id = req.params.id;
|
|
577
|
+
const targetGhii = decodeURIComponent(req.params.ghii);
|
|
578
|
+
const organism = await storage.getOrganism(id);
|
|
579
|
+
if (!organism) {
|
|
580
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (organism.creatorGhii !== callerGhii && !organism.admins.includes(callerGhii)) {
|
|
584
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the creator or an admin can lift a block'));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const membership = await storage.getMembership(id, targetGhii);
|
|
588
|
+
if (!membership || membership.status !== 'banned') {
|
|
589
|
+
res.status(404).json(error(config.nodeId, 'NOT_BANNED', 'Target is not blocked'));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
await storage.deleteMembership(membership.id);
|
|
593
|
+
res.json(success(config.nodeId, { unbanned: targetGhii }));
|
|
594
|
+
emitChange('organisms');
|
|
595
|
+
});
|
|
596
|
+
/* ── POST /v1/organisms/:id/transfer — Transfer ownership ──
|
|
597
|
+
* The creator hands the organism to an existing active member: the target becomes `creator`,
|
|
598
|
+
* the previous creator is demoted to `admin` (kept as a member). Only the current creator may
|
|
599
|
+
* call this. The new creator is notified. */
|
|
600
|
+
router.post('/v1/organisms/:id/transfer', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
601
|
+
const callerGhii = req.auth.owner;
|
|
602
|
+
const id = req.params.id;
|
|
603
|
+
const { to } = req.body ?? {};
|
|
604
|
+
if (!to || typeof to !== 'string') {
|
|
605
|
+
res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'Body field "to" (an active member) is required'));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const organism = await storage.getOrganism(id);
|
|
609
|
+
if (!organism) {
|
|
610
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (organism.creatorGhii !== callerGhii) {
|
|
614
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the current creator can transfer ownership'));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (to === callerGhii) {
|
|
618
|
+
res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'You are already the creator'));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const targetMembership = await storage.getMembership(id, to);
|
|
622
|
+
if (!targetMembership || targetMembership.status !== 'active') {
|
|
623
|
+
res.status(404).json(error(config.nodeId, 'NOT_MEMBER', 'Target must be an active member of this organism'));
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const callerMembership = await storage.getMembership(id, callerGhii);
|
|
627
|
+
const now = new Date().toISOString();
|
|
628
|
+
// Promote the new creator, demote the old one to admin.
|
|
629
|
+
await storage.updateMembership(targetMembership.id, { role: 'creator' });
|
|
630
|
+
if (callerMembership)
|
|
631
|
+
await storage.updateMembership(callerMembership.id, { role: 'admin' });
|
|
632
|
+
const admins = [...new Set([...organism.admins.filter(a => a !== to), callerGhii])];
|
|
633
|
+
await storage.updateOrganism(id, { creatorGhii: to, admins, updatedAt: now });
|
|
634
|
+
await notify(storage, `${to}@${config.nodeId}`, {
|
|
635
|
+
type: 'organism_ownership_transferred',
|
|
636
|
+
title: `You are now the creator of "${organism.name}"`,
|
|
637
|
+
link: '/v1/profile#organisms',
|
|
638
|
+
});
|
|
639
|
+
emitChange('notifications');
|
|
640
|
+
res.json(success(config.nodeId, { creator: to, previousCreator: callerGhii }));
|
|
641
|
+
emitChange('organisms');
|
|
642
|
+
});
|
|
643
|
+
/* ── Invitations (invite_only flow; works for any policy) ──
|
|
644
|
+
* A creator/admin invites an owner by bare name. The invite is a membership row with
|
|
645
|
+
* status `invited` + `invitedBy`. The invitee is notified and accepts/declines. */
|
|
646
|
+
/* POST /v1/organisms/:id/invitations — invite an owner */
|
|
647
|
+
router.post('/v1/organisms/:id/invitations', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
648
|
+
const callerGhii = req.auth.owner;
|
|
649
|
+
const id = req.params.id;
|
|
650
|
+
const { invitee } = req.body ?? {};
|
|
651
|
+
if (!invitee || typeof invitee !== 'string') {
|
|
652
|
+
res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'Body field "invitee" (an owner name) is required'));
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const organism = await storage.getOrganism(id);
|
|
656
|
+
if (!organism) {
|
|
657
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (organism.creatorGhii !== callerGhii && !organism.admins.includes(callerGhii)) {
|
|
661
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the creator or an admin can invite members'));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const existing = await storage.getMembership(id, invitee);
|
|
665
|
+
if (existing && existing.status === 'active') {
|
|
666
|
+
res.status(409).json(error(config.nodeId, 'ALREADY_MEMBER', 'That owner is already a member'));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (existing && existing.status === 'invited') {
|
|
670
|
+
res.status(409).json(error(config.nodeId, 'ALREADY_INVITED', 'That owner already has a pending invitation'));
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (existing && existing.status === 'banned') {
|
|
674
|
+
res.status(409).json(error(config.nodeId, 'BANNED', 'That owner is blocked — lift the block before inviting'));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const now = new Date().toISOString();
|
|
678
|
+
const membership = await storage.createMembership({
|
|
679
|
+
id: uuidv4(),
|
|
680
|
+
organismId: id,
|
|
681
|
+
ghii: invitee,
|
|
682
|
+
role: 'member',
|
|
683
|
+
status: 'invited',
|
|
684
|
+
invitedBy: callerGhii,
|
|
685
|
+
joinedAt: now,
|
|
686
|
+
});
|
|
687
|
+
await notify(storage, `${invitee}@${config.nodeId}`, {
|
|
688
|
+
type: 'organism_invitation',
|
|
689
|
+
title: `${callerGhii} invited you to join "${organism.name}"`,
|
|
690
|
+
link: '/v1/profile#organisms',
|
|
691
|
+
});
|
|
692
|
+
emitChange('notifications');
|
|
693
|
+
res.status(201).json(success(config.nodeId, { invitation: membership, status: 'invited' }));
|
|
694
|
+
emitChange('organisms');
|
|
695
|
+
});
|
|
696
|
+
/* GET /v1/organisms/:id/invitations — list outstanding invitations (creator/admin) */
|
|
697
|
+
router.get('/v1/organisms/:id/invitations', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
698
|
+
const callerGhii = req.auth.owner;
|
|
699
|
+
const id = req.params.id;
|
|
700
|
+
const organism = await storage.getOrganism(id);
|
|
701
|
+
if (!organism) {
|
|
702
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (organism.creatorGhii !== callerGhii && !organism.admins.includes(callerGhii)) {
|
|
706
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the creator or an admin can view invitations'));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const invitations = await storage.listMembers(id, { status: 'invited' });
|
|
710
|
+
res.json(success(config.nodeId, { invitations, total: invitations.length }));
|
|
711
|
+
});
|
|
712
|
+
/* GET /v1/organisms/invitations/mine — the caller's own pending invitations (across organisms) */
|
|
713
|
+
router.get('/v1/organisms/invitations/mine', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
714
|
+
const callerGhii = req.auth.owner;
|
|
715
|
+
const memberships = (await storage.listMembershipsByGhii(callerGhii)).filter(m => m.status === 'invited');
|
|
716
|
+
const invitations = [];
|
|
717
|
+
for (const m of memberships) {
|
|
718
|
+
const org = await storage.getOrganism(m.organismId);
|
|
719
|
+
if (org)
|
|
720
|
+
invitations.push({ membership: m, organism: { id: org.id, name: org.name, description: org.description, type: org.type, visibility: org.visibility } });
|
|
721
|
+
}
|
|
722
|
+
res.json(success(config.nodeId, { invitations, total: invitations.length }));
|
|
723
|
+
});
|
|
724
|
+
/* POST /v1/organisms/:id/invitations/accept — the invitee accepts */
|
|
725
|
+
router.post('/v1/organisms/:id/invitations/accept', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
726
|
+
const callerGhii = req.auth.owner;
|
|
727
|
+
const id = req.params.id;
|
|
728
|
+
const organism = await storage.getOrganism(id);
|
|
729
|
+
if (!organism) {
|
|
730
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const membership = await storage.getMembership(id, callerGhii);
|
|
734
|
+
if (!membership || membership.status !== 'invited') {
|
|
735
|
+
res.status(404).json(error(config.nodeId, 'NO_INVITATION', 'You have no pending invitation to this organism'));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const now = new Date().toISOString();
|
|
739
|
+
await storage.updateMembership(membership.id, { status: 'active', joinedAt: now });
|
|
740
|
+
await storage.updateOrganism(id, { members: [...new Set([...organism.members, callerGhii])], updatedAt: now });
|
|
741
|
+
// Notify the inviter that the invitation was accepted.
|
|
742
|
+
if (membership.invitedBy) {
|
|
743
|
+
await notify(storage, `${membership.invitedBy}@${config.nodeId}`, {
|
|
744
|
+
type: 'organism_invitation_accepted',
|
|
745
|
+
title: `${callerGhii} accepted your invitation to "${organism.name}"`,
|
|
746
|
+
link: '/v1/profile#organisms',
|
|
747
|
+
});
|
|
748
|
+
emitChange('notifications');
|
|
749
|
+
}
|
|
750
|
+
res.json(success(config.nodeId, { status: 'joined' }));
|
|
751
|
+
emitChange('organisms');
|
|
752
|
+
});
|
|
753
|
+
/* POST /v1/organisms/:id/invitations/decline — the invitee declines */
|
|
754
|
+
router.post('/v1/organisms/:id/invitations/decline', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
755
|
+
const callerGhii = req.auth.owner;
|
|
756
|
+
const id = req.params.id;
|
|
757
|
+
const membership = await storage.getMembership(id, callerGhii);
|
|
758
|
+
if (!membership || membership.status !== 'invited') {
|
|
759
|
+
res.status(404).json(error(config.nodeId, 'NO_INVITATION', 'You have no pending invitation to this organism'));
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
await storage.deleteMembership(membership.id);
|
|
763
|
+
res.json(success(config.nodeId, { status: 'declined' }));
|
|
764
|
+
emitChange('organisms');
|
|
765
|
+
});
|
|
766
|
+
/* ── Agent attachment (manage organism.agentGaiis) ──
|
|
767
|
+
* An owner attaches one of their OWN agents to an organism they belong to, so the agent shows
|
|
768
|
+
* as an organism participant and passes the workspace membership gate in its own right. */
|
|
769
|
+
/* POST /v1/organisms/:id/agents — attach an agent GAII (caller must own it + be a member) */
|
|
770
|
+
router.post('/v1/organisms/:id/agents', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
771
|
+
const callerGhii = req.auth.owner;
|
|
772
|
+
const id = req.params.id;
|
|
773
|
+
const { agent_gaii } = req.body ?? {};
|
|
774
|
+
if (!agent_gaii || typeof agent_gaii !== 'string') {
|
|
775
|
+
res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'Body field "agent_gaii" is required'));
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const parsed = parseGaiiLoose(agent_gaii);
|
|
779
|
+
if (!parsed.agent || parsed.owner !== callerGhii) {
|
|
780
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'You can only attach your own agents'));
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const organism = await storage.getOrganism(id);
|
|
784
|
+
if (!organism) {
|
|
785
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const membership = await storage.getMembership(id, callerGhii);
|
|
789
|
+
const isMember = !!membership && membership.status === 'active';
|
|
790
|
+
if (!isMember && organism.creatorGhii !== callerGhii) {
|
|
791
|
+
res.status(403).json(error(config.nodeId, 'NOT_MEMBER', 'You must be a member to attach an agent'));
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (organism.agentGaiis.includes(agent_gaii)) {
|
|
795
|
+
res.status(409).json(error(config.nodeId, 'ALREADY_ATTACHED', 'That agent is already attached'));
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
await storage.updateOrganism(id, { agentGaiis: [...organism.agentGaiis, agent_gaii], updatedAt: new Date().toISOString() });
|
|
799
|
+
res.status(201).json(success(config.nodeId, { attached: agent_gaii }));
|
|
800
|
+
emitChange('organisms');
|
|
801
|
+
});
|
|
802
|
+
/* DELETE /v1/organisms/:id/agents/:gaii — detach an agent (its owner, or creator/admin) */
|
|
803
|
+
router.delete('/v1/organisms/:id/agents/:gaii', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
804
|
+
const callerGhii = req.auth.owner;
|
|
805
|
+
const id = req.params.id;
|
|
806
|
+
const agentGaii = decodeURIComponent(req.params.gaii);
|
|
807
|
+
const organism = await storage.getOrganism(id);
|
|
808
|
+
if (!organism) {
|
|
809
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const parsed = parseGaiiLoose(agentGaii);
|
|
813
|
+
const ownsAgent = parsed.owner === callerGhii;
|
|
814
|
+
const isAdmin = organism.creatorGhii === callerGhii || organism.admins.includes(callerGhii);
|
|
815
|
+
if (!ownsAgent && !isAdmin) {
|
|
816
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the agent owner or an organism admin can detach it'));
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (!organism.agentGaiis.includes(agentGaii)) {
|
|
820
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'That agent is not attached'));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
await storage.updateOrganism(id, { agentGaiis: organism.agentGaiis.filter(g => g !== agentGaii), updatedAt: new Date().toISOString() });
|
|
824
|
+
res.json(success(config.nodeId, { detached: agentGaii }));
|
|
825
|
+
emitChange('organisms');
|
|
826
|
+
});
|
|
475
827
|
/* ── GET /v1/organisms/:id/workspace — Manifest-driven workspace read ──
|
|
476
828
|
*
|
|
477
829
|
* Generic over ANY manifest: reads `organism.{id}.meta.manifest`, then for each
|
|
@@ -610,6 +962,129 @@ export function organismsRouter(config, storage) {
|
|
|
610
962
|
{ description: 'Publish a draft', method: 'POST', url: `/v1/organisms/${id}/publish` },
|
|
611
963
|
]));
|
|
612
964
|
});
|
|
965
|
+
/* ── GET /v1/organisms/:id/search — Search organism / workspace content ──
|
|
966
|
+
* Full-text-ish (case-insensitive substring) search across the records + documents of every
|
|
967
|
+
* workspace the caller can read (or one workspace if ?ws=). Returns matches with the workspace,
|
|
968
|
+
* space (objectType), instance id, a title, and a snippet around the hit. Honours the same
|
|
969
|
+
* workspace-level read authorization as GET /:id/workspace (manifest gate) — a member only
|
|
970
|
+
* searches workspaces they may read; drafts, version history and meta records are skipped. */
|
|
971
|
+
router.get('/v1/organisms/:id/search', requireAuth(), async (req, res) => {
|
|
972
|
+
const id = req.params.id;
|
|
973
|
+
const q = (typeof req.query.q === 'string' ? req.query.q : '').trim();
|
|
974
|
+
const onlyWs = typeof req.query.ws === 'string' ? req.query.ws : undefined;
|
|
975
|
+
if (q.length < 2) {
|
|
976
|
+
res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'Query "q" must be at least 2 characters'));
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const organism = await storage.getOrganism(id);
|
|
980
|
+
if (!organism) {
|
|
981
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
// Membership gate (same as workspace read): an org agent or an active member.
|
|
985
|
+
const callerSub = req.auth.sub;
|
|
986
|
+
const ownerName = req.auth.owner;
|
|
987
|
+
let isMember = !!callerSub && organism.agentGaiis.includes(callerSub);
|
|
988
|
+
if (!isMember && ownerName) {
|
|
989
|
+
const m = await storage.getMembership(id, ownerName);
|
|
990
|
+
isMember = !!m && m.status === 'active';
|
|
991
|
+
}
|
|
992
|
+
if (!isMember) {
|
|
993
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Not an active member of this organism'));
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const callerGaii = resolveIdentity(req.auth, config.nodeId);
|
|
997
|
+
const { results, truncated } = await searchOrganismContent(storage, config, organism, callerGaii, q, onlyWs);
|
|
998
|
+
res.json(success(config.nodeId, { query: q, results, total: results.length, truncated }));
|
|
999
|
+
});
|
|
1000
|
+
/* ── Comments / threads on workspace records + documents ──
|
|
1001
|
+
* A comment targets one workspace object (record or document) by (ws, space, instance_id), can be
|
|
1002
|
+
* anchored to a part of a document (anchor.section or anchor.quote) or left general (no anchor),
|
|
1003
|
+
* and can reply to another comment (parent_id) to form threads. Comments are memory-backed under
|
|
1004
|
+
* `organism.{id}.w.{ws}.meta.comments.{space}~{instance}.{commentId}` — the meta.* prefix keeps
|
|
1005
|
+
* them OUT of the workspace read + content search. Authoring is open to any member or organism
|
|
1006
|
+
* agent (so agents can comment); read requires the same workspace-read authorization as the
|
|
1007
|
+
* content; a comment can be deleted by its author or a creator/admin. */
|
|
1008
|
+
/* POST /v1/organisms/:id/comments — add a comment */
|
|
1009
|
+
router.post('/v1/organisms/:id/comments', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
1010
|
+
const id = req.params.id;
|
|
1011
|
+
const { ws, space, instance_id, body, anchor, parent_id } = req.body ?? {};
|
|
1012
|
+
if (!ws || !space || !instance_id || typeof body !== 'string' || !body.trim()) {
|
|
1013
|
+
res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'ws, space, instance_id and a non-empty body are required'));
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const organism = await storage.getOrganism(id);
|
|
1017
|
+
if (!organism) {
|
|
1018
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const callerGaii = resolveIdentity(req.auth, config.nodeId);
|
|
1022
|
+
if (!(await canAccessWorkspaceComments(storage, config, organism, req.auth.sub, req.auth.owner, callerGaii, ws))) {
|
|
1023
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'You cannot comment in this workspace'));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const comment = await addComment(storage, id, callerGaii, { ws, space, instanceId: instance_id, body, anchor, parentId: parent_id });
|
|
1027
|
+
emitChange('organisms');
|
|
1028
|
+
res.status(201).json(success(config.nodeId, { comment }));
|
|
1029
|
+
});
|
|
1030
|
+
/* GET /v1/organisms/:id/comments?ws=&space=&instance_id= — list a target's thread */
|
|
1031
|
+
router.get('/v1/organisms/:id/comments', requireAuth(), async (req, res) => {
|
|
1032
|
+
const id = req.params.id;
|
|
1033
|
+
const ws = req.query.ws;
|
|
1034
|
+
const space = req.query.space;
|
|
1035
|
+
const instanceId = req.query.instance_id;
|
|
1036
|
+
if (!ws || !space || !instanceId) {
|
|
1037
|
+
res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'ws, space and instance_id query params are required'));
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
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;
|
|
1044
|
+
}
|
|
1045
|
+
const callerGaii = resolveIdentity(req.auth, config.nodeId);
|
|
1046
|
+
if (!(await canAccessWorkspaceComments(storage, config, organism, req.auth.sub, req.auth.owner, callerGaii, ws))) {
|
|
1047
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'You cannot read this workspace'));
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const comments = await listComments(storage, id, ws, space, instanceId);
|
|
1051
|
+
res.json(success(config.nodeId, { comments, total: comments.length }));
|
|
1052
|
+
});
|
|
1053
|
+
/* DELETE /v1/organisms/:id/comments/:commentId?ws=&space=&instance_id= — delete (author or creator/admin) */
|
|
1054
|
+
router.delete('/v1/organisms/:id/comments/:commentId', requireAuth(), requireRole('agent'), async (req, res) => {
|
|
1055
|
+
const id = req.params.id;
|
|
1056
|
+
const commentId = req.params.commentId;
|
|
1057
|
+
const ws = req.query.ws;
|
|
1058
|
+
const space = req.query.space;
|
|
1059
|
+
const instanceId = req.query.instance_id;
|
|
1060
|
+
if (!ws || !space || !instanceId) {
|
|
1061
|
+
res.status(400).json(error(config.nodeId, 'INVALID_INPUT', 'ws, space and instance_id query params are required'));
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const organism = await storage.getOrganism(id);
|
|
1065
|
+
if (!organism) {
|
|
1066
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Organism not found'));
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const callerGaii = resolveIdentity(req.auth, config.nodeId);
|
|
1070
|
+
const callerOwner = req.auth.owner;
|
|
1071
|
+
const key = `${commentPrefix(id, ws, space, instanceId)}${commentId}`;
|
|
1072
|
+
const scan = await storage.listAllMemory({ prefix: key, limit: 5 });
|
|
1073
|
+
const rec = scan.items.find(r => r.key === key);
|
|
1074
|
+
if (!rec) {
|
|
1075
|
+
res.status(404).json(error(config.nodeId, 'NOT_FOUND', 'Comment not found'));
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
const isAuthor = rec.ownerGaii === callerGaii;
|
|
1079
|
+
const isAdmin = organism.creatorGhii === callerOwner || organism.admins.includes(callerOwner);
|
|
1080
|
+
if (!isAuthor && !isAdmin) {
|
|
1081
|
+
res.status(403).json(error(config.nodeId, 'ACCESS_DENIED', 'Only the comment author or an organism admin can delete it'));
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
await storage.deleteMemory(rec.ownerGaii, key);
|
|
1085
|
+
emitChange('organisms');
|
|
1086
|
+
res.json(success(config.nodeId, { deleted: commentId }));
|
|
1087
|
+
});
|
|
613
1088
|
/* ── Gate primitive (Phase 4) — PendingApproval ──
|
|
614
1089
|
*
|
|
615
1090
|
* Generic over any organism/manifest. The gate ENGINE is here; the gate CONDITIONS are data
|