@kbve/droid 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/droid.mjs +2311 -203
  2. package/index.d.ts +3 -0
  3. package/lib/agents/api-types.d.ts +121 -0
  4. package/lib/agents/api-types.d.ts.map +1 -0
  5. package/lib/agents/auth/init.d.ts +8 -0
  6. package/lib/agents/auth/init.d.ts.map +1 -0
  7. package/lib/agents/cache.d.ts +9 -0
  8. package/lib/agents/cache.d.ts.map +1 -0
  9. package/lib/agents/client/callGuildVault.d.ts +3 -0
  10. package/lib/agents/client/callGuildVault.d.ts.map +1 -0
  11. package/lib/agents/client/callJson.d.ts +3 -0
  12. package/lib/agents/client/callJson.d.ts.map +1 -0
  13. package/lib/agents/config.d.ts +36 -0
  14. package/lib/agents/config.d.ts.map +1 -0
  15. package/lib/agents/constants.d.ts +11 -0
  16. package/lib/agents/constants.d.ts.map +1 -0
  17. package/lib/agents/createAgents.d.ts +4 -0
  18. package/lib/agents/createAgents.d.ts.map +1 -0
  19. package/lib/agents/ctx.d.ts +46 -0
  20. package/lib/agents/ctx.d.ts.map +1 -0
  21. package/lib/agents/discord/bot.d.ts +21 -0
  22. package/lib/agents/discord/bot.d.ts.map +1 -0
  23. package/lib/agents/discord/guilds.d.ts +8 -0
  24. package/lib/agents/discord/guilds.d.ts.map +1 -0
  25. package/lib/agents/formDrafts.d.ts +5 -0
  26. package/lib/agents/formDrafts.d.ts.map +1 -0
  27. package/lib/agents/generated/agents-schema.d.ts +73 -0
  28. package/lib/agents/generated/agents-schema.d.ts.map +1 -0
  29. package/lib/agents/generated/discordsh-agents-schema.d.ts +71 -0
  30. package/lib/agents/generated/discordsh-agents-schema.d.ts.map +1 -0
  31. package/lib/agents/github/backfill.d.ts +23 -0
  32. package/lib/agents/github/backfill.d.ts.map +1 -0
  33. package/lib/agents/github/events.d.ts +10 -0
  34. package/lib/agents/github/events.d.ts.map +1 -0
  35. package/lib/agents/github/pat.d.ts +13 -0
  36. package/lib/agents/github/pat.d.ts.map +1 -0
  37. package/lib/agents/github/repoConfig.d.ts +25 -0
  38. package/lib/agents/github/repoConfig.d.ts.map +1 -0
  39. package/lib/agents/github/tokens.d.ts +22 -0
  40. package/lib/agents/github/tokens.d.ts.map +1 -0
  41. package/lib/agents/github/webhook.d.ts +37 -0
  42. package/lib/agents/github/webhook.d.ts.map +1 -0
  43. package/lib/agents/index.d.ts +8 -0
  44. package/lib/agents/index.d.ts.map +1 -0
  45. package/lib/agents/state/atoms.d.ts +122 -0
  46. package/lib/agents/state/atoms.d.ts.map +1 -0
  47. package/lib/agents/types.d.ts +65 -0
  48. package/lib/agents/types.d.ts.map +1 -0
  49. package/lib/gateway/SupabaseGateway.d.ts.map +1 -1
  50. package/lib/gateway/types.d.ts +1 -1
  51. package/lib/gateway/types.d.ts.map +1 -1
  52. package/lib/workers/canvas-worker.js +6 -449
  53. package/lib/workers/data.d.ts.map +1 -1
  54. package/lib/workers/db-worker.d.ts +2 -2
  55. package/lib/workers/db-worker.d.ts.map +1 -1
  56. package/lib/workers/db-worker.js +14 -4170
  57. package/lib/workers/main.d.ts +0 -2
  58. package/lib/workers/main.d.ts.map +1 -1
  59. package/lib/workers/supabase-db-worker.js +11 -9325
  60. package/lib/workers/supabase-shared-worker.js +13 -12623
  61. package/lib/workers/ws-worker.d.ts +1 -2
  62. package/lib/workers/ws-worker.d.ts.map +1 -1
  63. package/lib/workers/ws-worker.js +6 -333
  64. package/main.js +5622 -4786
  65. package/package.json +2 -1
  66. package/src/index.ts +3 -0
  67. package/src/lib/agents/api-types.ts +170 -0
  68. package/src/lib/agents/auth/init.ts +200 -0
  69. package/src/lib/agents/cache.ts +127 -0
  70. package/src/lib/agents/client/callGuildVault.ts +71 -0
  71. package/src/lib/agents/client/callJson.ts +64 -0
  72. package/src/lib/agents/config.ts +83 -0
  73. package/src/lib/agents/constants.ts +11 -0
  74. package/src/lib/agents/createAgents.ts +62 -0
  75. package/src/lib/agents/ctx.ts +63 -0
  76. package/src/lib/agents/discord/bot.ts +169 -0
  77. package/src/lib/agents/discord/guilds.ts +106 -0
  78. package/src/lib/agents/formDrafts.ts +55 -0
  79. package/src/lib/agents/generated/agents-schema.ts +155 -0
  80. package/src/lib/agents/generated/discordsh-agents-schema.ts +96 -0
  81. package/src/lib/agents/github/backfill.ts +126 -0
  82. package/src/lib/agents/github/events.ts +131 -0
  83. package/src/lib/agents/github/pat.ts +150 -0
  84. package/src/lib/agents/github/repoConfig.ts +240 -0
  85. package/src/lib/agents/github/tokens.ts +222 -0
  86. package/src/lib/agents/github/webhook.ts +349 -0
  87. package/src/lib/agents/index.ts +35 -0
  88. package/src/lib/agents/state/atoms.ts +122 -0
  89. package/src/lib/agents/types.ts +83 -0
  90. package/src/lib/api.ts +71 -0
  91. package/src/lib/gateway/SupabaseGateway.ts +55 -2
  92. package/src/lib/gateway/WorkerCommunication.ts +1 -1
  93. package/src/lib/gateway/capabilities.spec.ts +144 -0
  94. package/src/lib/gateway/types.ts +97 -70
  95. package/src/lib/mod/module/supabase/mod-supabase.ts +10 -5
  96. package/src/lib/state/auth.ts +121 -3
  97. package/src/lib/state/bento.spec.ts +76 -0
  98. package/src/lib/state/bento.ts +51 -0
  99. package/src/lib/state/index.ts +64 -0
  100. package/src/lib/state/profile-sync.ts +214 -0
  101. package/src/lib/state/profile.ts +155 -0
  102. package/src/lib/state/staff.ts +128 -0
  103. package/src/lib/state/sync-bus.ts +109 -0
  104. package/src/lib/state/welcome-toast.spec.ts +101 -0
  105. package/src/lib/sw-recovery.spec.ts +165 -0
  106. package/src/lib/sw-recovery.ts +141 -0
  107. package/src/lib/types/event-schemas.spec.ts +120 -0
  108. package/src/lib/types/event-types.spec.ts +5 -2
  109. package/src/lib/types/event-types.ts +74 -0
  110. package/src/lib/workers/data.ts +5 -1
  111. package/src/lib/workers/db-worker.ts +34 -8
  112. package/src/lib/workers/main.ts +3 -18
  113. package/src/lib/workers/supabase-db-worker.ts +25 -3
  114. package/src/lib/workers/supabase-shared-worker.ts +43 -8
  115. package/src/lib/workers/ws-worker.spec.ts +41 -0
  116. package/src/lib/workers/ws-worker.ts +121 -28
  117. package/workers/main.js +2 -12
  118. package/comlink.js +0 -247
  119. package/index.js +0 -9263
  120. package/reference.js +0 -719
