@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
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ $bentoShipment,
4
+ $bentoLedger,
5
+ shipBentoWord,
6
+ advanceBentoShipment,
7
+ resetBentoFlow,
8
+ } from './bento';
9
+
10
+ beforeEach(() => {
11
+ resetBentoFlow();
12
+ });
13
+
14
+ describe('Bento flow state', () => {
15
+ it('shipBentoWord creates a shipped shipment', () => {
16
+ const shipment = shipBentoWord('games');
17
+ expect(shipment.word).toBe('games');
18
+ expect(shipment.stage).toBe('shipped');
19
+ expect($bentoShipment.get()).toEqual(shipment);
20
+ expect($bentoLedger.get()).toEqual([shipment]);
21
+ });
22
+
23
+ it('shipBentoWord assigns unique incrementing ids', () => {
24
+ const a = shipBentoWord('games');
25
+ const b = shipBentoWord('apps');
26
+ expect(b.id).toBeGreaterThan(a.id);
27
+ });
28
+
29
+ it('advanceBentoShipment updates stage and preserves word', () => {
30
+ const shipment = shipBentoWord('games');
31
+ const arrived = advanceBentoShipment(shipment.id, 'arrived');
32
+ expect(arrived?.word).toBe('games');
33
+ expect(arrived?.stage).toBe('arrived');
34
+ expect($bentoShipment.get()?.stage).toBe('arrived');
35
+ expect($bentoLedger.get()).toHaveLength(1);
36
+ });
37
+
38
+ it('advanceBentoShipment returns null for unknown id', () => {
39
+ expect(advanceBentoShipment(999, 'arrived')).toBeNull();
40
+ });
41
+
42
+ it('advanceBentoShipment returns null when stage unchanged', () => {
43
+ const shipment = shipBentoWord('games');
44
+ expect(advanceBentoShipment(shipment.id, 'shipped')).toBeNull();
45
+ });
46
+
47
+ it('listeners see each lifecycle transition', () => {
48
+ const handler = vi.fn();
49
+ const unsub = $bentoShipment.listen(handler);
50
+
51
+ const shipment = shipBentoWord('games');
52
+ advanceBentoShipment(shipment.id, 'arrived');
53
+ advanceBentoShipment(shipment.id, 'delivered');
54
+
55
+ expect(handler).toHaveBeenCalledTimes(3);
56
+ expect(handler.mock.calls.map(([s]) => s.stage)).toEqual([
57
+ 'shipped',
58
+ 'arrived',
59
+ 'delivered',
60
+ ]);
61
+ unsub();
62
+ });
63
+
64
+ it('ledger caps at 24 shipments', () => {
65
+ for (let i = 0; i < 30; i++) shipBentoWord(`word-${i}`);
66
+ expect($bentoLedger.get()).toHaveLength(24);
67
+ expect($bentoLedger.get()[23].word).toBe('word-29');
68
+ });
69
+
70
+ it('resetBentoFlow clears state', () => {
71
+ shipBentoWord('games');
72
+ resetBentoFlow();
73
+ expect($bentoShipment.get()).toBeNull();
74
+ expect($bentoLedger.get()).toEqual([]);
75
+ });
76
+ });
@@ -0,0 +1,51 @@
1
+ import { atom } from 'nanostores';
2
+
3
+ export type BentoStage = 'shipped' | 'arrived' | 'delivered' | 'dropped';
4
+
5
+ export interface BentoShipment {
6
+ id: number;
7
+ word: string;
8
+ stage: BentoStage;
9
+ at: number;
10
+ }
11
+
12
+ const LEDGER_MAX = 24;
13
+
14
+ let nextShipmentId = 1;
15
+
16
+ export const $bentoShipment = atom<BentoShipment | null>(null);
17
+ export const $bentoLedger = atom<readonly BentoShipment[]>([]);
18
+
19
+ function record(shipment: BentoShipment) {
20
+ const rest = $bentoLedger.get().filter((s) => s.id !== shipment.id);
21
+ $bentoLedger.set([...rest, shipment].slice(-LEDGER_MAX));
22
+ $bentoShipment.set(shipment);
23
+ }
24
+
25
+ export function shipBentoWord(word: string): BentoShipment {
26
+ const shipment: BentoShipment = {
27
+ id: nextShipmentId++,
28
+ word,
29
+ stage: 'shipped',
30
+ at: Date.now(),
31
+ };
32
+ record(shipment);
33
+ return shipment;
34
+ }
35
+
36
+ export function advanceBentoShipment(
37
+ id: number,
38
+ stage: BentoStage,
39
+ ): BentoShipment | null {
40
+ const current = $bentoLedger.get().find((s) => s.id === id);
41
+ if (!current || current.stage === stage) return null;
42
+ const updated: BentoShipment = { ...current, stage, at: Date.now() };
43
+ record(updated);
44
+ return updated;
45
+ }
46
+
47
+ export function resetBentoFlow() {
48
+ nextShipmentId = 1;
49
+ $bentoShipment.set(null);
50
+ $bentoLedger.set([]);
51
+ }
@@ -1,8 +1,13 @@
1
1
  export {
2
2
  $auth,
3
+ $isStaff,
3
4
  setAuth,
4
5
  resetAuth,
6
+ AuthFlags,
7
+ AuthPresets,
8
+ hasAuthFlag,
5
9
  type AuthTone,
10
+ type AuthFlag,
6
11
  type AuthState,
7
12
  } from './auth';
