@valentinkolb/cloud 0.3.1 → 0.5.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 (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,715 @@
1
+ import { sql } from "bun";
2
+ import { crypto, err, fail, ok, type PageParams, type Paginated, type Result } from "@valentinkolb/stdlib";
3
+ import { accounts } from "./accounts";
4
+ import { audit } from "./audit";
5
+ import { isUniqueViolation, toPgTextArray } from "./postgres";
6
+ import { serviceAccounts, type ServiceAccount } from "./service-accounts";
7
+ import type { User } from "../contracts/shared";
8
+
9
+ export type ServiceAccountCredentialStatus = "active" | "revoked";
10
+ export type ServiceAccountCredentialKind = "api_token";
11
+
12
+ export type ServiceAccountCredential = {
13
+ id: string;
14
+ serviceAccountId: string;
15
+ name: string;
16
+ kind: ServiceAccountCredentialKind;
17
+ status: ServiceAccountCredentialStatus;
18
+ tokenPrefix: string;
19
+ scopes: string[];
20
+ expiresAt: string | null;
21
+ lastUsedAt: string | null;
22
+ createdBy: string | null;
23
+ createdAt: string;
24
+ revokedAt: string | null;
25
+ revokedBy: string | null;
26
+ };
27
+
28
+ export type AuthenticatedServiceAccountCredential = {
29
+ credential: ServiceAccountCredential;
30
+ serviceAccount: ServiceAccount;
31
+ delegatedUser: User | null;
32
+ };
33
+
34
+ export type ServiceAccountCredentialOwner =
35
+ | {
36
+ type: "user";
37
+ userId: string;
38
+ uid: string;
39
+ displayName: string;
40
+ mail: string | null;
41
+ }
42
+ | {
43
+ type: "resource";
44
+ appId: string;
45
+ resourceType: string;
46
+ resourceId: string;
47
+ };
48
+
49
+ export type ServiceAccountCredentialOverview = ServiceAccountCredential & {
50
+ serviceAccount: ServiceAccount;
51
+ owner: ServiceAccountCredentialOwner;
52
+ };
53
+
54
+ type DbCredentialRow = {
55
+ id: string;
56
+ service_account_id: string;
57
+ name: string;
58
+ kind: ServiceAccountCredentialKind;
59
+ status: ServiceAccountCredentialStatus;
60
+ token_prefix: string;
61
+ scopes: string[];
62
+ expires_at: Date | null;
63
+ last_used_at: Date | null;
64
+ created_by: string | null;
65
+ created_at: Date;
66
+ revoked_at: Date | null;
67
+ revoked_by: string | null;
68
+ };
69
+
70
+ type DbCredentialWithSecretRow = DbCredentialRow & {
71
+ secret_hash: string;
72
+ } & DbCredentialServiceAccountFields;
73
+
74
+ type DbCredentialServiceAccountFields = {
75
+ service_account_id: string;
76
+ service_account_name: string;
77
+ service_account_kind: ServiceAccount["kind"];
78
+ service_account_status: ServiceAccount["status"];
79
+ delegated_user_id: string | null;
80
+ app_id: string | null;
81
+ resource_type: string | null;
82
+ resource_id: string | null;
83
+ service_account_created_by: string | null;
84
+ service_account_created_at: Date;
85
+ };
86
+
87
+ type SqlRunner = typeof sql;
88
+ type DbCredentialOverviewRow = DbCredentialRow & DbCredentialServiceAccountFields & {
89
+ delegated_uid: string | null;
90
+ delegated_display_name: string | null;
91
+ delegated_mail: string | null;
92
+ };
93
+
94
+ const TOKEN_PREFIX = "cld";
95
+ const TOKEN_PATTERN = /^cld_([0-9a-f]{24})_([0-9a-f]{64})$/i;
96
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
97
+
98
+ const isForeignKeyViolation = (error: unknown): boolean => {
99
+ if (!error || typeof error !== "object") return false;
100
+ const e = error as { code?: string; errno?: string };
101
+ return e.code === "23503" || e.errno === "23503";
102
+ };
103
+
104
+ const USER_DELEGATED_UNIQUE_CONSTRAINT = "uniq_service_accounts_user_delegated";
105
+
106
+ const mapCredential = (row: DbCredentialRow): ServiceAccountCredential => ({
107
+ id: row.id,
108
+ serviceAccountId: row.service_account_id,
109
+ name: row.name,
110
+ kind: row.kind,
111
+ status: row.status,
112
+ tokenPrefix: row.token_prefix,
113
+ scopes: row.scopes ?? [],
114
+ expiresAt: row.expires_at?.toISOString() ?? null,
115
+ lastUsedAt: row.last_used_at?.toISOString() ?? null,
116
+ createdBy: row.created_by,
117
+ createdAt: row.created_at.toISOString(),
118
+ revokedAt: row.revoked_at?.toISOString() ?? null,
119
+ revokedBy: row.revoked_by,
120
+ });
121
+
122
+ const mapServiceAccount = (row: DbCredentialServiceAccountFields): ServiceAccount => ({
123
+ id: row.service_account_id,
124
+ name: row.service_account_name,
125
+ kind: row.service_account_kind,
126
+ status: row.service_account_status,
127
+ delegatedUserId: row.delegated_user_id,
128
+ appId: row.app_id,
129
+ resourceType: row.resource_type,
130
+ resourceId: row.resource_id,
131
+ createdBy: row.service_account_created_by,
132
+ createdAt: row.service_account_created_at.toISOString(),
133
+ });
134
+
135
+ const mapCredentialOverview = (row: DbCredentialOverviewRow): ServiceAccountCredentialOverview => {
136
+ const serviceAccount = mapServiceAccount(row);
137
+ return {
138
+ ...mapCredential(row),
139
+ serviceAccount,
140
+ owner:
141
+ serviceAccount.kind === "user_delegated" && serviceAccount.delegatedUserId
142
+ ? {
143
+ type: "user",
144
+ userId: serviceAccount.delegatedUserId,
145
+ uid: row.delegated_uid ?? serviceAccount.delegatedUserId,
146
+ displayName: row.delegated_display_name ?? "",
147
+ mail: row.delegated_mail,
148
+ }
149
+ : {
150
+ type: "resource",
151
+ appId: serviceAccount.appId ?? "",
152
+ resourceType: serviceAccount.resourceType ?? "",
153
+ resourceId: serviceAccount.resourceId ?? "",
154
+ },
155
+ };
156
+ };
157
+
158
+ const actorForUser = (user: Pick<User, "id" | "uid" | "provider" | "roles">) => ({
159
+ userId: user.id,
160
+ uid: user.uid,
161
+ provider: user.provider,
162
+ roles: user.roles,
163
+ });
164
+
165
+ const normalizeName = (value: string): string => value.trim();
166
+
167
+ const parseToken = (token: string): { tokenPrefix: string; secret: string } | null => {
168
+ const match = token.match(TOKEN_PATTERN);
169
+ if (!match) return null;
170
+ return { tokenPrefix: match[1]!.toLowerCase(), secret: match[2]!.toLowerCase() };
171
+ };
172
+
173
+ const generateTokenParts = (): { tokenPrefix: string; secret: string; token: string } => {
174
+ const tokenPrefix = crypto.common.generateKey(12);
175
+ const secret = crypto.common.generateKey(32);
176
+ return { tokenPrefix, secret, token: `${TOKEN_PREFIX}_${tokenPrefix}_${secret}` };
177
+ };
178
+
179
+ const generateUniqueTokenParts = async (): Promise<{ tokenPrefix: string; secret: string; token: string }> => {
180
+ for (let i = 0; i < 5; i += 1) {
181
+ const parts = generateTokenParts();
182
+ const [row] = await sql<{ exists: boolean }[]>`
183
+ SELECT EXISTS(
184
+ SELECT 1 FROM auth.service_account_credentials WHERE token_prefix = ${parts.tokenPrefix}
185
+ ) AS exists
186
+ `;
187
+ if (!row?.exists) return parts;
188
+ }
189
+ throw new Error("Failed to generate unique API token prefix");
190
+ };
191
+
192
+ export const isApiToken = (token: string | null | undefined): boolean => Boolean(token && TOKEN_PATTERN.test(token));
193
+
194
+ export const getOrCreateUserDelegatedServiceAccount = async (params: {
195
+ userId: string;
196
+ createdBy?: string | null;
197
+ }): Promise<Result<ServiceAccount>> => {
198
+ const [existing] = await sql<{
199
+ id: string;
200
+ name: string;
201
+ kind: ServiceAccount["kind"];
202
+ status: ServiceAccount["status"];
203
+ delegated_user_id: string | null;
204
+ app_id: string | null;
205
+ resource_type: string | null;
206
+ resource_id: string | null;
207
+ created_by: string | null;
208
+ created_at: Date;
209
+ }[]>`
210
+ SELECT id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
211
+ FROM auth.service_accounts
212
+ WHERE kind = 'user_delegated'
213
+ AND delegated_user_id = ${params.userId}::uuid
214
+ ORDER BY created_at ASC
215
+ LIMIT 1
216
+ `;
217
+ if (existing) {
218
+ return ok({
219
+ id: existing.id,
220
+ name: existing.name,
221
+ kind: existing.kind,
222
+ status: existing.status,
223
+ delegatedUserId: existing.delegated_user_id,
224
+ appId: existing.app_id,
225
+ resourceType: existing.resource_type,
226
+ resourceId: existing.resource_id,
227
+ createdBy: existing.created_by,
228
+ createdAt: existing.created_at.toISOString(),
229
+ });
230
+ }
231
+
232
+ try {
233
+ return await serviceAccounts.createUserDelegated({
234
+ name: "Personal API keys",
235
+ delegatedUserId: params.userId,
236
+ createdBy: params.createdBy ?? params.userId,
237
+ });
238
+ } catch (error) {
239
+ if (!isUniqueViolation(error, USER_DELEGATED_UNIQUE_CONSTRAINT)) throw error;
240
+ const [row] = await sql<{
241
+ id: string;
242
+ name: string;
243
+ kind: ServiceAccount["kind"];
244
+ status: ServiceAccount["status"];
245
+ delegated_user_id: string | null;
246
+ app_id: string | null;
247
+ resource_type: string | null;
248
+ resource_id: string | null;
249
+ created_by: string | null;
250
+ created_at: Date;
251
+ }[]>`
252
+ SELECT id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
253
+ FROM auth.service_accounts
254
+ WHERE kind = 'user_delegated'
255
+ AND delegated_user_id = ${params.userId}::uuid
256
+ LIMIT 1
257
+ `;
258
+ if (!row) return fail(err.internal("Failed to load user service account"));
259
+ return ok({
260
+ id: row.id,
261
+ name: row.name,
262
+ kind: row.kind,
263
+ status: row.status,
264
+ delegatedUserId: row.delegated_user_id,
265
+ appId: row.app_id,
266
+ resourceType: row.resource_type,
267
+ resourceId: row.resource_id,
268
+ createdBy: row.created_by,
269
+ createdAt: row.created_at.toISOString(),
270
+ });
271
+ }
272
+ };
273
+
274
+ const insertApiToken = async (db: SqlRunner, params: {
275
+ serviceAccountId: string;
276
+ name: string;
277
+ createdBy?: string | null;
278
+ expiresAt?: string | null;
279
+ scopes?: string[];
280
+ }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
281
+ if (!UUID_PATTERN.test(params.serviceAccountId)) return fail(err.notFound("Service account"));
282
+ const name = normalizeName(params.name);
283
+ if (!name) return fail(err.badInput("API key name is required"));
284
+ if (name.length > 120) return fail(err.badInput("API key name must be 120 characters or fewer"));
285
+
286
+ let expiresAt: Date | null = null;
287
+ if (params.expiresAt) {
288
+ expiresAt = new Date(params.expiresAt);
289
+ if (Number.isNaN(expiresAt.getTime())) return fail(err.badInput("Invalid expiry date"));
290
+ if (expiresAt.getTime() <= Date.now()) return fail(err.badInput("Expiry must be in the future"));
291
+ }
292
+
293
+ const parts = await generateUniqueTokenParts();
294
+ const secretHash = await Bun.password.hash(parts.secret);
295
+
296
+ try {
297
+ const [row] = await db<DbCredentialRow[]>`
298
+ INSERT INTO auth.service_account_credentials (
299
+ service_account_id,
300
+ name,
301
+ token_prefix,
302
+ secret_hash,
303
+ scopes,
304
+ expires_at,
305
+ created_by
306
+ )
307
+ VALUES (
308
+ ${params.serviceAccountId}::uuid,
309
+ ${name},
310
+ ${parts.tokenPrefix},
311
+ ${secretHash},
312
+ ${toPgTextArray(params.scopes ?? [])}::text[],
313
+ ${expiresAt},
314
+ ${params.createdBy ?? null}::uuid
315
+ )
316
+ RETURNING id, service_account_id, name, kind, status, token_prefix, scopes, expires_at, last_used_at, created_by, created_at, revoked_at, revoked_by
317
+ `;
318
+ if (!row) return fail(err.internal("Failed to create API key"));
319
+ return ok({ credential: mapCredential(row), token: parts.token });
320
+ } catch (error) {
321
+ if (isForeignKeyViolation(error)) return fail(err.notFound("Service account"));
322
+ if (isUniqueViolation(error)) return fail(err.conflict("API key"));
323
+ throw error;
324
+ }
325
+ };
326
+
327
+ export const createApiToken = (params: {
328
+ serviceAccountId: string;
329
+ name: string;
330
+ createdBy?: string | null;
331
+ expiresAt?: string | null;
332
+ scopes?: string[];
333
+ }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => insertApiToken(sql, params);
334
+
335
+ export const createUserApiToken = async (params: {
336
+ user: User;
337
+ name: string;
338
+ expiresAt?: string | null;
339
+ }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
340
+ const serviceAccountResult = await getOrCreateUserDelegatedServiceAccount({
341
+ userId: params.user.id,
342
+ createdBy: params.user.id,
343
+ });
344
+ if (!serviceAccountResult.ok) return fail(serviceAccountResult.error);
345
+
346
+ return sql.begin(async (tx) => {
347
+ const result = await insertApiToken(tx, {
348
+ serviceAccountId: serviceAccountResult.data.id,
349
+ name: params.name,
350
+ expiresAt: params.expiresAt,
351
+ createdBy: params.user.id,
352
+ });
353
+
354
+ return audit.recordResult({
355
+ action: "service_account_credential.create",
356
+ actor: actorForUser(params.user),
357
+ target: { type: "service_account_credential", id: result.ok ? result.data.credential.id : null, label: params.name },
358
+ metadata: {
359
+ serviceAccountId: serviceAccountResult.data.id,
360
+ kind: "api_token",
361
+ expiresAt: params.expiresAt ?? null,
362
+ },
363
+ result,
364
+ db: tx,
365
+ });
366
+ });
367
+ };
368
+
369
+ export const createResourceApiToken = async (params: {
370
+ serviceAccountId: string;
371
+ actor: User;
372
+ name: string;
373
+ expiresAt?: string | null;
374
+ scopes?: string[];
375
+ }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
376
+ const serviceAccount = await serviceAccounts.get({ id: params.serviceAccountId });
377
+ if (!serviceAccount || serviceAccount.kind !== "resource_bound") return fail(err.notFound("Resource service account"));
378
+ if (serviceAccount.status !== "active") return fail(err.badInput("Resource service account is disabled"));
379
+
380
+ return sql.begin(async (tx) => {
381
+ const result = await insertApiToken(tx, {
382
+ serviceAccountId: serviceAccount.id,
383
+ name: params.name,
384
+ expiresAt: params.expiresAt,
385
+ createdBy: params.actor.id,
386
+ scopes: params.scopes,
387
+ });
388
+
389
+ return audit.recordResult({
390
+ action: "service_account_credential.create",
391
+ actor: actorForUser(params.actor),
392
+ target: { type: "service_account_credential", id: result.ok ? result.data.credential.id : null, label: params.name },
393
+ metadata: {
394
+ serviceAccountId: serviceAccount.id,
395
+ kind: "api_token",
396
+ serviceAccountKind: serviceAccount.kind,
397
+ appId: serviceAccount.appId,
398
+ resourceType: serviceAccount.resourceType,
399
+ resourceId: serviceAccount.resourceId,
400
+ expiresAt: params.expiresAt ?? null,
401
+ },
402
+ result,
403
+ db: tx,
404
+ });
405
+ });
406
+ };
407
+
408
+ export const listForDelegatedUser = async (params: { userId: string }): Promise<ServiceAccountCredential[]> => {
409
+ const rows = await sql<DbCredentialRow[]>`
410
+ SELECT c.id, c.service_account_id, c.name, c.kind, c.status, c.token_prefix, c.scopes, c.expires_at,
411
+ c.last_used_at, c.created_by, c.created_at, c.revoked_at, c.revoked_by
412
+ FROM auth.service_account_credentials c
413
+ JOIN auth.service_accounts sa ON sa.id = c.service_account_id
414
+ WHERE sa.kind = 'user_delegated'
415
+ AND sa.delegated_user_id = ${params.userId}::uuid
416
+ AND c.status = 'active'
417
+ ORDER BY c.created_at DESC
418
+ `;
419
+ return rows.map(mapCredential);
420
+ };
421
+
422
+ export const listOverview = async (config?: {
423
+ pagination?: PageParams;
424
+ filter?: {
425
+ search?: string;
426
+ serviceAccountKind?: ServiceAccount["kind"];
427
+ credentialStatus?: ServiceAccountCredentialStatus;
428
+ userId?: string;
429
+ appId?: string;
430
+ resourceType?: string;
431
+ resourceId?: string;
432
+ serviceAccountId?: string;
433
+ };
434
+ }): Promise<Paginated<ServiceAccountCredentialOverview>> => {
435
+ const page = Math.max(1, config?.pagination?.page ?? 1);
436
+ const perPage = Math.max(1, Math.min(config?.pagination?.perPage ?? 100, 500));
437
+ const offset = (page - 1) * perPage;
438
+ const search = config?.filter?.search?.trim() || null;
439
+ const serviceAccountKind = config?.filter?.serviceAccountKind ?? null;
440
+ const credentialStatus = config?.filter?.credentialStatus ?? null;
441
+ const userId = config?.filter?.userId ?? null;
442
+ const appId = config?.filter?.appId ?? null;
443
+ const resourceType = config?.filter?.resourceType ?? null;
444
+ const resourceId = config?.filter?.resourceId ?? null;
445
+ const serviceAccountId = config?.filter?.serviceAccountId ?? null;
446
+
447
+ const [countRow] = await sql<{ count: string }[]>`
448
+ SELECT COUNT(*)::text AS count
449
+ FROM auth.service_account_credentials c
450
+ JOIN auth.service_accounts sa ON sa.id = c.service_account_id
451
+ LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
452
+ WHERE (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
453
+ AND (${credentialStatus}::text IS NULL OR c.status = ${credentialStatus})
454
+ AND (${userId}::uuid IS NULL OR sa.delegated_user_id = ${userId}::uuid)
455
+ AND (${serviceAccountId}::uuid IS NULL OR sa.id = ${serviceAccountId}::uuid)
456
+ AND (${appId}::text IS NULL OR sa.app_id = ${appId})
457
+ AND (${resourceType}::text IS NULL OR sa.resource_type = ${resourceType})
458
+ AND (${resourceId}::text IS NULL OR sa.resource_id = ${resourceId})
459
+ AND (
460
+ ${search}::text IS NULL
461
+ OR c.name ILIKE '%' || ${search} || '%'
462
+ OR c.token_prefix ILIKE '%' || ${search} || '%'
463
+ OR sa.name ILIKE '%' || ${search} || '%'
464
+ OR du.uid ILIKE '%' || ${search} || '%'
465
+ OR du.display_name ILIKE '%' || ${search} || '%'
466
+ OR du.mail ILIKE '%' || ${search} || '%'
467
+ OR sa.app_id ILIKE '%' || ${search} || '%'
468
+ OR sa.resource_type ILIKE '%' || ${search} || '%'
469
+ OR sa.resource_id ILIKE '%' || ${search} || '%'
470
+ )
471
+ `;
472
+
473
+ const rows = await sql<DbCredentialOverviewRow[]>`
474
+ SELECT
475
+ c.id,
476
+ c.service_account_id,
477
+ c.name,
478
+ c.kind,
479
+ c.status,
480
+ c.token_prefix,
481
+ c.scopes,
482
+ c.expires_at,
483
+ c.last_used_at,
484
+ c.created_by,
485
+ c.created_at,
486
+ c.revoked_at,
487
+ c.revoked_by,
488
+ sa.name AS service_account_name,
489
+ sa.kind AS service_account_kind,
490
+ sa.status AS service_account_status,
491
+ sa.delegated_user_id,
492
+ sa.app_id,
493
+ sa.resource_type,
494
+ sa.resource_id,
495
+ sa.created_by AS service_account_created_by,
496
+ sa.created_at AS service_account_created_at,
497
+ du.uid AS delegated_uid,
498
+ du.display_name AS delegated_display_name,
499
+ du.mail AS delegated_mail
500
+ FROM auth.service_account_credentials c
501
+ JOIN auth.service_accounts sa ON sa.id = c.service_account_id
502
+ LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
503
+ WHERE (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
504
+ AND (${credentialStatus}::text IS NULL OR c.status = ${credentialStatus})
505
+ AND (${userId}::uuid IS NULL OR sa.delegated_user_id = ${userId}::uuid)
506
+ AND (${serviceAccountId}::uuid IS NULL OR sa.id = ${serviceAccountId}::uuid)
507
+ AND (${appId}::text IS NULL OR sa.app_id = ${appId})
508
+ AND (${resourceType}::text IS NULL OR sa.resource_type = ${resourceType})
509
+ AND (${resourceId}::text IS NULL OR sa.resource_id = ${resourceId})
510
+ AND (
511
+ ${search}::text IS NULL
512
+ OR c.name ILIKE '%' || ${search} || '%'
513
+ OR c.token_prefix ILIKE '%' || ${search} || '%'
514
+ OR sa.name ILIKE '%' || ${search} || '%'
515
+ OR du.uid ILIKE '%' || ${search} || '%'
516
+ OR du.display_name ILIKE '%' || ${search} || '%'
517
+ OR du.mail ILIKE '%' || ${search} || '%'
518
+ OR sa.app_id ILIKE '%' || ${search} || '%'
519
+ OR sa.resource_type ILIKE '%' || ${search} || '%'
520
+ OR sa.resource_id ILIKE '%' || ${search} || '%'
521
+ )
522
+ ORDER BY c.created_at DESC
523
+ LIMIT ${perPage}
524
+ OFFSET ${offset}
525
+ `;
526
+
527
+ const total = Number.parseInt(countRow?.count ?? "0", 10);
528
+ return {
529
+ items: rows.map(mapCredentialOverview),
530
+ page,
531
+ perPage,
532
+ total,
533
+ hasNext: page * perPage < total,
534
+ };
535
+ };
536
+
537
+ export const revokeForDelegatedUser = async (params: {
538
+ credentialId: string;
539
+ user: User;
540
+ }): Promise<Result<void>> => {
541
+ if (!UUID_PATTERN.test(params.credentialId)) return fail(err.notFound("API key"));
542
+
543
+ return sql.begin(async (tx) => {
544
+ const [row] = await tx<DbCredentialRow[]>`
545
+ UPDATE auth.service_account_credentials c
546
+ SET status = 'revoked',
547
+ revoked_at = now(),
548
+ revoked_by = ${params.user.id}::uuid
549
+ FROM auth.service_accounts sa
550
+ WHERE c.id = ${params.credentialId}::uuid
551
+ AND c.service_account_id = sa.id
552
+ AND sa.kind = 'user_delegated'
553
+ AND sa.delegated_user_id = ${params.user.id}::uuid
554
+ AND c.status = 'active'
555
+ RETURNING c.id, c.service_account_id, c.name, c.kind, c.status, c.token_prefix, c.scopes, c.expires_at,
556
+ c.last_used_at, c.created_by, c.created_at, c.revoked_at, c.revoked_by
557
+ `;
558
+
559
+ const result = row ? ok() : fail(err.notFound("API key"));
560
+ return audit.recordResult({
561
+ action: "service_account_credential.revoke",
562
+ actor: actorForUser(params.user),
563
+ target: { type: "service_account_credential", id: params.credentialId, label: row?.name ?? null },
564
+ metadata: { serviceAccountId: row?.service_account_id ?? null },
565
+ result,
566
+ db: tx,
567
+ });
568
+ });
569
+ };
570
+
571
+ export const revoke = async (params: {
572
+ credentialId: string;
573
+ actor: User;
574
+ }): Promise<Result<void>> => {
575
+ if (!UUID_PATTERN.test(params.credentialId)) return fail(err.notFound("API key"));
576
+
577
+ return sql.begin(async (tx) => {
578
+ const [row] = await tx<DbCredentialRow[]>`
579
+ UPDATE auth.service_account_credentials
580
+ SET status = 'revoked',
581
+ revoked_at = now(),
582
+ revoked_by = ${params.actor.id}::uuid
583
+ WHERE id = ${params.credentialId}::uuid
584
+ AND status = 'active'
585
+ RETURNING id, service_account_id, name, kind, status, token_prefix, scopes, expires_at,
586
+ last_used_at, created_by, created_at, revoked_at, revoked_by
587
+ `;
588
+
589
+ const result = row ? ok() : fail(err.notFound("API key"));
590
+ return audit.recordResult({
591
+ action: "service_account_credential.revoke",
592
+ actor: actorForUser(params.actor),
593
+ target: { type: "service_account_credential", id: params.credentialId, label: row?.name ?? null },
594
+ metadata: { serviceAccountId: row?.service_account_id ?? null, adminAction: true },
595
+ result,
596
+ db: tx,
597
+ });
598
+ });
599
+ };
600
+
601
+ const findActiveByTokenPrefix = async (tokenPrefix: string): Promise<DbCredentialWithSecretRow | null> => {
602
+ const [row] = await sql<DbCredentialWithSecretRow[]>`
603
+ SELECT
604
+ c.id,
605
+ c.service_account_id,
606
+ c.name,
607
+ c.kind,
608
+ c.status,
609
+ c.token_prefix,
610
+ c.secret_hash,
611
+ c.scopes,
612
+ c.expires_at,
613
+ c.last_used_at,
614
+ c.created_by,
615
+ c.created_at,
616
+ c.revoked_at,
617
+ c.revoked_by,
618
+ sa.name AS service_account_name,
619
+ sa.kind AS service_account_kind,
620
+ sa.status AS service_account_status,
621
+ sa.delegated_user_id,
622
+ sa.app_id,
623
+ sa.resource_type,
624
+ sa.resource_id,
625
+ sa.created_by AS service_account_created_by,
626
+ sa.created_at AS service_account_created_at
627
+ FROM auth.service_account_credentials c
628
+ JOIN auth.service_accounts sa ON sa.id = c.service_account_id
629
+ WHERE c.token_prefix = ${tokenPrefix}
630
+ AND c.status = 'active'
631
+ AND sa.status = 'active'
632
+ AND (c.expires_at IS NULL OR c.expires_at > now())
633
+ LIMIT 1
634
+ `;
635
+ return row ?? null;
636
+ };
637
+
638
+ export const authenticateApiToken = async (token: string): Promise<AuthenticatedServiceAccountCredential | null> => {
639
+ const parsed = parseToken(token);
640
+ if (!parsed) return null;
641
+
642
+ const row = await findActiveByTokenPrefix(parsed.tokenPrefix);
643
+ if (!row) {
644
+ await audit.record({
645
+ action: "service_account_credential.authenticate",
646
+ outcome: "denied",
647
+ reason: "API key not found, inactive, or expired",
648
+ metadata: { tokenPrefix: parsed.tokenPrefix },
649
+ });
650
+ return null;
651
+ }
652
+
653
+ const valid = await Bun.password.verify(parsed.secret, row.secret_hash);
654
+ if (!valid) {
655
+ await audit.record({
656
+ action: "service_account_credential.authenticate",
657
+ outcome: "denied",
658
+ target: { type: "service_account_credential", id: row.id, label: row.name },
659
+ reason: "Invalid API key secret",
660
+ metadata: { tokenPrefix: parsed.tokenPrefix, serviceAccountId: row.service_account_id },
661
+ });
662
+ return null;
663
+ }
664
+
665
+ const serviceAccount = mapServiceAccount(row);
666
+ const delegatedUser = serviceAccount.delegatedUserId ? await accounts.users.get({ id: serviceAccount.delegatedUserId }) : null;
667
+ if (serviceAccount.kind === "user_delegated" && !delegatedUser) {
668
+ await audit.record({
669
+ action: "service_account_credential.authenticate",
670
+ outcome: "denied",
671
+ target: { type: "service_account_credential", id: row.id, label: row.name },
672
+ reason: "Delegated user is missing",
673
+ metadata: { serviceAccountId: row.service_account_id },
674
+ });
675
+ return null;
676
+ }
677
+
678
+ await sql.begin(async (tx) => {
679
+ await tx`
680
+ UPDATE auth.service_account_credentials
681
+ SET last_used_at = now()
682
+ WHERE id = ${row.id}::uuid
683
+ `;
684
+
685
+ await audit.record({
686
+ action: "service_account_credential.authenticate",
687
+ outcome: "allowed",
688
+ actor: delegatedUser ? actorForUser(delegatedUser) : null,
689
+ target: { type: "service_account_credential", id: row.id, label: row.name },
690
+ metadata: {
691
+ serviceAccountId: row.service_account_id,
692
+ serviceAccountKind: serviceAccount.kind,
693
+ },
694
+ }, tx);
695
+ });
696
+
697
+ return {
698
+ credential: mapCredential({ ...row, last_used_at: new Date() }),
699
+ serviceAccount,
700
+ delegatedUser,
701
+ };
702
+ };
703
+
704
+ export const serviceAccountCredentials = {
705
+ isApiToken,
706
+ getOrCreateUserDelegatedServiceAccount,
707
+ createApiToken,
708
+ createUserApiToken,
709
+ createResourceApiToken,
710
+ listForDelegatedUser,
711
+ listOverview,
712
+ revokeForDelegatedUser,
713
+ revoke,
714
+ authenticateApiToken,
715
+ };