@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,33 @@
1
+ /**
2
+ * Simple heartbeat for app registry entries.
3
+ * Uses setInterval — KISS over scheduler+job+cron.
4
+ *
5
+ * The entry's TTL is set on the ephemeral-store factory (see ./registry.ts);
6
+ * `touch` extends that TTL without re-sending the value. A failed touch means
7
+ * the entry has expired and we fall back to `upsert` to re-seed it.
8
+ */
9
+ import { appRegistry } from "./registry";
10
+ import type { AppRegistryEntry } from "../contracts/registry";
11
+
12
+ const HEARTBEAT_INTERVAL_MS = 60_000;
13
+
14
+ export const createHeartbeat = (appId: string, entry: AppRegistryEntry) => {
15
+ let timer: Timer | null = null;
16
+ const key = `apps/${appId}`;
17
+
18
+ return {
19
+ start: async () => {
20
+ await appRegistry.upsert({ key, value: entry });
21
+ timer = setInterval(async () => {
22
+ const result = await appRegistry.touch({ key });
23
+ if (!result.ok) {
24
+ await appRegistry.upsert({ key, value: entry });
25
+ }
26
+ }, HEARTBEAT_INTERVAL_MS);
27
+ },
28
+ stop: async () => {
29
+ if (timer) clearInterval(timer);
30
+ await appRegistry.remove({ key });
31
+ },
32
+ };
33
+ };
@@ -0,0 +1,100 @@
1
+ import { ephemeral } from "@valentinkolb/sync";
2
+ import type { AppRegistryEntry } from "../contracts/registry";
3
+
4
+ /**
5
+ * Shared app registry backed by Redis via @valentinkolb/sync ephemeral store.
6
+ * Replaces the v4 `registry` module with `ephemeral<T>` + prefix filter.
7
+ *
8
+ * TTL is 3× the heartbeat interval (see `./heartbeat.ts`).
9
+ */
10
+ const REGISTRY_TTL_MS = 120_000;
11
+
12
+ export const appRegistry = ephemeral<AppRegistryEntry>({
13
+ id: "cloud-apps",
14
+ ttlMs: REGISTRY_TTL_MS,
15
+ });
16
+
17
+ /**
18
+ * App entry enriched with registry metadata.
19
+ * `createdAt` = first registration of the container (uptime anchor).
20
+ * `updatedAt` = most recent heartbeat touch.
21
+ */
22
+ export type AppRegistryDetail = AppRegistryEntry & {
23
+ createdAt: number;
24
+ updatedAt: number;
25
+ expiresAt: number;
26
+ version: string;
27
+ };
28
+
29
+ /**
30
+ * List all currently live (TTL-valid) app registry entries.
31
+ */
32
+ export const listApps = async (): Promise<AppRegistryEntry[]> => {
33
+ const snap = await appRegistry.snapshot({ prefix: "apps/" });
34
+ return snap.entries.map((e) => e.value);
35
+ };
36
+
37
+ /**
38
+ * Same as `listApps` but returns registry metadata for admin observability.
39
+ */
40
+ export const listAppsDetailed = async (): Promise<AppRegistryDetail[]> => {
41
+ const snap = await appRegistry.snapshot({ prefix: "apps/" });
42
+ return snap.entries.map((e) => ({
43
+ ...e.value,
44
+ createdAt: e.createdAt,
45
+ updatedAt: e.updatedAt,
46
+ expiresAt: e.expiresAt,
47
+ version: e.version,
48
+ }));
49
+ };
50
+
51
+ /**
52
+ * Aggregate every running app's `legalLinks` into one flat list. Used by the
53
+ * login footer, app Footer, and rail "more" dropdown to render a unified set
54
+ * of legal/info links (Imprint, Privacy, Terms, FAQ, …).
55
+ *
56
+ * Order = registration order across apps (no explicit weights — KISS). Within
57
+ * one app, declaration order is preserved. Duplicate `href`s are de-duped
58
+ * (last-seen wins).
59
+ */
60
+ export const listLegalLinks = async (): Promise<Array<{ label: string; href: string; icon?: string }>> => {
61
+ const apps = await listApps();
62
+ const seen = new Map<string, { label: string; href: string; icon?: string }>();
63
+ for (const app of apps) {
64
+ for (const link of app.legalLinks ?? []) seen.set(link.href, { ...link });
65
+ }
66
+ return [...seen.values()];
67
+ };
68
+
69
+ /**
70
+ * Aggregate every running app's widget endpoints into one flat list. Used by
71
+ * the dashboard app to build the widget grid: it fetches each widget URL
72
+ * with the user's session forwarded and renders the response.
73
+ *
74
+ * Order = registration order across apps.
75
+ */
76
+ export type DashboardWidget = {
77
+ appId: string;
78
+ appName: string;
79
+ appIcon: string;
80
+ widgetId: string;
81
+ /** Fully-qualified URL — `<baseUrl>/<path>`. */
82
+ url: string;
83
+ };
84
+
85
+ export const listWidgets = async (): Promise<DashboardWidget[]> => {
86
+ const apps = await listApps();
87
+ const out: DashboardWidget[] = [];
88
+ for (const app of apps) {
89
+ for (const w of app.widgets ?? []) {
90
+ out.push({
91
+ appId: app.id,
92
+ appName: app.name,
93
+ appIcon: app.icon,
94
+ widgetId: w.id,
95
+ url: `${app.baseUrl.replace(/\/$/, "")}${w.path.startsWith("/") ? w.path : `/${w.path}`}`,
96
+ });
97
+ }
98
+ }
99
+ return out;
100
+ };
@@ -0,0 +1,38 @@
1
+ import type { CloudRuntime, RuntimeAppMeta } from "../contracts/app";
2
+ import type { AppRegistryEntry } from "../contracts/registry";
3
+ import type { Role } from "../contracts/shared";
4
+
5
+ /**
6
+ * Builds a `CloudRuntime` (the shape consumed by Layout, AdminSidebar, NavMenu)
7
+ * from registry entries.
8
+ *
9
+ * This produces the exact same shape as `createRuntimeContext()` in core/runtime.ts,
10
+ * so all existing UI components work unchanged.
11
+ */
12
+ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[]): CloudRuntime => ({
13
+ apps: entries.map(
14
+ (e): RuntimeAppMeta => ({
15
+ id: e.id,
16
+ name: e.name,
17
+ icon: e.icon,
18
+ description: e.description,
19
+ adminHref: e.nav?.adminHref,
20
+ routes: e.routes,
21
+ nav: e.nav
22
+ ? {
23
+ href: e.nav.href,
24
+ match: e.nav.match,
25
+ section: e.nav.section,
26
+ requiresAuth: e.nav.requiresAuth,
27
+ // Registry stores roles as serialized strings; the source type is
28
+ // Role[] and round-trip is value-preserving.
29
+ requiresRoles: e.nav.requiresRoles as Role[] | undefined,
30
+ }
31
+ : undefined,
32
+ searchTags: e.search?.tags,
33
+ searchHelp: e.search?.help,
34
+ searchTagHelp: e.search?.tagHelp,
35
+ legalLinks: e.legalLinks ? e.legalLinks.map((l) => ({ ...l })) : undefined,
36
+ }),
37
+ ),
38
+ });
@@ -0,0 +1,134 @@
1
+ import { Hono } from "hono";
2
+ import { describeRoute } from "hono-openapi";
3
+ import { z } from "zod";
4
+ import { accountsAppService as accountsService } from "../services";
5
+ import { respond, auth, jsonResponse, requiresAuth, v } from "../server";
6
+ import { err, fail } from "@valentinkolb/stdlib";
7
+ import {
8
+ EntityKindSchema,
9
+ EntityListItemSchema,
10
+ ErrorResponseSchema,
11
+ PaginationQuerySchema,
12
+ PaginationResponseSchema,
13
+ UserProfileSchema,
14
+ UserProviderSchema,
15
+ createPagination,
16
+ parsePagination,
17
+ } from "../contracts";
18
+
19
+ const EntitiesListResponseSchema = z.object({
20
+ items: z.array(EntityListItemSchema),
21
+ pagination: PaginationResponseSchema,
22
+ });
23
+
24
+ const VALID_ENTITY_KINDS = new Set<z.infer<typeof EntityKindSchema>>(EntityKindSchema.options);
25
+
26
+ const QuerySchema = z
27
+ .object({
28
+ ...PaginationQuerySchema.shape,
29
+ search: z.string().optional(),
30
+ kinds: z.string().optional(),
31
+ provider: UserProviderSchema.optional(),
32
+ profile: UserProfileSchema.optional(),
33
+ exclude_user_ids: z.string().optional(),
34
+ exclude_group_ids: z.string().optional(),
35
+ user_member_of_group_ids: z.string().optional(),
36
+ member_of_group_id: z.uuid().optional(),
37
+ manager_of_group_id: z.uuid().optional(),
38
+ parent_group_id: z.uuid().optional(),
39
+ managed_by_user_id: z.uuid().optional(),
40
+ recursive: z.enum(["true", "false"]).optional(),
41
+ })
42
+ .refine((value) => {
43
+ const relationFilters = [
44
+ value.member_of_group_id,
45
+ value.manager_of_group_id,
46
+ value.parent_group_id,
47
+ value.managed_by_user_id,
48
+ ].filter(Boolean);
49
+ return relationFilters.length <= 1;
50
+ }, {
51
+ message: "Only one relation filter can be used at a time.",
52
+ path: ["member_of_group_id"],
53
+ });
54
+
55
+ const parseCsv = (value?: string) =>
56
+ value
57
+ ?.split(",")
58
+ .map((part) => part.trim())
59
+ .filter(Boolean) ?? [];
60
+
61
+ const parseKinds = (value?: string) => {
62
+ const kinds = parseCsv(value);
63
+ if (kinds.length === 0) return { ok: true as const, value: undefined };
64
+ if (kinds.every((kind): kind is z.infer<typeof EntityKindSchema> => VALID_ENTITY_KINDS.has(kind as z.infer<typeof EntityKindSchema>))) {
65
+ return { ok: true as const, value: [...new Set(kinds)] };
66
+ }
67
+ return { ok: false as const, message: "Invalid kinds query parameter." };
68
+ };
69
+
70
+ const parseUuidList = (value: string | undefined, label: string) => {
71
+ const ids = parseCsv(value);
72
+ if (ids.length === 0) return { ok: true as const, value: undefined };
73
+ const parsed = z.array(z.uuid()).safeParse(ids);
74
+ if (parsed.success) {
75
+ return { ok: true as const, value: parsed.data };
76
+ }
77
+ return { ok: false as const, message: `Invalid ${label} query parameter.` };
78
+ };
79
+
80
+ const app = new Hono()
81
+ .get(
82
+ "/entities",
83
+ auth.requireRole("user"),
84
+ describeRoute({
85
+ tags: ["Accounts"],
86
+ summary: "List mixed users and groups",
87
+ description: "List users and groups together with SQL-backed filtering, relation scoping, and pagination.",
88
+ ...requiresAuth,
89
+ responses: {
90
+ 200: jsonResponse(EntitiesListResponseSchema, "Paginated mixed entity list"),
91
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
92
+ 403: jsonResponse(ErrorResponseSchema, "Full account required"),
93
+ },
94
+ }),
95
+ v("query", QuerySchema),
96
+ async (c) => {
97
+ const query = c.req.valid("query");
98
+ const params = parsePagination(query);
99
+ const kinds = parseKinds(query.kinds);
100
+ if (!kinds.ok) return respond(c, fail(err.badInput(kinds.message)));
101
+ const excludeUserIds = parseUuidList(query.exclude_user_ids, "exclude_user_ids");
102
+ if (!excludeUserIds.ok) return respond(c, fail(err.badInput(excludeUserIds.message)));
103
+ const excludeGroupIds = parseUuidList(query.exclude_group_ids, "exclude_group_ids");
104
+ if (!excludeGroupIds.ok) return respond(c, fail(err.badInput(excludeGroupIds.message)));
105
+ const userMemberOfGroupIds = parseUuidList(query.user_member_of_group_ids, "user_member_of_group_ids");
106
+ if (!userMemberOfGroupIds.ok) return respond(c, fail(err.badInput(userMemberOfGroupIds.message)));
107
+
108
+ const result = await accountsService.entity.list({
109
+ pagination: { page: params.page, perPage: params.perPage },
110
+ search: query.search,
111
+ kinds: kinds.value,
112
+ provider: query.provider,
113
+ profile: query.profile,
114
+ excludeUserIds: excludeUserIds.value,
115
+ excludeGroupIds: excludeGroupIds.value,
116
+ userMemberOfGroupIds: userMemberOfGroupIds.value,
117
+ memberOfGroupId: query.member_of_group_id,
118
+ managerOfGroupId: query.manager_of_group_id,
119
+ parentGroupId: query.parent_group_id,
120
+ managedByUserId: query.managed_by_user_id,
121
+ recursive: query.recursive === "true",
122
+ });
123
+
124
+ return respond(c, {
125
+ ok: true,
126
+ data: {
127
+ items: result.items,
128
+ pagination: createPagination(params, result.total),
129
+ },
130
+ });
131
+ },
132
+ );
133
+
134
+ export default app;
@@ -0,0 +1,210 @@
1
+ import { Hono } from "hono";
2
+ import { describeRoute } from "hono-openapi";
3
+ import { z } from "zod";
4
+ import { v, jsonResponse, requiresAdmin, auth, type AuthContext, respond } from "../server";
5
+ import { ok } from "@valentinkolb/stdlib";
6
+ import { accountLifecycle, lifecycleJobs } from "../services";
7
+ import { ErrorResponseSchema, MessageResponseSchema, PaginationQuerySchema, createPagination, parsePagination } from "../contracts";
8
+
9
+ const DeletedAccountSchema = z.object({
10
+ id: z.uuid(),
11
+ deletedUserId: z.uuid(),
12
+ uid: z.string(),
13
+ mail: z.string().nullable(),
14
+ displayName: z.string().nullable(),
15
+ previousProvider: z.string().nullable(),
16
+ previousProfile: z.string().nullable(),
17
+ reason: z.string(),
18
+ deletedAt: z.string().datetime(),
19
+ meta: z.record(z.string(), z.unknown()),
20
+ });
21
+
22
+ const ReminderAuditSchema = z.object({
23
+ id: z.uuid(),
24
+ userId: z.uuid().nullable(),
25
+ uid: z.string().nullable(),
26
+ mail: z.string().nullable(),
27
+ displayName: z.string().nullable(),
28
+ kind: z.enum(["account_expiry"]),
29
+ thresholdDays: z.number().int().positive(),
30
+ targetExpiryAt: z.string().datetime(),
31
+ status: z.enum(["pending", "sent", "error"]),
32
+ attemptCount: z.number().int().nonnegative(),
33
+ lastAttemptAt: z.string().datetime().nullable(),
34
+ sentAt: z.string().datetime().nullable(),
35
+ lastError: z.string().nullable(),
36
+ createdAt: z.string().datetime(),
37
+ });
38
+
39
+ const DeletedAccountsResponseSchema = z.object({
40
+ items: z.array(DeletedAccountSchema),
41
+ pagination: z.object({
42
+ page: z.number(),
43
+ per_page: z.number(),
44
+ total: z.number(),
45
+ total_pages: z.number(),
46
+ has_next: z.boolean(),
47
+ }),
48
+ });
49
+
50
+ const ReminderAuditResponseSchema = z.object({
51
+ items: z.array(ReminderAuditSchema),
52
+ pagination: z.object({
53
+ page: z.number(),
54
+ per_page: z.number(),
55
+ total: z.number(),
56
+ total_pages: z.number(),
57
+ has_next: z.boolean(),
58
+ }),
59
+ });
60
+
61
+ const TriggerJobResponseSchema = z.object({
62
+ message: z.string(),
63
+ jobId: z.string(),
64
+ });
65
+
66
+ const app = new Hono<AuthContext>()
67
+ .use(auth.requireRole("admin"))
68
+ .get(
69
+ "/deleted-accounts",
70
+ describeRoute({
71
+ tags: ["Admin Lifecycle"],
72
+ summary: "List deleted account lifecycle entries",
73
+ ...requiresAdmin,
74
+ responses: {
75
+ 200: jsonResponse(DeletedAccountsResponseSchema, "Deleted account lifecycle entries"),
76
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
77
+ 403: jsonResponse(ErrorResponseSchema, "Admin access required"),
78
+ },
79
+ }),
80
+ v(
81
+ "query",
82
+ z.object({
83
+ ...PaginationQuerySchema.shape,
84
+ reason: z.string().optional(),
85
+ search: z.string().optional(),
86
+ }),
87
+ ),
88
+ async (c) => {
89
+ const query = c.req.valid("query");
90
+ const pagination = parsePagination(query);
91
+ const result = await accountLifecycle.listDeletedAccounts({
92
+ page: pagination.page,
93
+ perPage: pagination.perPage,
94
+ reason: query.reason,
95
+ search: query.search,
96
+ });
97
+
98
+ return c.json({
99
+ items: result.items,
100
+ pagination: createPagination(pagination, result.total),
101
+ });
102
+ },
103
+ )
104
+ .get(
105
+ "/reminders",
106
+ describeRoute({
107
+ tags: ["Admin Lifecycle"],
108
+ summary: "List reminder history entries",
109
+ ...requiresAdmin,
110
+ responses: {
111
+ 200: jsonResponse(ReminderAuditResponseSchema, "Reminder history entries"),
112
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
113
+ 403: jsonResponse(ErrorResponseSchema, "Admin access required"),
114
+ },
115
+ }),
116
+ v(
117
+ "query",
118
+ z.object({
119
+ ...PaginationQuerySchema.shape,
120
+ status: z.enum(["pending", "sent", "error"]).optional(),
121
+ kind: z.enum(["account_expiry"]).optional(),
122
+ search: z.string().optional(),
123
+ }),
124
+ ),
125
+ async (c) => {
126
+ const query = c.req.valid("query");
127
+ const pagination = parsePagination(query);
128
+ const result = await accountLifecycle.listReminderAudit({
129
+ page: pagination.page,
130
+ perPage: pagination.perPage,
131
+ status: query.status,
132
+ kind: query.kind,
133
+ search: query.search,
134
+ });
135
+
136
+ return c.json({
137
+ items: result.items,
138
+ pagination: createPagination(pagination, result.total),
139
+ });
140
+ },
141
+ )
142
+ // Single unified job-dispatch endpoint. The five RPC POSTs it replaces all
143
+ // mapped to `lifecycleJobs.submit*`; consolidating keeps the API surface
144
+ // small and makes adding job kinds a one-line change.
145
+ .post(
146
+ "/jobs",
147
+ describeRoute({
148
+ tags: ["Admin Lifecycle"],
149
+ summary: "Submit a lifecycle job",
150
+ description: "Dispatches one of the configured account-lifecycle jobs. Returns the submitted job ID.",
151
+ ...requiresAdmin,
152
+ responses: {
153
+ 200: jsonResponse(TriggerJobResponseSchema, "Job submitted"),
154
+ 400: jsonResponse(ErrorResponseSchema, "Unknown job kind"),
155
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
156
+ 403: jsonResponse(ErrorResponseSchema, "Admin access required"),
157
+ },
158
+ }),
159
+ v(
160
+ "json",
161
+ z.object({
162
+ kind: z.enum(["ipa-sync", "ipa-backfill", "local-user-backfill", "guest-backfill", "reminders"]),
163
+ }),
164
+ ),
165
+ async (c) =>
166
+ respond(c, async () => {
167
+ const { kind } = c.req.valid("json");
168
+ switch (kind) {
169
+ case "ipa-sync":
170
+ return ok({ message: "IPA sync job submitted", jobId: await lifecycleJobs.submitIpaSync() });
171
+ case "ipa-backfill":
172
+ return ok({ message: "IPA backfill job submitted", jobId: await lifecycleJobs.submitIpaBackfill() });
173
+ case "local-user-backfill":
174
+ return ok({ message: "Local user backfill job submitted", jobId: await lifecycleJobs.submitLocalUserBackfill() });
175
+ case "guest-backfill":
176
+ return ok({ message: "Local guest backfill job submitted", jobId: await lifecycleJobs.submitGuestBackfill() });
177
+ case "reminders":
178
+ return ok({ message: "Reminder job submitted", jobId: await lifecycleJobs.submitReminderRun() });
179
+ }
180
+ }),
181
+ )
182
+ .get(
183
+ "/health",
184
+ describeRoute({
185
+ tags: ["Admin Lifecycle"],
186
+ summary: "Scheduler health metrics",
187
+ ...requiresAdmin,
188
+ responses: {
189
+ 200: jsonResponse(z.object({ metrics: z.record(z.string(), z.unknown()) }), "Scheduler metrics"),
190
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
191
+ 403: jsonResponse(ErrorResponseSchema, "Admin access required"),
192
+ },
193
+ }),
194
+ async (c) => c.json({ metrics: lifecycleJobs.metrics() }),
195
+ )
196
+ .get(
197
+ "/",
198
+ describeRoute({
199
+ tags: ["Admin Lifecycle"],
200
+ summary: "Lifecycle API root",
201
+ ...requiresAdmin,
202
+ responses: {
203
+ 200: jsonResponse(MessageResponseSchema, "Lifecycle API available"),
204
+ },
205
+ }),
206
+ (c) => c.json({ message: "Account lifecycle admin API" }),
207
+ );
208
+
209
+ export default app;
210
+ export type ApiType = typeof app;
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+
3
+ import { UserSchema } from "../../contracts";
4
+
5
+ export const LoginSchema = z.object({
6
+ username: z.string(),
7
+ password: z.string(),
8
+ acceptedAgb: z.literal(true),
9
+ });
10
+
11
+ export const EmailLoginSchema = z.object({
12
+ email: z.email(),
13
+ acceptedAgb: z.literal(true),
14
+ });
15
+
16
+ export const VerifyTokenSchema = z.object({
17
+ token: z.uuid(),
18
+ acceptedAgb: z.literal(true),
19
+ });
20
+
21
+ export const AdminLoginSchema = z.object({
22
+ token: z.string().min(1),
23
+ });
24
+
25
+ export const AuthResponseSchema = z.object({
26
+ session_token: z.string(),
27
+ user: UserSchema,
28
+ });