@k2b/cloud 0.38.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.
Files changed (99) hide show
  1. package/package.json +6 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/panel-cookie.ts +39 -0
  9. package/src/_internal/registry-validation.ts +21 -26
  10. package/src/_internal/runtime-context.ts +1 -0
  11. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  12. package/src/access/PermissionEditor.tsx +30 -5
  13. package/src/access/ResourceApiKeys.tsx +5 -1
  14. package/src/ai/approval-routes.ts +5 -2
  15. package/src/ai/approvals.ts +139 -28
  16. package/src/ai/browser.ts +10 -0
  17. package/src/ai/chat/action-sentences.ts +1 -44
  18. package/src/ai/chat/turn-view.tsx +35 -3
  19. package/src/ai/chat/view-block.tsx +7 -2
  20. package/src/ai/chat-task-contracts.ts +28 -3
  21. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  22. package/src/ai/chat-task-reasons.ts +92 -0
  23. package/src/ai/chat-tasks.ts +396 -66
  24. package/src/ai/index.ts +12 -0
  25. package/src/ai/migrate.ts +28 -2
  26. package/src/ai/request-context.ts +7 -1
  27. package/src/ai/routes.ts +27 -1
  28. package/src/ai/runtime.ts +12 -27
  29. package/src/ai/store.ts +5 -3
  30. package/src/ai/timeline.ts +9 -1
  31. package/src/ai/tool-wording.ts +45 -0
  32. package/src/ai/turn-abort.ts +33 -0
  33. package/src/ai/types.ts +12 -1
  34. package/src/api/admin-core-settings.ts +190 -139
  35. package/src/api/admin-outgoing-mail.ts +4 -2
  36. package/src/api/app-approval.ts +9 -2
  37. package/src/api/approvals.ts +1 -1
  38. package/src/api/index.ts +2 -1
  39. package/src/browser/CloudResourceSearch.tsx +10 -6
  40. package/src/browser/app-approval.ts +7 -2
  41. package/src/browser/composer-catalog.ts +103 -0
  42. package/src/browser/composer-insert-messages.ts +31 -0
  43. package/src/browser/composer-insert.ts +150 -0
  44. package/src/browser/panel-messages.ts +24 -0
  45. package/src/browser/panel-slot.ts +354 -0
  46. package/src/browser/panel.ts +28 -0
  47. package/src/browser/resource-picker.tsx +12 -1
  48. package/src/contracts/app-approval.ts +49 -9
  49. package/src/contracts/app.ts +14 -0
  50. package/src/contracts/registry.ts +3 -1
  51. package/src/contracts/shared.ts +2 -0
  52. package/src/server/services/access.ts +5 -1
  53. package/src/services/accounts/posix.ts +2 -1
  54. package/src/services/app-approval.ts +158 -38
  55. package/src/services/approvals/device.ts +2 -0
  56. package/src/services/index.ts +21 -3
  57. package/src/services/notifications/browser.ts +16 -5
  58. package/src/services/pdf/image-protocol.ts +135 -0
  59. package/src/services/pdf/image-variants.ts +124 -0
  60. package/src/services/pdf/image-worker.ts +31 -0
  61. package/src/services/pdf/image.ts +109 -0
  62. package/src/services/pdf/index.ts +9 -0
  63. package/src/services/settings/api.ts +11 -6
  64. package/src/services/settings/app.ts +5 -4
  65. package/src/services/settings/core-settings.ts +4 -3
  66. package/src/services/settings/index.ts +19 -17
  67. package/src/services/settings/namespace.ts +2 -1
  68. package/src/services/settings/store.ts +83 -11
  69. package/src/shared/ai-platform-prompt.ts +1 -1
  70. package/src/shared/app-presentation.ts +1 -0
  71. package/src/shared/redirect.ts +12 -0
  72. package/src/ssr/CloudShell.island.tsx +86 -9
  73. package/src/ssr/Layout.tsx +42 -9
  74. package/src/ssr/LayoutHeader.tsx +15 -9
  75. package/src/ssr/LayoutHelp.tsx +1 -1
  76. package/src/ssr/LayoutRail.tsx +2 -2
  77. package/src/ssr/LayoutTabBar.tsx +29 -0
  78. package/src/ssr/PanelLayout.tsx +27 -0
  79. package/src/ssr/PanelRuntime.island.tsx +87 -0
  80. package/src/ssr/PanelSlot.tsx +108 -0
  81. package/src/ssr/PanelTrigger.tsx +49 -0
  82. package/src/ssr/ShellRuntime.island.tsx +60 -5
  83. package/src/ssr/app-appearance.ts +3 -2
  84. package/src/ssr/app-color.ts +100 -0
  85. package/src/ssr/app-launchpad.tsx +18 -1
  86. package/src/ssr/app-navigation.ts +15 -0
  87. package/src/ssr/compact-shell.baseline.json +5270 -0
  88. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  89. package/src/ssr/index.ts +3 -0
  90. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  91. package/src/ssr/platform-messages.ts +30 -0
  92. package/src/ssr/rail-navigation.ts +44 -0
  93. package/src/ssr/shell-data.tsx +4 -0
  94. package/src/ssr/shell-shortcuts.ts +13 -2
  95. package/src/styles/effects.css +3 -2
  96. package/src/styles/global.css +269 -55
  97. package/src/styles/panel.css +373 -0
  98. package/src/styles/tokens.css +23 -6
  99. package/src/styles/utilities-navigation.css +5 -21
