@k2b/cloud 0.40.0 → 0.42.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 +9 -5
- package/src/_internal/bounded-json.ts +14 -2
- package/src/_internal/capabilities.ts +87 -8
- package/src/_internal/capability-reviews.ts +6 -0
- package/src/_internal/capability-sentences.ts +2 -1
- package/src/_internal/capability-timeout.ts +71 -0
- package/src/_internal/define-app.ts +158 -92
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/http-drain.ts +56 -0
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/setup-lock.ts +103 -0
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +16 -17
- package/src/access/PrincipalPicker.tsx +45 -10
- package/src/access/messages.ts +8 -0
- package/src/access/principal-label.ts +18 -0
- package/src/access/ui.ts +1 -0
- package/src/account/EntitySearch.tsx +5 -4
- package/src/ai/action-review.ts +2 -1
- package/src/ai/app-skill-store.ts +1 -1
- package/src/ai/capabilities.ts +21 -3
- package/src/ai/capability-execution.ts +14 -12
- package/src/ai/chat/action-card.ts +76 -4
- package/src/ai/chat/action-messages.ts +14 -0
- package/src/ai/chat/action-question.browser-harness.tsx +12 -0
- package/src/ai/chat/action-question.tsx +214 -23
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +1 -26
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/client/controller.ts +150 -32
- package/src/ai/client/transport.ts +14 -2
- package/src/ai/code-capability-routes.ts +27 -23
- package/src/ai/code-runtime-tools.ts +14 -16
- package/src/ai/code-source-tools.ts +16 -18
- package/src/ai/executor.ts +64 -21
- package/src/ai/fetch-file-tool.ts +67 -76
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/http.ts +1 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/migrate.ts +28 -0
- package/src/ai/open-questions.ts +168 -0
- package/src/ai/projects.ts +1 -2
- package/src/ai/protocol.ts +2 -2
- package/src/ai/question-text.ts +8 -0
- package/src/ai/recent-resources.ts +12 -5
- package/src/ai/review-content.ts +77 -0
- package/src/ai/routes.ts +119 -35
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skill-tool.ts +17 -6
- package/src/ai/skills-routes.ts +72 -27
- package/src/ai/skills.ts +139 -60
- package/src/ai/store.ts +184 -31
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +12 -6
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +25 -4
- package/src/ai/usage.ts +2 -2
- package/src/api/admin-core-settings.ts +15 -2
- package/src/api/admin-geo.ts +54 -0
- package/src/api/admin-linux-identities.ts +6 -3
- package/src/api/admin-outgoing-mail.ts +29 -15
- package/src/api/app-approval-actions.ts +31 -7
- package/src/api/approvals-loader.ts +3 -3
- package/src/api/approvals.ts +51 -0
- package/src/api/apps.ts +4 -2
- package/src/api/auth/schemas.ts +3 -0
- package/src/api/auth.ts +54 -18
- package/src/api/capabilities.ts +258 -181
- package/src/api/capability-streams.ts +21 -13
- package/src/api/cli-plugins.ts +4 -1
- package/src/api/geo-glyphs.ts +11 -0
- package/src/api/geo.ts +128 -0
- package/src/api/help.ts +12 -4
- package/src/api/index.ts +5 -0
- package/src/api/mcp.ts +73 -41
- package/src/api/me-panel-consent.ts +8 -5
- package/src/api/me.ts +79 -3
- package/src/api/search.ts +21 -14
- package/src/api/sync-ops.ts +19 -13
- package/src/api/widgets.ts +42 -18
- package/src/approvals/Approvals.tsx +30 -66
- package/src/approvals/approval-card.ts +68 -0
- package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
- package/src/browser/CloudResourceSearch.tsx +3 -13
- package/src/browser/PanelLeaveNotice.tsx +18 -0
- package/src/browser/navigation-search.ts +78 -25
- package/src/browser/panel-leave.ts +69 -0
- package/src/browser/panel-messages.ts +12 -0
- package/src/browser/panel-slot.ts +173 -24
- package/src/browser/panel.ts +3 -0
- package/src/browser/resource-search-messages.ts +2 -0
- package/src/capabilities/client.ts +28 -1
- package/src/capabilities/executions.ts +22 -3
- package/src/capabilities/geo-client.ts +57 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/review-storage.ts +24 -0
- package/src/capabilities/reviews.ts +17 -3
- package/src/capabilities/server.ts +36 -16
- package/src/cli/access.ts +15 -23
- package/src/cli/admin/linux.ts +13 -2
- package/src/cli/admin/outgoing-mail.ts +26 -4
- package/src/cli/capabilities.ts +19 -0
- package/src/config/env.ts +0 -6
- package/src/contracts/app-access.ts +48 -0
- package/src/contracts/app-approval.ts +3 -3
- package/src/contracts/app.ts +4 -1
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/avatar.ts +186 -0
- package/src/contracts/capabilities.ts +149 -55
- package/src/contracts/geo-admin.ts +127 -0
- package/src/contracts/geo-map.ts +19 -0
- package/src/contracts/geo-route.ts +168 -0
- package/src/contracts/geo.ts +248 -0
- package/src/contracts/index.ts +4 -0
- package/src/contracts/outgoing-mail.ts +17 -4
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +3 -0
- package/src/events/app-access.ts +68 -0
- package/src/events/live-delivery.ts +128 -0
- package/src/events/live.ts +14 -130
- package/src/geo/CloudMap.tsx +95 -0
- package/src/geo/map-messages.ts +17 -0
- package/src/geo/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/client-ip.ts +36 -0
- package/src/server/index.ts +3 -4
- package/src/server/middleware/app-access.ts +312 -0
- package/src/server/middleware/auth.ts +5 -3
- package/src/server/middleware/invocation.ts +11 -0
- package/src/server/middleware/rate-limit.ts +4 -4
- package/src/server/services/access.ts +13 -9
- package/src/server/services/index.ts +0 -2
- package/src/server/services/services.ts +0 -2
- package/src/services/accounts/app.ts +23 -3
- package/src/services/accounts/posix.ts +21 -7
- package/src/services/accounts/users.ts +12 -1
- package/src/services/allowed-network-fetch.ts +16 -10
- package/src/services/app-access-metrics.ts +24 -0
- package/src/services/app-access.ts +123 -0
- package/src/services/app-discovery.ts +27 -0
- package/src/services/approvals/content.ts +40 -0
- package/src/services/approvals/device.ts +49 -33
- package/src/services/approvals/index.ts +15 -6
- package/src/services/approvals/list.ts +6 -11
- package/src/services/approvals/question.ts +72 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/password-reset.ts +32 -6
- package/src/services/downstream-budget.ts +87 -0
- package/src/services/geo/admin.ts +262 -0
- package/src/services/geo/bucket.ts +67 -0
- package/src/services/geo/cache.ts +57 -0
- package/src/services/geo/costs.ts +5 -0
- package/src/services/geo/errors.ts +41 -0
- package/src/services/geo/fixtures/README.md +17 -0
- package/src/services/geo/fixtures/along.json +355 -0
- package/src/services/geo/fixtures/autocomplete.json +218 -0
- package/src/services/geo/fixtures/geocode.json +129 -0
- package/src/services/geo/fixtures/places.json +355 -0
- package/src/services/geo/fixtures/reverse.json +61 -0
- package/src/services/geo/fixtures/routing.json +57 -0
- package/src/services/geo/geoapify-route.ts +93 -0
- package/src/services/geo/geoapify.ts +281 -0
- package/src/services/geo/ledger.ts +122 -0
- package/src/services/geo/map.ts +160 -0
- package/src/services/geo/migrate.ts +25 -0
- package/src/services/geo/polyline.ts +184 -0
- package/src/services/geo/route-chain.ts +145 -0
- package/src/services/geo/route-costs.ts +9 -0
- package/src/services/geo/route-geometry.ts +39 -0
- package/src/services/geo/route-plan.ts +172 -0
- package/src/services/geo/route-service.ts +433 -0
- package/src/services/geo/service.ts +300 -0
- package/src/services/geo/settings.ts +49 -0
- package/src/services/help/index.ts +16 -7
- package/src/services/help/types.ts +6 -1
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/issue-invocation.ts +67 -0
- package/src/services/identity/key-ring.ts +5 -3
- package/src/services/identity/runtime-config.ts +23 -0
- package/src/services/index.ts +11 -0
- package/src/services/mandates/index.ts +40 -3
- package/src/services/network-security.ts +6 -3
- package/src/services/network-transport.ts +99 -0
- package/src/services/notifications/catalog.ts +3 -2
- package/src/services/notifications/runtime.ts +1 -0
- package/src/services/notifications/web-push-transport.ts +45 -38
- package/src/services/outgoing-mail/dispatcher.ts +6 -1
- package/src/services/outgoing-mail/index.ts +55 -1
- package/src/services/outgoing-mail/messages.ts +82 -3
- package/src/services/outgoing-mail/resolve-host.ts +2 -2
- package/src/services/outgoing-mail/retention.ts +13 -1
- package/src/services/outgoing-mail/store.ts +36 -6
- package/src/services/outgoing-mail/test-send.ts +34 -4
- package/src/services/outgoing-mail/transport.ts +1 -1
- package/src/services/pdf/gotenberg.ts +11 -38
- package/src/services/pdf/markdown.ts +160 -2
- package/src/services/pdf/template-preview.ts +1 -1
- package/src/services/public-http.ts +28 -40
- package/src/services/session/index.ts +203 -29
- package/src/services/settings/app.ts +4 -1
- package/src/services/settings/core-settings.ts +88 -0
- package/src/services/settings/defaults.ts +8 -2
- package/src/services/settings/errors.ts +11 -0
- package/src/services/settings/geo-budget.ts +40 -0
- package/src/services/settings/index.ts +4 -3
- package/src/services/settings/store.ts +25 -17
- package/src/services/weather/geo.ts +29 -111
- package/src/services/weather/index.ts +4 -3
- package/src/services/weather/locations.ts +8 -5
- package/src/services/weather/migrate.ts +1 -0
- package/src/services/weather/settings.ts +0 -16
- package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
- package/src/shared/avatar-art.ts +400 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/network-address.ts +22 -3
- package/src/shared/template-rendering.ts +36 -4
- package/src/shared-text/documents.ts +1 -1
- package/src/shared-text/index.ts +2 -0
- package/src/shared-text/log.ts +51 -3
- package/src/shared-text/snapshots.ts +340 -0
- package/src/ssr/AdminLayout.tsx +4 -2
- package/src/ssr/CloudShell.island.tsx +6 -0
- package/src/ssr/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/Layout.tsx +65 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
- package/src/ssr/LayoutHelp.tsx +185 -542
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- package/src/ssr/PanelRuntime.island.tsx +11 -7
- package/src/ssr/PwaLayout.tsx +2 -2
- package/src/ssr/RailApps.tsx +6 -0
- package/src/ssr/ShellRuntime.island.tsx +23 -7
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- package/src/ssr/admin-navigation.ts +1 -0
- package/src/ssr/app-badges.ts +6 -1
- package/src/ssr/compact-shell.browser-harness.ts +44 -5
- package/src/ssr/help.ts +7 -5
- package/src/ssr/index.ts +2 -1
- package/src/ssr/islands/index.ts +3 -4
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/mobile-menu-history.ts +4 -0
- package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
- package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
- package/src/ssr/page-swap.browser-harness.tsx +177 -0
- package/src/ssr/page-swap.ts +28 -0
- package/src/ssr/page-trail-runtime.ts +32 -13
- package/src/ssr/panel-consent.ts +2 -2
- package/src/ssr/panel-idle.ts +9 -0
- package/src/ssr/panel-slot.browser-harness.tsx +3 -0
- package/src/ssr/platform-messages.ts +8 -0
- package/src/ssr/runtime.ts +17 -0
- package/src/ssr/shell-data.tsx +10 -2
- package/src/styles/approvals.css +6 -0
- package/src/styles/effects.css +15 -0
- package/src/styles/global.css +70 -6
- package/src/server/services/geo.ts +0 -215
|
@@ -25,9 +25,12 @@ import {
|
|
|
25
25
|
type SignInMethod,
|
|
26
26
|
setBrowserCookie,
|
|
27
27
|
} from "../known-browsers";
|
|
28
|
+
import { logger } from "../logging";
|
|
28
29
|
import * as settings from "../settings";
|
|
29
30
|
import { loadJwtSession, loadJwtSessionIdentity } from "./user";
|
|
30
31
|
|
|
32
|
+
const log = logger("auth:session");
|
|
33
|
+
|
|
31
34
|
export type SessionKind = "web" | "app";
|
|
32
35
|
|
|
33
36
|
export type SessionData = {
|
|
@@ -90,12 +93,13 @@ const verifyForRequest = (c: Context, token: string): Promise<CloudSessionClaims
|
|
|
90
93
|
/** Moves the account's authentication epoch, which ends every session issued before it.
|
|
91
94
|
* Request-time expiry uses only this: device operations check expiry themselves and an
|
|
92
95
|
* administrator can lift it, so revoking devices would depend on which request came first. */
|
|
93
|
-
const endSessions = async (db: SQL, userId: string): Promise<
|
|
94
|
-
const [updated] = await db<Array<{
|
|
96
|
+
const endSessions = async (db: SQL, userId: string): Promise<number> => {
|
|
97
|
+
const [updated] = await db<Array<{ auth_epoch: string | number | bigint }>>`
|
|
95
98
|
UPDATE auth.users SET auth_epoch = auth_epoch + 1
|
|
96
|
-
WHERE id = ${userId}::uuid RETURNING
|
|
99
|
+
WHERE id = ${userId}::uuid RETURNING auth_epoch
|
|
97
100
|
`;
|
|
98
101
|
if (!updated) throw new Error("Cannot revoke sessions for an unknown user");
|
|
102
|
+
return Number(updated.auth_epoch);
|
|
99
103
|
};
|
|
100
104
|
|
|
101
105
|
const authenticateVerified = async (credential: CloudSessionClaims): Promise<AuthenticatedSession | null> => {
|
|
@@ -291,7 +295,7 @@ const issueJwtSession = async (
|
|
|
291
295
|
options: SessionIssueOptions,
|
|
292
296
|
attempt = 0,
|
|
293
297
|
committedNotice: NewBrowserSignInNotice | null = null,
|
|
294
|
-
): Promise<{ token: string; notice: NewBrowserSignInNotice | null }> => {
|
|
298
|
+
): Promise<{ token: string; expiresAt: Date; notice: NewBrowserSignInNotice | null }> => {
|
|
295
299
|
const signer = await prepareIdentitySigner("session");
|
|
296
300
|
let family: PreparedFamily;
|
|
297
301
|
try {
|
|
@@ -305,7 +309,7 @@ const issueJwtSession = async (
|
|
|
305
309
|
}
|
|
306
310
|
const token = await signFamily(family);
|
|
307
311
|
const notice = committedNotice ?? family.notice;
|
|
308
|
-
if (token) return { token, notice };
|
|
312
|
+
if (token) return { token, expiresAt: family.expiresAt, notice };
|
|
309
313
|
if (attempt === 0) return issueJwtSession(c, userId, { ...options, sid: undefined }, 1, notice);
|
|
310
314
|
throw new Error("Cloud session signer changed repeatedly during issuance");
|
|
311
315
|
};
|
|
@@ -346,6 +350,176 @@ const issueInTransaction = async <T>(
|
|
|
346
350
|
}
|
|
347
351
|
};
|
|
348
352
|
|
|
353
|
+
/** Revokes the account's active mobile app phones and records each phone. */
|
|
354
|
+
const revokePhones = async (
|
|
355
|
+
tx: SQL,
|
|
356
|
+
userId: string,
|
|
357
|
+
reason: "sign_out_everywhere" | "sign_out_others" | "password_change" | "password_reset",
|
|
358
|
+
): Promise<void> => {
|
|
359
|
+
const phones = await tx<Array<{ id: string }>>`
|
|
360
|
+
UPDATE auth.pwa_devices SET revoked_at = now(), revocation_reason = ${reason}
|
|
361
|
+
WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
|
|
362
|
+
`;
|
|
363
|
+
for (const phone of phones)
|
|
364
|
+
await audit.record(
|
|
365
|
+
{
|
|
366
|
+
action: "auth.pwa.device.revoke",
|
|
367
|
+
outcome: "allowed",
|
|
368
|
+
target: { type: "pwa_device", id: phone.id },
|
|
369
|
+
metadata: { targetUserId: userId, provenance: "system", reason },
|
|
370
|
+
},
|
|
371
|
+
tx,
|
|
372
|
+
);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/** Copies a web family to a new sid, epoch and signer; sign-in time, expiry, origin and the Cloud Login device
|
|
376
|
+
* that approved it stay the same, so removing that device still ends the copy. */
|
|
377
|
+
const copyFamily = async (tx: SQL, from: string, to: { sid: string; authEpoch: number; kid: string }): Promise<void> => {
|
|
378
|
+
await tx`
|
|
379
|
+
INSERT INTO auth.session_families (
|
|
380
|
+
sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending,
|
|
381
|
+
pwa_device_id, app_device_id
|
|
382
|
+
)
|
|
383
|
+
SELECT ${to.sid}::uuid, user_id, ${to.authEpoch}, ${to.kid}, issued_at, expires_at,
|
|
384
|
+
created_request_id, created_user_agent, legal_pending, pwa_device_id, app_device_id
|
|
385
|
+
FROM auth.session_families WHERE sid = ${from}::uuid
|
|
386
|
+
`;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/** Ends other sign-ins and moves the chosen browser to a new session id with its sign-in time and expiry.
|
|
390
|
+
* The epoch change ends the account's other browser sessions, its OAuth sign-ins and access tokens, and pending
|
|
391
|
+
* Cloud Login pairings and device changes; mobile app phones are revoked with an audit entry per phone. Unlike
|
|
392
|
+
* `revokeAllForUser`, Cloud Login devices stay paired. API keys, passkeys and mandates stay valid.
|
|
393
|
+
* `keep` is the browser sign-in the request authenticated, with the epoch it was authenticated at. The kept
|
|
394
|
+
* family is revoked and replaced, so every check by session id rejects old copies of its cookie, and a
|
|
395
|
+
* request that a concurrent change already ended keeps nothing.
|
|
396
|
+
* If preparing the signer fails or it stays inactive after one retry, ends all sign-ins without keeping a browser.
|
|
397
|
+
* If its signer changes after commit, retries once with a new family unless another sign-out intervened.
|
|
398
|
+
* `kept` is null when no browser was kept: no `keep`, its epoch is no longer current, the family was not
|
|
399
|
+
* a current active web session of the account, or signing it after the change failed. That browser must
|
|
400
|
+
* sign in again. */
|
|
401
|
+
const endOtherSignIns = async (
|
|
402
|
+
userId: string,
|
|
403
|
+
options: { keep?: { sid: string; authEpoch: number } | null; reason: "password_change" | "password_reset" | "sign_out_others" },
|
|
404
|
+
): Promise<{ kept: { token: string; expiresAt: Date } | null }> => {
|
|
405
|
+
const extendSigner = async (tx: SQL, signer: PreparedIdentitySigner, expiresAt: Date): Promise<void> => {
|
|
406
|
+
const [activeKey] = await tx<Array<{ kid: string }>>`
|
|
407
|
+
UPDATE auth.signing_keys
|
|
408
|
+
SET verify_until = GREATEST(verify_until, ${new Date(expiresAt.getTime() + IDENTITY_CLOCK_TOLERANCE_SECONDS * 1_000 + IDENTITY_ROLLOUT_MARGIN_MS)})
|
|
409
|
+
WHERE kid = ${signer.kid} AND purpose = 'session' AND state = 'active' AND sign_until > now()
|
|
410
|
+
RETURNING kid
|
|
411
|
+
`;
|
|
412
|
+
if (!activeKey) throw new Error(SIGNER_INACTIVE);
|
|
413
|
+
};
|
|
414
|
+
let keep = options.keep ?? null;
|
|
415
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
416
|
+
let signer: PreparedIdentitySigner | null = null;
|
|
417
|
+
if (keep) {
|
|
418
|
+
try {
|
|
419
|
+
signer = await prepareIdentitySigner("session");
|
|
420
|
+
} catch {
|
|
421
|
+
log.warn("Could not prepare session signer; ending all sign-ins", { userId });
|
|
422
|
+
keep = null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
let family: PreparedFamily | null;
|
|
426
|
+
try {
|
|
427
|
+
family = await sql.begin(async (tx): Promise<PreparedFamily | null> => {
|
|
428
|
+
const [user] = await tx<Array<{ auth_epoch: string | number | bigint }>>`
|
|
429
|
+
SELECT auth_epoch FROM auth.users WHERE id = ${userId}::uuid FOR NO KEY UPDATE
|
|
430
|
+
`;
|
|
431
|
+
if (!user) throw new Error("Cannot revoke sessions for an unknown user");
|
|
432
|
+
const [kept] =
|
|
433
|
+
keep && Number(user.auth_epoch) === keep.authEpoch
|
|
434
|
+
? // Locking the key orders this keep before a concurrent emergency key revocation. It is the
|
|
435
|
+
// lock extendSigner takes for the same current key, so concurrent keeps queue instead of deadlocking.
|
|
436
|
+
await tx<Array<{ sid: string; issued_at: Date; expires_at: Date }>>`
|
|
437
|
+
SELECT sf.sid, sf.issued_at, sf.expires_at FROM auth.session_families sf
|
|
438
|
+
JOIN auth.signing_keys sk ON sk.kid = sf.signing_kid AND sk.state <> 'revoked'
|
|
439
|
+
WHERE sf.sid = ${keep.sid}::uuid AND sf.user_id = ${userId}::uuid
|
|
440
|
+
AND sf.pwa_device_id IS NULL AND sf.revoked_at IS NULL AND sf.expires_at > now()
|
|
441
|
+
AND sf.auth_epoch = ${user.auth_epoch}
|
|
442
|
+
FOR UPDATE OF sf FOR NO KEY UPDATE OF sk
|
|
443
|
+
`
|
|
444
|
+
: [];
|
|
445
|
+
const authEpoch = await endSessions(tx, userId);
|
|
446
|
+
let prepared: PreparedFamily | null = null;
|
|
447
|
+
if (kept && signer) {
|
|
448
|
+
const issuedAt = new Date(kept.issued_at);
|
|
449
|
+
const expiresAt = new Date(kept.expires_at);
|
|
450
|
+
await extendSigner(tx, signer, expiresAt);
|
|
451
|
+
const sid = crypto.randomUUID();
|
|
452
|
+
await copyFamily(tx, kept.sid, { sid, authEpoch, kid: signer.kid });
|
|
453
|
+
await tx`
|
|
454
|
+
UPDATE auth.session_families SET revoked_at = now(), revocation_reason = 'rotated'
|
|
455
|
+
WHERE sid = ${kept.sid}::uuid
|
|
456
|
+
`;
|
|
457
|
+
prepared = {
|
|
458
|
+
userId,
|
|
459
|
+
sid,
|
|
460
|
+
authEpoch,
|
|
461
|
+
issuedAt,
|
|
462
|
+
expiresAt,
|
|
463
|
+
signer,
|
|
464
|
+
options: { ttlSeconds: (expiresAt.getTime() - issuedAt.getTime()) / 1_000, recordLogin: false },
|
|
465
|
+
notice: null,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
await revokePhones(tx, userId, options.reason);
|
|
469
|
+
return prepared;
|
|
470
|
+
});
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if (signer && isSignerInactive(error)) {
|
|
473
|
+
if (attempt === 0) {
|
|
474
|
+
invalidateIdentitySignerCache("session", signer.kid);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (attempt === 1) {
|
|
478
|
+
log.warn("Session signer stayed inactive; ending all sign-ins", { userId });
|
|
479
|
+
keep = null;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
if (!family) return { kept: null };
|
|
486
|
+
try {
|
|
487
|
+
const token = await signFamily(family);
|
|
488
|
+
if (token) return { kept: { token, expiresAt: family.expiresAt } };
|
|
489
|
+
const freshSigner = await prepareIdentitySigner("session");
|
|
490
|
+
const replacement = await sql.begin(async (tx): Promise<PreparedFamily | null> => {
|
|
491
|
+
const [user] = await tx<Array<{ auth_epoch: string | number | bigint }>>`
|
|
492
|
+
SELECT auth_epoch FROM auth.users WHERE id = ${userId}::uuid FOR NO KEY UPDATE
|
|
493
|
+
`;
|
|
494
|
+
if (!user || Number(user.auth_epoch) !== family.authEpoch) return null;
|
|
495
|
+
// Device removal can skip this already-revoked family, so the replacement must recheck its device.
|
|
496
|
+
const [revoked] = await tx<Array<{ sid: string }>>`
|
|
497
|
+
SELECT sf.sid FROM auth.session_families sf
|
|
498
|
+
WHERE sf.sid = ${family.sid}::uuid AND sf.user_id = ${userId}::uuid
|
|
499
|
+
AND sf.revocation_reason = 'signer_revoked_during_issuance'
|
|
500
|
+
AND (sf.app_device_id IS NULL OR EXISTS (
|
|
501
|
+
SELECT 1 FROM auth.app_devices d WHERE d.id = sf.app_device_id AND d.revoked_at IS NULL
|
|
502
|
+
)) FOR UPDATE
|
|
503
|
+
`;
|
|
504
|
+
if (!revoked) return null;
|
|
505
|
+
await extendSigner(tx, freshSigner, family.expiresAt);
|
|
506
|
+
const sid = crypto.randomUUID();
|
|
507
|
+
await copyFamily(tx, revoked.sid, { sid, authEpoch: family.authEpoch, kid: freshSigner.kid });
|
|
508
|
+
return { ...family, sid, signer: freshSigner };
|
|
509
|
+
});
|
|
510
|
+
if (!replacement) return { kept: null };
|
|
511
|
+
const replacementToken = await signFamily(replacement);
|
|
512
|
+
if (!replacementToken) throw new Error("Cloud session signer changed repeatedly during issuance");
|
|
513
|
+
return { kept: { token: replacementToken, expiresAt: replacement.expiresAt } };
|
|
514
|
+
} catch (error) {
|
|
515
|
+
log.warn("Could not keep browser signed in after ending other sign-ins", {
|
|
516
|
+
error: error instanceof Error ? error.message : String(error),
|
|
517
|
+
});
|
|
518
|
+
return { kept: null };
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
|
|
349
523
|
/** Today's web credential: an explicit non-API bearer, otherwise the `session_token` cookie. */
|
|
350
524
|
const getWebOrBearerToken = (c: Context): string | null => {
|
|
351
525
|
const authorization = c.req.header("Authorization");
|
|
@@ -416,7 +590,11 @@ export const session = {
|
|
|
416
590
|
const ttlSeconds = Math.floor(expiryHours * 60 * 60);
|
|
417
591
|
const cookie = readBrowserCookie(c) ?? newBrowserCookie();
|
|
418
592
|
const { browser, os, country, city } = describeRequest(c);
|
|
419
|
-
const {
|
|
593
|
+
const {
|
|
594
|
+
token: clientToken,
|
|
595
|
+
expiresAt,
|
|
596
|
+
notice,
|
|
597
|
+
} = await issueJwtSession(c, userId, {
|
|
420
598
|
ttlSeconds,
|
|
421
599
|
authEpoch: signIn.authEpoch,
|
|
422
600
|
appDeviceId: signIn.appDeviceId,
|
|
@@ -435,17 +613,25 @@ export const session = {
|
|
|
435
613
|
// Handoff precedes the response so the person signing in cannot turn the notice off first.
|
|
436
614
|
if (signIn.notify !== null && notice) await announceNewBrowser(signIn.notify, notice);
|
|
437
615
|
|
|
438
|
-
|
|
616
|
+
session.setWebCookie(c, clientToken, expiresAt);
|
|
617
|
+
setBrowserCookie(c, cookie);
|
|
618
|
+
return clientToken;
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
/** Sets the `session_token` cookie the way `create` does. Use it for the token that `endOtherSignIns` keeps:
|
|
622
|
+
* the max age runs to `expiresAt`, so that browser keeps its original expiry. */
|
|
623
|
+
setWebCookie: (c: Context, token: string, expiresAt: Date): void => {
|
|
624
|
+
setCookie(c, "session_token", token, {
|
|
439
625
|
httpOnly: true,
|
|
440
626
|
secure: !env.IS_DEVELOPMENT,
|
|
441
627
|
sameSite: "Lax",
|
|
442
|
-
maxAge:
|
|
628
|
+
maxAge: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1_000)),
|
|
443
629
|
path: "/",
|
|
444
630
|
});
|
|
445
|
-
setBrowserCookie(c, cookie);
|
|
446
|
-
return clientToken;
|
|
447
631
|
},
|
|
448
632
|
|
|
633
|
+
endOtherSignIns,
|
|
634
|
+
|
|
449
635
|
revoke: revokeToken,
|
|
450
636
|
|
|
451
637
|
/** Signs out the web session (or an explicit bearer session). The mobile app's session is unaffected. */
|
|
@@ -457,25 +643,12 @@ export const session = {
|
|
|
457
643
|
|
|
458
644
|
issueInTransaction,
|
|
459
645
|
|
|
460
|
-
/** Signs the account out everywhere: every session, every paired sign-in device and every paired phone, audited per device.
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
UPDATE auth.pwa_devices SET revoked_at = now(), revocation_reason = 'sign_out_everywhere'
|
|
467
|
-
WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
|
|
468
|
-
`;
|
|
469
|
-
for (const phone of phones)
|
|
470
|
-
await audit.record(
|
|
471
|
-
{
|
|
472
|
-
action: "auth.pwa.device.revoke",
|
|
473
|
-
outcome: "allowed",
|
|
474
|
-
target: { type: "pwa_device", id: phone.id },
|
|
475
|
-
metadata: { targetUserId: userId, provenance: "system", reason: "sign_out_everywhere" },
|
|
476
|
-
},
|
|
477
|
-
tx,
|
|
478
|
-
);
|
|
646
|
+
/** Signs the account out everywhere: every session, every paired sign-in device and every paired phone, audited per device.
|
|
647
|
+
* Returns the new authentication epoch so a following sign-in can stay bound to this sign-out. */
|
|
648
|
+
revokeAllForUser: async (userId: string): Promise<number> => {
|
|
649
|
+
return sql.begin(async (tx) => {
|
|
650
|
+
const authEpoch = await endSessions(tx, userId);
|
|
651
|
+
await revokePhones(tx, userId, "sign_out_everywhere");
|
|
479
652
|
// A paired device approves new sign-ins on its own, so a lost one would outlive the epoch change.
|
|
480
653
|
const devices = await tx<Array<{ id: string }>>`
|
|
481
654
|
UPDATE auth.app_devices SET revoked_at = now() WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
|
|
@@ -490,6 +663,7 @@ export const session = {
|
|
|
490
663
|
},
|
|
491
664
|
tx,
|
|
492
665
|
);
|
|
666
|
+
return authEpoch;
|
|
493
667
|
});
|
|
494
668
|
},
|
|
495
669
|
|
|
@@ -13,6 +13,7 @@ import { ImageMetadataError } from "../image-metadata";
|
|
|
13
13
|
import type { SettingEntry } from ".";
|
|
14
14
|
import * as settingsPrimitives from ".";
|
|
15
15
|
import { SETTINGS_MAP, validateSettingValue } from "./defaults";
|
|
16
|
+
import { GeoBudgetSettingError } from "./geo-budget";
|
|
16
17
|
import { SettingUnreadableError } from "./store";
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -69,7 +70,8 @@ export const settingsService = {
|
|
|
69
70
|
return ok(undefined);
|
|
70
71
|
} catch (error) {
|
|
71
72
|
// Preserve the typed HTTP status and recovery code through Hono.
|
|
72
|
-
if (error instanceof ImageMetadataError || error instanceof SettingUnreadableError
|
|
73
|
+
if (error instanceof ImageMetadataError || error instanceof SettingUnreadableError || error instanceof GeoBudgetSettingError)
|
|
74
|
+
throw error;
|
|
73
75
|
const message = error instanceof Error ? error.message : String(error);
|
|
74
76
|
if (message.startsWith("Unknown setting:")) {
|
|
75
77
|
return fail(err.badInput(message));
|
|
@@ -85,6 +87,7 @@ export const settingsService = {
|
|
|
85
87
|
await settingsPrimitives.remove(config.key);
|
|
86
88
|
return ok(undefined);
|
|
87
89
|
} catch (error) {
|
|
90
|
+
if (error instanceof GeoBudgetSettingError) throw error;
|
|
88
91
|
return fail(err.internal(`Failed to reset setting: ${error instanceof Error ? error.message : String(error)}`));
|
|
89
92
|
}
|
|
90
93
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { env } from "../../config/env";
|
|
2
|
+
import { GEO_MAX_CREDITS, GEO_MAX_RPS } from "../../contracts/geo-admin";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Platform-owned settings, declared in the `defineApp({ settings: ... })` shape.
|
|
@@ -204,6 +205,93 @@ export const CORE_SETTINGS = {
|
|
|
204
205
|
description: "Five-field cron schedule used by all automatic cleanup jobs in app.timezone",
|
|
205
206
|
},
|
|
206
207
|
|
|
208
|
+
// ── Places and addresses ──────────────────────────────────────────────
|
|
209
|
+
"geo.api_key": {
|
|
210
|
+
kind: "secret",
|
|
211
|
+
label: "Geoapify API key",
|
|
212
|
+
description: "Used only for HTTPS requests to the fixed EU endpoint.",
|
|
213
|
+
default: "",
|
|
214
|
+
presentation: {
|
|
215
|
+
translations: { de: { label: "Geoapify-Schlüssel", description: "Nur für HTTPS-Anfragen an den festen EU-Endpunkt." } },
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
"geo.plan": {
|
|
219
|
+
kind: "enum",
|
|
220
|
+
label: "Geoapify plan",
|
|
221
|
+
description: "Sets requests per second and the provider daily allowance.",
|
|
222
|
+
default: "free",
|
|
223
|
+
options: [
|
|
224
|
+
{ value: "free", label: "Free" },
|
|
225
|
+
{ value: "api10", label: "API 10" },
|
|
226
|
+
{ value: "api25", label: "API 25" },
|
|
227
|
+
{ value: "api50", label: "API 50" },
|
|
228
|
+
{ value: "api100", label: "API 100" },
|
|
229
|
+
{ value: "api250", label: "API 250" },
|
|
230
|
+
{ value: "custom", label: "Custom" },
|
|
231
|
+
],
|
|
232
|
+
presentation: {
|
|
233
|
+
translations: {
|
|
234
|
+
de: {
|
|
235
|
+
label: "Geoapify-Tarif",
|
|
236
|
+
description: "Bestimmt Anfragen pro Sekunde und das Tageskontingent des Anbieters.",
|
|
237
|
+
options: { custom: "Eigener" },
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
"geo.custom_rps": {
|
|
243
|
+
kind: "number",
|
|
244
|
+
label: "Custom requests per second",
|
|
245
|
+
description: "Used only with the custom plan.",
|
|
246
|
+
default: 5,
|
|
247
|
+
integer: true,
|
|
248
|
+
min: 1,
|
|
249
|
+
max: GEO_MAX_RPS,
|
|
250
|
+
presentation: { translations: { de: { label: "Eigene Anfragen pro Sekunde", description: "Gilt nur beim eigenen Tarif." } } },
|
|
251
|
+
},
|
|
252
|
+
"geo.custom_daily_credits": {
|
|
253
|
+
kind: "number",
|
|
254
|
+
label: "Custom daily provider credits",
|
|
255
|
+
description: "Provider allowance; used only with the custom plan.",
|
|
256
|
+
default: 3000,
|
|
257
|
+
integer: true,
|
|
258
|
+
min: 1,
|
|
259
|
+
max: GEO_MAX_CREDITS,
|
|
260
|
+
presentation: {
|
|
261
|
+
translations: { de: { label: "Eigene tägliche Anbieter-Credits", description: "Anbieterkontingent; gilt nur beim eigenen Tarif." } },
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
"geo.monthly_budget_credits": {
|
|
265
|
+
kind: "number",
|
|
266
|
+
label: "Monthly geo budget (credits)",
|
|
267
|
+
description: "Cloud-wide hard limit per calendar month in app.timezone.",
|
|
268
|
+
default: 90000,
|
|
269
|
+
integer: true,
|
|
270
|
+
min: 0,
|
|
271
|
+
max: GEO_MAX_CREDITS,
|
|
272
|
+
presentation: {
|
|
273
|
+
translations: { de: { label: "Monatsbudget Geo (Credits)", description: "Harte Cloud-Grenze je Kalendermonat in app.timezone." } },
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
"geo.person_daily_credits": {
|
|
277
|
+
kind: "number",
|
|
278
|
+
label: "Daily geo credits per person",
|
|
279
|
+
description: "Hard limit per access subject and local day; must not exceed the monthly budget. Agents and service accounts count too.",
|
|
280
|
+
default: 300,
|
|
281
|
+
integer: true,
|
|
282
|
+
min: 0,
|
|
283
|
+
max: GEO_MAX_CREDITS,
|
|
284
|
+
presentation: {
|
|
285
|
+
translations: {
|
|
286
|
+
de: {
|
|
287
|
+
label: "Tägliche Geo-Credits pro Person",
|
|
288
|
+
description:
|
|
289
|
+
"Harte Grenze je Zugriffsidentität und lokalem Tag; darf das Monatsbudget nicht überschreiten. Gilt auch für Agenten und Dienstkonten.",
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
|
|
207
295
|
// ── AI ──────────────────────────────────────────────────────────────────
|
|
208
296
|
"ai.enabled": {
|
|
209
297
|
kind: "boolean",
|
|
@@ -162,7 +162,13 @@ export const SETTINGS: SettingDef[] = [
|
|
|
162
162
|
...toLegacySettingDefs(CORE_SETTINGS),
|
|
163
163
|
];
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
// Keep the existing permissive grammar without ambiguous regex backtracking.
|
|
166
|
+
const isEmail = (value: string): boolean => {
|
|
167
|
+
const at = value.indexOf("@");
|
|
168
|
+
if (at < 1 || value.indexOf("@", at + 1) !== -1 || /\s/.test(value)) return false;
|
|
169
|
+
const dot = value.indexOf(".", at + 2);
|
|
170
|
+
return dot !== -1 && dot < value.length - 1;
|
|
171
|
+
};
|
|
166
172
|
|
|
167
173
|
const toStringValue = (value: unknown): string | null => {
|
|
168
174
|
if (typeof value === "string") return value;
|
|
@@ -303,7 +309,7 @@ export const validateSettingValue = (def: SettingDef, raw: unknown): SettingVali
|
|
|
303
309
|
: { ok: false, error: `${getSettingLabel(def)} must be one of: ${def.options.map((option) => option.value).join(", ")}` };
|
|
304
310
|
case "email":
|
|
305
311
|
if (typeof value !== "string") return { ok: false, error: `${getSettingLabel(def)} must be a valid email address` };
|
|
306
|
-
return value.length === 0 ||
|
|
312
|
+
return value.length === 0 || isEmail(value)
|
|
307
313
|
? { ok: true, value }
|
|
308
314
|
: { ok: false, error: `${getSettingLabel(def)} must be a valid email address` };
|
|
309
315
|
case "url":
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { HTTPException } from "hono/http-exception";
|
|
2
|
+
|
|
3
|
+
export class SettingUnreadableError extends HTTPException {
|
|
4
|
+
readonly code = "setting_unreadable" as const;
|
|
5
|
+
|
|
6
|
+
constructor(readonly key: string) {
|
|
7
|
+
const message = `The stored value of setting "${key}" cannot be read by this application. Replace it with a new value or reset it to the default.`;
|
|
8
|
+
super(409, { message, res: Response.json({ code: "setting_unreadable", message }, { status: 409 }) });
|
|
9
|
+
this.name = "SettingUnreadableError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { sql } from "bun";
|
|
2
|
+
import { HTTPException } from "hono/http-exception";
|
|
3
|
+
import { decryptValue } from "./crypto";
|
|
4
|
+
import { SETTINGS_MAP, validateSettingValue } from "./defaults";
|
|
5
|
+
import { SettingUnreadableError } from "./errors";
|
|
6
|
+
|
|
7
|
+
const MONTH = "geo.monthly_budget_credits";
|
|
8
|
+
const DAY = "geo.person_daily_credits";
|
|
9
|
+
export class GeoBudgetSettingError extends HTTPException {
|
|
10
|
+
readonly code = "geo_budget_invalid";
|
|
11
|
+
constructor() {
|
|
12
|
+
const message = "Daily geo credits must not exceed the monthly geo budget";
|
|
13
|
+
super(400, {
|
|
14
|
+
message,
|
|
15
|
+
res: Response.json({ code: "geo_budget_invalid", message, errors: { [DAY]: message, [MONTH]: message } }, { status: 400 }),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Validate the final pair once, holding the lock until the caller commits all mutations. */
|
|
20
|
+
export const assertGeoBudgetPair = async (tx: typeof sql, updates: Record<string, unknown>, resets: readonly string[]): Promise<void> => {
|
|
21
|
+
if (![MONTH, DAY].some((key) => Object.hasOwn(updates, key) || resets.includes(key))) return;
|
|
22
|
+
await tx`SELECT pg_advisory_xact_lock(hashtextextended('core.geo.budget-settings', 0))`;
|
|
23
|
+
const resolve = async (key: string): Promise<unknown> => {
|
|
24
|
+
if (Object.hasOwn(updates, key)) return updates[key];
|
|
25
|
+
const def = SETTINGS_MAP.get(key);
|
|
26
|
+
if (resets.includes(key)) return def?.default;
|
|
27
|
+
const [row] = await tx<{ value: string }[]>`SELECT value FROM settings.entries WHERE key=${key}`;
|
|
28
|
+
if (!row) return def?.default;
|
|
29
|
+
try {
|
|
30
|
+
const value = await decryptValue(row.value);
|
|
31
|
+
if (def && validateSettingValue(def, value).ok) return value;
|
|
32
|
+
} catch {
|
|
33
|
+
// Recovery can replace or reset a row without decrypting its old value.
|
|
34
|
+
}
|
|
35
|
+
throw new SettingUnreadableError(key);
|
|
36
|
+
};
|
|
37
|
+
const month = await resolve(MONTH);
|
|
38
|
+
const day = await resolve(DAY);
|
|
39
|
+
if (typeof month !== "number" || typeof day !== "number" || day > month) throw new GeoBudgetSettingError();
|
|
40
|
+
};
|
|
@@ -185,14 +185,15 @@ export async function get<T = unknown>(key: string): Promise<T> {
|
|
|
185
185
|
|
|
186
186
|
/**
|
|
187
187
|
* Write a setting. Pass `db` to run inside a caller's transaction — then the
|
|
188
|
-
* caller
|
|
188
|
+
* caller owns geo budget pair validation before mutations and must call
|
|
189
|
+
* `invalidateSettingsCache` once it commits.
|
|
189
190
|
*/
|
|
190
191
|
export async function set(key: string, value: unknown, db?: SqlClient, options?: { replaceUnreadable?: boolean }): Promise<void> {
|
|
191
192
|
await writeKey(key, value, db, options);
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
export async function read(key: string): Promise<SettingRead> {
|
|
195
|
-
return readKeyState(key);
|
|
195
|
+
export async function read(key: string, db?: SqlClient): Promise<SettingRead> {
|
|
196
|
+
return readKeyState(key, db);
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
export async function remove(key: string, db?: SqlClient): Promise<void> {
|
|
@@ -22,6 +22,10 @@ import { toPgTextArray } from "../postgres";
|
|
|
22
22
|
import { requestCacheRedis } from "../request-cache-redis";
|
|
23
23
|
import { decryptValue, encryptValue } from "./crypto";
|
|
24
24
|
import { SETTINGS, SETTINGS_MAP, type SettingDef, validateSettingValue } from "./defaults";
|
|
25
|
+
import { SettingUnreadableError } from "./errors";
|
|
26
|
+
import { assertGeoBudgetPair } from "./geo-budget";
|
|
27
|
+
|
|
28
|
+
export { SettingUnreadableError } from "./errors";
|
|
25
29
|
|
|
26
30
|
const REDIS_KEY = (k: string) => `settings:${k}`;
|
|
27
31
|
|
|
@@ -30,16 +34,6 @@ type SqlClient = typeof sql;
|
|
|
30
34
|
export type StoredSettingState = "none" | "readable" | "unreadable";
|
|
31
35
|
export type SettingRead = { value: unknown; stored: StoredSettingState };
|
|
32
36
|
|
|
33
|
-
export class SettingUnreadableError extends HTTPException {
|
|
34
|
-
readonly code = "setting_unreadable" as const;
|
|
35
|
-
|
|
36
|
-
constructor(readonly key: string) {
|
|
37
|
-
const message = `The stored value of setting "${key}" cannot be read by this application. Replace it with a new value or reset it to the default.`;
|
|
38
|
-
super(409, { message, res: Response.json({ code: "setting_unreadable", message }, { status: 409 }) });
|
|
39
|
-
this.name = "SettingUnreadableError";
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
37
|
/**
|
|
44
38
|
* Forget cached values so every container re-reads on next access.
|
|
45
39
|
*
|
|
@@ -83,8 +77,8 @@ const resolveFallback = (def: SettingDef | undefined): unknown => {
|
|
|
83
77
|
*/
|
|
84
78
|
export const readKey = async (key: string): Promise<unknown> => (await bulkRead([key])).get(key);
|
|
85
79
|
|
|
86
|
-
export const readKeyState = async (key: string): Promise<SettingRead> => {
|
|
87
|
-
const state = (await readStates([key])).get(key);
|
|
80
|
+
export const readKeyState = async (key: string, db: SqlClient = sql): Promise<SettingRead> => {
|
|
81
|
+
const state = (await readStates([key], db)).get(key);
|
|
88
82
|
if (!state) throw new Error(`Missing read state for setting "${key}"`);
|
|
89
83
|
return state;
|
|
90
84
|
};
|
|
@@ -155,10 +149,10 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
|
|
|
155
149
|
* State reads go directly to Postgres without reading or filling the cache;
|
|
156
150
|
* value reads (`get`, snapshot) keep using the cache.
|
|
157
151
|
*/
|
|
158
|
-
export const readStates = async (keys: readonly string[]): Promise<Map<string, SettingRead>> => {
|
|
152
|
+
export const readStates = async (keys: readonly string[], db: SqlClient = sql): Promise<Map<string, SettingRead>> => {
|
|
159
153
|
const result = new Map<string, SettingRead>();
|
|
160
154
|
if (keys.length === 0) return result;
|
|
161
|
-
const rows = await
|
|
155
|
+
const rows = await db<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray([...keys])}::text[])`;
|
|
162
156
|
const byKey = new Map(rows.map((row) => [row.key, row]));
|
|
163
157
|
for (const key of keys) {
|
|
164
158
|
const row = byKey.get(key);
|
|
@@ -233,6 +227,8 @@ export const deleteLegacyKeys = async (extraKnownKeys: readonly string[] = []):
|
|
|
233
227
|
* the caller explicitly replaces them. The row check and write share a transaction.
|
|
234
228
|
* Lock with a no-op UPDATE so ROW EXCLUSIVE is acquired before the row lock,
|
|
235
229
|
* preserving lock order with table-level SHARE locks (#1303 review).
|
|
230
|
+
* A caller passing `db` owns geo budget pair validation before mutations and
|
|
231
|
+
* cache invalidation after commit.
|
|
236
232
|
*/
|
|
237
233
|
export const writeKey = async (key: string, value: unknown, db?: SqlClient, options?: { replaceUnreadable?: boolean }): Promise<void> => {
|
|
238
234
|
const def = SETTINGS_MAP.get(key);
|
|
@@ -274,16 +270,28 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient, opti
|
|
|
274
270
|
throw new Error(`Could not write setting "${key}" after 3 attempts`);
|
|
275
271
|
};
|
|
276
272
|
if (db) await write(db);
|
|
277
|
-
else
|
|
273
|
+
else
|
|
274
|
+
await sql.begin(async (tx) => {
|
|
275
|
+
await assertGeoBudgetPair(tx, { [key]: validated.value }, []);
|
|
276
|
+
await write(tx);
|
|
277
|
+
});
|
|
278
278
|
|
|
279
279
|
// Passing a transaction means the caller owns invalidation, because the row
|
|
280
280
|
// is not visible to anyone else until they commit.
|
|
281
281
|
if (!db) await invalidateSettingsCache([key]);
|
|
282
282
|
};
|
|
283
283
|
|
|
284
|
-
/** Delete the DB row and invalidate Redis.
|
|
284
|
+
/** Delete the DB row and invalidate Redis. With `db`, the caller owns geo budget pair validation and post-commit invalidation. */
|
|
285
285
|
export const deleteKey = async (key: string, db?: SqlClient): Promise<void> => {
|
|
286
|
-
|
|
286
|
+
const remove = async (tx: SqlClient) => {
|
|
287
|
+
await tx`DELETE FROM settings.entries WHERE key = ${key}`;
|
|
288
|
+
};
|
|
289
|
+
if (db) await remove(db);
|
|
290
|
+
else
|
|
291
|
+
await sql.begin(async (tx) => {
|
|
292
|
+
await assertGeoBudgetPair(tx, {}, [key]);
|
|
293
|
+
await remove(tx);
|
|
294
|
+
});
|
|
287
295
|
if (!db) await invalidateSettingsCache([key]);
|
|
288
296
|
};
|
|
289
297
|
|