@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.
- package/package.json +18 -8
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +119 -47
- package/src/_internal/runtime-context.ts +1 -0
- 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 +113 -10
- package/src/api/index.ts +15 -25
- 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 +4 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +4 -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 +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- 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/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 +64 -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 +49 -0
- package/src/shared/redirect.ts +52 -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
package/src/api/me.ts
CHANGED
|
@@ -10,17 +10,34 @@ import { auth, jsonResponse, rateLimit, requiresAuth, respond, v, type AuthConte
|
|
|
10
10
|
import { ok } from "@valentinkolb/stdlib";
|
|
11
11
|
import {
|
|
12
12
|
ChangePasswordSchema,
|
|
13
|
+
AccountActivityListResponseSchema,
|
|
13
14
|
CreateAccountRequestSchema,
|
|
15
|
+
CreateUserApiKeyResponseSchema,
|
|
16
|
+
CreateUserApiKeySchema,
|
|
17
|
+
CreateWebAuthnPasskeySchema,
|
|
14
18
|
ErrorResponseSchema,
|
|
19
|
+
ListWebAuthnPasskeysResponseSchema,
|
|
15
20
|
MessageResponseSchema,
|
|
21
|
+
ServiceAccountCredentialSchema,
|
|
16
22
|
UpdateProfileSchema,
|
|
17
23
|
UserSchema,
|
|
24
|
+
WebAuthnPasskeySchema,
|
|
18
25
|
} from "../contracts";
|
|
19
26
|
import {
|
|
20
27
|
accountLifecycle,
|
|
21
28
|
accountsAppService as accountsService,
|
|
29
|
+
audit,
|
|
30
|
+
serviceAccountCredentials,
|
|
31
|
+
webauthn,
|
|
22
32
|
} from "../services";
|
|
23
33
|
|
|
34
|
+
const toAccountsActor = (user: AuthContext["Variables"]["user"]) => ({
|
|
35
|
+
userId: user.id,
|
|
36
|
+
uid: user.uid,
|
|
37
|
+
roles: user.roles,
|
|
38
|
+
provider: user.provider,
|
|
39
|
+
});
|
|
40
|
+
|
|
24
41
|
const ExtendAccountResponseSchema = z.object({
|
|
25
42
|
message: z.string(),
|
|
26
43
|
newExpiry: z.string().datetime().optional(),
|
|
@@ -31,10 +48,42 @@ const AccountRequestResponseSchema = z.object({
|
|
|
31
48
|
message: z.string(),
|
|
32
49
|
});
|
|
33
50
|
|
|
51
|
+
const ListApiKeysResponseSchema = z.object({
|
|
52
|
+
items: z.array(ServiceAccountCredentialSchema),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const AccountActivityQuerySchema = z.object({
|
|
56
|
+
days: z.coerce.number().int().pipe(z.union([z.literal(7), z.literal(30), z.literal(90)])).optional().default(30),
|
|
57
|
+
});
|
|
58
|
+
|
|
34
59
|
const app = new Hono<AuthContext>()
|
|
35
60
|
.use(rateLimit())
|
|
36
61
|
.use(auth.requireRole("authenticated"))
|
|
37
62
|
|
|
63
|
+
.get(
|
|
64
|
+
"/activity",
|
|
65
|
+
describeRoute({
|
|
66
|
+
tags: ["Me"],
|
|
67
|
+
summary: "List current user account activity",
|
|
68
|
+
description: "List safe self-service audit activity for the authenticated account.",
|
|
69
|
+
...requiresAuth,
|
|
70
|
+
responses: {
|
|
71
|
+
200: jsonResponse(AccountActivityListResponseSchema, "Account activity"),
|
|
72
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
v("query", AccountActivityQuerySchema),
|
|
76
|
+
async (c) =>
|
|
77
|
+
respond(c, async () => {
|
|
78
|
+
const page = await audit.listSelfServiceActivity({
|
|
79
|
+
userId: c.get("user").id,
|
|
80
|
+
days: c.req.valid("query").days,
|
|
81
|
+
pagination: { page: 1, perPage: 50 },
|
|
82
|
+
});
|
|
83
|
+
return ok({ items: page.items });
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
|
|
38
87
|
.get(
|
|
39
88
|
"/",
|
|
40
89
|
describeRoute({
|
|
@@ -67,15 +116,162 @@ const app = new Hono<AuthContext>()
|
|
|
67
116
|
async (c) =>
|
|
68
117
|
respond(c, async () => {
|
|
69
118
|
const user = c.get("user");
|
|
70
|
-
const token = c.get("sessionToken");
|
|
71
119
|
const data = c.req.valid("json");
|
|
72
|
-
const
|
|
73
|
-
const result = await accountsService.user.update({ ipaSession, id: user.id, data });
|
|
120
|
+
const result = await accountsService.user.update({ actor: toAccountsActor(user), id: user.id, data });
|
|
74
121
|
if (!result.ok) return result;
|
|
75
122
|
return ok({ message: "Profile updated." });
|
|
76
123
|
}),
|
|
77
124
|
)
|
|
78
125
|
|
|
126
|
+
.get(
|
|
127
|
+
"/passkeys",
|
|
128
|
+
describeRoute({
|
|
129
|
+
tags: ["Me"],
|
|
130
|
+
summary: "List current user passkeys",
|
|
131
|
+
description: "List WebAuthn passkeys registered to the authenticated account.",
|
|
132
|
+
...requiresAuth,
|
|
133
|
+
responses: {
|
|
134
|
+
200: jsonResponse(ListWebAuthnPasskeysResponseSchema, "Passkeys"),
|
|
135
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
async (c) =>
|
|
139
|
+
respond(c, async () => {
|
|
140
|
+
const items = await webauthn.listForUser({ userId: c.get("user").id });
|
|
141
|
+
return ok({ items });
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
.post(
|
|
146
|
+
"/passkeys/registration/start",
|
|
147
|
+
describeRoute({
|
|
148
|
+
tags: ["Me"],
|
|
149
|
+
summary: "Start passkey registration",
|
|
150
|
+
description: "Create WebAuthn registration options for the authenticated account.",
|
|
151
|
+
...requiresAuth,
|
|
152
|
+
responses: {
|
|
153
|
+
200: jsonResponse(z.unknown(), "Passkey registration options"),
|
|
154
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
async (c) => respond(c, webauthn.beginRegistration({ user: c.get("user") })),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
.post(
|
|
161
|
+
"/passkeys/registration/verify",
|
|
162
|
+
describeRoute({
|
|
163
|
+
tags: ["Me"],
|
|
164
|
+
summary: "Verify passkey registration",
|
|
165
|
+
description: "Verify a WebAuthn registration response and store the passkey public credential.",
|
|
166
|
+
...requiresAuth,
|
|
167
|
+
responses: {
|
|
168
|
+
201: jsonResponse(WebAuthnPasskeySchema, "Passkey created"),
|
|
169
|
+
400: jsonResponse(ErrorResponseSchema, "Passkey registration failed"),
|
|
170
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
v("json", CreateWebAuthnPasskeySchema),
|
|
174
|
+
async (c) =>
|
|
175
|
+
respond(c, () => {
|
|
176
|
+
const data = c.req.valid("json");
|
|
177
|
+
return webauthn.finishRegistration({
|
|
178
|
+
user: c.get("user"),
|
|
179
|
+
name: data.name,
|
|
180
|
+
response: data.response as never,
|
|
181
|
+
});
|
|
182
|
+
}, 201),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
.delete(
|
|
186
|
+
"/passkeys/:id",
|
|
187
|
+
describeRoute({
|
|
188
|
+
tags: ["Me"],
|
|
189
|
+
summary: "Delete current user passkey",
|
|
190
|
+
description: "Delete a WebAuthn passkey registered to the authenticated account.",
|
|
191
|
+
...requiresAuth,
|
|
192
|
+
responses: {
|
|
193
|
+
200: jsonResponse(MessageResponseSchema, "Passkey deleted"),
|
|
194
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
195
|
+
404: jsonResponse(ErrorResponseSchema, "Passkey not found"),
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
async (c) =>
|
|
199
|
+
respond(c, async () => {
|
|
200
|
+
const result = await webauthn.deleteForUser({ user: c.get("user"), id: c.req.param("id") });
|
|
201
|
+
if (!result.ok) return result;
|
|
202
|
+
return ok({ message: "Passkey deleted." });
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
.post(
|
|
207
|
+
"/api-keys",
|
|
208
|
+
describeRoute({
|
|
209
|
+
tags: ["Me"],
|
|
210
|
+
summary: "Create current user API key",
|
|
211
|
+
description: "Create a user-bound API key for the authenticated account. The raw token is returned once.",
|
|
212
|
+
...requiresAuth,
|
|
213
|
+
responses: {
|
|
214
|
+
201: jsonResponse(CreateUserApiKeyResponseSchema, "API key created"),
|
|
215
|
+
400: jsonResponse(ErrorResponseSchema, "Failed to create API key"),
|
|
216
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
219
|
+
v("json", CreateUserApiKeySchema),
|
|
220
|
+
async (c) =>
|
|
221
|
+
respond(c, async () => {
|
|
222
|
+
const user = c.get("user");
|
|
223
|
+
const data = c.req.valid("json");
|
|
224
|
+
return serviceAccountCredentials.createUserApiToken({
|
|
225
|
+
user,
|
|
226
|
+
name: data.name,
|
|
227
|
+
expiresAt: data.expiresAt ?? null,
|
|
228
|
+
});
|
|
229
|
+
}, 201),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
.get(
|
|
233
|
+
"/api-keys",
|
|
234
|
+
describeRoute({
|
|
235
|
+
tags: ["Me"],
|
|
236
|
+
summary: "List current user API keys",
|
|
237
|
+
description: "List active user-bound API keys owned by the authenticated account.",
|
|
238
|
+
...requiresAuth,
|
|
239
|
+
responses: {
|
|
240
|
+
200: jsonResponse(ListApiKeysResponseSchema, "API keys"),
|
|
241
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
244
|
+
async (c) =>
|
|
245
|
+
respond(c, async () => {
|
|
246
|
+
const items = await serviceAccountCredentials.listForDelegatedUser({ userId: c.get("user").id });
|
|
247
|
+
return ok({ items });
|
|
248
|
+
}),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
.delete(
|
|
252
|
+
"/api-keys/:id",
|
|
253
|
+
describeRoute({
|
|
254
|
+
tags: ["Me"],
|
|
255
|
+
summary: "Revoke current user API key",
|
|
256
|
+
description: "Revoke an API key owned by the authenticated account.",
|
|
257
|
+
...requiresAuth,
|
|
258
|
+
responses: {
|
|
259
|
+
200: jsonResponse(MessageResponseSchema, "API key revoked"),
|
|
260
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
261
|
+
404: jsonResponse(ErrorResponseSchema, "API key not found"),
|
|
262
|
+
},
|
|
263
|
+
}),
|
|
264
|
+
async (c) =>
|
|
265
|
+
respond(c, async () => {
|
|
266
|
+
const result = await serviceAccountCredentials.revokeForDelegatedUser({
|
|
267
|
+
credentialId: c.req.param("id"),
|
|
268
|
+
user: c.get("user"),
|
|
269
|
+
});
|
|
270
|
+
if (!result.ok) return result;
|
|
271
|
+
return ok({ message: "API key revoked." });
|
|
272
|
+
}),
|
|
273
|
+
)
|
|
274
|
+
|
|
79
275
|
.post(
|
|
80
276
|
"/password",
|
|
81
277
|
describeRoute({
|
|
@@ -118,10 +314,8 @@ const app = new Hono<AuthContext>()
|
|
|
118
314
|
async (c) =>
|
|
119
315
|
respond(c, async () => {
|
|
120
316
|
const user = c.get("user");
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
const result = await accountLifecycle.extendCurrentUserAccount({ user, ipaSession });
|
|
124
|
-
return ok(result);
|
|
317
|
+
const result = await accountLifecycle.extendCurrentUserAccount({ user });
|
|
318
|
+
return result;
|
|
125
319
|
}),
|
|
126
320
|
)
|
|
127
321
|
|
|
@@ -142,12 +336,7 @@ const app = new Hono<AuthContext>()
|
|
|
142
336
|
async (c) =>
|
|
143
337
|
respond(c, async () => {
|
|
144
338
|
const user = c.get("user");
|
|
145
|
-
|
|
146
|
-
return { ok: false, error: "Only guest accounts can be self-deleted.", status: 403 };
|
|
147
|
-
}
|
|
148
|
-
const token = c.get("sessionToken");
|
|
149
|
-
const ipaSession = user.provider === "ipa" ? await auth.session.getIpaSession(token) : null;
|
|
150
|
-
const result = await accountsService.user.removeSelf({ user, ipaSession });
|
|
339
|
+
const result = await accountsService.user.removeSelf({ user });
|
|
151
340
|
if (!result.ok) return result;
|
|
152
341
|
await auth.session.delete(c);
|
|
153
342
|
return ok({ message: "Account deleted." });
|
|
@@ -197,7 +386,7 @@ const app = new Hono<AuthContext>()
|
|
|
197
386
|
if (!pending) {
|
|
198
387
|
return { ok: false, error: "No pending request", status: 404 };
|
|
199
388
|
}
|
|
200
|
-
const result = await accountsService.accountRequest.withdraw({ id: pending.id,
|
|
389
|
+
const result = await accountsService.accountRequest.withdraw({ id: pending.id, actor: toAccountsActor(user) });
|
|
201
390
|
if (!result.ok) return result;
|
|
202
391
|
return ok({ message: "Request withdrawn" });
|
|
203
392
|
}),
|
|
@@ -37,6 +37,7 @@ export const SearchResponseSchema = z.object({
|
|
|
37
37
|
query: z.string(),
|
|
38
38
|
count: z.number().int().nonnegative(),
|
|
39
39
|
items: z.array(SearchItemSchema),
|
|
40
|
+
unsupportedTags: z.array(z.string()).optional(),
|
|
40
41
|
});
|
|
41
42
|
|
|
42
43
|
export type SearchItem = z.infer<typeof SearchItemSchema>;
|
package/src/api/search.ts
CHANGED
|
@@ -8,11 +8,18 @@ import { SearchItemSchema, SearchQuerySchema, SearchResponseSchema, type SearchI
|
|
|
8
8
|
|
|
9
9
|
const log = logger("search");
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Maximum items returned to the client after merging across providers.
|
|
13
|
+
* The frontend has no further limit — this caps the rendered list.
|
|
14
|
+
*/
|
|
15
|
+
const GLOBAL_RESULT_LIMIT = 30;
|
|
16
|
+
|
|
11
17
|
type HttpSearchProvider = {
|
|
12
18
|
appId: string;
|
|
13
19
|
appName: string;
|
|
14
20
|
appIcon: string;
|
|
15
21
|
endpoint: string;
|
|
22
|
+
tags: string[];
|
|
16
23
|
};
|
|
17
24
|
|
|
18
25
|
/**
|
|
@@ -28,6 +35,7 @@ const getSearchProviders = async (): Promise<HttpSearchProvider[]> => {
|
|
|
28
35
|
appName: e.name,
|
|
29
36
|
appIcon: e.icon,
|
|
30
37
|
endpoint: e.search!.endpoint,
|
|
38
|
+
tags: [...e.search!.tags],
|
|
31
39
|
}));
|
|
32
40
|
};
|
|
33
41
|
|
|
@@ -50,26 +58,69 @@ export const createSearchRoutes = () =>
|
|
|
50
58
|
200: jsonResponse(SearchResponseSchema, "Merged search results"),
|
|
51
59
|
400: jsonResponse(ErrorResponseSchema, "Invalid query"),
|
|
52
60
|
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
61
|
+
403: jsonResponse(ErrorResponseSchema, "User-backed actor required"),
|
|
53
62
|
},
|
|
54
63
|
}),
|
|
55
64
|
v("query", SearchQuerySchema),
|
|
56
65
|
async (c) => {
|
|
66
|
+
if (!c.get("user")) {
|
|
67
|
+
return c.json({ message: "Global search requires a user-backed actor", code: "FORBIDDEN" }, 403);
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
const query = c.req.valid("query");
|
|
58
71
|
const providers = await getSearchProviders();
|
|
72
|
+
const cookie = c.req.raw.headers.get("Cookie") ?? "";
|
|
73
|
+
const authorization = c.req.raw.headers.get("Authorization");
|
|
74
|
+
|
|
75
|
+
// Pre-filter providers by tag overlap. With no tags, every provider
|
|
76
|
+
// runs (text-only search). With tags, only providers that own at least
|
|
77
|
+
// one requested tag participate — saves fanout to apps that can't
|
|
78
|
+
// contribute. Tags the user typed that no provider declares are
|
|
79
|
+
// returned to the client so it can render a helpful empty state.
|
|
80
|
+
const knownTags = new Set(providers.flatMap((p) => p.tags));
|
|
81
|
+
const unsupportedTags = query.tag.filter((t) => !knownTags.has(t));
|
|
82
|
+
const active =
|
|
83
|
+
query.tag.length === 0
|
|
84
|
+
? providers
|
|
85
|
+
: providers.filter((p) => p.tags.some((t) => query.tag.includes(t)));
|
|
86
|
+
|
|
87
|
+
if (query.tag.length > 0 && active.length === 0) {
|
|
88
|
+
return c.json({
|
|
89
|
+
query: query.q,
|
|
90
|
+
count: 0,
|
|
91
|
+
items: [],
|
|
92
|
+
unsupportedTags,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Single-provider queries get a larger sample for better local
|
|
97
|
+
// ranking — the global slice below still caps the response. Capped
|
|
98
|
+
// at GLOBAL_RESULT_LIMIT so a single app can saturate the response
|
|
99
|
+
// but no more.
|
|
100
|
+
const effectiveProviderLimit =
|
|
101
|
+
active.length === 1
|
|
102
|
+
? Math.min(GLOBAL_RESULT_LIMIT, query.provider_limit * 3)
|
|
103
|
+
: query.provider_limit;
|
|
59
104
|
|
|
60
105
|
const settled = await Promise.allSettled(
|
|
61
|
-
|
|
106
|
+
active.map(async (provider) => {
|
|
107
|
+
// Scope tags to those this provider declared. Apps no longer need
|
|
108
|
+
// their own gate — the framework guarantees they only see tags
|
|
109
|
+
// they understand.
|
|
110
|
+
const scopedTags = query.tag.filter((t) => provider.tags.includes(t));
|
|
111
|
+
|
|
62
112
|
const res = await fetch(provider.endpoint, {
|
|
63
113
|
method: "POST",
|
|
64
114
|
headers: {
|
|
65
115
|
"Content-Type": "application/json",
|
|
66
|
-
// Forward the
|
|
67
|
-
Cookie:
|
|
116
|
+
// Forward the authenticated user context to app search providers.
|
|
117
|
+
Cookie: cookie,
|
|
118
|
+
...(authorization ? { Authorization: authorization } : {}),
|
|
68
119
|
},
|
|
69
120
|
body: JSON.stringify({
|
|
70
121
|
query: query.q,
|
|
71
|
-
tags:
|
|
72
|
-
limit:
|
|
122
|
+
tags: scopedTags,
|
|
123
|
+
limit: effectiveProviderLimit,
|
|
73
124
|
}),
|
|
74
125
|
});
|
|
75
126
|
|
|
@@ -106,7 +157,7 @@ export const createSearchRoutes = () =>
|
|
|
106
157
|
if (result.status === "fulfilled") return result.value;
|
|
107
158
|
|
|
108
159
|
log.warn("Search provider failed", {
|
|
109
|
-
appId:
|
|
160
|
+
appId: active[index]?.appId ?? "unknown",
|
|
110
161
|
tags: query.tag,
|
|
111
162
|
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
112
163
|
});
|
|
@@ -119,10 +170,13 @@ export const createSearchRoutes = () =>
|
|
|
119
170
|
return a.title.localeCompare(b.title);
|
|
120
171
|
});
|
|
121
172
|
|
|
173
|
+
const sliced = items.slice(0, GLOBAL_RESULT_LIMIT);
|
|
174
|
+
|
|
122
175
|
return c.json({
|
|
123
176
|
query: query.q,
|
|
124
|
-
count:
|
|
125
|
-
items,
|
|
177
|
+
count: sliced.length,
|
|
178
|
+
items: sliced,
|
|
179
|
+
...(unsupportedTags.length > 0 ? { unsupportedTags } : {}),
|
|
126
180
|
});
|
|
127
181
|
},
|
|
128
182
|
);
|
package/src/config/ssr.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { createSSRHandler } from "@valentinkolb/ssr/hono";
|
|
|
7
7
|
import { env } from "./env";
|
|
8
8
|
import { dirname, resolve } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { themeBootstrapScript } from "../shared/theme";
|
|
10
11
|
|
|
11
12
|
/** Cache-busting version stamp — changes on every server start / rebuild. */
|
|
12
13
|
const v = Date.now();
|
|
@@ -37,15 +38,7 @@ export const { config, plugin, html } = createConfig<PageOptions>({
|
|
|
37
38
|
<link rel="icon" href="/branding/favicon">
|
|
38
39
|
<link rel="stylesheet" href="/public/build.css?v=${v}">
|
|
39
40
|
<link rel="stylesheet" href="/public/katex.css?v=${v}">
|
|
40
|
-
<script>
|
|
41
|
-
(function() {
|
|
42
|
-
var el = document.documentElement;
|
|
43
|
-
if (!el.hasAttribute('data-theme-fixed')) {
|
|
44
|
-
var theme = document.cookie.match(/theme=([^;]+)/)?.[1] || 'light';
|
|
45
|
-
el.classList.add(theme);
|
|
46
|
-
}
|
|
47
|
-
})();
|
|
48
|
-
</script>
|
|
41
|
+
<script>${themeBootstrapScript}</script>
|
|
49
42
|
</head>
|
|
50
43
|
<body>
|
|
51
44
|
${body}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ANNOUNCEMENTS_COOKIE,
|
|
4
|
+
mergeAnnouncementCookieState,
|
|
5
|
+
parseAnnouncementCookieHeader,
|
|
6
|
+
parseAnnouncementCookieValue,
|
|
7
|
+
serializeAnnouncementCookieState,
|
|
8
|
+
} from "./announcements";
|
|
9
|
+
|
|
10
|
+
describe("announcement cookie state", () => {
|
|
11
|
+
test("returns defaults for empty or malformed cookies", () => {
|
|
12
|
+
expect(parseAnnouncementCookieValue(null)).toEqual({ seenAnnouncementVersion: 0, dismissedBannerVersions: [] });
|
|
13
|
+
expect(parseAnnouncementCookieValue("%7Bbad")).toEqual({ seenAnnouncementVersion: 0, dismissedBannerVersions: [] });
|
|
14
|
+
expect(parseAnnouncementCookieHeader("theme=dark")).toEqual({ seenAnnouncementVersion: 0, dismissedBannerVersions: [] });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("parses state from a cookie header", () => {
|
|
18
|
+
const value = serializeAnnouncementCookieState({
|
|
19
|
+
seenAnnouncementVersion: 12,
|
|
20
|
+
dismissedBannerVersions: [3, 2, 3],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(parseAnnouncementCookieHeader(`theme=dark; ${ANNOUNCEMENTS_COOKIE}=${value}; other=1`)).toEqual({
|
|
24
|
+
seenAnnouncementVersion: 12,
|
|
25
|
+
dismissedBannerVersions: [3, 2],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("merges state monotonically", () => {
|
|
30
|
+
expect(
|
|
31
|
+
mergeAnnouncementCookieState(
|
|
32
|
+
{ seenAnnouncementVersion: 10, dismissedBannerVersions: [4] },
|
|
33
|
+
{ seenAnnouncementVersion: 8, dismissedBannerVersions: [5, 4] },
|
|
34
|
+
),
|
|
35
|
+
).toEqual({ seenAnnouncementVersion: 10, dismissedBannerVersions: [5, 4] });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const ANNOUNCEMENTS_COOKIE = "cloud_announcements";
|
|
4
|
+
export const ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
|
5
|
+
export const MAX_DISMISSED_BANNER_VERSIONS = 50;
|
|
6
|
+
|
|
7
|
+
export const AnnouncementKindSchema = z.enum(["announcement", "banner"]);
|
|
8
|
+
export type AnnouncementKind = z.infer<typeof AnnouncementKindSchema>;
|
|
9
|
+
|
|
10
|
+
export const AnnouncementToneSchema = z.enum(["info", "success", "warning", "danger"]);
|
|
11
|
+
export type AnnouncementTone = z.infer<typeof AnnouncementToneSchema>;
|
|
12
|
+
|
|
13
|
+
export const AnnouncementCookieStateSchema = z.object({
|
|
14
|
+
seenAnnouncementVersion: z.number().int().nonnegative().default(0),
|
|
15
|
+
dismissedBannerVersions: z.array(z.number().int().positive()).default([]),
|
|
16
|
+
});
|
|
17
|
+
export type AnnouncementCookieState = z.infer<typeof AnnouncementCookieStateSchema>;
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_ANNOUNCEMENT_COOKIE_STATE: AnnouncementCookieState = {
|
|
20
|
+
seenAnnouncementVersion: 0,
|
|
21
|
+
dismissedBannerVersions: [],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const AnnouncementEntrySchema = z.object({
|
|
25
|
+
id: z.uuid(),
|
|
26
|
+
version: z.number().int().positive(),
|
|
27
|
+
kind: AnnouncementKindSchema,
|
|
28
|
+
title: z.string(),
|
|
29
|
+
body: z.string(),
|
|
30
|
+
tone: AnnouncementToneSchema,
|
|
31
|
+
publishedAt: z.string().datetime(),
|
|
32
|
+
expiresAt: z.string().datetime().nullable(),
|
|
33
|
+
createdAt: z.string().datetime(),
|
|
34
|
+
updatedAt: z.string().datetime(),
|
|
35
|
+
createdBy: z.uuid().nullable(),
|
|
36
|
+
updatedBy: z.uuid().nullable(),
|
|
37
|
+
});
|
|
38
|
+
export type AnnouncementEntry = z.infer<typeof AnnouncementEntrySchema>;
|
|
39
|
+
|
|
40
|
+
export const AnnouncementDisplayEntrySchema = AnnouncementEntrySchema.omit({ body: true }).extend({
|
|
41
|
+
bodyHtml: z.string(),
|
|
42
|
+
});
|
|
43
|
+
export type AnnouncementDisplayEntry = z.infer<typeof AnnouncementDisplayEntrySchema>;
|
|
44
|
+
|
|
45
|
+
const DatetimeInputSchema = z.string().datetime();
|
|
46
|
+
const NullableDatetimeInputSchema = z.string().datetime().nullable();
|
|
47
|
+
|
|
48
|
+
export const CreateAnnouncementSchema = z.object({
|
|
49
|
+
kind: AnnouncementKindSchema,
|
|
50
|
+
title: z.string().trim().min(1).max(180),
|
|
51
|
+
body: z.string().trim().min(1).max(20_000),
|
|
52
|
+
tone: AnnouncementToneSchema.default("info"),
|
|
53
|
+
publishedAt: DatetimeInputSchema.optional(),
|
|
54
|
+
expiresAt: NullableDatetimeInputSchema.optional(),
|
|
55
|
+
});
|
|
56
|
+
export type CreateAnnouncement = z.infer<typeof CreateAnnouncementSchema>;
|
|
57
|
+
|
|
58
|
+
export const UpdateAnnouncementSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
kind: AnnouncementKindSchema.optional(),
|
|
61
|
+
title: z.string().trim().min(1).max(180).optional(),
|
|
62
|
+
body: z.string().trim().min(1).max(20_000).optional(),
|
|
63
|
+
tone: AnnouncementToneSchema.optional(),
|
|
64
|
+
publishedAt: DatetimeInputSchema.optional(),
|
|
65
|
+
expiresAt: NullableDatetimeInputSchema.optional(),
|
|
66
|
+
})
|
|
67
|
+
.refine((value) => Object.keys(value).length > 0, "Provide at least one field to update.");
|
|
68
|
+
export type UpdateAnnouncement = z.infer<typeof UpdateAnnouncementSchema>;
|
|
69
|
+
|
|
70
|
+
export const AnnouncementListResponseSchema = z.object({
|
|
71
|
+
items: z.array(AnnouncementEntrySchema),
|
|
72
|
+
});
|
|
73
|
+
export type AnnouncementListResponse = z.infer<typeof AnnouncementListResponseSchema>;
|
|
74
|
+
|
|
75
|
+
export const ActiveAnnouncementsResponseSchema = z.object({
|
|
76
|
+
banners: z.array(AnnouncementDisplayEntrySchema),
|
|
77
|
+
announcements: z.array(AnnouncementDisplayEntrySchema),
|
|
78
|
+
latestAnnouncementVersion: z.number().int().nonnegative(),
|
|
79
|
+
});
|
|
80
|
+
export type ActiveAnnouncementsResponse = z.infer<typeof ActiveAnnouncementsResponseSchema>;
|
|
81
|
+
|
|
82
|
+
const normalizeCookieState = (value: unknown): AnnouncementCookieState => {
|
|
83
|
+
const parsed = AnnouncementCookieStateSchema.safeParse(value);
|
|
84
|
+
if (!parsed.success) return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
|
|
85
|
+
const dismissed = [...new Set(parsed.data.dismissedBannerVersions)]
|
|
86
|
+
.filter((version) => Number.isInteger(version) && version > 0)
|
|
87
|
+
.sort((a, b) => b - a)
|
|
88
|
+
.slice(0, MAX_DISMISSED_BANNER_VERSIONS);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
seenAnnouncementVersion: Math.max(0, parsed.data.seenAnnouncementVersion),
|
|
92
|
+
dismissedBannerVersions: dismissed,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const parseAnnouncementCookieValue = (value: string | null | undefined): AnnouncementCookieState => {
|
|
97
|
+
if (!value) return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
|
|
98
|
+
try {
|
|
99
|
+
return normalizeCookieState(JSON.parse(decodeURIComponent(value)));
|
|
100
|
+
} catch {
|
|
101
|
+
return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const parseAnnouncementCookieHeader = (cookieHeader: string | null | undefined): AnnouncementCookieState => {
|
|
106
|
+
if (!cookieHeader) return DEFAULT_ANNOUNCEMENT_COOKIE_STATE;
|
|
107
|
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${ANNOUNCEMENTS_COOKIE}=([^;]+)`));
|
|
108
|
+
return parseAnnouncementCookieValue(match?.[1]);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const serializeAnnouncementCookieState = (state: AnnouncementCookieState): string =>
|
|
112
|
+
encodeURIComponent(JSON.stringify(normalizeCookieState(state)));
|
|
113
|
+
|
|
114
|
+
export const mergeAnnouncementCookieState = (
|
|
115
|
+
current: AnnouncementCookieState,
|
|
116
|
+
patch: Partial<AnnouncementCookieState>,
|
|
117
|
+
): AnnouncementCookieState =>
|
|
118
|
+
normalizeCookieState({
|
|
119
|
+
seenAnnouncementVersion: Math.max(current.seenAnnouncementVersion, patch.seenAnnouncementVersion ?? 0),
|
|
120
|
+
dismissedBannerVersions: [...current.dismissedBannerVersions, ...(patch.dismissedBannerVersions ?? [])],
|
|
121
|
+
});
|
package/src/contracts/app.ts
CHANGED
|
@@ -43,6 +43,10 @@ export type AppMeta = {
|
|
|
43
43
|
* silently skip rendering for the current user.
|
|
44
44
|
*/
|
|
45
45
|
widgets?: WidgetEndpoint[];
|
|
46
|
+
/** Setting keys declared by this app. Used by admin tooling to protect active app-owned settings. */
|
|
47
|
+
settingKeys?: readonly string[];
|
|
48
|
+
/** Gateway-relative URL where this app's OpenAPI JSON is served, or undefined. */
|
|
49
|
+
openapi?: string;
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
export type WidgetEndpoint = {
|
package/src/contracts/index.ts
CHANGED
|
@@ -47,4 +47,8 @@ export type AppRegistryEntry = {
|
|
|
47
47
|
search?: AppRegistrySearch;
|
|
48
48
|
legalLinks?: AppRegistryLegalLink[];
|
|
49
49
|
widgets?: AppRegistryWidget[];
|
|
50
|
+
/** Setting keys declared by this app. Used by admin tooling to avoid treating live app-owned settings as legacy. */
|
|
51
|
+
settingKeys?: readonly string[];
|
|
52
|
+
/** Gateway-relative URL where this app serves its OpenAPI JSON spec. */
|
|
53
|
+
openapi?: string;
|
|
50
54
|
};
|