@valentinkolb/cloud 0.1.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 (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Widget JSON contract — what an app's widget endpoint must return when the
3
+ * dashboard fetches it. Each block maps 1:1 to a `<Widget*>` SolidJS component
4
+ * (see `packages/cloud/src/ui/widgets/`).
5
+ *
6
+ * The dashboard fetches each widget endpoint with the user's cookie forwarded;
7
+ * the endpoint is responsible for permission gating:
8
+ * - `200` + body → render
9
+ * - `204` → skip silently (user has no permission / no content)
10
+ * - anything else → log and skip
11
+ */
12
+
13
+ export type WidgetTone = "emerald" | "amber" | "red" | "blue" | "zinc";
14
+
15
+ export type WidgetAccent = {
16
+ tone: WidgetTone;
17
+ /** Tabler icon class, e.g. `"ti ti-trending-up"`. */
18
+ icon: string;
19
+ /** When set, renders as a pill with bg+text. Without text, plain colored icon. */
20
+ text?: string;
21
+ };
22
+
23
+ export type WidgetStatBlock = {
24
+ kind: "stat";
25
+ value: string | number;
26
+ label: string;
27
+ sub?: string;
28
+ /** Override the default value colour, e.g. `"text-amber-600 dark:text-amber-400"`. */
29
+ valueClass?: string;
30
+ accent?: WidgetAccent;
31
+ /** Block fills remaining vertical space inside the widget and centres its content. */
32
+ grow?: boolean;
33
+ };
34
+
35
+ export type WidgetListItem = {
36
+ icon?: string;
37
+ /** Override the default dimmed icon colour with a tone — useful for
38
+ * conveying priority, status, or category at a glance. */
39
+ iconTone?: WidgetTone;
40
+ label: string;
41
+ sub?: string;
42
+ /** Right-aligned trailing meta (timestamp, count). */
43
+ meta?: string;
44
+ /** When set, the row becomes a clickable link. */
45
+ href?: string;
46
+ };
47
+
48
+ export type WidgetListBlock = {
49
+ kind: "list";
50
+ items: WidgetListItem[];
51
+ /** Shown when `items` is empty. */
52
+ emptyMessage?: string;
53
+ /** Block fills remaining vertical space (with internal scroll if needed). */
54
+ grow?: boolean;
55
+ };
56
+
57
+ export type WidgetStatusBlock = {
58
+ kind: "status";
59
+ tone: "ok" | "warn" | "error" | "info";
60
+ title: string;
61
+ message?: string;
62
+ /** Override the tone-default icon. */
63
+ icon?: string;
64
+ /** Block fills remaining vertical space and centres its content. */
65
+ grow?: boolean;
66
+ };
67
+
68
+ export type WidgetPill = {
69
+ label: string;
70
+ value: string | number;
71
+ tone?: WidgetTone;
72
+ href?: string;
73
+ };
74
+
75
+ export type WidgetPillsBlock = {
76
+ kind: "pills";
77
+ pills: WidgetPill[];
78
+ /** Block fills remaining vertical space and centres its content. */
79
+ grow?: boolean;
80
+ };
81
+
82
+ /**
83
+ * Hero block — single big centred message. Use for spotlight content like a
84
+ * quote, a single weather location, or empty-state messages ("All clear",
85
+ * "No locations saved yet"). Always grows to fill available space.
86
+ */
87
+ export type WidgetHeroBlock = {
88
+ kind: "hero";
89
+ /** Big centred line, e.g. quote text, "14°C · partly cloudy", "All caught up". */
90
+ title: string;
91
+ /** Smaller dimmed line below the title, e.g. author, city, hint. */
92
+ subtitle?: string;
93
+ /** Tabler icon class shown above the title. */
94
+ icon?: string;
95
+ /** Tone for the icon. Defaults to dimmed. */
96
+ tone?: WidgetTone;
97
+ };
98
+
99
+ /** Discriminated union of every block type the dashboard can render. */
100
+ export type WidgetBlock =
101
+ | WidgetStatBlock
102
+ | WidgetListBlock
103
+ | WidgetStatusBlock
104
+ | WidgetPillsBlock
105
+ | WidgetHeroBlock;
106
+
107
+ /**
108
+ * Top-level shape returned by a widget endpoint. The dashboard renders the
109
+ * `<Widget>` container with the given title/icon/href/meta, then stacks the
110
+ * blocks vertically — composition is open: any number, any order.
111
+ */
112
+ export type WidgetResponse = {
113
+ title: string;
114
+ /** Tabler icon class for the widget header. */
115
+ icon?: string;
116
+ /** When set, the widget header becomes a link to this URL. */
117
+ href?: string;
118
+ /** Tiny meta string in the header (e.g. "last 24h"). */
119
+ meta?: string;
120
+ blocks: WidgetBlock[];
121
+ };
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { defineApp } from "./_internal/define-app";
2
+ export type { AppOptions, StartOptions, StartResult, AppDefinition } from "./_internal/define-app";
3
+ export { appRegistry, listApps, listAppsDetailed, listLegalLinks, listWidgets } from "./_internal/registry";
4
+ export type { AppRegistryDetail, DashboardWidget } from "./_internal/registry";
5
+ export { createHeartbeat } from "./_internal/heartbeat";
6
+ export { buildRuntimeFromRegistry } from "./_internal/runtime-context";
@@ -0,0 +1 @@
1
+ export { api, respond } from "./respond";
@@ -0,0 +1,55 @@
1
+ import type { Context } from "hono";
2
+ import { isServiceError, type Result } from "@valentinkolb/stdlib";
3
+
4
+ type LegacyResult<T = void> = { ok: true; data: T } | { ok: false; error: string; status: number };
5
+
6
+ type AnyResult<T = unknown> = Result<T> | LegacyResult<T>;
7
+
8
+ type ErrorResponseBody = {
9
+ message: string;
10
+ code?: string;
11
+ };
12
+
13
+ const toErrorResponse = (result: AnyResult): [ErrorResponseBody, number] => {
14
+ if (result.ok) {
15
+ throw new Error("toErrorResponse called with successful result");
16
+ }
17
+
18
+ // Legacy shape: { ok: false, error: string, status: number }
19
+ if ("status" in result && typeof result.status === "number") {
20
+ return [{ message: result.error }, result.status];
21
+ }
22
+
23
+ // New shape: { ok: false, error: ServiceError }
24
+ if (isServiceError(result.error)) {
25
+ return [
26
+ {
27
+ message: result.error.message,
28
+ code: result.error.code,
29
+ },
30
+ result.error.status,
31
+ ];
32
+ }
33
+
34
+ // Defensive fallback
35
+ return [{ message: "Internal server error", code: "INTERNAL" }, 500];
36
+ };
37
+
38
+ export const respond = async <T>(
39
+ c: Context,
40
+ resultOrFn: AnyResult<T> | Promise<AnyResult<T>> | (() => AnyResult<T> | Promise<AnyResult<T>>),
41
+ successStatus = 200,
42
+ ) => {
43
+ const result = typeof resultOrFn === "function" ? await resultOrFn() : await resultOrFn;
44
+
45
+ if (!result.ok) {
46
+ const [body, status] = toErrorResponse(result);
47
+ return c.json(body, status as 400 | 401 | 403 | 404 | 409 | 500);
48
+ }
49
+
50
+ return c.json(result.data, successStatus as 200 | 201);
51
+ };
52
+
53
+ export const api = {
54
+ respond,
55
+ } as const;
@@ -0,0 +1,54 @@
1
+ import { hc } from "hono/client";
2
+ import type { Hono } from "hono";
3
+
4
+ export type CreateApiClientConfig = {
5
+ baseUrl?: string;
6
+ };
7
+
8
+ // ==========================
9
+ // API Client
10
+ // ==========================
11
+
12
+ /**
13
+ * Creates a typed Hono API client.
14
+ */
15
+ export const createApiClient = <TApi extends Hono<any, any, any>>(config: CreateApiClientConfig = {}) => hc<TApi>(config.baseUrl ?? "/api");
16
+
17
+ /**
18
+ * Untyped fallback API client for core-only browser code.
19
+ */
20
+ export const apiClient: any = hc("/api");
21
+
22
+ // ==========================
23
+ // Clipboard
24
+ // ==========================
25
+
26
+ /**
27
+ * Copies text to the clipboard.
28
+ * Fails silently with console error if clipboard API is unavailable.
29
+ */
30
+ export const copyToClipboard = async (text: string): Promise<void> => {
31
+ try {
32
+ await navigator.clipboard.writeText(text);
33
+ } catch (err) {
34
+ console.error("Failed to copy:", err);
35
+ }
36
+ };
37
+
38
+ /**
39
+ * Checks if a value is an image URL served by the API.
40
+ * Used to determine if an image field contains an existing server URL or new base64 data.
41
+ */
42
+ export const isImageUrl = (value: string | null | undefined): boolean => typeof value === "string" && value.includes("/avatar");
43
+
44
+ export const api = {
45
+ create: createApiClient,
46
+ } as const;
47
+
48
+ export const clipboard = {
49
+ copy: copyToClipboard,
50
+ } as const;
51
+
52
+ export const url = {
53
+ isImage: isImageUrl,
54
+ } as const;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `AppContext<App>` — Hono context type for routes mounted by an app.
3
+ *
4
+ * Combines the existing `AuthContext` variables (user, sessionToken) with a
5
+ * typed per-request settings snapshot derived from the app's `defineApp.settings`
6
+ * declaration.
7
+ *
8
+ * Convention: each app exports a named alias from its `index.ts`:
9
+ *
10
+ * ```ts
11
+ * import { app } from "./config";
12
+ * import type { AppContext } from "@valentinkolb/cloud/server";
13
+ * export type FilesAppContext = AppContext<typeof app>;
14
+ * ```
15
+ *
16
+ * Then routes:
17
+ *
18
+ * ```ts
19
+ * import { type FilesAppContext } from "..";
20
+ * new Hono<FilesAppContext>().get("/", (c) => {
21
+ * const s = c.get("settings"); // typed nested readonly object
22
+ * s.app.name // string (from core's settings)
23
+ * s.files.filegate_url // string (from app-files's own settings)
24
+ * });
25
+ * ```
26
+ *
27
+ * The `settings` variable is populated by the per-request snapshot middleware
28
+ * registered by `app.start()`; the snapshot is frozen for the duration of the
29
+ * request.
30
+ */
31
+ import type { AppDefinition } from "../_internal/define-app";
32
+ import type { AppSettings } from "../contracts/settings-types";
33
+ import type { AuthContext } from "./middleware/auth";
34
+
35
+ export type AppContext<App extends AppDefinition<any>> = {
36
+ Variables: AuthContext["Variables"] & {
37
+ settings: AppSettings<App>;
38
+ };
39
+ };
@@ -0,0 +1,62 @@
1
+ export { api, respond } from "./api";
2
+ export { api as apiClient } from "./api-client";
3
+ export type { CreateApiClientConfig } from "./api-client";
4
+
5
+ export {
6
+ middleware,
7
+ auth,
8
+ jsonResponse,
9
+ imageResponse,
10
+ openApiMeta,
11
+ requiresAuth,
12
+ requiresAdmin,
13
+ requiresIpa,
14
+ requiresIpaUser,
15
+ requiresUser,
16
+ rateLimit,
17
+ requestLogger,
18
+ validator,
19
+ v,
20
+ } from "./middleware";
21
+ export type { AuthContext, RateLimitConfig, RateLimitRouteOverride } from "./middleware";
22
+ export type { AppContext } from "./app-context";
23
+
24
+ export {
25
+ services,
26
+ freeipa,
27
+ images,
28
+ password,
29
+ generatePassword,
30
+ geo,
31
+ geoService,
32
+ PERMISSION_LEVELS,
33
+ hasPermission,
34
+ createAccess,
35
+ getAccess,
36
+ updateAccess,
37
+ deleteAccess,
38
+ getEffectivePermission,
39
+ resolveDisplayNames,
40
+ ok,
41
+ okMany,
42
+ fail,
43
+ err,
44
+ unwrap,
45
+ paginate,
46
+ 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,
62
+ } from "./services";
@@ -0,0 +1,168 @@
1
+ import type { Context } from "hono";
2
+ import { createMiddleware } from "hono/factory";
3
+ import type { MessageResponse, Role, RoleOrSpecial, User, UserProfile, UserProvider } from "../../contracts/shared";
4
+ import { accounts } from "../../services/accounts";
5
+ import { session } from "../../services/session";
6
+
7
+ // ==========================
8
+ // Types
9
+ // ==========================
10
+
11
+ /** Hono context with authenticated user variables. */
12
+ export type AuthContext = {
13
+ Variables: {
14
+ user: User;
15
+ sessionToken: string;
16
+ };
17
+ };
18
+
19
+ // ==========================
20
+ // Role-based Middleware
21
+ // ==========================
22
+
23
+ type RejectResult = string | Response | { message: string; status: number };
24
+
25
+ type RoleOptions = {
26
+ onReject?: (c: Context, reason: "unauthenticated" | "forbidden") => RejectResult;
27
+ };
28
+
29
+ type AccountOptions = RoleOptions & {
30
+ provider?: UserProvider;
31
+ profile?: UserProfile;
32
+ };
33
+
34
+ const handleReject = (c: Context, options: RoleOptions, reason: "unauthenticated" | "forbidden"): Response | Promise<Response> => {
35
+ if (options.onReject) {
36
+ const result = options.onReject(c, reason);
37
+ if (typeof result === "string") return c.redirect(result);
38
+ if (result instanceof Response) return result;
39
+ return c.json({ message: result.message } as MessageResponse, result.status as 400 | 401 | 403 | 404 | 500);
40
+ }
41
+ // Default: JSON response
42
+ if (reason === "unauthenticated") {
43
+ return c.json({ message: "Authentication required" } as MessageResponse, 401);
44
+ }
45
+ return c.json({ message: "Insufficient permissions" } as MessageResponse, 403);
46
+ };
47
+
48
+ const loadSessionUser = async (c: Context<AuthContext>): Promise<{ token: string | null; user: User | null }> => {
49
+ const token = session.getToken(c);
50
+ const data = token ? await session.getData(token) : null;
51
+ const user = data ? await accounts.users.get({ id: data.userId }) : null;
52
+
53
+ if (user && token) {
54
+ c.set("user", user);
55
+ c.set("sessionToken", token);
56
+ }
57
+
58
+ return { token, user };
59
+ };
60
+
61
+ /**
62
+ * Universal auth middleware. Handles authentication AND authorization.
63
+ *
64
+ * @param args - Roles to check (OR logic) + optional RoleOptions at the end. Special roles:
65
+ * - "*": No check, always passes (like optionalAuth)
66
+ * - "authenticated": Any logged-in user
67
+ * - "anonymous": Only non-logged-in users (for login page)
68
+ *
69
+ * @example
70
+ * // API: Only admins (returns JSON 401/403)
71
+ * .use(requireRole("admin"))
72
+ *
73
+ * // API: Admins OR group managers
74
+ * .use(requireRole("admin", "group-manager"))
75
+ *
76
+ * // SSR: Admin area with redirect
77
+ * .use(requireRole("admin", redirect("/")))
78
+ *
79
+ * // SSR: Protected page with login redirect
80
+ * .use(requireRole("authenticated", redirectToLogin))
81
+ *
82
+ * // SSR: Login page (only for non-logged-in users)
83
+ * .use(requireRole("anonymous", redirect("/")))
84
+ */
85
+ const requireRole = (...args: (RoleOrSpecial | RoleOptions)[]) => {
86
+ // Parse args: roles + optional options at the end
87
+ const lastArg = args[args.length - 1];
88
+ const hasOptions = typeof lastArg === "object" && lastArg !== null && "onReject" in lastArg;
89
+ const options: RoleOptions = hasOptions ? (args.pop() as RoleOptions) : {};
90
+ const roles = args as RoleOrSpecial[];
91
+
92
+ return createMiddleware<AuthContext>(async (c, next) => {
93
+ // "*" = no check at all, pass through (but try to load user)
94
+ if (roles.includes("*")) {
95
+ await loadSessionUser(c);
96
+ return next();
97
+ }
98
+
99
+ const { user } = await loadSessionUser(c);
100
+
101
+ // "anonymous" = must NOT be logged in
102
+ if (roles.includes("anonymous")) {
103
+ if (user) {
104
+ return handleReject(c, options, "forbidden");
105
+ }
106
+ return next();
107
+ }
108
+
109
+ // All other roles require authentication
110
+ if (!user) {
111
+ return handleReject(c, options, "unauthenticated");
112
+ }
113
+
114
+ // "authenticated" = any logged-in user
115
+ if (roles.includes("authenticated")) {
116
+ return next();
117
+ }
118
+
119
+ // Check if user has at least one required role
120
+ const hasRequiredRole = roles.some((role) => user.roles.includes(role as Role));
121
+ if (!hasRequiredRole) {
122
+ return handleReject(c, options, "forbidden");
123
+ }
124
+
125
+ return next();
126
+ });
127
+ };
128
+
129
+ /** Preset: Redirect to a fixed URL on rejection */
130
+ const redirect = (url: string): RoleOptions => ({
131
+ onReject: () => url,
132
+ });
133
+
134
+ /** Preset: Redirect to login page with returnTo parameter */
135
+ const redirectToLogin: RoleOptions = {
136
+ onReject: (c) => `/auth/login?redirectTo=${encodeURIComponent(new URL(c.req.url).pathname)}`,
137
+ };
138
+
139
+ const requireAccount = (options: AccountOptions) =>
140
+ createMiddleware<AuthContext>(async (c, next) => {
141
+ const { user } = await loadSessionUser(c);
142
+
143
+ if (!user) {
144
+ return handleReject(c, options, "unauthenticated");
145
+ }
146
+
147
+ if (options.provider && user.provider !== options.provider) {
148
+ return handleReject(c, options, "forbidden");
149
+ }
150
+
151
+ if (options.profile && user.profile !== options.profile) {
152
+ return handleReject(c, options, "forbidden");
153
+ }
154
+
155
+ return next();
156
+ });
157
+
158
+ // ==========================
159
+ // Export
160
+ // ==========================
161
+
162
+ export const auth = {
163
+ session,
164
+ requireRole,
165
+ requireAccount,
166
+ redirect,
167
+ redirectToLogin,
168
+ };
@@ -0,0 +1,7 @@
1
+ export { middleware } from "./middleware";
2
+
3
+ export { auth, type AuthContext } from "./auth";
4
+ export { jsonResponse, imageResponse, openApiMeta, requiresAuth, requiresAdmin, requiresIpa, requiresIpaUser, requiresUser } from "./openapi";
5
+ export { rateLimit, type RateLimitConfig, type RateLimitRouteOverride } from "./rate-limit";
6
+ export { requestLogger } from "./request-logger";
7
+ export { validator, v } from "./validator";
@@ -0,0 +1,47 @@
1
+ import { auth } from "./auth";
2
+ import { imageResponse, jsonResponse, openApiMeta, requiresAdmin, requiresAuth, requiresIpa, requiresIpaUser, requiresUser } from "./openapi";
3
+ import { rateLimit } from "./rate-limit";
4
+ import { requestLogger } from "./request-logger";
5
+ import { validator, v } from "./validator";
6
+
7
+ export const middleware = {
8
+ get auth() {
9
+ return auth;
10
+ },
11
+ get jsonResponse() {
12
+ return jsonResponse;
13
+ },
14
+ get imageResponse() {
15
+ return imageResponse;
16
+ },
17
+ get openApiMeta() {
18
+ return openApiMeta;
19
+ },
20
+ get requiresAuth() {
21
+ return requiresAuth;
22
+ },
23
+ get requiresAdmin() {
24
+ return requiresAdmin;
25
+ },
26
+ get requiresIpa() {
27
+ return requiresIpa;
28
+ },
29
+ get requiresIpaUser() {
30
+ return requiresIpaUser;
31
+ },
32
+ get requiresUser() {
33
+ return requiresUser;
34
+ },
35
+ get rateLimit() {
36
+ return rateLimit;
37
+ },
38
+ get requestLogger() {
39
+ return requestLogger;
40
+ },
41
+ get validator() {
42
+ return validator;
43
+ },
44
+ get v() {
45
+ return v;
46
+ },
47
+ } as const;
@@ -0,0 +1,126 @@
1
+ import { resolver, type GenerateSpecOptions } from "hono-openapi";
2
+ import type { ZodType } from "zod";
3
+
4
+ // ==========================
5
+ // Response Helpers
6
+ // ==========================
7
+
8
+ /**
9
+ * Helper to define JSON response schema for OpenAPI documentation.
10
+ *
11
+ * @param schema - Zod schema for the response body
12
+ * @param description - Human-readable description of the response
13
+ * @returns OpenAPI response object with application/json content type
14
+ */
15
+ export const jsonResponse = <T extends ZodType>(schema: T, description: string) => ({
16
+ description,
17
+ content: {
18
+ "application/json": {
19
+ schema: resolver(schema),
20
+ },
21
+ },
22
+ });
23
+
24
+ /**
25
+ * Helper to define image response for OpenAPI documentation.
26
+ *
27
+ * @param description - Human-readable description of the response
28
+ * @returns OpenAPI response object with image/webp content type
29
+ */
30
+ export const imageResponse = (description: string) => ({
31
+ description,
32
+ content: {
33
+ "image/webp": {
34
+ schema: { type: "string" as const, format: "binary" },
35
+ },
36
+ },
37
+ });
38
+
39
+ // ==========================
40
+ // OpenAPI Specification
41
+ // ==========================
42
+
43
+ /**
44
+ * OpenAPI spec metadata for the API documentation.
45
+ * Includes info, tags, and security schemes.
46
+ */
47
+ export const openApiMeta: Partial<GenerateSpecOptions> = {
48
+ documentation: {
49
+ info: {
50
+ // Hardcoded — the OpenAPI spec is generated at module-load and
51
+ // settings reads are async. The dynamic per-deployment app name
52
+ // appears elsewhere (Layout header, page titles).
53
+ title: "Cloud API",
54
+ version: "0.0.1",
55
+ description: "IPA Management Tool API",
56
+ },
57
+ servers: [{ url: "/api", description: "API Server" }],
58
+ tags: [
59
+ {
60
+ name: "Auth",
61
+ description: "Authentication endpoints (login, logout, refresh)",
62
+ },
63
+ { name: "Users", description: "User listing and search (admin)" },
64
+ { name: "Groups", description: "Group listing and search (admin)" },
65
+ ],
66
+ components: {
67
+ securitySchemes: {
68
+ cookieAuth: {
69
+ type: "apiKey",
70
+ in: "cookie",
71
+ name: "session_token",
72
+ description: "Session cookie (automatically set after login)",
73
+ },
74
+ bearerAuth: {
75
+ type: "http",
76
+ scheme: "bearer",
77
+ description: "Bearer token in Authorization header",
78
+ },
79
+ },
80
+ },
81
+ },
82
+ };
83
+
84
+ // ==========================
85
+ // Security Requirements
86
+ // ==========================
87
+
88
+ /**
89
+ * Security requirement for routes that need authentication.
90
+ * Accepts either cookie or bearer token.
91
+ */
92
+ export const requiresAuth = {
93
+ security: [{ cookieAuth: [] as string[], bearerAuth: [] as string[] }],
94
+ };
95
+
96
+ /**
97
+ * Security requirement for routes that need admin role.
98
+ * Accepts either cookie or bearer token.
99
+ */
100
+ export const requiresAdmin = {
101
+ security: [{ cookieAuth: [] as string[], bearerAuth: [] as string[] }],
102
+ };
103
+
104
+ /**
105
+ * Security requirement for routes that need any authenticated user.
106
+ * Accepts either cookie or bearer token.
107
+ */
108
+ export const requiresIpa = {
109
+ security: [{ cookieAuth: [] as string[], bearerAuth: [] as string[] }],
110
+ };
111
+
112
+ /**
113
+ * Security requirement for routes that need a full user profile.
114
+ * Accepts either cookie or bearer token.
115
+ */
116
+ export const requiresUser = {
117
+ security: [{ cookieAuth: [] as string[], bearerAuth: [] as string[] }],
118
+ };
119
+
120
+ /**
121
+ * Security requirement for routes that need an IPA-backed full user.
122
+ * Accepts either cookie or bearer token.
123
+ */
124
+ export const requiresIpaUser = {
125
+ security: [{ cookieAuth: [] as string[], bearerAuth: [] as string[] }],
126
+ };