@valentinkolb/cloud 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -1 +1 @@
1
- export { api, respond } from "./respond";
1
+ export { api, respond, respondMessage } from "./respond";
@@ -1,9 +1,16 @@
1
- import type { Context } from "hono";
2
- import { isServiceError, type Result } from "@valentinkolb/stdlib";
1
+ import { isServiceError, ok, type Result, type ServiceError } from "@valentinkolb/stdlib";
2
+ import type { Context, TypedResponse } from "hono";
3
+ import type { StatusCode } from "hono/utils/http-status";
3
4
 
4
- type LegacyResult<T = void> = { ok: true; data: T } | { ok: false; error: string; status: number };
5
+ type LegacyErrorStatus = ServiceError["status"] | 413;
6
+ type LegacyErrorResult<S extends number = number> = { ok: false; error: string; status: S };
7
+ type LegacyResult<T = void> = { ok: true; data: T } | LegacyErrorResult;
5
8
 
6
9
  type AnyResult<T = unknown> = Result<T> | LegacyResult<T>;
10
+ type ResultOrFn<T> = T | Promise<T> | (() => T | Promise<T>);
11
+ type SuccessStatus = 200 | 201;
12
+ type ErrorStatus = ServiceError["status"] | LegacyErrorStatus;
13
+ type JsonTypedResponse<T, Status extends StatusCode> = TypedResponse<T, Status, "json">;
7
14
 
