@k2b/cloud 0.37.0 → 0.39.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 +8 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- 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/define-app.ts +28 -2
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- 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/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +11 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +10 -0
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +460 -66
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +46 -3
- package/src/ai/readable-html.ts +1057 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/help.ts +23 -4
- package/src/api/index.ts +2 -1
- package/src/api/mcp.ts +8 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +17 -2
- package/src/cli/admin/workflows.ts +1 -0
- package/src/config/env.ts +14 -0
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/help.ts +136 -10
- package/src/server/services/access.ts +5 -1
- package/src/server/services/geo.ts +21 -4
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +22 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- 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/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 +67 -43
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +62 -11
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +72 -25
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +34 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +11 -0
- package/src/ssr/shell-shortcuts.ts +19 -0
- package/src/styles/effects.css +689 -185
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/resource-search.css +25 -0
- package/src/styles/tokens.css +23 -6
- package/src/styles/utilities-navigation.css +5 -21
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- package/src/workflows/store/retention.ts +157 -0
|
@@ -3,6 +3,7 @@ import { lazySync } from "../_internal/process-sync";
|
|
|
3
3
|
import { env } from "../config/env";
|
|
4
4
|
import { type AccountCategory, accountCategory } from "../contracts/account-categories";
|
|
5
5
|
import {
|
|
6
|
+
APP_ACTION_LIMITS,
|
|
6
7
|
APP_APPROVAL_FEATURES,
|
|
7
8
|
APP_APPROVAL_PATH,
|
|
8
9
|
APP_APPROVAL_PROTOCOL,
|
|
@@ -19,9 +20,11 @@ import {
|
|
|
19
20
|
} from "../contracts/app-approval";
|
|
20
21
|
import { publicCloudOrigin } from "../shared/app-url";
|
|
21
22
|
import { isAccountCategoryAllowed } from "./account-category-policy";
|
|
23
|
+
import { appApprovalText } from "./approvals/device";
|
|
22
24
|
import { audit } from "./audit";
|
|
23
25
|
import { logger } from "./logging";
|
|
24
26
|
import { pairingSecret } from "./pairing-secret";
|
|
27
|
+
import { toPgTextArray } from "./postgres";
|
|
25
28
|
import { requireEligibleAccount, requireRecentWebSession } from "./session/recent";
|
|
26
29
|
import { CORE_SETTINGS } from "./settings/core-settings";
|
|
27
30
|
import { decryptValue } from "./settings/crypto";
|
|
@@ -109,6 +112,7 @@ type DeviceRow = {
|
|
|
109
112
|
created_at: Date;
|
|
110
113
|
last_used_at: Date | null;
|
|
111
114
|
assisted: boolean;
|
|
115
|
+
accepts: string[] | null;
|
|
112
116
|
};
|
|
113
117
|
type PairingRow = {
|
|
114
118
|
id: string;
|
|
@@ -127,6 +131,11 @@ type PairingRow = {
|
|
|
127
131
|
comparison: string | null;
|
|
128
132
|
};
|
|
129
133
|
type LoginRow = {
|
|
134
|
+
kind: "browser" | "terminal";
|
|
135
|
+
device_authorization_id: string | null;
|
|
136
|
+
client_id: string | null;
|
|
137
|
+
scopes: string[] | null;
|
|
138
|
+
lifetime_seconds: number | null;
|
|
130
139
|
id: string;
|
|
131
140
|
user_id: string | null;
|
|
132
141
|
auth_epoch: string | null;
|
|
@@ -164,9 +173,30 @@ export type AppDeviceActions = {
|
|
|
164
173
|
approved: boolean;
|
|
165
174
|
}): Promise<boolean>;
|
|
166
175
|
};
|
|
167
|
-
|
|
176
|
+
export type AppTerminalLoginInput = {
|
|
177
|
+
deviceAuthorizationId: string;
|
|
178
|
+
identifier: string;
|
|
179
|
+
clientId: string;
|
|
180
|
+
scopes: string[];
|
|
181
|
+
lifetimeSeconds: number;
|
|
182
|
+
};
|
|
183
|
+
export type AppTerminalLogins = {
|
|
184
|
+
/** Live pending authorizations whose registry client still allows the device grant. */
|
|
185
|
+
describe(tx: SQL, ids: string[]): Promise<Map<string, { id: string; name: string }>>;
|
|
186
|
+
/** In the sign-in decision transaction; false aborts both decisions and their audits. */
|
|
187
|
+
decide(
|
|
188
|
+
tx: SQL,
|
|
189
|
+
input: {
|
|
190
|
+
deviceAuthorizationId: string;
|
|
191
|
+
clientId: string;
|
|
192
|
+
user: Pick<AccountRow, "id" | "uid" | "provider" | "profile">;
|
|
193
|
+
decision: "approve" | "deny";
|
|
194
|
+
},
|
|
195
|
+
): Promise<boolean>;
|
|
196
|
+
};
|
|
197
|
+
// Bundle sign-in and Action wake-ups for 30 seconds while the previously woken request is still open.
|
|
168
198
|
// Cloud Login gives Actions a separate 30-per-token budget in five minutes, preserving the full sign-in budget.
|
|
169
|
-
const
|
|
199
|
+
const WAKE_SECONDS = 30;
|
|
170
200
|
/** Best-effort sign-in wake-ups through the trusted authenticator; returns the HTTP status. */
|
|
171
201
|
export type AppLoginPushSender = (
|
|
172
202
|
appOrigin: string,
|
|
@@ -247,7 +277,7 @@ export const createAppApprovalService = (
|
|
|
247
277
|
// Bounded opportunistic maintenance: each accepted write retires up to 100
|
|
248
278
|
// expired rows per table, so bursts cannot trigger an unbounded sweep.
|
|
249
279
|
await db`DELETE FROM auth.app_pairings WHERE id IN (SELECT id FROM auth.app_pairings WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
250
|
-
await db`DELETE FROM auth.app_logins WHERE id IN (SELECT id FROM auth.app_logins WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
280
|
+
await db`DELETE FROM auth.app_logins WHERE id IN (SELECT id FROM auth.app_logins WHERE expires_at < now() AND NOT (kind='terminal' AND state='approved') ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
251
281
|
await db`DELETE FROM auth.app_device_proofs WHERE (device_id,jti) IN (SELECT device_id,jti FROM auth.app_device_proofs WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
252
282
|
};
|
|
253
283
|
const authorizePairing = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
|
|
@@ -269,12 +299,18 @@ export const createAppApprovalService = (
|
|
|
269
299
|
|
|
270
300
|
/** Runs after the login commit and never delays or fails it. The body names only
|
|
271
301
|
* this Cloud and the request id; a 410 means the phone's subscription is gone. */
|
|
272
|
-
const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string) => {
|
|
302
|
+
const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string, options?: { terminal: boolean }) => {
|
|
273
303
|
try {
|
|
274
|
-
const rows = await db<{ push_token: string }[]>`
|
|
275
|
-
|
|
304
|
+
const rows = await db<{ push_token: string }[]>`
|
|
305
|
+
UPDATE auth.app_devices d SET login_wake_at=now(), login_wake_ref=${requestId}::uuid
|
|
306
|
+
WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL
|
|
307
|
+
AND (${options?.terminal ?? false}=false OR 'terminal-logins'=ANY(d.accepts))
|
|
308
|
+
AND (d.login_wake_at IS NULL OR d.login_wake_at <= now() - ${WAKE_SECONDS} * interval '1 second'
|
|
309
|
+
OR NOT EXISTS (SELECT 1 FROM auth.app_logins w
|
|
310
|
+
WHERE w.id=d.login_wake_ref AND w.state='pending' AND w.expires_at > now()))
|
|
311
|
+
RETURNING push_token`;
|
|
276
312
|
await Promise.all(
|
|
277
|
-
rows.map(
|
|
313
|
+
Array.from(new Set(rows.map((row) => row.push_token)), async (token) => {
|
|
278
314
|
const status = await push(cfg.appOrigin, { token, cloudOrigin: cfg.issuer, requestRef: requestId });
|
|
279
315
|
if (status === 410)
|
|
280
316
|
await db`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
|
|
@@ -285,6 +321,31 @@ export const createAppApprovalService = (
|
|
|
285
321
|
}
|
|
286
322
|
};
|
|
287
323
|
|
|
324
|
+
const loginCandidate = async (tx: SQL, identifier: string, category?: AccountCategory) => {
|
|
325
|
+
const candidates = await tx<AccountRow[]>`
|
|
326
|
+
SELECT id,uid,provider,profile,auth_epoch,account_expires FROM auth.users
|
|
327
|
+
WHERE lower(uid)=lower(${identifier}) OR lower(btrim(mail))=lower(${identifier}) LIMIT 2`;
|
|
328
|
+
const candidate = candidates.length === 1 ? candidates[0] : undefined;
|
|
329
|
+
if (
|
|
330
|
+
candidate &&
|
|
331
|
+
((category !== undefined && accountCategory(candidate) !== category) ||
|
|
332
|
+
(candidate.account_expires && new Date(candidate.account_expires).getTime() <= Date.now()) ||
|
|
333
|
+
!(await isAccountCategoryAllowed(candidate, tx)))
|
|
334
|
+
)
|
|
335
|
+
return undefined;
|
|
336
|
+
if (!candidate) return undefined;
|
|
337
|
+
// Re-read under the account lock: starts share one budget, and epoch changes cannot race the snapshot.
|
|
338
|
+
const [locked] = await tx<AccountRow[]>`SELECT id,uid,provider,profile,auth_epoch,account_expires
|
|
339
|
+
FROM auth.users WHERE id=${candidate.id}::uuid FOR UPDATE`;
|
|
340
|
+
if (locked?.account_expires && new Date(locked.account_expires).getTime() <= Date.now()) return undefined;
|
|
341
|
+
return locked;
|
|
342
|
+
};
|
|
343
|
+
const loginBudget = async (tx: SQL, issuer: string, userId: string) => {
|
|
344
|
+
const [count] = await tx<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_logins
|
|
345
|
+
WHERE issuer=${issuer} AND user_id=${userId}::uuid AND expires_at > now() AND state IN ('pending','approved')`;
|
|
346
|
+
return (count?.count ?? 0) < limits.pendingPerAccount;
|
|
347
|
+
};
|
|
348
|
+
|
|
288
349
|
return {
|
|
289
350
|
config,
|
|
290
351
|
cleanup,
|
|
@@ -303,7 +364,7 @@ export const createAppApprovalService = (
|
|
|
303
364
|
AND device.issuer=${cfg.issuer} AND device.revoked_at IS NULL AND device.push_token IS NOT NULL
|
|
304
365
|
AND device.actions_at IS NOT NULL
|
|
305
366
|
LIMIT ${limits.devicesPerAccount}
|
|
306
|
-
) AND (d.action_wake_at IS NULL OR d.action_wake_at <= now() - ${
|
|
367
|
+
) AND (d.action_wake_at IS NULL OR d.action_wake_at <= now() - ${WAKE_SECONDS} * interval '1 second'
|
|
307
368
|
OR NOT EXISTS (SELECT 1 FROM approvals.requests w
|
|
308
369
|
WHERE w.id=d.action_wake_ref AND w.status='pending' AND w.expires_at > now()))
|
|
309
370
|
RETURNING d.push_token,d.user_id`;
|
|
@@ -505,24 +566,8 @@ export const createAppApprovalService = (
|
|
|
505
566
|
expiresAt = future(limits.loginSeconds);
|
|
506
567
|
let owner: string | undefined;
|
|
507
568
|
await db.begin(async (tx) => {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
>`SELECT id,uid,provider,profile,auth_epoch,account_expires FROM auth.users WHERE lower(uid)=lower(${identifier}) OR lower(btrim(mail))=lower(${identifier}) LIMIT 2`;
|
|
511
|
-
let candidate = candidates.length === 1 ? candidates[0] : undefined;
|
|
512
|
-
if (
|
|
513
|
-
candidate &&
|
|
514
|
-
(accountCategory(candidate) !== category ||
|
|
515
|
-
(candidate.account_expires && new Date(candidate.account_expires).getTime() <= Date.now()) ||
|
|
516
|
-
!(await isAccountCategoryAllowed(candidate, tx)))
|
|
517
|
-
)
|
|
518
|
-
candidate = undefined;
|
|
519
|
-
if (candidate) {
|
|
520
|
-
await tx`SELECT id FROM auth.users WHERE id=${candidate.id}::uuid FOR UPDATE`;
|
|
521
|
-
const [count] = await tx<
|
|
522
|
-
{ count: number }[]
|
|
523
|
-
>`SELECT count(*)::int AS count FROM auth.app_logins WHERE issuer=${cfg.issuer} AND user_id=${candidate.id}::uuid AND expires_at > now() AND state IN ('pending','approved')`;
|
|
524
|
-
if ((count?.count ?? 0) >= limits.pendingPerAccount) candidate = undefined;
|
|
525
|
-
}
|
|
569
|
+
let candidate = await loginCandidate(tx, identifier, category);
|
|
570
|
+
if (candidate && !(await loginBudget(tx, cfg.issuer, candidate.id))) candidate = undefined;
|
|
526
571
|
// Unknown, ambiguous, disabled and throttled accounts have the identical
|
|
527
572
|
// public shape and a decoy pending transaction; no account enumeration.
|
|
528
573
|
await tx`INSERT INTO auth.app_logins(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at)
|
|
@@ -533,7 +578,38 @@ export const createAppApprovalService = (
|
|
|
533
578
|
if (owner) void wake(cfg, owner, id);
|
|
534
579
|
return { requestId: id, browserSecret, comparison: code, expiresAt: iso(expiresAt), pollAfterSeconds: limits.pollSeconds };
|
|
535
580
|
},
|
|
536
|
-
|
|
581
|
+
startTerminalLogin: async (
|
|
582
|
+
input: AppTerminalLoginInput,
|
|
583
|
+
access: (tx: SQL, account: Pick<AccountRow, "id" | "profile">) => Promise<boolean>,
|
|
584
|
+
) => {
|
|
585
|
+
const cfg = await config();
|
|
586
|
+
await cleanup();
|
|
587
|
+
const id = crypto.randomUUID(),
|
|
588
|
+
code = comparison(),
|
|
589
|
+
expiresAt = future(limits.loginSeconds);
|
|
590
|
+
let owner: string | undefined;
|
|
591
|
+
await db.begin(async (tx) => {
|
|
592
|
+
let candidate = await loginCandidate(tx, input.identifier);
|
|
593
|
+
const subject = candidate ?? { id: crypto.randomUUID(), profile: "user" as const };
|
|
594
|
+
// Even a decoy performs the client-access, device and shared-budget queries.
|
|
595
|
+
const allowed = await access(tx, subject);
|
|
596
|
+
const devices = await tx`SELECT id FROM auth.app_devices WHERE issuer=${cfg.issuer}
|
|
597
|
+
AND user_id=${subject.id}::uuid AND revoked_at IS NULL LIMIT 1`;
|
|
598
|
+
const budget = await loginBudget(tx, cfg.issuer, subject.id);
|
|
599
|
+
if (!allowed || !devices.length || !budget) candidate = undefined;
|
|
600
|
+
const inserted = await tx`INSERT INTO auth.app_logins
|
|
601
|
+
(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at,kind,device_authorization_id,client_id,scopes,lifetime_seconds)
|
|
602
|
+
VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},
|
|
603
|
+
${candidate ? accountCategory(candidate) : "login"},${hash(secret())},${secret()},${code},${expiresAt},'terminal',
|
|
604
|
+
${input.deviceAuthorizationId}::uuid,${input.clientId},${toPgTextArray(input.scopes)}::text[],${input.lifetimeSeconds})
|
|
605
|
+
ON CONFLICT (device_authorization_id) WHERE device_authorization_id IS NOT NULL DO NOTHING RETURNING id`;
|
|
606
|
+
if (!inserted.length) return reject("CONFLICT", 409);
|
|
607
|
+
owner = candidate?.id;
|
|
608
|
+
});
|
|
609
|
+
if (owner) void wake(cfg, owner, id, { terminal: true });
|
|
610
|
+
return { comparison: code, expiresAt: iso(expiresAt) };
|
|
611
|
+
},
|
|
612
|
+
deviceCommand: async (request: AppDeviceRequest, actions: AppDeviceActions, terminal?: AppTerminalLogins) => {
|
|
537
613
|
const cfg = await config();
|
|
538
614
|
const { proof, signature } = request;
|
|
539
615
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -580,20 +656,40 @@ export const createAppApprovalService = (
|
|
|
580
656
|
return { operation: command.operation, userId: device.user_id, deviceId: device.id };
|
|
581
657
|
if (command.operation === "pending" || command.operation === "inbox") {
|
|
582
658
|
const user = await eligible(tx, device.user_id);
|
|
659
|
+
const acceptsTerminal =
|
|
660
|
+
command.operation === "inbox" && command.accept?.includes("terminal-logins") === true && terminal !== undefined;
|
|
583
661
|
const rows = await tx<
|
|
584
662
|
LoginRow[]
|
|
585
|
-
>`SELECT * FROM auth.app_logins WHERE issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid AND state='pending' AND expires_at>now() AND auth_epoch=${user.auth_epoch} AND category=${accountCategory(user)} ORDER BY created_at LIMIT ${limits.pendingPerAccount}`;
|
|
586
|
-
const
|
|
587
|
-
|
|
663
|
+
>`SELECT * FROM auth.app_logins WHERE (kind='browser' OR ${acceptsTerminal}) AND issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid AND state='pending' AND expires_at>now() AND auth_epoch=${user.auth_epoch} AND category=${accountCategory(user)} ORDER BY created_at LIMIT ${limits.pendingPerAccount}`;
|
|
664
|
+
const clients =
|
|
665
|
+
acceptsTerminal && terminal
|
|
666
|
+
? await terminal.describe(
|
|
667
|
+
tx,
|
|
668
|
+
rows.flatMap((row) => (row.device_authorization_id ? [row.device_authorization_id] : [])),
|
|
669
|
+
)
|
|
670
|
+
: new Map<string, { id: string; name: string }>();
|
|
671
|
+
const requests: AppPendingLogin[] = [];
|
|
672
|
+
for (const row of rows) {
|
|
673
|
+
const login: AppPendingLogin = {
|
|
588
674
|
requestId: row.id,
|
|
589
675
|
challenge: row.challenge,
|
|
590
676
|
comparison: row.comparison,
|
|
591
677
|
createdAt: iso(row.created_at),
|
|
592
678
|
expiresAt: iso(row.expires_at),
|
|
593
|
-
}
|
|
594
|
-
|
|
679
|
+
};
|
|
680
|
+
if (row.kind === "terminal") {
|
|
681
|
+
const client = row.device_authorization_id ? clients.get(row.device_authorization_id) : undefined;
|
|
682
|
+
if (!client || client.id !== row.client_id || !row.scopes || !row.lifetime_seconds) continue;
|
|
683
|
+
login.terminal = {
|
|
684
|
+
client: { id: client.id, name: appApprovalText(client.name || client.id, APP_ACTION_LIMITS.nameChars) || "OAuth" },
|
|
685
|
+
scopes: row.scopes,
|
|
686
|
+
lifetimeSeconds: row.lifetime_seconds,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
requests.push(login);
|
|
690
|
+
}
|
|
595
691
|
if (command.operation === "inbox") {
|
|
596
|
-
await tx`UPDATE auth.app_devices SET actions_at=now() WHERE id=${device.id}::uuid`;
|
|
692
|
+
await tx`UPDATE auth.app_devices SET actions_at=now(), accepts=${toPgTextArray(command.accept ?? [])}::text[] WHERE id=${device.id}::uuid`;
|
|
597
693
|
return { operation: "inbox" as const, userId: device.user_id, logins: requests };
|
|
598
694
|
}
|
|
599
695
|
return { requests, pollAfterSeconds: limits.pollSeconds };
|
|
@@ -612,9 +708,29 @@ export const createAppApprovalService = (
|
|
|
612
708
|
row.category !== accountCategory(user)
|
|
613
709
|
)
|
|
614
710
|
return reject("CONFLICT", 409);
|
|
711
|
+
if (
|
|
712
|
+
row.kind === "terminal" &&
|
|
713
|
+
(!device.accepts?.includes("terminal-logins") ||
|
|
714
|
+
!terminal ||
|
|
715
|
+
!row.device_authorization_id ||
|
|
716
|
+
!row.client_id ||
|
|
717
|
+
!(await terminal.decide(tx, {
|
|
718
|
+
deviceAuthorizationId: row.device_authorization_id,
|
|
719
|
+
clientId: row.client_id,
|
|
720
|
+
user,
|
|
721
|
+
decision: command.decision,
|
|
722
|
+
})))
|
|
723
|
+
)
|
|
724
|
+
return reject("CONFLICT", 409);
|
|
615
725
|
const state = command.decision === "approve" ? "approved" : "denied";
|
|
616
|
-
|
|
617
|
-
await
|
|
726
|
+
// A hook may wait on another transaction. Expiry still wins before either decision commits.
|
|
727
|
+
const decided = await tx`UPDATE auth.app_logins SET state=${state},approved_by=${device.id}::uuid
|
|
728
|
+
WHERE id=${row.id}::uuid AND expires_at > clock_timestamp() RETURNING id`;
|
|
729
|
+
if (!decided.length) return reject("CONFLICT", 409);
|
|
730
|
+
await record(tx, `login.${command.decision}`, device.user_id, device.id, {
|
|
731
|
+
requestId: row.id,
|
|
732
|
+
...(row.kind === "terminal" ? { kind: "terminal", clientId: row.client_id, deviceGrantId: row.device_authorization_id } : {}),
|
|
733
|
+
});
|
|
618
734
|
return { state };
|
|
619
735
|
});
|
|
620
736
|
if (outcome.operation !== undefined) {
|
|
@@ -655,7 +771,7 @@ export const createAppApprovalService = (
|
|
|
655
771
|
browserStatus: async (id: string, token: string, hold?: { ms: number; signal?: AbortSignal }) => {
|
|
656
772
|
const cfg = await config();
|
|
657
773
|
const read = async () => {
|
|
658
|
-
const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
774
|
+
const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
659
775
|
if (!row || !matches(token, row.browser_hash)) return reject("UNAVAILABLE", 404);
|
|
660
776
|
const expiresIn = new Date(row.expires_at).getTime() - Date.now();
|
|
661
777
|
return { state: expiresIn <= 0 ? "expired" : row.state, expiresIn };
|
|
@@ -686,10 +802,14 @@ export const createAppApprovalService = (
|
|
|
686
802
|
const cfg = await config();
|
|
687
803
|
return db.begin(async (tx) => {
|
|
688
804
|
// Lock order is device -> request for both approval and completion.
|
|
689
|
-
const [snapshot] = await tx<
|
|
805
|
+
const [snapshot] = await tx<
|
|
806
|
+
LoginRow[]
|
|
807
|
+
>`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
690
808
|
if (!snapshot || !snapshot.approved_by || !matches(token, snapshot.browser_hash)) return reject("CONFLICT", 409);
|
|
691
809
|
const device = await activeDevice(tx, snapshot.approved_by, cfg.issuer);
|
|
692
|
-
const [row] = await tx<
|
|
810
|
+
const [row] = await tx<
|
|
811
|
+
LoginRow[]
|
|
812
|
+
>`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
|
|
693
813
|
const user = await eligible(tx, device.user_id);
|
|
694
814
|
if (
|
|
695
815
|
!row ||
|
|
@@ -21,6 +21,8 @@ const cut = (value: string, limit: number): string => {
|
|
|
21
21
|
return points.length > limit ? `${points.slice(0, limit - 1).join("")}…` : value;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
export const appApprovalText = (value: string, limit: number): string => cut(plain(value), limit);
|
|
25
|
+
|
|
24
26
|
/** A deterministic, plain-text snapshot; the digest binds the device's choice to everything it shows. */
|
|
25
27
|
export const appActionView = async (
|
|
26
28
|
row: PendingApprovalRow,
|
|
@@ -4,7 +4,8 @@ import { getApp, listApps } from "../../_internal/registry";
|
|
|
4
4
|
import type { AppRegistryEntry } from "../../contracts/registry";
|
|
5
5
|
import type { RequestActor } from "../../contracts/shared";
|
|
6
6
|
import { resolveAppPresentations } from "../../shared/app-presentation";
|
|
7
|
-
import { helpLocaleChain } from "../../shared/help";
|
|
7
|
+
import { type HelpArticleLink, helpLocaleChain, parseHelpReference } from "../../shared/help";
|
|
8
|
+
import { lexHelpMarkdown, walkHelpTokens } from "../../shared/markdown";
|
|
8
9
|
import { logger } from "../logging";
|
|
9
10
|
import { HELP_RESOURCE_PAGE_SIZE, type HelpSelection, hasHelpBm25, metadata, queryHelp } from "./store";
|
|
10
11
|
import type { HelpReader } from "./types";
|
|
@@ -36,9 +37,8 @@ export const createHelpReader = (
|
|
|
36
37
|
): HelpReader => {
|
|
37
38
|
const db = dependencies.db ?? sql;
|
|
38
39
|
const visible = (app: AppRegistryEntry | null): app is AppRegistryEntry => app !== null && canReadAppHelp(app, viewer);
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
return resolveAppPresentations(apps.filter(visible), locale).flatMap((app) =>
|
|
40
|
+
const selectionFor = (apps: AppRegistryEntry[]): HelpSelection[] =>
|
|
41
|
+
apps.flatMap((app) =>
|
|
42
42
|
app.help
|
|
43
43
|
? [
|
|
44
44
|
{
|
|
@@ -50,6 +50,9 @@ export const createHelpReader = (
|
|
|
50
50
|
]
|
|
51
51
|
: [],
|
|
52
52
|
);
|
|
53
|
+
const select = async (appId?: string): Promise<HelpSelection[]> => {
|
|
54
|
+
const apps = appId ? [await (dependencies.getApp ?? getApp)(appId)] : await (dependencies.listApps ?? listApps)();
|
|
55
|
+
return selectionFor(resolveAppPresentations(apps.filter(visible), locale));
|
|
53
56
|
};
|
|
54
57
|
return {
|
|
55
58
|
async manifest(appId) {
|
|
@@ -80,6 +83,7 @@ export const createHelpReader = (
|
|
|
80
83
|
icon: row.icon ?? undefined,
|
|
81
84
|
description: row.description ?? undefined,
|
|
82
85
|
order: row.sort_order,
|
|
86
|
+
kind: row.kind,
|
|
83
87
|
locale: row.locale,
|
|
84
88
|
searchUrl: `/api/help/v1/${encodeURIComponent(appId)}/search`,
|
|
85
89
|
url: `/api/help/v1/${encodeURIComponent(appId)}/documents/${encodeURIComponent(row.document_id)}`,
|
|
@@ -91,7 +95,44 @@ export const createHelpReader = (
|
|
|
91
95
|
},
|
|
92
96
|
async read(input) {
|
|
93
97
|
const [row] = await queryHelp(await select(input.appId), { documentId: input.documentId, body: true, limit: 1 }, db);
|
|
94
|
-
|
|
98
|
+
if (row?.markdown === undefined) return null;
|
|
99
|
+
const references = new Set<string>();
|
|
100
|
+
const add = (reference: string) => {
|
|
101
|
+
const ref = parseHelpReference(reference);
|
|
102
|
+
if (ref) references.add(`${ref.appId ?? input.appId}/${ref.documentId}`);
|
|
103
|
+
};
|
|
104
|
+
if (row.next_id) add(row.next_id);
|
|
105
|
+
for (const reference of row.related) add(reference);
|
|
106
|
+
walkHelpTokens(lexHelpMarkdown(row.markdown, row.locale), (token) => {
|
|
107
|
+
if (token.type === "link" && token.href.startsWith("help:")) add(token.href.slice(5));
|
|
108
|
+
});
|
|
109
|
+
// Only targets in the current Help of apps this viewer may see become links; the rest stay text.
|
|
110
|
+
const referencedApps = new Set([...references].map((reference) => reference.split("/")[0]));
|
|
111
|
+
const apps = references.size
|
|
112
|
+
? resolveAppPresentations(
|
|
113
|
+
(referencedApps.size === 1 && referencedApps.has(input.appId)
|
|
114
|
+
? [await (dependencies.getApp ?? getApp)(input.appId)]
|
|
115
|
+
: await (dependencies.listApps ?? listApps)()
|
|
116
|
+
).filter((app): app is AppRegistryEntry => visible(app) && referencedApps.has(app.id)),
|
|
117
|
+
locale,
|
|
118
|
+
)
|
|
119
|
+
: [];
|
|
120
|
+
const targets = apps.length ? await queryHelp(selectionFor(apps), { references: [...references] }, db) : [];
|
|
121
|
+
const accents = new Map(apps.map((app) => [app.id, app.appearance?.accent]));
|
|
122
|
+
const links: Record<string, HelpArticleLink> = {};
|
|
123
|
+
for (const target of targets) {
|
|
124
|
+
links[`${target.app_id}/${target.document_id}`] = {
|
|
125
|
+
appId: target.app_id,
|
|
126
|
+
appName: target.app_name,
|
|
127
|
+
accent: accents.get(target.app_id),
|
|
128
|
+
documentId: target.document_id,
|
|
129
|
+
title: target.title,
|
|
130
|
+
description: target.description ?? undefined,
|
|
131
|
+
icon: target.icon ?? undefined,
|
|
132
|
+
url: `/help/apps/${encodeURIComponent(target.app_id)}/${encodeURIComponent(target.document_id)}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { ...metadata(row), markdown: row.markdown, links };
|
|
95
136
|
},
|
|
96
137
|
async search(input) {
|
|
97
138
|
const query = input.query.trim().slice(0, 200);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
2
|
import { APP_REGISTRY_TTL_MS } from "../../_internal/registry";
|
|
3
|
-
import {
|
|
3
|
+
import type { HelpAccessLevel, HelpKind } from "../../shared/help";
|
|
4
|
+
import { ensureSchema, toPgTextArray } from "../postgres";
|
|
4
5
|
import type { HelpCorpus, HelpMetadata } from "./types";
|
|
5
6
|
|
|
6
7
|
// One expired app lease of grace after an app can no longer be discovered.
|
|
@@ -26,6 +27,15 @@ export const migrateHelp = async (db: SQL = sql): Promise<void> => {
|
|
|
26
27
|
PRIMARY KEY (app_id, manifest_hash, locale, document_id),
|
|
27
28
|
FOREIGN KEY (app_id, manifest_hash) REFERENCES help.corpora ON DELETE CASCADE
|
|
28
29
|
)`.simple();
|
|
30
|
+
const columns = await tx`SELECT column_name FROM information_schema.columns
|
|
31
|
+
WHERE table_schema = 'help' AND table_name = 'documents'
|
|
32
|
+
AND column_name IN ('kind', 'needs', 'next_id', 'related')`;
|
|
33
|
+
if (columns.length < 4)
|
|
34
|
+
await tx`ALTER TABLE help.documents
|
|
35
|
+
ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'task',
|
|
36
|
+
ADD COLUMN IF NOT EXISTS needs text,
|
|
37
|
+
ADD COLUMN IF NOT EXISTS next_id text,
|
|
38
|
+
ADD COLUMN IF NOT EXISTS related text[] NOT NULL DEFAULT '{}'`.simple();
|
|
29
39
|
await tx`CREATE INDEX IF NOT EXISTS help_search_idx ON help.documents USING gin(search_document)`.simple();
|
|
30
40
|
});
|
|
31
41
|
// The operator enables the extension; Help never installs server extensions.
|
|
@@ -63,9 +73,9 @@ export const registerHelp = async (corpus: HelpCorpus, db: SQL = sql): Promise<v
|
|
|
63
73
|
for (const document of documents) {
|
|
64
74
|
const text = `${document.title}\n${document.description ?? ""}\n${document.searchText}`;
|
|
65
75
|
await tx`INSERT INTO help.documents(app_id, manifest_hash, locale, document_id, title, description, icon,
|
|
66
|
-
sort_order, markdown, search_text, search_config, search_document)
|
|
76
|
+
sort_order, kind, needs, next_id, related, markdown, search_text, search_config, search_document)
|
|
67
77
|
VALUES (${corpus.appId}, ${corpus.manifestHash}, ${locale}, ${document.id}, ${document.title},
|
|
68
|
-
${document.description ?? null}, ${document.icon ?? null}, ${document.order}, ${document.markdown}, ${text}, ${config},
|
|
78
|
+
${document.description ?? null}, ${document.icon ?? null}, ${document.order}, ${document.kind}, ${document.needs ?? null}, ${document.next ?? null}, ${toPgTextArray([...document.related])}::text[], ${document.markdown}, ${text}, ${config},
|
|
69
79
|
setweight(to_tsvector(${config}::regconfig, ${document.id + " " + document.title}), 'A') ||
|
|
70
80
|
setweight(to_tsvector(${config}::regconfig, ${document.description ?? ""}), 'B') ||
|
|
71
81
|
setweight(to_tsvector(${config}::regconfig, ${document.searchText}), 'D'))`;
|
|
@@ -97,6 +107,10 @@ type Row = {
|
|
|
97
107
|
description: string | null;
|
|
98
108
|
icon: string | null;
|
|
99
109
|
sort_order: number;
|
|
110
|
+
kind: HelpKind;
|
|
111
|
+
needs: HelpAccessLevel | null;
|
|
112
|
+
next_id: string | null;
|
|
113
|
+
related: string[];
|
|
100
114
|
markdown?: string;
|
|
101
115
|
};
|
|
102
116
|
export const metadata = (row: Row): HelpMetadata => ({
|
|
@@ -109,6 +123,10 @@ export const metadata = (row: Row): HelpMetadata => ({
|
|
|
109
123
|
description: row.description ?? undefined,
|
|
110
124
|
icon: row.icon ?? undefined,
|
|
111
125
|
order: row.sort_order,
|
|
126
|
+
kind: row.kind,
|
|
127
|
+
needs: row.needs ?? undefined,
|
|
128
|
+
next: row.next_id ?? undefined,
|
|
129
|
+
related: row.related,
|
|
112
130
|
});
|
|
113
131
|
|
|
114
132
|
export const hasHelpBm25 = async (db: SQL = sql): Promise<boolean> => {
|
|
@@ -130,6 +148,7 @@ export const queryHelp = async (
|
|
|
130
148
|
limit?: number;
|
|
131
149
|
cursor?: string;
|
|
132
150
|
bm25?: boolean;
|
|
151
|
+
references?: readonly string[];
|
|
133
152
|
},
|
|
134
153
|
db: SQL = sql,
|
|
135
154
|
): Promise<Row[]> => {
|
|
@@ -145,10 +164,11 @@ export const queryHelp = async (
|
|
|
145
164
|
const pending = db.unsafe<Row[]>(
|
|
146
165
|
`WITH active AS (
|
|
147
166
|
SELECT * FROM jsonb_to_recordset(($1::text)::jsonb) AS a(app_id text, app_name text, manifest_hash text, locales text[])
|
|
148
|
-
) SELECT d.app_id, a.app_name, d.manifest_hash, d.locale, d.document_id, d.title, d.description, d.icon, d.sort_order
|
|
167
|
+
) SELECT d.app_id, a.app_name, d.manifest_hash, d.locale, d.document_id, d.title, d.description, d.icon, d.sort_order, d.kind, d.needs, d.next_id, d.related
|
|
149
168
|
${input.body ? ", d.markdown" : ""}
|
|
150
169
|
FROM help.documents d JOIN active a ON a.app_id=d.app_id AND a.manifest_hash=d.manifest_hash
|
|
151
170
|
WHERE length($2::text) >= 0 AND d.locale=ANY(a.locales)
|
|
171
|
+
AND ($5::text[] IS NULL OR (d.app_id || '/' || d.document_id)=ANY($5::text[]))
|
|
152
172
|
AND NOT EXISTS (SELECT 1 FROM help.documents preferred
|
|
153
173
|
WHERE preferred.app_id=d.app_id AND preferred.manifest_hash=d.manifest_hash AND preferred.document_id=d.document_id
|
|
154
174
|
AND preferred.locale=ANY(a.locales) AND array_position(a.locales, preferred.locale)<array_position(a.locales, d.locale))
|
|
@@ -162,12 +182,13 @@ export const queryHelp = async (
|
|
|
162
182
|
: ""
|
|
163
183
|
}
|
|
164
184
|
d.app_id COLLATE "C", ${input.cursor !== undefined ? "" : "d.sort_order,"} d.document_id COLLATE "C"
|
|
165
|
-
${input.limit !== undefined ? "LIMIT $
|
|
185
|
+
${input.limit !== undefined ? "LIMIT $6" : ""}`,
|
|
166
186
|
[
|
|
167
187
|
JSON.stringify(selection),
|
|
168
188
|
input.query ?? "",
|
|
169
189
|
input.documentId ?? null,
|
|
170
190
|
input.cursor ?? null,
|
|
191
|
+
input.references ? toPgTextArray([...input.references]) : null,
|
|
171
192
|
...(input.limit !== undefined ? [input.limit] : []),
|
|
172
193
|
],
|
|
173
194
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AppRegistryHelpSummary } from "../../contracts/registry";
|
|
2
2
|
import type { RequestActor } from "../../contracts/shared";
|
|
3
|
-
import type { HelpDocumentManifest } from "../../shared/help";
|
|
3
|
+
import type { HelpAccessLevel, HelpArticleLink, HelpDocumentManifest, HelpKind } from "../../shared/help";
|
|
4
4
|
|
|
5
5
|
export type HelpDocument = {
|
|
6
6
|
id: string;
|
|
@@ -8,6 +8,10 @@ export type HelpDocument = {
|
|
|
8
8
|
icon?: string;
|
|
9
9
|
description?: string;
|
|
10
10
|
order: number;
|
|
11
|
+
kind: HelpKind;
|
|
12
|
+
needs?: HelpAccessLevel;
|
|
13
|
+
next?: string;
|
|
14
|
+
related: readonly string[];
|
|
11
15
|
markdown: string;
|
|
12
16
|
searchText: string;
|
|
13
17
|
};
|
|
@@ -30,8 +34,12 @@ export type HelpMetadata = {
|
|
|
30
34
|
description?: string;
|
|
31
35
|
icon?: string;
|
|
32
36
|
order: number;
|
|
37
|
+
kind: HelpKind;
|
|
38
|
+
needs?: HelpAccessLevel;
|
|
39
|
+
next?: string;
|
|
40
|
+
related: readonly string[];
|
|
33
41
|
};
|
|
34
|
-
export type HelpArticle = HelpMetadata & { markdown: string };
|
|
42
|
+
export type HelpArticle = HelpMetadata & { markdown: string; links: Readonly<Record<string, HelpArticleLink>> };
|
|
35
43
|
export type HelpMatch = Pick<HelpMetadata, "appId" | "appName" | "locale" | "documentId" | "title" | "description"> & { kind: "help" };
|
|
36
44
|
export type HelpSearchInput = { query: string; appId?: string; limit?: number };
|
|
37
45
|
export type HelpReadInput = { appId: string; documentId: string };
|
package/src/services/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { accountIdentities, AccountIdentityError } from "./accounts/identities";
|
|
|
8
8
|
export type { AccountIdentityUser, AccountIdentityGroup, AccountIdentityPage, AccountIdentityAvailability } from "./accounts/identities";
|
|
9
9
|
export type { AccountIdentityReconciliation } from "./accounts/identity-reconciliation";
|
|
10
10
|
export { providers } from "./providers";
|
|
11
|
+
export { createAllowedNetworkFetch } from "./allowed-network-fetch";
|
|
11
12
|
export { requestPublicHttps, type PublicHttpInput } from "./public-http";
|
|
12
13
|
export { authFlows } from "./auth-flows";
|
|
13
14
|
export { toPgIntArray, toPgTextArray, toPgUuidArray, escapeLikePattern, isUniqueViolation } from "./postgres";
|
|
@@ -113,8 +114,8 @@ export type { AccountLifecycleNotificationSender } from "./account-lifecycle/not
|
|
|
113
114
|
export { lifecycleJobs } from "./account-lifecycle/scheduler";
|
|
114
115
|
|
|
115
116
|
export { settings } from "./settings/namespace";
|
|
116
|
-
export { loadCache, get, set, remove, getAll } from "./settings";
|
|
117
|
-
export type { SettingEntry } from "./settings";
|
|
117
|
+
export { loadCache, get, read, readStates, set, remove, getAll, SettingUnreadableError } from "./settings";
|
|
118
|
+
export type { SettingEntry, SettingRead, StoredSettingState } from "./settings";
|
|
118
119
|
export { SETTINGS, SETTINGS_MAP, registerSettings } from "./settings/defaults";
|
|
119
120
|
export { validateSettingValue, normalizeSettingValue, getSettingLabel } from "./settings/defaults";
|
|
120
121
|
export type { SettingDef, SettingKind, SettingOption } from "./settings/defaults";
|
|
@@ -163,6 +164,9 @@ export {
|
|
|
163
164
|
renderHtmlToPdfWithConfig,
|
|
164
165
|
renderTemplatePdfPreview,
|
|
165
166
|
testGotenberg,
|
|
167
|
+
preparePdfImage,
|
|
168
|
+
PDF_IMAGE_MAX_INPUT_BYTES,
|
|
169
|
+
PDF_IMAGE_MAX_BOXES,
|
|
166
170
|
} from "./pdf";
|
|
167
171
|
export type {
|
|
168
172
|
BuildPresetPdfHtmlInput,
|
|
@@ -183,6 +187,12 @@ export type {
|
|
|
183
187
|
TemplatePdfPreviewError,
|
|
184
188
|
TemplatePdfPreviewPhase,
|
|
185
189
|
TemplatePdfPreviewResult,
|
|
190
|
+
PdfImageBox,
|
|
191
|
+
PdfImageVariant,
|
|
192
|
+
PdfImageErrorCode,
|
|
193
|
+
PreparePdfImageInput,
|
|
194
|
+
PreparePdfImageOptions,
|
|
195
|
+
PreparePdfImageResult,
|
|
186
196
|
} from "./pdf";
|
|
187
197
|
|
|
188
198
|
export { createSyncOpsRoutes, SYNC_OPS_DEAD_LETTER_LIMIT } from "./sync-ops";
|
|
@@ -197,7 +207,16 @@ export type {
|
|
|
197
207
|
|
|
198
208
|
export { latestTopicCursor } from "./topic-cursor";
|
|
199
209
|
export { readAccountCategoryPolicy, isAccountCategoryAllowed } from "./account-category-policy";
|
|
200
|
-
export {
|
|
210
|
+
export {
|
|
211
|
+
AppApprovalError,
|
|
212
|
+
appApproval,
|
|
213
|
+
type AppDeviceAdministrator,
|
|
214
|
+
type AppDeviceEnrollmentNotice,
|
|
215
|
+
type AppApprovalConfig,
|
|
216
|
+
type AppTerminalLogins,
|
|
217
|
+
type AppTerminalLoginInput,
|
|
218
|
+
readAppApprovalConfig,
|
|
219
|
+
} from "./app-approval";
|
|
201
220
|
export { PwaError, pwaDevices, type PwaDeviceAdministrator, type PwaDevicePairedNotice } from "./pwa-devices";
|
|
202
221
|
export { legalConsent } from "./legal-consent";
|
|
203
222
|
|
|
@@ -7,6 +7,7 @@ import { isSafeNotificationTargetHref } from "../../contracts/notification-types
|
|
|
7
7
|
import { type BrowserPushSubscription, BrowserPushSubscriptionSchema } from "../../contracts/user-notifications";
|
|
8
8
|
import { decryptSecret, encryptSecret } from "../secrets";
|
|
9
9
|
import { coreSettings } from "../settings/api";
|
|
10
|
+
import { SettingUnreadableError } from "../settings/store";
|
|
10
11
|
import { type NotificationDestination, registerNotificationChannel } from "./channels";
|
|
11
12
|
import { NOTIFICATION_PREVIEW_LIMIT } from "./preview";
|
|
12
13
|
import { sendPinnedWebPush } from "./web-push-transport";
|
|
@@ -76,10 +77,16 @@ const ensureVapidConfig = (): Promise<{ publicKey: string; privateKey: string }>
|
|
|
76
77
|
vapidConfigPromise = sql
|
|
77
78
|
.begin(async (tx) => {
|
|
78
79
|
await tx`SELECT pg_advisory_xact_lock(hashtextextended('notifications:web-push-vapid', 0))`;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const keys = ["notifications.web_push_public_key", "notifications.web_push_private_key"] as const;
|
|
81
|
+
const states = await Promise.all(keys.map((key) => coreSettings.read<string>(key)));
|
|
82
|
+
for (const [index, state] of states.entries()) {
|
|
83
|
+
if (state.stored === "unreadable") {
|
|
84
|
+
const key = keys[index]!;
|
|
85
|
+
console.warn(`[notifications] cannot read setting "${key}"; keeping stored VAPID keys unchanged`);
|
|
86
|
+
throw new SettingUnreadableError(key);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let [publicKey, privateKey] = states.map((state) => state.value);
|
|
83
90
|
if (!publicKey || !privateKey) {
|
|
84
91
|
const generated = webpush.generateVAPIDKeys();
|
|
85
92
|
publicKey = generated.publicKey;
|
|
@@ -240,7 +247,11 @@ unregisterDriver = registerNotificationChannel(browserDriver);
|
|
|
240
247
|
export const browserNotifications = {
|
|
241
248
|
start: async (): Promise<void> => {
|
|
242
249
|
unregisterDriver ??= registerNotificationChannel(browserDriver);
|
|
243
|
-
|
|
250
|
+
try {
|
|
251
|
+
await ensureWebPushConfigured();
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (!(error instanceof SettingUnreadableError)) throw error;
|
|
254
|
+
}
|
|
244
255
|
},
|
|
245
256
|
stop: (): void => {
|
|
246
257
|
unregisterDriver?.();
|