@@ -17,6 +17,8 @@ import {
17
17
  import { SharedWorkerStrategy } from './strategies/SharedWorkerStrategy';
18
18
  import { WebWorkerStrategy } from './strategies/WebWorkerStrategy';
19
19
  import { DirectStrategy } from './strategies/DirectStrategy';
20
+ import { getWorkerCommunication } from './WorkerCommunication';
21
+ import { DroidEvents } from '../workers/events';
20
22
 
21
23
  // Vite ?worker&url imports — bundles workers as JS and returns their URL
22
24
  import defaultSharedWorkerUrl from '../workers/supabase-shared-worker?worker&url';
@@ -72,6 +74,22 @@ export class SupabaseGateway implements ISupabaseStrategy {
72
74
  this.strategy = new DirectStrategy();
73
75
  break;
74
76
  }
77
+
78
+ // Forward worker-error events from BroadcastChannel to DroidEvents
79
+ if (typeof window !== 'undefined') {
80
+ const comm = getWorkerCommunication();
81
+ comm.on('worker-error', (payload) => {
82
+ DroidEvents.emit(
83
+ 'worker-error',
84
+ payload as {
85
+ timestamp: number;
86
+ worker: 'shared' | 'db' | 'ws';
87
+ operation: string;
88
+ message: string;
89
+ },
90
+ );
91
+ });
92
+ }
75
93
  }