8
15
  type ErrorResponseBody = {
9
16
  message: string;
@@ -35,21 +42,54 @@ const toErrorResponse = (result: AnyResult): [ErrorResponseBody, number] => {
35
42
  return [{ message: "Internal server error", code: "INTERNAL" }, 500];
36
43
  };
37
44
 
38
- export const respond = async <T>(
45
+ export async function respond<E extends ServiceError>(
39
46
  c: Context,
40
- resultOrFn: AnyResult<T> | Promise<AnyResult<T>> | (() => AnyResult<T> | Promise<AnyResult<T>>),
41
- successStatus = 200,
42
- ) => {
47
+ resultOrFn: ResultOrFn<Result<never, E>>,
48
+ successStatus?: SuccessStatus,
49
+ ): Promise<JsonTypedResponse<ErrorResponseBody, E["status"]>>;
50
+ export async function respond<T>(
51
+ c: Context,
52
+ resultOrFn: ResultOrFn<Result<T, never>>,
53
+ successStatus?: SuccessStatus,
54
+ ): Promise<JsonTypedResponse<T, SuccessStatus>>;
55
+ export async function respond<T, E extends ServiceError>(
56
+ c: Context,
57
+ resultOrFn: ResultOrFn<Result<T, E>>,
58
+ successStatus?: SuccessStatus,
59
+ ): Promise<JsonTypedResponse<T, SuccessStatus> | JsonTypedResponse<ErrorResponseBody, E["status"]>>;
60
+ export async function respond<S extends LegacyErrorStatus>(
61
+ c: Context,
62
+ resultOrFn: ResultOrFn<LegacyErrorResult<S>>,
63
+ successStatus?: SuccessStatus,
64
+ ): Promise<JsonTypedResponse<ErrorResponseBody, S>>;
65
+ export async function respond<T>(
66
+ c: Context,
67
+ resultOrFn: ResultOrFn<AnyResult<T>>,
68
+ successStatus?: SuccessStatus,
69
+ ): Promise<JsonTypedResponse<T, SuccessStatus> | JsonTypedResponse<ErrorResponseBody, ErrorStatus>>;
70
+ export async function respond<T>(
71
+ c: Context,
72
+ resultOrFn: ResultOrFn<AnyResult<T>>,
73
+ successStatus: SuccessStatus = 200,
74
+ ): Promise<JsonTypedResponse<T, SuccessStatus> | JsonTypedResponse<ErrorResponseBody, ErrorStatus>> {
43
75
  const result = typeof resultOrFn === "function" ? await resultOrFn() : await resultOrFn;
44
76
 
45
77
  if (!result.ok) {
46
78
  const [body, status] = toErrorResponse(result);
47
- return c.json(body, status as 400 | 401 | 403 | 404 | 409 | 500);
79
+ return c.json(body, status as 400 | 401 | 403 | 404 | 409 | 413 | 500) as JsonTypedResponse<ErrorResponseBody, ErrorStatus>;
48
80
  }
49
81
 
50
- return c.json(result.data, successStatus as 200 | 201);
51
- };
82
+ return c.json(result.data, successStatus as 200 | 201) as JsonTypedResponse<T, SuccessStatus>;
83
+ }
84
+
85
+ export const respondMessage = (c: Context, resultPromise: Promise<Result<void>>, message: string) =>
86
+ respond(c, async () => {
87
+ const result = await resultPromise;
88
+ if (!result.ok) return result;
89
+ return ok({ message });
90
+ });
52
91
 
53
92
  export const api = {
54
93
  respond,
94
+ respondMessage,
55
95
  } as const;
@@ -1,62 +1,68 @@
1
- export { api, respond } from "./api";
2
- export { api as apiClient } from "./api-client";
1
+ export { api, respond, respondMessage } from "./api";
3
2
  export type { CreateApiClientConfig } from "./api-client";
4
-
3
+ export { api as apiClient } from "./api-client";
4
+ export type { AppContext } from "./app-context";
5
+ export type { AuthContext, RateLimitConfig, RateLimitRouteOverride } from "./middleware";
5
6
  export {
6
- middleware,
7
7
  auth,
8
- jsonResponse,
9
8
  imageResponse,
9
+ jsonResponse,
10
+ middleware,
10
11
  openApiMeta,
11
- requiresAuth,
12
+ rateLimit,
13
+ requestLogger,
12
14
  requiresAdmin,
15
+ requiresAuth,
13
16
  requiresIpa,
14
17
  requiresIpaUser,
15
18
  requiresUser,
16
- rateLimit,
17
- requestLogger,
18
- validator,
19
19
  v,
20
+ validator,
20
21
  } from "./middleware";
21
- export type { AuthContext, RateLimitConfig, RateLimitRouteOverride } from "./middleware";
22
- export type { AppContext } from "./app-context";
22
+ export type {
23
+ AccessEntry,
24
+ AccessSubject,
25
+ AccessUser,
26
+ AccessUserSource,
27
+ GeoPlace,
28
+ GeoService,
29
+ PageParams,
30
+ Paginated,
31
+ PermissionLevel,
32
+ Principal,
33
+ PrincipalType,
34
+ ResourceAccessAdapter,
35
+ Result,
36
+ ServiceError,
37
+ ServiceErrorCode,
38
+ } from "./services";
39
+ export type { RequestActor, ServiceAccountRequestActor, UserRequestActor } from "./middleware";
23
40
 
24
41
  export {
25
- services,
42
+ createAccess,
43
+ deleteAccess,
44
+ err,
45
+ fail,
26
46
  freeipa,
27
- images,
28
- password,
29
47
  generatePassword,
30
48
  geo,
31
49
  geoService,
32
- PERMISSION_LEVELS,
33
- hasPermission,
34
- createAccess,
35
50
  getAccess,
36
- updateAccess,
37
- deleteAccess,
38
51
  getEffectivePermission,
39
- resolveDisplayNames,
52
+ hasPermission,
53
+ images,
54
+ isServiceError,
55
+ listUsersWithAccess,
40
56
  ok,
41
57
  okMany,
42
- fail,
43
- err,
44
- unwrap,
58
+ PERMISSION_LEVELS,
45
59
  paginate,
60
+ paginateItems,
61
+ password,
62
+ resolveDisplayNames,
63
+ services,
46
64
  tryCatch,
47
- isServiceError,
48
- } from "./services";
49
- export type {
50
- AccessEntry,
51
- PermissionLevel,
52
- PrincipalType,
53
- Principal,
54
- ResourceAccessAdapter,
55
- GeoService,
56
- GeoPlace,
57
- Result,
58
- Paginated,
59
- PageParams,
60
- ServiceError,
61
- ServiceErrorCode,
65
+ unwrap,
66
+ updateAccess,
62
67
  } from "./services";
68
+ export { getDateConfig, getTimeZone, TIMEZONE_COOKIE, time } from "./time";
@@ -2,17 +2,45 @@ import type { Context } from "hono";
2
2
  import { createMiddleware } from "hono/factory";
3
3
  import type { MessageResponse, Role, RoleOrSpecial, User, UserProfile, UserProvider } from "../../contracts/shared";
4
4
  import { accounts } from "../../services/accounts";
5
+ import { oauthTokens } from "../../services/oauth-tokens";
5
6
  import { session } from "../../services/session";
7
+ import { serviceAccountCredentials } from "../../services/service-account-credentials";
8
+ import { createLoginRedirectUrl } from "../../shared/redirect";
9
+ import type { ServiceAccount } from "../../services/service-accounts";
10
+ import type { AccessSubject } from "../services/access";
6
11
 
7
12
  // ==========================
8
13
  // Types
9
14
  // ==========================
10
15
 
16
+ export type UserRequestActor = {
17
+ kind: "user";
18
+ user: User;
19
+ };
20
+
21
+ export type ServiceAccountRequestActor =
22
+ | {
23
+ kind: "service_account";
24
+ serviceAccount: ServiceAccount;
25
+ delegatedUser: User;
26
+ scopes: string[];
27
+ }
28
+ | {
29
+ kind: "service_account";
30
+ serviceAccount: ServiceAccount;
31
+ delegatedUser: null;
32
+ scopes: string[];
33
+ };
34
+
35
+ export type RequestActor = UserRequestActor | ServiceAccountRequestActor;
36
+
11
37
  /** Hono context with authenticated user variables. */
12
38
  export type AuthContext = {
13
39
  Variables: {
40
+ actor: RequestActor;
41
+ accessSubject: AccessSubject;
14
42
  user: User;
15
- sessionToken: string;
43
+ sessionToken?: string;
16
44
  };
17
45
  };
18
46
 
@@ -45,17 +73,74 @@ const handleReject = (c: Context, options: RoleOptions, reason: "unauthenticated
45
73
  return c.json({ message: "Insufficient permissions" } as MessageResponse, 403);
46
74
  };
47
75
 
48
- const loadSessionUser = async (c: Context<AuthContext>): Promise<{ token: string | null; user: User | null }> => {
76
+ const loadAuthenticatedActor = async (c: Context<AuthContext>): Promise<{
77
+ token: string | null;
78
+ user: User | null;
79
+ actor: RequestActor | null;
80
+ }> => {
49
81
  const token = session.getToken(c);
50
82
  const data = token ? await session.getData(token) : null;
51
83
  const user = data ? await accounts.users.get({ id: data.userId }) : null;
52
84
 
53
85
  if (user && token) {
86
+ c.set("actor", { kind: "user", user });
87
+ c.set("accessSubject", { type: "user", userId: user.id });
54
88
  c.set("user", user);
55
89
  c.set("sessionToken", token);
56
90
  }
57
91
 
58
- return { token, user };
92
+ if (user) return { token, user, actor: { kind: "user", user } };
93
+
94
+ const bearer = session.getBearerToken(c);
95
+ if (bearer && serviceAccountCredentials.isApiToken(bearer)) {
96
+ const authResult = await serviceAccountCredentials.authenticateApiToken(bearer);
97
+ if (!authResult) return { token: null, user: null, actor: null };
98
+
99
+ const actor: RequestActor = {
100
+ kind: "service_account",
101
+ serviceAccount: authResult.serviceAccount,
102
+ delegatedUser: authResult.delegatedUser,
103
+ scopes: authResult.credential.scopes,
104
+ };
105
+ c.set("actor", actor);
106
+ if (authResult.delegatedUser) {
107
+ c.set("accessSubject", { type: "user", userId: authResult.delegatedUser.id });
108
+ c.set("user", authResult.delegatedUser);
109
+ } else {
110
+ c.set("accessSubject", { type: "service_account", serviceAccountId: authResult.serviceAccount.id });
111
+ }
112
+ return { token: null, user: authResult.delegatedUser, actor };
113
+ }
114
+
115
+ if (bearer) {
116
+ const authResult = await oauthTokens.verifyAccessToken(bearer);
117
+ if (!authResult) return { token: null, user: null, actor: null };
118
+
119
+ if (authResult.kind === "user") {
120
+ const actor: RequestActor = { kind: "user", user: authResult.user };
121
+ c.set("actor", actor);
122
+ c.set("accessSubject", { type: "user", userId: authResult.user.id });
123
+ c.set("user", authResult.user);
124
+ return { token: null, user: authResult.user, actor };
125
+ }
126
+
127
+ const actor: RequestActor = {
128
+ kind: "service_account",
129
+ serviceAccount: authResult.serviceAccount,
130
+ delegatedUser: authResult.delegatedUser,
131
+ scopes: authResult.scopes,
132
+ };
133
+ c.set("actor", actor);
134
+ if (authResult.delegatedUser) {
135
+ c.set("accessSubject", { type: "user", userId: authResult.delegatedUser.id });
136
+ c.set("user", authResult.delegatedUser);
137
+ } else {
138
+ c.set("accessSubject", { type: "service_account", serviceAccountId: authResult.serviceAccount.id });
139
+ }
140
+ return { token: null, user: authResult.delegatedUser, actor };
141
+ }
142
+
143
+ return { token: null, user: null, actor: null };
59
144
  };
60
145
 
61
146
  /**
@@ -92,22 +177,22 @@ const requireRole = (...args: (RoleOrSpecial | RoleOptions)[]) => {
92
177
  return createMiddleware<AuthContext>(async (c, next) => {
93
178
  // "*" = no check at all, pass through (but try to load user)
94
179
  if (roles.includes("*")) {
95
- await loadSessionUser(c);
180
+ await loadAuthenticatedActor(c);
96
181
  return next();
97
182
  }
98
183
 
99
- const { user } = await loadSessionUser(c);
184
+ const { user, actor } = await loadAuthenticatedActor(c);
100
185
 
101
186
  // "anonymous" = must NOT be logged in
102
187
  if (roles.includes("anonymous")) {
103
- if (user) {
188
+ if (actor) {
104
189
  return handleReject(c, options, "forbidden");
105
190
  }
106
191
  return next();
107
192
  }
108
193
 
109
194
  // All other roles require authentication
110
- if (!user) {
195
+ if (!actor) {
111
196
  return handleReject(c, options, "unauthenticated");
112
197
  }
113
198
 
@@ -116,6 +201,10 @@ const requireRole = (...args: (RoleOrSpecial | RoleOptions)[]) => {
116
201
  return next();
117
202
  }
118
203
 
204
+ if (!user) {
205
+ return handleReject(c, options, "forbidden");
206
+ }
207
+
119
208
  // Check if user has at least one required role
120
209
  const hasRequiredRole = roles.some((role) => user.roles.includes(role as Role));
121
210
  if (!hasRequiredRole) {
@@ -133,12 +222,12 @@ const redirect = (url: string): RoleOptions => ({
133
222
 
134
223
  /** Preset: Redirect to login page with returnTo parameter */
135
224
  const redirectToLogin: RoleOptions = {
136
- onReject: (c) => `/auth/login?redirectTo=${encodeURIComponent(new URL(c.req.url).pathname)}`,
225
+ onReject: (c) => createLoginRedirectUrl(c.req.url),
137
226
  };
138
227
 
139
228
  const requireAccount = (options: AccountOptions) =>
140
229
  createMiddleware<AuthContext>(async (c, next) => {
141
- const { user } = await loadSessionUser(c);
230
+ const { user } = await loadAuthenticatedActor(c);
142
231
 
143
232
  if (!user) {
144
233
  return handleReject(c, options, "unauthenticated");
@@ -1,6 +1,7 @@
1
1
  export { middleware } from "./middleware";
2
2
 
3
- export { auth, type AuthContext } from "./auth";
3
+ export { auth, type AuthContext, type RequestActor, type ServiceAccountRequestActor, type UserRequestActor } from "./auth";
4
+ export type { AccessSubject } from "../services/access";
4
5
  export { jsonResponse, imageResponse, openApiMeta, requiresAuth, requiresAdmin, requiresIpa, requiresIpaUser, requiresUser } from "./openapi";
5
6
  export { rateLimit, type RateLimitConfig, type RateLimitRouteOverride } from "./rate-limit";
6
7
  export { requestLogger } from "./request-logger";
@@ -13,9 +13,25 @@
13
13
  * `.use()` path instead.
14
14
  */
15
15
  import { createMiddleware } from "hono/factory";
16
+ import {
17
+ type ActiveAnnouncementsResponse,
18
+ type AnnouncementCookieState,
19
+ parseAnnouncementCookieHeader,
20
+ } from "../../contracts/announcements";
21
+ import { announcements } from "../../services/announcements";
22
+ import { logger } from "../../services/logging";
16
23
  import { loadSnapshot } from "../../services/settings/snapshot";
17
24
 
18
25
  const DEFAULT_SKIP = ["/public/", "/_ssr/", "/branding/", "/favicon"] as const;
26
+ const ANNOUNCEMENT_SKIP = ["/api/", "/public/", "/_ssr/", "/branding/", "/favicon"] as const;
27
+ const log = logger("middleware:settings");
28
+
29
+ export type LayoutAnnouncementsState = ActiveAnnouncementsResponse & {
30
+ cookieState: AnnouncementCookieState;
31
+ };
32
+
33
+ const shouldLoadAnnouncements = (path: string, cookieHeader: string | null): boolean =>
34
+ Boolean(cookieHeader?.match(/(?:^|;\s*)session_token=/)) && !ANNOUNCEMENT_SKIP.some((prefix) => path.startsWith(prefix));
19
35
 
20
36
  export const settings = (opts?: { skipPrefixes?: readonly string[] }) => {
21
37
  const skip = opts?.skipPrefixes ?? DEFAULT_SKIP;
@@ -24,6 +40,16 @@ export const settings = (opts?: { skipPrefixes?: readonly string[] }) => {
24
40
  if (!skip.some((p) => path.startsWith(p))) {
25
41
  (c as unknown as { set: (k: string, v: unknown) => void }).set("settings", await loadSnapshot());
26
42
  }
43
+ const cookieHeader = c.req.header("Cookie") ?? null;
44
+ if (shouldLoadAnnouncements(path, cookieHeader)) {
45
+ const cookieState = parseAnnouncementCookieHeader(cookieHeader);
46
+ try {
47
+ const active = await announcements.active.forState({ state: cookieState });
48
+ (c as unknown as { set: (k: string, v: unknown) => void }).set("announcements", { ...active, cookieState });
49
+ } catch (error) {
50
+ log.warn("Failed to preload announcements", { error: error instanceof Error ? error.message : String(error) });
51
+ }
52
+ }
27
53
  await next();
28
54
  });
29
55
  };
@@ -0,0 +1,197 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sql } from "bun";
3
+ import { createAccess, deleteAccess, getEffectivePermission, listUsersWithAccess } from "./access";
4
+
5
+ type Fixture = {
6
+ accessIds: string[];
7
+ userIds: {
8
+ direct: string;
9
+ group: string;
10
+ nested: string;
11
+ outside: string;
12
+ };
13
+ groupIds: {
14
+ parent: string;
15
+ child: string;
16
+ };
17
+ serviceAccountId: string;
18
+ };
19
+
20
+ const canUseDatabase = async () => {
21
+ try {
22
+ const [row] = await sql<{ users: string | null; groups: string | null; access: string | null }[]>`
23
+ SELECT
24
+ to_regclass('auth.users')::text AS users,
25
+ to_regclass('auth.groups')::text AS groups,
26
+ to_regclass('auth.access')::text AS access
27
+ `;
28
+ return Boolean(row?.users && row.groups && row.access);
29
+ } catch {
30
+ return false;
31
+ }
32
+ };
33
+
34
+ const insertUser = async (suffix: string, label: string) => {
35
+ const [row] = await sql<{ id: string }[]>`
36
+ INSERT INTO auth.users (uid, provider, profile, display_name, mail)
37
+ VALUES (${`access-helper-${label}-${suffix}`}, 'local', 'user', ${`Access ${label}`}, ${`${label}.${suffix}@example.test`})
38
+ RETURNING id
39
+ `;
40
+ return row!.id;
41
+ };
42
+
43
+ const insertGroup = async (suffix: string, label: string) => {
44
+ const [row] = await sql<{ id: string }[]>`
45
+ INSERT INTO auth.groups (cn, provider, name, description)
46
+ VALUES (${`access-helper-${label}-${suffix}`}, 'local', ${`Access ${label}`}, ${`Access ${label} test group`})
47
+ RETURNING id
48
+ `;
49
+ return row!.id;
50
+ };
51
+
52
+ const createFixture = async (): Promise<Fixture> => {
53
+ const suffix = crypto.randomUUID();
54
+ const directUserId = await insertUser(suffix, "direct");
55
+ const groupUserId = await insertUser(suffix, "group");
56
+ const nestedUserId = await insertUser(suffix, "nested");
57
+ const outsideUserId = await insertUser(suffix, "outside");
58
+ const parentGroupId = await insertGroup(suffix, "parent");
59
+ const childGroupId = await insertGroup(suffix, "child");
60
+ const [serviceAccount] = await sql<{ id: string }[]>`
61
+ INSERT INTO auth.service_accounts (name, kind, app_id, resource_type, resource_id)
62
+ VALUES (${`Access service ${suffix}`}, 'resource_bound', 'access-test', 'fixture', ${suffix})
63
+ RETURNING id
64
+ `;
65
+
66
+ await sql`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${groupUserId}::uuid, ${parentGroupId}::uuid)`;
67
+ await sql`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${nestedUserId}::uuid, ${childGroupId}::uuid)`;
68
+ await sql`INSERT INTO auth.group_groups_v2 (parent_group_id, child_group_id) VALUES (${parentGroupId}::uuid, ${childGroupId}::uuid)`;
69
+
70
+ const [directAccess] = await sql<{ id: string }[]>`
71
+ INSERT INTO auth.access (user_id, permission)
72
+ VALUES (${directUserId}::uuid, 'read')
73
+ RETURNING id
74
+ `;
75
+ const [groupAccess] = await sql<{ id: string }[]>`
76
+ INSERT INTO auth.access (group_id, permission)
77
+ VALUES (${parentGroupId}::uuid, 'write')
78
+ RETURNING id
79
+ `;
80
+ const [publicAccess] = await sql<{ id: string }[]>`
81
+ INSERT INTO auth.access (permission)
82
+ VALUES ('admin')
83
+ RETURNING id
84
+ `;
85
+ const [authenticatedAccess] = await sql<{ id: string }[]>`
86
+ INSERT INTO auth.access (authenticated_only, permission)
87
+ VALUES (TRUE, 'admin')
88
+ RETURNING id
89
+ `;
90
+
91
+ return {
92
+ accessIds: [directAccess!.id, groupAccess!.id, publicAccess!.id, authenticatedAccess!.id],
93
+ userIds: {
94
+ direct: directUserId,
95
+ group: groupUserId,
96
+ nested: nestedUserId,
97
+ outside: outsideUserId,
98
+ },
99
+ groupIds: {
100
+ parent: parentGroupId,
101
+ child: childGroupId,
102
+ },
103
+ serviceAccountId: serviceAccount!.id,
104
+ };
105
+ };
106
+
107
+ const cleanupFixture = async (fixture: Fixture) => {
108
+ for (const accessId of fixture.accessIds) {
109
+ await sql`DELETE FROM auth.access WHERE id = ${accessId}::uuid`;
110
+ }
111
+ for (const groupId of Object.values(fixture.groupIds)) {
112
+ await sql`DELETE FROM auth.group_groups_v2 WHERE parent_group_id = ${groupId}::uuid OR child_group_id = ${groupId}::uuid`;
113
+ await sql`DELETE FROM auth.user_groups_v2 WHERE group_id = ${groupId}::uuid`;
114
+ }
115
+ for (const groupId of Object.values(fixture.groupIds)) {
116
+ await sql`DELETE FROM auth.groups WHERE id = ${groupId}::uuid`;
117
+ }
118
+ await sql`DELETE FROM auth.service_accounts WHERE id = ${fixture.serviceAccountId}::uuid`;
119
+ for (const userId of Object.values(fixture.userIds)) {
120
+ await sql`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
121
+ }
122
+ };
123
+
124
+ describe("listUsersWithAccess", () => {
125
+ test("expands direct and recursive group access without exposing mail", async () => {
126
+ if (!(await canUseDatabase())) {
127
+ console.warn("Skipping access helper DB test: auth tables are not available.");
128
+ return;
129
+ }
130
+
131
+ const fixture = await createFixture();
132
+ try {
133
+ const users = await listUsersWithAccess({ accessIds: fixture.accessIds, limit: 20 });
134
+ const byId = new Map(users.map((user) => [user.id, user]));
135
+
136
+ expect(byId.has(fixture.userIds.direct)).toBe(true);
137
+ expect(byId.has(fixture.userIds.group)).toBe(true);
138
+ expect(byId.has(fixture.userIds.nested)).toBe(true);
139
+ expect(byId.has(fixture.userIds.outside)).toBe(false);
140
+
141
+ expect(byId.get(fixture.userIds.direct)?.source).toEqual({ type: "direct" });
142
+ expect(byId.get(fixture.userIds.nested)?.source).toEqual({
143
+ type: "group",
144
+ groupId: fixture.groupIds.parent,
145
+ groupName: "Access parent",
146
+ });
147
+ expect(byId.get(fixture.userIds.nested)?.permission).toBe("write");
148
+ expect("mail" in byId.get(fixture.userIds.nested)!).toBe(false);
149
+
150
+ const groupSearch = await listUsersWithAccess({ accessIds: fixture.accessIds, search: "parent", limit: 20 });
151
+ expect(groupSearch.map((user) => user.id)).toContain(fixture.userIds.nested);
152
+
153
+ const explicitUsers = await listUsersWithAccess({
154
+ accessIds: fixture.accessIds,
155
+ userIds: [fixture.userIds.nested, fixture.userIds.outside],
156
+ });
157
+ expect(explicitUsers.map((user) => user.id)).toEqual([fixture.userIds.nested]);
158
+
159
+ const writers = await listUsersWithAccess({ accessIds: fixture.accessIds, minimumPermission: "write", limit: 20 });
160
+ expect(writers.map((user) => user.id)).not.toContain(fixture.userIds.direct);
161
+ expect(writers.map((user) => user.id)).toContain(fixture.userIds.group);
162
+
163
+ const serviceAccountPublicPermission = await getEffectivePermission({
164
+ accessIds: fixture.accessIds,
165
+ userId: null,
166
+ userGroups: [],
167
+ serviceAccountId: fixture.serviceAccountId,
168
+ });
169
+ expect(serviceAccountPublicPermission).toBe("none");
170
+
171
+ const serviceAccountAccess = await createAccess({
172
+ principal: { type: "service_account", serviceAccountId: fixture.serviceAccountId },
173
+ permission: "write",
174
+ });
175
+ expect(serviceAccountAccess.ok).toBe(true);
176
+ if (!serviceAccountAccess.ok) return;
177
+ fixture.accessIds.push(serviceAccountAccess.data.id);
178
+
179
+ const serviceAccountPermission = await getEffectivePermission({
180
+ accessIds: [serviceAccountAccess.data.id],
181
+ userId: null,
182
+ userGroups: [],
183
+ serviceAccountId: fixture.serviceAccountId,
184
+ });
185
+ expect(serviceAccountPermission).toBe("write");
186
+
187
+ const serviceAccountUsers = await listUsersWithAccess({ accessIds: [serviceAccountAccess.data.id], limit: 20 });
188
+ expect(serviceAccountUsers).toEqual([]);
189
+
190
+ const deleteResult = await deleteAccess({ id: serviceAccountAccess.data.id });
191
+ expect(deleteResult.ok).toBe(true);
192
+ fixture.accessIds = fixture.accessIds.filter((id) => id !== serviceAccountAccess.data.id);
193
+ } finally {
194
+ await cleanupFixture(fixture);
195
+ }
196
+ });
197
+ });