@k2b/cloud 0.38.0 → 0.40.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/README.md +8 -0
- package/package.json +12 -5
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +63 -16
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +32 -7
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/background-review.ts +72 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +45 -163
- package/src/ai/chat/view-block.tsx +22 -7
- package/src/ai/chat-task-contracts.ts +31 -3
- package/src/ai/chat-task-reasons-migrate.ts +75 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +438 -78
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +14 -0
- package/src/ai/migrate.ts +39 -4
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +172 -2
- package/src/ai/runtime.ts +13 -27
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +232 -27
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +67 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +40 -4
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +6 -2
- package/src/api/app-approval.ts +54 -6
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +3 -1
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +7 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +60 -6
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +145 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +160 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +35 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +756 -0
- package/src/browser/panel.ts +65 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +159 -12
- package/src/contracts/app.ts +14 -0
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +2 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +533 -90
- package/src/services/approvals/device.ts +9 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +32 -3
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/services/webauthn.ts +12 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared/redirect.ts +12 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +95 -10
- package/src/ssr/Layout.tsx +66 -10
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +35 -0
- package/src/ssr/PanelRuntime.island.tsx +94 -0
- package/src/ssr/PanelSlot.tsx +158 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +69 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +105 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +28 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +439 -0
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +159 -0
- package/src/ssr/platform-messages.ts +48 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +5 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +201 -11
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +314 -63
- package/src/styles/panel.css +416 -0
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +36 -13
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +22 -43
|
@@ -5,6 +5,8 @@ import { HTTPException } from "hono/http-exception";
|
|
|
5
5
|
import { env } from "../../config/env";
|
|
6
6
|
import { PWA_AUTH_PATH, PWA_COOKIES } from "../../contracts/pwa";
|
|
7
7
|
import type { User } from "../../contracts/shared";
|
|
8
|
+
import { getLocale } from "../../server/locale";
|
|
9
|
+
import { describeRequest } from "../../server/request-context";
|
|
8
10
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
9
11
|
import { isAccountExpired } from "../account-model";
|
|
10
12
|
import { audit } from "../audit";
|
|
@@ -12,6 +14,17 @@ import { IDENTITY_CLOCK_TOLERANCE_SECONDS, IDENTITY_ROLLOUT_MARGIN_MS } from "..
|
|
|
12
14
|
import { invalidateIdentitySignerCache, type PreparedIdentitySigner, prepareIdentitySigner } from "../identity/key-ring";
|
|
13
15
|
import { getIdentityRuntimeConfig } from "../identity/runtime-config";
|
|
14
16
|
import { type CloudSessionClaims, isSessionJwtCandidate, signSessionToken, verifySessionToken } from "../identity/session-token";
|
|
17
|
+
import {
|
|
18
|
+
announceNewBrowser,
|
|
19
|
+
type NewBrowserSignInNotice,
|
|
20
|
+
type NewBrowserSignInNotifier,
|
|
21
|
+
newBrowserCookie,
|
|
22
|
+
noticeFirstName,
|
|
23
|
+
readBrowserCookie,
|
|
24
|
+
rememberSignIn,
|
|
25
|
+
type SignInMethod,
|
|
26
|
+
setBrowserCookie,
|
|
27
|
+
} from "../known-browsers";
|
|
15
28
|
import * as settings from "../settings";
|
|
16
29
|
import { loadJwtSession, loadJwtSessionIdentity } from "./user";
|
|
17
30
|
|
|
@@ -144,12 +157,24 @@ const revokeToken = async (token: string, requestId?: string | null): Promise<vo
|
|
|
144
157
|
}
|
|
145
158
|
};
|
|
146
159
|
|
|
160
|
+
export class SessionEpochChangedError extends Error {}
|
|
161
|
+
/** The Cloud Login device that approved a sign-in was removed before the session was issued. */
|
|
162
|
+
export class SessionAppDeviceInactiveError extends Error {}
|
|
163
|
+
|
|
147
164
|
export type SessionIssueOptions = {
|
|
165
|
+
authEpoch?: number;
|
|
148
166
|
ttlSeconds: number;
|
|
167
|
+
/** Only web session.create supplies browser persistence, never mobile issuance. */
|
|
168
|
+
knownBrowser?: Pick<
|
|
169
|
+
Parameters<typeof rememberSignIn>[1],
|
|
170
|
+
"cookie" | "browser" | "os" | "country" | "city" | "method" | "device" | "locale" | "announce"
|
|
171
|
+
>;
|
|
149
172
|
/** Pre-chosen family id, for callers that record it in the same transaction. */
|
|
150
173
|
sid?: string;
|
|
151
174
|
/** Binds the family to a paired phone of the mobile app; the device must be active. */
|
|
152
175
|
pwaDeviceId?: string | null;
|
|
176
|
+
/** The active Cloud Login device that approved this web sign-in. */
|
|
177
|
+
appDeviceId?: string;
|
|
153
178
|
/** Web sign-ins update `last_login_local`; app renewals track use per device instead. */
|
|
154
179
|
recordLogin?: boolean;
|
|
155
180
|
};
|
|
@@ -162,12 +187,14 @@ type PreparedFamily = {
|
|
|
162
187
|
expiresAt: Date;
|
|
163
188
|
signer: PreparedIdentitySigner;
|
|
164
189
|
options: SessionIssueOptions;
|
|
190
|
+
notice: NewBrowserSignInNotice | null;
|
|
165
191
|
};
|
|
166
192
|
|
|
167
193
|
const SIGNER_INACTIVE = "Prepared Cloud session signer is no longer active";
|
|
168
194
|
const isSignerInactive = (error: unknown) => error instanceof Error && error.message === SIGNER_INACTIVE;
|
|
169
195
|
|
|
170
|
-
/** Inserts one family in `tx`. Locks the user row first, like every caller that locks more rows
|
|
196
|
+
/** Inserts one family in `tx`. Locks the user row first, like every caller that locks more rows; `NO KEY UPDATE`
|
|
197
|
+
* serializes with every other account lock but lets foreign-key checks of concurrent writes pass. */
|
|
171
198
|
const insertFamily = async (
|
|
172
199
|
tx: SQL,
|
|
173
200
|
c: Context,
|
|
@@ -175,19 +202,22 @@ const insertFamily = async (
|
|
|
175
202
|
signer: PreparedIdentitySigner,
|
|
176
203
|
options: SessionIssueOptions,
|
|
177
204
|
): Promise<PreparedFamily> => {
|
|
178
|
-
const issuedAt = new Date();
|
|
179
|
-
const expiresAt = new Date(issuedAt.getTime() + options.ttlSeconds * 1_000);
|
|
180
205
|
const sid = options.sid ?? crypto.randomUUID();
|
|
181
206
|
const requestId = c.req.header("x-request-id")?.slice(0, 64) ?? null;
|
|
182
207
|
const userAgent = c.req.header("user-agent")?.slice(0, 256) ?? null;
|
|
183
|
-
const [user] = await tx<
|
|
184
|
-
|
|
208
|
+
const [user] = await tx<
|
|
209
|
+
Array<{ given_name: string | null; auth_epoch: string | number | bigint; provider: "local" | "ipa"; profile: "guest" | "user" }>
|
|
210
|
+
>`
|
|
211
|
+
SELECT given_name, auth_epoch, provider, profile FROM auth.users WHERE id = ${userId}::uuid FOR NO KEY UPDATE
|
|
185
212
|
`;
|
|
186
213
|
if (!user) throw new Error("Cannot create a session for an unknown user");
|
|
214
|
+
const issuedAt = new Date();
|
|
215
|
+
const expiresAt = new Date(issuedAt.getTime() + options.ttlSeconds * 1_000);
|
|
187
216
|
if (!(await isAccountCategoryAllowed(user, tx)))
|
|
188
217
|
throw new HTTPException(403, { message: "This account category is disabled. Contact an administrator." });
|
|
189
218
|
const epoch = Number(user.auth_epoch);
|
|
190
219
|
if (!Number.isSafeInteger(epoch) || epoch < 0) throw new Error("User auth_epoch is invalid");
|
|
220
|
+
if (options.authEpoch !== undefined && epoch !== options.authEpoch) throw new SessionEpochChangedError();
|
|
191
221
|
if (options.pwaDeviceId) {
|
|
192
222
|
// Device revocation locks the same user row first, so a revoked device never gets a new family.
|
|
193
223
|
const [device] = await tx`
|
|
@@ -195,6 +225,14 @@ const insertFamily = async (
|
|
|
195
225
|
`;
|
|
196
226
|
if (!device) throw new Error("Cannot create an app session for an inactive device");
|
|
197
227
|
}
|
|
228
|
+
if (options.appDeviceId) {
|
|
229
|
+
// A device that decides a terminal sign-in holds its row and then takes a key-share lock on this user row, so the
|
|
230
|
+
// user row above is held without blocking key-share locks. Removals that end the device's sign-ins hold the user
|
|
231
|
+
// row too, so they wait for this family.
|
|
232
|
+
const [device] = await tx`SELECT id FROM auth.app_devices
|
|
233
|
+
WHERE id=${options.appDeviceId}::uuid AND user_id=${userId}::uuid AND revoked_at IS NULL`;
|
|
234
|
+
if (!device) throw new SessionAppDeviceInactiveError("Cannot create a session for an inactive sign-in device");
|
|
235
|
+
}
|
|
198
236
|
const [activeKey] = await tx<Array<{ kid: string }>>`
|
|
199
237
|
UPDATE auth.signing_keys
|
|
200
238
|
SET verify_until = GREATEST(verify_until, ${new Date(expiresAt.getTime() + IDENTITY_CLOCK_TOLERANCE_SECONDS * 1_000 + IDENTITY_ROLLOUT_MARGIN_MS)})
|
|
@@ -204,12 +242,15 @@ const insertFamily = async (
|
|
|
204
242
|
if (!activeKey) throw new Error(SIGNER_INACTIVE);
|
|
205
243
|
await tx`
|
|
206
244
|
INSERT INTO auth.session_families (
|
|
207
|
-
sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending, pwa_device_id
|
|
245
|
+
sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending, pwa_device_id, app_device_id
|
|
208
246
|
) VALUES (${sid}, ${userId}, ${epoch}, ${signer.kid}, ${issuedAt}, ${expiresAt}, ${requestId}, ${userAgent},
|
|
209
|
-
NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid), ${options.pwaDeviceId ?? null})
|
|
247
|
+
NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid), ${options.pwaDeviceId ?? null}, ${options.appDeviceId ?? null})
|
|
210
248
|
`;
|
|
249
|
+
const notice = options.knownBrowser
|
|
250
|
+
? await rememberSignIn(tx, { ...options.knownBrowser, userId, firstName: noticeFirstName(user.given_name), now: issuedAt })
|
|
251
|
+
: null;
|
|
211
252
|
if (options.recordLogin !== false) await tx`UPDATE auth.users SET last_login_local = ${issuedAt} WHERE id = ${userId}::uuid`;
|
|
212
|
-
return { userId, sid, authEpoch: epoch, issuedAt, expiresAt, signer, options };
|
|
253
|
+
return { userId, sid, authEpoch: epoch, issuedAt, expiresAt, signer, options, notice };
|
|
213
254
|
};
|
|
214
255
|
|
|
215
256
|
/** Signs a committed family. Null when the signer was revoked meanwhile; the family is revoked then. */
|
|
@@ -244,7 +285,13 @@ const signFamily = async (family: PreparedFamily): Promise<string | null> => {
|
|
|
244
285
|
};
|
|
245
286
|
|
|
246
287
|
/** One issuance attempt is retried once when the signing key changes underneath it. */
|
|
247
|
-
const issueJwtSession = async (
|
|
288
|
+
const issueJwtSession = async (
|
|
289
|
+
c: Context,
|
|
290
|
+
userId: string,
|
|
291
|
+
options: SessionIssueOptions,
|
|
292
|
+
attempt = 0,
|
|
293
|
+
committedNotice: NewBrowserSignInNotice | null = null,
|
|
294
|
+
): Promise<{ token: string; notice: NewBrowserSignInNotice | null }> => {
|
|
248
295
|
const signer = await prepareIdentitySigner("session");
|
|
249
296
|
let family: PreparedFamily;
|
|
250
297
|
try {
|
|
@@ -252,13 +299,14 @@ const issueJwtSession = async (c: Context, userId: string, options: SessionIssue
|
|
|
252
299
|
} catch (error) {
|
|
253
300
|
if (attempt === 0 && isSignerInactive(error)) {
|
|
254
301
|
invalidateIdentitySignerCache("session", signer.kid);
|
|
255
|
-
return issueJwtSession(c, userId, options, 1);
|
|
302
|
+
return issueJwtSession(c, userId, options, 1, committedNotice);
|
|
256
303
|
}
|
|
257
304
|
throw error;
|
|
258
305
|
}
|
|
259
306
|
const token = await signFamily(family);
|
|
260
|
-
|
|
261
|
-
if (
|
|
307
|
+
const notice = committedNotice ?? family.notice;
|
|
308
|
+
if (token) return { token, notice };
|
|
309
|
+
if (attempt === 0) return issueJwtSession(c, userId, { ...options, sid: undefined }, 1, notice);
|
|
262
310
|
throw new Error("Cloud session signer changed repeatedly during issuance");
|
|
263
311
|
};
|
|
264
312
|
|
|
@@ -281,7 +329,7 @@ const issueInTransaction = async <T>(
|
|
|
281
329
|
value = await sql.begin((tx) =>
|
|
282
330
|
work(tx, async (userId, options) => {
|
|
283
331
|
if (family) throw new Error("Only one session family per transaction");
|
|
284
|
-
family = await insertFamily(tx, c, userId, signer, options);
|
|
332
|
+
family = await insertFamily(tx, c, userId, signer, { ...options, knownBrowser: undefined });
|
|
285
333
|
return family.sid;
|
|
286
334
|
}),
|
|
287
335
|
);
|
|
@@ -294,7 +342,7 @@ const issueInTransaction = async <T>(
|
|
|
294
342
|
}
|
|
295
343
|
if (!family) return { value, token: null };
|
|
296
344
|
const token = await signFamily(family);
|
|
297
|
-
return { value, token: token ?? (await issueJwtSession(c, family.userId, { ...family.options, sid: undefined }, 1)) };
|
|
345
|
+
return { value, token: token ?? (await issueJwtSession(c, family.userId, { ...family.options, sid: undefined }, 1)).token };
|
|
298
346
|
}
|
|
299
347
|
};
|
|
300
348
|
|
|
@@ -349,11 +397,43 @@ export const session = {
|
|
|
349
397
|
|
|
350
398
|
getBearerToken: (c: Context): string | null => parseBearer(c.req.header("Authorization")),
|
|
351
399
|
|
|
352
|
-
create: async (
|
|
400
|
+
create: async (
|
|
401
|
+
c: Context,
|
|
402
|
+
userId: string,
|
|
403
|
+
signIn: {
|
|
404
|
+
method: SignInMethod;
|
|
405
|
+
device?: string | null;
|
|
406
|
+
/** Null means this sign-in announces nothing, including during maintenance. */
|
|
407
|
+
notify: NewBrowserSignInNotifier | null;
|
|
408
|
+
/** Account epoch an earlier approval was bound to; issuance fails when sign-out everywhere moved it. */
|
|
409
|
+
authEpoch?: number;
|
|
410
|
+
/** The Cloud Login device that approved this sign-in; removing the device can end the session. */
|
|
411
|
+
appDeviceId?: string;
|
|
412
|
+
},
|
|
413
|
+
): Promise<string> => {
|
|
353
414
|
const expiryHours = await settings.get<number>("user.session.expiry_hours");
|
|
354
415
|
if (!Number.isFinite(expiryHours) || expiryHours < 1) throw new Error("user.session.expiry_hours must be a positive number");
|
|
355
416
|
const ttlSeconds = Math.floor(expiryHours * 60 * 60);
|
|
356
|
-
const
|
|
417
|
+
const cookie = readBrowserCookie(c) ?? newBrowserCookie();
|
|
418
|
+
const { browser, os, country, city } = describeRequest(c);
|
|
419
|
+
const { token: clientToken, notice } = await issueJwtSession(c, userId, {
|
|
420
|
+
ttlSeconds,
|
|
421
|
+
authEpoch: signIn.authEpoch,
|
|
422
|
+
appDeviceId: signIn.appDeviceId,
|
|
423
|
+
knownBrowser: {
|
|
424
|
+
cookie,
|
|
425
|
+
browser,
|
|
426
|
+
os,
|
|
427
|
+
country,
|
|
428
|
+
city,
|
|
429
|
+
method: signIn.method,
|
|
430
|
+
device: signIn.device ?? null,
|
|
431
|
+
locale: getLocale(c),
|
|
432
|
+
announce: signIn.notify !== null,
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
// Handoff precedes the response so the person signing in cannot turn the notice off first.
|
|
436
|
+
if (signIn.notify !== null && notice) await announceNewBrowser(signIn.notify, notice);
|
|
357
437
|
|
|
358
438
|
setCookie(c, "session_token", clientToken, {
|
|
359
439
|
httpOnly: true,
|
|
@@ -362,6 +442,7 @@ export const session = {
|
|
|
362
442
|
maxAge: ttlSeconds,
|
|
363
443
|
path: "/",
|
|
364
444
|
});
|
|
445
|
+
setBrowserCookie(c, cookie);
|
|
365
446
|
return clientToken;
|
|
366
447
|
},
|
|
367
448
|
|
|
@@ -11,6 +11,15 @@ export type EligibleAccount = {
|
|
|
11
11
|
account_expires: Date | null;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
/** New personal access requires a browser sign-in from the last ten minutes. */
|
|
15
|
+
export const LASTING_ACCESS_SIGN_IN_SECONDS = 600;
|
|
16
|
+
|
|
17
|
+
export class ConfirmedBrowserSignInError extends Error {
|
|
18
|
+
constructor(readonly code: "REAUTHENTICATE" | "FORBIDDEN") {
|
|
19
|
+
super(code === "REAUTHENTICATE" ? "Sign in again to confirm it is you." : "This account cannot create new access.");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
type Reject = (code: "REAUTHENTICATE" | "FORBIDDEN") => never;
|
|
15
24
|
|
|
16
25
|
/** The account exists, has not expired and its category is enabled. */
|
|
@@ -40,3 +49,7 @@ export const requireRecentWebSession = async (
|
|
|
40
49
|
if (!row) return reject("REAUTHENTICATE");
|
|
41
50
|
return requireEligibleAccount(tx, actor.userId, reject);
|
|
42
51
|
};
|
|
52
|
+
|
|
53
|
+
/** Checks the browser session inside the transaction that creates personal access. */
|
|
54
|
+
export const requireConfirmedBrowserSignIn = (tx: SQL, signIn: { userId: string; sid: string }, reject: Reject): Promise<EligibleAccount> =>
|
|
55
|
+
requireRecentWebSession(tx, signIn, LASTING_ACCESS_SIGN_IN_SECONDS, reject);
|
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
* `services/weather/forecast.ts`, OpenAPI middleware, cron callbacks).
|
|
13
13
|
* Loosely typed (caller provides the value type via the `T` generic).
|
|
14
14
|
*
|
|
15
|
-
* Backend: cache-aside via `store.ts` (Redis 5min TTL + DB
|
|
16
|
-
*
|
|
15
|
+
* Backend: value reads use cache-aside via `store.ts` (Redis 5min TTL + DB
|
|
16
|
+
* fallback); stored-state reads use Postgres. All functions are async — sync
|
|
17
|
+
* render-time access uses the per-request snapshot
|
|
17
18
|
* exposed via `c.get("settings")`.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import type { AppSettingsMap, KindToType } from "../../contracts/settings-types";
|
|
21
|
-
import { deleteKey, readKey, writeKey } from "./store";
|
|
22
|
+
import { deleteKey, readKey, readKeyState, type StoredSettingState, writeKey } from "./store";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Per-app typed settings API. Keys constrained to those declared in the
|
|
@@ -26,7 +27,8 @@ import { deleteKey, readKey, writeKey } from "./store";
|
|
|
26
27
|
*/
|
|
27
28
|
export interface SettingsAPI<F extends Record<string, unknown>> {
|
|
28
29
|
get<K extends keyof F & string>(key: K): Promise<F[K]>;
|
|
29
|
-
|
|
30
|
+
read<K extends keyof F & string>(key: K): Promise<{ value: F[K]; stored: StoredSettingState }>;
|
|
31
|
+
set<K extends keyof F & string>(key: K, value: F[K], options?: { replaceUnreadable?: boolean }): Promise<void>;
|
|
30
32
|
remove<K extends keyof F & string>(key: K): Promise<void>;
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -40,7 +42,8 @@ export const createSettingsAPI = <S extends AppSettingsMap>(): SettingsAPI<{
|
|
|
40
42
|
[K in keyof S]: KindToType<S[K]["kind"]>;
|
|
41
43
|
}> => ({
|
|
42
44
|
get: async (key) => readKey(key) as never,
|
|
43
|
-
|
|
45
|
+
read: async (key) => readKeyState(key) as never,
|
|
46
|
+
set: async (key, value, options) => writeKey(key, value, undefined, options),
|
|
44
47
|
remove: async (key) => deleteKey(key),
|
|
45
48
|
});
|
|
46
49
|
|
|
@@ -56,6 +59,8 @@ export const createSettingsAPI = <S extends AppSettingsMap>(): SettingsAPI<{
|
|
|
56
59
|
*/
|
|
57
60
|
export const coreSettings = {
|
|
58
61
|
get: <T = unknown>(key: string): Promise<T> => readKey(key) as Promise<T>,
|
|
59
|
-
|
|
62
|
+
read: <T = unknown>(key: string): Promise<{ value: T; stored: StoredSettingState }> =>
|
|
63
|
+
readKeyState(key) as Promise<{ value: T; stored: StoredSettingState }>,
|
|
64
|
+
set: (key: string, value: unknown, options?: { replaceUnreadable?: boolean }): Promise<void> => writeKey(key, value, undefined, options),
|
|
60
65
|
remove: (key: string): Promise<void> => deleteKey(key),
|
|
61
66
|
};
|
|
@@ -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 { SettingUnreadableError } from "./store";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Redact secret-kind setting values before they leave the server.
|
|
@@ -54,7 +55,7 @@ export const settingsService = {
|
|
|
54
55
|
|
|
55
56
|
return paginateItems(filtered, config?.pagination);
|
|
56
57
|
},
|
|
57
|
-
update: async (config: { key: string; value: unknown }) => {
|
|
58
|
+
update: async (config: { key: string; value: unknown; replaceUnreadable?: boolean }) => {
|
|
58
59
|
if (!SETTINGS_MAP.has(config.key)) {
|
|
59
60
|
return fail(err.badInput(`Unknown setting: ${config.key}`));
|
|
60
61
|
}
|
|
@@ -64,11 +65,11 @@ export const settingsService = {
|
|
|
64
65
|
return fail(err.badInput(validated.error));
|
|
65
66
|
}
|
|
66
67
|
try {
|
|
67
|
-
await settingsPrimitives.set(config.key, validated.value);
|
|
68
|
+
await settingsPrimitives.set(config.key, validated.value, undefined, { replaceUnreadable: config.replaceUnreadable });
|
|
68
69
|
return ok(undefined);
|
|
69
70
|
} catch (error) {
|
|
70
|
-
//
|
|
71
|
-
if (error instanceof ImageMetadataError) throw error;
|
|
71
|
+
// Preserve the typed HTTP status and recovery code through Hono.
|
|
72
|
+
if (error instanceof ImageMetadataError || error instanceof SettingUnreadableError) throw error;
|
|
72
73
|
const message = error instanceof Error ? error.message : String(error);
|
|
73
74
|
if (message.startsWith("Unknown setting:")) {
|
|
74
75
|
return fail(err.badInput(message));
|
|
@@ -385,15 +385,16 @@ export const CORE_SETTINGS = {
|
|
|
385
385
|
default: 10000,
|
|
386
386
|
min: 100,
|
|
387
387
|
max: 120000,
|
|
388
|
-
description:
|
|
388
|
+
description:
|
|
389
|
+
"Maximum time in milliseconds for one Gotenberg render request. Gotenberg's own --api-timeout (default 30 s) also caps it.",
|
|
389
390
|
},
|
|
390
391
|
"gotenberg.max_html_bytes": {
|
|
391
392
|
kind: "number",
|
|
392
|
-
label: "Max
|
|
393
|
+
label: "Max input bytes",
|
|
393
394
|
default: 1048576,
|
|
394
395
|
min: 1024,
|
|
395
396
|
max: 10485760,
|
|
396
|
-
description: "Maximum
|
|
397
|
+
description: "Maximum size of one render request: HTML, header, footer, images and fonts together.",
|
|
397
398
|
},
|
|
398
399
|
"gotenberg.max_pdf_bytes": {
|
|
399
400
|
kind: "number",
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Resolution order: DB value -> env fallback -> code default.
|
|
5
5
|
*
|
|
6
|
-
* Custom DB values are encrypted at rest using APP_SECRET.
|
|
7
|
-
*
|
|
6
|
+
* Custom DB values are encrypted at rest using APP_SECRET. Value reads use
|
|
7
|
+
* Redis cache-aside in `./store.ts` (5-minute TTL); stored-state reads use DB.
|
|
8
8
|
* `loadCache()` runs once at boot to normalize legacy rows and bootstrap
|
|
9
9
|
* env-backed entries into Postgres. Startup never replaces an existing row;
|
|
10
10
|
* env bootstrap only fills missing keys.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { redis, sql } from "bun";
|
|
14
14
|
import { decryptValue, encryptValue, getAppSecret } from "./crypto";
|
|
15
15
|
import { resolveSettingPresentation, SETTINGS, SETTINGS_MAP, type SettingDef, validateSettingValue } from "./defaults";
|
|
16
|
-
import {
|
|
16
|
+
import { deleteKey, readKey, readKeyState, readStates, type SettingRead, writeKey } from "./store";
|
|
17
17
|
|
|
18
18
|
type SqlClient = typeof sql;
|
|
19
19
|
|
|
@@ -162,8 +162,8 @@ const bootstrapEnvBackedEntries = async (config: {
|
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
164
|
* Boot-time normalization + env-bootstrap. Runs once at startup. Does NOT
|
|
165
|
-
* populate any in-process cache —
|
|
166
|
-
* (`store.ts`)
|
|
165
|
+
* populate any in-process cache — value reads use Redis cache-aside
|
|
166
|
+
* (`store.ts`), and stored-state reads go directly to Postgres.
|
|
167
167
|
*/
|
|
168
168
|
export async function loadCache(): Promise<void> {
|
|
169
169
|
getAppSecret();
|
|
@@ -187,8 +187,12 @@ export async function get<T = unknown>(key: string): Promise<T> {
|
|
|
187
187
|
* Write a setting. Pass `db` to run inside a caller's transaction — then the
|
|
188
188
|
* caller must also call `invalidateSettingsCache` once it commits.
|
|
189
189
|
*/
|
|
190
|
-
export async function set(key: string, value: unknown, db?: SqlClient): Promise<void> {
|
|
191
|
-
await writeKey(key, value, db);
|
|
190
|
+
export async function set(key: string, value: unknown, db?: SqlClient, options?: { replaceUnreadable?: boolean }): Promise<void> {
|
|
191
|
+
await writeKey(key, value, db, options);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function read(key: string): Promise<SettingRead> {
|
|
195
|
+
return readKeyState(key);
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
export async function remove(key: string, db?: SqlClient): Promise<void> {
|
|
@@ -198,20 +202,17 @@ export async function remove(key: string, db?: SqlClient): Promise<void> {
|
|
|
198
202
|
import type { SettingEntry } from "../../contracts/shared";
|
|
199
203
|
|
|
200
204
|
export type { SettingEntry } from "../../contracts/shared";
|
|
201
|
-
export {
|
|
205
|
+
export type { SettingRead, StoredSettingState } from "./store";
|
|
206
|
+
export { invalidateSettingsCache, readStates, SettingUnreadableError } from "./store";
|
|
202
207
|
|
|
203
208
|
export async function getAll(locale?: string): Promise<SettingEntry[]> {
|
|
204
|
-
// Determine which keys have a custom row in Postgres (vs. falling back to
|
|
205
|
-
// env / code default). One indexed scan, then bulk-read all values.
|
|
206
|
-
const customRows = await sql<{ key: string }[]>`SELECT key FROM settings.entries`;
|
|
207
|
-
const customKeys = new Set(customRows.map((row) => row.key));
|
|
208
|
-
|
|
209
209
|
const allKeys = SETTINGS.map((def) => def.key);
|
|
210
|
-
const values = await
|
|
210
|
+
const values = await readStates(allKeys);
|
|
211
211
|
|
|
212
212
|
return SETTINGS.map((def) => {
|
|
213
213
|
const presentation = resolveSettingPresentation(def, locale);
|
|
214
|
-
const
|
|
214
|
+
const { value, stored } = values.get(def.key)!;
|
|
215
|
+
const isCustom = stored !== "none";
|
|
215
216
|
const envFallback = resolveEnvValue(def, "fallback");
|
|
216
217
|
const resetValue = envFallback ?? def.default;
|
|
217
218
|
|
|
@@ -222,12 +223,13 @@ export async function getAll(locale?: string): Promise<SettingEntry[]> {
|
|
|
222
223
|
description: presentation.description ?? "",
|
|
223
224
|
placeholder: presentation.placeholder,
|
|
224
225
|
group: def.group,
|
|
225
|
-
value
|
|
226
|
+
value,
|
|
226
227
|
default: def.default,
|
|
227
228
|
resetValue: def.kind === "secret" ? "" : resetValue,
|
|
228
|
-
valueSource:
|
|
229
|
+
valueSource: stored === "readable" ? "custom" : envFallback === undefined ? "default" : "env",
|
|
229
230
|
resetValueSource: envFallback === undefined ? "default" : "env",
|
|
230
231
|
isCustom,
|
|
232
|
+
unreadable: stored === "unreadable",
|
|
231
233
|
templateVars: "templateVars" in def ? def.templateVars : undefined,
|
|
232
234
|
options: presentation.options,
|
|
233
235
|
min: "min" in def ? def.min : undefined,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Settings store — Redis cache-aside read/write primitives.
|
|
3
3
|
*
|
|
4
|
-
* Pattern: each key is cached in Redis with a 5-minute TTL.
|
|
4
|
+
* Pattern: each key is cached in Redis with a 5-minute TTL. Value reads try Redis
|
|
5
5
|
* first, fall back to DB on miss, and populate Redis. Writes update DB and
|
|
6
6
|
* delete the Redis key (next reader repopulates with fresh DB state).
|
|
7
7
|
*
|
|
@@ -27,6 +27,19 @@ const REDIS_KEY = (k: string) => `settings:${k}`;
|
|
|
27
27
|
|
|
28
28
|
type SqlClient = typeof sql;
|
|
29
29
|
|
|
30
|
+
export type StoredSettingState = "none" | "readable" | "unreadable";
|
|
31
|
+
export type SettingRead = { value: unknown; stored: StoredSettingState };
|
|
32
|
+
|
|
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
|
+
|
|
30
43
|
/**
|
|
31
44
|
* Forget cached values so every container re-reads on next access.
|
|
32
45
|
*
|
|
@@ -70,6 +83,12 @@ const resolveFallback = (def: SettingDef | undefined): unknown => {
|
|
|
70
83
|
*/
|
|
71
84
|
export const readKey = async (key: string): Promise<unknown> => (await bulkRead([key])).get(key);
|
|
72
85
|
|
|
86
|
+
export const readKeyState = async (key: string): Promise<SettingRead> => {
|
|
87
|
+
const state = (await readStates([key])).get(key);
|
|
88
|
+
if (!state) throw new Error(`Missing read state for setting "${key}"`);
|
|
89
|
+
return state;
|
|
90
|
+
};
|
|
91
|
+
|
|
73
92
|
/** One warm MGET; missing rows are cached independently of local env/defaults. */
|
|
74
93
|
export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unknown>> => {
|
|
75
94
|
const result = new Map<string, unknown>();
|
|
@@ -131,6 +150,32 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
|
|
|
131
150
|
return result;
|
|
132
151
|
};
|
|
133
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Stored state is per process (APP_SECRET), but the plaintext cache is shared.
|
|
155
|
+
* State reads go directly to Postgres without reading or filling the cache;
|
|
156
|
+
* value reads (`get`, snapshot) keep using the cache.
|
|
157
|
+
*/
|
|
158
|
+
export const readStates = async (keys: readonly string[]): Promise<Map<string, SettingRead>> => {
|
|
159
|
+
const result = new Map<string, SettingRead>();
|
|
160
|
+
if (keys.length === 0) return result;
|
|
161
|
+
const rows = await sql<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray([...keys])}::text[])`;
|
|
162
|
+
const byKey = new Map(rows.map((row) => [row.key, row]));
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
const row = byKey.get(key);
|
|
165
|
+
const def = SETTINGS_MAP.get(key);
|
|
166
|
+
result.set(key, { stored: row ? "unreadable" : "none", value: resolveFallback(def) });
|
|
167
|
+
if (!row) continue;
|
|
168
|
+
try {
|
|
169
|
+
const value = await decryptValue(row.value);
|
|
170
|
+
const validated = def ? validateSettingValue(def, value) : { ok: true as const, value };
|
|
171
|
+
if (validated.ok) result.set(key, { stored: "readable", value: validated.value });
|
|
172
|
+
} catch {
|
|
173
|
+
// Keep this process's fallback without changing the stored row or cache.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
};
|
|
178
|
+
|
|
134
179
|
/**
|
|
135
180
|
* Get every known setting key (across all registered defs).
|
|
136
181
|
* Used by snapshot loader to determine what to bulk-read.
|
|
@@ -184,11 +229,12 @@ export const deleteLegacyKeys = async (extraKnownKeys: readonly string[] = []):
|
|
|
184
229
|
/**
|
|
185
230
|
* Encrypt the value, upsert the DB row, invalidate the Redis key.
|
|
186
231
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
232
|
+
* Validate new values and refuse to overwrite unreadable stored rows unless
|
|
233
|
+
* the caller explicitly replaces them. The row check and write share a transaction.
|
|
234
|
+
* Lock with a no-op UPDATE so ROW EXCLUSIVE is acquired before the row lock,
|
|
235
|
+
* preserving lock order with table-level SHARE locks (#1303 review).
|
|
190
236
|
*/
|
|
191
|
-
export const writeKey = async (key: string, value: unknown, db?: SqlClient): Promise<void> => {
|
|
237
|
+
export const writeKey = async (key: string, value: unknown, db?: SqlClient, options?: { replaceUnreadable?: boolean }): Promise<void> => {
|
|
192
238
|
const def = SETTINGS_MAP.get(key);
|
|
193
239
|
if (!def) throw new Error(`Unknown setting: ${key}`);
|
|
194
240
|
const validated = validateSettingValue(def, value);
|
|
@@ -197,12 +243,38 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient): Pro
|
|
|
197
243
|
const storedValue =
|
|
198
244
|
def.kind === "image" && typeof validated.value === "string" ? stripImageDataUrlMetadata(validated.value) : validated.value;
|
|
199
245
|
const encrypted = await encryptValue(storedValue);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
246
|
+
const write = async (tx: SqlClient) => {
|
|
247
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
248
|
+
let [row] = await tx<{ value: string }[]>`UPDATE settings.entries SET value = value WHERE key = ${key} RETURNING value`;
|
|
249
|
+
if (!row) {
|
|
250
|
+
const inserted = await tx<{ key: string }[]>`
|
|
251
|
+
INSERT INTO settings.entries (key, value, updated_at)
|
|
252
|
+
VALUES (${key}, ${encrypted}, now())
|
|
253
|
+
ON CONFLICT (key) DO NOTHING RETURNING key
|
|
254
|
+
`;
|
|
255
|
+
if (inserted.length > 0) return;
|
|
256
|
+
// A concurrent insert can win after the missing-row read; lock and check its value before updating.
|
|
257
|
+
[row] = await tx<{ value: string }[]>`UPDATE settings.entries SET value = value WHERE key = ${key} RETURNING value`;
|
|
258
|
+
if (!row) continue;
|
|
259
|
+
}
|
|
260
|
+
if (options?.replaceUnreadable !== true) {
|
|
261
|
+
let readable = false;
|
|
262
|
+
try {
|
|
263
|
+
readable = validateSettingValue(def, await decryptValue(row.value)).ok;
|
|
264
|
+
} catch {
|
|
265
|
+
// Only the key is exposed by the recovery error.
|
|
266
|
+
}
|
|
267
|
+
if (!readable) throw new SettingUnreadableError(key);
|
|
268
|
+
}
|
|
269
|
+
await tx`
|
|
270
|
+
UPDATE settings.entries SET value = ${encrypted}, updated_at = now() WHERE key = ${key}
|
|
271
|
+
`;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
throw new Error(`Could not write setting "${key}" after 3 attempts`);
|
|
275
|
+
};
|
|
276
|
+
if (db) await write(db);
|
|
277
|
+
else await sql.begin(write);
|
|
206
278
|
|
|
207
279
|
// Passing a transaction means the caller owns invalidation, because the row
|
|
208
280
|
// is not visible to anyone else until they commit.
|
package/src/services/webauthn.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { accounts } from "./accounts";
|
|
|
16
16
|
import { audit } from "./audit";
|
|
17
17
|
import { logger } from "./logging";
|
|
18
18
|
import { isUniqueViolation, toPgTextArray } from "./postgres";
|
|
19
|
+
import { ConfirmedBrowserSignInError, requireConfirmedBrowserSignIn } from "./session/recent";
|
|
19
20
|
import { coreSettings } from "./settings/api";
|
|
20
21
|
|
|
21
22
|
const CHALLENGE_TTL_SECONDS = 300;
|
|
@@ -185,6 +186,7 @@ export const beginRegistration = async (params: { user: User }): Promise<Result<
|
|
|
185
186
|
|
|
186
187
|
export const finishRegistration = async (params: {
|
|
187
188
|
user: User;
|
|
189
|
+
browserSignIn: { userId: string; sid: string };
|
|
188
190
|
name: string;
|
|
189
191
|
response: RegistrationResponseJSON;
|
|
190
192
|
}): Promise<Result<WebAuthnPasskey>> => {
|
|
@@ -221,6 +223,16 @@ export const finishRegistration = async (params: {
|
|
|
221
223
|
|
|
222
224
|
try {
|
|
223
225
|
return sql.begin(async (tx) => {
|
|
226
|
+
if (params.browserSignIn.userId !== params.user.id) return fail(err.forbidden("The browser sign-in belongs to another account."));
|
|
227
|
+
try {
|
|
228
|
+
await requireConfirmedBrowserSignIn(tx, params.browserSignIn, (code) => {
|
|
229
|
+
throw new ConfirmedBrowserSignInError(code);
|
|
230
|
+
});
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error instanceof ConfirmedBrowserSignInError) return fail({ code: error.code, status: 403, message: error.message });
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
|
|
224
236
|
const [row] = await tx<DbPasskeyRow[]>`
|
|
225
237
|
INSERT INTO auth.webauthn_credentials (
|
|
226
238
|
user_id,
|
|
@@ -59,7 +59,7 @@ Most replies need no offer. Make at most one offer, in the last sentence of the
|
|
|
59
59
|
{%- if memoryToolEnabled %}
|
|
60
60
|
- The user corrected the tone of a result, such as a mail that is too formal, without stating a lasting rule: offer to remember it as their preference. Memory keeps only the user's own words, so a plain yes cannot be saved: suggest a one-line rule they can send back, such as "Always write mails casually and briefly".
|
|
61
61
|
{%- endif %}
|
|
62
|
-
- The user says the request recurs (again, every week, like last time, always), pastes a long reusable instruction, or a workflow default covers it: offer to save it as a {% if skillOffers %}Skill or {% endif %}
|
|
62
|
+
- The user says the request recurs (again, every week, like last time, always), pastes a long reusable instruction, or a workflow default covers it: offer to save it as a {% if skillOffers %}Skill or {% endif %}routine.
|
|
63
63
|
- A concrete next step clearly saves the user real work they may not know you can do: offer it, such as a manual follow-up the user mentioned, drafting the reply, or turning findings into Space tasks.
|
|
64
64
|
Offer only what the Skills, tool hints, and apps in this prompt support. Do not search mail, chats, or other data only to justify an offer, and do not claim how often something happened unless the user said it or this chat shows it. Do not start the offered work until the user agrees.
|
|
65
65
|
{%- if skillOffers %}
|
|
@@ -65,6 +65,7 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
|
|
|
65
65
|
return {
|
|
66
66
|
...app,
|
|
67
67
|
...resolveAppIdentityPresentation(app, requestedLocale),
|
|
68
|
+
...(app.panel ? { panel: { ...app.panel, label: translation.panelLabel ?? app.panel.label } } : {}),
|
|
68
69
|
adminNav: app.adminNav?.map((group) => ({
|
|
69
70
|
...group,
|
|
70
71
|
label: (group.id && translation.adminGroups?.[group.id]) || group.label,
|