@@ -30,7 +30,10 @@ export const APP_ACTION_LIMITS = {
30
30
  valueChars: 300,
31
31
  } as const;
32
32
  /** Commands a Cloud serves beyond the first version, announced in `/info` as `features`. */
33
- export const APP_APPROVAL_FEATURES = ["actions"] as const;
33
+ export const APP_APPROVAL_FEATURES = ["actions", "terminal-logins"] as const;
34
+
35
+ export const APP_APPROVAL_ACCEPT = ["terminal-logins"] as const;
36
+ export type AppApprovalAccept = (typeof APP_APPROVAL_ACCEPT)[number];
34
37
 
35
38
  const Id = z.string().uuid();
36
39
  const Secret = z.string().regex(/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/);
@@ -77,7 +80,17 @@ export const AppDeviceCommandSchema = z.discriminatedUnion("operation", [
77
80
  /** Stores the authenticator's opaque push token for sign-in wake-ups on this device. */
78
81
  z.object({ operation: z.literal("push"), token: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict(),
79
82
  /** Pending sign-ins and the newest open Action approvals of the signing device's account in one call. */
80
- z.object({ operation: z.literal("inbox") }).strict(),
83
+ z
84
+ .object({
85
+ operation: z.literal("inbox"),
86
+ accept: z
87
+ .array(z.enum(APP_APPROVAL_ACCEPT))
88
+ .min(1)
89
+ .max(8)
90
+ .refine((values) => new Set(values).size === values.length)
91
+ .optional(),
92
+ })
93
+ .strict(),
81
94
  /** One open Action approval as the device shows it, with the digest the device recomputes before it decides. */
82
95
  z.object({ operation: z.literal("action"), actionId: Id }).strict(),
83
96
  /** Decides an Action approval. `digest` binds the decision to exactly the view the person saw. */
@@ -121,6 +134,7 @@ export const appDeviceProofMessage = (proof: AppDeviceProof): string => {
121
134
  c.operation,
122
135
  ...(c.operation === "decide" ? [c.requestId, c.challenge, c.comparison, c.decision] : []),
123
136
  ...(c.operation === "push" ? [c.token] : []),
137
+ ...(c.operation === "inbox" && c.accept !== undefined ? [c.accept] : []),
124
138
  ...(c.operation === "action" ? [c.actionId] : []),
125
139
  ...(c.operation === "act" ? [c.actionId, c.digest, c.decision] : []),
126
140
  ]);
@@ -151,8 +165,40 @@ export const AppDeviceViewSchema = z
151
165
  assisted: z.boolean(),
152
166
  })
153
167
  .strict();
168
+ /** Plain text on one line: no control, format or bidirectional characters, so an app cannot fake Cloud's own frame. */
169
+ const PlainText = (max: number) =>
170
+ z
171
+ .string()
172
+ .refine((value) => Array.from(value).length <= max, `Must contain at most ${max} code points`)
173
+ .regex(/^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}]*$/u);
174
+ export const AppTerminalLoginSchema = z
175
+ .object({
176
+ client: z
177
+ .object({
178
+ // Bound encoded bytes including escapes, so five complete consent rows fit the response budget.
179
+ id: PlainText(256)
180
+ .min(1)
181
+ .refine((value) => new TextEncoder().encode(JSON.stringify(value)).length <= 258),
182
+ name: PlainText(APP_ACTION_LIMITS.nameChars).min(1),
183
+ })
184
+ .strict(),
185
+ scopes: z
186
+ .array(z.string().regex(/^[a-z][a-z0-9_:.-]{0,39}$/))
187
+ .min(1)
188
+ .max(16),
189
+ lifetimeSeconds: z.number().int().positive(),
190
+ })
191
+ .strict();
192
+ export type AppTerminalLogin = z.infer<typeof AppTerminalLoginSchema>;
154
193
  export const AppPendingLoginSchema = z
