@muxoai/platform 1.4.0 → 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.
- package/.agents/plugins/marketplace.json +2 -2
- package/.claude-plugin/marketplace.json +2 -2
- package/ARCHITECTURE.md +16 -6
- package/README.md +1 -1
- package/dist/cli.js +317 -10
- package/dist/composio-catalog.d.ts +58 -0
- package/dist/composio-catalog.js +474 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +16 -0
- package/dist/durable-workflow.d.ts +45 -0
- package/dist/durable-workflow.js +90 -0
- package/dist/engine.d.ts +276 -5
- package/dist/engine.js +1261 -71
- package/dist/mcp-schema.d.ts +125 -0
- package/dist/mcp-schema.js +1076 -0
- package/dist/mcp.d.ts +2 -30
- package/dist/mcp.js +505 -539
- package/dist/memory-graph.d.ts +21 -0
- package/dist/memory-graph.js +173 -0
- package/dist/nl.js +155 -0
- package/dist/oauth.d.ts +32 -0
- package/dist/oauth.js +134 -0
- package/dist/policy.d.ts +75 -1
- package/dist/policy.js +1119 -0
- package/dist/providers/composio-instant.js +225 -5
- package/dist/providers/composio-sdk.d.ts +68 -5
- package/dist/providers/composio-sdk.js +683 -78
- package/dist/providers/microsoft-governance.d.ts +28 -1
- package/dist/providers/microsoft-governance.js +224 -11
- package/dist/providers/stripe-billing.d.ts +42 -0
- package/dist/providers/stripe-billing.js +230 -1
- package/dist/providers/stripe-projects.d.ts +451 -3
- package/dist/providers/stripe-projects.js +2106 -87
- package/dist/providers/supermemory.d.ts +74 -1
- package/dist/providers/supermemory.js +426 -0
- package/dist/provisioning-managed.d.ts +50 -0
- package/dist/provisioning-managed.js +155 -0
- package/dist/sdk.d.ts +440 -3
- package/dist/sdk.js +305 -3
- package/dist/server.js +1841 -232
- package/dist/store.d.ts +113 -6
- package/dist/store.js +675 -52
- package/dist/types.d.ts +192 -3
- package/dist/ui/console-html.d.ts +10 -1
- package/dist/ui/console-html.js +1082 -452
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/plugins/muxo/.claude-plugin/plugin.json +1 -1
- package/plugins/muxo/.codex-plugin/plugin.json +1 -1
- package/plugins/muxo/plugin.json +2 -2
- package/plugins/muxo/skills/muxo/SKILL.md +42 -1
- package/plugins/muxo/skills/muxo/references/mcp.md +3 -2
- 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
|
-
|
|
427
|
-
|
|
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,
|
|
473
|
-
|
|
474
|
-
|
|
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(
|
|
969
|
-
|
|
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
|
|
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
|
-
|
|
1515
|
-
.
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
|
2118
|
+
targetEmail,
|
|
1523
2119
|
status: "invited",
|
|
1524
2120
|
expiresAt: expires.toISOString(),
|
|
1525
2121
|
};
|
|
1526
2122
|
}
|
|
1527
|
-
|
|
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
|
-
|
|
1534
|
-
|
|
1535
|
-
const
|
|
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: "
|
|
1547
|
-
|
|
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 {
|