@valentinkolb/cloud 0.4.0 → 0.5.1

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 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +116 -13
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +47 -7
  49. package/src/services/auth-flows/magic-link.ts +92 -20
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/notifications/index.ts +82 -11
  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 +79 -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 +58 -0
  92. package/src/shared/redirect.ts +56 -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
@@ -1,13 +1,14 @@
1
1
  import { sql } from "bun";
2
- import { sendEmail } from "./email";
3
2
  import type { PaginationParams } from "../../contracts/shared";
4
- import { escapeLikePattern } from "../postgres";
5
3
  import { logger } from "../logging";
4
+ import { escapeLikePattern } from "../postgres";
5
+ import { sendEmail } from "./email";
6
6
 
7
7
  const log = logger("notifications");
8
8
 
9
9
  export type NotificationType = "email";
10
10
  export type NotificationStatus = "sent" | "pending" | "error";
11
+ export type NotificationStatusSummary = Record<NotificationStatus, number>;
11
12
 
12
13
  /**
13
14
  * Computes notification delivery status from sent/error timestamps.
@@ -56,6 +57,12 @@ export type NotificationMessage = {
56
57
  status: NotificationStatus;
57
58
  };
58
59
 
60
+ const emptyStatusSummary = (): NotificationStatusSummary => ({
61
+ sent: 0,
62
+ pending: 0,
63
+ error: 0,
64
+ });
65
+
59
66
  type DbNotificationRow = {
60
67
  id: string;
61
68
  type: NotificationType;
@@ -143,23 +150,26 @@ export const sendToUser = async (params: SendToUserParams): Promise<{ ok: true;
143
150
  */
