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