@muxoai/platform 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.agents/plugins/marketplace.json +2 -2
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/ARCHITECTURE.md +16 -6
  4. package/README.md +2 -2
  5. package/dist/cli.js +323 -13
  6. package/dist/composio-catalog.d.ts +234 -0
  7. package/dist/composio-catalog.js +1894 -0
  8. package/dist/config.d.ts +16 -0
  9. package/dist/config.js +27 -1
  10. package/dist/durable-workflow.d.ts +45 -0
  11. package/dist/durable-workflow.js +90 -0
  12. package/dist/engine.d.ts +293 -5
  13. package/dist/engine.js +1502 -88
  14. package/dist/mcp-schema.d.ts +286 -0
  15. package/dist/mcp-schema.js +2559 -0
  16. package/dist/mcp.d.ts +2 -30
  17. package/dist/mcp.js +913 -558
  18. package/dist/memory-graph.d.ts +21 -0
  19. package/dist/memory-graph.js +173 -0
  20. package/dist/nl.js +155 -0
  21. package/dist/oauth.d.ts +32 -0
  22. package/dist/oauth.js +134 -0
  23. package/dist/policy.d.ts +182 -1
  24. package/dist/policy.js +1524 -0
  25. package/dist/providers/composio-instant.js +262 -7
  26. package/dist/providers/composio-sdk.d.ts +125 -5
  27. package/dist/providers/composio-sdk.js +901 -82
  28. package/dist/providers/microsoft-governance.d.ts +28 -1
  29. package/dist/providers/microsoft-governance.js +224 -11
  30. package/dist/providers/stripe-billing.d.ts +42 -0
  31. package/dist/providers/stripe-billing.js +230 -1
  32. package/dist/providers/stripe-projects.d.ts +455 -3
  33. package/dist/providers/stripe-projects.js +2570 -129
  34. package/dist/providers/supermemory.d.ts +74 -1
  35. package/dist/providers/supermemory.js +426 -0
  36. package/dist/provisioning-managed.d.ts +50 -0
  37. package/dist/provisioning-managed.js +155 -0
  38. package/dist/sdk.d.ts +440 -3
  39. package/dist/sdk.js +305 -3
  40. package/dist/server.js +2343 -264
  41. package/dist/store.d.ts +113 -6
  42. package/dist/store.js +677 -52
  43. package/dist/types.d.ts +207 -3
  44. package/dist/ui/console-html.d.ts +40 -1
  45. package/dist/ui/console-html.js +1813 -629
  46. package/dist/ui/design-tokens.js +1 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +4 -3
  50. package/plugins/muxo/.claude-plugin/plugin.json +2 -2
  51. package/plugins/muxo/.codex-plugin/plugin.json +15 -8
  52. package/plugins/muxo/.cursor-plugin/plugin.json +30 -0
  53. package/plugins/muxo/hooks/session-start.sh +15 -3
  54. package/plugins/muxo/hooks/user-prompt-submit.sh +14 -1
  55. package/plugins/muxo/mcp.json +0 -10
  56. package/plugins/muxo/plugin.json +4 -4
  57. package/plugins/muxo/skills/muxo/SKILL.md +59 -9
  58. package/plugins/muxo/skills/muxo/references/cli.md +5 -1
  59. package/plugins/muxo/skills/muxo/references/mcp.md +13 -7
  60. package/plugins/muxo/skills/muxo-activity-summary/SKILL.md +81 -0
  61. package/plugins/muxo/skills/setup/SKILL.md +9 -1
  62. package/src/catalogues/stripe-projects-catalog.json +140 -0
package/dist/store.js CHANGED
@@ -18,16 +18,58 @@ const DEFAULT_POLICY = {
18
18
  allowProvisioning: true,
19
19
  maxCreditsPerRun: 250,
20
20
  };
