@valentinkolb/cloud 0.4.0 → 0.5.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 +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
|
|
3
|
+
import { isUniqueViolation } from "./postgres";
|
|
4
|
+
|
|
5
|
+
export type ServiceAccountKind = "user_delegated" | "resource_bound";
|
|
6
|
+
export type ServiceAccountStatus = "active" | "disabled";
|
|
7
|
+
|
|
8
|
+
export type ServiceAccount = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
kind: ServiceAccountKind;
|
|
12
|
+
status: ServiceAccountStatus;
|
|
13
|
+
delegatedUserId: string | null;
|
|
14
|
+
appId: string | null;
|
|
15
|
+
resourceType: string | null;
|
|
16
|
+
resourceId: string | null;
|
|
17
|
+
createdBy: string | null;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type DbServiceAccount = {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
kind: ServiceAccountKind;
|
|
25
|
+
status: ServiceAccountStatus;
|
|
26
|
+
delegated_user_id: string | null;
|
|
27
|
+
app_id: string | null;
|
|
28
|
+
resource_type: string | null;
|
|
29
|
+
resource_id: string | null;
|
|
30
|
+
created_by: string | null;
|
|
31
|
+
created_at: Date;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const mapServiceAccount = (row: DbServiceAccount): ServiceAccount => ({
|
|
35
|
+
id: row.id,
|
|
36
|
+
name: row.name,
|
|
37
|
+
kind: row.kind,
|
|
38
|
+
status: row.status,
|
|
39
|
+
delegatedUserId: row.delegated_user_id,
|
|
40
|
+
appId: row.app_id,
|
|
41
|
+
resourceType: row.resource_type,
|
|
42
|
+
resourceId: row.resource_id,
|
|
43
|
+
createdBy: row.created_by,
|
|
44
|
+
createdAt: row.created_at.toISOString(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const trimRequired = (value: string): string => value.trim();
|
|
48
|
+
|
|
49
|
+
const isForeignKeyViolation = (error: unknown): boolean => (error as { code?: string } | null)?.code === "23503";
|
|
50
|
+
const RESOURCE_BOUND_UNIQUE_CONSTRAINT = "uniq_service_accounts_resource_bound";
|
|
51
|
+
|
|
52
|
+
export const getByResource = async (params: {
|
|
53
|
+
appId: string;
|
|
54
|
+
resourceType: string;
|
|
55
|
+
resourceId: string;
|
|
56
|
+
}): Promise<ServiceAccount | null> => {
|
|
57
|
+
const [row] = await sql<DbServiceAccount[]>`
|
|
58
|
+
SELECT id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
|
|
59
|
+
FROM auth.service_accounts
|
|
60
|
+
WHERE kind = 'resource_bound'
|
|
61
|
+
AND app_id = ${params.appId}
|
|
62
|
+
AND resource_type = ${params.resourceType}
|
|
63
|
+
AND resource_id = ${params.resourceId}
|
|
64
|
+
ORDER BY created_at ASC
|
|
65
|
+
LIMIT 1
|
|
66
|
+
`;
|
|
67
|
+
return row ? mapServiceAccount(row) : null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const get = async (params: { id: string }): Promise<ServiceAccount | null> => {
|
|
71
|
+
const [row] = await sql<DbServiceAccount[]>`
|
|
72
|
+
SELECT id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
|
|
73
|
+
FROM auth.service_accounts
|
|
74
|
+
WHERE id = ${params.id}::uuid
|
|
75
|
+
`;
|
|
76
|
+
return row ? mapServiceAccount(row) : null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const createUserDelegated = async (params: {
|
|
80
|
+
name: string;
|
|
81
|
+
delegatedUserId: string;
|
|
82
|
+
createdBy?: string | null;
|
|
83
|
+
}): Promise<Result<ServiceAccount>> => {
|
|
84
|
+
const name = trimRequired(params.name);
|
|
85
|
+
if (!name) return fail(err.badInput("Service account name is required"));
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const [row] = await sql<DbServiceAccount[]>`
|
|
89
|
+
INSERT INTO auth.service_accounts (name, kind, delegated_user_id, created_by)
|
|
90
|
+
VALUES (${name}, 'user_delegated', ${params.delegatedUserId}::uuid, ${params.createdBy ?? null}::uuid)
|
|
91
|
+
RETURNING id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
|
|
92
|
+
`;
|
|
93
|
+
return row ? ok(mapServiceAccount(row)) : fail(err.internal("Failed to create service account"));
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (isForeignKeyViolation(error)) return fail(err.notFound("Delegated user"));
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const createResourceBound = async (params: {
|
|
101
|
+
name: string;
|
|
102
|
+
appId: string;
|
|
103
|
+
resourceType: string;
|
|
104
|
+
resourceId: string;
|
|
105
|
+
createdBy?: string | null;
|
|
106
|
+
}): Promise<Result<ServiceAccount>> => {
|
|
107
|
+
const name = trimRequired(params.name);
|
|
108
|
+
const appId = trimRequired(params.appId);
|
|
109
|
+
const resourceType = trimRequired(params.resourceType);
|
|
110
|
+
const resourceId = trimRequired(params.resourceId);
|
|
111
|
+
if (!name) return fail(err.badInput("Service account name is required"));
|
|
112
|
+
if (!appId || !resourceType || !resourceId) return fail(err.badInput("Resource binding is required"));
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const [row] = await sql<DbServiceAccount[]>`
|
|
116
|
+
INSERT INTO auth.service_accounts (name, kind, app_id, resource_type, resource_id, created_by)
|
|
117
|
+
VALUES (${name}, 'resource_bound', ${appId}, ${resourceType}, ${resourceId}, ${params.createdBy ?? null}::uuid)
|
|
118
|
+
RETURNING id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
|
|
119
|
+
`;
|
|
120
|
+
return row ? ok(mapServiceAccount(row)) : fail(err.internal("Failed to create service account"));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (isForeignKeyViolation(error)) return fail(err.notFound("Creator"));
|
|
123
|
+
if (isUniqueViolation(error, RESOURCE_BOUND_UNIQUE_CONSTRAINT)) return fail(err.conflict("Resource service account"));
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const getOrCreateResourceBound = async (params: {
|
|
129
|
+
name: string;
|
|
130
|
+
appId: string;
|
|
131
|
+
resourceType: string;
|
|
132
|
+
resourceId: string;
|
|
133
|
+
createdBy?: string | null;
|
|
134
|
+
}): Promise<Result<ServiceAccount>> => {
|
|
135
|
+
const existing = await getByResource(params);
|
|
136
|
+
if (existing) return ok(existing);
|
|
137
|
+
|
|
138
|
+
const created = await createResourceBound(params);
|
|
139
|
+
if (created.ok || created.error.code !== "CONFLICT") return created;
|
|
140
|
+
|
|
141
|
+
const raced = await getByResource(params);
|
|
142
|
+
return raced ? ok(raced) : fail(err.internal("Failed to load resource service account"));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const setStatus = async (params: { id: string; status: ServiceAccountStatus }): Promise<Result<void>> => {
|
|
146
|
+
const result = await sql`
|
|
147
|
+
UPDATE auth.service_accounts
|
|
148
|
+
SET status = ${params.status}
|
|
149
|
+
WHERE id = ${params.id}::uuid
|
|
150
|
+
`;
|
|
151
|
+
if (result.count === 0) return fail(err.notFound("Service account"));
|
|
152
|
+
return ok();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const delete_ = async (params: { id: string }): Promise<Result<void>> => {
|
|
156
|
+
const result = await sql`
|
|
157
|
+
DELETE FROM auth.service_accounts
|
|
158
|
+
WHERE id = ${params.id}::uuid
|
|
159
|
+
`;
|
|
160
|
+
if (result.count === 0) return fail(err.notFound("Service account"));
|
|
161
|
+
return ok();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const deleteForResource = async (params: {
|
|
165
|
+
appId: string;
|
|
166
|
+
resourceType: string;
|
|
167
|
+
resourceId: string;
|
|
168
|
+
}): Promise<number> => {
|
|
169
|
+
const result = await sql`
|
|
170
|
+
DELETE FROM auth.service_accounts
|
|
171
|
+
WHERE kind = 'resource_bound'
|
|
172
|
+
AND app_id = ${params.appId}
|
|
173
|
+
AND resource_type = ${params.resourceType}
|
|
174
|
+
AND resource_id = ${params.resourceId}
|
|
175
|
+
`;
|
|
176
|
+
return result.count;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const serviceAccounts = {
|
|
180
|
+
get,
|
|
181
|
+
createUserDelegated,
|
|
182
|
+
createResourceBound,
|
|
183
|
+
getByResource,
|
|
184
|
+
getOrCreateResourceBound,
|
|
185
|
+
setStatus,
|
|
186
|
+
delete: delete_,
|
|
187
|
+
deleteForResource,
|
|
188
|
+
};
|
|
@@ -14,7 +14,6 @@ import * as settings from "../settings";
|
|
|
14
14
|
*/
|
|
15
15
|
type SessionData = {
|
|
16
16
|
userId: string;
|
|
17
|
-
ipaSession: string | null;
|
|
18
17
|
gen: number;
|
|
19
18
|
};
|
|
20
19
|
|
|
@@ -48,6 +47,8 @@ const parseBearer = (header: string | undefined): string | null => {
|
|
|
48
47
|
return match?.[1] ?? null;
|
|
49
48
|
};
|
|
50
49
|
|
|
50
|
+
const isCloudApiToken = (token: string | null): boolean => Boolean(token?.startsWith("cld_"));
|
|
51
|
+
|
|
51
52
|
export const session = {
|
|
52
53
|
/**
|
|
53
54
|
* Get session token from cookie or Authorization header.
|
|
@@ -59,18 +60,20 @@ export const session = {
|
|
|
59
60
|
getToken: (c: Context): string | null => {
|
|
60
61
|
const cookie = getCookie(c, "session_token");
|
|
61
62
|
const bearer = parseBearer(c.req.header("Authorization"));
|
|
62
|
-
return cookie || bearer || null;
|
|
63
|
+
return cookie || (isCloudApiToken(bearer) ? null : bearer) || null;
|
|
63
64
|
},
|
|
64
65
|
|
|
66
|
+
getBearerToken: (c: Context): string | null => parseBearer(c.req.header("Authorization")),
|
|
67
|
+
|
|
65
68
|
parseToken,
|
|
66
69
|
|
|
67
|
-
create: async (c: Context, userId: string
|
|
70
|
+
create: async (c: Context, userId: string): Promise<string> => {
|
|
68
71
|
const randomToken = crypto.randomUUID();
|
|
69
72
|
const expiryHours = await settings.get<number>("user.session.expiry_hours");
|
|
70
73
|
const ttl = expiryHours * 60 * 60;
|
|
71
74
|
|
|
72
75
|
const gen = await readGen(userId);
|
|
73
|
-
const data: SessionData = { userId,
|
|
76
|
+
const data: SessionData = { userId, gen };
|
|
74
77
|
await redis.set(sessionKey(userId, randomToken), JSON.stringify(data), "EX", ttl);
|
|
75
78
|
|
|
76
79
|
await sql`UPDATE auth.users SET last_login_local = now() WHERE id = ${userId}`;
|
|
@@ -130,8 +133,4 @@ export const session = {
|
|
|
130
133
|
return data;
|
|
131
134
|
},
|
|
132
135
|
|
|
133
|
-
getIpaSession: async (token: string): Promise<string | null> => {
|
|
134
|
-
const data = await session.getData(token);
|
|
135
|
-
return data?.ipaSession ?? null;
|
|
136
|
-
},
|
|
137
136
|
};
|
|
@@ -6,25 +6,12 @@
|
|
|
6
6
|
* Lives in cloud-lib because every app that has app-scoped settings needs
|
|
7
7
|
* the same API to render its admin form (files, weather, etc.).
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { paginateItems } from "../../server/services";
|
|
10
|
+
import { err, fail, ok, type PageParams, type Paginated } from "@valentinkolb/stdlib";
|
|
10
11
|
import * as settingsPrimitives from ".";
|
|
11
12
|
import type { SettingEntry } from ".";
|
|
12
13
|
import { SETTINGS_MAP, validateSettingValue } from "./defaults";
|
|
13
14
|
|
|
14
|
-
const paginateEntries = <T>(items: T[], pagination?: PageParams): Paginated<T> => {
|
|
15
|
-
if (!pagination) {
|
|
16
|
-
return { items, page: 1, perPage: items.length, total: items.length, hasNext: false };
|
|
17
|
-
}
|
|
18
|
-
const { page, perPage, offset } = paginate(pagination);
|
|
19
|
-
return {
|
|
20
|
-
items: items.slice(offset, offset + perPage),
|
|
21
|
-
page,
|
|
22
|
-
perPage,
|
|
23
|
-
total: items.length,
|
|
24
|
-
hasNext: page * perPage < items.length,
|
|
25
|
-
};
|
|
26
|
-
};
|
|
27
|
-
|
|
28
15
|
/**
|
|
29
16
|
* Redact secret-kind setting values before they leave the server.
|
|
30
17
|
*
|
|
@@ -42,10 +29,7 @@ const redactSecretValue = (entry: SettingEntry): SettingEntry => {
|
|
|
42
29
|
|
|
43
30
|
export const settingsService = {
|
|
44
31
|
entry: {
|
|
45
|
-
list: async (config?: {
|
|
46
|
-
pagination?: PageParams;
|
|
47
|
-
filter?: { query?: string; group?: string };
|
|
48
|
-
}): Promise<Paginated<SettingEntry>> => {
|
|
32
|
+
list: async (config?: { pagination?: PageParams; filter?: { query?: string; group?: string } }): Promise<Paginated<SettingEntry>> => {
|
|
49
33
|
const entries = await settingsPrimitives.getAll();
|
|
50
34
|
const query = config?.filter?.query?.trim().toLowerCase();
|
|
51
35
|
const group = config?.filter?.group?.trim().toLowerCase();
|
|
@@ -62,7 +46,7 @@ export const settingsService = {
|
|
|
62
46
|
})
|
|
63
47
|
.map(redactSecretValue);
|
|
64
48
|
|
|
65
|
-
return
|
|
49
|
+
return paginateItems(filtered, config?.pagination);
|
|
66
50
|
},
|
|
67
51
|
update: async (config: { key: string; value: unknown }) => {
|
|
68
52
|
if (!SETTINGS_MAP.has(config.key)) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { SettingKind, SettingOption } from "../../contracts/shared";
|
|
13
|
+
|
|
13
14
|
export type { SettingKind, SettingOption };
|
|
14
15
|
|
|
15
16
|
type SettingEnvResolver = () => unknown;
|
|
@@ -24,16 +25,7 @@ type SettingCommon = {
|
|
|
24
25
|
envBootstrap?: SettingEnvResolver;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
|
-
type SettingStringLikeKind =
|
|
28
|
-
| "string"
|
|
29
|
-
| "text"
|
|
30
|
-
| "email"
|
|
31
|
-
| "url"
|
|
32
|
-
| "secret"
|
|
33
|
-
| "image"
|
|
34
|
-
| "cron"
|
|
35
|
-
| "timezone"
|
|
36
|
-
| "template";
|
|
28
|
+
type SettingStringLikeKind = "string" | "text" | "email" | "url" | "secret" | "image" | "cron" | "timezone" | "template";
|
|
37
29
|
|
|
38
30
|
type StringLikeSettingDef = SettingCommon & {
|
|
39
31
|
kind: SettingStringLikeKind;
|
|
@@ -77,9 +69,7 @@ export type SettingDef =
|
|
|
77
69
|
| StringListSettingDef
|
|
78
70
|
| NumberListSettingDef;
|
|
79
71
|
|
|
80
|
-
export type SettingValidationResult =
|
|
81
|
-
| { ok: true; value: SettingDef["default"] }
|
|
82
|
-
| { ok: false; error: string };
|
|
72
|
+
export type SettingValidationResult = { ok: true; value: SettingDef["default"] } | { ok: false; error: string };
|
|
83
73
|
|
|
84
74
|
const envString = (key: string): string | undefined => {
|
|
85
75
|
const value = process.env[key]?.trim();
|
|
@@ -116,7 +106,8 @@ export const SETTINGS: SettingDef[] = [
|
|
|
116
106
|
label: "URL",
|
|
117
107
|
kind: "string",
|
|
118
108
|
default: "localhost:3000",
|
|
119
|
-
description:
|
|
109
|
+
description:
|
|
110
|
+
"Public-facing application URL used for links in emails, OAuth redirects, and WebSocket connections (with or without scheme)",
|
|
120
111
|
placeholder: "e.g. https://cloud.example.org",
|
|
121
112
|
group: "app",
|
|
122
113
|
envFallback: () => envString("APP_URL"),
|
|
@@ -209,7 +200,8 @@ export const SETTINGS: SettingDef[] = [
|
|
|
209
200
|
label: "CA Certificate (PEM)",
|
|
210
201
|
kind: "text",
|
|
211
202
|
default: "",
|
|
212
|
-
description:
|
|
203
|
+
description:
|
|
204
|
+
"Paste the FreeIPA root CA in PEM format to trust self-signed/private-CA servers without disabling validation. Preferred over allow_insecure.",
|
|
213
205
|
placeholder: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
|
|
214
206
|
group: "freeipa",
|
|
215
207
|
},
|
|
@@ -218,7 +210,8 @@ export const SETTINGS: SettingDef[] = [
|
|
|
218
210
|
label: "Allow Insecure TLS",
|
|
219
211
|
kind: "boolean",
|
|
220
212
|
default: false,
|
|
221
|
-
description:
|
|
213
|
+
description:
|
|
214
|
+
"Skip TLS certificate validation entirely. Use only for local dev — disables MITM protection. Ignored when ca_cert is set.",
|
|
222
215
|
group: "freeipa",
|
|
223
216
|
},
|
|
224
217
|
{
|
|
@@ -445,6 +438,20 @@ export const SETTINGS: SettingDef[] = [
|
|
|
445
438
|
group: "mail",
|
|
446
439
|
templateVars: ["TOKEN", "MAGIC_LINK", "APP_NAME"],
|
|
447
440
|
},
|
|
441
|
+
{
|
|
442
|
+
key: "mail.password_reset",
|
|
443
|
+
label: "Password Reset Template",
|
|
444
|
+
kind: "template",
|
|
445
|
+
default: `<p>You requested a password reset for your {{APP_NAME}} account.</p>
|
|
446
|
+
<p style="text-align:center;margin:24px 0;">
|
|
447
|
+
<a href="{{RESET_LINK}}" target="_blank" style="color:#3b82f6;text-decoration:underline;">Set a new password</a>
|
|
448
|
+
</p>
|
|
449
|
+
<p style="color:#71717a;font-size:12px;margin:0 0 8px 0;">This link expires in 15 minutes. Never share this link with anyone. If you didn't request this, you can ignore this email.</p>
|
|
450
|
+
{{#CONTACT_EMAIL}}<p style="color:#71717a;font-size:12px;margin:0;">If you need help, contact <a href="mailto:{{CONTACT_EMAIL}}">{{CONTACT_EMAIL}}</a>.</p>{{/CONTACT_EMAIL}}`,
|
|
451
|
+
description: "Password reset email template (HTML). Subject: {{APP_NAME}} Password Reset",
|
|
452
|
+
group: "mail",
|
|
453
|
+
templateVars: ["RESET_LINK", "APP_NAME", "CONTACT_EMAIL"],
|
|
454
|
+
},
|
|
448
455
|
{
|
|
449
456
|
key: "mail.account_expiry_reminder",
|
|
450
457
|
label: "Account Expiry Reminder Template",
|
|
@@ -530,7 +537,7 @@ export const SETTINGS: SettingDef[] = [
|
|
|
530
537
|
// Three pages, three modes each:
|
|
531
538
|
// mode = "local" → render markdown from `legal.<kind>.content`
|
|
532
539
|
// mode = "external" → 302-redirect to `legal.<kind>.url`
|
|
533
|
-
// All three pages live in
|
|
540
|
+
// All three pages live in Core (mounts: /impressum,
|
|
534
541
|
// /legal/privacy, /legal/terms).
|
|
535
542
|
{
|
|
536
543
|
key: "legal.terms.mode",
|
|
@@ -622,6 +629,45 @@ export const SETTINGS: SettingDef[] = [
|
|
|
622
629
|
placeholder: "https://example.org/imprint",
|
|
623
630
|
group: "legal",
|
|
624
631
|
},
|
|
632
|
+
|
|
633
|
+
{
|
|
634
|
+
key: "notebooks.reindex_cron",
|
|
635
|
+
label: "Reindex Cron",
|
|
636
|
+
kind: "cron",
|
|
637
|
+
default: "0 */12 * * *",
|
|
638
|
+
description: "Five-field cron schedule for the periodic note-refs reindex job (links, tags, attachments) in app.timezone.",
|
|
639
|
+
group: "notebooks",
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
key: "notebooks.snapshot_cron",
|
|
643
|
+
label: "Snapshot Cron",
|
|
644
|
+
kind: "cron",
|
|
645
|
+
default: "0 3 * * *",
|
|
646
|
+
description: "Five-field cron schedule for automatic notebook S3 snapshots in app.timezone.",
|
|
647
|
+
group: "notebooks",
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
key: "notebooks.max_attachment_size_mb",
|
|
651
|
+
label: "Max Attachment Size",
|
|
652
|
+
kind: "number",
|
|
653
|
+
default: 10,
|
|
654
|
+
min: 1,
|
|
655
|
+
max: 200,
|
|
656
|
+
description:
|
|
657
|
+
"Per-file upload limit for notebook attachments (megabytes). Oversize images are auto-resized client-side before the upload hits this gate; non-image files exceeding the limit are rejected with a clear error.",
|
|
658
|
+
group: "notebooks",
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
key: "notebooks.max_image_dimension_px",
|
|
662
|
+
label: "Max Image Side",
|
|
663
|
+
kind: "number",
|
|
664
|
+
default: 2048,
|
|
665
|
+
min: 256,
|
|
666
|
+
max: 8192,
|
|
667
|
+
description:
|
|
668
|
+
"Longest-side cap (pixels) applied when an oversize image is auto-resized before upload. Aspect ratio is preserved; PNG inputs stay PNG, everything else becomes WebP at quality 0.85.",
|
|
669
|
+
group: "notebooks",
|
|
670
|
+
},
|
|
625
671
|
];
|
|
626
672
|
|
|
627
673
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -645,11 +691,7 @@ const parseStringList = (value: unknown): string[] | null => {
|
|
|
645
691
|
};
|
|
646
692
|
|
|
647
693
|
const parseNumberList = (value: unknown): number[] | null => {
|
|
648
|
-
const rawValues = Array.isArray(value)
|
|
649
|
-
? value
|
|
650
|
-
: typeof value === "string"
|
|
651
|
-
? value.split(/[,\n]/).map((entry) => entry.trim())
|
|
652
|
-
: null;
|
|
694
|
+
const rawValues = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[,\n]/).map((entry) => entry.trim()) : null;
|
|
653
695
|
|
|
654
696
|
if (!rawValues) return null;
|
|
655
697
|
|
|
@@ -22,6 +22,13 @@ const REDIS_KEY = (k: string) => `settings:${k}`;
|
|
|
22
22
|
const REDIS_TTL_SEC = 300;
|
|
23
23
|
|
|
24
24
|
type StoredRow = { key: string; value: string };
|
|
25
|
+
type LegacyStoredRow = { key: string; value: string; updated_at: Date | string | null };
|
|
26
|
+
|
|
27
|
+
export type LegacySettingRow = {
|
|
28
|
+
key: string;
|
|
29
|
+
updatedAt: string | null;
|
|
30
|
+
decryptable: boolean;
|
|
31
|
+
};
|
|
25
32
|
|
|
26
33
|
/**
|
|
27
34
|
* Resolve the env-fallback or default value for a key whose DB row is missing
|
|
@@ -147,6 +154,46 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
|
|
|
147
154
|
*/
|
|
148
155
|
export const allKnownKeys = (): string[] => SETTINGS.map((d) => d.key);
|
|
149
156
|
|
|
157
|
+
const knownKeysWith = (extraKnownKeys: readonly string[]) => Array.from(new Set([...allKnownKeys(), ...extraKnownKeys]));
|
|
158
|
+
|
|
159
|
+
export const listLegacyKeys = async (extraKnownKeys: readonly string[] = []): Promise<LegacySettingRow[]> => {
|
|
160
|
+
const knownKeys = knownKeysWith(extraKnownKeys);
|
|
161
|
+
const rows = await sql<LegacyStoredRow[]>`
|
|
162
|
+
SELECT key, value, updated_at
|
|
163
|
+
FROM settings.entries
|
|
164
|
+
WHERE NOT (key = ANY(${toPgTextArray(knownKeys)}::text[]))
|
|
165
|
+
ORDER BY updated_at DESC NULLS LAST, key ASC
|
|
166
|
+
`;
|
|
167
|
+
|
|
168
|
+
const legacy: LegacySettingRow[] = [];
|
|
169
|
+
for (const row of rows) {
|
|
170
|
+
let decryptable = true;
|
|
171
|
+
try {
|
|
172
|
+
await decryptValue(row.value);
|
|
173
|
+
} catch {
|
|
174
|
+
decryptable = false;
|
|
175
|
+
}
|
|
176
|
+
legacy.push({
|
|
177
|
+
key: row.key,
|
|
178
|
+
updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : null,
|
|
179
|
+
decryptable,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return legacy;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const deleteLegacyKeys = async (extraKnownKeys: readonly string[] = []): Promise<{ deleted: string[] }> => {
|
|
186
|
+
const knownKeys = knownKeysWith(extraKnownKeys);
|
|
187
|
+
const rows = await sql<{ key: string }[]>`
|
|
188
|
+
DELETE FROM settings.entries
|
|
189
|
+
WHERE NOT (key = ANY(${toPgTextArray(knownKeys)}::text[]))
|
|
190
|
+
RETURNING key
|
|
191
|
+
`;
|
|
192
|
+
const deleted = rows.map((row) => row.key);
|
|
193
|
+
if (deleted.length > 0) await redis.del(...deleted.map(REDIS_KEY));
|
|
194
|
+
return { deleted };
|
|
195
|
+
};
|
|
196
|
+
|
|
150
197
|
/**
|
|
151
198
|
* Encrypt the value, upsert the DB row, invalidate the Redis key.
|
|
152
199
|
*
|
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
import { redis } from "bun";
|
|
2
|
-
import { coreSettings } from "../settings/api";
|
|
3
2
|
import { logger } from "../logging";
|
|
3
|
+
import { coreSettings } from "../settings/api";
|
|
4
4
|
import type { CurrentWeather, DailyForecast, HourlyForecast, WeatherData, WeatherIcon } from "./types";
|
|
5
5
|
|
|
6
6
|
const log = logger("weather");
|
|
7
7
|
|
|
8
8
|
const BRIGHTSKY_API = "https://api.brightsky.dev";
|
|
9
|
+
const BRIGHTSKY_TIMEOUT_MS = 400;
|
|
9
10
|
|
|
10
11
|
export type ForecastLocationConfig = {
|
|
11
12
|
lat?: string;
|
|
12
13
|
lon?: string;
|
|
13
14
|
};
|
|
14
15
|
|
|
16
|
+
type ResolvedForecastLocation = {
|
|
17
|
+
lat: string;
|
|
18
|
+
lon: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const resolveForecastLocation = async (config?: ForecastLocationConfig): Promise<ResolvedForecastLocation | null> => {
|
|
22
|
+
const lat = (config?.lat ?? (await coreSettings.get<string>("weather.default_lat"))).trim();
|
|
23
|
+
const lon = (config?.lon ?? (await coreSettings.get<string>("weather.default_lon"))).trim();
|
|
24
|
+
|
|
25
|
+
if (!lat || !lon) {
|
|
26
|
+
log.error("Weather default coordinates are not configured", {
|
|
27
|
+
missing: [
|
|
28
|
+
!lat ? "weather.default_lat" : null,
|
|
29
|
+
!lon ? "weather.default_lon" : null,
|
|
30
|
+
].filter(Boolean),
|
|
31
|
+
});
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { lat, lon };
|
|
36
|
+
};
|
|
37
|
+
|
|
15
38
|
/** Brightsky current_weather API response */
|
|
16
39
|
type BrightskyCurrentResponse = {
|
|
17
40
|
weather: {
|
|
@@ -81,9 +104,12 @@ const getCacheKey = (lat: string, lon: string): string => {
|
|
|
81
104
|
|
|
82
105
|
/** Fetch current weather from Brightsky API. */
|
|
83
106
|
const fetchCurrentFromApi = async (lat: string, lon: string): Promise<CurrentWeather | null> => {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timeout = setTimeout(() => controller.abort(), BRIGHTSKY_TIMEOUT_MS);
|
|
109
|
+
|
|
84
110
|
try {
|
|
85
111
|
const url = `${BRIGHTSKY_API}/current_weather?lat=${lat}&lon=${lon}`;
|
|
86
|
-
const response = await fetch(url);
|
|
112
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
87
113
|
|
|
88
114
|
if (!response.ok) {
|
|
89
115
|
log.error("Brightsky API error", { status: response.status });
|
|
@@ -119,15 +145,20 @@ const fetchCurrentFromApi = async (lat: string, lon: string): Promise<CurrentWea
|
|
|
119
145
|
} catch (error) {
|
|
120
146
|
log.error("Failed to fetch from Brightsky", {
|
|
121
147
|
error: error instanceof Error ? error.message : String(error),
|
|
148
|
+
timeoutMs: BRIGHTSKY_TIMEOUT_MS,
|
|
122
149
|
});
|
|
123
150
|
return null;
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timeout);
|
|
124
153
|
}
|
|
125
154
|
};
|
|
126
155
|
|
|
127
156
|
/** Get current weather, using Redis cache. */
|
|
128
157
|
export const getCurrentWeather = async (config?: ForecastLocationConfig): Promise<CurrentWeather | null> => {
|
|
129
|
-
const
|
|
130
|
-
|
|
158
|
+
const location = await resolveForecastLocation(config);
|
|
159
|
+
if (!location) return null;
|
|
160
|
+
|
|
161
|
+
const { lat, lon } = location;
|
|
131
162
|
const cacheKey = getCacheKey(lat, lon);
|
|
132
163
|
|
|
133
164
|
const cached = await redis.get(cacheKey);
|
|
@@ -257,8 +288,10 @@ const fetchForecastFromApi = async (lat: string, lon: string): Promise<{ hourly:
|
|
|
257
288
|
|
|
258
289
|
/** Get full weather data including forecasts. */
|
|
259
290
|
export const getWeatherData = async (config?: ForecastLocationConfig): Promise<WeatherData | null> => {
|
|
260
|
-
const
|
|
261
|
-
|
|
291
|
+
const location = await resolveForecastLocation(config);
|
|
292
|
+
if (!location) return null;
|
|
293
|
+
|
|
294
|
+
const { lat, lon } = location;
|
|
262
295
|
const cacheKey = `weather:full:${lat}:${lon}`;
|
|
263
296
|
|
|
264
297
|
const cached = await redis.get(cacheKey);
|
|
@@ -270,7 +303,7 @@ export const getWeatherData = async (config?: ForecastLocationConfig): Promise<W
|
|
|
270
303
|
}
|
|
271
304
|
}
|
|
272
305
|
|
|
273
|
-
const [current, forecast] = await Promise.all([getCurrentWeather(
|
|
306
|
+
const [current, forecast] = await Promise.all([getCurrentWeather(location), fetchForecastFromApi(lat, lon)]);
|
|
274
307
|
|
|
275
308
|
if (!current) return null;
|
|
276
309
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveWebAuthnRp } from "./webauthn";
|
|
3
|
+
|
|
4
|
+
describe("resolveWebAuthnRp", () => {
|
|
5
|
+
test("derives rp id and origin from https app url", () => {
|
|
6
|
+
expect(resolveWebAuthnRp({ appUrl: "https://cloud.example/app", appName: "Cloud" })).toEqual({
|
|
7
|
+
rpName: "Cloud",
|
|
8
|
+
rpID: "cloud.example",
|
|
9
|
+
origin: "https://cloud.example",
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("accepts localhost over http for development", () => {
|
|
14
|
+
expect(resolveWebAuthnRp({ appUrl: "http://localhost:3000", appName: "" })).toEqual({
|
|
15
|
+
rpName: "Cloud",
|
|
16
|
+
rpID: "localhost",
|
|
17
|
+
origin: "http://localhost:3000",
|
|
18
|
+
});
|
|
19
|
+
expect(resolveWebAuthnRp({ appUrl: "localhost:3000", appName: "Cloud" })).toEqual({
|
|
20
|
+
rpName: "Cloud",
|
|
21
|
+
rpID: "localhost",
|
|
22
|
+
origin: "http://localhost:3000",
|
|
23
|
+
});
|
|
24
|
+
expect(resolveWebAuthnRp({ appUrl: "http://[::1]:3000", appName: "Cloud" })).toEqual({
|
|
25
|
+
rpName: "Cloud",
|
|
26
|
+
rpID: "[::1]",
|
|
27
|
+
origin: "http://[::1]:3000",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("rejects non-local insecure origins", () => {
|
|
32
|
+
expect(() => resolveWebAuthnRp({ appUrl: "http://cloud.example", appName: "Cloud" })).toThrow(
|
|
33
|
+
"WebAuthn requires an HTTPS app.url",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
});
|