155
- .object({ requestId: Id, challenge: Secret, comparison: Comparison, createdAt: DateTime, expiresAt: DateTime })
194
+ .object({
195
+ requestId: Id,
196
+ challenge: Secret,
197
+ comparison: Comparison,
198
+ createdAt: DateTime,
199
+ expiresAt: DateTime,
200
+ terminal: AppTerminalLoginSchema.optional(),
201
+ })
156
202
  .strict();
157
203
  export const AppPairingPayloadSchema = z
158
204
  .object({ protocol: z.literal(APP_APPROVAL_PROTOCOL), issuer: z.string().url(), pairingId: Id, secret: Secret, expiresAt: DateTime })
@@ -215,12 +261,6 @@ export const AppDeviceAccountSchema = z
215
261
  })
216
262
  .strict();
217
263
  export type AppDeviceAccount = z.infer<typeof AppDeviceAccountSchema>;
218
- /** Plain text on one line: no control, format or bidirectional characters, so an app cannot fake Cloud's own frame. */
219
- const PlainText = (max: number) =>
220
- z
221
- .string()
222
- .refine((value) => Array.from(value).length <= max, `Must contain at most ${max} code points`)
223
- .regex(/^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}]*$/u);
224
264
  const ActionApp = z
225
265
  .object({ id: PlainText(APP_ACTION_LIMITS.nameChars).min(1), name: PlainText(APP_ACTION_LIMITS.nameChars).min(1) })
226
266
  .strict();
