@myrialabs/clopen 0.1.9 → 0.2.0

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 (94) hide show
  1. package/README.md +23 -1
  2. package/backend/index.ts +25 -1
  3. package/backend/lib/auth/auth-service.ts +484 -0
  4. package/backend/lib/auth/index.ts +4 -0
  5. package/backend/lib/auth/permissions.ts +63 -0
  6. package/backend/lib/auth/rate-limiter.ts +145 -0
  7. package/backend/lib/auth/tokens.ts +53 -0
  8. package/backend/lib/chat/stream-manager.ts +4 -1
  9. package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
  10. package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
  11. package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
  12. package/backend/lib/database/migrations/index.ts +21 -0
  13. package/backend/lib/database/queries/auth-queries.ts +201 -0
  14. package/backend/lib/database/queries/index.ts +2 -1
  15. package/backend/lib/database/queries/session-queries.ts +13 -0
  16. package/backend/lib/database/queries/snapshot-queries.ts +1 -1
  17. package/backend/lib/engine/adapters/opencode/server.ts +9 -1
  18. package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
  19. package/backend/lib/mcp/config.ts +13 -18
  20. package/backend/lib/mcp/index.ts +9 -0
  21. package/backend/lib/mcp/remote-server.ts +132 -0
  22. package/backend/lib/mcp/servers/helper.ts +49 -3
  23. package/backend/lib/mcp/servers/index.ts +3 -2
  24. package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
  25. package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
  26. package/backend/lib/preview/browser/browser-pool.ts +73 -176
  27. package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
  28. package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
  29. package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
  30. package/backend/lib/snapshot/helpers.ts +22 -49
  31. package/backend/lib/snapshot/snapshot-service.ts +148 -83
  32. package/backend/lib/utils/ws.ts +65 -1
  33. package/backend/ws/auth/index.ts +17 -0
  34. package/backend/ws/auth/invites.ts +84 -0
  35. package/backend/ws/auth/login.ts +269 -0
  36. package/backend/ws/auth/status.ts +41 -0
  37. package/backend/ws/auth/users.ts +32 -0
  38. package/backend/ws/chat/stream.ts +13 -0
  39. package/backend/ws/engine/claude/accounts.ts +3 -1
  40. package/backend/ws/engine/utils.ts +38 -6
  41. package/backend/ws/index.ts +4 -4
  42. package/backend/ws/preview/browser/interact.ts +27 -5
  43. package/backend/ws/snapshot/restore.ts +111 -12
  44. package/backend/ws/snapshot/timeline.ts +56 -29
  45. package/bin/clopen.ts +56 -1
  46. package/bun.lock +113 -51
  47. package/frontend/App.svelte +47 -29
  48. package/frontend/lib/components/auth/InvitePage.svelte +215 -0
  49. package/frontend/lib/components/auth/LoginPage.svelte +129 -0
  50. package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
  51. package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
  52. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
  53. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
  54. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
  55. package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
  56. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
  57. package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
  58. package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
  59. package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
  60. package/frontend/lib/components/git/CommitForm.svelte +6 -4
  61. package/frontend/lib/components/history/HistoryModal.svelte +1 -1
  62. package/frontend/lib/components/history/HistoryView.svelte +1 -1
  63. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
  64. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
  65. package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
  66. package/frontend/lib/components/settings/SettingsView.svelte +21 -7
  67. package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
  68. package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
  69. package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
  70. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
  71. package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
  72. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  73. package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
  74. package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
  75. package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
  76. package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
  77. package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
  78. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
  79. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  80. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
  81. package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
  82. package/frontend/lib/stores/features/auth.svelte.ts +296 -0
  83. package/frontend/lib/stores/features/settings.svelte.ts +53 -9
  84. package/frontend/lib/stores/features/user.svelte.ts +26 -68
  85. package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
  86. package/frontend/lib/stores/ui/update.svelte.ts +2 -14
  87. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
  88. package/package.json +8 -6
  89. package/shared/types/stores/settings.ts +16 -2
  90. package/shared/utils/logger.ts +1 -0
  91. package/shared/utils/ws-client.ts +30 -13
  92. package/shared/utils/ws-server.ts +42 -4
  93. package/backend/lib/mcp/stdio-server.ts +0 -103
  94. package/backend/ws/mcp/index.ts +0 -61