76
94
 
77
95
  getStrategyType(): StrategyType {
@@ -84,12 +102,47 @@ export class SupabaseGateway implements ISupabaseStrategy {
84
102
 
85
103
  // Delegate all methods to the selected strategy
86
104
 
87
- init(
105
+ async init(
88
106
  url: string,
89
107
  anonKey: string,
90
108
  options?: Record<string, unknown>,
91
109
  ): Promise<SessionResponse> {
92
- return this.strategy.init(url, anonKey, options);
110
+ if (this.strategyType === 'direct') {
111
+ return this.strategy.init(url, anonKey, options);
112
+ }
113
+
114
+ const WORKER_INIT_FALLBACK_MS = 3500;
115
+ const primary = this.strategy.init(url, anonKey, options);
116
+
117
+ let timer: ReturnType<typeof setTimeout> | null = null;
118
+ const fallback = new Promise<never>((_, reject) => {
119
+ timer = setTimeout(
120
+ () => reject(new Error('worker init stalled')),
121
+ WORKER_INIT_FALLBACK_MS,
122
+ );
123
+ });
124
+
125
+ try {
126
+ const result = await Promise.race([primary, fallback]);
127
+ if (timer) clearTimeout(timer);
128
+ return result;
129
+ } catch (err) {
130
+ if (timer) clearTimeout(timer);
131
+ const previousType = this.strategyType;
132
+ console.warn(
133
+ `[SupabaseGateway] ${previousType} init did not respond in ${WORKER_INIT_FALLBACK_MS}ms — switching to direct strategy.`,
134
+ err,
135
+ );
136
+ this.strategy = new DirectStrategy();
137
+ this.strategyType = 'direct';
138
+ DroidEvents.emit('gateway-strategy-fallback', {
139
+ timestamp: Date.now(),
140
+ from: previousType,
141
+ to: 'direct',
142
+ reason: err instanceof Error ? err.message : String(err),
143
+ });
144
+ return this.strategy.init(url, anonKey, options);
145
+ }
93
146
  }
94
147
 
95
148
  on(event: string, callback: (payload: unknown) => void): () => void {
@@ -58,7 +58,7 @@ export class WorkerCommunication {
58
58
  if (type === 'realtime' && key) {
59
59
  this.emit(`realtime:${key}`, event.payload);
60
60
  } else {
61
- this.emit(type, event);
61
+ this.emit(type, event.data ?? event);
62
62
  }
63
63
  }
64
64
 
@@ -0,0 +1,144 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ detectCapabilities,
4
+ getStrategyDescription,
5
+ logCapabilities,
6
+ selectStrategy,
7
+ } from './capabilities';
8
+
9
+ const realUA = navigator.userAgent;
10
+
11
+ afterEach(() => {
12
+ Object.defineProperty(navigator, 'userAgent', {
13
+ value: realUA,
14
+ configurable: true,
15
+ });
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ function setUA(ua: string) {
20
+ Object.defineProperty(navigator, 'userAgent', {
21
+ value: ua,
22
+ configurable: true,
23
+ });
24
+ }
25
+
26
+ describe('detectCapabilities', () => {
27
+ it('flags Safari when Safari is in UA but Chrome is not', () => {
28
+ setUA(
29
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
30
+ );
31
+ const caps = detectCapabilities();
32
+ expect(caps.isSafari).toBe(true);
33
+ expect(caps.isAndroid).toBe(false);
34
+ });
35
+
36
+ it('does not flag Safari for Chrome on macOS', () => {
37
+ setUA(
38
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36',
39
+ );
40
+ const caps = detectCapabilities();
41
+ expect(caps.isSafari).toBe(false);
42
+ });
43
+
44
+ it('flags Android UA', () => {
45
+ setUA(
46
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36',
47
+ );
48
+ const caps = detectCapabilities();
49
+ expect(caps.isAndroid).toBe(true);
50
+ });
51
+ });
52
+
53
+ describe('selectStrategy', () => {
54
+ it('picks shared-worker on capable desktop', () => {
55
+ expect(
56
+ selectStrategy({
57
+ hasSharedWorker: true,
58
+ hasWorker: true,
59
+ hasBroadcastChannel: true,
60
+ isAndroid: false,
61
+ isSafari: false,
62
+ }),
63
+ ).toBe('shared-worker');
64
+ });
65
+
66
+ it('falls back to web-worker on Android even with SharedWorker support', () => {
67
+ expect(
68
+ selectStrategy({
69
+ hasSharedWorker: true,
70
+ hasWorker: true,
71
+ hasBroadcastChannel: true,
72
+ isAndroid: true,
73
+ isSafari: false,
74
+ }),
75
+ ).toBe('web-worker');
76
+ });
77
+
78
+ it('falls back to web-worker without SharedWorker', () => {
79
+ expect(
80
+ selectStrategy({
81
+ hasSharedWorker: false,
82
+ hasWorker: true,
83
+ hasBroadcastChannel: true,
84
+ isAndroid: false,
85
+ isSafari: true,
86
+ }),
87
+ ).toBe('web-worker');
88
+ });
89
+
90
+ it('falls back to direct without BroadcastChannel', () => {
91
+ expect(
92
+ selectStrategy({
93
+ hasSharedWorker: true,
94
+ hasWorker: true,
95
+ hasBroadcastChannel: false,
96
+ isAndroid: false,
97
+ isSafari: false,
98
+ }),
99
+ ).toBe('direct');
100
+ });
101
+
102
+ it('returns direct when Worker is unavailable', () => {
103
+ expect(
104
+ selectStrategy({
105
+ hasSharedWorker: false,
106
+ hasWorker: false,
107
+ hasBroadcastChannel: false,
108
+ isAndroid: false,
109
+ isSafari: false,
110
+ }),
111
+ ).toBe('direct');
112
+ });
113
+ });
114
+
115
+ describe('getStrategyDescription', () => {
116
+ it('returns a description for every strategy variant', () => {
117
+ expect(getStrategyDescription('shared-worker')).toMatch(/SharedWorker/);
118
+ expect(getStrategyDescription('web-worker')).toMatch(/WebSocket/);
119
+ expect(getStrategyDescription('direct')).toMatch(/Direct/);
120
+ });
121
+ });
122
+
123
+ describe('logCapabilities', () => {
124
+ it('writes a single console.log with the capability snapshot', () => {
125
+ const spy = vi
126
+ .spyOn(console, 'log')
127
+ .mockImplementation(() => undefined);
128
+ logCapabilities({
129
+ hasSharedWorker: true,
130
+ hasWorker: true,
131
+ hasBroadcastChannel: true,
132
+ isAndroid: false,
133
+ isSafari: false,
134
+ });
135
+ expect(spy).toHaveBeenCalledTimes(1);
136
+ const [, payload] = spy.mock.calls[0];
137
+ expect(payload).toMatchObject({
138
+ SharedWorker: 'yes',
139
+ Worker: 'yes',
140
+ BroadcastChannel: 'yes',
141
+ Platform: 'Desktop',
142
+ });
143
+ });
144
+ });
@@ -1,91 +1,118 @@
1
1
  // Shared types for all gateway strategies
2
2
 
3
3
  export interface SupabaseConfig {
4
- url: string;
5
- anonKey: string;
6
- options?: Record<string, unknown>;
4
+ url: string;
5
+ anonKey: string;
6
+ options?: Record<string, unknown>;
7
7
  }
8
8
 
9
9
  export interface Session {
10
- access_token: string;
11
- refresh_token: string;
12
- user: Record<string, unknown>;
13
- expires_at?: number;
10
+ access_token: string;
11
+ refresh_token: string;
12
+ user: Record<string, unknown>;
13
+ expires_at?: number;
14
14
  }
15
15
 
16
16
  export interface SessionResponse {
17
- session: Session | null;
18
- user?: Record<string, unknown>;
17
+ session: Session | null;
18
+ user?: Record<string, unknown>;
19
19
  }
20
20
 
21
21
  export interface WebSocketStatus {
22
- status: 'connected' | 'disconnected' | 'connecting' | 'error';
23
- readyState: number | null;
24
- error?: string;
22
+ status: 'connected' | 'disconnected' | 'connecting' | 'error';
23
+ readyState: number | null;
24
+ error?: string;
25
25
  }
26
26
 
27
27
  // Unified API that all strategies must implement
28
28
  export interface ISupabaseStrategy {
29
- // Lifecycle
30
- init(url: string, anonKey: string, options?: Record<string, unknown>): Promise<SessionResponse>;
31
-
32
- // Event handling
33
- on(event: string, callback: (payload: unknown) => void): () => void;
34
-
35
- // Auth
36
- getSession(): Promise<SessionResponse>;
37
- signInWithPassword(email: string, password: string): Promise<SessionResponse>;
38
- signOut(): Promise<void>;
39
-
40
- // Database
41
- select(table: string, opts?: SelectOptions): Promise<unknown[]>;
42
- insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<unknown[]>;
43
- update(table: string, data: Record<string, unknown>, match: Record<string, unknown>): Promise<unknown[]>;
44
- upsert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<unknown[]>;
45
- delete(table: string, match: Record<string, unknown>): Promise<unknown[]>;
46
- rpc(fn: string, args?: Record<string, unknown>): Promise<unknown>;
47
-
48
- // Realtime
49
- subscribePostgres(key: string, params: Record<string, unknown>, callback: (payload: unknown) => void): () => void;
50
-
51
- // WebSocket
52
- connectWebSocket(wsUrl?: string): Promise<WebSocketStatus>;
53
- disconnectWebSocket(): Promise<void>;
54
- sendWebSocketMessage(data: unknown): Promise<void>;
55
- getWebSocketStatus(): Promise<WebSocketStatus>;
56
- onWebSocketMessage(callback: (message: unknown) => void): () => void;
57
- onWebSocketStatus(callback: (status: WebSocketStatus) => void): () => void;
58
-
59
- // Cleanup
60
- terminate(): void;
29
+ // Lifecycle
30
+ init(
31
+ url: string,
32
+ anonKey: string,
33
+ options?: Record<string, unknown>,
34
+ ): Promise<SessionResponse>;
35
+
36
+ // Event handling
37
+ on(event: string, callback: (payload: unknown) => void): () => void;
38
+
39
+ // Auth
40
+ getSession(): Promise<SessionResponse>;
41
+ signInWithPassword(
42
+ email: string,
43
+ password: string,
44
+ ): Promise<SessionResponse>;
45
+ signOut(): Promise<void>;
46
+
47
+ // Database
48
+ select(table: string, opts?: SelectOptions): Promise<unknown[]>;
49
+ insert(
50
+ table: string,
51
+ data: Record<string, unknown> | Record<string, unknown>[],
52
+ ): Promise<unknown[]>;
53
+ update(
54
+ table: string,
55
+ data: Record<string, unknown>,
56
+ match: Record<string, unknown>,
57
+ ): Promise<unknown[]>;
58
+ upsert(
59
+ table: string,
60
+ data: Record<string, unknown> | Record<string, unknown>[],
61
+ ): Promise<unknown[]>;
62
+ delete(table: string, match: Record<string, unknown>): Promise<unknown[]>;
63
+ rpc(fn: string, args?: Record<string, unknown>): Promise<unknown>;
64
+
65
+ // Realtime
66
+ subscribePostgres(
67
+ key: string,
68
+ params: Record<string, unknown>,
69
+ callback: (payload: unknown) => void,
70
+ ): () => void;
71
+
72
+ // WebSocket
73
+ connectWebSocket(wsUrl?: string): Promise<WebSocketStatus>;
74
+ disconnectWebSocket(): Promise<void>;
75
+ sendWebSocketMessage(data: unknown): Promise<void>;
76
+ getWebSocketStatus(): Promise<WebSocketStatus>;
77
+ onWebSocketMessage(callback: (message: unknown) => void): () => void;
78
+ onWebSocketStatus(callback: (status: WebSocketStatus) => void): () => void;
79
+
80
+ // Cleanup
81
+ terminate(): void;
61
82
  }
62
83
 
63
84
  export interface SelectOptions {
64
- columns?: string;
65
- match?: Record<string, unknown>;
66
- limit?: number;
85
+ columns?: string;
86
+ match?: Record<string, unknown>;
87
+ limit?: number;
67
88
  }
68
89
 
69
90
  // Worker communication messages
70
91
  export interface WorkerMessage {
71
- id: string;
72
- type: string;
73
- payload?: unknown;
92
+ id: string;
93
+ type: string;
94
+ payload?: unknown;
74
95
  }
75
96
 
76
97
  export interface WorkerResponse {
77
- id: string;
78
- ok: boolean;
79
- data?: unknown;
80
- error?: string;
98
+ id: string;
99
+ ok: boolean;
100
+ data?: unknown;
101
+ error?: string;
81
102
  }
82
103
 
83
104
  // BroadcastChannel event types
84
105
  export interface BroadcastEvent {
85
- type: 'auth' | 'ws.message' | 'ws.status' | 'realtime' | 'ready';
86
- data?: unknown;
87
- key?: string;
88
- payload?: unknown;
106
+ type:
107
+ | 'auth'
108
+ | 'ws.message'
109
+ | 'ws.status'
110
+ | 'realtime'
111
+ | 'ready'
112
+ | 'worker-error';
113
+ data?: unknown;
114
+ key?: string;
115
+ payload?: unknown;
89
116
  }
90
117
 
91
118
  // Strategy types
@@ -93,20 +120,20 @@ export type StrategyType = 'shared-worker' | 'web-worker' | 'direct';
93
120
 
94
121
  // Browser capabilities
95
122
  export interface BrowserCapabilities {
96
- hasSharedWorker: boolean;
97
- hasWorker: boolean;
98
- hasBroadcastChannel: boolean;
99
- isAndroid: boolean;
100
- isSafari: boolean;
123
+ hasSharedWorker: boolean;
124
+ hasWorker: boolean;
125
+ hasBroadcastChannel: boolean;
126
+ isAndroid: boolean;
127
+ isSafari: boolean;
101
128
  }
102
129
 
103
130
  // Gateway configuration
104
131
  export interface GatewayConfig {
105
- workerUrls?: {
106
- sharedWorker?: string | URL;
107
- dbWorker?: string | URL;
108
- webSocketWorker?: string | URL;
109
- };
110
- forceStrategy?: StrategyType;
111
- poolSize?: number;
132
+ workerUrls?: {
133
+ sharedWorker?: string | URL;
134
+ dbWorker?: string | URL;
135
+ webSocketWorker?: string | URL;
136
+ };
137
+ forceStrategy?: StrategyType;
138
+ poolSize?: number;
112
139
  }
@@ -24,9 +24,8 @@ export const mod = {
24
24
 
25
25
  async loadSupabaseClient() {
26
26
  try {
27
- const supabaseModule = await import(
28
- 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm'
29
- );
27
+ const supabaseModule =
28
+ await import('https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm');
30
29
  console.log('[mod-supabase] Supabase module loaded');
31
30
  return supabaseModule.createClient;
32
31
  } catch (err) {
@@ -45,7 +44,10 @@ export const mod = {
45
44
  }
46
45
  },
47
46
 
48
- async queryTestTable() {
47
+ async queryTestTable(): Promise<{
48
+ data: unknown[] | null;
49
+ error: unknown;
50
+ }> {
49
51
  if (!supabase)
50
52
  throw new Error(
51
53
  'Supabase not configured. Call `configure()` first.',
@@ -54,7 +56,10 @@ export const mod = {
54
56
  return { data, error };
55
57
  },
56
58
 
57
- async insertTest(payload: Record<string, unknown>) {
59
+ async insertTest(payload: Record<string, unknown>): Promise<{
60
+ data: unknown[] | null;
61
+ error: unknown;
62
+ }> {
58
63
  if (!supabase)
59
64
  throw new Error(
60
65
  'Supabase not configured. Call `configure()` first.',
@@ -1,10 +1,44 @@
1
- import { map } from 'nanostores';
1
+ import { map, computed } from 'nanostores';
2
2
 
3
3
  export type AuthTone = 'loading' | 'auth' | 'anon' | 'error';
4
4
 
5
+ // ---------------------------------------------------------------------------
6
+ // Bitwise auth flags — composable visibility for UI elements.
7
+ //
8
+ // A guest has: PUBLIC | ANON = 0b0011 (3)
9
+ // A user has: PUBLIC | AUTH = 0b0101 (5)
10
+ // Staff has: PUBLIC | AUTH | STAFF = 0b1101 (13)
11
+ //
12
+ // To check: (userFlags & requiredFlag) === requiredFlag
13
+ // ---------------------------------------------------------------------------
14
+ export const AuthFlags = {
15
+ NONE: 0b0000,
16
+ PUBLIC: 0b0001,
17
+ ANON: 0b0010,
18
+ AUTH: 0b0100,
19
+ STAFF: 0b1000,
20
+ } as const;
21
+
22
+ export type AuthFlag = (typeof AuthFlags)[keyof typeof AuthFlags];
23
+
24
+ /** Convenience presets for common flag combinations. */
25
+ export const AuthPresets = {
26
+ GUEST: AuthFlags.PUBLIC | AuthFlags.ANON,
27
+ USER: AuthFlags.PUBLIC | AuthFlags.AUTH,
28
+ STAFF: AuthFlags.PUBLIC | AuthFlags.AUTH | AuthFlags.STAFF,
29
+ LOADING: AuthFlags.NONE,
30
+ } as const;
31
+
32
+ /** Check if a user's flags satisfy a required flag bitmask. */
33
+ export function hasAuthFlag(userFlags: number, required: number): boolean {
34
+ return (userFlags & required) === required;
35
+ }
36
+
5
37
  export interface AuthState {
6
38
  tone: AuthTone;
39
+ flags: number;
7
40
  name: string;
41
+ username: string | undefined;
8
42
  avatar: string | undefined;
9
43
  id: string;
10
44
  error: string | undefined;
@@ -12,13 +46,97 @@ export interface AuthState {
12
46
 
13
47
  const DEFAULT_AUTH: AuthState = {
14
48
  tone: 'loading',
49
+ flags: AuthPresets.LOADING,
15
50
  name: '',
51
+ username: undefined,
16
52
  avatar: undefined,
17
53
  id: '',
18
54
  error: undefined,
19
55
  };
20
56
 
21
- export const $auth = map<AuthState>({ ...DEFAULT_AUTH });
57
+ const SB_AUTH_TOKEN_KEY = 'sb-auth-token';
58
+ const PROFILE_CACHE_KEY = 'cache:profile:me';
59
+ const STAFF_CACHE_KEY = 'cache:staff:perms';
60
+
61
+ function seedAuthFromStorage(): AuthState {
62
+ if (typeof localStorage === 'undefined') return { ...DEFAULT_AUTH };
63
+
64
+ let session: {
65
+ user?: { id?: string; user_metadata?: Record<string, unknown> };
66
+ } | null = null;
67
+ try {
68
+ const raw = localStorage.getItem(SB_AUTH_TOKEN_KEY);
69
+ if (!raw) return { ...DEFAULT_AUTH };
70
+ const parsed = JSON.parse(raw);
71
+ const inner = parsed?.currentSession ?? parsed;
72
+ if (inner?.user?.id) session = inner;
73
+ } catch {
74
+ return { ...DEFAULT_AUTH };
75
+ }
76
+ if (!session?.user?.id) return { ...DEFAULT_AUTH };
77
+
78
+ const meta = (session.user.user_metadata ?? {}) as Record<string, unknown>;
79
+ let username: string | undefined;
80
+ let avatar: string | undefined =
81
+ typeof meta['avatar_url'] === 'string'
82
+ ? (meta['avatar_url'] as string)
83
+ : undefined;
84
+
85
+ try {
86
+ const raw = localStorage.getItem(PROFILE_CACHE_KEY);
87
+ if (raw && raw !== 'null') {
88
+ const env = JSON.parse(raw);
89
+ if (env?.user_id === session.user.id) {
90
+ const profile = env.profile ?? {};
91
+ if (typeof profile.username === 'string')
92
+ username = profile.username;
93
+ avatar =
94
+ profile.discord?.avatar_url ||
95
+ profile.github?.avatar_url ||
96
+ profile.twitch?.avatar_url ||
97
+ avatar;
98
+ }
99
+ }
100
+ } catch {
101
+ /* best effort */
102
+ }
103
+
104
+ let flags: number = AuthPresets.USER;
105
+ try {
106
+ const raw = localStorage.getItem(STAFF_CACHE_KEY);
107
+ if (raw && raw !== 'null') {
108
+ const env = JSON.parse(raw);
109
+ if (
110
+ env?.user_id === session.user.id &&
111
+ typeof env?.bitmask === 'number' &&
112
+ env.bitmask > 0
113
+ ) {
114
+ flags = AuthPresets.STAFF;
115
+ }
116
+ }
117
+ } catch {
118
+ /* best effort */
119
+ }
120
+
121
+ return {
122
+ tone: 'auth',
123
+ flags,
124
+ name:
125
+ typeof meta['full_name'] === 'string'
126
+ ? (meta['full_name'] as string)
127
+ : '',
128
+ username,
129
+ avatar,
130
+ id: session.user.id,
131
+ error: undefined,
132
+ };
133
+ }
134
+
135
+ export const $auth = map<AuthState>(seedAuthFromStorage());
136
+
137
+ export const $isStaff = computed($auth, (s) =>
138
+ hasAuthFlag(s.flags, AuthFlags.STAFF),
139
+ );
22
140
 
23
141
  export function setAuth(state: Partial<AuthState>) {
24
142
  const current = $auth.get();
@@ -26,5 +144,5 @@ export function setAuth(state: Partial<AuthState>) {
26
144
  }
27
145
 
28
146
  export function resetAuth() {
29
- $auth.set({ ...DEFAULT_AUTH, tone: 'anon' });
147
+ $auth.set({ ...DEFAULT_AUTH, tone: 'anon', flags: AuthPresets.GUEST });
30
148
  }