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.
Files changed (89) hide show
  1. package/README.md +1 -1
  2. package/dist/locales/en.json +60 -0
  3. package/dist/locales/fi.json +60 -0
  4. package/dist/public/css/views/agents-detail.css +4 -0
  5. package/dist/public/css/views/profile.css +9 -0
  6. package/dist/public/css/views/public-workspace-viewer.css +23 -3
  7. package/dist/public/js/services/organisms.js +79 -3
  8. package/dist/public/views/profile/agents-tab.js +1 -1
  9. package/dist/public/views/profile/organisms-tab.js +355 -3
  10. package/dist/public/views/public-workspace-viewer.js +74 -11
  11. package/dist/src/cli/connect/auth.js +1 -1
  12. package/dist/src/cli/connect/auth.js.map +1 -1
  13. package/dist/src/cli/connect/config.d.ts +1 -1
  14. package/dist/src/cli/connect/config.d.ts.map +1 -1
  15. package/dist/src/cli/connect/mcp/server.d.ts.map +1 -1
  16. package/dist/src/cli/connect/mcp/server.js +5 -1
  17. package/dist/src/cli/connect/mcp/server.js.map +1 -1
  18. package/dist/src/cli/connect/mcp/tools/agent-management.js +1 -1
  19. package/dist/src/cli/connect/mcp/tools/agent-management.js.map +1 -1
  20. package/dist/src/cli/connect/mcp/tools/index.d.ts.map +1 -1
  21. package/dist/src/cli/connect/mcp/tools/index.js +2 -0
  22. package/dist/src/cli/connect/mcp/tools/index.js.map +1 -1
  23. package/dist/src/cli/connect/mcp/tools/organisms.d.ts.map +1 -1
  24. package/dist/src/cli/connect/mcp/tools/organisms.js +57 -0
  25. package/dist/src/cli/connect/mcp/tools/organisms.js.map +1 -1
  26. package/dist/src/cli/connect/mcp/tools/schedules.d.ts +15 -0
  27. package/dist/src/cli/connect/mcp/tools/schedules.d.ts.map +1 -0
  28. package/dist/src/cli/connect/mcp/tools/schedules.js +66 -0
  29. package/dist/src/cli/connect/mcp/tools/schedules.js.map +1 -0
  30. package/dist/src/cli/connect/tool-call.d.ts.map +1 -1
  31. package/dist/src/cli/connect/tool-call.js +81 -2
  32. package/dist/src/cli/connect/tool-call.js.map +1 -1
  33. package/dist/src/cli/init-wizard.d.ts.map +1 -1
  34. package/dist/src/cli/init-wizard.js +11 -3
  35. package/dist/src/cli/init-wizard.js.map +1 -1
  36. package/dist/src/generated/api-types.d.ts +540 -0
  37. package/dist/src/generated/api-types.d.ts.map +1 -1
  38. package/dist/src/index.js +1 -1
  39. package/dist/src/mcp/agent-management.d.ts.map +1 -1
  40. package/dist/src/mcp/agent-management.js +4 -3
  41. package/dist/src/mcp/agent-management.js.map +1 -1
  42. package/dist/src/mcp/annotations.d.ts.map +1 -1
  43. package/dist/src/mcp/annotations.js +6 -0
  44. package/dist/src/mcp/annotations.js.map +1 -1
  45. package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
  46. package/dist/src/mcp/catalog/definitions.js +67 -2
  47. package/dist/src/mcp/catalog/definitions.js.map +1 -1
  48. package/dist/src/mcp/catalog/scopes.d.ts +3 -2
  49. package/dist/src/mcp/catalog/scopes.d.ts.map +1 -1
  50. package/dist/src/mcp/catalog/scopes.js +4 -2
  51. package/dist/src/mcp/catalog/scopes.js.map +1 -1
  52. package/dist/src/mcp/catalog/surfaces.js +6 -6
  53. package/dist/src/mcp/catalog/surfaces.js.map +1 -1
  54. package/dist/src/mcp/organisms.d.ts.map +1 -1
  55. package/dist/src/mcp/organisms.js +145 -0
  56. package/dist/src/mcp/organisms.js.map +1 -1
  57. package/dist/src/models/agent-onboarding-schemas.d.ts +8 -1
  58. package/dist/src/models/agent-onboarding-schemas.d.ts.map +1 -1
  59. package/dist/src/models/agent-onboarding-schemas.js +30 -1
  60. package/dist/src/models/agent-onboarding-schemas.js.map +1 -1
  61. package/dist/src/models/schemas.d.ts +1 -0
  62. package/dist/src/models/schemas.d.ts.map +1 -1
  63. package/dist/src/models/schemas.js +1 -1
  64. package/dist/src/models/schemas.js.map +1 -1
  65. package/dist/src/routes/agents.d.ts.map +1 -1
  66. package/dist/src/routes/agents.js +4 -3
  67. package/dist/src/routes/agents.js.map +1 -1
  68. package/dist/src/routes/organisms.d.ts +3 -0
  69. package/dist/src/routes/organisms.d.ts.map +1 -1
  70. package/dist/src/routes/organisms.js +475 -0
  71. package/dist/src/routes/organisms.js.map +1 -1
  72. package/dist/src/services/handbooks/admin.d.ts +1 -1
  73. package/dist/src/services/handbooks/admin.d.ts.map +1 -1
  74. package/dist/src/services/handbooks/admin.js +1 -1
  75. package/dist/src/services/organism-comments.d.ts +30 -0
  76. package/dist/src/services/organism-comments.d.ts.map +1 -0
  77. package/dist/src/services/organism-comments.js +74 -0
  78. package/dist/src/services/organism-comments.js.map +1 -0
  79. package/dist/src/services/organism-search.d.ts +27 -0
  80. package/dist/src/services/organism-search.d.ts.map +1 -0
  81. package/dist/src/services/organism-search.js +84 -0
  82. package/dist/src/services/organism-search.js.map +1 -0
  83. package/dist/src/storage/interface.d.ts +13 -4
  84. package/dist/src/storage/interface.d.ts.map +1 -1
  85. package/dist/src/storage/interface.js.map +1 -1
  86. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  87. package/dist/src/storage/providers/sqlite/schema.js +1 -1
  88. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  89. 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