@@ -285,47 +285,18 @@ export class SnapshotService {
285
285
  */
286
286
  async checkRestoreConflicts(
287
287
  sessionId: string,
288
- targetCheckpointMessageId: string,
289
- projectPath?: string
288
+ targetCheckpointMessageId: string | null,
289
+ projectPath?: string,
290
+ targetPath?: string[]
290
291
  ): Promise<RestoreConflictCheck> {
291
292
  const sessionSnapshots = snapshotQueries.getBySessionId(sessionId);
293
+ const isInitialRestore = targetCheckpointMessageId === null;
292
294
 
293
- const targetIndex = sessionSnapshots.findIndex(
294
- s => s.message_id === targetCheckpointMessageId
295
+ // Build expected state at target (branch-aware when targetPath is provided)
296
+ const expectedState = this.buildExpectedState(
297
+ sessionSnapshots, targetCheckpointMessageId, targetPath
295
298
  );
296
299
 
297
- if (targetIndex === -1) {
298
- return { hasConflicts: false, conflicts: [], checkpointsToUndo: [] };
299
- }
300
-
301
- // Build expected state at target (same bidirectional algorithm as restoreSessionScoped)
302
- // This determines ALL files that would be affected by the restore
303
- const expectedState = new Map<string, string>(); // filepath → expectedHash
304
-
305
- for (let i = 0; i <= targetIndex; i++) {
306
- const snap = sessionSnapshots[i];
307
- if (!snap.session_changes) continue;
308
- try {
309
- const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
310
- for (const [filepath, change] of Object.entries(changes)) {
311
- expectedState.set(filepath, change.newHash);
312
- }
313
- } catch { /* skip malformed */ }
314
- }
315
-
316
- for (let i = targetIndex + 1; i < sessionSnapshots.length; i++) {
317
- const snap = sessionSnapshots[i];
318
- if (!snap.session_changes) continue;
319
- try {
320
- const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
321
- for (const [filepath, change] of Object.entries(changes)) {
322
- if (!expectedState.has(filepath)) {
323
- expectedState.set(filepath, change.oldHash);
324
- }
325
- }
326
- } catch { /* skip malformed */ }
327
- }
328
-
329
300
  if (expectedState.size === 0) {
330
301
  return { hasConflicts: false, conflicts: [], checkpointsToUndo: [] };
331
302
  }
@@ -354,8 +325,13 @@ export class SnapshotService {
354
325
 
355
326
  // Determine reference time for cross-session conflict check
356
327
  // Use min(targetTime, currentHeadTime) to cover both undo and redo
357
- const targetSnapshot = sessionSnapshots[targetIndex];
358
- const targetTime = targetSnapshot.created_at;
328
+ // For initial restore, use the session's created_at or the earliest snapshot time
329
+ const targetSnapshot = isInitialRestore
330
+ ? null
331
+ : sessionSnapshots.find(s => s.message_id === targetCheckpointMessageId) || null;
332
+ const targetTime = targetSnapshot
333
+ ? targetSnapshot.created_at
334
+ : (sessionSnapshots[0]?.created_at || new Date(0).toISOString());
359
335
  let referenceTime = targetTime;
360
336
 
361
337
  const currentHead = sessionQueries.getHead(sessionId);
@@ -384,7 +360,9 @@ export class SnapshotService {
384
360
 
385
361
  // Check for cross-session conflicts
386
362
  const conflicts: RestoreConflict[] = [];
387
- const projectId = targetSnapshot.project_id;
363
+ const projectId = targetSnapshot
364
+ ? targetSnapshot.project_id
365
+ : (sessionSnapshots[0]?.project_id || '');
388
366
  const allProjectSnapshots = this.getAllProjectSnapshots(projectId);
389
367
 
390
368
  for (const otherSnap of allProjectSnapshots) {
@@ -472,60 +450,29 @@ export class SnapshotService {
472
450
  * Restore to a checkpoint using session-scoped changes.
473
451
  * Works in both directions (forward and backward).
474
452
  *
475
- * Algorithm:
476
- * 1. Walk snapshots [0..targetIndex] build expected file state at target
477
- * 2. Walk snapshots [targetIndex+1..end] files changed only after target need reverting
478
- * 3. For each file in the expected state map, compare with current disk and restore if different
453
+ * When targetPath is provided, uses branch-aware algorithm:
454
+ * 1. Only apply changes from snapshots on the path (root target)
455
+ * 2. Revert ALL changes from snapshots on other branches
456
+ * 3. Compare expected state with disk and restore if different
479
457
  * 4. Update in-memory baseline to match restored state
458
+ *
459
+ * Falls back to linear algorithm when targetPath is not provided.
480
460
  */
481
461
  async restoreSessionScoped(
482
462
  projectPath: string,
483
463
  sessionId: string,
484
- targetCheckpointMessageId: string,
485
- conflictResolutions?: ConflictResolution
464
+ targetCheckpointMessageId: string | null,
465
+ conflictResolutions?: ConflictResolution,
466
+ targetPath?: string[]
486
467
  ): Promise<{ restoredFiles: number; skippedFiles: number }> {
487
468
  try {
488
469
  const sessionSnapshots = snapshotQueries.getBySessionId(sessionId);
489
470
 
490
- const targetIndex = sessionSnapshots.findIndex(
491
- s => s.message_id === targetCheckpointMessageId
492
- );
493
-
494
- if (targetIndex === -1) {
495
- debug.warn('snapshot', 'Target checkpoint snapshot not found');
496
- return { restoredFiles: 0, skippedFiles: 0 };
497
- }
498
-
499
471
  // Build expected file state at the target checkpoint
500
- // filepath hash that the file should be at the target
501
- const expectedState = new Map<string, string>();
502
-
503
- // Walk snapshots from first to target (inclusive): apply forward changes
504
- for (let i = 0; i <= targetIndex; i++) {
505
- const snap = sessionSnapshots[i];
506
- if (!snap.session_changes) continue;
507
- try {
508
- const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
509
- for (const [filepath, change] of Object.entries(changes)) {
510
- expectedState.set(filepath, change.newHash);
511
- }
512
- } catch { /* skip */ }
513
- }
514
-
515
- // Walk snapshots after target: files changed only after target need reverting to oldHash
516
- for (let i = targetIndex + 1; i < sessionSnapshots.length; i++) {
517
- const snap = sessionSnapshots[i];
518
- if (!snap.session_changes) continue;
519
- try {
520
- const changes = JSON.parse(snap.session_changes) as SessionScopedChanges;
521
- for (const [filepath, change] of Object.entries(changes)) {
522
- if (!expectedState.has(filepath)) {
523
- // File was first changed AFTER target → revert to pre-change state
524
- expectedState.set(filepath, change.oldHash);
525
- }
526
- }
527
- } catch { /* skip */ }
528
- }
472
+ // Branch-aware when targetPath is provided
473
+ const expectedState = this.buildExpectedState(
474
+ sessionSnapshots, targetCheckpointMessageId, targetPath
475
+ );
529
476
 
530
477
  debug.log('snapshot', `Restore to checkpoint: ${expectedState.size} files in expected state`);
531
478
 
@@ -600,6 +547,124 @@ export class SnapshotService {
600
547
  // Helpers
601
548
  // ========================================================================
602
549
 
550
+ /**
551
+ * Build the expected file state map for a restore operation.
552
+ *
553
+ * Branch-aware algorithm (when targetPath is provided):
554
+ * 1. Separate snapshots into path (root→target) vs non-path
555
+ * 2. Forward walk: only apply newHash from snapshots on the path (in path order)
556
+ * 3. Revert walk: revert ALL non-path snapshots using oldHash (first-wins)
557
+ *
558
+ * This correctly handles multi-branch checkpoint trees by NOT including
559
+ * changes from other branches in the forward walk.
560
+ *
561
+ * Fallback linear algorithm (when targetPath is not provided):
562
+ * Walks all snapshots chronologically — only correct for single-branch paths.
563
+ */
564
+ private buildExpectedState(
565
+ sessionSnapshots: MessageSnapshot[],
566
+ targetCheckpointMessageId: string | null,
567
+ targetPath?: string[]
568
+ ): Map<string, string> {
569
+ const expectedState = new Map<string, string>();
570
+ const isInitialRestore = targetCheckpointMessageId === null;
571
+
572
+ if (isInitialRestore) {
573
+ // Revert ALL snapshots → everything goes back to oldHash (first-wins)
574
+ for (const snap of sessionSnapshots) {
575
+ if (!snap.session_changes) continue;
576
+ try {
577
+ const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
578
+ for (const [filepath, change] of Object.entries(changes)) {
579
+ if (!expectedState.has(filepath)) {
580
+ expectedState.set(filepath, change.oldHash);
581
+ }
582
+ }
583
+ } catch { /* skip malformed */ }
584
+ }
585
+ } else if (targetPath && targetPath.length > 0) {
586
+ // Branch-aware restore: only include snapshots on the path from root to target
587
+ const pathSet = new Set(targetPath);
588
+
589
+ // Separate snapshots into path vs non-path
590
+ const snapshotByMsgId = new Map<string, MessageSnapshot>();
591
+ const nonPathSnapshots: MessageSnapshot[] = [];
592
+
593
+ for (const snap of sessionSnapshots) {
594
+ if (pathSet.has(snap.message_id)) {
595
+ snapshotByMsgId.set(snap.message_id, snap);
596
+ } else {
597
+ nonPathSnapshots.push(snap);
598
+ }
599
+ }
600
+
601
+ // Forward walk: apply path snapshots in path order (root → target)
602
+ // Later path snapshots overwrite earlier ones for the same file (correct)
603
+ for (const cpId of targetPath) {
604
+ const snap = snapshotByMsgId.get(cpId);
605
+ if (!snap?.session_changes) continue;
606
+ try {
607
+ const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
608
+ for (const [filepath, change] of Object.entries(changes)) {
609
+ expectedState.set(filepath, change.newHash);
610
+ }
611
+ } catch { /* skip malformed */ }
612
+ }
613
+
614
+ // Revert all non-path snapshots (changes on other branches)
615
+ // Process in chronological order with first-wins semantics:
616
+ // if two non-path snapshots change the same file, the earliest one's
617
+ // oldHash is used (state before any branch diverged)
618
+ for (const snap of nonPathSnapshots) {
619
+ if (!snap.session_changes) continue;
620
+ try {
621
+ const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
622
+ for (const [filepath, change] of Object.entries(changes)) {
623
+ if (!expectedState.has(filepath)) {
624
+ expectedState.set(filepath, change.oldHash);
625
+ }
626
+ }
627
+ } catch { /* skip malformed */ }
628
+ }
629
+ } else {
630
+ // Fallback: linear algorithm (no path info available)
631
+ const targetIndex = sessionSnapshots.findIndex(
632
+ s => s.message_id === targetCheckpointMessageId
633
+ );
634
+
635
+ if (targetIndex === -1) {
636
+ debug.warn('snapshot', 'Target checkpoint snapshot not found (linear fallback)');
637
+ return expectedState;
638
+ }
639
+
640
+ for (let i = 0; i <= targetIndex; i++) {
641
+ const snap = sessionSnapshots[i];
642
+ if (!snap.session_changes) continue;
643
+ try {
644
+ const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
645
+ for (const [filepath, change] of Object.entries(changes)) {
646
+ expectedState.set(filepath, change.newHash);
647
+ }
648
+ } catch { /* skip malformed */ }
649
+ }
650
+
651
+ for (let i = targetIndex + 1; i < sessionSnapshots.length; i++) {
652
+ const snap = sessionSnapshots[i];
653
+ if (!snap.session_changes) continue;
654
+ try {
655
+ const changes = JSON.parse(snap.session_changes as string) as SessionScopedChanges;
656
+ for (const [filepath, change] of Object.entries(changes)) {
657
+ if (!expectedState.has(filepath)) {
658
+ expectedState.set(filepath, change.oldHash);
659
+ }
660
+ }
661
+ } catch { /* skip malformed */ }
662
+ }
663
+ }
664
+
665
+ return expectedState;
666
+ }
667
+
603
668
  /**
604
669
  * Calculate line-level change stats for changed files.
605
670
  */
@@ -67,6 +67,12 @@ interface ConnectionState {
67
67
  chatSessionIds: Set<string>;
68
68
  /** Cleanup functions called automatically on unregister (connection close) */
69
69
  cleanups: Set<() => void>;
70
+ /** Whether this connection has been authenticated */
71
+ authenticated: boolean;
72
+ /** User role (admin or member) — set by auth handler */
73
+ role: 'admin' | 'member' | null;
74
+ /** Hash of the session token used for this connection */
75
+ sessionTokenHash: string | null;
70
76
  }
71
77
 
72
78
  /**
@@ -140,7 +146,7 @@ class WSServer {
140
146
  const id = crypto.randomUUID();
141
147
  this.rawToId.set(raw, id);
142
148
  this.connections.set(id, conn);
143
- this.connectionState.set(id, { userId: null, projectId: null, chatSessionIds: new Set(), cleanups: new Set() });
149
+ this.connectionState.set(id, { userId: null, projectId: null, chatSessionIds: new Set(), cleanups: new Set(), authenticated: false, role: null, sessionTokenHash: null });
144
150
 
145
151
  this.metrics.totalConnections = this.connections.size;
146
152
  debug.log('websocket', `Connection registered: ${id} (total: ${this.connections.size})`);
@@ -516,6 +522,64 @@ class WSServer {
516
522
  return this.connectionState.get(id);
517
523
  }
518
524
 
525
+ /**
526
+ * Set authentication state for a connection.
527
+ * Called by auth handlers after successful login/setup/invite.
528
+ */
529
+ setAuth(conn: WSConnection, userId: string, role: 'admin' | 'member', sessionTokenHash: string): void {
530
+ const wsId = this.ensureRegistered(conn);
531
+ const state = this.connectionState.get(wsId);
532
+ if (state) {
533
+ state.authenticated = true;
534
+ state.role = role;
535
+ state.sessionTokenHash = sessionTokenHash;
536
+ }
537
+ // Also set userId via existing method (handles room management)
538
+ this.setUser(conn, userId);
539
+ debug.log('websocket', `Connection ${wsId} authenticated: userId=${userId}, role=${role}`);
540
+ }
541
+
542
+ /**
543
+ * Get the role for a connection.
544
+ */
545
+ getRole(conn: WSConnection): 'admin' | 'member' | null {
546
+ const id = this.resolveId(conn);
547
+ if (!id) return null;
548
+ return this.connectionState.get(id)?.role ?? null;
549
+ }
550
+
551
+ /**
552
+ * Check if a connection is authenticated.
553
+ */
554
+ isAuthenticated(conn: WSConnection): boolean {
555
+ const id = this.resolveId(conn);
556
+ if (!id) return false;
557
+ return this.connectionState.get(id)?.authenticated ?? false;
558
+ }
559
+
560
+ /**
561
+ * Get remote IP address of a connection.
562
+ */
563
+ getRemoteAddress(conn: WSConnection): string {
564
+ const raw = (conn as any).raw;
565
+ return raw?.remoteAddress ?? 'unknown';
566
+ }
567
+
568
+ /**
569
+ * Clear authentication state for a connection (logout).
570
+ */
571
+ clearAuth(conn: WSConnection): void {
572
+ const id = this.resolveId(conn);
573
+ if (!id) return;
574
+ const state = this.connectionState.get(id);
575
+ if (state) {
576
+ state.authenticated = false;
577
+ state.role = null;
578
+ state.sessionTokenHash = null;
579
+ }
580
+ debug.log('websocket', `Connection ${id} auth cleared`);
581
+ }
582
+
519
583
  /**
520
584
  * Check if connection can receive data (backpressure check)
521
585
  */
@@ -0,0 +1,17 @@
1
+ import { createRouter } from '$shared/utils/ws-server';
2
+ import { t } from 'elysia';
3
+ import { statusHandler } from './status';
4
+ import { loginHandler } from './login';
5
+ import { inviteHandler } from './invites';
6
+ import { usersHandler } from './users';
7
+
8
+ export const authRouter = createRouter()
9
+ .merge(statusHandler)
10
+ .merge(loginHandler)
11
+ .merge(inviteHandler)
12
+ .merge(usersHandler)
13
+ // Declare auth:error event (emitted by auth gate in WSRouter)
14
+ .emit('auth:error', t.Object({
15
+ error: t.String(),
16
+ blockedAction: t.String()
17
+ }));
@@ -0,0 +1,84 @@
1
+ import { t } from 'elysia';
2
+ import { createRouter } from '$shared/utils/ws-server';
3
+ import { createInvite, listInvites, revokeInvite } from '$backend/lib/auth/auth-service';
4
+ import { ws } from '$backend/lib/utils/ws';
5
+
6
+ export const inviteHandler = createRouter()
7
+ // Create invite token (admin only — enforced by auth gate)
8
+ .http('auth:create-invite', {
9
+ data: t.Object({
10
+ label: t.Optional(t.String()),
11
+ maxUses: t.Optional(t.Number({ minimum: 0 })),
12
+ expiresInMinutes: t.Optional(t.Number({ minimum: 1 }))
13
+ }),
14
+ response: t.Object({
15
+ inviteToken: t.String(),
16
+ invite: t.Object({
17
+ id: t.String(),
18
+ role: t.String(),
19
+ label: t.Union([t.String(), t.Null()]),
20
+ max_uses: t.Number(),
21
+ use_count: t.Number(),
22
+ expires_at: t.Union([t.String(), t.Null()]),
23
+ created_at: t.String()
24
+ })
25
+ })
26
+ }, async ({ data, conn }) => {
27
+ const userId = ws.getUserId(conn);
28
+ const result = createInvite(userId, {
29
+ label: data.label,
30
+ maxUses: data.maxUses,
31
+ expiresInMinutes: data.expiresInMinutes
32
+ });
33
+
34
+ return {
35
+ inviteToken: result.inviteToken,
36
+ invite: {
37
+ id: result.invite!.id,
38
+ role: result.invite!.role,
39
+ label: result.invite!.label,
40
+ max_uses: result.invite!.max_uses,
41
+ use_count: result.invite!.use_count,
42
+ expires_at: result.invite!.expires_at,
43
+ created_at: result.invite!.created_at
44
+ }
45
+ };
46
+ })
47
+
48
+ // List all invites (admin only)
49
+ .http('auth:list-invites', {
50
+ data: t.Object({}),
51
+ response: t.Array(t.Object({
52
+ id: t.String(),
53
+ role: t.String(),
54
+ label: t.Union([t.String(), t.Null()]),
55
+ max_uses: t.Number(),
56
+ use_count: t.Number(),
57
+ expires_at: t.Union([t.String(), t.Null()]),
58
+ created_at: t.String()
59
+ }))
60
+ }, async () => {
61
+ const invites = listInvites();
62
+ return invites.map(inv => ({
63
+ id: inv.id,
64
+ role: inv.role,
65
+ label: inv.label,
66
+ max_uses: inv.max_uses,
67
+ use_count: inv.use_count,
68
+ expires_at: inv.expires_at,
69
+ created_at: inv.created_at
70
+ }));
71
+ })
72
+
73
+ // Revoke invite (admin only)
74
+ .http('auth:revoke-invite', {
75
+ data: t.Object({
76
+ id: t.String({ minLength: 1 })
77
+ }),
78
+ response: t.Object({
79
+ success: t.Boolean()
80
+ })
81
+ }, async ({ data }) => {
82
+ revokeInvite(data.id);
83
+ return { success: true };
84
+ });