144
151
  export const list = async (
145
152
  pagination: PaginationParams,
146
- options?: { sentBy?: string; isAdmin?: boolean; search?: string },
153
+ options?: { sentBy?: string; isAdmin?: boolean; search?: string; status?: NotificationStatus },
147
154
  ): Promise<{ notifications: NotificationMessage[]; total: number }> => {
148
155
  const { offset, perPage } = pagination;
149
- const { sentBy, isAdmin, search } = options ?? {};
156
+ const { sentBy, isAdmin, search, status } = options ?? {};
150
157
 
151
158
  // Build query based on permissions
152
159
  let countRows: Array<{ count: number | string }> = [];
153
160
  let dataRows: DbNotificationRow[] = [];
154
161
 
155
162
  const searchPattern = search ? `%${escapeLikePattern(search)}%` : null;
163
+ const statusFilter = status ?? null;
156
164
 
157
165
  if (isAdmin) {
158
166
  // Admins see all notifications
159
167
  if (searchPattern) {
160
168
  countRows = await sql`
161
169
  SELECT COUNT(*)::int as count FROM notifications.messages
162
- WHERE subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\'
170
+ WHERE
171
+ (${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
172
+ AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
163
173
  `;
164
174
  dataRows = await sql`
165
175
  SELECT
@@ -168,12 +178,17 @@ export const list = async (
168
178
  u.display_name as sent_by_name
169
179
  FROM notifications.messages m
170
180
  LEFT JOIN auth.users u ON m.sent_by = u.id
171
- WHERE m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\'
181
+ WHERE
182
+ (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
183
+ AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
172
184
  ORDER BY m.created_at DESC
173
185
  LIMIT ${perPage} OFFSET ${offset}
174
186
  `;
175
187
  } else {
176
- countRows = await sql`SELECT COUNT(*)::int as count FROM notifications.messages`;
188
+ countRows = await sql`
189
+ SELECT COUNT(*)::int as count FROM notifications.messages
190
+ WHERE ${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter}
191
+ `;
177
192
  dataRows = await sql`
178
193
  SELECT
179
194
  m.id, m.type, m.recipient, m.subject, m.content,
@@ -181,6 +196,7 @@ export const list = async (
181
196
  u.display_name as sent_by_name
182
197
  FROM notifications.messages m
183
198
  LEFT JOIN auth.users u ON m.sent_by = u.id
199
+ WHERE ${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter}
184
200
  ORDER BY m.created_at DESC
185
201
  LIMIT ${perPage} OFFSET ${offset}
186
202
  `;
@@ -190,7 +206,10 @@ export const list = async (
190
206
  if (searchPattern) {
191
207
  countRows = await sql`
192
208
  SELECT COUNT(*)::int as count FROM notifications.messages
193
- WHERE sent_by = ${sentBy} AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
209
+ WHERE
210
+ sent_by = ${sentBy}
211
+ AND (${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
212
+ AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
194
213
  `;
195
214
  dataRows = await sql`
196
215
  SELECT
@@ -199,12 +218,20 @@ export const list = async (
199
218
  u.display_name as sent_by_name
200
219
  FROM notifications.messages m
201
220
  LEFT JOIN auth.users u ON m.sent_by = u.id
202
- WHERE m.sent_by = ${sentBy} AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
221
+ WHERE
222
+ m.sent_by = ${sentBy}
223
+ AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
224
+ AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
203
225
  ORDER BY m.created_at DESC
204
226
  LIMIT ${perPage} OFFSET ${offset}
205
227
  `;
206
228
  } else {
207
- countRows = await sql`SELECT COUNT(*)::int as count FROM notifications.messages WHERE sent_by = ${sentBy}`;
229
+ countRows = await sql`
230
+ SELECT COUNT(*)::int as count FROM notifications.messages
231
+ WHERE
232
+ sent_by = ${sentBy}
233
+ AND (${statusFilter}::text IS NULL OR CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
234
+ `;
208
235
  dataRows = await sql`
209
236
  SELECT
210
237
  m.id, m.type, m.recipient, m.subject, m.content,
@@ -212,7 +239,9 @@ export const list = async (
212
239
  u.display_name as sent_by_name
213
240
  FROM notifications.messages m
214
241
  LEFT JOIN auth.users u ON m.sent_by = u.id
215
- WHERE m.sent_by = ${sentBy}
242
+ WHERE
243
+ m.sent_by = ${sentBy}
244
+ AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
216
245
  ORDER BY m.created_at DESC
217
246
  LIMIT ${perPage} OFFSET ${offset}
218
247
  `;
@@ -246,6 +275,47 @@ export const list = async (
246
275
  return { notifications, total };
247
276
  };
248
277
 
278
+ /**
279
+ * Count current notification statuses for recent entries.
280
+ */
281
+ export const getStatusSummary = async (options?: {
282
+ sentBy?: string;
283
+ isAdmin?: boolean;
284
+ days?: number;
285
+ }): Promise<NotificationStatusSummary> => {
286
+ const { sentBy, isAdmin, days = 7 } = options ?? {};
287
+ const windowDays = Math.max(1, Math.floor(days));
288
+ const summary = emptyStatusSummary();
289
+
290
+ let rows: Array<{ status: NotificationStatus; count: number | string }> = [];
291
+ if (isAdmin) {
292
+ rows = await sql`
293
+ SELECT
294
+ CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END as status,
295
+ COUNT(*)::int as count
296
+ FROM notifications.messages
297
+ WHERE created_at >= now() - (${windowDays}::int * interval '1 day')
298
+ GROUP BY status
299
+ `;
300
+ } else if (sentBy) {
301
+ rows = await sql`
302
+ SELECT
303
+ CASE WHEN sent_at IS NOT NULL THEN 'sent' WHEN error IS NOT NULL THEN 'error' ELSE 'pending' END as status,
304
+ COUNT(*)::int as count
305
+ FROM notifications.messages
306
+ WHERE sent_by = ${sentBy} AND created_at >= now() - (${windowDays}::int * interval '1 day')
307
+ GROUP BY status
308
+ `;
309
+ }
310
+
311
+ for (const row of rows) {
312
+ const count = typeof row.count === "string" ? Number.parseInt(row.count, 10) : row.count;
313
+ summary[row.status] = Number.isFinite(count) ? count : 0;
314
+ }
315
+
316
+ return summary;
317
+ };
318
+
249
319
  /**
250
320
  * Get a single notification by ID.
251
321
  */
@@ -410,4 +480,5 @@ export const notifications = {
410
480
  update,
411
481
  getPendingSystemCount,
412
482
  sendAllPendingSystem,
483
+ getStatusSummary,
413
484
  };
@@ -0,0 +1,104 @@
1
+ import { sql } from "bun";
2
+ import * as jose from "jose";
3
+ import { accounts } from "./accounts";
4
+ import * as settings from "./settings";
5
+ import { serviceAccounts, type ServiceAccount } from "./service-accounts";
6
+ import type { User } from "../contracts/shared";
7
+
8
+ type DbKey = {
9
+ public_key: string;
10
+ kid: string;
11
+ };
12
+
13
+ const parseScopeClaim = (payload: jose.JWTPayload): string[] => {
14
+ const value = payload.scope;
15
+ if (typeof value !== "string") return [];
16
+ return value
17
+ .split(/\s+/)
18
+ .map((scope) => scope.trim())
19
+ .filter(Boolean);
20
+ };
21
+
22
+ export type AuthenticatedOAuthToken =
23
+ | {
24
+ kind: "user";
25
+ payload: jose.JWTPayload;
26
+ user: User;
27
+ }
28
+ | {
29
+ kind: "service_account";
30
+ payload: jose.JWTPayload;
31
+ serviceAccount: ServiceAccount;
32
+ delegatedUser: User | null;
33
+ scopes: string[];
34
+ };
35
+
36
+ const getIssuer = async (): Promise<string> => {
37
+ const appUrl = await settings.get<string>("app.url");
38
+ return appUrl.startsWith("http") ? appUrl : `https://${appUrl}`;
39
+ };
40
+
41
+ const getCurrentPublicKey = async (): Promise<CryptoKey | null> => {
42
+ const [row] = await sql<DbKey[]>`
43
+ SELECT public_key, kid
44
+ FROM oauth.keys
45
+ WHERE id = 'current'
46
+ `;
47
+ if (!row) return null;
48
+ return jose.importSPKI(row.public_key, "RS256");
49
+ };
50
+
51
+ const getStringClaim = (payload: jose.JWTPayload, key: string): string | null => {
52
+ const value = payload[key];
53
+ return typeof value === "string" && value.length > 0 ? value : null;
54
+ };
55
+
56
+ export const verifyAccessToken = async (token: string): Promise<AuthenticatedOAuthToken | null> => {
57
+ const publicKey = await getCurrentPublicKey();
58
+ if (!publicKey) return null;
59
+
60
+ let payload: jose.JWTPayload;
61
+ try {
62
+ const result = await jose.jwtVerify(token, publicKey, {
63
+ issuer: await getIssuer(),
64
+ audience: "cloud",
65
+ });
66
+ payload = result.payload;
67
+ } catch {
68
+ return null;
69
+ }
70
+
71
+ if (payload.token_use !== "access") return null;
72
+
73
+ const serviceAccountId = getStringClaim(payload, "service_account_id");
74
+ if (serviceAccountId) {
75
+ const serviceAccount = await serviceAccounts.get({ id: serviceAccountId });
76
+ if (!serviceAccount || serviceAccount.status !== "active") return null;
77
+
78
+ const delegatedUser = serviceAccount.delegatedUserId ? await accounts.users.get({ id: serviceAccount.delegatedUserId }) : null;
79
+ if (serviceAccount.kind === "user_delegated" && !delegatedUser) return null;
80
+
81
+ return {
82
+ kind: "service_account",
83
+ payload,
84
+ serviceAccount,
85
+ delegatedUser,
86
+ scopes: parseScopeClaim(payload),
87
+ };
88
+ }
89
+
90
+ const userId = getStringClaim(payload, "id");
91
+ const uid = getStringClaim(payload, "uid") ?? (typeof payload.sub === "string" ? payload.sub : null);
92
+ const user = userId ? await accounts.users.get({ id: userId }) : uid ? await accounts.users.get({ uid }) : null;
93
+ if (!user) return null;
94
+
95
+ return {
96
+ kind: "user",
97
+ payload,
98
+ user,
99
+ };
100
+ };
101
+
102
+ export const oauthTokens = {
103
+ verifyAccessToken,
104
+ };
@@ -35,17 +35,32 @@ export const parsePgJsonRecord = (value: unknown): Record<string, unknown> | nul
35
35
  };
36
36
 
37
37
  /**
38
- * Classify a thrown Postgres error. Bun's sql driver surfaces the canonical
39
- * SQLSTATE on `.code`. Use this at service boundaries to turn
40
- * unique-constraint violations into typed 409 results instead of bubbling up
41
- * raw DB errors to API clients.
38
+ * Classify a thrown Postgres error. Use at service boundaries to turn
39
+ * unique-constraint violations into typed 409 results instead of bubbling
40
+ * up raw DB errors to API clients.
41
+ *
42
+ * Two driver shapes coexist in this repo:
43
+ * - postgres.js: `e.code = "23505"` (the SQLSTATE directly)
44
+ * - bun.sql: `e.code = "ERR_POSTGRES_SERVER_ERROR"`, SQLSTATE on `e.errno`
45
+ *
46
+ * Checking only `e.code` silently fails on Bun (the Wave-1.1 migration
47
+ * idempotence bug had the same root cause). Treat either field carrying
48
+ * "23505" as a unique violation so the helper works regardless of which
49
+ * driver instantiated the error.
42
50
  */
43
- export type PgError = { code?: string; constraint_name?: string; detail?: string; message?: string };
51
+ export type PgError = {
52
+ code?: string;
53
+ errno?: string;
54
+ constraint_name?: string;
55
+ detail?: string;
56
+ message?: string;
57
+ };
44
58
 
45
59
  export const isUniqueViolation = (error: unknown, constraintName?: string): boolean => {
46
60
  if (!error || typeof error !== "object") return false;
47
61
  const e = error as PgError;
48
- if (e.code !== "23505") return false;
62
+ const sqlstate = e.code === "23505" || e.errno === "23505";
63
+ if (!sqlstate) return false;
49
64
  if (!constraintName) return true;
50
65
  return e.constraint_name === constraintName;
51
66
  };
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ consumePasswordResetToken,
4
+ createPasswordResetToken,
5
+ } from "./auth";
6
+
7
+ describe("local auth password reset tokens", () => {
8
+ test("consumes password reset tokens only once", async () => {
9
+ const payload = {
10
+ userId: crypto.randomUUID(),
11
+ uid: `reset-${crypto.randomUUID()}`,
12
+ email: `reset-${crypto.randomUUID()}@example.test`,
13
+ };
14
+ const token = await createPasswordResetToken({
15
+ ...payload,
16
+ ttlSeconds: 30,
17
+ });
18
+
19
+ expect(await consumePasswordResetToken(token)).toEqual(payload);
20
+ expect(await consumePasswordResetToken(token)).toBeNull();
21
+ });
22
+ });
@@ -1,13 +1,56 @@
1
1
  import { redis } from "bun";
2
2
 
3
- export const createMagicLinkToken = async (params: { email: string; ttlSeconds?: number }): Promise<string> => {
3
+ export const createMagicLinkToken = async (params: {
4
+ email: string;
5
+ ttlSeconds?: number;
6
+ }): Promise<string> => {
4
7
  const token = crypto.randomUUID();
5
- await redis.set(`email-login:${token}`, JSON.stringify({ email: params.email }), "EX", params.ttlSeconds ?? 300);
8
+ await redis.set(
9
+ `email-login:${token}`,
10
+ JSON.stringify({ email: params.email }),
11
+ "EX",
12
+ params.ttlSeconds ?? 300
13
+ );
6
14
  return token;
7
15
  };
8
16
 
9
- export const consumeMagicLinkToken = async (token: string): Promise<{ email: string } | null> => {
17
+ export const consumeMagicLinkToken = async (
18
+ token: string
19
+ ): Promise<{ email: string } | null> => {
10
20
  const raw = await redis.getdel(`email-login:${token}`);
11
21
  if (!raw) return null;
12
22
  return JSON.parse(raw) as { email: string };
13
23
  };
24
+
25
+ type PasswordResetPayload = {
26
+ userId: string;
27
+ uid: string;
28
+ email: string;
29
+ };
30
+
31
+ const passwordResetTokenKey = (token: string) => `password-reset:${token}`;
32
+
33
+ export const createPasswordResetToken = async (
34
+ params: PasswordResetPayload & { ttlSeconds?: number }
35
+ ): Promise<string> => {
36
+ const token = crypto.randomUUID();
37
+ await redis.set(
38
+ passwordResetTokenKey(token),
39
+ JSON.stringify({
40
+ userId: params.userId,
41
+ uid: params.uid,
42
+ email: params.email,
43
+ }),
44
+ "EX",
45
+ params.ttlSeconds ?? 900
46
+ );
47
+ return token;
48
+ };
49
+
50
+ export const consumePasswordResetToken = async (
51
+ token: string
52
+ ): Promise<PasswordResetPayload | null> => {
53
+ const raw = await redis.getdel(passwordResetTokenKey(token));
54
+ if (!raw) return null;
55
+ return JSON.parse(raw) as PasswordResetPayload;
56
+ };
@@ -0,0 +1,10 @@
1
+ import { decryptValue, encryptValue } from "./settings/crypto";
2
+
3
+ export const encryptSecret = async (value: unknown): Promise<string> => encryptValue(value);
4
+
5
+ export const decryptSecret = async <T = unknown>(value: string): Promise<T> => (await decryptValue(value)) as T;
6
+
7
+ export const secrets = {
8
+ encrypt: encryptSecret,
9
+ decrypt: decryptSecret,
10
+ };
@@ -0,0 +1,210 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sql } from "bun";
3
+ import { Hono } from "hono";
4
+ import { auth, type AuthContext } from "../server/middleware/auth";
5
+ import { accounts } from "./accounts";
6
+ import { serviceAccountCredentials } from "./service-account-credentials";
7
+ import { serviceAccounts } from "./service-accounts";
8
+
9
+ const canUseDatabase = async () => {
10
+ try {
11
+ const [row] = await sql<{
12
+ users: string | null;
13
+ service_accounts: string | null;
14
+ credentials: string | null;
15
+ audit_events: string | null;
16
+ ipa_effective_groups: string | null;
17
+ }[]>`
18
+ SELECT
19
+ to_regclass('auth.users')::text AS users,
20
+ to_regclass('auth.service_accounts')::text AS service_accounts,
21
+ to_regclass('auth.service_account_credentials')::text AS credentials,
22
+ to_regclass('audit.events')::text AS audit_events,
23
+ to_regclass('auth.ipa_user_effective_groups')::text AS ipa_effective_groups
24
+ `;
25
+ return Boolean(row?.users && row.service_accounts && row.credentials && row.audit_events && row.ipa_effective_groups);
26
+ } catch {
27
+ return false;
28
+ }
29
+ };
30
+
31
+ const insertUser = async () => {
32
+ const suffix = crypto.randomUUID();
33
+ const [row] = await sql<{ id: string }[]>`
34
+ INSERT INTO auth.users (uid, provider, profile, display_name, mail, given_name, sn)
35
+ VALUES (${`api-key-${suffix}`}, 'local', 'user', 'API Key Test', ${`api-key-${suffix}@example.test`}, 'API', 'Key')
36
+ RETURNING id
37
+ `;
38
+ return row!.id;
39
+ };
40
+
41
+ describe("serviceAccountCredentials", () => {
42
+ test("creates, authenticates, lists, and revokes user delegated API keys", async () => {
43
+ if (!(await canUseDatabase())) {
44
+ console.warn("Skipping service account credential DB test: auth/audit tables are not available.");
45
+ return;
46
+ }
47
+
48
+ const userId = await insertUser();
49
+ try {
50
+ const user = await accounts.users.get({ id: userId });
51
+ expect(user).not.toBeNull();
52
+ if (!user) return;
53
+
54
+ const created = await serviceAccountCredentials.createUserApiToken({
55
+ user,
56
+ name: "Test key",
57
+ expiresAt: null,
58
+ });
59
+ expect(created.ok).toBe(true);
60
+ if (!created.ok) return;
61
+ expect(created.data.token).toMatch(/^cld_[0-9a-f]{24}_[0-9a-f]{64}$/);
62
+ expect(created.data.credential.name).toBe("Test key");
63
+
64
+ const authenticated = await serviceAccountCredentials.authenticateApiToken(created.data.token);
65
+ expect(authenticated?.delegatedUser?.id).toBe(user.id);
66
+ expect(authenticated?.serviceAccount.kind).toBe("user_delegated");
67
+
68
+ const app = new Hono<AuthContext>()
69
+ .use(auth.requireRole("authenticated"))
70
+ .get("/me", (c) => c.json({
71
+ actorKind: c.get("actor").kind,
72
+ userId: c.get("user").id,
73
+ accessSubject: c.get("accessSubject"),
74
+ }));
75
+ const response = await app.request("/me", {
76
+ headers: { Authorization: `Bearer ${created.data.token}` },
77
+ });
78
+ expect(response.status).toBe(200);
79
+ expect(await response.json()).toEqual({
80
+ actorKind: "service_account",
81
+ userId: user.id,
82
+ accessSubject: { type: "user", userId: user.id },
83
+ });
84
+
85
+ const listed = await serviceAccountCredentials.listForDelegatedUser({ userId: user.id });
86
+ expect(listed.map((key) => key.id)).toContain(created.data.credential.id);
87
+
88
+ const overview = await serviceAccountCredentials.listOverview({
89
+ filter: { userId: user.id, serviceAccountKind: "user_delegated", credentialStatus: "active" },
90
+ });
91
+ expect(overview.items.map((key) => key.id)).toContain(created.data.credential.id);
92
+ expect(overview.items.find((key) => key.id === created.data.credential.id)?.owner).toMatchObject({
93
+ type: "user",
94
+ userId: user.id,
95
+ });
96
+
97
+ const adminRevoked = await serviceAccountCredentials.revoke({
98
+ credentialId: created.data.credential.id,
99
+ actor: user,
100
+ });
101
+ expect(adminRevoked.ok).toBe(true);
102
+
103
+ const afterAdminRevoke = await serviceAccountCredentials.authenticateApiToken(created.data.token);
104
+ expect(afterAdminRevoke).toBeNull();
105
+
106
+ const second = await serviceAccountCredentials.createUserApiToken({
107
+ user,
108
+ name: "Second test key",
109
+ expiresAt: null,
110
+ });
111
+ expect(second.ok).toBe(true);
112
+ if (!second.ok) return;
113
+
114
+ const revoked = await serviceAccountCredentials.revokeForDelegatedUser({
115
+ credentialId: second.data.credential.id,
116
+ user,
117
+ });
118
+ expect(revoked.ok).toBe(true);
119
+
120
+ const afterRevoke = await serviceAccountCredentials.authenticateApiToken(second.data.token);
121
+ expect(afterRevoke).toBeNull();
122
+ } finally {
123
+ await sql`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
124
+ }
125
+ });
126
+
127
+ test("creates, authenticates, lists, and revokes resource-bound API keys", async () => {
128
+ if (!(await canUseDatabase())) {
129
+ console.warn("Skipping resource service account credential DB test: auth/audit tables are not available.");
130
+ return;
131
+ }
132
+
133
+ const userId = await insertUser();
134
+ const resourceId = crypto.randomUUID();
135
+ let serviceAccountId: string | null = null;
136
+
137
+ try {
138
+ const user = await accounts.users.get({ id: userId });
139
+ expect(user).not.toBeNull();
140
+ if (!user) return;
141
+
142
+ const serviceAccount = await serviceAccounts.getOrCreateResourceBound({
143
+ name: "Test notebook integration",
144
+ appId: "notebooks",
145
+ resourceType: "notebook",
146
+ resourceId,
147
+ createdBy: user.id,
148
+ });
149
+ expect(serviceAccount.ok).toBe(true);
150
+ if (!serviceAccount.ok) return;
151
+ serviceAccountId = serviceAccount.data.id;
152
+
153
+ const sameServiceAccount = await serviceAccounts.getOrCreateResourceBound({
154
+ name: "Ignored duplicate name",
155
+ appId: "notebooks",
156
+ resourceType: "notebook",
157
+ resourceId,
158
+ createdBy: user.id,
159
+ });
160
+ expect(sameServiceAccount.ok).toBe(true);
161
+ expect(sameServiceAccount.ok ? sameServiceAccount.data.id : null).toBe(serviceAccount.data.id);
162
+
163
+ const created = await serviceAccountCredentials.createResourceApiToken({
164
+ serviceAccountId: serviceAccount.data.id,
165
+ actor: user,
166
+ name: "Resource key",
167
+ expiresAt: null,
168
+ });
169
+ expect(created.ok).toBe(true);
170
+ if (!created.ok) return;
171
+ expect(created.data.token).toMatch(/^cld_[0-9a-f]{24}_[0-9a-f]{64}$/);
172
+
173
+ const authenticated = await serviceAccountCredentials.authenticateApiToken(created.data.token);
174
+ expect(authenticated?.delegatedUser).toBeNull();
175
+ expect(authenticated?.serviceAccount).toMatchObject({
176
+ kind: "resource_bound",
177
+ appId: "notebooks",
178
+ resourceType: "notebook",
179
+ resourceId,
180
+ });
181
+
182
+ const overview = await serviceAccountCredentials.listOverview({
183
+ filter: {
184
+ appId: "notebooks",
185
+ resourceType: "notebook",
186
+ resourceId,
187
+ serviceAccountKind: "resource_bound",
188
+ credentialStatus: "active",
189
+ },
190
+ });
191
+ expect(overview.items.map((key) => key.id)).toContain(created.data.credential.id);
192
+ expect(overview.items.find((key) => key.id === created.data.credential.id)?.owner).toEqual({
193
+ type: "resource",
194
+ appId: "notebooks",
195
+ resourceType: "notebook",
196
+ resourceId,
197
+ });
198
+
199
+ const revoked = await serviceAccountCredentials.revoke({
200
+ credentialId: created.data.credential.id,
201
+ actor: user,
202
+ });
203
+ expect(revoked.ok).toBe(true);
204
+ expect(await serviceAccountCredentials.authenticateApiToken(created.data.token)).toBeNull();
205
+ } finally {
206
+ if (serviceAccountId) await sql`DELETE FROM auth.service_accounts WHERE id = ${serviceAccountId}::uuid`;
207
+ await sql`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
208
+ }
209
+ });
210
+ });