@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kbve/droid",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A manager for web, shared and service workers, aimming to make it as modular and upgradable.",
5
5
  "keywords": [
6
6
  "workers",
@@ -35,6 +35,7 @@
35
35
  "workers/",
36
36
  "lib/workers/",
37
37
  "lib/gateway/",
38
+ "lib/agents/",
38
39
  "assets/",
39
40
  "src/",
40
41
  "comlink*.js",
package/src/index.ts CHANGED
@@ -5,10 +5,13 @@ export * from './lib/types/bento';
5
5
  export * from './lib/types/event-types';
6
6
  export * from './lib/types/panel-types';
7
7
  export * from './lib/types/ui-event-types';
8
+ export * from './lib/sw-recovery';
8
9
  export * from './lib/workers/events';
9
10
  export * from './lib/mod/mod-urls';
10
11
  export * from './lib/gateway';
11
12
  export * from './lib/state';
13
+ export * from './lib/agents';
14
+ export * from './lib/api';
12
15
 
13
16
  export type { VirtualNode } from './lib/types/modules';
14
17
 
@@ -0,0 +1,170 @@
1
+ import type { AgentsStore } from './state/atoms';
2
+ import type { TokenServiceValue } from './generated/agents-schema';
3
+ import type {
4
+ AgentTokenRow,
5
+ BackfillResult,
6
+ BotConfigFormDraft,
7
+ DiscordForumChannel,
8
+ DiscordshConfig,
9
+ GithubRepoHook,
10
+ PatValidation,
11
+ Result,
12
+ WebhookInstallResult,
13
+ } from './types';
14
+
15
+ export interface AgentsMethods {
16
+ // auth
17
+ initAuth(force?: boolean): Promise<void>;
18
+ resyncOwnedGuilds(): Promise<boolean>;
19
+ signInWithDiscord(): Promise<void>;
20
+
21
+ // guilds
22
+ loadOwnedGuilds(force?: boolean): Promise<void>;
23
+ selectGuild(guildId: string): void;
24
+ refreshSelectedGuild(): Promise<void>;
25
+
26
+ // tokens
27
+ loadTokens(guildId: string, force?: boolean): Promise<void>;
28
+ addToken(input: {
29
+ tokenName: string;
30
+ service: TokenServiceValue;
31
+ tokenValue: string;
32
+ description?: string | null;
33
+ }): Promise<Result<{ tokenId: string }>>;
34
+ deleteToken(tokenId: string): Promise<Result>;
35
+ peekToken(
36
+ service: TokenServiceValue,
37
+ ): Promise<Result<{ value: string | null }>>;
38
+ toggleToken(tokenId: string, isActive: boolean): Promise<Result>;
39
+ hasService(service: TokenServiceValue): AgentTokenRow | null;
40
+
41
+ // repo allowlist
42
+ getRepoAllowlist(): Promise<
43
+ Result<{ repos: string[]; raw: string | null }>
44
+ >;
45
+ setRepoAllowlist(repos: string[]): Promise<Result>;
46
+ hydrateRepoAllowlistDraft(guildId: string, repos: string[]): void;
47
+ patchRepoAllowlistDraft(guildId: string, repos: string[]): void;
48
+ clearRepoAllowlistDraft(guildId: string): void;
49
+ ensureRepoAllowlistLoaded(
50
+ guildId: string,
51
+ force?: boolean,
52
+ ): Promise<Result>;
53
+ saveRepoAllowlistDraft(guildId: string): Promise<Result>;
54
+
55
+ // bot config
56
+ getBotConfig(): Promise<Result<{ config: DiscordshConfig }>>;
57
+ setBotConfig(config: DiscordshConfig): Promise<Result>;
58
+ hydrateBotConfigDraft(guildId: string, cfg: DiscordshConfig): void;
59
+ patchBotConfigDraft(
60
+ guildId: string,
61
+ patch: Partial<BotConfigFormDraft>,
62
+ ): void;
63
+ clearBotConfigDraft(guildId: string): void;
64
+ ensureBotConfigLoaded(guildId: string, force?: boolean): Promise<Result>;
65
+ saveBotConfigDraft(guildId: string): Promise<Result>;
66
+
67
+ // webhook secret drafts
68
+ setWebhookDraft(guildId: string, secret: string | null): void;
69
+ clearWebhookError(guildId: string): void;
70
+ saveWebhookDraft(
71
+ guildId: string,
72
+ tokenName: string,
73
+ description: string,
74
+ ): Promise<Result<{ tokenId: string }>>;
75
+
76
+ // PAT
77
+ setPatDraft(guildId: string, pat: string): void;
78
+ clearPatState(guildId: string): void;
79
+ validatePatForGuild(guildId: string): Promise<Result>;
80
+ savePatForGuild(
81
+ guildId: string,
82
+ tokenName: string,
83
+ ): Promise<Result<{ tokenId: string }>>;
84
+ validateGithubPat(pat: string): Promise<Result<PatValidation>>;
85
+
86
+ // backfill
87
+ patchBackfillDraft(
88
+ guildId: string,
89
+ partial: Partial<{ owner: string; repo: string }>,
90
+ ): void;
91
+ clearBackfillResult(guildId: string): void;
92
+ runBackfillForGuild(guildId: string): Promise<void>;
93
+ runBackfill(input: {
94
+ owner: string;
95
+ repo: string;
96
+ state?: 'open' | 'closed' | 'all';
97
+ maxPages?: number;
98
+ perPage?: number;
99
+ }): Promise<BackfillResult | { ok: false; error: string }>;
100
+
101
+ // webhook install / ping / verify
102
+ setWebhookInstallSelected(guildId: string, repo: string): void;
103
+ clearWebhookInstallResult(guildId: string): void;
104
+ installWebhookForGuild(guildId: string): Promise<void>;
105
+ pingWebhookForGuild(guildId: string): Promise<void>;
106
+ verifyWebhookInstall(guildId: string, repoFull: string): Promise<void>;
107
+ installRepoWebhook(
108
+ guildId: string,
109
+ owner: string,
110
+ repo: string,
111
+ ): Promise<WebhookInstallResult | { ok: false; error: string }>;
112
+ pingRepoWebhook(
113
+ guildId: string,
114
+ owner: string,
115
+ repo: string,
116
+ ): Promise<
117
+ | { ok: true; hookId: number; url: string }
118
+ | { ok: false; error: string; retryAfterMs?: number }
119
+ >;
120
+ listRepoWebhooks(
121
+ guildId: string,
122
+ owner: string,
123
+ repo: string,
124
+ ): Promise<Result<{ expectedUrl: string; hooks: GithubRepoHook[] }>>;
125
+ rotateWebhookForGuild(
126
+ guildId: string,
127
+ owner: string,
128
+ repo: string,
129
+ ): Promise<void>;
130
+ deleteWebhookForGuild(
131
+ guildId: string,
132
+ owner: string,
133
+ repo: string,
134
+ ): Promise<Result>;
135
+ loadWebhookDeliveries(
136
+ guildId: string,
137
+ owner: string,
138
+ repo: string,
139
+ limit?: number,
140
+ ): Promise<void>;
141
+ webhookUrlFor(guildId: string): string;
142
+
143
+ // discord bot / channels
144
+ ensureGuildChannelsLoaded(guildId: string, force?: boolean): Promise<void>;
145
+ ensureBotMembershipLoaded(guildId: string, force?: boolean): Promise<void>;
146
+ invalidateBotMembership(guildId: string): void;
147
+ isBotMember(
148
+ guildId: string,
149
+ ): Promise<
150
+ | { ok: true; isMember: boolean; joinedAt: string | null }
151
+ | { ok: false; error: string }
152
+ >;
153
+ listForumChannels(
154
+ guildId: string,
155
+ ): Promise<Result<{ channels: DiscordForumChannel[] }>>;
156
+ botInstallUrl(guildId?: string): string | null;
157
+ fetchBotInstallUrl(guildId: string): Promise<string | null>;
158
+
159
+ // events
160
+ loadEventStats(guildId: string): Promise<void>;
161
+ loadFailedEvents(guildId: string, limit?: number): Promise<void>;
162
+ loadPendingEvents(guildId: string, limit?: number): Promise<void>;
163
+ requeueEvent(
164
+ guildId: string,
165
+ eventId: number,
166
+ reason?: string,
167
+ ): Promise<Result>;
168
+ }
169
+
170
+ export type AgentsApi = AgentsStore & AgentsMethods;
@@ -0,0 +1,200 @@
1
+ import {
2
+ BOOTSTRAP_COOLDOWN_MS,
3
+ FORCED_REFRESH_COOLDOWN_MS,
4
+ INIT_DEDUP_MS,
5
+ } from '../constants';
6
+ import {
7
+ extractJwtOwnedGuildIds,
8
+ loadCachedGuilds,
9
+ parseJwtPayload,
10
+ } from '../cache';
11
+ import type { AgentsCtx } from '../ctx';
12
+ import type { AgentsApi } from '../api-types';
13
+
14
+ export function makeAuth(ctx: AgentsCtx, api: AgentsApi) {
15
+ const { store, config, endpoints, runtime } = ctx;
16
+
17
+ async function bootstrapDiscord(providerToken: string): Promise<boolean> {
18
+ const accessToken = store.$accessToken.get();
19
+ if (!accessToken) return false;
20
+ const now = Date.now();
21
+ if (
22
+ runtime.lastBootstrapOk &&
23
+ now - runtime.lastBootstrapAt < BOOTSTRAP_COOLDOWN_MS
24
+ ) {
25
+ return true;
26
+ }
27
+ if (runtime.bootstrapInflight) return runtime.bootstrapInflight;
28
+ runtime.bootstrapInflight = (async () => {
29
+ try {
30
+ const resp = await fetch(endpoints.discordBootstrap, {
31
+ method: 'POST',
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ Authorization: `Bearer ${accessToken}`,
35
+ },
36
+ body: JSON.stringify({ provider_token: providerToken }),
37
+ });
38
+ if (!resp.ok) {
39
+ if (resp.status === 429) {
40
+ runtime.lastBootstrapAt = Date.now();
41
+ runtime.lastBootstrapOk = true;
42
+ }
43
+ return false;
44
+ }
45
+ runtime.lastBootstrapAt = Date.now();
46
+ runtime.lastBootstrapOk = true;
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ } finally {
51
+ runtime.bootstrapInflight = null;
52
+ }
53
+ })();
54
+ return runtime.bootstrapInflight;
55
+ }
56
+
57
+ async function forceTokenRefresh(): Promise<boolean> {
58
+ const now = Date.now();
59
+ if (now - runtime.lastForcedRefreshAt < FORCED_REFRESH_COOLDOWN_MS) {
60
+ return false;
61
+ }
62
+ if (runtime.refreshInflight) return runtime.refreshInflight;
63
+ runtime.refreshInflight = (async () => {
64
+ try {
65
+ const accessToken = await config.refreshSession();
66
+ if (!accessToken) {
67
+ console.warn('[agents] refreshSession returned no session');
68
+ return false;
69
+ }
70
+ store.$accessToken.set(accessToken);
71
+ runtime.lastForcedRefreshAt = Date.now();
72
+ return true;
73
+ } catch (e) {
74
+ console.warn('[agents] refreshSession threw:', e);
75
+ return false;
76
+ } finally {
77
+ runtime.refreshInflight = null;
78
+ }
79
+ })();
80
+ return runtime.refreshInflight;
81
+ }
82
+
83
+ async function resyncOwnedGuilds(): Promise<boolean> {
84
+ const providerToken = store.$providerToken.get();
85
+ if (!providerToken) return false;
86
+ const ok = await bootstrapDiscord(providerToken);
87
+ if (!ok) return false;
88
+ return await forceTokenRefresh();
89
+ }
90
+
91
+ async function runInitAuth(): Promise<void> {
92
+ try {
93
+ const session = await config.loadSession();
94
+ const accessToken = session.accessToken;
95
+
96
+ if (!accessToken) {
97
+ store.$authState.set('unauthenticated');
98
+ return;
99
+ }
100
+
101
+ store.$accessToken.set(accessToken);
102
+
103
+ const jwtPayload = parseJwtPayload(accessToken);
104
+ const userId = (jwtPayload?.['sub'] as string | undefined) ?? null;
105
+ if (userId) store.$userId.set(userId);
106
+
107
+ let providerToken: string | null = session.providerToken;
108
+ if (!providerToken) {
109
+ providerToken = config.getDiscordProviderToken();
110
+ }
111
+
112
+ if (providerToken) {
113
+ store.$providerToken.set(providerToken);
114
+ store.$authState.set('authenticated');
115
+ const cachedOwned = userId ? loadCachedGuilds(userId) : null;
116
+ if (cachedOwned && cachedOwned.length > 0) {
117
+ store.$guilds.set(cachedOwned);
118
+ if (cachedOwned.length === 1) {
119
+ api.selectGuild(cachedOwned[0].id);
120
+ }
121
+ }
122
+ return;
123
+ }
124
+
125
+ const cached = userId ? loadCachedGuilds(userId) : null;
126
+ const jwtIds = extractJwtOwnedGuildIds(accessToken);
127
+ const jwtSet = new Set(jwtIds);
128
+
129
+ if (cached && cached.length > 0) {
130
+ const filtered =
131
+ jwtIds.length > 0
132
+ ? cached.filter((g) => jwtSet.has(g.id))
133
+ : cached;
134
+ store.$guilds.set(filtered);
135
+ store.$guildsStale.set(true);
136
+ store.$authState.set('authenticated');
137
+ if (filtered.length === 1) {
138
+ api.selectGuild(filtered[0].id);
139
+ }
140
+ return;
141
+ }
142
+
143
+ if (jwtIds.length > 0) {
144
+ store.$guilds.set(
145
+ jwtIds.map((id) => ({
146
+ id,
147
+ name: `Guild #${id.slice(-6)}`,
148
+ icon: null,
149
+ owner: true,
150
+ permissions: '0',
151
+ })),
152
+ );
153
+ store.$guildsStale.set(true);
154
+ store.$authState.set('authenticated');
155
+ if (jwtIds.length === 1) api.selectGuild(jwtIds[0]);
156
+ return;
157
+ }
158
+
159
+ store.$authState.set('discord_reauth_required');
160
+ } catch {
161
+ store.$authState.set('unauthenticated');
162
+ } finally {
163
+ runtime.lastInitAt = Date.now();
164
+ }
165
+ }
166
+
167
+ async function initAuth(force = false): Promise<void> {
168
+ if (!force) {
169
+ if (runtime.initPromise) return runtime.initPromise;
170
+ if (
171
+ runtime.lastInitAt > 0 &&
172
+ Date.now() - runtime.lastInitAt < INIT_DEDUP_MS &&
173
+ store.$authState.get() !== 'loading'
174
+ ) {
175
+ return;
176
+ }
177
+ }
178
+ runtime.initPromise = runInitAuth();
179
+ try {
180
+ await runtime.initPromise;
181
+ } finally {
182
+ runtime.initPromise = null;
183
+ }
184
+ }
185
+
186
+ async function signInWithDiscord(): Promise<void> {
187
+ try {
188
+ await config.signInWithDiscord();
189
+ } catch (e) {
190
+ config.notify({
191
+ message:
192
+ e instanceof Error ? e.message : 'Discord sign-in failed',
193
+ severity: 'error',
194
+ duration: 5000,
195
+ });
196
+ }
197
+ }
198
+
199
+ return { initAuth, resyncOwnedGuilds, signInWithDiscord };
200
+ }
@@ -0,0 +1,127 @@
1
+ import {
2
+ GUILDS_CACHE_KEY,
3
+ GUILDS_CACHE_TTL_MS,
4
+ TOKENS_CACHE_KEY,
5
+ TOKENS_CACHE_TTL_MS,
6
+ } from './constants';
7
+ import type { AgentTokenRow, DiscordGuild } from './types';
8
+
9
+ export function parseJwtPayload(jwt: string): Record<string, unknown> | null {
10
+ try {
11
+ const parts = jwt.split('.');
12
+ if (parts.length < 2) return null;
13
+ const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
14
+ const padded = b64 + '==='.slice((b64.length + 3) % 4);
15
+ const json = atob(padded);
16
+ return JSON.parse(json) as Record<string, unknown>;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export function extractJwtOwnedGuildIds(jwt: string): string[] {
23
+ const payload = parseJwtPayload(jwt);
24
+ if (!payload || !Array.isArray(payload['owned_guilds'])) return [];
25
+ return payload['owned_guilds'].filter(
26
+ (g): g is string => typeof g === 'string' && /^[0-9]{17,20}$/.test(g),
27
+ );
28
+ }
29
+
30
+ interface CachedGuildsBlob {
31
+ user_id: string;
32
+ guilds: DiscordGuild[];
33
+ cached_at: number;
34
+ }
35
+
36
+ export function loadCachedGuilds(userId: string): DiscordGuild[] | null {
37
+ try {
38
+ const raw = localStorage.getItem(GUILDS_CACHE_KEY);
39
+ if (!raw) return null;
40
+ const blob = JSON.parse(raw) as CachedGuildsBlob;
41
+ if (blob.user_id !== userId) return null;
42
+ if (Date.now() - blob.cached_at > GUILDS_CACHE_TTL_MS) return null;
43
+ if (!Array.isArray(blob.guilds)) return null;
44
+ return blob.guilds;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ export function saveCachedGuilds(userId: string, guilds: DiscordGuild[]): void {
51
+ try {
52
+ const blob: CachedGuildsBlob = {
53
+ user_id: userId,
54
+ guilds,
55
+ cached_at: Date.now(),
56
+ };
57
+ localStorage.setItem(GUILDS_CACHE_KEY, JSON.stringify(blob));
58
+ } catch {
59
+ /* non-fatal */
60
+ }
61
+ }
62
+
63
+ interface CachedTokensBlob {
64
+ user_id: string;
65
+ tokens_by_guild: Record<
66
+ string,
67
+ { tokens: AgentTokenRow[]; cached_at: number }
68
+ >;
69
+ }
70
+
71
+ export function loadCachedTokens(
72
+ userId: string,
73
+ guildId: string,
74
+ ): AgentTokenRow[] | null {
75
+ try {
76
+ const raw = localStorage.getItem(TOKENS_CACHE_KEY);
77
+ if (!raw) return null;
78
+ const blob = JSON.parse(raw) as CachedTokensBlob;
79
+ if (blob.user_id !== userId) return null;
80
+ const entry = blob.tokens_by_guild?.[guildId];
81
+ if (!entry) return null;
82
+ if (Date.now() - entry.cached_at > TOKENS_CACHE_TTL_MS) return null;
83
+ return entry.tokens;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function saveCachedTokens(
90
+ userId: string,
91
+ guildId: string,
92
+ tokens: AgentTokenRow[],
93
+ ): void {
94
+ try {
95
+ const raw = localStorage.getItem(TOKENS_CACHE_KEY);
96
+ let blob: CachedTokensBlob;
97
+ try {
98
+ blob = raw
99
+ ? (JSON.parse(raw) as CachedTokensBlob)
100
+ : { user_id: userId, tokens_by_guild: {} };
101
+ } catch {
102
+ blob = { user_id: userId, tokens_by_guild: {} };
103
+ }
104
+ if (blob.user_id !== userId) {
105
+ blob = { user_id: userId, tokens_by_guild: {} };
106
+ }
107
+ blob.tokens_by_guild[guildId] = { tokens, cached_at: Date.now() };
108
+ localStorage.setItem(TOKENS_CACHE_KEY, JSON.stringify(blob));
109
+ } catch {
110
+ /* non-fatal */
111
+ }
112
+ }
113
+
114
+ export function invalidateCachedTokens(userId: string, guildId: string): void {
115
+ try {
116
+ const raw = localStorage.getItem(TOKENS_CACHE_KEY);
117
+ if (!raw) return;
118
+ const blob = JSON.parse(raw) as CachedTokensBlob;
119
+ if (blob.user_id !== userId) return;
120
+ if (blob.tokens_by_guild?.[guildId]) {
121
+ delete blob.tokens_by_guild[guildId];
122
+ localStorage.setItem(TOKENS_CACHE_KEY, JSON.stringify(blob));
123
+ }
124
+ } catch {
125
+ /* non-fatal */
126
+ }
127
+ }
@@ -0,0 +1,71 @@
1
+ import type { AgentsCtx, CallGuildVault } from '../ctx';
2
+
3
+ export function makeCallGuildVault(ctx: AgentsCtx): CallGuildVault {
4
+ return async function callGuildVault(
5
+ command: string,
6
+ accessToken: string,
7
+ extra: Record<string, unknown>,
8
+ opts: { retriedAfterResync?: boolean } = {},
9
+ ) {
10
+ try {
11
+ const resp = await fetch(ctx.endpoints.guildVault, {
12
+ method: 'POST',
13
+ headers: {
14
+ 'Content-Type': 'application/json',
15
+ Authorization: `Bearer ${accessToken}`,
16
+ },
17
+ body: JSON.stringify({ command, ...extra }),
18
+ });
19
+ const text = await resp.text();
20
+ let body: unknown;
21
+ try {
22
+ body = JSON.parse(text);
23
+ } catch {
24
+ return {
25
+ ok: false,
26
+ error: `HTTP ${resp.status}: ${text.slice(0, 200)}`,
27
+ };
28
+ }
29
+ if (!resp.ok) {
30
+ const b = body as {
31
+ error?: string;
32
+ sqlstate?: string | null;
33
+ hint?: string | null;
34
+ context?: string | null;
35
+ } | null;
36
+ const errMsg = b?.error ?? '';
37
+ const looksStale =
38
+ resp.status === 403 &&
39
+ /owned_guilds/i.test(errMsg) &&
40
+ !opts.retriedAfterResync;
41
+ if (looksStale) {
42
+ const resynced = await ctx.resyncOwnedGuilds();
43
+ if (resynced) {
44
+ const newToken = ctx.store.$accessToken.get();
45
+ if (newToken) {
46
+ return ctx.callGuildVault(
47
+ command,
48
+ newToken,
49
+ extra,
50
+ {
51
+ retriedAfterResync: true,
52
+ },
53
+ );
54
+ }
55
+ }
56
+ }
57
+ const parts: string[] = [errMsg || `HTTP ${resp.status}`];
58
+ if (b?.sqlstate) parts.push(`sqlstate=${b.sqlstate}`);
59
+ if (b?.hint) parts.push(`hint=${b.hint}`);
60
+ if (b?.context) parts.push(`context=${b.context}`);
61
+ return { ok: false, error: parts.join(' · ') };
62
+ }
63
+ return { ok: true, body };
64
+ } catch (e) {
65
+ return {
66
+ ok: false,
67
+ error: e instanceof Error ? e.message : 'Network error',
68
+ };
69
+ }
70
+ };
71
+ }
@@ -0,0 +1,64 @@
1
+ import type { AgentsCtx, CallJson } from '../ctx';
2
+
3
+ export function makeCallJson(ctx: AgentsCtx): CallJson {
4
+ return async function callJson<T>(
5
+ url: string,
6
+ body: Record<string, unknown>,
7
+ opts: { retriedAfterResync?: boolean } = {},
8
+ ) {
9
+ const accessToken = ctx.store.$accessToken.get();
10
+ if (!accessToken) return { ok: false, error: 'Not authenticated' };
11
+ try {
12
+ const resp = await fetch(url, {
13
+ method: 'POST',
14
+ headers: {
15
+ 'Content-Type': 'application/json',
16
+ Authorization: `Bearer ${accessToken}`,
17
+ },
18
+ body: JSON.stringify(body),
19
+ });
20
+ const text = await resp.text();
21
+ let parsed: unknown;
22
+ try {
23
+ parsed = text.length > 0 ? JSON.parse(text) : {};
24
+ } catch {
25
+ return {
26
+ ok: false,
27
+ error: `HTTP ${resp.status}: ${text.slice(0, 200)}`,
28
+ };
29
+ }
30
+ if (!resp.ok) {
31
+ const b = parsed as {
32
+ error?: string;
33
+ sqlstate?: string | null;
34
+ hint?: string | null;
35
+ context?: string | null;
36
+ } | null;
37
+ const errMsg = b?.error ?? '';
38
+ const looksStale =
39
+ resp.status === 403 &&
40
+ /owned_guilds/i.test(errMsg) &&
41
+ !opts.retriedAfterResync;
42
+ if (looksStale) {
43
+ const resynced = await ctx.resyncOwnedGuilds();
44
+ if (resynced) {
45
+ return ctx.callJson<T>(url, body, {
46
+ retriedAfterResync: true,
47
+ });
48
+ }
49
+ }
50
+ const parts: string[] = [errMsg || `HTTP ${resp.status}`];
51
+ if (b?.sqlstate) parts.push(`sqlstate=${b.sqlstate}`);
52
+ if (b?.hint) parts.push(`hint=${b.hint}`);
53
+ if (b?.context) parts.push(`context=${b.context}`);
54
+ return { ok: false, error: parts.join(' · ') };
55
+ }
56
+ return { ok: true, data: parsed as T };
57
+ } catch (e) {
58
+ return {
59
+ ok: false,
60
+ error: e instanceof Error ? e.message : 'Network error',
61
+ };
62
+ }
63
+ };
64
+ }