@k2b/cloud 0.45.0 → 0.47.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 +11 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +40 -0
- package/src/_internal/heartbeat.ts +19 -0
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +9 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +6 -0
- package/src/_internal/workload-credential.ts +153 -0
- package/src/_internal/workload-protocol.ts +36 -0
- package/src/ai/application-tasks.ts +5 -2
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chart-block.ts +36 -711
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tool-contracts.ts +21 -259
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/executor.ts +13 -1
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/protocol.ts +3 -1
- package/src/ai/routes.ts +2 -0
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/admin-app-keys.ts +66 -0
- package/src/api/admin-core-settings.ts +2 -0
- package/src/api/admin-identity.ts +27 -1
- package/src/api/admin-settings-env.ts +120 -0
- package/src/api/capabilities.ts +99 -15
- package/src/api/index.ts +3 -0
- package/src/api/mcp.ts +4 -2
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/browser/notifications.ts +25 -8
- package/src/capabilities/discussion-providers.ts +100 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +69 -0
- package/src/capabilities/response.ts +4 -1
- package/src/capabilities/server-invoke.ts +64 -36
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/capabilities/types.ts +1 -1
- package/src/cli/admin/app-credentials.ts +32 -7
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +7 -1
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +27 -21
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/index.ts +9 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +37 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/client-ip.ts +1 -1
- package/src/services/accounts/app.ts +28 -6
- package/src/services/accounts/entities.ts +11 -4
- package/src/services/accounts/groups.ts +10 -4
- package/src/services/accounts/local-groups.ts +87 -38
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/app-keys.ts +298 -0
- package/src/services/gateway.ts +6 -0
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/index.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/identity/resource-readers.ts +80 -0
- package/src/services/identity/runtime-config.ts +14 -6
- package/src/services/identity/workload-runtime.ts +253 -0
- package/src/services/index.ts +4 -1
- package/src/services/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/service-account-credentials.ts +63 -21
- package/src/services/settings/api.ts +3 -3
- package/src/services/settings/binding.ts +45 -0
- package/src/services/settings/core-settings.ts +99 -1
- package/src/services/settings/defaults.ts +16 -2
- package/src/services/settings/env-status.ts +69 -0
- package/src/services/settings/index.ts +69 -26
- package/src/services/settings/store.ts +73 -8
- package/src/services/settings/validation.ts +40 -0
- package/src/shared/index.ts +11 -10
- package/src/shared/markdown/extensions/tables.ts +10 -1
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/start.ts +36 -0
- package/src/styles/global.css +2 -0
- package/src/workflows/ai/index.ts +1 -0
- package/src/workflows/ai/runtime.ts +16 -8
- package/src/workflows/ai/settings.ts +5 -0
- package/src/shared/markdown/formula.ts +0 -1283
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/** Core-only workload issuer. This module never reads identity signing keys, OAuth secrets, or sessions. */
|
|
2
|
+
import type { NatsConnection } from "@nats-io/transport-node";
|
|
3
|
+
import { sql } from "bun";
|
|
4
|
+
import { getProcessNats } from "../../_internal/process-sync";
|
|
5
|
+
import { APP_REGISTRY_TTL_MS, appInstancePrefix, appInstanceRegistry } from "../../_internal/registry";
|
|
6
|
+
import {
|
|
7
|
+
WORKLOAD_CHECK_INTERVAL_MS,
|
|
8
|
+
WORKLOAD_LIFETIME_MS,
|
|
9
|
+
WORKLOAD_ROTATE_BEFORE_MS,
|
|
10
|
+
type WorkloadRequest,
|
|
11
|
+
WorkloadRequestSchema,
|
|
12
|
+
type WorkloadResponse,
|
|
13
|
+
workloadSubject,
|
|
14
|
+
} from "../../_internal/workload-protocol";
|
|
15
|
+
import { env } from "../../config/env";
|
|
16
|
+
import { audit } from "../audit";
|
|
17
|
+
import { logger } from "../logging";
|
|
18
|
+
import { serviceAccountCredentials } from "../service-account-credentials";
|
|
19
|
+
import { serviceAccounts } from "../service-accounts";
|
|
20
|
+
|
|
21
|
+
const isRegistered = async (appId: string, instanceId: string): Promise<boolean> => {
|
|
22
|
+
const key = `${appInstancePrefix(appId)}${instanceId}`;
|
|
23
|
+
const snapshot = await appInstanceRegistry().snapshot({ prefix: key });
|
|
24
|
+
return snapshot.entries.some((entry) => entry.key === key && entry.value.appId === appId && entry.value.instanceId === instanceId);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const issueAppWorkloadCredential = async (
|
|
28
|
+
request: WorkloadRequest,
|
|
29
|
+
registered: (appId: string, instanceId: string) => Promise<boolean> = isRegistered,
|
|
30
|
+
): Promise<WorkloadResponse> => {
|
|
31
|
+
if (!(await registered(request.appId, request.instanceId))) return { status: "unregistered" };
|
|
32
|
+
const account = await serviceAccounts.getOrCreateResourceBound({
|
|
33
|
+
name: `${request.appId} workload`,
|
|
34
|
+
appId: request.appId,
|
|
35
|
+
resourceType: "cloud.app",
|
|
36
|
+
resourceId: request.appId,
|
|
37
|
+
});
|
|
38
|
+
if (!account.ok) return { status: "unavailable" };
|
|
39
|
+
return sql.begin(async (db) => {
|
|
40
|
+
// One account lock serializes issuance/rotation/removal across Core replicas. Admin revocation
|
|
41
|
+
// locks the credential row too, so it cannot be overwritten by an in-flight refresh.
|
|
42
|
+
const [owner] = await db<{ status: string }[]>`SELECT status FROM auth.service_accounts WHERE id = ${account.data.id}::uuid FOR UPDATE`;
|
|
43
|
+
if (owner?.status !== "active") return { status: "revoked" };
|
|
44
|
+
const [current] = await db<{ id: string; status: string; expires_at: Date; previous_credential_id: string | null }[]>`
|
|
45
|
+
SELECT c.id, c.status, c.expires_at, i.previous_credential_id FROM auth.app_workload_instances i
|
|
46
|
+
JOIN auth.service_account_credentials c ON c.id = i.credential_id
|
|
47
|
+
WHERE i.app_id = ${request.appId} AND i.instance_id = ${request.instanceId}
|
|
48
|
+
FOR UPDATE OF c
|
|
49
|
+
`;
|
|
50
|
+
if (request.operation === "release") {
|
|
51
|
+
if (current?.status === "active") {
|
|
52
|
+
for (const credentialId of [current.id, current.previous_credential_id]) {
|
|
53
|
+
if (!credentialId) continue;
|
|
54
|
+
const revoked = await serviceAccountCredentials.revoke(
|
|
55
|
+
{ credentialId, system: { service: "app-workload", reason: "instance stopped" } },
|
|
56
|
+
{ db },
|
|
57
|
+
);
|
|
58
|
+
if (!revoked.ok) throw new Error("Stopped workload credential could not be revoked");
|
|
59
|
+
}
|
|
60
|
+
await db`DELETE FROM auth.app_workload_instances WHERE app_id = ${request.appId} AND instance_id = ${request.instanceId}`;
|
|
61
|
+
await db`DELETE FROM auth.service_account_credentials WHERE id = ${current.id}::uuid OR id = ${current.previous_credential_id}::uuid`;
|
|
62
|
+
}
|
|
63
|
+
return { status: "revoked" };
|
|
64
|
+
}
|
|
65
|
+
if (!(await registered(request.appId, request.instanceId))) return { status: "unregistered" };
|
|
66
|
+
await db`UPDATE auth.app_workload_instances SET last_refreshed_at = now()
|
|
67
|
+
WHERE app_id = ${request.appId} AND instance_id = ${request.instanceId}`;
|
|
68
|
+
if (
|
|
69
|
+
current?.status === "active" &&
|
|
70
|
+
current.id === request.credentialId &&
|
|
71
|
+
current.expires_at.getTime() - Date.now() > WORKLOAD_ROTATE_BEFORE_MS
|
|
72
|
+
) {
|
|
73
|
+
return { status: "current", credentialId: current.id };
|
|
74
|
+
}
|
|
75
|
+
const expiresAt = new Date(Date.now() + WORKLOAD_LIFETIME_MS).toISOString();
|
|
76
|
+
const issued = await serviceAccountCredentials.createApiToken(
|
|
77
|
+
{
|
|
78
|
+
serviceAccountId: account.data.id,
|
|
79
|
+
name: `Runtime ${request.instanceId}`,
|
|
80
|
+
scopes: ["identity:invoke"],
|
|
81
|
+
expiresAt,
|
|
82
|
+
},
|
|
83
|
+
{ db },
|
|
84
|
+
);
|
|
85
|
+
if (!issued.ok) return { status: "unavailable" };
|
|
86
|
+
// An acknowledged current token overlaps its successor until expiry. When a reply was
|
|
87
|
+
// lost, replace only the undelivered generation and keep the token the caller still knows.
|
|
88
|
+
const retainedId = current
|
|
89
|
+
? request.credentialId === current.id
|
|
90
|
+
? current.id
|
|
91
|
+
: current.previous_credential_id === request.credentialId
|
|
92
|
+
? current.previous_credential_id
|
|
93
|
+
: null
|
|
94
|
+
: null;
|
|
95
|
+
for (const retiredId of [current?.id, current?.previous_credential_id]) {
|
|
96
|
+
if (!retiredId || retiredId === retainedId) continue;
|
|
97
|
+
const revoked = await serviceAccountCredentials.revoke(
|
|
98
|
+
{ credentialId: retiredId, system: { service: "app-workload", reason: "rotation" } },
|
|
99
|
+
{ db },
|
|
100
|
+
);
|
|
101
|
+
if (!revoked.ok) throw new Error("Workload rotation could not retire previous credential");
|
|
102
|
+
}
|
|
103
|
+
await db`
|
|
104
|
+
INSERT INTO auth.app_workload_instances (app_id, instance_id, credential_id, previous_credential_id)
|
|
105
|
+
VALUES (${request.appId}, ${request.instanceId}, ${issued.data.credential.id}::uuid, ${retainedId}::uuid)
|
|
106
|
+
ON CONFLICT (app_id, instance_id) DO UPDATE
|
|
107
|
+
SET credential_id = EXCLUDED.credential_id, previous_credential_id = EXCLUDED.previous_credential_id, last_refreshed_at = now()
|
|
108
|
+
`;
|
|
109
|
+
for (const retiredId of [current?.id, current?.previous_credential_id]) {
|
|
110
|
+
if (retiredId && retiredId !== retainedId) await db`DELETE FROM auth.service_account_credentials WHERE id = ${retiredId}::uuid`;
|
|
111
|
+
}
|
|
112
|
+
await audit.record(
|
|
113
|
+
{
|
|
114
|
+
action: "service_account_credential.create",
|
|
115
|
+
outcome: "allowed",
|
|
116
|
+
target: { type: "service_account_credential", id: issued.data.credential.id, label: issued.data.credential.name },
|
|
117
|
+
metadata: {
|
|
118
|
+
provenance: "system",
|
|
119
|
+
service: "app-workload",
|
|
120
|
+
appId: request.appId,
|
|
121
|
+
instanceId: request.instanceId,
|
|
122
|
+
serviceAccountId: account.data.id,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
db,
|
|
126
|
+
);
|
|
127
|
+
// Deliberately construct the response: only a newly minted app-bound token and its coordinates.
|
|
128
|
+
return {
|
|
129
|
+
status: "issued",
|
|
130
|
+
appId: request.appId,
|
|
131
|
+
instanceId: request.instanceId,
|
|
132
|
+
credentialId: issued.data.credential.id,
|
|
133
|
+
token: issued.data.token,
|
|
134
|
+
expiresAt,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Revoke disappeared instances, including TTL expiry after an ungraceful stop. Pages bound memory. */
|
|
140
|
+
export const revokeRemovedAppWorkloads = async (
|
|
141
|
+
registered: (appId: string, instanceId: string) => Promise<boolean> = isRegistered,
|
|
142
|
+
): Promise<void> => {
|
|
143
|
+
let afterApp = "";
|
|
144
|
+
let afterInstance = "";
|
|
145
|
+
for (;;) {
|
|
146
|
+
// Same page size as the credential administration API; never materialize credential history.
|
|
147
|
+
const rows = await sql<{ app_id: string; instance_id: string; credential_id: string; service_account_id: string }[]>`
|
|
148
|
+
SELECT i.app_id, i.instance_id, i.credential_id, c.service_account_id
|
|
149
|
+
FROM auth.app_workload_instances i JOIN auth.service_account_credentials c ON c.id = i.credential_id
|
|
150
|
+
WHERE c.status = 'active' AND i.last_refreshed_at < ${new Date(Date.now() - APP_REGISTRY_TTL_MS)} AND (i.app_id, i.instance_id) > (${afterApp}, ${afterInstance})
|
|
151
|
+
ORDER BY i.app_id, i.instance_id LIMIT 100
|
|
152
|
+
`;
|
|
153
|
+
if (!rows.length) return;
|
|
154
|
+
for (const row of rows) {
|
|
155
|
+
afterApp = row.app_id;
|
|
156
|
+
afterInstance = row.instance_id;
|
|
157
|
+
if (await registered(row.app_id, row.instance_id)) continue;
|
|
158
|
+
await sql.begin(async (db) => {
|
|
159
|
+
await db`SELECT id FROM auth.service_accounts WHERE id = ${row.service_account_id}::uuid FOR UPDATE`;
|
|
160
|
+
if (await registered(row.app_id, row.instance_id)) return;
|
|
161
|
+
// Read again under the account lock, in case another Core replica rotated it meanwhile.
|
|
162
|
+
const [current] = await db<
|
|
163
|
+
{ credential_id: string; previous_credential_id: string | null; status: string; last_refreshed_at: Date }[]
|
|
164
|
+
>`
|
|
165
|
+
SELECT i.credential_id, i.previous_credential_id, c.status, i.last_refreshed_at FROM auth.app_workload_instances i
|
|
166
|
+
JOIN auth.service_account_credentials c ON c.id = i.credential_id
|
|
167
|
+
WHERE i.app_id = ${row.app_id} AND i.instance_id = ${row.instance_id}
|
|
168
|
+
FOR UPDATE OF c
|
|
169
|
+
`;
|
|
170
|
+
if (current?.status === "active" && current.last_refreshed_at.getTime() < Date.now() - APP_REGISTRY_TTL_MS) {
|
|
171
|
+
for (const credentialId of [current.credential_id, current.previous_credential_id]) {
|
|
172
|
+
if (!credentialId) continue;
|
|
173
|
+
const revoked = await serviceAccountCredentials.revoke(
|
|
174
|
+
{ credentialId, system: { service: "app-workload", reason: "instance removed" } },
|
|
175
|
+
{ db },
|
|
176
|
+
);
|
|
177
|
+
if (!revoked.ok) throw new Error("Removed workload credential could not be revoked");
|
|
178
|
+
}
|
|
179
|
+
// An automatically retired instance may register again after a registry reset. An
|
|
180
|
+
// admin-revoked credential keeps its mapping and disabled account until explicit recovery.
|
|
181
|
+
await db`DELETE FROM auth.app_workload_instances WHERE app_id = ${row.app_id} AND instance_id = ${row.instance_id}`;
|
|
182
|
+
await db`DELETE FROM auth.service_account_credentials WHERE id = ${current.credential_id}::uuid OR id = ${current.previous_credential_id}::uuid`;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const startAppWorkloadBroker = async (
|
|
190
|
+
options: {
|
|
191
|
+
connection?: NatsConnection;
|
|
192
|
+
namespace?: string;
|
|
193
|
+
issue?: (request: WorkloadRequest) => Promise<WorkloadResponse>;
|
|
194
|
+
reconcile?: () => Promise<void>;
|
|
195
|
+
} = {},
|
|
196
|
+
): Promise<() => Promise<void>> => {
|
|
197
|
+
const connection = options.connection ?? getProcessNats();
|
|
198
|
+
const issue = options.issue ?? issueAppWorkloadCredential;
|
|
199
|
+
const reconcile = options.reconcile ?? revokeRemovedAppWorkloads;
|
|
200
|
+
const log = logger("app-workload");
|
|
201
|
+
// Issuance gets four concurrent DB transactions per Core replica, reserving the rest of
|
|
202
|
+
// Bun SQL's default ten-connection pool for identity requests. Overload retries at the caller.
|
|
203
|
+
const inFlight = new Set<Promise<void>>();
|
|
204
|
+
let stopped = false;
|
|
205
|
+
let maintenance: Promise<void> | undefined;
|
|
206
|
+
let timer: Timer | undefined;
|
|
207
|
+
const subscription = connection.subscribe(workloadSubject(options.namespace ?? env.SYNC_NAMESPACE), {
|
|
208
|
+
queue: "core-workload-v1",
|
|
209
|
+
callback: (error, message) => {
|
|
210
|
+
if (error || !message.reply) return;
|
|
211
|
+
// App id, instance id and UUID fit in 1 KiB including JSON escaping. Reject larger input before parsing.
|
|
212
|
+
if (stopped || inFlight.size >= 4 || message.data.length > 1024) {
|
|
213
|
+
message.respond(JSON.stringify({ status: "unavailable" }));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const operation = (async () => {
|
|
217
|
+
let response: WorkloadResponse = { status: "unavailable" };
|
|
218
|
+
try {
|
|
219
|
+
const parsed = WorkloadRequestSchema.safeParse(message.json<unknown>());
|
|
220
|
+
if (parsed.success) response = await issue(parsed.data);
|
|
221
|
+
} catch {
|
|
222
|
+
log.error("Workload credential issuance failed; caller will retry");
|
|
223
|
+
}
|
|
224
|
+
if (!stopped) message.respond(JSON.stringify(response));
|
|
225
|
+
})();
|
|
226
|
+
inFlight.add(operation);
|
|
227
|
+
void operation.finally(() => inFlight.delete(operation));
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
const maintain = () => {
|
|
231
|
+
maintenance = reconcile()
|
|
232
|
+
.catch(() => {
|
|
233
|
+
log.error("Workload removal reconciliation failed; retrying");
|
|
234
|
+
})
|
|
235
|
+
.finally(() => {
|
|
236
|
+
if (!stopped) timer = setTimeout(maintain, WORKLOAD_CHECK_INTERVAL_MS);
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
const stop = async () => {
|
|
240
|
+
stopped = true;
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
subscription.unsubscribe();
|
|
243
|
+
await Promise.allSettled([...inFlight, ...(maintenance ? [maintenance] : [])]);
|
|
244
|
+
};
|
|
245
|
+
maintain();
|
|
246
|
+
try {
|
|
247
|
+
await connection.flush();
|
|
248
|
+
} catch (error) {
|
|
249
|
+
await stop();
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
return stop;
|
|
253
|
+
};
|
package/src/services/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type { PosixCandidate } from "./accounts/posix";
|
|
|
8
8
|
export { accountIdentities, AccountIdentityError } from "./accounts/identities";
|
|
9
9
|
export type { AccountIdentityUser, AccountIdentityGroup, AccountIdentityPage, AccountIdentityAvailability } from "./accounts/identities";
|
|
10
10
|
export type { AccountIdentityReconciliation } from "./accounts/identity-reconciliation";
|
|
11
|
+
export { getAppWorkloadCredential } from "../_internal/workload-credential";
|
|
11
12
|
export { providers } from "./providers";
|
|
12
13
|
export { createAllowedNetworkFetch } from "./allowed-network-fetch";
|
|
13
14
|
export { requestPublicHttps, type PublicHttpInput } from "./public-http";
|
|
@@ -281,7 +282,9 @@ export {
|
|
|
281
282
|
type AppAccessSnapshot,
|
|
282
283
|
} from "./app-access-management";
|
|
283
284
|
|
|
284
|
-
|
|
285
|
+
import { storage as platformStorage } from "./object-store";
|
|
286
|
+
/** @deprecated Import storage from @k2b/cloud. */
|
|
287
|
+
export const storage = platformStorage;
|
|
285
288
|
export type {
|
|
286
289
|
ObjectStoreFile,
|
|
287
290
|
ObjectVisibility,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { env } from "../../config/env";
|
|
2
1
|
import { networkAddressFamily, normalizeNetworkHostname, parseNetworkAllowlistEnv } from "../../shared/network-address";
|
|
3
2
|
import { NetworkTargetBlockedError, resolveAllowedNetworkAddresses } from "../network-security";
|
|
3
|
+
import { coreSettings } from "../settings/api";
|
|
4
4
|
|
|
5
|
-
export const outgoingMailAllowlist = () =>
|
|
5
|
+
export const outgoingMailAllowlist = async () =>
|
|
6
|
+
parseNetworkAllowlistEnv("outgoing_mail.allowed_targets", await coreSettings.get<string[]>("outgoing_mail.allowed_targets"));
|
|
6
7
|
|
|
7
8
|
/** One bounded lookup shared by SMTP and bounce IMAP; clients use only the checked addresses. */
|
|
8
9
|
export const resolveMailHost = async (host: string, signal?: AbortSignal, resolve = resolveAllowedNetworkAddresses, port?: number) => {
|
|
@@ -14,7 +15,7 @@ export const resolveMailHost = async (host: string, signal?: AbortSignal, resolv
|
|
|
14
15
|
try {
|
|
15
16
|
cancellation.throwIfAborted();
|
|
16
17
|
const addresses = await Promise.race([
|
|
17
|
-
resolve(hostname,
|
|
18
|
+
outgoingMailAllowlist().then((allowlist) => resolve(hostname, allowlist, undefined, port)),
|
|
18
19
|
new Promise<never>((_, reject) => {
|
|
19
20
|
abort = () => reject(cancellation.reason);
|
|
20
21
|
cancellation.addEventListener("abort", abort, { once: true });
|
|
@@ -29,7 +30,7 @@ export const resolveMailHost = async (host: string, signal?: AbortSignal, resolv
|
|
|
29
30
|
} catch (error) {
|
|
30
31
|
if (error instanceof NetworkTargetBlockedError)
|
|
31
32
|
throw new NetworkTargetBlockedError(
|
|
32
|
-
"Outgoing mail target is on an internal network. An operator can allow it with
|
|
33
|
+
"Outgoing mail target is on an internal network. An operator can allow it with outgoing_mail.allowed_targets.",
|
|
33
34
|
);
|
|
34
35
|
throw error;
|
|
35
36
|
} finally {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GEO_SNAPSHOT_MAX_BYTES,
|
|
3
|
+
GEO_SNAPSHOT_MAX_METADATA_BYTES,
|
|
4
|
+
type GeoMapSnapshotManifest,
|
|
5
|
+
GeoMapSnapshotManifestSchema,
|
|
6
|
+
geoMapSnapshotPart,
|
|
7
|
+
} from "../../contracts/geo-snapshot";
|
|
8
|
+
import { type PreparePdfImageOptions, preparePdfImage } from "./image";
|
|
9
|
+
|
|
10
|
+
export type GeoMapSnapshotAsset = { id: string; name: string; data: Blob; type: "image/png" | "image/jpeg"; width: number; height: number };
|
|
11
|
+
export type GeoMapSnapshotAssetErrorCode = "invalid" | "stale" | "missing" | "too_large" | "unreadable" | "timed_out" | "aborted";
|
|
12
|
+
export class GeoMapSnapshotAssetError extends Error {
|
|
13
|
+
constructor(readonly code: GeoMapSnapshotAssetErrorCode) {
|
|
14
|
+
super(`Map snapshot upload: ${code}`);
|
|
15
|
+
this.name = "GeoMapSnapshotAssetError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Application calls only after authorizing and reading its canonical saved revision/maps.
|
|
21
|
+
* Validates a presentation upload; it cannot attest that the client's pixels depict the saved geography.
|
|
22
|
+
* Enforce a streaming HTTP body limit BEFORE parsing FormData. Never accept resource URLs or renderer credentials.
|
|
23
|
+
*/
|
|
24
|
+
export async function prepareGeoMapSnapshotAssets(
|
|
25
|
+
form: FormData,
|
|
26
|
+
expected: { revision: string; maps: Pick<GeoMapSnapshotManifest["images"][number], "id" | "fingerprint">[]; maxBytes: number },
|
|
27
|
+
options: PreparePdfImageOptions = {},
|
|
28
|
+
): Promise<GeoMapSnapshotAsset[]> {
|
|
29
|
+
const fail = (code: GeoMapSnapshotAssetErrorCode): never => {
|
|
30
|
+
throw new GeoMapSnapshotAssetError(code);
|
|
31
|
+
};
|
|
32
|
+
if (!Number.isSafeInteger(expected.maxBytes) || expected.maxBytes < 0) throw new RangeError("Invalid PDF asset budget");
|
|
33
|
+
const timeout = options.timeoutMs ?? 30_000;
|
|
34
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 240_000) throw new RangeError("Invalid map preflight timeout");
|
|
35
|
+
const deadline = Date.now() + timeout;
|
|
36
|
+
const check = () => {
|
|
37
|
+
if (options.signal?.aborted) fail("aborted");
|
|
38
|
+
if (Date.now() >= deadline) fail("timed_out");
|
|
39
|
+
};
|
|
40
|
+
check();
|
|
41
|
+
const metadata = form.getAll("mapSnapshots");
|
|
42
|
+
if (metadata.length !== 1 || typeof metadata[0] !== "string") return fail("invalid");
|
|
43
|
+
if (new TextEncoder().encode(metadata[0]).byteLength > GEO_SNAPSHOT_MAX_METADATA_BYTES) return fail("too_large");
|
|
44
|
+
let manifest: GeoMapSnapshotManifest;
|
|
45
|
+
try {
|
|
46
|
+
manifest = GeoMapSnapshotManifestSchema.parse(JSON.parse(metadata[0]));
|
|
47
|
+
} catch {
|
|
48
|
+
return fail("invalid");
|
|
49
|
+
}
|
|
50
|
+
if (manifest.revision !== expected.revision) return fail("stale");
|
|
51
|
+
const maps = new Map(expected.maps.map((map) => [map.id, map.fingerprint]));
|
|
52
|
+
if (maps.size !== expected.maps.length) throw new Error("Expected maps must have unique IDs");
|
|
53
|
+
if (manifest.images.length !== maps.size) return fail("missing");
|
|
54
|
+
for (const image of manifest.images) {
|
|
55
|
+
if (!maps.has(image.id)) return fail("invalid");
|
|
56
|
+
if (maps.get(image.id) !== image.fingerprint) return fail("stale");
|
|
57
|
+
}
|
|
58
|
+
const names = new Set(manifest.images.map((image) => geoMapSnapshotPart(image.id)));
|
|
59
|
+
for (const name of form.keys()) if (name.startsWith("map-snapshot-") && !names.has(name)) return fail("invalid");
|
|
60
|
+
const maximum = Math.min(expected.maxBytes, GEO_SNAPSHOT_MAX_BYTES);
|
|
61
|
+
let inputBytes = 0;
|
|
62
|
+
const uploads = manifest.images.map((image) => {
|
|
63
|
+
const parts = form.getAll(geoMapSnapshotPart(image.id));
|
|
64
|
+
if (parts.length !== 1 || !(parts[0] instanceof Blob)) return fail("missing");
|
|
65
|
+
const data = parts[0];
|
|
66
|
+
if (data.type !== "image/png") return fail("invalid");
|
|
67
|
+
inputBytes += data.size;
|
|
68
|
+
if (data.size < 33 || inputBytes > maximum) return fail("too_large");
|
|
69
|
+
return { image, data };
|
|
70
|
+
});
|
|
71
|
+
// Check every PNG IHDR before starting any expensive decoder. Filenames are ignored.
|
|
72
|
+
for (const { image, data } of uploads) {
|
|
73
|
+
check();
|
|
74
|
+
const header = new Uint8Array(await data.slice(0, 33).arrayBuffer());
|
|
75
|
+
const view = new DataView(header.buffer);
|
|
76
|
+
if (
|
|
77
|
+
![137, 80, 78, 71, 13, 10, 26, 10].every((byte, index) => header[index] === byte) ||
|
|
78
|
+
view.getUint32(8) !== 13 ||
|
|
79
|
+
view.getUint32(12) !== 0x49484452 ||
|
|
80
|
+
view.getUint32(16) !== image.width ||
|
|
81
|
+
view.getUint32(20) !== image.height
|
|
82
|
+
)
|
|
83
|
+
return fail("invalid");
|
|
84
|
+
}
|
|
85
|
+
const assets: GeoMapSnapshotAsset[] = [];
|
|
86
|
+
let outputBytes = 0;
|
|
87
|
+
for (const { image, data } of uploads) {
|
|
88
|
+
check();
|
|
89
|
+
const remaining = maximum - outputBytes;
|
|
90
|
+
if (remaining < 1) return fail("too_large");
|
|
91
|
+
const prepared = await preparePdfImage(
|
|
92
|
+
{ data, boxes: [{ width: image.width, height: image.height }], maxBytes: remaining },
|
|
93
|
+
{
|
|
94
|
+
signal: options.signal,
|
|
95
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
check();
|
|
99
|
+
if (!prepared.ok) return fail(prepared.code === "unsupported_format" ? "invalid" : prepared.code);
|
|
100
|
+
const variant = prepared.variants[0];
|
|
101
|
+
if (!variant) return fail("too_large");
|
|
102
|
+
if (variant.width !== image.width || variant.height !== image.height) return fail("invalid");
|
|
103
|
+
outputBytes += variant.data.size;
|
|
104
|
+
assets.push({ id: image.id, name: `geo-map-${assets.length}.${variant.type === "image/png" ? "png" : "jpg"}`, ...variant });
|
|
105
|
+
}
|
|
106
|
+
return assets;
|
|
107
|
+
}
|
|
@@ -221,10 +221,10 @@ const verifyApiTokenSecret = async (secret: string, storedHash: string, scopes:
|
|
|
221
221
|
}
|
|
222
222
|
};
|
|
223
223
|
|
|
224
|
-
const generateUniqueTokenParts = async (): Promise<{ tokenPrefix: string; secret: string; token: string }> => {
|
|
224
|
+
const generateUniqueTokenParts = async (db: SqlRunner): Promise<{ tokenPrefix: string; secret: string; token: string }> => {
|
|
225
225
|
for (let i = 0; i < 5; i += 1) {
|
|
226
226
|
const parts = generateTokenParts();
|
|
227
|
-
const [row] = await
|
|
227
|
+
const [row] = await db<{ exists: boolean }[]>`
|
|
228
228
|
SELECT EXISTS(
|
|
229
229
|
SELECT 1 FROM auth.service_account_credentials WHERE token_prefix = ${parts.tokenPrefix}
|
|
230
230
|
) AS exists
|
|
@@ -342,7 +342,7 @@ const insertApiToken = async (
|
|
|
342
342
|
if (expiresAt.getTime() <= Date.now()) return fail(err.badInput("Expiry must be in the future"));
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
const parts = await generateUniqueTokenParts();
|
|
345
|
+
const parts = await generateUniqueTokenParts(db);
|
|
346
346
|
const secretHash = hashApiTokenSecret(parts.secret, params.scopes ?? []);
|
|
347
347
|
|
|
348
348
|
try {
|
|
@@ -376,15 +376,18 @@ const insertApiToken = async (
|
|
|
376
376
|
}
|
|
377
377
|
};
|
|
378
378
|
|
|
379
|
-
export const createApiToken = async (
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
379
|
+
export const createApiToken = async (
|
|
380
|
+
params: {
|
|
381
|
+
serviceAccountId: string;
|
|
382
|
+
name: string;
|
|
383
|
+
createdBy?: string | null;
|
|
384
|
+
expiresAt?: string | null;
|
|
385
|
+
scopes?: string[];
|
|
386
|
+
},
|
|
387
|
+
options: { db?: SQL } = {},
|
|
388
|
+
): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
386
389
|
if (params.scopes?.includes(AI_INFERENCE_SCOPE)) return fail(err.badInput("ai:inference is reserved for models-only personal API keys"));
|
|
387
|
-
return insertApiToken(sql, params);
|
|
390
|
+
return insertApiToken(options.db ?? sql, params);
|
|
388
391
|
};
|
|
389
392
|
|
|
390
393
|
export const createUserApiToken = async (params: {
|
|
@@ -531,7 +534,7 @@ export const listForDelegatedUser = async (params: { userId: string }): Promise<
|
|
|
531
534
|
c.last_used_at, c.created_by, c.created_at, c.revoked_at, c.revoked_by
|
|
532
535
|
FROM auth.service_account_credentials c
|
|
533
536
|
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
534
|
-
WHERE sa.kind = 'user_delegated'
|
|
537
|
+
WHERE c.kind = 'api_token' AND sa.kind = 'user_delegated'
|
|
535
538
|
AND sa.delegated_user_id = ${params.userId}::uuid
|
|
536
539
|
AND c.status = 'active'
|
|
537
540
|
ORDER BY c.created_at DESC
|
|
@@ -569,7 +572,7 @@ export const listOverview = async (config?: {
|
|
|
569
572
|
FROM auth.service_account_credentials c
|
|
570
573
|
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
571
574
|
LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
|
|
572
|
-
WHERE (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
|
|
575
|
+
WHERE c.kind = 'api_token' AND (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
|
|
573
576
|
AND (${credentialStatus}::text IS NULL OR c.status = ${credentialStatus})
|
|
574
577
|
AND (${userId}::uuid IS NULL OR sa.delegated_user_id = ${userId}::uuid)
|
|
575
578
|
AND (${serviceAccountId}::uuid IS NULL OR sa.id = ${serviceAccountId}::uuid)
|
|
@@ -621,7 +624,7 @@ export const listOverview = async (config?: {
|
|
|
621
624
|
FROM auth.service_account_credentials c
|
|
622
625
|
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
623
626
|
LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
|
|
624
|
-
WHERE (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
|
|
627
|
+
WHERE c.kind = 'api_token' AND (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
|
|
625
628
|
AND (${credentialStatus}::text IS NULL OR c.status = ${credentialStatus})
|
|
626
629
|
AND (${userId}::uuid IS NULL OR sa.delegated_user_id = ${userId}::uuid)
|
|
627
630
|
AND (${serviceAccountId}::uuid IS NULL OR sa.id = ${serviceAccountId}::uuid)
|
|
@@ -688,7 +691,7 @@ export const getOverview = async (params: { id: string }): Promise<ServiceAccoun
|
|
|
688
691
|
FROM auth.service_account_credentials c
|
|
689
692
|
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
690
693
|
LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
|
|
691
|
-
WHERE c.id = ${params.id}::uuid
|
|
694
|
+
WHERE c.kind = 'api_token' AND c.id = ${params.id}::uuid
|
|
692
695
|
LIMIT 1
|
|
693
696
|
`;
|
|
694
697
|
return row ? mapCredentialOverview(row) : null;
|
|
@@ -732,14 +735,14 @@ type RevokeCredentialInput =
|
|
|
732
735
|
export const revoke = async (params: RevokeCredentialInput, options: { db?: SQL } = {}): Promise<Result<void>> => {
|
|
733
736
|
if (!UUID_PATTERN.test(params.credentialId)) return fail(err.notFound("API key"));
|
|
734
737
|
|
|
735
|
-
const run = async (db: SQL): Promise<Result<void>> => {
|
|
738
|
+
const run = async (db: SQL, credentialId = params.credentialId): Promise<Result<void>> => {
|
|
736
739
|
const [row] = await db<DbCredentialRow[]>`
|
|
737
740
|
UPDATE auth.service_account_credentials
|
|
738
741
|
SET status = 'revoked',
|
|
739
742
|
revoked_at = now(),
|
|
740
743
|
revoked_by = ${params.actor?.id ?? null}::uuid
|
|
741
|
-
WHERE id = ${
|
|
742
|
-
AND status = 'active'
|
|
744
|
+
WHERE id = ${credentialId}::uuid
|
|
745
|
+
AND status = 'active' AND kind = 'api_token'
|
|
743
746
|
RETURNING id, service_account_id, name, kind, status, token_prefix, scopes, expires_at,
|
|
744
747
|
last_used_at, created_by, created_at, revoked_at, revoked_by
|
|
745
748
|
`;
|
|
@@ -750,14 +753,14 @@ export const revoke = async (params: RevokeCredentialInput, options: { db?: SQL
|
|
|
750
753
|
SELECT id, service_account_id, name, kind, status, token_prefix, scopes, expires_at,
|
|
751
754
|
last_used_at, created_by, created_at, revoked_at, revoked_by
|
|
752
755
|
FROM auth.service_account_credentials
|
|
753
|
-
WHERE id = ${
|
|
756
|
+
WHERE id = ${credentialId}::uuid AND kind = 'api_token'
|
|
754
757
|
LIMIT 1
|
|
755
758
|
`;
|
|
756
759
|
const result = row || existing?.status === "revoked" ? ok() : fail(err.notFound("API key"));
|
|
757
760
|
return audit.recordResult({
|
|
758
761
|
action: "service_account_credential.revoke",
|
|
759
762
|
actor: params.actor ? actorForUser(params.actor) : null,
|
|
760
|
-
target: { type: "service_account_credential", id:
|
|
763
|
+
target: { type: "service_account_credential", id: credentialId, label: row?.name ?? existing?.name ?? null },
|
|
761
764
|
metadata: {
|
|
762
765
|
serviceAccountId: row?.service_account_id ?? existing?.service_account_id ?? null,
|
|
763
766
|
...(params.actor ? { adminAction: true } : { provenance: "system", service: params.system.service, reason: params.system.reason }),
|
|
@@ -766,7 +769,46 @@ export const revoke = async (params: RevokeCredentialInput, options: { db?: SQL
|
|
|
766
769
|
db,
|
|
767
770
|
});
|
|
768
771
|
};
|
|
769
|
-
|
|
772
|
+
const revokeWithWorkload = async (db: SQL): Promise<Result<void>> => {
|
|
773
|
+
if (params.actor) {
|
|
774
|
+
const [instance] = await db<{ app_id: string; instance_id: string; service_account_id: string }[]>`
|
|
775
|
+
SELECT i.app_id, i.instance_id, c.service_account_id FROM auth.app_workload_instances i
|
|
776
|
+
JOIN auth.service_account_credentials c ON c.id = i.credential_id
|
|
777
|
+
WHERE i.credential_id = ${params.credentialId}::uuid OR i.previous_credential_id = ${params.credentialId}::uuid
|
|
778
|
+
`;
|
|
779
|
+
if (instance) {
|
|
780
|
+
// Same lock order as Core rotation. Resolve the latest generation *after* waiting,
|
|
781
|
+
// so an admin revoking the displayed token also revokes an overlapping rotation.
|
|
782
|
+
await db`SELECT id FROM auth.service_accounts WHERE id = ${instance.service_account_id}::uuid FOR UPDATE`;
|
|
783
|
+
// The account is the durable app-wide off switch, including future process instances.
|
|
784
|
+
await db`UPDATE auth.service_accounts SET status = 'disabled' WHERE id = ${instance.service_account_id}::uuid`;
|
|
785
|
+
await audit.record(
|
|
786
|
+
{
|
|
787
|
+
action: "service_account.disable",
|
|
788
|
+
outcome: "allowed",
|
|
789
|
+
actor: actorForUser(params.actor),
|
|
790
|
+
target: { type: "service_account", id: instance.service_account_id },
|
|
791
|
+
metadata: { appId: instance.app_id, serviceAccountId: instance.service_account_id, status: "disabled" },
|
|
792
|
+
},
|
|
793
|
+
db,
|
|
794
|
+
);
|
|
795
|
+
const [current] = await db<{ credential_id: string; previous_credential_id: string | null }[]>`
|
|
796
|
+
SELECT credential_id, previous_credential_id FROM auth.app_workload_instances
|
|
797
|
+
WHERE app_id = ${instance.app_id} AND instance_id = ${instance.instance_id}
|
|
798
|
+
`;
|
|
799
|
+
if (current) {
|
|
800
|
+
for (const credentialId of [current.credential_id, current.previous_credential_id]) {
|
|
801
|
+
if (!credentialId) continue;
|
|
802
|
+
const result = await run(db, credentialId);
|
|
803
|
+
if (!result.ok) return result;
|
|
804
|
+
}
|
|
805
|
+
return ok();
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return run(db);
|
|
810
|
+
};
|
|
811
|
+
return options.db ? revokeWithWorkload(options.db) : sql.begin(revokeWithWorkload);
|
|
770
812
|
};
|
|
771
813
|
|
|
772
814
|
const findActiveByTokenPrefix = async (tokenPrefix: string): Promise<DbCredentialWithSecretRow | null> => {
|
|
@@ -27,7 +27,7 @@ import { deleteKey, readKey, readKeyState, type StoredSettingState, writeKey } f
|
|
|
27
27
|
*/
|
|
28
28
|
export interface SettingsAPI<F extends Record<string, unknown>> {
|
|
29
29
|
get<K extends keyof F & string>(key: K): Promise<F[K]>;
|
|
30
|
-
read<K extends keyof F & string>(key: K): Promise<{ value: F[K]; stored: StoredSettingState }>;
|
|
30
|
+
read<K extends keyof F & string>(key: K): Promise<{ value: F[K]; stored: StoredSettingState; bindingMismatch?: true }>;
|
|
31
31
|
set<K extends keyof F & string>(key: K, value: F[K], options?: { replaceUnreadable?: boolean }): Promise<void>;
|
|
32
32
|
remove<K extends keyof F & string>(key: K): Promise<void>;
|
|
33
33
|
}
|
|
@@ -59,8 +59,8 @@ export const createSettingsAPI = <S extends AppSettingsMap>(): SettingsAPI<{
|
|
|
59
59
|
*/
|
|
60
60
|
export const coreSettings = {
|
|
61
61
|
get: <T = unknown>(key: string): Promise<T> => readKey(key) as Promise<T>,
|
|
62
|
-
read: <T = unknown>(key: string): Promise<{ value: T; stored: StoredSettingState }> =>
|
|
63
|
-
readKeyState(key) as Promise<{ value: T; stored: StoredSettingState }>,
|
|
62
|
+
read: <T = unknown>(key: string): Promise<{ value: T; stored: StoredSettingState; bindingMismatch?: true }> =>
|
|
63
|
+
readKeyState(key) as Promise<{ value: T; stored: StoredSettingState; bindingMismatch?: true }>,
|
|
64
64
|
set: (key: string, value: unknown, options?: { replaceUnreadable?: boolean }): Promise<void> => writeKey(key, value, undefined, options),
|
|
65
65
|
remove: (key: string): Promise<void> => deleteKey(key),
|
|
66
66
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { SETTINGS, SETTINGS_MAP } from "./defaults";
|
|
4
|
+
|
|
5
|
+
const envelope = z.object({ value: z.unknown(), bindings: z.record(z.string(), z.string()) });
|
|
6
|
+
const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
7
|
+
export const settingOwners = (key: string) => SETTINGS.filter((def) => def.resetOnChange?.includes(key));
|
|
8
|
+
|
|
9
|
+
/** Bind the value inside its encrypted row; no independently writable marker. */
|
|
10
|
+
export const bindSettingValue = async (key: string, value: unknown, resolve: (key: string) => Promise<unknown>): Promise<unknown> => {
|
|
11
|
+
const owners = settingOwners(key);
|
|
12
|
+
if (!owners.length) return value;
|
|
13
|
+
const bindings: Record<string, string> = {};
|
|
14
|
+
for (const owner of owners) bindings[owner.key] = hash(await resolve(owner.key));
|
|
15
|
+
return { value, bindings };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const unwrapSettingValue = (key: string, raw: unknown): unknown => {
|
|
19
|
+
if (!settingOwners(key).length) return raw;
|
|
20
|
+
const parsed = envelope.safeParse(raw);
|
|
21
|
+
return parsed.success ? parsed.data.value : raw;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const replaceSettingValue = (key: string, raw: unknown, value: unknown): unknown => {
|
|
25
|
+
if (!settingOwners(key).length) return value;
|
|
26
|
+
const parsed = envelope.safeParse(raw);
|
|
27
|
+
return parsed.success ? { ...parsed.data, value } : value;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Missing or stale bindings require a new value, including legacy unbound rows. */
|
|
31
|
+
export const resolveBoundSetting = async (
|
|
32
|
+
key: string,
|
|
33
|
+
raw: unknown,
|
|
34
|
+
resolve: (key: string) => Promise<unknown>,
|
|
35
|
+
): Promise<{ value: unknown; mismatch: boolean }> => {
|
|
36
|
+
const owners = settingOwners(key);
|
|
37
|
+
const fallback = SETTINGS_MAP.get(key)?.default;
|
|
38
|
+
if (!owners.length) return { value: raw, mismatch: false };
|
|
39
|
+
const parsed = envelope.safeParse(raw);
|
|
40
|
+
if (!parsed.success) return { value: fallback, mismatch: true };
|
|
41
|
+
for (const owner of owners) {
|
|
42
|
+
if (parsed.data.bindings[owner.key] !== hash(await resolve(owner.key))) return { value: fallback, mismatch: true };
|
|
43
|
+
}
|
|
44
|
+
return { value: parsed.data.value, mismatch: false };
|
|
45
|
+
};
|