@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.
- package/package.json +6 -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/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/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +10 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- 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 +396 -66
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +28 -2
- 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/index.ts +2 -1
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +7 -2
- 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/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/index.ts +21 -3
- package/src/services/notifications/browser.ts +16 -5
- 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 +19 -17
- 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/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +42 -9
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.tsx +1 -1
- 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 +60 -5
- 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/index.ts +3 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +30 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +4 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +3 -2
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/tokens.css +23 -6
- 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
|
|
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({
|
|
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();
|
package/src/contracts/app.ts
CHANGED
|
@@ -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[];
|
package/src/contracts/shared.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/services/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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?.();
|