@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.
- package/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
+
};
|
package/src/services/postgres.ts
CHANGED
|
@@ -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.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 = {
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
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 (
|
|
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
|
+
});
|