@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,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings service — runtime-configurable settings backed by Postgres.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order: DB value -> env fallback -> code default.
|
|
5
|
+
*
|
|
6
|
+
* Custom DB values are encrypted at rest using APP_SECRET. All read/write
|
|
7
|
+
* goes through the Redis cache-aside layer in `./store.ts` (5-minute TTL).
|
|
8
|
+
* `loadCache()` runs once at boot to normalize legacy rows and bootstrap
|
|
9
|
+
* env-backed entries into Postgres.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { sql } from "bun";
|
|
13
|
+
import { env } from "../../config/env";
|
|
14
|
+
import { encryptValue, decryptValue, getAppSecret } from "./crypto";
|
|
15
|
+
import {
|
|
16
|
+
SETTINGS,
|
|
17
|
+
SETTINGS_MAP,
|
|
18
|
+
getSettingLabel,
|
|
19
|
+
type SettingDef,
|
|
20
|
+
type SettingKind,
|
|
21
|
+
type SettingOption,
|
|
22
|
+
validateSettingValue,
|
|
23
|
+
} from "./defaults";
|
|
24
|
+
import { readKey, writeKey, deleteKey, bulkRead } from "./store";
|
|
25
|
+
|
|
26
|
+
type StoredRow = { key: string; value: string };
|
|
27
|
+
type PendingRow = { key: string; value: unknown; rewrite: boolean; existed: boolean };
|
|
28
|
+
type NormalizationStats = {
|
|
29
|
+
encryptedLoaded: number;
|
|
30
|
+
normalizedUpdated: number;
|
|
31
|
+
envBootstrapped: number;
|
|
32
|
+
invalidSkipped: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const isEqual = (left: unknown, right: unknown): boolean => JSON.stringify(left) === JSON.stringify(right);
|
|
36
|
+
|
|
37
|
+
const resolveEnvValue = (def: SettingDef | undefined, mode: "fallback" | "bootstrap"): unknown => {
|
|
38
|
+
const raw = mode === "fallback" ? def?.envFallback?.() : def?.envBootstrap?.();
|
|
39
|
+
if (!def || raw === undefined) return undefined;
|
|
40
|
+
|
|
41
|
+
const validated = validateSettingValue(def, raw);
|
|
42
|
+
if (!validated.ok) {
|
|
43
|
+
console.warn(`[settings] ignoring invalid ${mode} value for "${def.key}": ${validated.error}`);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return validated.value;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const shouldBootstrapValue = (def: SettingDef, value: unknown): boolean => {
|
|
51
|
+
if (value === undefined || value === null) return false;
|
|
52
|
+
|
|
53
|
+
switch (def.kind) {
|
|
54
|
+
case "boolean":
|
|
55
|
+
return value === true;
|
|
56
|
+
case "number":
|
|
57
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
58
|
+
case "string_list":
|
|
59
|
+
case "number_list":
|
|
60
|
+
return Array.isArray(value) && value.length > 0;
|
|
61
|
+
default:
|
|
62
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const upsertEncryptedRow = async (key: string, value: unknown): Promise<void> => {
|
|
67
|
+
const encryptedValue = await encryptValue(value);
|
|
68
|
+
await sql`
|
|
69
|
+
INSERT INTO settings.entries (key, value, updated_at)
|
|
70
|
+
VALUES (${key}, ${encryptedValue}, now())
|
|
71
|
+
ON CONFLICT (key)
|
|
72
|
+
DO UPDATE SET value = ${encryptedValue}, updated_at = now()
|
|
73
|
+
`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const normalizeStoredEntries = async (): Promise<{ rows: Array<{ key: string; value: unknown }>; stats: NormalizationStats }> => {
|
|
77
|
+
const rows = await sql<StoredRow[]>`SELECT key, value FROM settings.entries`;
|
|
78
|
+
const stats: NormalizationStats = {
|
|
79
|
+
encryptedLoaded: 0,
|
|
80
|
+
normalizedUpdated: 0,
|
|
81
|
+
envBootstrapped: 0,
|
|
82
|
+
invalidSkipped: 0,
|
|
83
|
+
};
|
|
84
|
+
const pendingRows = new Map<string, PendingRow>();
|
|
85
|
+
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
try {
|
|
88
|
+
const value = await decryptValue(row.value);
|
|
89
|
+
pendingRows.set(row.key, { key: row.key, value, rewrite: false, existed: true });
|
|
90
|
+
stats.encryptedLoaded += 1;
|
|
91
|
+
} catch {
|
|
92
|
+
console.warn(`[settings] skipping invalid stored value for "${row.key}"`);
|
|
93
|
+
stats.invalidSkipped += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const normalized: Array<{ key: string; value: unknown }> = [];
|
|
98
|
+
|
|
99
|
+
for (const [key, row] of pendingRows.entries()) {
|
|
100
|
+
const def = SETTINGS_MAP.get(key);
|
|
101
|
+
if (!def) {
|
|
102
|
+
console.warn(`[settings] skipping unknown stored key "${key}"`);
|
|
103
|
+
stats.invalidSkipped += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const validated = validateSettingValue(def, row.value);
|
|
108
|
+
if (!validated.ok) {
|
|
109
|
+
console.warn(`[settings] skipping invalid value for "${key}": ${validated.error}`);
|
|
110
|
+
stats.invalidSkipped += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
normalized.push({ key, value: validated.value });
|
|
115
|
+
|
|
116
|
+
if (row.rewrite || !row.existed || !isEqual(row.value, validated.value)) {
|
|
117
|
+
await upsertEncryptedRow(key, validated.value);
|
|
118
|
+
stats.normalizedUpdated += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { rows: normalized, stats };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const bootstrapEnvBackedEntries = async (config: {
|
|
126
|
+
rows: Array<{ key: string; value: unknown }>;
|
|
127
|
+
stats: NormalizationStats;
|
|
128
|
+
}): Promise<Array<{ key: string; value: unknown }>> => {
|
|
129
|
+
const rowsByKey = new Map(config.rows.map((row) => [row.key, row.value]));
|
|
130
|
+
|
|
131
|
+
for (const def of SETTINGS) {
|
|
132
|
+
if (rowsByKey.has(def.key)) continue;
|
|
133
|
+
|
|
134
|
+
const envValue = resolveEnvValue(def, "bootstrap");
|
|
135
|
+
if (!shouldBootstrapValue(def, envValue)) continue;
|
|
136
|
+
|
|
137
|
+
await upsertEncryptedRow(def.key, envValue);
|
|
138
|
+
rowsByKey.set(def.key, envValue);
|
|
139
|
+
config.stats.envBootstrapped += 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return [...rowsByKey.entries()].map(([key, value]) => ({ key, value }));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Boot-time normalization + env-bootstrap. Runs once at startup. Does NOT
|
|
147
|
+
* populate any in-process cache — every read goes through Redis cache-aside
|
|
148
|
+
* (`store.ts`) at request time.
|
|
149
|
+
*/
|
|
150
|
+
export async function loadCache(): Promise<void> {
|
|
151
|
+
getAppSecret();
|
|
152
|
+
const { rows, stats } = await normalizeStoredEntries();
|
|
153
|
+
const bootstrappedRows = await bootstrapEnvBackedEntries({ rows, stats });
|
|
154
|
+
|
|
155
|
+
console.log(
|
|
156
|
+
`[settings] loaded ${bootstrappedRows.length} custom setting(s) (${stats.encryptedLoaded} encrypted, ${stats.normalizedUpdated} normalized, ${stats.envBootstrapped} env-bootstrapped, ${stats.invalidSkipped} skipped)`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Read a setting via the Redis cache-aside layer (always within Redis-TTL
|
|
162
|
+
* fresh). Resolution: Redis cache → Postgres → env-fallback → code default.
|
|
163
|
+
*/
|
|
164
|
+
export async function get<T = unknown>(key: string): Promise<T> {
|
|
165
|
+
return readKey(key) as Promise<T>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function set(key: string, value: unknown): Promise<void> {
|
|
169
|
+
await writeKey(key, value);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function remove(key: string): Promise<void> {
|
|
173
|
+
await deleteKey(key);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
import type { SettingEntry } from "../../contracts/shared";
|
|
177
|
+
export type { SettingEntry } from "../../contracts/shared";
|
|
178
|
+
|
|
179
|
+
export async function getAll(): Promise<SettingEntry[]> {
|
|
180
|
+
// Determine which keys have a custom row in Postgres (vs. falling back to
|
|
181
|
+
// env / code default). One indexed scan, then bulk-read all values.
|
|
182
|
+
const customRows = await sql<{ key: string }[]>`SELECT key FROM settings.entries`;
|
|
183
|
+
const customKeys = new Set(customRows.map((row) => row.key));
|
|
184
|
+
|
|
185
|
+
const allKeys = SETTINGS.map((def) => def.key);
|
|
186
|
+
const values = await bulkRead(allKeys);
|
|
187
|
+
|
|
188
|
+
return SETTINGS.map((def) => ({
|
|
189
|
+
key: def.key,
|
|
190
|
+
label: getSettingLabel(def),
|
|
191
|
+
kind: def.kind,
|
|
192
|
+
description: def.description,
|
|
193
|
+
placeholder: def.placeholder,
|
|
194
|
+
group: def.group,
|
|
195
|
+
value: values.get(def.key),
|
|
196
|
+
default: def.default,
|
|
197
|
+
isCustom: customKeys.has(def.key),
|
|
198
|
+
templateVars: "templateVars" in def ? def.templateVars : undefined,
|
|
199
|
+
options: "options" in def ? def.options : undefined,
|
|
200
|
+
min: "min" in def ? def.min : undefined,
|
|
201
|
+
max: "max" in def ? def.max : undefined,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request settings snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Loads every known setting via `bulkRead` (one Redis MGET + DB fallback for
|
|
5
|
+
* misses) and builds a frozen nested object keyed by dotted-path segments.
|
|
6
|
+
*
|
|
7
|
+
* Used by the middleware that runs at request start (see `define-app.ts`).
|
|
8
|
+
* Exposed on the Hono context as `c.get("settings")` — typed per-app via
|
|
9
|
+
* `AppContext<typeof app>`.
|
|
10
|
+
*
|
|
11
|
+
* The snapshot is read-only and stable for the duration of one request:
|
|
12
|
+
* later writes (in this or other containers) do not mutate this snapshot.
|
|
13
|
+
* If a long-running handler needs fresh values, it should use the typed
|
|
14
|
+
* async API (`app.settings.get(key)`) instead.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { allKnownKeys, bulkRead } from "./store";
|
|
18
|
+
|
|
19
|
+
/** Build a frozen nested object from the registered settings. */
|
|
20
|
+
export const loadSnapshot = async (): Promise<Readonly<Record<string, unknown>>> => {
|
|
21
|
+
const flat = await bulkRead(allKnownKeys());
|
|
22
|
+
|
|
23
|
+
const tree: Record<string, unknown> = {};
|
|
24
|
+
for (const [key, value] of flat) {
|
|
25
|
+
const parts = key.split(".");
|
|
26
|
+
let cursor = tree;
|
|
27
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
28
|
+
const part = parts[i]!;
|
|
29
|
+
const existing = cursor[part];
|
|
30
|
+
if (typeof existing !== "object" || existing === null || Array.isArray(existing)) {
|
|
31
|
+
cursor[part] = {};
|
|
32
|
+
}
|
|
33
|
+
cursor = cursor[part] as Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
cursor[parts[parts.length - 1]!] = value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return deepFreeze(tree);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const deepFreeze = <T>(obj: T): Readonly<T> => {
|
|
42
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
43
|
+
for (const value of Object.values(obj as Record<string, unknown>)) {
|
|
44
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
45
|
+
deepFreeze(value);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return Object.freeze(obj);
|
|
49
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings store — Redis cache-aside read/write primitives.
|
|
3
|
+
*
|
|
4
|
+
* Pattern: each key is cached in Redis with a 5-minute TTL. Reads try Redis
|
|
5
|
+
* first, fall back to DB on miss, and populate Redis. Writes update DB and
|
|
6
|
+
* delete the Redis key (next reader repopulates with fresh DB state).
|
|
7
|
+
*
|
|
8
|
+
* This achieves cross-container coherence without polling or pubsub: a write
|
|
9
|
+
* in container A invalidates the shared Redis cache; container B's next read
|
|
10
|
+
* hits Redis (miss after del), goes to DB, sees the new value, repopulates.
|
|
11
|
+
*
|
|
12
|
+
* Reads/writes here are async — sync callers should use the per-request
|
|
13
|
+
* snapshot exposed via `c.get("settings")` (built by snapshot.ts middleware).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { redis, sql } from "bun";
|
|
17
|
+
import { decryptValue, encryptValue } from "./crypto";
|
|
18
|
+
import { SETTINGS, SETTINGS_MAP, validateSettingValue, type SettingDef } from "./defaults";
|
|
19
|
+
import { toPgTextArray } from "../postgres";
|
|
20
|
+
|
|
21
|
+
const REDIS_KEY = (k: string) => `settings:${k}`;
|
|
22
|
+
const REDIS_TTL_SEC = 300;
|
|
23
|
+
|
|
24
|
+
type StoredRow = { key: string; value: string };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the env-fallback or default value for a key whose DB row is missing
|
|
28
|
+
* or invalid. Mirrors the existing `resolve()` logic in services/settings/index.ts
|
|
29
|
+
* but takes a SettingDef directly (no global state).
|
|
30
|
+
*/
|
|
31
|
+
const resolveFallback = (def: SettingDef | undefined): unknown => {
|
|
32
|
+
if (!def) return undefined;
|
|
33
|
+
const raw = def.envFallback?.();
|
|
34
|
+
if (raw !== undefined) {
|
|
35
|
+
const validated = validateSettingValue(def, raw);
|
|
36
|
+
if (validated.ok) return validated.value;
|
|
37
|
+
}
|
|
38
|
+
return def.default;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read a single setting key. Tries Redis first, falls back to DB.
|
|
43
|
+
* On DB hit, populates Redis with TTL. On miss, returns env-fallback or default.
|
|
44
|
+
*/
|
|
45
|
+
export const readKey = async (key: string): Promise<unknown> => {
|
|
46
|
+
const def = SETTINGS_MAP.get(key);
|
|
47
|
+
|
|
48
|
+
const cached = await redis.get(REDIS_KEY(key));
|
|
49
|
+
if (cached !== null) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(cached);
|
|
52
|
+
} catch {
|
|
53
|
+
// Corrupt cache entry — drop and re-read from DB.
|
|
54
|
+
await redis.del(REDIS_KEY(key));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const rows = await sql<StoredRow[]>`SELECT value FROM settings.entries WHERE key = ${key}`;
|
|
59
|
+
if (rows.length > 0 && rows[0]) {
|
|
60
|
+
try {
|
|
61
|
+
const decrypted = await decryptValue(rows[0].value);
|
|
62
|
+
if (def) {
|
|
63
|
+
const validated = validateSettingValue(def, decrypted);
|
|
64
|
+
if (validated.ok) {
|
|
65
|
+
await redis.set(REDIS_KEY(key), JSON.stringify(validated.value), "EX", REDIS_TTL_SEC);
|
|
66
|
+
return validated.value;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
// No def known — still cache the decrypted value (caller's responsibility to interpret).
|
|
70
|
+
await redis.set(REDIS_KEY(key), JSON.stringify(decrypted), "EX", REDIS_TTL_SEC);
|
|
71
|
+
return decrypted;
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Decryption failure — legacy row encrypted with a different APP_SECRET.
|
|
75
|
+
// Skip silently and fall through to env/default fallback.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return resolveFallback(def);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Bulk read for snapshot construction. One Redis MGET round-trip, DB fallback
|
|
84
|
+
* for misses (single SELECT with key = ANY), populates Redis for missed keys.
|
|
85
|
+
*
|
|
86
|
+
* Returns a Map keyed by the input keys; every input key is present in the
|
|
87
|
+
* result (with env-fallback or default as last resort).
|
|
88
|
+
*/
|
|
89
|
+
export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unknown>> => {
|
|
90
|
+
const result = new Map<string, unknown>();
|
|
91
|
+
if (keys.length === 0) return result;
|
|
92
|
+
|
|
93
|
+
// 1. Redis MGET — one round-trip
|
|
94
|
+
const cached = await redis.mget(...keys.map(REDIS_KEY));
|
|
95
|
+
const missing: string[] = [];
|
|
96
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
97
|
+
const k = keys[i]!;
|
|
98
|
+
const c = cached[i];
|
|
99
|
+
if (c !== null && c !== undefined) {
|
|
100
|
+
try {
|
|
101
|
+
result.set(k, JSON.parse(c));
|
|
102
|
+
continue;
|
|
103
|
+
} catch {
|
|
104
|
+
// Drop corrupt cache entry, treat as missing.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
missing.push(k);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 2. Fetch missing from DB in one query (Bun sql can't serialize JS arrays
|
|
111
|
+
// for ANY(), so we hand-build the Postgres TEXT[] literal).
|
|
112
|
+
if (missing.length > 0) {
|
|
113
|
+
const rows = await sql<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray(missing)}::text[])`;
|
|
114
|
+
for (const row of rows) {
|
|
115
|
+
try {
|
|
116
|
+
const decrypted = await decryptValue(row.value);
|
|
117
|
+
const def = SETTINGS_MAP.get(row.key);
|
|
118
|
+
if (def) {
|
|
119
|
+
const validated = validateSettingValue(def, decrypted);
|
|
120
|
+
if (validated.ok) {
|
|
121
|
+
result.set(row.key, validated.value);
|
|
122
|
+
await redis.set(REDIS_KEY(row.key), JSON.stringify(validated.value), "EX", REDIS_TTL_SEC);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
result.set(row.key, decrypted);
|
|
126
|
+
await redis.set(REDIS_KEY(row.key), JSON.stringify(decrypted), "EX", REDIS_TTL_SEC);
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Legacy row with mismatched key — silent skip.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 3. Apply env-fallback / default for keys that are still missing
|
|
135
|
+
for (const k of keys) {
|
|
136
|
+
if (!result.has(k)) {
|
|
137
|
+
result.set(k, resolveFallback(SETTINGS_MAP.get(k)));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get every known setting key (across all registered defs).
|
|
146
|
+
* Used by snapshot loader to determine what to bulk-read.
|
|
147
|
+
*/
|
|
148
|
+
export const allKnownKeys = (): string[] => SETTINGS.map((d) => d.key);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Encrypt the value, upsert the DB row, invalidate the Redis key.
|
|
152
|
+
*
|
|
153
|
+
* Validation is the caller's responsibility — the typed wrapper API
|
|
154
|
+
* (createSettingsAPI) validates against the declared SettingDef before reaching
|
|
155
|
+
* here. Direct callers must ensure the value matches the setting's kind.
|
|
156
|
+
*/
|
|
157
|
+
export const writeKey = async (key: string, value: unknown): Promise<void> => {
|
|
158
|
+
const def = SETTINGS_MAP.get(key);
|
|
159
|
+
if (!def) throw new Error(`Unknown setting: ${key}`);
|
|
160
|
+
const validated = validateSettingValue(def, value);
|
|
161
|
+
if (!validated.ok) throw new Error(validated.error);
|
|
162
|
+
|
|
163
|
+
const encrypted = await encryptValue(validated.value);
|
|
164
|
+
await sql`
|
|
165
|
+
INSERT INTO settings.entries (key, value, updated_at)
|
|
166
|
+
VALUES (${key}, ${encrypted}, now())
|
|
167
|
+
ON CONFLICT (key)
|
|
168
|
+
DO UPDATE SET value = ${encrypted}, updated_at = now()
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
// Invalidate the cache so other containers re-read on next access.
|
|
172
|
+
await redis.del(REDIS_KEY(key));
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Delete the DB row and invalidate Redis. */
|
|
176
|
+
export const deleteKey = async (key: string): Promise<void> => {
|
|
177
|
+
await sql`DELETE FROM settings.entries WHERE key = ${key}`;
|
|
178
|
+
await redis.del(REDIS_KEY(key));
|
|
179
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mustache-based template rendering for email templates stored in settings.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import Mustache from "mustache";
|
|
6
|
+
|
|
7
|
+
/** Render a mustache template with variables. */
|
|
8
|
+
export function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
9
|
+
return Mustache.render(template, vars);
|
|
10
|
+
}
|