@valentinkolb/cloud 0.3.1 → 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 -8
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +119 -47
- package/src/_internal/runtime-context.ts +1 -0
- 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 +15 -25
- 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 +4 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +4 -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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized profile calculation for IPA-backed users.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Full sync writes `auth.ipa_user_effective_groups` from FreeIPA group_find.
|
|
5
|
+
* Local group mutations keep that projection as source of truth; the local
|
|
6
|
+
* display mirror is only a bootstrap fallback before the first full sync.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { sql } from "bun";
|
|
@@ -37,6 +38,17 @@ export const getAllUserGroups = async (userId: string): Promise<string[]> => {
|
|
|
37
38
|
return rows.map((row) => row.name as string);
|
|
38
39
|
};
|
|
39
40
|
|
|
41
|
+
export const getEffectiveUserGroups = async (userId: string): Promise<string[]> => {
|
|
42
|
+
const rows: DbRow[] = await sql`
|
|
43
|
+
SELECT group_name
|
|
44
|
+
FROM auth.ipa_user_effective_groups
|
|
45
|
+
WHERE user_id = ${userId}::uuid
|
|
46
|
+
ORDER BY group_name
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
return rows.map((row) => row.group_name as string);
|
|
50
|
+
};
|
|
51
|
+
|
|
40
52
|
/**
|
|
41
53
|
* Calculate canonical IPA profile from effective group names. Reads
|
|
42
54
|
* `freeipa.groups.base_ipa_realm` from settings (cache-aside).
|
|
@@ -54,11 +66,41 @@ export const calculateIpaProfileFromLocalDb = async (userId: string): Promise<"u
|
|
|
54
66
|
return calculateIpaProfile(groups);
|
|
55
67
|
};
|
|
56
68
|
|
|
69
|
+
export const calculateIpaProfileFromEffectiveProjection = async (userId: string): Promise<"user" | "guest"> => {
|
|
70
|
+
const groups = await getEffectiveUserGroups(userId);
|
|
71
|
+
return calculateIpaProfile(groups);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const rebuildEffectiveProjectionFromLocalMirror = async (userId: string): Promise<string[]> => {
|
|
75
|
+
const groups = await getAllUserGroups(userId);
|
|
76
|
+
await sql`
|
|
77
|
+
DELETE FROM auth.ipa_user_effective_groups
|
|
78
|
+
WHERE user_id = ${userId}::uuid
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
for (const group of groups) {
|
|
82
|
+
await sql`
|
|
83
|
+
INSERT INTO auth.ipa_user_effective_groups (user_id, group_name)
|
|
84
|
+
VALUES (${userId}::uuid, ${group})
|
|
85
|
+
ON CONFLICT DO NOTHING
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return groups;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const getEffectiveUserGroupsWithMirrorFallback = async (userId: string): Promise<string[]> => {
|
|
93
|
+
const projectedGroups = await getEffectiveUserGroups(userId);
|
|
94
|
+
if (projectedGroups.length > 0) return projectedGroups;
|
|
95
|
+
return rebuildEffectiveProjectionFromLocalMirror(userId);
|
|
96
|
+
};
|
|
97
|
+
|
|
57
98
|
/**
|
|
58
99
|
* Update one IPA-backed user's canonical profile projection.
|
|
59
100
|
*/
|
|
60
101
|
export const updateUserIpaProfile = async (userId: string): Promise<void> => {
|
|
61
|
-
const
|
|
102
|
+
const groups = await getEffectiveUserGroupsWithMirrorFallback(userId);
|
|
103
|
+
const profile = await calculateIpaProfile(groups);
|
|
62
104
|
await sql`
|
|
63
105
|
UPDATE auth.users
|
|
64
106
|
SET provider = 'ipa',
|
|
@@ -63,11 +63,9 @@ export const search = async (query: string, options: SearchOptions): Promise<{ u
|
|
|
63
63
|
SELECT u.id, u.uid, u.provider, u.profile, u.given_name, u.sn, u.display_name, u.mail,
|
|
64
64
|
EXISTS(
|
|
65
65
|
SELECT 1
|
|
66
|
-
FROM auth.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
AND g_admin.provider = 'ipa'
|
|
70
|
-
AND g_admin.name = ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
66
|
+
FROM auth.ipa_user_effective_groups eg
|
|
67
|
+
WHERE eg.user_id = u.id
|
|
68
|
+
AND eg.group_name = ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
71
69
|
) AS effective_admin
|
|
72
70
|
FROM auth.users u
|
|
73
71
|
WHERE u.provider = 'ipa'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MutationResult } from "../../contracts/shared";
|
|
2
|
+
import { providers } from "../providers";
|
|
3
|
+
import { getFreeIpaConfig } from "../freeipa-config";
|
|
4
|
+
|
|
5
|
+
export const getServiceIpaSession = async (): Promise<MutationResult<string>> => {
|
|
6
|
+
if (!(await getFreeIpaConfig()).enabled) {
|
|
7
|
+
return { ok: false, error: "FreeIPA is disabled.", status: 400 };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
return { ok: true, data: await providers.ipa.auth.getServiceSession() };
|
|
12
|
+
} catch {
|
|
13
|
+
return { ok: false, error: "Internal FreeIPA service session unavailable.", status: 500 };
|
|
14
|
+
}
|
|
15
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { selectStaleLocalIpaRows } from "./sync-planning";
|
|
3
|
+
|
|
4
|
+
describe("selectStaleLocalIpaRows", () => {
|
|
5
|
+
test("keeps unchanged active IPA users out of stale transitions", () => {
|
|
6
|
+
const stale = selectStaleLocalIpaRows({
|
|
7
|
+
localRows: [{ uid: "eva", mail: "eva@example.test" }],
|
|
8
|
+
activeRemoteUsers: [{ uid: "eva", mail: "eva@example.test" }],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
expect(stale).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("keeps UID-renamed IPA users out of stale transitions when mail still matches", () => {
|
|
15
|
+
const stale = selectStaleLocalIpaRows({
|
|
16
|
+
localRows: [{ uid: "old-eva", mail: "eva@example.test" }],
|
|
17
|
+
activeRemoteUsers: [{ uid: "new-eva", mail: "eva@example.test" }],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(stale).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns IPA users with no active UID or mail match as stale", () => {
|
|
24
|
+
const local = { uid: "old-eva", mail: "old-eva@example.test" };
|
|
25
|
+
const stale = selectStaleLocalIpaRows({
|
|
26
|
+
localRows: [local],
|
|
27
|
+
activeRemoteUsers: [{ uid: "new-eva", mail: "eva@example.test" }],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(stale).toEqual([local]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type IpaIdentity = {
|
|
2
|
+
uid: string;
|
|
3
|
+
mail: string | null;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const selectStaleLocalIpaRows = <T extends IpaIdentity>(params: {
|
|
7
|
+
localRows: T[];
|
|
8
|
+
activeRemoteUsers: IpaIdentity[];
|
|
9
|
+
}): T[] => {
|
|
10
|
+
const activeUids = new Set(params.activeRemoteUsers.map((user) => user.uid));
|
|
11
|
+
const activeMails = new Set(
|
|
12
|
+
params.activeRemoteUsers
|
|
13
|
+
.map((user) => user.mail)
|
|
14
|
+
.filter((mail): mail is string => Boolean(mail)),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
return params.localRows.filter((row) => {
|
|
18
|
+
if (activeUids.has(row.uid)) return false;
|
|
19
|
+
if (row.mail && activeMails.has(row.mail)) return false;
|
|
20
|
+
return true;
|
|
21
|
+
});
|
|
22
|
+
};
|
package/src/services/ipa/sync.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import { applyIpaAccountTransitionPolicy } from "../accounts/switching";
|
|
3
3
|
import {
|
|
4
|
+
calculateIpaProfileFromGroupNames,
|
|
4
5
|
parseIpaAccountTransitionPolicy,
|
|
5
6
|
parseIpaMatchMode,
|
|
6
7
|
} from "../account-model";
|
|
@@ -10,9 +11,12 @@ import * as settings from "../settings";
|
|
|
10
11
|
import { session } from "../session";
|
|
11
12
|
import { freeipa } from "../../server/services";
|
|
12
13
|
import { getFreeIpaConfig } from "../freeipa-config";
|
|
13
|
-
import {
|
|
14
|
+
import { buildEffectiveIpaGroupsByUid } from "./effective-groups";
|
|
15
|
+
import { calculateIpaProfileFromEffectiveProjection, getEffectiveUserGroups } from "./profile";
|
|
16
|
+
import { selectStaleLocalIpaRows } from "./sync-planning";
|
|
14
17
|
|
|
15
18
|
type DbRow = Record<string, unknown>;
|
|
19
|
+
type LocalIpaRow = DbRow & { uid: string; mail: string | null };
|
|
16
20
|
|
|
17
21
|
const log = logger("auth:ipa:sync");
|
|
18
22
|
|
|
@@ -148,7 +152,7 @@ const transformSyncUser = (raw: Record<string, unknown>): SyncUser => {
|
|
|
148
152
|
* `excludedGroupsSet` is hoisted to the caller so we don't re-read settings
|
|
149
153
|
* once per group.
|
|
150
154
|
*/
|
|
151
|
-
const transformSyncGroup = (raw: Record<string, unknown>, excludedGroupsSet: Set<string>): SyncGroup => ({
|
|
155
|
+
const transformSyncGroup = (raw: Record<string, unknown>, excludedGroupsSet: Set<string> = new Set()): SyncGroup => ({
|
|
152
156
|
cn: freeipa.util.str(raw.cn),
|
|
153
157
|
description: freeipa.util.str(raw.description) || null,
|
|
154
158
|
gidnumber: freeipa.util.num(raw.gidnumber),
|
|
@@ -224,15 +228,13 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
224
228
|
]);
|
|
225
229
|
|
|
226
230
|
const allRawUsers = readIpaList({ response: usersRes, entity: "users" });
|
|
227
|
-
|
|
228
|
-
const users = allRawUsers
|
|
229
|
-
.filter((raw) => {
|
|
230
|
-
const groups = (raw.memberof_group as string[]) ?? [];
|
|
231
|
-
return config.groupsBaseSync.some((g) => groups.includes(g));
|
|
232
|
-
})
|
|
233
|
-
.map(transformSyncUser);
|
|
234
|
-
|
|
231
|
+
const allUsers = allRawUsers.map(transformSyncUser);
|
|
235
232
|
const allRawGroups = readIpaList({ response: groupsRes, entity: "groups" });
|
|
233
|
+
const effectiveGroupsByUid = buildEffectiveIpaGroupsByUid(allRawGroups.map((raw) => transformSyncGroup(raw)));
|
|
234
|
+
const users = allUsers.filter((user) => {
|
|
235
|
+
const effectiveGroups = effectiveGroupsByUid.get(user.uid) ?? [];
|
|
236
|
+
return config.groupsBaseSync.some((group) => effectiveGroups.includes(group));
|
|
237
|
+
});
|
|
236
238
|
const groups = allRawGroups.map((raw) => transformSyncGroup(raw, excludedGroupsSet)).filter((g) => !excludedGroupsSet.has(g.cn));
|
|
237
239
|
|
|
238
240
|
const activeUsers = users.filter((u) => !isExpired(u));
|
|
@@ -241,7 +243,7 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
241
243
|
// stale/transition branch so their local mirror is either demoted or deleted per policy,
|
|
242
244
|
// and their sessions are revoked. Treating expired users as in-scope would leave a stale
|
|
243
245
|
// unexpired local row plus live sessions.
|
|
244
|
-
const
|
|
246
|
+
const remoteGroupCns = new Set(allRawGroups.map((raw) => freeipa.util.str(raw.cn)).filter(Boolean));
|
|
245
247
|
const groupCns = new Set(groups.map((g) => g.cn));
|
|
246
248
|
const matchMode = parseIpaMatchMode(await settings.get<string | null>("freeipa.user_match_mode"));
|
|
247
249
|
const transitionPolicy = parseIpaAccountTransitionPolicy(
|
|
@@ -269,7 +271,7 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
269
271
|
if (activeUsers.length === 0 && localIpaUsers > 0) {
|
|
270
272
|
throw new Error(`Refusing IPA sync: remote active users list is empty while local has ${localIpaUsers} IPA users`);
|
|
271
273
|
}
|
|
272
|
-
if (
|
|
274
|
+
if (remoteGroupCns.size === 0 && localGroups > 0) {
|
|
273
275
|
throw new Error(`Refusing IPA sync: remote groups list is empty while local has ${localGroups} groups`);
|
|
274
276
|
}
|
|
275
277
|
|
|
@@ -279,20 +281,65 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
279
281
|
WHERE provider = 'ipa'
|
|
280
282
|
ORDER BY uid
|
|
281
283
|
`;
|
|
282
|
-
const
|
|
284
|
+
const currentProfileByUid = new Map(
|
|
285
|
+
localIpaRows.map((row) => [row.uid as string, row.profile as "user" | "guest"]),
|
|
286
|
+
);
|
|
287
|
+
let profileDriftCount = 0;
|
|
288
|
+
const profileDriftSamples: string[] = [];
|
|
289
|
+
let profilesPromoted = 0;
|
|
290
|
+
let profilesDemoted = 0;
|
|
291
|
+
for (const user of activeUsers) {
|
|
292
|
+
const effectiveGroups = effectiveGroupsByUid.get(user.uid) ?? [];
|
|
293
|
+
const graphProfile = calculateIpaProfileFromGroupNames(effectiveGroups, config.groupsBaseIpaRealm);
|
|
294
|
+
const userSideProfile = calculateIpaProfileFromGroupNames(user.memberofGroup, config.groupsBaseIpaRealm);
|
|
295
|
+
if (graphProfile !== userSideProfile) {
|
|
296
|
+
profileDriftCount += 1;
|
|
297
|
+
if (profileDriftSamples.length < 10) profileDriftSamples.push(user.uid);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const previousProfile = currentProfileByUid.get(user.uid);
|
|
301
|
+
if (previousProfile === "guest" && graphProfile === "user") profilesPromoted += 1;
|
|
302
|
+
if (previousProfile === "user" && graphProfile === "guest") profilesDemoted += 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const localIpaIdentityRows: LocalIpaRow[] = localIpaRows.map((row) => ({
|
|
306
|
+
...row,
|
|
307
|
+
uid: row.uid as string,
|
|
308
|
+
mail: (row.mail as string | null) ?? null,
|
|
309
|
+
}));
|
|
310
|
+
const staleLocalUsers = selectStaleLocalIpaRows({
|
|
311
|
+
localRows: localIpaIdentityRows,
|
|
312
|
+
activeRemoteUsers: activeUsers.map((user) => ({ uid: user.uid, mail: user.mail })),
|
|
313
|
+
});
|
|
283
314
|
const staleLimit = Math.max(10, Math.ceil(Math.max(localIpaUsers, 1) * 0.2));
|
|
284
315
|
if (staleLocalUsers.length > staleLimit) {
|
|
285
316
|
throw new Error(
|
|
286
317
|
`Refusing IPA sync: ${staleLocalUsers.length} local IPA users disappeared from sync scope (limit ${staleLimit})`,
|
|
287
318
|
);
|
|
288
319
|
}
|
|
320
|
+
if (profilesDemoted > staleLimit) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`Refusing IPA sync: ${profilesDemoted} IPA users would be downgraded from user to guest (limit ${staleLimit})`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (profilesDemoted > 0) {
|
|
326
|
+
log.warn("IPA sync will downgrade user profiles", { profilesDemoted, limit: staleLimit });
|
|
327
|
+
}
|
|
328
|
+
if (profileDriftCount > 0) {
|
|
329
|
+
log.warn("IPA user memberOf drift detected; using group graph projection", {
|
|
330
|
+
profileDriftCount,
|
|
331
|
+
sampleUids: profileDriftSamples,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
289
334
|
|
|
290
335
|
const staleDemotedUsers: Array<{ id: string; uid: string }> = [];
|
|
336
|
+
let effectiveGroupsRebuilt = 0;
|
|
291
337
|
await sql.begin(async (tx) => {
|
|
292
338
|
// 1. Upsert active IPA users
|
|
293
339
|
// Match order: mail (existing IPA user, handles UID renames) → mail (guest promotion) → uid (new or unchanged)
|
|
294
340
|
for (const u of activeUsers) {
|
|
295
|
-
const
|
|
341
|
+
const effectiveGroups = effectiveGroupsByUid.get(u.uid) ?? [];
|
|
342
|
+
const profile = calculateIpaProfileFromGroupNames(effectiveGroups, config.groupsBaseIpaRealm);
|
|
296
343
|
const provider = "ipa";
|
|
297
344
|
|
|
298
345
|
if (u.mail) {
|
|
@@ -473,6 +520,23 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
473
520
|
const userIdRows: DbRow[] = await tx`SELECT id, uid FROM auth.users WHERE provider = 'ipa'`;
|
|
474
521
|
const uidToId = new Map<string, string>(userIdRows.map((r) => [r.uid as string, r.id as string]));
|
|
475
522
|
|
|
523
|
+
await tx`
|
|
524
|
+
DELETE FROM auth.ipa_user_effective_groups
|
|
525
|
+
WHERE user_id IN (SELECT id FROM auth.users WHERE provider = 'ipa')
|
|
526
|
+
`;
|
|
527
|
+
const effectiveGroupRows: { user_id: string; group_name: string }[] = [];
|
|
528
|
+
for (const [uid, groupNames] of effectiveGroupsByUid) {
|
|
529
|
+
const userId = uidToId.get(uid);
|
|
530
|
+
if (!userId) continue;
|
|
531
|
+
for (const groupName of groupNames) {
|
|
532
|
+
effectiveGroupRows.push({ user_id: userId, group_name: groupName });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (effectiveGroupRows.length > 0) {
|
|
536
|
+
await tx`INSERT INTO auth.ipa_user_effective_groups ${sql(effectiveGroupRows, "user_id", "group_name")} ON CONFLICT DO NOTHING`;
|
|
537
|
+
}
|
|
538
|
+
effectiveGroupsRebuilt = effectiveGroupRows.length;
|
|
539
|
+
|
|
476
540
|
// Bulk INSERT helper. Bun's `sql(rows)` only generates valid VALUES
|
|
477
541
|
// syntax when fed an array of OBJECTS (it derives the column list from
|
|
478
542
|
// object keys). Passing array-of-arrays produces broken SQL like
|
|
@@ -541,6 +605,11 @@ export const syncFromIpa = async (): Promise<void> => {
|
|
|
541
605
|
skippedLocalMailConflicts,
|
|
542
606
|
skippedLocalUidConflicts,
|
|
543
607
|
staleUsersDemoted: staleDemotedUsers.length,
|
|
608
|
+
scopeTransitions: staleDemotedUsers.length,
|
|
609
|
+
profileDriftCount,
|
|
610
|
+
profilesPromoted,
|
|
611
|
+
profilesDemoted,
|
|
612
|
+
effectiveGroupsRebuilt,
|
|
544
613
|
upsertedUsersByUid,
|
|
545
614
|
insertedUsersByUid,
|
|
546
615
|
updatedUsersByUid,
|
|
@@ -566,8 +635,9 @@ export type SyncUserOutcome =
|
|
|
566
635
|
|
|
567
636
|
/**
|
|
568
637
|
* Reconcile a local IPA mirror row after discovering the remote user is no
|
|
569
|
-
* longer
|
|
570
|
-
*
|
|
638
|
+
* longer valid because the IPA account is expired. Full sync also uses this
|
|
639
|
+
* transition policy for graph-derived out-of-scope users; single-user sync does
|
|
640
|
+
* not demote based on FreeIPA user-side memberOf data.
|
|
571
641
|
*/
|
|
572
642
|
const reconcileOutOfScopeUser = async (params: {
|
|
573
643
|
userId: string;
|
|
@@ -632,12 +702,13 @@ const reconcileOutOfScopeUser = async (params: {
|
|
|
632
702
|
* Called on login to ensure time-sensitive data is up-to-date.
|
|
633
703
|
*
|
|
634
704
|
* IMPORTANT: Only syncs user attributes (name, mail, expiry, aliases).
|
|
635
|
-
* Does NOT sync group memberships
|
|
636
|
-
*
|
|
705
|
+
* Does NOT sync group memberships. Scope/profile come from the last full-sync
|
|
706
|
+
* effective group projection; user-side memberOf drift is logged but never used
|
|
707
|
+
* for destructive transitions here.
|
|
637
708
|
*
|
|
638
709
|
* Returns a typed outcome so callers (notably `authFlows.ipa.login`) can decide
|
|
639
|
-
* whether to grant a session.
|
|
640
|
-
*
|
|
710
|
+
* whether to grant a session. Expired users are reconciled immediately; group
|
|
711
|
+
* scope changes are handled by full sync.
|
|
641
712
|
*/
|
|
642
713
|
export const syncUser = async (username: string): Promise<SyncUserOutcome> => {
|
|
643
714
|
const config = await getFreeIpaConfig();
|
|
@@ -697,29 +768,30 @@ export const syncUser = async (username: string): Promise<SyncUserOutcome> => {
|
|
|
697
768
|
return { status: "expired", userId: existingUserId };
|
|
698
769
|
}
|
|
699
770
|
|
|
700
|
-
const inSyncGroups = config.groupsBaseSync.some((g) => user.memberofGroup.includes(g));
|
|
701
|
-
if (!inSyncGroups) {
|
|
702
|
-
log.warn("User not in sync groups during single-user sync", { username });
|
|
703
|
-
if (existingUserId) {
|
|
704
|
-
await reconcileOutOfScopeUser({
|
|
705
|
-
userId: existingUserId,
|
|
706
|
-
uid: user.uid,
|
|
707
|
-
mail: (existingRow?.mail as string | null) ?? user.mail,
|
|
708
|
-
displayName: (existingRow?.display_name as string | null) ?? user.displayName,
|
|
709
|
-
previousProfile,
|
|
710
|
-
reason: "sync_out_of_scope_demoted",
|
|
711
|
-
meta: { reason: "missing_from_ipa_sync_scope" },
|
|
712
|
-
});
|
|
713
|
-
}
|
|
714
|
-
return { status: "out_of_scope", userId: existingUserId };
|
|
715
|
-
}
|
|
716
|
-
|
|
717
771
|
if (!existingUserId) {
|
|
718
772
|
log.warn("User not found in local DB during single-user sync", { username });
|
|
719
773
|
return { status: "not_found_local" };
|
|
720
774
|
}
|
|
721
775
|
|
|
722
|
-
const
|
|
776
|
+
const effectiveGroups = await getEffectiveUserGroups(existingUserId);
|
|
777
|
+
const inSyncGroups = config.groupsBaseSync.some((g) => effectiveGroups.includes(g));
|
|
778
|
+
if (!inSyncGroups) {
|
|
779
|
+
log.warn("User not in projected sync groups during single-user sync", {
|
|
780
|
+
username,
|
|
781
|
+
reason: "missing_from_last_full_sync_projection",
|
|
782
|
+
});
|
|
783
|
+
return { status: "out_of_scope", userId: existingUserId };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const profile = await calculateIpaProfileFromEffectiveProjection(existingUserId);
|
|
787
|
+
const userSideProfile = calculateIpaProfileFromGroupNames(user.memberofGroup, config.groupsBaseIpaRealm);
|
|
788
|
+
if (profile !== userSideProfile) {
|
|
789
|
+
log.warn("IPA user memberOf drift detected during single-user sync", {
|
|
790
|
+
username,
|
|
791
|
+
projectedProfile: profile,
|
|
792
|
+
userSideProfile,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
723
795
|
const provider = "ipa";
|
|
724
796
|
|
|
725
797
|
// Update user attributes only (no group sync!)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import * as jose from "jose";
|
|
3
|
+
import { accounts } from "./accounts";
|
|
4
|
+
import * as settings from "./settings";
|
|
5
|
+
import { serviceAccounts, type ServiceAccount } from "./service-accounts";
|
|
6
|
+
import type { User } from "../contracts/shared";
|
|
7
|
+
|
|
8
|
+
type DbKey = {
|
|
9
|
+
public_key: string;
|
|
10
|
+
kid: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const parseScopeClaim = (payload: jose.JWTPayload): string[] => {
|
|
14
|
+
const value = payload.scope;
|
|
15
|
+
if (typeof value !== "string") return [];
|
|
16
|
+
return value
|
|
17
|
+
.split(/\s+/)
|
|
18
|
+
.map((scope) => scope.trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type AuthenticatedOAuthToken =
|
|
23
|
+
| {
|
|
24
|
+
kind: "user";
|
|
25
|
+
payload: jose.JWTPayload;
|
|
26
|
+
user: User;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
kind: "service_account";
|
|
30
|
+
payload: jose.JWTPayload;
|
|
31
|
+
serviceAccount: ServiceAccount;
|
|
32
|
+
delegatedUser: User | null;
|
|
33
|
+
scopes: string[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const getIssuer = async (): Promise<string> => {
|
|
37
|
+
const appUrl = await settings.get<string>("app.url");
|
|
38
|
+
return appUrl.startsWith("http") ? appUrl : `https://${appUrl}`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getCurrentPublicKey = async (): Promise<CryptoKey | null> => {
|
|
42
|
+
const [row] = await sql<DbKey[]>`
|
|
43
|
+
SELECT public_key, kid
|
|
44
|
+
FROM oauth.keys
|
|
45
|
+
WHERE id = 'current'
|
|
46
|
+
`;
|
|
47
|
+
if (!row) return null;
|
|
48
|
+
return jose.importSPKI(row.public_key, "RS256");
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const getStringClaim = (payload: jose.JWTPayload, key: string): string | null => {
|
|
52
|
+
const value = payload[key];
|
|
53
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const verifyAccessToken = async (token: string): Promise<AuthenticatedOAuthToken | null> => {
|
|
57
|
+
const publicKey = await getCurrentPublicKey();
|
|
58
|
+
if (!publicKey) return null;
|
|
59
|
+
|
|
60
|
+
let payload: jose.JWTPayload;
|
|
61
|
+
try {
|
|
62
|
+
const result = await jose.jwtVerify(token, publicKey, {
|
|
63
|
+
issuer: await getIssuer(),
|
|
64
|
+
audience: "cloud",
|
|
65
|
+
});
|
|
66
|
+
payload = result.payload;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (payload.token_use !== "access") return null;
|
|
72
|
+
|
|
73
|
+
const serviceAccountId = getStringClaim(payload, "service_account_id");
|
|
74
|
+
if (serviceAccountId) {
|
|
75
|
+
const serviceAccount = await serviceAccounts.get({ id: serviceAccountId });
|
|
76
|
+
if (!serviceAccount || serviceAccount.status !== "active") return null;
|
|
77
|
+
|
|
78
|
+
const delegatedUser = serviceAccount.delegatedUserId ? await accounts.users.get({ id: serviceAccount.delegatedUserId }) : null;
|
|
79
|
+
if (serviceAccount.kind === "user_delegated" && !delegatedUser) return null;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
kind: "service_account",
|
|
83
|
+
payload,
|
|
84
|
+
serviceAccount,
|
|
85
|
+
delegatedUser,
|
|
86
|
+
scopes: parseScopeClaim(payload),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const userId = getStringClaim(payload, "id");
|
|
91
|
+
const uid = getStringClaim(payload, "uid") ?? (typeof payload.sub === "string" ? payload.sub : null);
|
|
92
|
+
const user = userId ? await accounts.users.get({ id: userId }) : uid ? await accounts.users.get({ uid }) : null;
|
|
93
|
+
if (!user) return null;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
kind: "user",
|
|
97
|
+
payload,
|
|
98
|
+
user,
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const oauthTokens = {
|
|
103
|
+
verifyAccessToken,
|
|
104
|
+
};
|
package/src/services/postgres.ts
CHANGED
|
@@ -35,17 +35,32 @@ export const parsePgJsonRecord = (value: unknown): Record<string, unknown> | nul
|
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Classify a thrown Postgres error.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
* Classify a thrown Postgres error. Use at service boundaries to turn
|
|
39
|
+
* unique-constraint violations into typed 409 results instead of bubbling
|
|
40
|
+
* up raw DB errors to API clients.
|
|
41
|
+
*
|
|
42
|
+
* Two driver shapes coexist in this repo:
|
|
43
|
+
* - postgres.js: `e.code = "23505"` (the SQLSTATE directly)
|
|
44
|
+
* - bun.sql: `e.code = "ERR_POSTGRES_SERVER_ERROR"`, SQLSTATE on `e.errno`
|
|
45
|
+
*
|
|
46
|
+
* Checking only `e.code` silently fails on Bun (the Wave-1.1 migration
|
|
47
|
+
* idempotence bug had the same root cause). Treat either field carrying
|
|
48
|
+
* "23505" as a unique violation so the helper works regardless of which
|
|
49
|
+
* driver instantiated the error.
|
|
42
50
|
*/
|
|
43
|
-
export type PgError = {
|
|
51
|
+
export type PgError = {
|
|
52
|
+
code?: string;
|
|
53
|
+
errno?: string;
|
|
54
|
+
constraint_name?: string;
|
|
55
|
+
detail?: string;
|
|
56
|
+
message?: string;
|
|
57
|
+
};
|
|
44
58
|
|
|
45
59
|
export const isUniqueViolation = (error: unknown, constraintName?: string): boolean => {
|
|
46
60
|
if (!error || typeof error !== "object") return false;
|
|
47
61
|
const e = error as PgError;
|
|
48
|
-
|
|
62
|
+
const sqlstate = e.code === "23505" || e.errno === "23505";
|
|
63
|
+
if (!sqlstate) return false;
|
|
49
64
|
if (!constraintName) return true;
|
|
50
65
|
return e.constraint_name === constraintName;
|
|
51
66
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
consumePasswordResetToken,
|
|
4
|
+
createPasswordResetToken,
|
|
5
|
+
} from "./auth";
|
|
6
|
+
|
|
7
|
+
describe("local auth password reset tokens", () => {
|
|
8
|
+
test("consumes password reset tokens only once", async () => {
|
|
9
|
+
const payload = {
|
|
10
|
+
userId: crypto.randomUUID(),
|
|
11
|
+
uid: `reset-${crypto.randomUUID()}`,
|
|
12
|
+
email: `reset-${crypto.randomUUID()}@example.test`,
|
|
13
|
+
};
|
|
14
|
+
const token = await createPasswordResetToken({
|
|
15
|
+
...payload,
|
|
16
|
+
ttlSeconds: 30,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(await consumePasswordResetToken(token)).toEqual(payload);
|
|
20
|
+
expect(await consumePasswordResetToken(token)).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -1,13 +1,56 @@
|
|
|
1
1
|
import { redis } from "bun";
|
|
2
2
|
|
|
3
|
-
export const createMagicLinkToken = async (params: {
|
|
3
|
+
export const createMagicLinkToken = async (params: {
|
|
4
|
+
email: string;
|
|
5
|
+
ttlSeconds?: number;
|
|
6
|
+
}): Promise<string> => {
|
|
4
7
|
const token = crypto.randomUUID();
|
|
5
|
-
await redis.set(
|
|
8
|
+
await redis.set(
|
|
9
|
+
`email-login:${token}`,
|
|
10
|
+
JSON.stringify({ email: params.email }),
|
|
11
|
+
"EX",
|
|
12
|
+
params.ttlSeconds ?? 300
|
|
13
|
+
);
|
|
6
14
|
return token;
|
|
7
15
|
};
|
|
8
16
|
|
|
9
|
-
export const consumeMagicLinkToken = async (
|
|
17
|
+
export const consumeMagicLinkToken = async (
|
|
18
|
+
token: string
|
|
19
|
+
): Promise<{ email: string } | null> => {
|
|
10
20
|
const raw = await redis.getdel(`email-login:${token}`);
|
|
11
21
|
if (!raw) return null;
|
|
12
22
|
return JSON.parse(raw) as { email: string };
|
|
13
23
|
};
|
|
24
|
+
|
|
25
|
+
type PasswordResetPayload = {
|
|
26
|
+
userId: string;
|
|
27
|
+
uid: string;
|
|
28
|
+
email: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const passwordResetTokenKey = (token: string) => `password-reset:${token}`;
|
|
32
|
+
|
|
33
|
+
export const createPasswordResetToken = async (
|
|
34
|
+
params: PasswordResetPayload & { ttlSeconds?: number }
|
|
35
|
+
): Promise<string> => {
|
|
36
|
+
const token = crypto.randomUUID();
|
|
37
|
+
await redis.set(
|
|
38
|
+
passwordResetTokenKey(token),
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
userId: params.userId,
|
|
41
|
+
uid: params.uid,
|
|
42
|
+
email: params.email,
|
|
43
|
+
}),
|
|
44
|
+
"EX",
|
|
45
|
+
params.ttlSeconds ?? 900
|
|
46
|
+
);
|
|
47
|
+
return token;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const consumePasswordResetToken = async (
|
|
51
|
+
token: string
|
|
52
|
+
): Promise<PasswordResetPayload | null> => {
|
|
53
|
+
const raw = await redis.getdel(passwordResetTokenKey(token));
|
|
54
|
+
if (!raw) return null;
|
|
55
|
+
return JSON.parse(raw) as PasswordResetPayload;
|
|
56
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { decryptValue, encryptValue } from "./settings/crypto";
|
|
2
|
+
|
|
3
|
+
export const encryptSecret = async (value: unknown): Promise<string> => encryptValue(value);
|
|
4
|
+
|
|
5
|
+
export const decryptSecret = async <T = unknown>(value: string): Promise<T> => (await decryptValue(value)) as T;
|
|
6
|
+
|
|
7
|
+
export const secrets = {
|
|
8
|
+
encrypt: encryptSecret,
|
|
9
|
+
decrypt: decryptSecret,
|
|
10
|
+
};
|