@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,126 @@
1
+ import type { Context } from "hono";
2
+ import type { MiddlewareHandler } from "hono";
3
+ import { createMiddleware } from "hono/factory";
4
+ import { ratelimit } from "@valentinkolb/sync";
5
+ import { auth, type AuthContext } from "./auth";
6
+ import * as settings from "../../services/settings";
7
+ import type { MessageResponse } from "../../contracts/shared";
8
+
9
+ export type RateLimitRouteOverride = {
10
+ method?: string;
11
+ path: string | RegExp;
12
+ limitPerSecond?: number;
13
+ disabled?: boolean;
14
+ };
15
+
16
+ export type RateLimitConfig = {
17
+ limitPerSecond?: number;
18
+ windowSecs?: number;
19
+ keyBy?: "auto" | "ip" | "user";
20
+ routes?: RateLimitRouteOverride[];
21
+ };
22
+
23
+ type ResolvedRateLimit = {
24
+ disabled: boolean;
25
+ limitPerSecond: number;
26
+ windowSecs: number;
27
+ };
28
+
29
+ const DEFAULT_WINDOW_SECS = 1;
30
+ const LIMITER_PREFIX = "cloud:rate-limit";
31
+ const limiterCache = new Map<string, ReturnType<typeof ratelimit>>();
32
+
33
+ const getClientIp = (c: Context): string =>
34
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
35
+
36
+ const pathMatches = (requestPath: string, matcher: string | RegExp): boolean => {
37
+ if (matcher instanceof RegExp) return matcher.test(requestPath);
38
+ return requestPath === matcher || requestPath.startsWith(`${matcher}/`);
39
+ };
40
+
41
+ const resolveRouteOverride = (c: Context, overrides: RateLimitRouteOverride[] | undefined): RateLimitRouteOverride | undefined => {
42
+ if (!overrides || overrides.length === 0) return undefined;
43
+ return overrides.find((override) => {
44
+ if (override.method && override.method.toUpperCase() !== c.req.method.toUpperCase()) {
45
+ return false;
46
+ }
47
+ return pathMatches(c.req.path, override.path);
48
+ });
49
+ };
50
+
51
+ const resolveConfig = async (c: Context, config: RateLimitConfig): Promise<ResolvedRateLimit> => {
52
+ const override = resolveRouteOverride(c, config.routes);
53
+ if (override?.disabled) {
54
+ return {
55
+ disabled: true,
56
+ limitPerSecond: 0,
57
+ windowSecs: config.windowSecs ?? DEFAULT_WINDOW_SECS,
58
+ };
59
+ }
60
+
61
+ const configuredLimit = override?.limitPerSecond ?? config.limitPerSecond ?? null;
62
+ const limitPerSecond = configuredLimit ?? (await settings.get<number>("security.rate_limit_per_second"));
63
+
64
+ return {
65
+ disabled: false,
66
+ limitPerSecond: Math.max(1, Math.floor(limitPerSecond)),
67
+ windowSecs: Math.max(1, Math.floor(config.windowSecs ?? DEFAULT_WINDOW_SECS)),
68
+ };
69
+ };
70
+
71
+ const getLimiter = (limitPerSecond: number, windowSecs: number) => {
72
+ const cacheKey = `${limitPerSecond}:${windowSecs}`;
73
+ const cached = limiterCache.get(cacheKey);
74
+ if (cached) return cached;
75
+
76
+ const limiter = ratelimit({
77
+ id: cacheKey,
78
+ limit: limitPerSecond,
79
+ windowSecs,
80
+ prefix: LIMITER_PREFIX,
81
+ });
82
+ limiterCache.set(cacheKey, limiter);
83
+ return limiter;
84
+ };
85
+
86
+ const resolveIdentifier = async (c: Context<AuthContext>, keyBy: RateLimitConfig["keyBy"]): Promise<string> => {
87
+ if (keyBy === "ip") return `ip:${getClientIp(c)}`;
88
+
89
+ const token = auth.session.getToken(c);
90
+ if (!token) return `ip:${getClientIp(c)}`;
91
+
92
+ const data = await auth.session.getData(token);
93
+ if (!data) return `ip:${getClientIp(c)}`;
94
+
95
+ return `user:${data.userId}`;
96
+ };
97
+
98
+ /**
99
+ * Stateless per-route rate limiting middleware backed by @valentinkolb/sync.
100
+ * Keying defaults to user ID (when session exists), otherwise client IP.
101
+ */
102
+ export const rateLimit = (config: RateLimitConfig = {}): MiddlewareHandler<AuthContext> =>
103
+ createMiddleware<AuthContext>(async (c, next) => {
104
+ const resolved = await resolveConfig(c, config);
105
+ if (resolved.disabled) {
106
+ await next();
107
+ return;
108
+ }
109
+
110
+ const keyBy = config.keyBy ?? "auto";
111
+ const identifier = await resolveIdentifier(c, keyBy);
112
+ const limiter = getLimiter(resolved.limitPerSecond, resolved.windowSecs);
113
+ const result = await limiter.check(identifier);
114
+ const resetInSeconds = Math.max(1, Math.ceil(result.resetIn / 1000));
115
+
116
+ c.header("X-RateLimit-Limit", String(resolved.limitPerSecond));
117
+ c.header("X-RateLimit-Remaining", String(result.remaining));
118
+ c.header("X-RateLimit-Reset", String(resetInSeconds));
119
+
120
+ if (result.limited) {
121
+ c.header("Retry-After", String(resetInSeconds));
122
+ return c.json({ message: "Rate limit exceeded" } as MessageResponse, 429);
123
+ }
124
+
125
+ await next();
126
+ });
@@ -0,0 +1,41 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import { logger } from "../../services/logging";
3
+ import type { AuthContext } from "./auth";
4
+
5
+ const log = logger("http");
6
+
7
+ const SKIP_PREFIXES = ["/public/", "/_ssr/", "/favicon", "/branding/"];
8
+
9
+ /**
10
+ * HTTP request logging middleware.
11
+ * Logs to DB based on response status:
12
+ * - 5xx → error (server errors)
13
+ * - 429 → warn (rate limiting)
14
+ * - 401/403 → info (auth flows)
15
+ * - Everything else (2xx, 3xx, 400, 404) → not logged (too noisy)
16
+ */
17
+ export const requestLogger = createMiddleware<AuthContext>(async (c, next) => {
18
+ const path = c.req.path;
19
+ if (SKIP_PREFIXES.some((p) => path.startsWith(p))) return next();
20
+
21
+ const start = Date.now();
22
+ await next();
23
+ const status = c.res.status;
24
+ const duration = Date.now() - start;
25
+
26
+ const meta = {
27
+ method: c.req.method,
28
+ path,
29
+ status,
30
+ duration,
31
+ userId: c.get("user")?.id ?? null,
32
+ };
33
+
34
+ if (status >= 500) {
35
+ log.error(`${c.req.method} ${path} ${status}`, meta);
36
+ } else if (status === 429) {
37
+ log.warn(`${c.req.method} ${path} 429 rate-limited`, meta);
38
+ } else if (status === 401 || status === 403) {
39
+ log.info(`${c.req.method} ${path} ${status}`, meta);
40
+ }
41
+ });
@@ -0,0 +1,35 @@
1
+ import { validator as honoValidator } from "hono-openapi";
2
+ import type { ZodType } from "zod";
3
+ import type { ValidationTargets, Context } from "hono";
4
+
5
+ /**
6
+ * Zod validator middleware with pretty error messages and OpenAPI support.
7
+ * Uses hono-openapi validator for automatic OpenAPI schema generation.
8
+ *
9
+ * @param target - Where to validate: "json", "query", "param", "header", "cookie", or "form"
10
+ * @param schema - Zod schema to validate against
11
+ * @returns Hono middleware that validates request data and returns 400 on failure
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * app.post("/users", v("json", CreateUserSchema), async (c) => {
16
+ * const data = c.req.valid("json"); // Fully typed!
17
+ * // ...
18
+ * });
19
+ * ```
20
+ */
21
+ export const validator = <Target extends keyof ValidationTargets, T extends ZodType>(target: Target, schema: T) =>
22
+ honoValidator(target, schema, (result, c: Context) => {
23
+ if (!result.success) {
24
+ // Standard Schema returns issues array on failure
25
+ const errorMessage = result.error
26
+ ?.map((issue) => {
27
+ const path = issue.path?.map((p) => (typeof p === "object" && "key" in p ? String(p.key) : String(p))).join(".");
28
+ return path ? `${path}: ${issue.message}` : issue.message;
29
+ })
30
+ .join(", ");
31
+ return c.json({ message: errorMessage || "Validation failed" }, 400);
32
+ }
33
+ });
34
+
35
+ export const v = validator;
@@ -0,0 +1,294 @@
1
+ import { sql } from "bun";
2
+ import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
3
+
4
+ // ==========================
5
+ // Permission Levels
6
+ // ==========================
7
+
8
+ export const PERMISSION_LEVELS = ["none", "read", "write", "admin"] as const;
9
+ export type PermissionLevel = (typeof PERMISSION_LEVELS)[number];
10
+
11
+ /** Compare permission levels (returns true if a >= b) */
12
+ export const hasPermission = (userLevel: PermissionLevel, requiredLevel: PermissionLevel): boolean => {
13
+ const levels = PERMISSION_LEVELS;
14
+ return levels.indexOf(userLevel) >= levels.indexOf(requiredLevel);
15
+ };
16
+
17
+ // ==========================
18
+ // Principal Types
19
+ // ==========================
20
+
21
+ export type PrincipalType = "user" | "group" | "authenticated" | "public";
22
+
23
+ export type Principal =
24
+ | { type: "user"; userId: string }
25
+ | { type: "group"; groupId: string }
26
+ | { type: "authenticated" }
27
+ | { type: "public" };
28
+
29
+ // ==========================
30
+ // Access Entry Types
31
+ // ==========================
32
+
33
+ export type AccessEntry = {
34
+ id: string;
35
+ principal: Principal;
36
+ permission: PermissionLevel;
37
+ createdAt: string;
38
+ // Resolved display info (populated by service)
39
+ displayName?: string;
40
+ };
41
+
42
+ type DbAccess = {
43
+ id: string;
44
+ user_id: string | null;
45
+ group_id: string | null;
46
+ authenticated_only: boolean;
47
+ permission: PermissionLevel;
48
+ created_at: Date;
49
+ };
50
+
51
+ // ==========================
52
+ // Helper Functions
53
+ // ==========================
54
+
55
+ /**
56
+ * Converts UUID strings into a PostgreSQL uuid[] literal for relation queries.
57
+ */
58
+ const toPgUuidArray = (values: string[] | null | undefined): string => {
59
+ if (!Array.isArray(values) || values.length === 0) return "{}";
60
+ return `{${values.join(",")}}`;
61
+ };
62
+
63
+ /**
64
+ * Builds a typed access principal from one database access row.
65
+ */
66
+ const principalFromDb = (row: DbAccess): Principal => {
67
+ if (row.user_id) return { type: "user", userId: row.user_id };
68
+ if (row.group_id) return { type: "group", groupId: row.group_id };
69
+ if (row.authenticated_only) return { type: "authenticated" };
70
+ return { type: "public" };
71
+ };
72
+
73
+ /**
74
+ * Maps raw access rows into the normalized AccessEntry shape used by app services.
75
+ */
76
+ const mapToAccessEntry = (row: DbAccess): AccessEntry => ({
77
+ id: row.id,
78
+ principal: principalFromDb(row),
79
+ permission: row.permission,
80
+ createdAt: row.created_at.toISOString(),
81
+ });
82
+
83
+ // ==========================
84
+ // Core Access Service
85
+ // ==========================
86
+
87
+ /**
88
+ * Create a new access entry.
89
+ * Returns the created entry ID.
90
+ */
91
+ export const createAccess = async (params: { principal: Principal; permission: PermissionLevel }): Promise<Result<{ id: string }>> => {
92
+ const { principal, permission } = params;
93
+
94
+ let userId: string | null = null;
95
+ let groupId: string | null = null;
96
+ let authenticatedOnly = false;
97
+
98
+ if (principal.type === "user") {
99
+ userId = principal.userId;
100
+ // Verify user exists
101
+ const [user] = await sql<{ id: string }[]>`
102
+ SELECT id FROM auth.users WHERE id = ${userId}::uuid
103
+ `;
104
+ if (!user) {
105
+ return fail(err.notFound("User"));
106
+ }
107
+ } else if (principal.type === "group") {
108
+ groupId = principal.groupId;
109
+ // Verify group exists
110
+ const [group] = await sql<{ id: string }[]>`
111
+ SELECT id FROM auth.groups WHERE id = ${groupId}::uuid
112
+ `;
113
+ if (!group) {
114
+ return fail(err.notFound("Group"));
115
+ }
116
+ } else if (principal.type === "authenticated") {
117
+ authenticatedOnly = true;
118
+ }
119
+ // public: user/group null, authenticated_only false
120
+
121
+ const [row] = await sql<{ id: string }[]>`
122
+ INSERT INTO auth.access (user_id, group_id, authenticated_only, permission)
123
+ VALUES (${userId}::uuid, ${groupId}::uuid, ${authenticatedOnly}, ${permission}::auth.permission_level)
124
+ RETURNING id
125
+ `;
126
+
127
+ if (!row) {
128
+ return fail(err.internal("Failed to create access entry"));
129
+ }
130
+
131
+ return ok({ id: row.id });
132
+ };
133
+
134
+ /**
135
+ * Get an access entry by ID.
136
+ */
137
+ export const getAccess = async (params: { id: string }): Promise<AccessEntry | null> => {
138
+ const [row] = await sql<DbAccess[]>`
139
+ SELECT id, user_id, group_id, authenticated_only, permission, created_at
140
+ FROM auth.access
141
+ WHERE id = ${params.id}::uuid
142
+ `;
143
+ return row ? mapToAccessEntry(row) : null;
144
+ };
145
+
146
+ /**
147
+ * Update an access entry's permission level.
148
+ */
149
+ export const updateAccess = async (params: { id: string; permission: PermissionLevel }): Promise<Result<void>> => {
150
+ const result = await sql`
151
+ UPDATE auth.access
152
+ SET permission = ${params.permission}::auth.permission_level
153
+ WHERE id = ${params.id}::uuid
154
+ `;
155
+
156
+ if (result.count === 0) {
157
+ return fail(err.notFound("Access entry"));
158
+ }
159
+
160
+ return ok();
161
+ };
162
+
163
+ /**
164
+ * Delete an access entry.
165
+ */
166
+ export const deleteAccess = async (params: { id: string }): Promise<Result<void>> => {
167
+ const result = await sql`
168
+ DELETE FROM auth.access
169
+ WHERE id = ${params.id}::uuid
170
+ `;
171
+
172
+ if (result.count === 0) {
173
+ return fail(err.notFound("Access entry"));
174
+ }
175
+
176
+ return ok();
177
+ };
178
+
179
+ // ==========================
180
+ // Resource Access Helpers
181
+ // ==========================
182
+
183
+ /**
184
+ * Generic interface for resource access junction tables.
185
+ * Each app implements this to connect their resources to auth.access.
186
+ */
187
+ export type ResourceAccessAdapter<TResourceId = string> = {
188
+ /** Get all access entries for a resource */
189
+ list: (resourceId: TResourceId) => Promise<AccessEntry[]>;
190
+ /** Add an access entry to a resource */
191
+ add: (resourceId: TResourceId, accessId: string) => Promise<Result<void>>;
192
+ /** Remove an access entry from a resource */
193
+ remove: (resourceId: TResourceId, accessId: string) => Promise<Result<void>>;
194
+ /** Count access entries for a resource */
195
+ count: (resourceId: TResourceId) => Promise<number>;
196
+ };
197
+
198
+ /**
199
+ * Get the effective permission level for a user on a resource.
200
+ * Returns the highest permission from:
201
+ * - Direct user access
202
+ * - Group memberships
203
+ * - Public access
204
+ */
205
+ export const getEffectivePermission = async (params: {
206
+ accessIds: string[];
207
+ userId: string | null;
208
+ userGroups: string[];
209
+ }): Promise<PermissionLevel> => {
210
+ const accessIds = params.accessIds ?? [];
211
+ const userId = params.userId;
212
+ const userGroups = params.userGroups ?? [];
213
+
214
+ if (accessIds.length === 0) return "none";
215
+
216
+ // Query all matching access entries
217
+ const rows = await sql<{ permission: PermissionLevel }[]>`
218
+ SELECT permission
219
+ FROM auth.access
220
+ WHERE id = ANY(${toPgUuidArray(accessIds)}::uuid[])
221
+ AND (
222
+ user_id = ${userId}::uuid
223
+ OR group_id = ANY(${toPgUuidArray(userGroups)}::uuid[])
224
+ OR (${userId}::uuid IS NOT NULL AND authenticated_only = true)
225
+ OR (user_id IS NULL AND group_id IS NULL AND authenticated_only = false)
226
+ )
227
+ ORDER BY
228
+ CASE permission
229
+ WHEN 'admin' THEN 4
230
+ WHEN 'write' THEN 3
231
+ WHEN 'read' THEN 2
232
+ WHEN 'none' THEN 1
233
+ END DESC
234
+ LIMIT 1
235
+ `;
236
+
237
+ return rows[0]?.permission ?? "none";
238
+ };
239
+
240
+ /**
241
+ * Resolve display names for access entries.
242
+ * Populates the displayName field based on principal type.
243
+ */
244
+ export const resolveDisplayNames = async (entries: AccessEntry[]): Promise<AccessEntry[]> => {
245
+ const userIds = entries.filter((e) => e.principal.type === "user").map((e) => (e.principal as { type: "user"; userId: string }).userId);
246
+
247
+ const groupIds = entries
248
+ .filter((e) => e.principal.type === "group")
249
+ .map((e) => (e.principal as { type: "group"; groupId: string }).groupId);
250
+
251
+ // Fetch user display names
252
+ const userNames = new Map<string, string>();
253
+ if (userIds.length > 0) {
254
+ const users = await sql<{ id: string; display_name: string; uid: string }[]>`
255
+ SELECT id, display_name, uid
256
+ FROM auth.users
257
+ WHERE id = ANY(${toPgUuidArray(userIds)}::uuid[])
258
+ `;
259
+ for (const u of users) {
260
+ userNames.set(u.id, u.display_name || u.uid);
261
+ }
262
+ }
263
+
264
+ const groupNames = new Map<string, string>();
265
+ if (groupIds.length > 0) {
266
+ const groups = await sql<{ id: string; name: string }[]>`
267
+ SELECT id, name
268
+ FROM auth.groups
269
+ WHERE id = ANY(${toPgUuidArray(groupIds)}::uuid[])
270
+ `;
271
+ for (const group of groups) {
272
+ groupNames.set(group.id, group.name);
273
+ }
274
+ }
275
+
276
+ return entries.map((entry) => {
277
+ let displayName: string;
278
+ switch (entry.principal.type) {
279
+ case "user":
280
+ displayName = userNames.get(entry.principal.userId) ?? "Unknown User";
281
+ break;
282
+ case "group":
283
+ displayName = groupNames.get(entry.principal.groupId) ?? "Unknown Group";
284
+ break;
285
+ case "authenticated":
286
+ displayName = "All users (incl. guests)";
287
+ break;
288
+ case "public":
289
+ displayName = "Public";
290
+ break;
291
+ }
292
+ return { ...entry, displayName };
293
+ });
294
+ };
@@ -0,0 +1,100 @@
1
+ import { getFreeIpaTls } from "./tls";
2
+
3
+ export type IpaRpcResult = {
4
+ result: unknown;
5
+ count: number;
6
+ truncated: boolean;
7
+ summary: string | null;
8
+ };
9
+
10
+ export type IpaRpcResponse = {
11
+ result: IpaRpcResult | null;
12
+ error: { code: number; message: string; name: string } | null;
13
+ id: number;
14
+ };
15
+
16
+ export const baseUrl = (url: string): string => `https://${url}`;
17
+
18
+ const isNoModificationError = (error: IpaRpcResponse["error"]): boolean =>
19
+ error?.code === 4202 && (error.message ?? "").toLowerCase().includes("no modifications to be performed");
20
+
21
+ export const call = async (
22
+ config: {
23
+ url: string;
24
+ ipaSession: string;
25
+ method: string;
26
+ args?: unknown[];
27
+ options?: Record<string, unknown>;
28
+ },
29
+ ): Promise<IpaRpcResponse> => {
30
+ const tls = await getFreeIpaTls();
31
+ const res = await fetch(`${baseUrl(config.url)}/ipa/session/json`, {
32
+ method: "POST",
33
+ headers: {
34
+ "Content-Type": "application/json",
35
+ Referer: `${baseUrl(config.url)}/ipa`,
36
+ Accept: "application/json",
37
+ Cookie: `ipa_session=${config.ipaSession}`,
38
+ },
39
+ body: JSON.stringify({
40
+ method: config.method,
41
+ params: [config.args ?? [], { ...(config.options ?? {}), version: "2.251" }],
42
+ id: 0,
43
+ }),
44
+ ...(tls ? { tls } : {}),
45
+ });
46
+
47
+ if (!res.ok || !res.headers.get("content-type")?.includes("json")) {
48
+ const text = await res.text();
49
+ console.error("[freeipa:client] Non-JSON response", {
50
+ method: config.method,
51
+ status: res.status,
52
+ body: text.slice(0, 200),
53
+ });
54
+
55
+ if (res.status === 401 || text.includes("Invalid Authentication") || text.includes("GSSAPI Error")) {
56
+ return {
57
+ result: null,
58
+ error: {
59
+ code: 403,
60
+ message: "Your IPA session has expired or is invalid. Please log out and log in again to refresh your session.",
61
+ name: "SessionExpired",
62
+ },
63
+ id: 0,
64
+ };
65
+ }
66
+
67
+ return {
68
+ result: null,
69
+ error: {
70
+ code: res.status,
71
+ message: "Non-JSON response from IPA",
72
+ name: "FetchError",
73
+ },
74
+ id: 0,
75
+ };
76
+ }
77
+
78
+ const response = (await res.json()) as IpaRpcResponse;
79
+ if (response.error) {
80
+ if (isNoModificationError(response.error)) {
81
+ return {
82
+ result: {
83
+ result: null,
84
+ count: 0,
85
+ truncated: false,
86
+ summary: response.error.message,
87
+ },
88
+ error: null,
89
+ id: response.id,
90
+ };
91
+ }
92
+
93
+ console.error("[freeipa:client] RPC failed", {
94
+ method: config.method,
95
+ code: response.error.code,
96
+ message: response.error.message,
97
+ });
98
+ }
99
+ return response;
100
+ };
@@ -0,0 +1,9 @@
1
+ import * as client from "./client";
2
+ import * as session from "./session";
3
+ import * as util from "./util";
4
+
5
+ export const freeipa = {
6
+ client,
7
+ session,
8
+ util,
9
+ } as const;
@@ -0,0 +1,78 @@
1
+ import { baseUrl, call } from "./client";
2
+ import { getFreeIpaTls, getFreeIpaTlsFingerprint } from "./tls";
3
+
4
+ export type LoginResult = { status: "success"; session: string } | { status: "password_expired" } | { status: "failed" };
5
+
6
+ export const login = async (config: { url: string; username: string; password: string }): Promise<LoginResult> => {
7
+ const tls = await getFreeIpaTls();
8
+ const res = await fetch(`${baseUrl(config.url)}/ipa/session/login_password`, {
9
+ method: "POST",
10
+ headers: {
11
+ "Content-Type": "application/x-www-form-urlencoded",
12
+ Referer: `${baseUrl(config.url)}/ipa`,
13
+ Accept: "text/plain",
14
+ },
15
+ body: new URLSearchParams({ user: config.username, password: config.password }),
16
+ redirect: "manual",
17
+ ...(tls ? { tls } : {}),
18
+ });
19
+
20
+ const rejectionReason = res.headers.get("X-IPA-Rejection-Reason");
21
+ if (rejectionReason === "password-expired") {
22
+ return { status: "password_expired" };
23
+ }
24
+
25
+ if (!res.ok && res.status !== 303) return { status: "failed" };
26
+
27
+ const cookies = res.headers.getSetCookie?.() ?? [];
28
+ for (const cookie of cookies) {
29
+ const match = cookie.match(/ipa_session=([^;]+)/);
30
+ if (match?.[1]) return { status: "success", session: match[1] };
31
+ }
32
+
33
+ const single = res.headers.get("set-cookie") ?? "";
34
+ const match = single.match(/ipa_session=([^;]+)/);
35
+ return match?.[1] ? { status: "success", session: match[1] } : { status: "failed" };
36
+ };
37
+
38
+ let svcSession: string | null = null;
39
+ let svcSessionPromise: Promise<string> | null = null;
40
+ let svcSessionKey: string | null = null;
41
+
42
+ export const getServiceSession = async (config: {
43
+ url: string;
44
+ serviceUser: string;
45
+ servicePassword: string;
46
+ }): Promise<string> => {
47
+ // Include TLS fingerprint so toggling allow_insecure or rotating ca_cert
48
+ // forces a re-login (otherwise the cached session keeps using the old TLS
49
+ // trust anchor for fetches that wouldn't have succeeded under it).
50
+ const currentKey = `${config.url}::${config.serviceUser}::${await getFreeIpaTlsFingerprint()}`;
51
+ if (svcSessionKey !== currentKey) {
52
+ svcSession = null;
53
+ svcSessionKey = currentKey;
54
+ }
55
+
56
+ if (svcSession) {
57
+ const check = await call({ url: config.url, ipaSession: svcSession, method: "ping", args: [] });
58
+ if (!check.error) return svcSession;
59
+ }
60
+ if (!svcSessionPromise) {
61
+ svcSessionPromise = (async () => {
62
+ const result = await login({
63
+ url: config.url,
64
+ username: config.serviceUser,
65
+ password: config.servicePassword,
66
+ });
67
+ if (result.status !== "success") {
68
+ console.error("[freeipa:session] Service account auth failed");
69
+ throw new Error("Failed to authenticate FreeIPA service account. Check freeipa.url/freeipa.service_user/freeipa.service_password.");
70
+ }
71
+ svcSession = result.session;
72
+ return result.session;
73
+ })().finally(() => {
74
+ svcSessionPromise = null;
75
+ });
76
+ }
77
+ return svcSessionPromise;
78
+ };