@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.
- package/package.json +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- package/src/ui/widgets/index.ts +9 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment variable parsing.
|
|
3
|
+
* All env vars are parsed and validated here, then exported as typed `env` object.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const str = (key: string, fallback: string = ""): string => process.env[key] ?? fallback;
|
|
7
|
+
|
|
8
|
+
const int = (key: string, fallback: number): number => {
|
|
9
|
+
const value = process.env[key];
|
|
10
|
+
const parsed = parseInt(value ?? "", 10);
|
|
11
|
+
return isNaN(parsed) ? fallback : parsed;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
export const env = {
|
|
16
|
+
// Application (infrastructure — not changeable at runtime)
|
|
17
|
+
APP_SECRET: str("APP_SECRET"),
|
|
18
|
+
PORT: int("PORT", 3000),
|
|
19
|
+
IS_DEVELOPMENT: process.env.NODE_ENV === "development",
|
|
20
|
+
|
|
21
|
+
// Admin login (local emergency access — token-based, no IPA required)
|
|
22
|
+
ADMIN_LOGIN_TOKEN: str("ADMIN_LOGIN_TOKEN"),
|
|
23
|
+
} as const;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR configuration and helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createConfig } from "@valentinkolb/ssr";
|
|
6
|
+
import { createSSRHandler } from "@valentinkolb/ssr/hono";
|
|
7
|
+
import { env } from "./env";
|
|
8
|
+
import { dirname, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
/** Cache-busting version stamp — changes on every server start / rebuild. */
|
|
12
|
+
const v = Date.now();
|
|
13
|
+
|
|
14
|
+
type PageOptions = {
|
|
15
|
+
title?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
theme?: "light" | "dark";
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const { config, plugin, html } = createConfig<PageOptions>({
|
|
21
|
+
dev: env.IS_DEVELOPMENT,
|
|
22
|
+
verbose: true,
|
|
23
|
+
// Scan all workspace packages so app islands are bundled too (not only core islands).
|
|
24
|
+
rootDir: resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."),
|
|
25
|
+
|
|
26
|
+
template: ({ body, scripts, title, description, theme }) => {
|
|
27
|
+
// If theme is explicitly set, don't let the script override it
|
|
28
|
+
const themeFixed = theme !== undefined;
|
|
29
|
+
return `<!DOCTYPE html>
|
|
30
|
+
<html lang="de" class="${theme ?? "light"}"${themeFixed ? " data-theme-fixed" : ""}>
|
|
31
|
+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
32
|
+
<meta name="view-transition" content="same-origin">
|
|
33
|
+
<title>${title ?? "StuVe"}</title>
|
|
34
|
+
<meta name="description" content="${description ?? "Cloud workspace"}">
|
|
35
|
+
<meta name="theme-color" content="#09090b">
|
|
36
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
37
|
+
<link rel="icon" href="/branding/favicon">
|
|
38
|
+
<link rel="stylesheet" href="/public/build.css?v=${v}">
|
|
39
|
+
<link rel="stylesheet" href="/public/katex.css?v=${v}">
|
|
40
|
+
<script>
|
|
41
|
+
(function() {
|
|
42
|
+
var el = document.documentElement;
|
|
43
|
+
if (!el.hasAttribute('data-theme-fixed')) {
|
|
44
|
+
var theme = document.cookie.match(/theme=([^;]+)/)?.[1] || 'light';
|
|
45
|
+
el.classList.add(theme);
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
</script>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
${body}
|
|
52
|
+
</body>
|
|
53
|
+
${scripts}
|
|
54
|
+
</html>`;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const ssr = createSSRHandler(html);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Role, User } from "./shared";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One link entry contributed by an app to the global legal/info footer
|
|
5
|
+
* (login page, app footer, rail dropdown). Aggregated across all running
|
|
6
|
+
* apps via `listLegalLinks()`.
|
|
7
|
+
*/
|
|
8
|
+
export type LegalLink = {
|
|
9
|
+
label: string;
|
|
10
|
+
href: string;
|
|
11
|
+
icon?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type AppMeta = {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
icon: string;
|
|
18
|
+
description: string;
|
|
19
|
+
adminHref?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Top-level URL prefixes the gateway routes to this app. The gateway
|
|
22
|
+
* is dumb — it just builds a prefix-trie from these strings.
|
|
23
|
+
*/
|
|
24
|
+
routes: readonly string[];
|
|
25
|
+
nav?: {
|
|
26
|
+
href: string;
|
|
27
|
+
match?: string;
|
|
28
|
+
section: "primary" | "more" | "hidden";
|
|
29
|
+
requiresAuth?: boolean;
|
|
30
|
+
requiresRoles?: Role[];
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Legal/info pages this app owns. Aggregated app-wide and rendered in
|
|
34
|
+
* login footer, app Footer, and the rail "more" dropdown. Each app
|
|
35
|
+
* contributes its own (e.g. settings → terms/privacy/imprint, faq → FAQ).
|
|
36
|
+
*/
|
|
37
|
+
legalLinks?: LegalLink[];
|
|
38
|
+
/**
|
|
39
|
+
* Dashboard widget endpoints this app exposes. Each entry references an
|
|
40
|
+
* HTTP endpoint that returns a `WidgetResponse` (see `contracts/widgets.ts`).
|
|
41
|
+
* The dashboard app fetches these with the user's session forwarded; the
|
|
42
|
+
* endpoint is responsible for permission / role gating and returns 204 to
|
|
43
|
+
* silently skip rendering for the current user.
|
|
44
|
+
*/
|
|
45
|
+
widgets?: WidgetEndpoint[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type WidgetEndpoint = {
|
|
49
|
+
/** Unique-within-the-app id, e.g. "open-requests". */
|
|
50
|
+
id: string;
|
|
51
|
+
/** Absolute path on the app's HTTP service, e.g. "/api/accounts/widget/open-requests". */
|
|
52
|
+
path: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type RuntimeAppMeta = AppMeta & {
|
|
56
|
+
searchTags?: string[];
|
|
57
|
+
searchHelp?: string;
|
|
58
|
+
searchTagHelp?: AppSearchTagHelpEntry[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type CloudLogger = {
|
|
62
|
+
debug: (message: string, metadata?: Record<string, unknown>) => void;
|
|
63
|
+
info: (message: string, metadata?: Record<string, unknown>) => void;
|
|
64
|
+
warn: (message: string, metadata?: Record<string, unknown>) => void;
|
|
65
|
+
error: (message: string, metadata?: Record<string, unknown>) => void;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type CloudRuntime = {
|
|
69
|
+
apps: readonly RuntimeAppMeta[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type CloudContext = {
|
|
73
|
+
logger: (source: string) => CloudLogger;
|
|
74
|
+
settings: {
|
|
75
|
+
get: <T = unknown>(key: string) => Promise<T>;
|
|
76
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
77
|
+
};
|
|
78
|
+
runtime: CloudRuntime;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type CloudLifecycleContext = CloudContext;
|
|
82
|
+
|
|
83
|
+
export type AppLifecycle = {
|
|
84
|
+
setup?: (ctx: CloudContext) => Promise<void>;
|
|
85
|
+
start?: (ctx: CloudContext) => Promise<void>;
|
|
86
|
+
stop?: (ctx: CloudContext) => Promise<void>;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type SearchPriority = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
|
90
|
+
|
|
91
|
+
export type AppSearchContext = {
|
|
92
|
+
get: <K extends "user" | "sessionToken">(key: K) => K extends "user" ? User : string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type AppSearchInput = {
|
|
96
|
+
query: string;
|
|
97
|
+
tags: string[];
|
|
98
|
+
limit: number;
|
|
99
|
+
ctx: AppSearchContext;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type AppSearchMetadataEntry = {
|
|
103
|
+
label: string;
|
|
104
|
+
value: string;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type AppSearchTagHelpEntry = {
|
|
108
|
+
tag: string;
|
|
109
|
+
help: string;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type AppSearchResult = {
|
|
113
|
+
id: string;
|
|
114
|
+
title: string;
|
|
115
|
+
href: string;
|
|
116
|
+
preview?: string;
|
|
117
|
+
icon?: string;
|
|
118
|
+
priority?: SearchPriority;
|
|
119
|
+
metadata?: AppSearchMetadataEntry[];
|
|
120
|
+
previewUrl?: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type AppCapabilities = {
|
|
124
|
+
search?: {
|
|
125
|
+
tags?: readonly string[];
|
|
126
|
+
help?: string;
|
|
127
|
+
tagHelp?: readonly AppSearchTagHelpEntry[];
|
|
128
|
+
run: (input: AppSearchInput) => Promise<AppSearchResult[]>;
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Removes query parameters from a navigation href so path matching stays stable.
|
|
134
|
+
*/
|
|
135
|
+
export const stripQuery = (href: string): string => href.split("?")[0] ?? href;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolves the active-path matcher for a nav entry.
|
|
139
|
+
*/
|
|
140
|
+
export const resolveNavMatch = (meta: AppMeta): string | undefined => meta.nav?.match ?? (meta.nav ? stripQuery(meta.nav.href) : undefined);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schemas for account profile / password / request inputs. Live in core
|
|
5
|
+
* contracts so both the core `/api/me` router and the accounts admin app can
|
|
6
|
+
* consume the same shapes without cross-app imports.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SSH_PUBLIC_KEY_PATTERN = /^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-nistp(256|384|521))\s+[A-Za-z0-9+/=]+(?:\s+.+)?$/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* IPA-only self-service fields. Exposed so an admin schema can compose the
|
|
13
|
+
* same inner shape without extending the refined self-service schema.
|
|
14
|
+
*/
|
|
15
|
+
export const IpaProfileFieldsSchema = z.object({
|
|
16
|
+
phone: z.string().optional(),
|
|
17
|
+
address: z
|
|
18
|
+
.object({
|
|
19
|
+
street: z.string().optional(),
|
|
20
|
+
postalCode: z.string().optional(),
|
|
21
|
+
city: z.string().optional(),
|
|
22
|
+
state: z.string().optional(),
|
|
23
|
+
})
|
|
24
|
+
.optional(),
|
|
25
|
+
sshPublicKeys: z.array(z.string().regex(SSH_PUBLIC_KEY_PATTERN, "Invalid SSH public key format")).optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const UpdateProfileSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
givenname: z.string().min(1).optional(),
|
|
31
|
+
sn: z.string().min(1).optional(),
|
|
32
|
+
displayName: z.string().min(1).optional(),
|
|
33
|
+
ipa: IpaProfileFieldsSchema.optional(),
|
|
34
|
+
})
|
|
35
|
+
.refine(
|
|
36
|
+
(data) => data.givenname !== undefined || data.sn !== undefined || data.displayName !== undefined || data.ipa !== undefined,
|
|
37
|
+
{ message: "At least one profile field must be provided" },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export const ChangePasswordSchema = z
|
|
41
|
+
.object({
|
|
42
|
+
currentPassword: z.string().min(1),
|
|
43
|
+
newPassword: z.string().min(8),
|
|
44
|
+
confirmPassword: z.string().min(1),
|
|
45
|
+
})
|
|
46
|
+
.refine((data) => data.newPassword === data.confirmPassword, {
|
|
47
|
+
message: "Passwords do not match",
|
|
48
|
+
path: ["confirmPassword"],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const ChangeExpiredPasswordSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
username: z.string().min(1),
|
|
54
|
+
currentPassword: z.string().min(1),
|
|
55
|
+
newPassword: z.string().min(8),
|
|
56
|
+
confirmPassword: z.string().min(1),
|
|
57
|
+
})
|
|
58
|
+
.refine((data) => data.newPassword === data.confirmPassword, {
|
|
59
|
+
message: "Passwords do not match",
|
|
60
|
+
path: ["confirmPassword"],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const CreateAccountRequestSchema = z.object({
|
|
64
|
+
phone: z.string().optional().describe("Optional phone number for the request"),
|
|
65
|
+
comment: z.string().optional().describe("Why do you need a FreeIPA account?"),
|
|
66
|
+
acceptedAgb: z.literal(true).describe("Must accept terms of service"),
|
|
67
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-registry entry type. Populated internally by `defineApp()` + the
|
|
3
|
+
* heartbeat runtime; never parsed from external input — plain TS types are
|
|
4
|
+
* enough for type safety (no runtime validation needed).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type AppRegistryNav = {
|
|
8
|
+
href: string;
|
|
9
|
+
match?: string;
|
|
10
|
+
section: "primary" | "more" | "hidden";
|
|
11
|
+
requiresAuth?: boolean;
|
|
12
|
+
requiresRoles?: string[];
|
|
13
|
+
adminHref?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type AppRegistrySearch = {
|
|
17
|
+
tags: string[];
|
|
18
|
+
help: string;
|
|
19
|
+
tagHelp: Array<{ tag: string; help: string }>;
|
|
20
|
+
endpoint: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AppRegistryLegalLink = {
|
|
24
|
+
label: string;
|
|
25
|
+
href: string;
|
|
26
|
+
icon?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type AppRegistryWidget = {
|
|
30
|
+
id: string;
|
|
31
|
+
/** Absolute path on the app's HTTP service, e.g. "/api/quotes/widget/random". */
|
|
32
|
+
path: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type AppRegistryEntry = {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
icon: string;
|
|
39
|
+
description: string;
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
/**
|
|
42
|
+
* Top-level URL prefixes the gateway routes to this app. The gateway
|
|
43
|
+
* builds a prefix-trie from these strings, no derivation or heuristics.
|
|
44
|
+
*/
|
|
45
|
+
routes: readonly string[];
|
|
46
|
+
nav?: AppRegistryNav;
|
|
47
|
+
search?: AppRegistrySearch;
|
|
48
|
+
legalLinks?: AppRegistryLegalLink[];
|
|
49
|
+
widgets?: AppRegistryWidget[];
|
|
50
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level scaffolding for `defineApp({ settings: { ... } })`.
|
|
3
|
+
*
|
|
4
|
+
* These types let TypeScript derive a per-app `Settings` shape from the literal
|
|
5
|
+
* settings declaration in defineApp. The derived shape is:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Flat** — `{ "files.filegate_url": string, "freeipa.enable": boolean, ... }`
|
|
8
|
+
* (used by the typed async API: `app.settings.get("files.filegate_url")`)
|
|
9
|
+
*
|
|
10
|
+
* 2. **Nested + readonly** — `{ files: { filegate_url: string }, freeipa: { enable: boolean } }`
|
|
11
|
+
* (used by the per-request snapshot exposed via `c.get("settings")`)
|
|
12
|
+
*
|
|
13
|
+
* No runtime in this file — pure TypeScript.
|
|
14
|
+
*/
|
|
15
|
+
import type { SettingOption } from "../services/settings/defaults";
|
|
16
|
+
|
|
17
|
+
// ── Setting definition shape (what users write inside defineApp.settings) ───
|
|
18
|
+
|
|
19
|
+
type EnvResolver = () => unknown;
|
|
20
|
+
|
|
21
|
+
type CommonDef = {
|
|
22
|
+
label?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
envBootstrap?: EnvResolver;
|
|
25
|
+
envFallback?: EnvResolver;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type StringLikeKind = "string" | "text" | "email" | "url" | "secret" | "image" | "cron" | "timezone" | "template";
|
|
29
|
+
|
|
30
|
+
export type AppSettingDef =
|
|
31
|
+
| (CommonDef & { kind: StringLikeKind; default: string; placeholder?: string; templateVars?: readonly string[] })
|
|
32
|
+
| (CommonDef & { kind: "boolean"; default: boolean })
|
|
33
|
+
| (CommonDef & { kind: "number"; default: number; min?: number; max?: number; placeholder?: string })
|
|
34
|
+
| (CommonDef & { kind: "enum"; default: string; options: ReadonlyArray<SettingOption> })
|
|
35
|
+
| (CommonDef & { kind: "string_list"; default: readonly string[]; placeholder?: string })
|
|
36
|
+
| (CommonDef & { kind: "number_list"; default: readonly number[]; placeholder?: string });
|
|
37
|
+
|
|
38
|
+
/** A map of setting-key → definition. The key strings are dotted paths like "files.filegate_url". */
|
|
39
|
+
export type AppSettingsMap = Record<string, AppSettingDef>;
|
|
40
|
+
|
|
41
|
+
// ── Type-level transforms ───────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Map a setting `kind` literal to the runtime value type returned by reads. */
|
|
44
|
+
export type KindToType<K extends string> =
|
|
45
|
+
K extends "boolean" ? boolean :
|
|
46
|
+
K extends "number" ? number :
|
|
47
|
+
K extends "string_list" ? string[] :
|
|
48
|
+
K extends "number_list" ? number[] :
|
|
49
|
+
string;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Derive a flat `{ key: value }` map from a SettingsMap.
|
|
53
|
+
*
|
|
54
|
+
* Example:
|
|
55
|
+
* SettingsFlat<{ "app.name": { kind: "string"; default: "" } }>
|
|
56
|
+
* = { readonly "app.name": string }
|
|
57
|
+
*/
|
|
58
|
+
export type SettingsFlat<S extends Record<string, { kind: string }>> = {
|
|
59
|
+
readonly [K in keyof S]: KindToType<S[K]["kind"]>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Recursively un-flatten dotted-key maps into nested objects.
|
|
64
|
+
*
|
|
65
|
+
* Example:
|
|
66
|
+
* Unflatten<{ "files.filegate_url": string; "files.base_homes": string; "app.name": string }>
|
|
67
|
+
* = { readonly files: { readonly filegate_url: string; readonly base_homes: string }, readonly app: { readonly name: string } }
|
|
68
|
+
*/
|
|
69
|
+
export type Unflatten<T extends Record<string, unknown>> = {
|
|
70
|
+
readonly [K in Extract<keyof T, string> as K extends `${infer Head}.${string}` ? Head : K]:
|
|
71
|
+
K extends `${infer Head}.${string}`
|
|
72
|
+
? Unflatten<{ [P in Extract<keyof T, `${Head}.${string}`> as P extends `${Head}.${infer R}` ? R : never]: T[P] }>
|
|
73
|
+
: T[K];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Final per-app Settings shape: nested, readonly, derived from the App type.
|
|
78
|
+
*
|
|
79
|
+
* Used by `AppContext<App>` to type `c.get("settings")` correctly per-app.
|
|
80
|
+
*/
|
|
81
|
+
export type AppSettings<App> =
|
|
82
|
+
App extends { readonly _settings: infer S extends Record<string, { kind: string }> }
|
|
83
|
+
? Unflatten<SettingsFlat<S>>
|
|
84
|
+
: never;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const RoleSchema = z.enum([
|
|
4
|
+
"admin",
|
|
5
|
+
"ipa",
|
|
6
|
+
"guest",
|
|
7
|
+
"group-manager",
|
|
8
|
+
"local",
|
|
9
|
+
"user",
|
|
10
|
+
"ipa/user",
|
|
11
|
+
"ipa/guest",
|
|
12
|
+
"local/user",
|
|
13
|
+
"local/guest",
|
|
14
|
+
]);
|
|
15
|
+
export type Role = z.infer<typeof RoleSchema>;
|
|
16
|
+
|
|
17
|
+
export const UserProviderSchema = z.enum(["ipa", "local"]);
|
|
18
|
+
export type UserProvider = z.infer<typeof UserProviderSchema>;
|
|
19
|
+
|
|
20
|
+
export const UserProfileSchema = z.enum(["user", "guest"]);
|
|
21
|
+
export type UserProfile = z.infer<typeof UserProfileSchema>;
|
|
22
|
+
|
|
23
|
+
export const SpecialRoleSchema = z.enum(["*", "authenticated", "anonymous"]);
|
|
24
|
+
export type SpecialRole = z.infer<typeof SpecialRoleSchema>;
|
|
25
|
+
export type RoleOrSpecial = Role | SpecialRole;
|
|
26
|
+
|
|
27
|
+
export const hasRole = (user: { roles: Role[] }, ...roles: Role[]): boolean => roles.some((role) => user.roles.includes(role));
|
|
28
|
+
|
|
29
|
+
export const BaseUserSchema = z.object({
|
|
30
|
+
id: z.string(),
|
|
31
|
+
uid: z.string(),
|
|
32
|
+
roles: z.array(RoleSchema),
|
|
33
|
+
provider: UserProviderSchema,
|
|
34
|
+
profile: UserProfileSchema,
|
|
35
|
+
givenname: z.string(),
|
|
36
|
+
sn: z.string(),
|
|
37
|
+
displayName: z.string(),
|
|
38
|
+
mail: z.string().nullable(),
|
|
39
|
+
});
|
|
40
|
+
export type BaseUser = z.infer<typeof BaseUserSchema>;
|
|
41
|
+
|
|
42
|
+
export const IpaUserDataSchema = z.object({
|
|
43
|
+
uidNumber: z.number().nullable(),
|
|
44
|
+
phone: z.string().nullable(),
|
|
45
|
+
employeeType: z.string().nullable(),
|
|
46
|
+
mobile: z.string().nullable(),
|
|
47
|
+
address: z.object({
|
|
48
|
+
street: z.string().nullable(),
|
|
49
|
+
postalCode: z.string().nullable(),
|
|
50
|
+
city: z.string().nullable(),
|
|
51
|
+
state: z.string().nullable(),
|
|
52
|
+
}),
|
|
53
|
+
passwordExpires: z.string().nullable(),
|
|
54
|
+
lastLoginIpa: z.string().nullable(),
|
|
55
|
+
syncedAt: z.string().nullable(),
|
|
56
|
+
sshPublicKeys: z.array(z.string()),
|
|
57
|
+
sshFingerprints: z.array(z.string()),
|
|
58
|
+
});
|
|
59
|
+
export type IpaUserData = z.infer<typeof IpaUserDataSchema>;
|
|
60
|
+
|
|
61
|
+
const RichUserFields = {
|
|
62
|
+
accountExpires: z.string().nullable(),
|
|
63
|
+
lastLoginLocal: z.string().nullable(),
|
|
64
|
+
memberofGroup: z.array(z.string()),
|
|
65
|
+
memberofGroupIds: z.array(z.uuid()),
|
|
66
|
+
manages: z.array(z.string()),
|
|
67
|
+
managesGroupIds: z.array(z.uuid()),
|
|
68
|
+
} satisfies z.ZodRawShape;
|
|
69
|
+
|
|
70
|
+
export const IpaUserSchema = BaseUserSchema.extend({
|
|
71
|
+
provider: z.literal("ipa"),
|
|
72
|
+
ipa: IpaUserDataSchema,
|
|
73
|
+
...RichUserFields,
|
|
74
|
+
});
|
|
75
|
+
export type IpaUser = z.infer<typeof IpaUserSchema>;
|
|
76
|
+
|
|
77
|
+
export const LocalUserSchema = BaseUserSchema.extend({
|
|
78
|
+
provider: z.literal("local"),
|
|
79
|
+
ipa: z.null(),
|
|
80
|
+
...RichUserFields,
|
|
81
|
+
});
|
|
82
|
+
export type LocalUser = z.infer<typeof LocalUserSchema>;
|
|
83
|
+
|
|
84
|
+
export const UserSchema = z.discriminatedUnion("provider", [IpaUserSchema, LocalUserSchema]);
|
|
85
|
+
export type User = z.infer<typeof UserSchema>;
|
|
86
|
+
|
|
87
|
+
export const BaseGroupSchema = z.object({
|
|
88
|
+
id: z.uuid(),
|
|
89
|
+
provider: UserProviderSchema,
|
|
90
|
+
name: z.string(),
|
|
91
|
+
description: z.string().nullable(),
|
|
92
|
+
gidnumber: z.number().nullable(),
|
|
93
|
+
});
|
|
94
|
+
export type BaseGroup = z.infer<typeof BaseGroupSchema>;
|
|
95
|
+
|
|
96
|
+
export const EntityKindSchema = z.enum(["user", "group"]);
|
|
97
|
+
export type EntityKind = z.infer<typeof EntityKindSchema>;
|
|
98
|
+
|
|
99
|
+
export const EntityRelationSchema = z.object({
|
|
100
|
+
direct: z.boolean().optional(),
|
|
101
|
+
});
|
|
102
|
+
export type EntityRelation = z.infer<typeof EntityRelationSchema>;
|
|
103
|
+
|
|
104
|
+
export const EntityListItemSchema = z.discriminatedUnion("kind", [
|
|
105
|
+
z.object({
|
|
106
|
+
kind: z.literal("user"),
|
|
107
|
+
user: BaseUserSchema,
|
|
108
|
+
relation: EntityRelationSchema.optional(),
|
|
109
|
+
}),
|
|
110
|
+
z.object({
|
|
111
|
+
kind: z.literal("group"),
|
|
112
|
+
group: BaseGroupSchema,
|
|
113
|
+
relation: EntityRelationSchema.optional(),
|
|
114
|
+
}),
|
|
115
|
+
]);
|
|
116
|
+
export type EntityListItem = z.infer<typeof EntityListItemSchema>;
|
|
117
|
+
|
|
118
|
+
export const GroupMemberSchema = z.object({
|
|
119
|
+
type: z.enum(["user", "group"]),
|
|
120
|
+
id: z.string(),
|
|
121
|
+
displayName: z.string().nullable(),
|
|
122
|
+
});
|
|
123
|
+
export type GroupMember = z.infer<typeof GroupMemberSchema>;
|
|
124
|
+
|
|
125
|
+
export const SearchQuerySchema = z.object({
|
|
126
|
+
search: z.string().optional(),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export const PaginationQuerySchema = z.object({
|
|
130
|
+
page: z.coerce.number().int().positive().optional().default(1),
|
|
131
|
+
per_page: z.coerce.number().int().min(1).max(100).optional().default(20),
|
|
132
|
+
});
|
|
133
|
+
export type PaginationQuery = z.infer<typeof PaginationQuerySchema>;
|
|
134
|
+
|
|
135
|
+
export const PaginationResponseSchema = z.object({
|
|
136
|
+
page: z.number(),
|
|
137
|
+
per_page: z.number(),
|
|
138
|
+
total: z.number(),
|
|
139
|
+
total_pages: z.number(),
|
|
140
|
+
has_next: z.boolean(),
|
|
141
|
+
});
|
|
142
|
+
export type PaginationResponse = z.infer<typeof PaginationResponseSchema>;
|
|
143
|
+
|
|
144
|
+
export type PaginationParams = {
|
|
145
|
+
page: number;
|
|
146
|
+
perPage: number;
|
|
147
|
+
offset: number;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const parsePagination = (query: { page?: number; per_page?: number }): PaginationParams => {
|
|
151
|
+
const page = query.page ?? 1;
|
|
152
|
+
const perPage = query.per_page ?? 20;
|
|
153
|
+
const offset = (page - 1) * perPage;
|
|
154
|
+
return { page, perPage, offset };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const createPagination = (params: PaginationParams, total: number): PaginationResponse => {
|
|
158
|
+
const totalPages = Math.ceil(total / params.perPage);
|
|
159
|
+
return {
|
|
160
|
+
page: params.page,
|
|
161
|
+
per_page: params.perPage,
|
|
162
|
+
total,
|
|
163
|
+
total_pages: totalPages,
|
|
164
|
+
has_next: params.page < totalPages,
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const ErrorResponseSchema = z.object({
|
|
169
|
+
message: z.string(),
|
|
170
|
+
code: z.string().optional(),
|
|
171
|
+
});
|
|
172
|
+
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
|
|
173
|
+
|
|
174
|
+
export const MessageResponseSchema = z.object({
|
|
175
|
+
message: z.string(),
|
|
176
|
+
});
|
|
177
|
+
export type MessageResponse = z.infer<typeof MessageResponseSchema>;
|
|
178
|
+
|
|
179
|
+
export type MutationResult<T = void> = { ok: true; data: T } | { ok: false; error: string; status: 400 | 401 | 403 | 404 | 409 | 500 };
|
|
180
|
+
|
|
181
|
+
export const PermissionLevelSchema = z.enum(["none", "read", "write", "admin"]);
|
|
182
|
+
export type PermissionLevel = z.infer<typeof PermissionLevelSchema>;
|
|
183
|
+
|
|
184
|
+
export const PrincipalSchema = z.discriminatedUnion("type", [
|
|
185
|
+
z.object({ type: z.literal("user"), userId: z.uuid() }),
|
|
186
|
+
z.object({ type: z.literal("group"), groupId: z.uuid() }),
|
|
187
|
+
z.object({ type: z.literal("authenticated") }),
|
|
188
|
+
z.object({ type: z.literal("public") }),
|
|
189
|
+
]);
|
|
190
|
+
export type Principal = z.infer<typeof PrincipalSchema>;
|
|
191
|
+
|
|
192
|
+
export const AccessEntrySchema = z.object({
|
|
193
|
+
id: z.uuid(),
|
|
194
|
+
principal: PrincipalSchema,
|
|
195
|
+
permission: PermissionLevelSchema,
|
|
196
|
+
createdAt: z.string(),
|
|
197
|
+
displayName: z.string().optional(),
|
|
198
|
+
});
|
|
199
|
+
export type AccessEntry = z.infer<typeof AccessEntrySchema>;
|
|
200
|
+
|
|
201
|
+
export const NotebookPresenceParticipantSchema = z.object({
|
|
202
|
+
userId: z.uuid(),
|
|
203
|
+
displayName: z.string(),
|
|
204
|
+
color: z.string(),
|
|
205
|
+
peerCount: z.number().int().positive(),
|
|
206
|
+
joinedAt: z.string(),
|
|
207
|
+
});
|
|
208
|
+
export type NotebookPresenceParticipant = z.infer<typeof NotebookPresenceParticipantSchema>;
|
|
209
|
+
|
|
210
|
+
export const GrantAccessSchema = z.object({
|
|
211
|
+
principal: PrincipalSchema,
|
|
212
|
+
permission: PermissionLevelSchema,
|
|
213
|
+
});
|
|
214
|
+
export type GrantAccess = z.infer<typeof GrantAccessSchema>;
|
|
215
|
+
|
|
216
|
+
export const UpdateAccessSchema = z.object({
|
|
217
|
+
permission: PermissionLevelSchema,
|
|
218
|
+
});
|
|
219
|
+
export type UpdateAccess = z.infer<typeof UpdateAccessSchema>;
|
|
220
|
+
|
|
221
|
+
// ── Settings (browser-safe types) ────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
export type SettingKind =
|
|
224
|
+
| "string"
|
|
225
|
+
| "text"
|
|
226
|
+
| "email"
|
|
227
|
+
| "url"
|
|
228
|
+
| "secret"
|
|
229
|
+
| "image"
|
|
230
|
+
| "boolean"
|
|
231
|
+
| "number"
|
|
232
|
+
| "enum"
|
|
233
|
+
| "string_list"
|
|
234
|
+
| "number_list"
|
|
235
|
+
| "cron"
|
|
236
|
+
| "timezone"
|
|
237
|
+
| "template";
|
|
238
|
+
|
|
239
|
+
export type SettingOption = {
|
|
240
|
+
value: string;
|
|
241
|
+
label: string;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export type SettingEntry = {
|
|
245
|
+
key: string;
|
|
246
|
+
label: string;
|
|
247
|
+
kind: SettingKind;
|
|
248
|
+
description: string;
|
|
249
|
+
placeholder?: string;
|
|
250
|
+
group: string;
|
|
251
|
+
value: unknown;
|
|
252
|
+
default: unknown;
|
|
253
|
+
isCustom: boolean;
|
|
254
|
+
templateVars?: string[];
|
|
255
|
+
options?: SettingOption[];
|
|
256
|
+
min?: number;
|
|
257
|
+
max?: number;
|
|
258
|
+
};
|