@muxoai/platform 1.3.3 → 1.4.1

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