8
13
 
@@ -20,6 +25,16 @@ export {
20
25
  closeModal,
21
26
  } from './ui';
22
27
 
28
+ export {
29
+ $bentoShipment,
30
+ $bentoLedger,
31
+ shipBentoWord,
32
+ advanceBentoShipment,
33
+ resetBentoFlow,
34
+ type BentoStage,
35
+ type BentoShipment,
36
+ } from './bento';
37
+
23
38
  export { $toasts, addToast, removeToast } from './toasts';
24
39
 
25
40
  export { showWelcomeToast } from './welcome-toast';
@@ -32,3 +47,52 @@ export {
32
47
 
33
48
  export { OverlayManager } from './overlay-manager';
34
49
  export type { RenderPath } from './overlay-manager';
50
+
51
+ export {
52
+ $profileCache,
53
+ clearProfileCache,
54
+ fetchAndCacheProfile,
55
+ fetchProfileFromApi,
56
+ getProfileFromCache,
57
+ PROFILE_CACHE_TTL_MS,
58
+ PROFILE_DEFAULT_API_BASE,
59
+ readProfileForFastPaint,
60
+ readSupabaseSessionFromStorage,
61
+ setProfileCache,
62
+ type CachedSupabaseSession,
63
+ type DroidProfile,
64
+ type FetchProfileOptions,
65
+ type ProfileCacheEnvelope,
66
+ type ProfileFastPaintResult,
67
+ } from './profile';
68
+
69
+ export {
70
+ $staffPermissions,
71
+ applyStaffFlagFromCache,
72
+ bootStaffPermissions,
73
+ clearStaffPermsCache,
74
+ fetchAndCacheStaffPermissions,
75
+ fetchStaffPermissionsFromApi,
76
+ getStaffPermsFromCache,
77
+ setStaffPermsCache,
78
+ STAFF_CACHE_TTL_MS,
79
+ type FetchStaffPermsOptions,
80
+ type StaffPermsEnvelope,
81
+ } from './staff';
82
+
83
+ export {
84
+ broadcastProfileClear,
85
+ broadcastProfileRefresh,
86
+ broadcastStaffClear,
87
+ broadcastStaffRefresh,
88
+ installSyncBusListener,
89
+ type SyncBusMessage,
90
+ } from './sync-bus';
91
+
92
+ export {
93
+ disposeProfileSync,
94
+ installProfileSync,
95
+ refreshProfileSyncNow,
96
+ type AuthEventLike,
97
+ type ProfileSyncOptions,
98
+ } from './profile-sync';
@@ -0,0 +1,214 @@
1
+ import { broadcastProfileRefresh, broadcastStaffRefresh } from './sync-bus';
2
+ import {
3
+ $profileCache,
4
+ PROFILE_CACHE_TTL_MS,
5
+ fetchProfileFromApi,
6
+ setProfileCache,
7
+ type DroidProfile,
8
+ } from './profile';
9
+ import {
10
+ $staffPermissions,
11
+ STAFF_CACHE_TTL_MS,
12
+ fetchStaffPermissionsFromApi,
13
+ setStaffPermsCache,
14
+ } from './staff';
15
+
16
+ const FALLBACK_REFRESH_MS = 15 * 60 * 1000;
17
+ const DEBOUNCE_MS = 250;
18
+
19
+ export interface AuthEventLike {
20
+ session?: {
21
+ access_token?: string;
22
+ user?: { id?: string } | null;
23
+ } | null;
24
+ }
25
+
26
+ export interface ProfileSyncOptions {
27
+ apiBase: string;
28
+ supabaseUrl: string;
29
+ supabaseAnonKey: string;
30
+ /** Provide a way to subscribe to auth events from the SharedWorker / gateway. */
31
+ subscribeAuth: (handler: (event: AuthEventLike) => void) => () => void;
32
+ /**
33
+ * Optional override for the recurring fallback cadence. Defaults to
34
+ * 15 minutes. Pass `0` to disable.
35
+ */
36
+ fallbackIntervalMs?: number;
37
+ }
38
+
39
+ interface SyncState {
40
+ apiBase: string;
41
+ supabaseUrl: string;
42
+ supabaseAnonKey: string;
43
+ timer: ReturnType<typeof setInterval> | null;
44
+ debounce: ReturnType<typeof setTimeout> | null;
45
+ inflight: Promise<void> | null;
46
+ disposers: Array<() => void>;
47
+ }
48
+
49
+ let active: SyncState | null = null;
50
+
51
+ async function refreshProfileAndStaff(state: SyncState): Promise<void> {
52
+ if (state.inflight) return state.inflight;
53
+ state.inflight = (async () => {
54
+ try {
55
+ const sessionRaw = readSession();
56
+ const userId = sessionRaw?.user?.id;
57
+ const token = sessionRaw?.access_token;
58
+ if (!userId || !token) return;
59
+
60
+ const profilePromise = fetchProfileFromApi({
61
+ token,
62
+ apiBase: state.apiBase,
63
+ });
64
+ const staffPromise = fetchStaffPermissionsFromApi({
65
+ token,
66
+ apikey: state.supabaseAnonKey,
67
+ supabaseUrl: state.supabaseUrl,
68
+ });
69
+
70
+ const [profile, staff] = await Promise.all([
71
+ profilePromise.catch(() => null),
72
+ staffPromise.catch(() => null),
73
+ ]);
74
+
75
+ if (profile && profile.user_id) {
76
+ setProfileCache(profile);
77
+ broadcastProfileRefresh(profile);
78
+ }
79
+ if (typeof staff === 'number') {
80
+ setStaffPermsCache(userId, staff);
81
+ broadcastStaffRefresh(userId, staff);
82
+ }
83
+ } finally {
84
+ if (active === state) state.inflight = null;
85
+ }
86
+ })();
87
+ return state.inflight;
88
+ }
89
+
90
+ function readSession(): AuthEventLike['session'] {
91
+ if (typeof localStorage === 'undefined') return null;
92
+ const raw = localStorage.getItem('sb-auth-token');
93
+ if (!raw) return null;
94
+ try {
95
+ const parsed = JSON.parse(raw);
96
+ if (parsed?.currentSession?.access_token) return parsed.currentSession;
97
+ if (parsed?.access_token) return parsed;
98
+ return null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function scheduleRefresh(state: SyncState): void {
105
+ if (state.debounce) clearTimeout(state.debounce);
106
+ state.debounce = setTimeout(() => {
107
+ state.debounce = null;
108
+ void refreshProfileAndStaff(state);
109
+ }, DEBOUNCE_MS);
110
+ }
111
+
112
+ function isProfileStale(): boolean {
113
+ const entry = $profileCache.get();
114
+ if (!entry) return true;
115
+ return Date.now() - entry.cached_at > PROFILE_CACHE_TTL_MS;
116
+ }
117
+
118
+ function isStaffStale(): boolean {
119
+ const entry = $staffPermissions.get();
120
+ if (!entry) return true;
121
+ return Date.now() - entry.cached_at > STAFF_CACHE_TTL_MS;
122
+ }
123
+
124
+ /**
125
+ * Install a profile + staff_permissions sync coordinator. Returns a
126
+ * dispose function that tears down listeners and the fallback timer.
127
+ *
128
+ * Refresh triggers:
129
+ * - auth events from the SharedWorker (token refresh, sign-in)
130
+ * - `document.visibilitychange` (only if at least one cache is stale)
131
+ * - `window.focus` (only if at least one cache is stale)
132
+ * - fallback `setInterval` every `fallbackIntervalMs` (default 15m)
133
+ *
134
+ * Each refresh is debounced (250ms) and reentrancy-guarded; a refresh
135
+ * in flight is reused rather than queued.
136
+ *
137
+ * Idempotent — calling twice tears down the previous coordinator first
138
+ * so callers can re-run safely.
139
+ */
140
+ export function installProfileSync(opts: ProfileSyncOptions): () => void {
141
+ disposeProfileSync();
142
+
143
+ const state: SyncState = {
144
+ apiBase: opts.apiBase,
145
+ supabaseUrl: opts.supabaseUrl,
146
+ supabaseAnonKey: opts.supabaseAnonKey,
147
+ timer: null,
148
+ debounce: null,
149
+ inflight: null,
150
+ disposers: [],
151
+ };
152
+ active = state;
153
+
154
+ const unsubscribeAuth = opts.subscribeAuth((evt) => {
155
+ if (!evt?.session?.user?.id) return;
156
+ scheduleRefresh(state);
157
+ });
158
+ state.disposers.push(unsubscribeAuth);
159
+
160
+ if (typeof document !== 'undefined') {
161
+ const onVisibility = () => {
162
+ if (document.visibilityState !== 'visible') return;
163
+ if (!isProfileStale() && !isStaffStale()) return;
164
+ scheduleRefresh(state);
165
+ };
166
+ document.addEventListener('visibilitychange', onVisibility);
167
+ state.disposers.push(() =>
168
+ document.removeEventListener('visibilitychange', onVisibility),
169
+ );
170
+ }
171
+ if (typeof window !== 'undefined') {
172
+ const onFocus = () => {
173
+ if (!isProfileStale() && !isStaffStale()) return;
174
+ scheduleRefresh(state);
175
+ };
176
+ window.addEventListener('focus', onFocus);
177
+ state.disposers.push(() =>
178
+ window.removeEventListener('focus', onFocus),
179
+ );
180
+ }
181
+
182
+ const interval = opts.fallbackIntervalMs ?? FALLBACK_REFRESH_MS;
183
+ if (interval > 0) {
184
+ state.timer = setInterval(() => {
185
+ void refreshProfileAndStaff(state);
186
+ }, interval);
187
+ }
188
+
189
+ return disposeProfileSync;
190
+ }
191
+
192
+ export function disposeProfileSync(): void {
193
+ if (!active) return;
194
+ const state = active;
195
+ active = null;
196
+ if (state.timer) clearInterval(state.timer);
197
+ if (state.debounce) clearTimeout(state.debounce);
198
+ for (const dispose of state.disposers) {
199
+ try {
200
+ dispose();
201
+ } catch {
202
+ /* best effort */
203
+ }
204
+ }
205
+ state.disposers.length = 0;
206
+ }
207
+
208
+ /** Trigger an immediate refresh if a sync coordinator is installed. */
209
+ export async function refreshProfileSyncNow(): Promise<void> {
210
+ if (!active) return;
211
+ await refreshProfileAndStaff(active);
212
+ }
213
+
214
+ export type { DroidProfile };
@@ -0,0 +1,155 @@
1
+ import { persistentAtom } from '@nanostores/persistent';
2
+
3
+ export interface DroidProfile {
4
+ user_id: string;
5
+ username?: string;
6
+ email?: string;
7
+ profile_exists?: boolean;
8
+ discord?: {
9
+ username?: string;
10
+ avatar_url?: string;
11
+ is_guild_member?: boolean;
12
+ };
13
+ github?: { username?: string; avatar_url?: string };
14
+ twitch?: { username?: string; avatar_url?: string; is_live?: boolean };
15
+ connected_providers?: string[];
16
+ [k: string]: unknown;
17
+ }
18
+
19
+ export interface ProfileCacheEnvelope {
20
+ profile: DroidProfile;
21
+ cached_at: number;
22
+ user_id: string;
23
+ }
24
+
25
+ const PROFILE_CACHE_KEY = 'cache:profile:me';
26
+ const SUPABASE_SESSION_STORAGE_KEY = 'sb-auth-token';
27
+ export const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000;
28
+ export const PROFILE_DEFAULT_API_BASE = 'https://kbve.com';
29
+
30
+ export const $profileCache = persistentAtom<ProfileCacheEnvelope | null>(
31
+ PROFILE_CACHE_KEY,
32
+ null,
33
+ {
34
+ encode: (value) => JSON.stringify(value),
35
+ decode: (raw) => {
36
+ if (!raw || raw === 'null') return null;
37
+ try {
38
+ return JSON.parse(raw) as ProfileCacheEnvelope;
39
+ } catch {
40
+ return null;
41
+ }
42
+ },
43
+ },
44
+ );
45
+
46
+ export function getProfileFromCache(userId: string): DroidProfile | null {
47
+ const entry = $profileCache.get();
48
+ if (!entry) return null;
49
+ if (entry.user_id !== userId) return null;
50
+ if (Date.now() - entry.cached_at > PROFILE_CACHE_TTL_MS) return null;
51
+ return entry.profile;
52
+ }
53
+
54
+ export function setProfileCache(profile: DroidProfile): void {
55
+ if (!profile.user_id) return;
56
+ $profileCache.set({
57
+ profile,
58
+ cached_at: Date.now(),
59
+ user_id: profile.user_id,
60
+ });
61
+ }
62
+
63
+ export function clearProfileCache(): void {
64
+ $profileCache.set(null);
65
+ }
66
+
67
+ export interface CachedSupabaseSession {
68
+ access_token: string;
69
+ refresh_token?: string;
70
+ expires_at?: number;
71
+ user?: {
72
+ id: string;
73
+ email?: string;
74
+ user_metadata?: Record<string, unknown>;
75
+ [k: string]: unknown;
76
+ };
77
+ [k: string]: unknown;
78
+ }
79
+
80
+ export function readSupabaseSessionFromStorage(): CachedSupabaseSession | null {
81
+ if (typeof localStorage === 'undefined') return null;
82
+ const raw = localStorage.getItem(SUPABASE_SESSION_STORAGE_KEY);
83
+ if (!raw) return null;
84
+ try {
85
+ const parsed = JSON.parse(raw);
86
+ if (!parsed || typeof parsed !== 'object') return null;
87
+ if (
88
+ parsed.currentSession &&
89
+ typeof parsed.currentSession === 'object' &&
90
+ 'access_token' in parsed.currentSession
91
+ ) {
92
+ return parsed.currentSession as CachedSupabaseSession;
93
+ }
94
+ if ('access_token' in parsed) {
95
+ return parsed as CachedSupabaseSession;
96
+ }
97
+ return null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export interface FetchProfileOptions {
104
+ token: string;
105
+ apiBase?: string;
106
+ signal?: AbortSignal;
107
+ }
108
+
109
+ export async function fetchProfileFromApi(
110
+ opts: FetchProfileOptions,
111
+ ): Promise<DroidProfile | null> {
112
+ const base = opts.apiBase ?? PROFILE_DEFAULT_API_BASE;
113
+ const res = await fetch(`${base}/api/v1/profile/me`, {
114
+ method: 'GET',
115
+ headers: {
116
+ Authorization: `Bearer ${opts.token}`,
117
+ Accept: 'application/json',
118
+ },
119
+ signal: opts.signal,
120
+ });
121
+ if (!res.ok) return null;
122
+ try {
123
+ const json = (await res.json()) as DroidProfile;
124
+ if (!json || !json.user_id) return null;
125
+ return json;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ export async function fetchAndCacheProfile(
132
+ opts: FetchProfileOptions,
133
+ ): Promise<DroidProfile | null> {
134
+ const profile = await fetchProfileFromApi(opts);
135
+ if (profile) setProfileCache(profile);
136
+ return profile;
137
+ }
138
+
139
+ export interface ProfileFastPaintResult {
140
+ session: CachedSupabaseSession;
141
+ profile: DroidProfile;
142
+ stale: boolean;
143
+ }
144
+
145
+ export function readProfileForFastPaint(): ProfileFastPaintResult | null {
146
+ const session = readSupabaseSessionFromStorage();
147
+ if (!session?.user?.id || !session.access_token) return null;
148
+ const entry = $profileCache.get();
149
+ if (!entry || entry.user_id !== session.user.id) return null;
150
+ return {
151
+ session,
152
+ profile: entry.profile,
153
+ stale: Date.now() - entry.cached_at > PROFILE_CACHE_TTL_MS,
154
+ };
155
+ }
@@ -0,0 +1,128 @@
1
+ import { persistentAtom } from '@nanostores/persistent';
2
+ import { $auth, setAuth, AuthPresets } from './auth';
3
+
4
+ export interface StaffPermsEnvelope {
5
+ bitmask: number;
6
+ cached_at: number;
7
+ user_id: string;
8
+ }
9
+
10
+ const STAFF_CACHE_KEY = 'cache:staff:perms';
11
+ const SUPABASE_DEFAULT_URL = 'https://supabase.kbve.com';
12
+ export const STAFF_CACHE_TTL_MS = 10 * 60 * 1000;
13
+
14
+ export const $staffPermissions = persistentAtom<StaffPermsEnvelope | null>(
15
+ STAFF_CACHE_KEY,
16
+ null,
17
+ {
18
+ encode: (value) => JSON.stringify(value),
19
+ decode: (raw) => {
20
+ if (!raw || raw === 'null') return null;
21
+ try {
22
+ return JSON.parse(raw) as StaffPermsEnvelope;
23
+ } catch {
24
+ return null;
25
+ }
26
+ },
27
+ },
28
+ );
29
+
30
+ export function getStaffPermsFromCache(userId: string): number | null {
31
+ const entry = $staffPermissions.get();
32
+ if (!entry) return null;
33
+ if (entry.user_id !== userId) return null;
34
+ if (Date.now() - entry.cached_at > STAFF_CACHE_TTL_MS) return null;
35
+ return entry.bitmask;
36
+ }
37
+
38
+ export function setStaffPermsCache(userId: string, bitmask: number): void {
39
+ if (!userId) return;
40
+ $staffPermissions.set({ bitmask, cached_at: Date.now(), user_id: userId });
41
+ }
42
+
43
+ export function clearStaffPermsCache(): void {
44
+ $staffPermissions.set(null);
45
+ }
46
+
47
+ export interface FetchStaffPermsOptions {
48
+ token: string;
49
+ apikey: string;
50
+ supabaseUrl?: string;
51
+ signal?: AbortSignal;
52
+ }
53
+
54
+ export async function fetchStaffPermissionsFromApi(
55
+ opts: FetchStaffPermsOptions,
56
+ ): Promise<number | null> {
57
+ const base = opts.supabaseUrl ?? SUPABASE_DEFAULT_URL;
58
+ const res = await fetch(`${base}/rest/v1/rpc/staff_permissions`, {
59
+ method: 'POST',
60
+ headers: {
61
+ Authorization: `Bearer ${opts.token}`,
62
+ 'Content-Type': 'application/json',
63
+ apikey: opts.apikey,
64
+ },
65
+ body: '{}',
66
+ signal: opts.signal,
67
+ });
68
+ if (!res.ok) return null;
69
+ try {
70
+ const data = await res.json();
71
+ if (typeof data === 'number') return data;
72
+ return null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export async function fetchAndCacheStaffPermissions(
79
+ opts: FetchStaffPermsOptions & { userId: string },
80
+ ): Promise<number | null> {
81
+ const bitmask = await fetchStaffPermissionsFromApi(opts);
82
+ if (bitmask === null) return null;
83
+ setStaffPermsCache(opts.userId, bitmask);
84
+ return bitmask;
85
+ }
86
+
87
+ /**
88
+ * Apply STAFF flag to `$auth` if the cached staff permissions for
89
+ * `userId` are non-zero. Returns the bitmask (or null when no cache /
90
+ * stale / non-matching user).
91
+ */
92
+ export function applyStaffFlagFromCache(userId: string): number | null {
93
+ const bitmask = getStaffPermsFromCache(userId);
94
+ if (bitmask === null) return null;
95
+ if (bitmask > 0) {
96
+ const state = $auth.get();
97
+ if (state.tone === 'auth' && state.id === userId) {
98
+ setAuth({ flags: AuthPresets.STAFF });
99
+ }
100
+ }
101
+ return bitmask;
102
+ }
103
+
104
+ /**
105
+ * Compose: synchronously paint STAFF flag from cache, then refresh in
106
+ * the background. Caller decides whether to await the returned
107
+ * promise.
108
+ */
109
+ export function bootStaffPermissions(opts: {
110
+ userId: string;
111
+ token: string;
112
+ apikey: string;
113
+ supabaseUrl?: string;
114
+ }): { cachedBitmask: number | null; refresh: Promise<number | null> } {
115
+ const cachedBitmask = applyStaffFlagFromCache(opts.userId);
116
+ const refresh = fetchAndCacheStaffPermissions(opts)
117
+ .then((bitmask) => {
118
+ if (bitmask !== null && bitmask > 0) {
119
+ const state = $auth.get();
120
+ if (state.tone === 'auth' && state.id === opts.userId) {
121
+ setAuth({ flags: AuthPresets.STAFF });
122
+ }
123
+ }
124
+ return bitmask;
125
+ })
126
+ .catch(() => null);
127
+ return { cachedBitmask, refresh };
128
+ }