@@ -58,6 +58,7 @@ export type AppAdminNavigationGroup = {
58
58
 
59
59
  export type AppPresentationTranslation = {
60
60
  name?: string;
61
+ panelLabel?: string;
61
62
  description?: string;
62
63
  /** Group labels keyed by `adminNav[].id`. */
63
64
  adminGroups?: Readonly<Record<string, string>>;
@@ -107,6 +108,18 @@ export type AppPwaPart = {
107
108
  requiresRoles?: Role[];
108
109
  };
109
110
 
111
+ /** One same-origin panel page contributed by an application. Routes and services still authorize. */
112
+ export type AppPanel = {
113
+ /** GET page below the app's routes, rendered with PanelLayout. */
114
+ href: string;
115
+ /** Panel name in the app's base locale; translated with presentation.translations[locale].panelLabel. */
116
+ label: string;
117
+ /** Icon class, for example "ti ti-sparkles". */
118
+ icon: string;
119
+ /** Coarse visibility like nav.requiresRoles; the slot grants no access. */
120
+ requiresRoles?: Role[];
121
+ };
122
+
110
123
  export type AppMeta = {
111
124
  platformPermissions?: readonly PlatformPermission[];
112
125
  id: string;
@@ -142,6 +155,7 @@ export type AppMeta = {
142
155
  searchLinks?: readonly AppSearchLink[];
143
156
  /** The app's pages in the installable mobile app (preview), at `href` = `/pwa/<id>`. */
144
157
  pwa?: AppPwaPart;
158
+ panel?: AppPanel;
145
159
  /**
146
160
  * Dashboard widget endpoints this app exposes. Each entry references an
147
161
  * HTTP endpoint that returns a `WidgetResponse` (see `contracts/widgets.ts`).
@@ -1,4 +1,4 @@
1
- import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPresentationCatalog, AppSearchLink } from "./app";
1
+ import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPanel, AppPresentationCatalog, AppSearchLink } from "./app";
2
2
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
3
3
  import type { PlatformPermission } from "./outgoing-mail";
4
4
  import type { Role } from "./shared";
@@ -103,6 +103,8 @@ export type AppRegistryEntry = {
103
103
  searchLinks?: readonly AppSearchLink[];
104
104
  /** The app's part of the mobile app (preview); `href` is `/pwa/<id>`. Older readers ignore it. */
105
105
  pwa?: AppRegistryPwaPart;
106
+ /** Optional panel declaration; older readers ignore it. */
107
+ panel?: AppPanel;
106
108
  widgets?: AppRegistryWidget[];
107
109
  /** Setting keys declared by this app. Used by admin tooling to avoid treating live app-owned settings as legacy. */
108
110
  settingKeys?: readonly string[];
@@ -401,6 +401,8 @@ export type SettingEntry = {
401
401
  valueSource: SettingValueSource;
402
402
  resetValueSource: Exclude<SettingValueSource, "custom">;
403
403
  isCustom: boolean;
404
+ /** A stored row exists but this application cannot decrypt or validate it; `value` is the fallback in use. */
405
+ unreadable: boolean;
404
406
  templateVars?: string[];
405
407
  options?: SettingOption[];
406
408
  min?: number;
@@ -629,7 +629,11 @@ export const listUsersWithAccess = async (params: {
629
629
  OR LOWER(uid) LIKE ${pattern} ESCAPE '\\'
630
630
  OR LOWER(group_names) LIKE ${pattern} ESCAPE '\\'
631
631
  )
632
- ORDER BY LOWER(display_name), id
632
+ -- A limit must never hide an exact login-name match.
633
+ ORDER BY
634
+ CASE WHEN LOWER(uid) = ${query || null}::text THEN 0 ELSE 1 END,
635
+ CASE WHEN LOWER(display_name) = ${query || null}::text THEN 0 ELSE 1 END,
636
+ LOWER(display_name), id
633
637
  LIMIT ${limit}
634
638
  `;
635
639
 
@@ -329,7 +329,8 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
329
329
  // Same order as provisioning; settings write upgrades this lock in the same transaction.
330
330
  await lock(tx);
331
331
  if (parsed.data.enabled) await checkRange(tx, parsed.data, ranges);
332
- await settings.set(CONFIG_KEY, JSON.stringify(parsed.data), tx);
332
+ // complete value from explicit admin input; nothing is merged over a read (#1303)
333
+ await settings.set(CONFIG_KEY, JSON.stringify(parsed.data), tx, { replaceUnreadable: true });
333
334
  await audit.record(
334
335
  {
335
336
  action: "accounts.linux.configure",
@@ -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
- // Bundle Action wake-ups for 30 seconds while the previously woken request is still open.
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 ACTION_WAKE_SECONDS = 30;
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 }[]>`SELECT DISTINCT push_token FROM auth.app_devices
275
- WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL LIMIT ${limits.devicesPerAccount}`;
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(async ({ push_token: token }) => {
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() - ${ACTION_WAKE_SECONDS} * interval '1 second'
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
- const candidates = await tx<
509
- AccountRow[]
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
- deviceCommand: async (request: AppDeviceRequest, actions: AppDeviceActions) => {
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 requests = rows.map(
587
- (row): AppPendingLogin => ({
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
- await tx`UPDATE auth.app_logins SET state=${state},approved_by=${device.id}::uuid WHERE id=${row.id}::uuid`;
617
- await record(tx, `login.${command.decision}`, device.user_id, device.id, { requestId: row.id });
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<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
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<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
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,
@@ -114,8 +114,8 @@ export type { AccountLifecycleNotificationSender } from "./account-lifecycle/not
114
114
  export { lifecycleJobs } from "./account-lifecycle/scheduler";
115
115
 
116
116
  export { settings } from "./settings/namespace";
117
- export { loadCache, get, set, remove, getAll } from "./settings";
118
- 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";
119
119
  export { SETTINGS, SETTINGS_MAP, registerSettings } from "./settings/defaults";
120
120
  export { validateSettingValue, normalizeSettingValue, getSettingLabel } from "./settings/defaults";
121
121
  export type { SettingDef, SettingKind, SettingOption } from "./settings/defaults";
@@ -164,6 +164,9 @@ export {
164
164
  renderHtmlToPdfWithConfig,
165
165
  renderTemplatePdfPreview,
166
166
  testGotenberg,
167
+ preparePdfImage,
168
+ PDF_IMAGE_MAX_INPUT_BYTES,
169
+ PDF_IMAGE_MAX_BOXES,
167
170
  } from "./pdf";
168
171
  export type {
169
172
  BuildPresetPdfHtmlInput,
@@ -184,6 +187,12 @@ export type {
184
187
  TemplatePdfPreviewError,
185
188
  TemplatePdfPreviewPhase,
186
189
  TemplatePdfPreviewResult,
190
+ PdfImageBox,
191
+ PdfImageVariant,
192
+ PdfImageErrorCode,
193
+ PreparePdfImageInput,
194
+ PreparePdfImageOptions,
195
+ PreparePdfImageResult,
187
196
  } from "./pdf";
188
197
 
189
198
  export { createSyncOpsRoutes, SYNC_OPS_DEAD_LETTER_LIMIT } from "./sync-ops";
@@ -198,7 +207,16 @@ export type {
198
207
 
199
208
  export { latestTopicCursor } from "./topic-cursor";
200
209
  export { readAccountCategoryPolicy, isAccountCategoryAllowed } from "./account-category-policy";
201
- export { AppApprovalError, appApproval, type AppDeviceAdministrator, type AppDeviceEnrollmentNotice } from "./app-approval";
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";
202
220
  export { PwaError, pwaDevices, type PwaDeviceAdministrator, type PwaDevicePairedNotice } from "./pwa-devices";
203
221
  export { legalConsent } from "./legal-consent";
204
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
- let [publicKey, privateKey] = await Promise.all([
80
- coreSettings.get<string>("notifications.web_push_public_key"),
81
- coreSettings.get<string>("notifications.web_push_private_key"),
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
- await ensureWebPushConfigured();
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?.();