21
+ const RECEIPTS_COLUMN_NAMES = [
22
+ "id",
23
+ "run_id",
24
+ "plan_id",
25
+ "workspace_id",
26
+ "actor_id",
27
+ "tool_slug",
28
+ "lane",
29
+ "risk_level",
30
+ "status",
31
+ "actions_metered",
32
+ "credits_metered",
33
+ "input_hash",
34
+ "output_hash",
35
+ "prev_receipt_hash",
36
+ "receipt_hash",
37
+ "signature",
38
+ "decision_record_json",
39
+ "issued_at",
40
+ ];
41
+ const RECEIPTS_TABLE_COLUMNS = `
42
+ id TEXT PRIMARY KEY,
43
+ run_id TEXT NOT NULL,
44
+ plan_id TEXT NOT NULL,
45
+ workspace_id TEXT NOT NULL,
46
+ actor_id TEXT NOT NULL,
47
+ tool_slug TEXT NOT NULL,
48
+ lane TEXT NOT NULL,
49
+ risk_level TEXT NOT NULL,
50
+ status TEXT NOT NULL,
51
+ actions_metered INTEGER NOT NULL,
52
+ credits_metered INTEGER NOT NULL,
53
+ input_hash TEXT NOT NULL,
54
+ output_hash TEXT NOT NULL,
55
+ prev_receipt_hash TEXT NOT NULL,
56
+ receipt_hash TEXT NOT NULL,
57
+ signature TEXT NOT NULL,
58
+ decision_record_json TEXT,
59
+ issued_at TEXT NOT NULL
60
+ `;
21
61
  export class MuxoStore {
22
62
  db;
23
63
  vaultKey;
24
64
  constructor(dbPath, vaultKey) {
25
65
  this.db = new DatabaseSync(dbPath);
66
+ this.db.exec("PRAGMA busy_timeout = 10000;");
26
67
  this.vaultKey = vaultKey;
27
68
  this.initSchema();
28
69
  }
29
70
  initSchema() {
30
71
  this.db.exec(`
72
+ PRAGMA busy_timeout = 10000;
31
73
  PRAGMA journal_mode = WAL;
32
74
  PRAGMA foreign_keys = ON;
33
75
 
@@ -153,26 +195,7 @@ export class MuxoStore {
153
195
  completed_at TEXT
154
196
  );
155
197
 
156
- CREATE TABLE IF NOT EXISTS receipts (
157
- id TEXT PRIMARY KEY,
158
- run_id TEXT NOT NULL UNIQUE,
159
- plan_id TEXT NOT NULL,
160
- workspace_id TEXT NOT NULL,
161
- actor_id TEXT NOT NULL,
162
- tool_slug TEXT NOT NULL,
163
- lane TEXT NOT NULL,
164
- risk_level TEXT NOT NULL,
165
- status TEXT NOT NULL,
166
- actions_metered INTEGER NOT NULL,
167
- credits_metered INTEGER NOT NULL,
168
- input_hash TEXT NOT NULL,
169
- output_hash TEXT NOT NULL,
170
- prev_receipt_hash TEXT NOT NULL,
171
- receipt_hash TEXT NOT NULL,
172
- signature TEXT NOT NULL,
173
- decision_record_json TEXT,
174
- issued_at TEXT NOT NULL
175
- );
198
+ CREATE TABLE IF NOT EXISTS receipts (${RECEIPTS_TABLE_COLUMNS});
176
199
 
177
200
  CREATE TABLE IF NOT EXISTS memories (
178
201
  id TEXT PRIMARY KEY,
@@ -240,12 +263,42 @@ export class MuxoStore {
240
263
  updated_at TEXT NOT NULL,
241
264
  PRIMARY KEY (workspace_id, provider)
242
265
  );
266
+
267
+ CREATE TABLE IF NOT EXISTS workspace_members (
268
+ id TEXT PRIMARY KEY,
269
+ workspace_id TEXT NOT NULL,
270
+ user_id TEXT NOT NULL,
271
+ role TEXT NOT NULL DEFAULT 'operator',
272
+ team_name TEXT,
273
+ joined_via_key_id TEXT,
274
+ created_at TEXT NOT NULL,
275
+ UNIQUE(workspace_id, user_id)
276
+ );
277
+
278
+ CREATE TABLE IF NOT EXISTS clerk_users (
279
+ clerk_user_id TEXT PRIMARY KEY,
280
+ user_id TEXT NOT NULL,
281
+ created_at TEXT NOT NULL
282
+ );
283
+
284
+ CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
285
+ token_hash TEXT PRIMARY KEY,
286
+ key_id TEXT NOT NULL,
287
+ workspace_id TEXT NOT NULL,
288
+ client_id TEXT NOT NULL,
289
+ scope TEXT NOT NULL,
290
+ encrypted_access_token TEXT NOT NULL,
291
+ created_at TEXT NOT NULL
292
+ );
243
293
  `);
244
294
  // Backwards-compatible column migrations for existing databases
245
295
  for (const stmt of [
246
296
  "ALTER TABLE plans ADD COLUMN decision_record_json TEXT",
247
297
  "ALTER TABLE plans ADD COLUMN memory_context_json TEXT",
248
298
  "ALTER TABLE receipts ADD COLUMN decision_record_json TEXT",
299
+ "ALTER TABLE api_keys ADD COLUMN team_name TEXT",
300
+ "ALTER TABLE api_keys ADD COLUMN role TEXT DEFAULT 'operator'",
301
+ "ALTER TABLE api_keys ADD COLUMN created_by_email TEXT",
249
302
  ]) {
250
303
  try {
251
304
  this.db.exec(stmt);
@@ -254,6 +307,38 @@ export class MuxoStore {
254
307
  // Column already exists
255
308
  }
256
309
  }
310
+ this.migrateReceiptsAllowMultiplePerRun();
311
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_receipts_workspace_run ON receipts(workspace_id, run_id)");
312
+ }
313
+ /**
314
+ * Older databases declared receipts.run_id UNIQUE, which blocks cancellation receipts from
315
+ * superseding a run's original receipt. SQLite cannot drop a constraint, so rebuild the table.
316
+ */
317
+ migrateReceiptsAllowMultiplePerRun() {
318
+ const indexes = this.db.prepare("PRAGMA index_list(receipts)").all();
319
+ const hasUniqueRunId = indexes.some((idx) => {
320
+ if (!idx.unique || idx.origin !== "u")
321
+ return false;
322
+ const cols = this.db.prepare(`PRAGMA index_info("${idx.name}")`).all();
323
+ return cols.length === 1 && cols[0].name === "run_id";
324
+ });
325
+ if (!hasUniqueRunId)
326
+ return;
327
+ const columns = RECEIPTS_COLUMN_NAMES.join(", ");
328
+ this.db.exec("BEGIN");
329
+ try {
330
+ this.db.exec(`
331
+ CREATE TABLE receipts_migrated (${RECEIPTS_TABLE_COLUMNS});
332
+ INSERT INTO receipts_migrated (${columns}) SELECT ${columns} FROM receipts ORDER BY rowid;
333
+ DROP TABLE receipts;
334
+ ALTER TABLE receipts_migrated RENAME TO receipts;
335
+ `);
336
+ this.db.exec("COMMIT");
337
+ }
338
+ catch (err) {
339
+ this.db.exec("ROLLBACK");
340
+ throw err;
341
+ }
257
342
  }
258
343
  // --- Encryption Vault (AES-256-GCM) ---
259
344
  encryptSecrets(workspaceId, secrets) {
@@ -349,11 +434,18 @@ export class MuxoStore {
349
434
  referenceId: workspaceId,
350
435
  description: "Free Plan initial monthly allowance (1,000 actions + 250 trial credits)",
351
436
  });
437
+ this.ensureWorkspaceMembership({
438
+ workspaceId,
439
+ userId,
440
+ role: "owner",
441
+ });
352
442
  const sessionToken = this.createSession(userId, workspaceId);
353
443
  const defaultKey = this.createApiKey({
354
444
  workspaceId,
355
445
  name: "Default Live Key",
356
446
  environment: "live",
447
+ role: "owner",
448
+ createdByEmail: params.email.toLowerCase().trim(),
357
449
  });
358
450
  return {
359
451
  user: this.getUser(userId),
@@ -372,6 +464,11 @@ export class MuxoStore {
372
464
  return null;
373
465
  const user = this.mapUser(row);
374
466
  const workspace = this.getWorkspace(user.workspaceId);
467
+ this.ensureWorkspaceMembership({
468
+ workspaceId: workspace.id,
469
+ userId: user.id,
470
+ role: user.role,
471
+ });
375
472
  const sessionToken = this.createSession(user.id, workspace.id);
376
473
  return { user, workspace, sessionToken };
377
474
  }
@@ -385,7 +482,7 @@ export class MuxoStore {
385
482
  .run(this.hashToken(rawToken), userId, workspaceId, expires.toISOString(), now.toISOString());
386
483
  return rawToken;
387
484
  }
388
- resolveAuthToken(token) {
485
+ resolveAuthToken(token, workspaceOverrideId) {
389
486
  const trimmed = token.trim();
390
487
  if (!trimmed)
391
488
  return null;
@@ -423,11 +520,22 @@ export class MuxoStore {
423
520
  return null;
424
521
  }
425
522
  const user = this.getUser(String(sessionRow.user_id));
426
- const workspace = this.getWorkspace(String(sessionRow.workspace_id));
427
- if (!user || !workspace)
523
+ if (!user)
524
+ return null;
525
+ let targetWorkspaceId = String(sessionRow.workspace_id || user.workspaceId);
526
+ if (workspaceOverrideId &&
527
+ workspaceOverrideId.trim() &&
528
+ this.userCanAccessWorkspace(user.id, workspaceOverrideId.trim())) {
529
+ targetWorkspaceId = workspaceOverrideId.trim();
530
+ }
531
+ else if (user.workspaceId && this.userCanAccessWorkspace(user.id, user.workspaceId)) {
532
+ targetWorkspaceId = user.workspaceId;
533
+ }
534
+ const workspace = this.getWorkspace(targetWorkspaceId);
535
+ if (!workspace)
428
536
  return null;
429
537
  return {
430
- user,
538
+ user: { ...user, workspaceId: workspace.id },
431
539
  workspace,
432
540
  authType: "session",
433
541
  actorId: user.id,
@@ -445,6 +553,326 @@ export class MuxoStore {
445
553
  .get(workspaceId);
446
554
  return row ? this.mapWorkspace(row) : null;
447
555
  }
556
+ ensureWorkspaceMembership(params) {
557
+ const now = new Date().toISOString();
558
+ const role = params.role || "operator";
559
+ const existing = this.db
560
+ .prepare("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
561
+ .get(params.workspaceId, params.userId);
562
+ if (existing) {
563
+ if (params.teamName || params.role) {
564
+ this.db
565
+ .prepare("UPDATE workspace_members SET role = COALESCE(?, role), team_name = COALESCE(?, team_name) WHERE id = ?")
566
+ .run(params.role || null, params.teamName || null, String(existing.id));
567
+ }
568
+ return {
569
+ id: String(existing.id),
570
+ workspaceId: String(existing.workspace_id),
571
+ userId: String(existing.user_id),
572
+ role: (params.role || String(existing.role) || "operator"),
573
+ teamName: params.teamName ?? (existing.team_name ? String(existing.team_name) : null),
574
+ joinedViaKeyId: existing.joined_via_key_id ? String(existing.joined_via_key_id) : null,
575
+ createdAt: String(existing.created_at),
576
+ };
577
+ }
578
+ const id = `wsm_${crypto.randomBytes(8).toString("hex")}`;
579
+ this.db
580
+ .prepare(`INSERT INTO workspace_members (
581
+ id, workspace_id, user_id, role, team_name, joined_via_key_id, created_at
582
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`)
583
+ .run(id, params.workspaceId, params.userId, role, params.teamName || null, params.joinedViaKeyId || null, now);
584
+ return {
585
+ id,
586
+ workspaceId: params.workspaceId,
587
+ userId: params.userId,
588
+ role,
589
+ teamName: params.teamName || null,
590
+ joinedViaKeyId: params.joinedViaKeyId || null,
591
+ createdAt: now,
592
+ };
593
+ }
594
+ userCanAccessWorkspace(userId, workspaceId) {
595
+ const ws = this.getWorkspace(workspaceId);
596
+ if (!ws)
597
+ return false;
598
+ if (ws.ownerUserId === userId)
599
+ return true;
600
+ const user = this.getUser(userId);
601
+ if (user?.workspaceId === workspaceId)
602
+ return true;
603
+ const mem = this.db
604
+ .prepare("SELECT id FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
605
+ .get(workspaceId, userId);
606
+ return Boolean(mem);
607
+ }
608
+ listUserWorkspaces(userId, activeWorkspaceId) {
609
+ const user = this.getUser(userId);
610
+ const currentId = activeWorkspaceId || user?.workspaceId || "";
611
+ const ownedRows = this.db
612
+ .prepare("SELECT * FROM workspaces WHERE owner_user_id = ? ORDER BY created_at ASC")
613
+ .all(userId);
614
+ const memberRows = this.db
615
+ .prepare(`SELECT w.*, m.role AS member_role, m.team_name AS member_team_name
616
+ FROM workspace_members m
617
+ JOIN workspaces w ON w.id = m.workspace_id
618
+ WHERE m.user_id = ?
619
+ ORDER BY m.created_at ASC`)
620
+ .all(userId);
621
+ const byId = new Map();
622
+ for (const r of ownedRows) {
623
+ const ws = this.mapWorkspace(r);
624
+ byId.set(ws.id, {
625
+ id: ws.id,
626
+ name: ws.name,
627
+ slug: ws.slug,
628
+ tier: ws.tier,
629
+ creditsBalance: ws.creditsBalance,
630
+ monthlyActionsUsed: ws.monthlyActionsUsed,
631
+ monthlyActionsLimit: ws.monthlyActionsLimit,
632
+ role: "owner",
633
+ teamName: null,
634
+ isCurrent: ws.id === currentId,
635
+ createdAt: ws.createdAt,
636
+ });
637
+ }
638
+ for (const r of memberRows) {
639
+ const ws = this.mapWorkspace(r);
640
+ const existing = byId.get(ws.id);
641
+ byId.set(ws.id, {
642
+ id: ws.id,
643
+ name: ws.name,
644
+ slug: ws.slug,
645
+ tier: ws.tier,
646
+ creditsBalance: ws.creditsBalance,
647
+ monthlyActionsUsed: ws.monthlyActionsUsed,
648
+ monthlyActionsLimit: ws.monthlyActionsLimit,
649
+ role: existing?.role === "owner" ? "owner" : String(r.member_role || "operator"),
650
+ teamName: r.member_team_name ? String(r.member_team_name) : existing?.teamName || null,
651
+ isCurrent: ws.id === currentId,
652
+ createdAt: ws.createdAt,
653
+ });
654
+ }
655
+ if (currentId && !byId.has(currentId)) {
656
+ const ws = this.getWorkspace(currentId);
657
+ if (ws) {
658
+ byId.set(ws.id, {
659
+ id: ws.id,
660
+ name: ws.name,
661
+ slug: ws.slug,
662
+ tier: ws.tier,
663
+ creditsBalance: ws.creditsBalance,
664
+ monthlyActionsUsed: ws.monthlyActionsUsed,
665
+ monthlyActionsLimit: ws.monthlyActionsLimit,
666
+ role: ws.ownerUserId === userId ? "owner" : "operator",
667
+ teamName: null,
668
+ isCurrent: true,
669
+ createdAt: ws.createdAt,
670
+ });
671
+ }
672
+ }
673
+ return Array.from(byId.values());
674
+ }
675
+ switchUserWorkspace(userId, workspaceId) {
676
+ if (!this.userCanAccessWorkspace(userId, workspaceId))
677
+ return null;
678
+ const workspace = this.getWorkspace(workspaceId);
679
+ if (!workspace)
680
+ return null;
681
+ this.db
682
+ .prepare("UPDATE users SET workspace_id = ? WHERE id = ?")
683
+ .run(workspaceId, userId);
684
+ this.db
685
+ .prepare("UPDATE sessions SET workspace_id = ? WHERE user_id = ?")
686
+ .run(workspaceId, userId);
687
+ const user = this.getUser(userId);
688
+ if (!user)
689
+ return null;
690
+ return { user, workspace };
691
+ }
692
+ createWorkspaceForUser(params) {
693
+ const now = new Date().toISOString();
694
+ const workspaceId = `ws_${crypto.randomBytes(8).toString("hex")}`;
695
+ const cleanName = params.name.trim() || "Team Workspace";
696
+ const slugBase = cleanName
697
+ .toLowerCase()
698
+ .replace(/[^a-z0-9]+/g, "-")
699
+ .replace(/^-|-$/g, "");
700
+ const slug = `${slugBase || "ws"}-${crypto.randomBytes(3).toString("hex")}`;
701
+ const projectsEnvName = `muxo_${workspaceId.slice(3, 11)}`;
702
+ const starterCredits = 250;
703
+ this.db
704
+ .prepare(`INSERT INTO workspaces (
705
+ id, name, slug, owner_user_id, tier,
706
+ monthly_actions_limit, monthly_actions_used, credits_balance,
707
+ stripe_customer_id, stripe_subscription_id, projects_env_name,
708
+ policy_json, created_at, updated_at
709
+ ) VALUES (?, ?, ?, ?, 'free', 1000, 0, ?, NULL, NULL, ?, ?, ?, ?)`)
710
+ .run(workspaceId, cleanName, slug, params.userId, starterCredits, projectsEnvName, JSON.stringify(DEFAULT_POLICY), now, now);
711
+ this.addLedgerEntry({
712
+ workspaceId,
713
+ kind: "plan_grant",
714
+ actionsDelta: 1000,
715
+ creditsDelta: starterCredits,
716
+ balanceAfter: starterCredits,
717
+ referenceId: workspaceId,
718
+ description: `Workspace '${cleanName}' initial monthly allowance (1,000 actions + 250 trial credits)`,
719
+ });
720
+ const user = this.getUser(params.userId);
721
+ const keyRes = this.createApiKey({
722
+ workspaceId,
723
+ name: params.teamName ? `${params.teamName} — Team Key` : `${cleanName} — Default Key`,
724
+ environment: "live",
725
+ teamName: params.teamName || cleanName,
726
+ role: params.role || "owner",
727
+ createdByEmail: user?.email || null,
728
+ });
729
+ const membership = this.ensureWorkspaceMembership({
730
+ workspaceId,
731
+ userId: params.userId,
732
+ role: "owner",
733
+ teamName: params.teamName || null,
734
+ joinedViaKeyId: keyRes.record.id,
735
+ });
736
+ if (params.switchActive !== false) {
737
+ this.switchUserWorkspace(params.userId, workspaceId);
738
+ }
739
+ return {
740
+ workspace: this.getWorkspace(workspaceId),
741
+ apiKey: keyRes.secretKey,
742
+ keyRecord: keyRes.record,
743
+ membership,
744
+ };
745
+ }
746
+ joinWorkspaceWithApiKey(params) {
747
+ const trimmed = String(params.apiKey || "").trim();
748
+ if (!trimmed.startsWith("mk_live_") && !trimmed.startsWith("mk_test_")) {
749
+ return null;
750
+ }
751
+ const hash = this.hashToken(trimmed);
752
+ const row = this.db
753
+ .prepare("SELECT * FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL")
754
+ .get(hash);
755
+ if (!row)
756
+ return null;
757
+ const workspace = this.getWorkspace(String(row.workspace_id));
758
+ if (!workspace)
759
+ return null;
760
+ const keyRecord = {
761
+ id: String(row.id),
762
+ workspaceId: String(row.workspace_id),
763
+ name: String(row.name),
764
+ keyPrefix: String(row.key_prefix),
765
+ environment: String(row.environment),
766
+ scopes: JSON.parse(String(row.scopes_json || "[]")),
767
+ teamName: row.team_name ? String(row.team_name) : null,
768
+ role: (row.role ? String(row.role) : "operator"),
769
+ createdByEmail: row.created_by_email ? String(row.created_by_email) : null,
770
+ lastUsedAt: row.last_used_at ? String(row.last_used_at) : null,
771
+ revokedAt: row.revoked_at ? String(row.revoked_at) : null,
772
+ createdAt: String(row.created_at),
773
+ };
774
+ let membership = null;
775
+ let user = null;
776
+ if (params.userId) {
777
+ membership = this.ensureWorkspaceMembership({
778
+ workspaceId: workspace.id,
779
+ userId: params.userId,
780
+ role: workspace.ownerUserId === params.userId ? "owner" : (keyRecord.role || "operator"),
781
+ teamName: keyRecord.teamName || keyRecord.name,
782
+ joinedViaKeyId: keyRecord.id,
783
+ });
784
+ if (params.switchActive !== false) {
785
+ this.switchUserWorkspace(params.userId, workspace.id);
786
+ }
787
+ user = this.getUser(params.userId);
788
+ }
789
+ else {
790
+ user = this.getUser(workspace.ownerUserId);
791
+ }
792
+ return {
793
+ workspace,
794
+ keyRecord,
795
+ user,
796
+ membership,
797
+ };
798
+ }
799
+ onboardTeam(params) {
800
+ const cleanTeam = params.teamName.trim() || "Engineering Team";
801
+ const role = params.role || "operator";
802
+ const mode = params.mode === "dedicated_workspace" ? "dedicated_workspace" : "shared_workspace";
803
+ const cleanBase = params.baseUrl.replace(/\/$/, "");
804
+ const env = params.environment === "test" ? "test" : "live";
805
+ let targetWorkspace;
806
+ let apiKey;
807
+ let keyRecord;
808
+ if (mode === "dedicated_workspace") {
809
+ const currentWs = this.getWorkspace(params.currentWorkspaceId);
810
+ const wsName = currentWs ? `${currentWs.name} — ${cleanTeam}` : `${cleanTeam} Workspace`;
811
+ const created = this.createWorkspaceForUser({
812
+ userId: params.userId,
813
+ name: wsName,
814
+ teamName: cleanTeam,
815
+ role,
816
+ switchActive: false,
817
+ });
818
+ targetWorkspace = created.workspace;
819
+ apiKey = created.apiKey;
820
+ keyRecord = created.keyRecord;
821
+ }
822
+ else {
823
+ const currentWs = this.getWorkspace(params.currentWorkspaceId);
824
+ if (!currentWs)
825
+ throw new Error(`Workspace ${params.currentWorkspaceId} not found`);
826
+ targetWorkspace = currentWs;
827
+ const createdKey = this.createApiKey({
828
+ workspaceId: targetWorkspace.id,
829
+ name: `${cleanTeam} (${role})`,
830
+ environment: env,
831
+ scopes: params.scopes,
832
+ teamName: cleanTeam,
833
+ role,
834
+ createdByEmail: params.userEmail || null,
835
+ });
836
+ apiKey = createdKey.secretKey;
837
+ keyRecord = createdKey.record;
838
+ }
839
+ let claimUrl = null;
840
+ if (params.inviteEmail && params.inviteEmail.includes("@")) {
841
+ const claim = this.createAgentClaim({
842
+ workspaceId: targetWorkspace.id,
843
+ email: params.inviteEmail,
844
+ baseUrl: cleanBase,
845
+ });
846
+ claimUrl = claim.claimUrl;
847
+ }
848
+ return {
849
+ mode,
850
+ workspace: targetWorkspace,
851
+ teamName: cleanTeam,
852
+ role,
853
+ apiKey,
854
+ keyRecord,
855
+ claimUrl,
856
+ consoleUrl: `${cleanBase}/login`,
857
+ cliSetupCommand: `npx muxo setup --api-key ${apiKey} --url ${cleanBase}`,
858
+ mcpConfig: {
859
+ mcpServers: {
860
+ muxo: {
861
+ url: `${cleanBase}/mcp`,
862
+ headers: {
863
+ Authorization: `Bearer ${apiKey}`,
864
+ },
865
+ },
866
+ },
867
+ },
868
+ instructions: [
869
+ `Share this Team API Key with ${cleanTeam} (${role} access to '${targetWorkspace.name}').`,
870
+ `Console: Open ${cleanBase}/login, click "Join with a Team API Key", and paste the key (if signed in with Clerk, the workspace is permanently linked to their switcher).`,
871
+ `CLI & MCP: Run \`npx -y @muxoai/platform setup --api-key ${apiKey}\` to configure Claude Code, Cursor, Windsurf, or Codex in one command.`,
872
+ `Approvals: Team members can also paste this key directly on any ${cleanBase}/approve/<planId> link to review and approve actions.`,
873
+ ],
874
+ };
875
+ }
448
876
  updateWorkspacePolicy(workspaceId, policy) {
449
877
  const ws = this.getWorkspace(workspaceId);
450
878
  if (!ws)
@@ -466,12 +894,16 @@ export class MuxoStore {
466
894
  const rawSecret = `mk_${env}_${id.slice(4)}_${crypto.randomBytes(16).toString("hex")}`;
467
895
  const keyPrefix = `${rawSecret.slice(0, 16)}...${rawSecret.slice(-4)}`;
468
896
  const scopes = params.scopes || ["tools:read", "tools:execute", "resources:provision"];
897
+ const teamName = params.teamName ? params.teamName.trim() : null;
898
+ const role = params.role || "operator";
899
+ const createdByEmail = params.createdByEmail ? params.createdByEmail.trim() : null;
469
900
  const now = new Date().toISOString();
470
901
  this.db
471
902
  .prepare(`INSERT INTO api_keys (
472
- id, workspace_id, name, key_prefix, key_hash, environment, scopes_json, created_at
473
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
474
- .run(id, params.workspaceId, params.name, keyPrefix, this.hashToken(rawSecret), env, JSON.stringify(scopes), now);
903
+ id, workspace_id, name, key_prefix, key_hash, environment, scopes_json,
904
+ team_name, role, created_by_email, created_at
905
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
906
+ .run(id, params.workspaceId, params.name, keyPrefix, this.hashToken(rawSecret), env, JSON.stringify(scopes), teamName, role, createdByEmail, now);
475
907
  return {
476
908
  record: {
477
909
  id,
@@ -480,6 +912,9 @@ export class MuxoStore {
480
912
  keyPrefix,
481
913
  environment: env,
482
914
  scopes,
915
+ teamName,
916
+ role,
917
+ createdByEmail,
483
918
  lastUsedAt: null,
484
919
  revokedAt: null,
485
920
  createdAt: now,
@@ -498,6 +933,9 @@ export class MuxoStore {
498
933
  keyPrefix: String(r.key_prefix),
499
934
  environment: String(r.environment),
500
935
  scopes: JSON.parse(String(r.scopes_json || "[]")),
936
+ teamName: r.team_name ? String(r.team_name) : null,
937
+ role: (r.role ? String(r.role) : "operator"),
938
+ createdByEmail: r.created_by_email ? String(r.created_by_email) : null,
501
939
  lastUsedAt: r.last_used_at ? String(r.last_used_at) : null,
502
940
  revokedAt: r.revoked_at ? String(r.revoked_at) : null,
503
941
  createdAt: String(r.created_at),
@@ -508,8 +946,95 @@ export class MuxoStore {
508
946
  const res = this.db
509
947
  .prepare("UPDATE api_keys SET revoked_at = ? WHERE id = ? AND workspace_id = ?")
510
948
  .run(now, keyId, workspaceId);
949
+ if (res.changes > 0) {
950
+ this.db.prepare("DELETE FROM oauth_refresh_tokens WHERE key_id = ?").run(keyId);
951
+ }
511
952
  return res.changes > 0;
512
953
  }
954
+ /** Looks up an active workspace API key by its raw secret. */
955
+ findActiveApiKey(secret) {
956
+ const row = this.db
957
+ .prepare("SELECT id, workspace_id FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL")
958
+ .get(this.hashToken(secret.trim()));
959
+ return row ? { id: String(row.id), workspaceId: String(row.workspace_id) } : null;
960
+ }
961
+ // --- OAuth refresh tokens (hashed; the access token is kept encrypted so refresh can return it) ---
962
+ saveOAuthRefreshToken(params) {
963
+ this.db
964
+ .prepare(`INSERT INTO oauth_refresh_tokens (
965
+ token_hash, key_id, workspace_id, client_id, scope, encrypted_access_token, created_at
966
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`)
967
+ .run(this.hashToken(params.refreshToken), params.keyId, params.workspaceId, params.clientId, params.scope, this.encryptSecrets(params.workspaceId, { accessToken: params.accessToken }), new Date().toISOString());
968
+ }
969
+ getOAuthRefreshToken(refreshToken) {
970
+ const row = this.db
971
+ .prepare("SELECT * FROM oauth_refresh_tokens WHERE token_hash = ?")
972
+ .get(this.hashToken(refreshToken));
973
+ if (!row)
974
+ return null;
975
+ const workspaceId = String(row.workspace_id);
976
+ let accessToken = "";
977
+ try {
978
+ accessToken = this.decryptSecrets(workspaceId, String(row.encrypted_access_token)).accessToken || "";
979
+ }
980
+ catch {
981
+ return null;
982
+ }
983
+ return {
984
+ keyId: String(row.key_id),
985
+ workspaceId,
986
+ clientId: String(row.client_id),
987
+ scope: String(row.scope),
988
+ accessToken,
989
+ };
990
+ }
991
+ deleteOAuthRefreshToken(refreshToken) {
992
+ this.db.prepare("DELETE FROM oauth_refresh_tokens WHERE token_hash = ?").run(this.hashToken(refreshToken));
993
+ }
994
+ // --- Clerk sign-in ---
995
+ /**
996
+ * Maps a verified Clerk session to a local user. Only a verified email can link to an
997
+ * existing account; otherwise a fresh user and workspace are created so an unverified
998
+ * address can never take over someone else's account.
999
+ */
1000
+ provisionClerkUser(clerkUserId, identity, ownerWorkspaceId) {
1001
+ const mappedUser = this.getClerkLinkedUser(clerkUserId);
1002
+ if (mappedUser)
1003
+ return mappedUser;
1004
+ const email = identity.email ? identity.email.trim().toLowerCase() : null;
1005
+ const verifiedEmail = email && identity.emailVerified ? email : null;
1006
+ let user = verifiedEmail ? this.getUserByEmail(verifiedEmail) : null;
1007
+ if (!user) {
1008
+ const displayName = identity.name || (verifiedEmail ? verifiedEmail.split("@")[0] : "Muxo user");
1009
+ const placeholder = `${clerkUserId.toLowerCase()}@clerk.users.muxo.invalid`;
1010
+ user = this.registerUser({
1011
+ email: verifiedEmail || placeholder,
1012
+ name: displayName,
1013
+ password: crypto.randomBytes(24).toString("hex"),
1014
+ }).user;
1015
+ }
1016
+ if (ownerWorkspaceId && verifiedEmail && this.getWorkspace(ownerWorkspaceId)) {
1017
+ this.ensureWorkspaceMembership({ workspaceId: ownerWorkspaceId, userId: user.id, role: "owner" });
1018
+ this.db.prepare("UPDATE users SET workspace_id = ? WHERE id = ?").run(ownerWorkspaceId, user.id);
1019
+ user = this.getUser(user.id);
1020
+ }
1021
+ this.db
1022
+ .prepare("INSERT OR REPLACE INTO clerk_users (clerk_user_id, user_id, created_at) VALUES (?, ?, ?)")
1023
+ .run(clerkUserId, user.id, new Date().toISOString());
1024
+ return user;
1025
+ }
1026
+ getClerkLinkedUser(clerkUserId) {
1027
+ const mapped = this.db
1028
+ .prepare("SELECT user_id FROM clerk_users WHERE clerk_user_id = ?")
1029
+ .get(clerkUserId);
1030
+ return mapped ? this.getUser(mapped.user_id) : null;
1031
+ }
1032
+ getUserByEmail(email) {
1033
+ const row = this.db
1034
+ .prepare("SELECT * FROM users WHERE email = ?")
1035
+ .get(email.trim().toLowerCase());
1036
+ return row ? this.mapUser(row) : null;
1037
+ }
513
1038
  // --- Connections ---
514
1039
  upsertConnection(params) {
515
1040
  const now = new Date().toISOString();
@@ -621,10 +1146,24 @@ export class MuxoStore {
621
1146
  }
622
1147
  getResource(workspaceId, resourceNameOrId) {
623
1148
  const row = this.db
624
- .prepare("SELECT * FROM resources WHERE workspace_id = ? AND (resource_name = ? OR id = ?)")
625
- .get(workspaceId, resourceNameOrId, resourceNameOrId);
1149
+ .prepare("SELECT * FROM resources WHERE workspace_id = ? AND (resource_name = ? OR id = ? OR external_resource_id = ?)")
1150
+ .get(workspaceId, resourceNameOrId, resourceNameOrId, resourceNameOrId);
1151
+ return row ? this.mapResource(row) : null;
1152
+ }
1153
+ findResourceAnyWorkspace(resourceNameOrId) {
1154
+ const row = this.db
1155
+ .prepare("SELECT * FROM resources WHERE id = ? OR external_resource_id = ? OR resource_name = ? LIMIT 1")
1156
+ .get(resourceNameOrId, resourceNameOrId, resourceNameOrId);
626
1157
  return row ? this.mapResource(row) : null;
627
1158
  }
1159
+ getDecryptedResourceSecrets(workspaceId, resourceNameOrId) {
1160
+ const row = this.db
1161
+ .prepare("SELECT encrypted_secrets_hex FROM resources WHERE workspace_id = ? AND (id = ? OR external_resource_id = ? OR resource_name = ?) LIMIT 1")
1162
+ .get(workspaceId, resourceNameOrId, resourceNameOrId, resourceNameOrId);
1163
+ if (!row)
1164
+ return {};
1165
+ return this.decryptSecrets(workspaceId, row.encrypted_secrets_hex);
1166
+ }
628
1167
  listResources(workspaceId) {
629
1168
  const rows = this.db
630
1169
  .prepare("SELECT * FROM resources WHERE workspace_id = ? ORDER BY updated_at DESC")
@@ -799,6 +1338,12 @@ export class MuxoStore {
799
1338
  .get(planId, workspaceId);
800
1339
  return row ? this.mapPlan(row) : null;
801
1340
  }
1341
+ getPlanById(planId) {
1342
+ const row = this.db
1343
+ .prepare("SELECT * FROM plans WHERE id = ?")
1344
+ .get(planId);
1345
+ return row ? this.mapPlan(row) : null;
1346
+ }
802
1347
  listPlans(workspaceId, limit = 50) {
803
1348
  const rows = this.db
804
1349
  .prepare("SELECT * FROM plans WHERE workspace_id = ? ORDER BY created_at DESC LIMIT ?")
@@ -879,10 +1424,41 @@ export class MuxoStore {
879
1424
  receipt,
880
1425
  };
881
1426
  }
1427
+ cancelRun(params) {
1428
+ const run = this.getRun(params.workspaceId, params.runId);
1429
+ if (!run)
1430
+ throw new Error(`Run ${params.runId} not found`);
1431
+ const now = new Date().toISOString();
1432
+ const cancellation = {
1433
+ cancelled: true,
1434
+ cancelledBy: params.cancelledBy,
1435
+ cancelledAt: now,
1436
+ previousStatus: run.status,
1437
+ supersedesReceiptId: run.receiptId,
1438
+ ...(params.reason ? { reason: params.reason } : {}),
1439
+ };
1440
+ const receipt = this.issueReceipt({
1441
+ run,
1442
+ status: "cancelled",
1443
+ output: cancellation,
1444
+ actionsMetered: 0,
1445
+ creditsMetered: 0,
1446
+ decisionRecord: null,
1447
+ issuedAt: now,
1448
+ });
1449
+ this.db
1450
+ .prepare(`UPDATE runs
1451
+ SET status = 'cancelled', output_json = COALESCE(output_json, ?), error = ?,
1452
+ receipt_id = ?, completed_at = COALESCE(completed_at, ?),
1453
+ duration_ms = COALESCE(duration_ms, ?)
1454
+ WHERE id = ? AND workspace_id = ?`)
1455
+ .run(JSON.stringify(cancellation), params.reason || "Cancelled by operator", receipt.id, now, Math.max(0, Date.now() - new Date(run.startedAt).getTime()), run.id, params.workspaceId);
1456
+ return { run: this.getRun(params.workspaceId, run.id), receipt };
1457
+ }
882
1458
  issueReceipt(params) {
883
1459
  const id = `rcpt_${crypto.randomBytes(8).toString("hex")}`;
884
1460
  const lastReceipt = this.db
885
- .prepare("SELECT receipt_hash FROM receipts WHERE workspace_id = ? ORDER BY issued_at DESC LIMIT 1")
1461
+ .prepare("SELECT receipt_hash FROM receipts WHERE workspace_id = ? ORDER BY issued_at DESC, rowid DESC LIMIT 1")
886
1462
  .get(params.run.workspaceId);
887
1463
  const prevReceiptHash = lastReceipt?.receipt_hash || "genesis_0000000000000000";
888
1464
  const inputHash = crypto
@@ -964,14 +1540,17 @@ export class MuxoStore {
964
1540
  return rows.map((r) => this.mapRun(r));
965
1541
  }
966
1542
  getReceipt(workspaceId, receiptIdOrRunId) {
1543
+ // A cancelled run has both its original and its cancellation receipt; prefer an exact
1544
+ // receipt id match, otherwise the newest receipt for the run.
967
1545
  const row = this.db
968
- .prepare("SELECT * FROM receipts WHERE workspace_id = ? AND (id = ? OR run_id = ?)")
969
- .get(workspaceId, receiptIdOrRunId, receiptIdOrRunId);
1546
+ .prepare(`SELECT * FROM receipts WHERE workspace_id = ? AND (id = ? OR run_id = ?)
1547
+ ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, issued_at DESC, rowid DESC LIMIT 1`)
1548
+ .get(workspaceId, receiptIdOrRunId, receiptIdOrRunId, receiptIdOrRunId);
970
1549
  return row ? this.mapReceipt(row) : null;
971
1550
  }
972
1551
  listReceipts(workspaceId, limit = 50) {
973
1552
  const rows = this.db
974
- .prepare("SELECT * FROM receipts WHERE workspace_id = ? ORDER BY issued_at DESC LIMIT ?")
1553
+ .prepare("SELECT * FROM receipts WHERE workspace_id = ? ORDER BY issued_at DESC, rowid DESC LIMIT ?")
975
1554
  .all(workspaceId, limit);
976
1555
  return rows.map((r) => this.mapReceipt(r));
977
1556
  }
@@ -1507,46 +2086,92 @@ export class MuxoStore {
1507
2086
  expiresAt: String(row.expires_at),
1508
2087
  };
1509
2088
  }
2089
+ /**
2090
+ * Creates a claim link for a workspace. A claim minted without an email (at agent signup)
2091
+ * stays unusable until the agent names the owner; naming one reuses that same link.
2092
+ */
1510
2093
  createAgentClaim(params) {
1511
- const claimSlug = crypto.randomBytes(9).toString("base64url");
2094
+ const targetEmail = (params.email || "").trim().toLowerCase();
1512
2095
  const now = new Date();
1513
2096
  const expires = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
1514
- this.db
1515
- .prepare(`INSERT INTO agent_claims (
1516
- claim_slug, workspace_id, target_email, status, claimed_at, expires_at, created_at
1517
- ) VALUES (?, ?, ?, 'invited', NULL, ?, ?)`)
1518
- .run(claimSlug, params.workspaceId, params.email.trim().toLowerCase(), expires.toISOString(), now.toISOString());
2097
+ const unbound = targetEmail
2098
+ ? this.db
2099
+ .prepare(`SELECT claim_slug FROM agent_claims
2100
+ WHERE workspace_id = ? AND status = 'invited' AND target_email = ''
2101
+ ORDER BY created_at DESC LIMIT 1`)
2102
+ .get(params.workspaceId)
2103
+ : undefined;
2104
+ const claimSlug = unbound?.claim_slug || crypto.randomBytes(24).toString("base64url");
2105
+ if (unbound) {
2106
+ this.db
2107
+ .prepare("UPDATE agent_claims SET target_email = ?, expires_at = ? WHERE claim_slug = ?")
2108
+ .run(targetEmail, expires.toISOString(), claimSlug);
2109
+ }
2110
+ else {
2111
+ this.db
2112
+ .prepare(`INSERT INTO agent_claims (
2113
+ claim_slug, workspace_id, target_email, status, claimed_at, expires_at, created_at
2114
+ ) VALUES (?, ?, ?, 'invited', NULL, ?, ?)`)
2115
+ .run(claimSlug, params.workspaceId, targetEmail, expires.toISOString(), now.toISOString());
2116
+ }
1519
2117
  return {
1520
2118
  claimSlug,
1521
2119
  claimUrl: `${params.baseUrl.replace(/\/$/, "")}/claim/${claimSlug}`,
1522
- targetEmail: params.email.trim().toLowerCase(),
2120
+ targetEmail,
1523
2121
  status: "invited",
1524
2122
  expiresAt: expires.toISOString(),
1525
2123
  };
1526
2124
  }
1527
- redeemAgentClaim(claimSlug) {
2125
+ /** Returns a claim only while it can still be accepted (invited and not expired). */
2126
+ getOpenAgentClaim(claimSlug) {
1528
2127
  const row = this.db
1529
2128
  .prepare("SELECT * FROM agent_claims WHERE claim_slug = ?")
1530
2129
  .get(claimSlug);
1531
- if (!row)
2130
+ if (!row || String(row.status) !== "invited" || !row.target_email)
1532
2131
  return null;
1533
- const now = new Date().toISOString();
1534
- const workspaceId = String(row.workspace_id);
1535
- const targetEmail = String(row.target_email);
1536
- this.db
1537
- .prepare("UPDATE agent_claims SET status = 'claimed', claimed_at = ? WHERE claim_slug = ?")
1538
- .run(now, claimSlug);
1539
- const workspace = this.getWorkspace(workspaceId);
2132
+ if (Date.parse(String(row.expires_at)) <= Date.now())
2133
+ return null;
2134
+ const workspace = this.getWorkspace(String(row.workspace_id));
1540
2135
  if (!workspace)
1541
2136
  return null;
1542
2137
  return {
1543
2138
  claimSlug,
1544
2139
  workspace,
1545
- targetEmail,
1546
- status: "claimed",
1547
- claimedAt: now,
2140
+ targetEmail: String(row.target_email),
2141
+ status: "invited",
2142
+ expiresAt: String(row.expires_at),
1548
2143
  };
1549
2144
  }
2145
+ /** Transfers workspace ownership to the verified claimer and closes the claim. */
2146
+ acceptAgentClaim(claimSlug, userId) {
2147
+ const claim = this.getOpenAgentClaim(claimSlug);
2148
+ if (!claim || !this.getUser(userId))
2149
+ return null;
2150
+ const now = new Date().toISOString();
2151
+ this.db.exec("BEGIN");
2152
+ try {
2153
+ const closed = this.db
2154
+ .prepare("UPDATE agent_claims SET status = 'claimed', claimed_at = ? WHERE claim_slug = ? AND status = 'invited'")
2155
+ .run(now, claimSlug);
2156
+ if (closed.changes === 0) {
2157
+ this.db.exec("ROLLBACK");
2158
+ return null;
2159
+ }
2160
+ this.db
2161
+ .prepare("UPDATE workspaces SET owner_user_id = ?, updated_at = ? WHERE id = ?")
2162
+ .run(userId, now, claim.workspace.id);
2163
+ this.db
2164
+ .prepare("UPDATE users SET workspace_id = ?, role = 'owner' WHERE id = ?")
2165
+ .run(claim.workspace.id, userId);
2166
+ this.ensureWorkspaceMembership({ workspaceId: claim.workspace.id, userId, role: "owner" });
2167
+ this.db.exec("COMMIT");
2168
+ }
2169
+ catch (err) {
2170
+ this.db.exec("ROLLBACK");
2171
+ throw err;
2172
+ }
2173
+ return this.getWorkspace(claim.workspace.id);
2174
+ }
1550
2175
  // --- Row Mappers ---
1551
2176
  mapUser(row) {
1552
2177
  return {