@k2b/cloud 0.23.0 → 0.25.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 +3 -2
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +12 -3
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +1 -1
- package/src/_internal/registry.ts +78 -3
- package/src/access/PermissionEditor.tsx +32 -11
- package/src/access/managers.ts +32 -0
- package/src/access/messages.ts +6 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +65 -20
- package/src/ai/client/transport.ts +5 -5
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/memory-learning.ts +7 -12
- package/src/ai/memory-workflow-evidence.ts +10 -2
- package/src/ai/migrate.ts +80 -18
- package/src/ai/model-access.ts +4 -2
- package/src/ai/prefs.ts +19 -5
- package/src/ai/projects.ts +5 -2
- package/src/ai/runtime.ts +1 -6
- package/src/ai/skills.ts +5 -2
- package/src/ai/solid.ts +0 -5
- package/src/ai/store.ts +14 -0
- package/src/api/help.ts +2 -2
- package/src/api/pwa-phone.ts +10 -3
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- package/src/browser/FileChooser.tsx +600 -0
- package/src/browser/choose-files.browser-harness.tsx +32 -0
- package/src/browser/choose-files.tsx +70 -0
- package/src/browser/file-chooser-messages.ts +86 -0
- package/src/browser/file-providers.ts +217 -0
- package/src/browser/files.tsx +23 -0
- package/src/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/cli/admin/account-administration.ts +2 -2
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +1 -0
- package/src/events/live-engine.ts +11 -7
- package/src/events/live.ts +32 -32
- package/src/server/help.ts +3 -3
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +44 -11
- package/src/server/services/index.ts +1 -0
- package/src/services/index.ts +1 -1
- package/src/services/outbox.ts +29 -115
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +74 -8
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -14
- package/src/ssr/PwaLayout.tsx +2 -1
- package/src/ssr/platform-messages.ts +2 -2
- package/src/styles/file-chooser.css +169 -0
- package/src/styles/global.css +1 -0
- package/src/styles/resource-search.css +19 -0
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- package/src/shared/markdown/extensions/info-blocks.ts +0 -79
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
2
2
|
import { type SQLQuery, sql } from "bun";
|
|
3
|
+
import { type ManagerCandidate, removesLastManager } from "../../access/managers";
|
|
4
|
+
import { accessMessages } from "../../access/messages";
|
|
3
5
|
import { recursiveGroupIdsSubquery } from "../../services/accounts/group-sql";
|
|
4
6
|
import { toPgUuidArray } from "../../services/postgres";
|
|
5
7
|
import type { ServiceAccountKind } from "../../services/service-accounts";
|
|
@@ -238,6 +240,31 @@ export const updateAccess = async (params: { id: string; permission: PermissionL
|
|
|
238
240
|
return ok();
|
|
239
241
|
};
|
|
240
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Refuse a grant change that would leave a resource without a manager.
|
|
245
|
+
*
|
|
246
|
+
* Pass the resource's entries before and after the change, read in the
|
|
247
|
+
* transaction that writes it after locking the resource, so concurrent changes
|
|
248
|
+
* see each other. A manager is an `admin` entry for a user, a group, all
|
|
249
|
+
* signed-in users, or a standalone or agent service account; service-account
|
|
250
|
+
* entries need their `serviceAccountKind`. Only a change from at least one
|
|
251
|
+
* manager to none fails, with status 409 and code `LAST_MANAGER`. An app whose
|
|
252
|
+
* own precedence lets another entry shadow `admin` drops the shadowed entries
|
|
253
|
+
* from both lists first. An app whose permission editor names `admin`
|
|
254
|
+
* differently passes that localized name as `level`, so the message uses the
|
|
255
|
+
* word the person sees; it defaults to the editor's “Manage”.
|
|
256
|
+
*/
|
|
257
|
+
export const ensureManagerRemains = (params: {
|
|
258
|
+
before: readonly ManagerCandidate[];
|
|
259
|
+
after: readonly ManagerCandidate[];
|
|
260
|
+
locale?: string | null;
|
|
261
|
+
level?: string;
|
|
262
|
+
}): Result<void> => {
|
|
263
|
+
if (!removesLastManager(params.before, params.after)) return ok();
|
|
264
|
+
const { t } = accessMessages.resolve(params.locale ? [params.locale] : []);
|
|
265
|
+
return fail({ code: "LAST_MANAGER", message: t.lastManager({ level: params.level ?? t.manage }), status: 409 });
|
|
266
|
+
};
|
|
267
|
+
|
|
241
268
|
/** Resolve direct and nested group memberships from the authoritative database mirror. */
|
|
242
269
|
export const getEffectiveGroups = async (params: { userId: string | null }, db: AccessDb = sql): Promise<EffectiveGroup[]> => {
|
|
243
270
|
if (!params.userId) return [];
|
|
@@ -337,17 +364,23 @@ export const buildAccessPrincipalCondition = (params: { subject: AccessSubject |
|
|
|
337
364
|
* - Direct and nested user group memberships resolved from the database
|
|
338
365
|
* - Authenticated access for every authenticated subject
|
|
339
366
|
* - Public access for every subject, including anonymous callers
|
|
367
|
+
*
|
|
368
|
+
* Pass the transaction that locked the resource as `db` to authorize a change
|
|
369
|
+
* against the grants it writes.
|
|
340
370
|
*/
|
|
341
|
-
export const getEffectivePermission = async (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
371
|
+
export const getEffectivePermission = async (
|
|
372
|
+
params: {
|
|
373
|
+
accessIds: string[];
|
|
374
|
+
subject?: AccessSubject | null;
|
|
375
|
+
/** @deprecated Pass subject instead. */
|
|
376
|
+
userId?: string | null;
|
|
377
|
+
/** @deprecated Membership is resolved from userId and this value is intentionally ignored. */
|
|
378
|
+
userGroups?: string[];
|
|
379
|
+
/** @deprecated Pass subject instead. */
|
|
380
|
+
serviceAccountId?: string | null;
|
|
381
|
+
},
|
|
382
|
+
db: AccessDb = sql,
|
|
383
|
+
): Promise<PermissionLevel> => {
|
|
351
384
|
const accessIds = params.accessIds ?? [];
|
|
352
385
|
const subject =
|
|
353
386
|
"subject" in params
|
|
@@ -376,7 +409,7 @@ export const getEffectivePermission = async (params: {
|
|
|
376
409
|
},
|
|
377
410
|
});
|
|
378
411
|
|
|
379
|
-
const rows = await
|
|
412
|
+
const rows = await db<{ permission: PermissionLevel }[]>`
|
|
380
413
|
SELECT a.permission
|
|
381
414
|
FROM auth.access a
|
|
382
415
|
WHERE a.id = ANY(${toPgUuidArray(accessIds)}::uuid[])
|
package/src/services/index.ts
CHANGED
|
@@ -197,7 +197,7 @@ export type {
|
|
|
197
197
|
export { latestTopicCursor } from "./topic-cursor";
|
|
198
198
|
export { readAccountCategoryPolicy, isAccountCategoryAllowed } from "./account-category-policy";
|
|
199
199
|
export { AppApprovalError, appApproval, type AppDeviceAdministrator, type AppDeviceEnrollmentNotice } from "./app-approval";
|
|
200
|
-
export { PwaError, pwaDevices, type PwaDeviceAdministrator } from "./pwa-devices";
|
|
200
|
+
export { PwaError, pwaDevices, type PwaDeviceAdministrator, type PwaDevicePairedNotice } from "./pwa-devices";
|
|
201
201
|
export { legalConsent } from "./legal-consent";
|
|
202
202
|
|
|
203
203
|
/** Core-owned app bar administration; service methods enforce administrator access. */
|
package/src/services/outbox.ts
CHANGED
|
@@ -5,29 +5,25 @@ import { logger } from "./logging";
|
|
|
5
5
|
* PostgreSQL transactional-outbox dispatcher.
|
|
6
6
|
*
|
|
7
7
|
* Domain writers insert a row in their own transaction; this dispatcher claims
|
|
8
|
-
* pending rows (`FOR UPDATE SKIP LOCKED`), publishes them, and
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* column), and delivered rows are pruned after a retention period.
|
|
8
|
+
* pending rows (`FOR UPDATE SKIP LOCKED`), publishes them, and deletes a
|
|
9
|
+
* published row or schedules an exponential-backoff retry. Every row in the
|
|
10
|
+
* table is pending: rows stay until they are published.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* It works in batches: claims of one outbox (table and `where`) take turns, a
|
|
13
|
+
* claim takes rows in `sequence` order with several rows per ordering key, and
|
|
14
|
+
* the dispatcher publishes keys concurrently and the rows of one key in order,
|
|
15
|
+
* then completes the batch in one statement. A row that never failed is due at
|
|
16
|
+
* once. Claims stay proportional to the batch with an index on the `where`
|
|
17
|
+
* columns and `sequence`, and one on the `where` columns and `orderBy` limited
|
|
18
|
+
* to `claimed_until IS NOT NULL OR attempts > 0`.
|
|
20
19
|
*
|
|
21
20
|
* Required columns: `id uuid`, `attempts int`, `next_attempt_at timestamptz`,
|
|
22
|
-
* `claimed_until timestamptz`, `
|
|
23
|
-
* `
|
|
24
|
-
* `onDelivered: "delete"` needs no `delivered_at`.
|
|
21
|
+
* `claimed_until timestamptz`, `last_error text`, and the `orderBy` and
|
|
22
|
+
* `sequence` columns.
|
|
25
23
|
*/
|
|
26
24
|
|
|
27
25
|
const DEFAULT_CLAIM_MS = 30_000;
|
|
28
26
|
const DEFAULT_BATCH_SIZE = 100;
|
|
29
|
-
const DELIVERED_RETENTION = "7 days";
|
|
30
|
-
const DEAD_RETENTION = "30 days";
|
|
31
27
|
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
|
32
28
|
|
|
33
29
|
export type OutboxRow = {
|
|
@@ -43,20 +39,14 @@ export type PgOutboxConfig<Row extends OutboxRow> = {
|
|
|
43
39
|
publish: (row: Row) => Promise<unknown>;
|
|
44
40
|
reconcileIntervalMs: number;
|
|
45
41
|
/**
|
|
46
|
-
* Column whose rows
|
|
47
|
-
*
|
|
48
|
-
* With `sequence`, a value waits while any of its rows is claimed or waits
|
|
49
|
-
* for a retry.
|
|
42
|
+
* Column whose rows are published in `sequence` order. A value waits while
|
|
43
|
+
* any of its rows is claimed or waits for a retry.
|
|
50
44
|
*/
|
|
51
|
-
orderBy
|
|
45
|
+
orderBy: Extract<keyof Row, string>;
|
|
46
|
+
/** Insertion-order column that orders claims. */
|
|
47
|
+
sequence: string;
|
|
52
48
|
/** Fixed column values every claimed row matches, e.g. `{ kind: "live", app_id: "contacts" }`. */
|
|
53
49
|
where?: Readonly<Record<string, string>>;
|
|
54
|
-
/** Insertion-order column that orders claims instead of `(created_at, id)`, and enables batches (see above). */
|
|
55
|
-
sequence?: string;
|
|
56
|
-
/** Delete a published row instead of setting `delivered_at`; every row in the table is then pending. */
|
|
57
|
-
onDelivered?: "delete";
|
|
58
|
-
/** Mark rows dead after this many failed attempts (requires `dead_at`). */
|
|
59
|
-
maxAttempts?: number;
|
|
60
50
|
claimMs?: number;
|
|
61
51
|
batchSize?: number;
|
|
62
52
|
};
|
|
@@ -79,23 +69,15 @@ const identifier = (name: string, label: string): ReturnType<typeof sql.unsafe>
|
|
|
79
69
|
|
|
80
70
|
export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row>): PgOutbox<Row> => {
|
|
81
71
|
const table = identifier(config.table, "table");
|
|
82
|
-
const
|
|
83
|
-
const
|
|
72
|
+
const runKey = config.orderBy;
|
|
73
|
+
const runColumn = identifier(runKey, "ordering column");
|
|
74
|
+
const sequence = identifier(config.sequence, "sequence column");
|
|
84
75
|
const claimMs = config.claimMs ?? DEFAULT_CLAIM_MS;
|
|
85
76
|
const batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
86
77
|
const log = logger(config.name);
|
|
87
|
-
const sequence = config.sequence ? identifier(config.sequence, "sequence column") : null;
|
|
88
|
-
const deleteDelivered = config.onDelivered === "delete";
|
|
89
|
-
const notDead = maxAttempts === null ? sql`` : sql`AND dead_at IS NULL`;
|
|
90
|
-
const earlierNotDead = maxAttempts === null ? sql`` : sql`AND earlier.dead_at IS NULL`;
|
|
91
|
-
const pending = deleteDelivered ? sql`` : sql`AND delivered_at IS NULL`;
|
|
92
|
-
const earlierPending = deleteDelivered ? sql`` : sql`AND earlier.delivered_at IS NULL`;
|
|
93
78
|
const filters = Object.entries(config.where ?? {}).map(([column, value]) => ({ column: identifier(column, "filter column"), value }));
|
|
94
|
-
const matches = (alias: "current" | "
|
|
79
|
+
const matches = (alias: "current" | "busy") =>
|
|
95
80
|
filters.reduce((fragment, { column, value }) => sql`${fragment} AND ${sql.unsafe(alias)}.${column} = ${value}`, sql``);
|
|
96
|
-
// Rows without an ordering column are independent: each is a key of its own.
|
|
97
|
-
const runKey = config.orderBy ?? "id";
|
|
98
|
-
const runColumn = identifier(runKey, "ordering column");
|
|
99
81
|
const claimLock = `${config.table}:${JSON.stringify(config.where ?? {})}`;
|
|
100
82
|
|
|
101
83
|
/**
|
|
@@ -104,8 +86,9 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
104
86
|
* delays retries: it is the writer's transaction start, so a fresh row that
|
|
105
87
|
* committed later can carry an earlier one, or one after this claim began.
|
|
106
88
|
*/
|
|
107
|
-
const
|
|
89
|
+
const claim: PgOutbox<Row>["claim"] = (limit = batchSize) =>
|
|
108
90
|
sql.begin(async (tx) => {
|
|
91
|
+
const cap = Math.min(Math.max(limit, 1), batchSize);
|
|
109
92
|
// Claims take turns: a concurrent claim would skip this one's locked rows and take later rows of the same keys.
|
|
110
93
|
// A claim that outlives its claim period is worthless, so neither a slow statement nor a vanished client holds the turn longer.
|
|
111
94
|
await tx`
|
|
@@ -121,20 +104,16 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
121
104
|
FROM ${table} current
|
|
122
105
|
WHERE (current.attempts = 0 OR current.next_attempt_at <= now())
|
|
123
106
|
AND (current.claimed_until IS NULL OR current.claimed_until <= now())
|
|
124
|
-
${pending}
|
|
125
|
-
${notDead}
|
|
126
107
|
${matches("current")}
|
|
127
108
|
AND NOT EXISTS (
|
|
128
109
|
SELECT
|
|
129
110
|
FROM ${table} busy
|
|
130
111
|
WHERE busy.${runColumn} = current.${runColumn}
|
|
131
112
|
AND (busy.claimed_until > now() OR (busy.attempts > 0 AND busy.next_attempt_at > now()))
|
|
132
|
-
${pending}
|
|
133
|
-
${notDead}
|
|
134
113
|
${matches("busy")}
|
|
135
114
|
OFFSET 0
|
|
136
115
|
)
|
|
137
|
-
ORDER BY current.${
|
|
116
|
+
ORDER BY current.${sequence}
|
|
138
117
|
LIMIT ${cap}
|
|
139
118
|
FOR UPDATE SKIP LOCKED
|
|
140
119
|
), claimed AS (
|
|
@@ -144,63 +123,21 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
144
123
|
WHERE outbox.id = candidates.id
|
|
145
124
|
RETURNING outbox.*
|
|
146
125
|
)
|
|
147
|
-
SELECT * FROM claimed ORDER BY ${
|
|
126
|
+
SELECT * FROM claimed ORDER BY ${sequence}
|
|
148
127
|
`;
|
|
149
128
|
});
|
|
150
129
|
|
|
151
|
-
const claim: PgOutbox<Row>["claim"] = async (limit = batchSize) => {
|
|
152
|
-
const cap = Math.min(Math.max(limit, 1), batchSize);
|
|
153
|
-
if (sequence !== null) return claimBatch(cap, sequence);
|
|
154
|
-
const ordered =
|
|
155
|
-
orderBy === null
|
|
156
|
-
? sql``
|
|
157
|
-
: sql`
|
|
158
|
-
AND NOT EXISTS (
|
|
159
|
-
SELECT 1
|
|
160
|
-
FROM ${table} earlier
|
|
161
|
-
WHERE earlier.${orderBy} = current.${orderBy}
|
|
162
|
-
${earlierPending}
|
|
163
|
-
${earlierNotDead}
|
|
164
|
-
${matches("earlier")}
|
|
165
|
-
AND (earlier.created_at, earlier.id) < (current.created_at, current.id)
|
|
166
|
-
)
|
|
167
|
-
`;
|
|
168
|
-
// One statement: the CTE claim and the update commit atomically.
|
|
169
|
-
return sql<Row[]>`
|
|
170
|
-
WITH candidates AS MATERIALIZED (
|
|
171
|
-
SELECT current.id
|
|
172
|
-
FROM ${table} current
|
|
173
|
-
WHERE current.next_attempt_at <= now()
|
|
174
|
-
AND (current.claimed_until IS NULL OR current.claimed_until <= now())
|
|
175
|
-
${pending}
|
|
176
|
-
${notDead}
|
|
177
|
-
${matches("current")}
|
|
178
|
-
${ordered}
|
|
179
|
-
ORDER BY current.next_attempt_at, current.created_at, current.id
|
|
180
|
-
LIMIT ${cap}
|
|
181
|
-
FOR UPDATE SKIP LOCKED
|
|
182
|
-
)
|
|
183
|
-
UPDATE ${table} outbox
|
|
184
|
-
SET claimed_until = now() + (${claimMs} * interval '1 millisecond')
|
|
185
|
-
FROM candidates
|
|
186
|
-
WHERE outbox.id = candidates.id
|
|
187
|
-
RETURNING outbox.*
|
|
188
|
-
`;
|
|
189
|
-
};
|
|
190
|
-
|
|
191
130
|
const retryLater = async (row: Row, error: unknown) => {
|
|
192
131
|
const attempts = row.attempts + 1;
|
|
193
132
|
const message = error instanceof Error ? error.message : String(error);
|
|
194
133
|
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
|
195
|
-
const dead = maxAttempts === null ? sql`` : sql`dead_at = CASE WHEN ${attempts} >= ${maxAttempts} THEN now() ELSE dead_at END,`;
|
|
196
134
|
await sql`
|
|
197
135
|
UPDATE ${table}
|
|
198
136
|
SET attempts = ${attempts},
|
|
199
137
|
next_attempt_at = now() + (${delaySeconds} * interval '1 second'),
|
|
200
138
|
claimed_until = NULL,
|
|
201
|
-
${dead}
|
|
202
139
|
last_error = ${message.slice(0, 1_000)}
|
|
203
|
-
WHERE id = ${row.id}::uuid
|
|
140
|
+
WHERE id = ${row.id}::uuid AND attempts = ${row.attempts}
|
|
204
141
|
`;
|
|
205
142
|
log.warn("Outbox delivery failed", { outboxId: row.id, attempts, error: message });
|
|
206
143
|
};
|
|
@@ -208,15 +145,7 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
208
145
|
const dispatch: PgOutbox<Row>["dispatch"] = async (row, publish = config.publish) => {
|
|
209
146
|
try {
|
|
210
147
|
await publish(row);
|
|
211
|
-
|
|
212
|
-
await sql`DELETE FROM ${table} WHERE id = ${row.id}::uuid AND attempts = ${row.attempts}`;
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
await sql`
|
|
216
|
-
UPDATE ${table}
|
|
217
|
-
SET delivered_at = now(), claimed_until = NULL, last_error = NULL
|
|
218
|
-
WHERE id = ${row.id}::uuid ${pending} ${notDead} AND attempts = ${row.attempts}
|
|
219
|
-
`;
|
|
148
|
+
await sql`DELETE FROM ${table} WHERE id = ${row.id}::uuid AND attempts = ${row.attempts}`;
|
|
220
149
|
} catch (error) {
|
|
221
150
|
await retryLater(row, error);
|
|
222
151
|
}
|
|
@@ -241,12 +170,7 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
241
170
|
}
|
|
242
171
|
}),
|
|
243
172
|
);
|
|
244
|
-
if (delivered.length > 0) {
|
|
245
|
-
const ids = sql.array(delivered, "uuid");
|
|
246
|
-
await (deleteDelivered
|
|
247
|
-
? sql`DELETE FROM ${table} WHERE id = ANY(${ids})`
|
|
248
|
-
: sql`UPDATE ${table} SET delivered_at = now(), claimed_until = NULL, last_error = NULL WHERE id = ANY(${ids})`);
|
|
249
|
-
}
|
|
173
|
+
if (delivered.length > 0) await sql`DELETE FROM ${table} WHERE id = ANY(${sql.array(delivered, "uuid")})`;
|
|
250
174
|
for (const result of results) if (result.status === "rejected") throw result.reason;
|
|
251
175
|
};
|
|
252
176
|
|
|
@@ -257,22 +181,12 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
257
181
|
reconcileRequested = true;
|
|
258
182
|
if (activeReconcile) return activeReconcile;
|
|
259
183
|
activeReconcile = (async () => {
|
|
260
|
-
if (!deleteDelivered) {
|
|
261
|
-
const deadExpired = maxAttempts === null ? sql`` : sql`OR dead_at < now() - ${DEAD_RETENTION}::interval`;
|
|
262
|
-
await sql`
|
|
263
|
-
DELETE FROM ${table}
|
|
264
|
-
WHERE delivered_at < now() - ${DELIVERED_RETENTION}::interval
|
|
265
|
-
${deadExpired}
|
|
266
|
-
`;
|
|
267
|
-
}
|
|
268
184
|
let processed = 0;
|
|
269
185
|
let rows: Row[];
|
|
270
186
|
do {
|
|
271
187
|
reconcileRequested = false;
|
|
272
188
|
rows = await claim();
|
|
273
|
-
|
|
274
|
-
// Sequential: across keys, the claim order is the publish order.
|
|
275
|
-
else for (const row of rows) await dispatch(row);
|
|
189
|
+
await dispatchBatch(rows);
|
|
276
190
|
processed += rows.length;
|
|
277
191
|
} while (reconcileRequested || rows.length > 0);
|
|
278
192
|
return processed;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { markdownInfoBlocks } from "@k2b/ui";
|
|
1
2
|
import { Marked, Renderer, type Tokens } from "marked";
|
|
2
3
|
import postcss from "postcss";
|
|
3
4
|
import {
|
|
@@ -122,6 +123,25 @@ hr { border: 0; border-top: 1px solid #d1d5db; margin: .9em 0; }
|
|
|
122
123
|
`,
|
|
123
124
|
};
|
|
124
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Markdown info blocks print as the calm NoticeCard: a light tint per tone, no
|
|
128
|
+
* border, and the preset's own text colour, so they stay readable on paper.
|
|
129
|
+
* The tints are a step stronger than on screen because paper and printers wash
|
|
130
|
+
* them out. Every preset and application document shares them.
|
|
131
|
+
*/
|
|
132
|
+
const INFO_BLOCK_PRINT_CSS = `
|
|
133
|
+
.k2b-sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
134
|
+
.k2b-notice-card { margin: 0 0 1em; padding: .85em 1.05em; border-radius: .8em; background: var(--notice-tint); break-inside: avoid; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
|
135
|
+
.k2b-notice-card[data-tone="neutral"] { --notice-tint: #f1f2f4; }
|
|
136
|
+
.k2b-notice-card[data-tone="info"] { --notice-tint: #e9f1fe; }
|
|
137
|
+
.k2b-notice-card[data-tone="success"] { --notice-tint: #e6f6ee; }
|
|
138
|
+
.k2b-notice-card[data-tone="warning"] { --notice-tint: #fcf2da; }
|
|
139
|
+
.k2b-notice-card[data-tone="danger"] { --notice-tint: #fcebeb; }
|
|
140
|
+
.k2b-notice-card__title { margin: 0 0 .25em; font-weight: 600; }
|
|
141
|
+
.k2b-notice-card__body > :first-child { margin-top: 0; }
|
|
142
|
+
.k2b-notice-card__body > :last-child { margin-bottom: 0; }
|
|
143
|
+
`;
|
|
144
|
+
|
|
125
145
|
const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
|
|
126
146
|
|
|
127
147
|
const escapeHtml = (value: string): string =>
|
|
@@ -156,7 +176,7 @@ const renderMarkdown = (source: string): string => {
|
|
|
156
176
|
return `<a href="${escapeHtml(safeHref)}"${titleAttribute}>${body}</a>`;
|
|
157
177
|
};
|
|
158
178
|
|
|
159
|
-
const parser = new Marked({ breaks: true, gfm: true, renderer });
|
|
179
|
+
const parser = new Marked({ breaks: true, gfm: true, renderer }, markdownInfoBlocks());
|
|
160
180
|
try {
|
|
161
181
|
return parser.parse(source, { async: false }) as string;
|
|
162
182
|
} catch (cause) {
|
|
@@ -216,7 +236,7 @@ export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
|
|
|
216
236
|
const suppliedCustomCss = input.customCss?.trim() ?? "";
|
|
217
237
|
const customCss = suppliedCustomCss ? validateCustomCss(input.customCss ?? "") : "";
|
|
218
238
|
const presetCss = templateId ? TEMPLATE_CSS[templateId] : customCss ? "" : TEMPLATE_CSS.document;
|
|
219
|
-
const baseCss = [presetCss, input.css ? styleText(input.css) : ""].filter(Boolean).join("\n");
|
|
239
|
+
const baseCss = [presetCss, INFO_BLOCK_PRINT_CSS, input.css ? styleText(input.css) : ""].filter(Boolean).join("\n");
|
|
220
240
|
const stylesheet = `${baseCss}${baseCss && customCss ? `\n/* Custom CSS overrides */\n` : ""}${customCss}`;
|
|
221
241
|
return `<!doctype html>
|
|
222
242
|
<html lang="en">
|
|
@@ -4,6 +4,7 @@ import type { Context } from "hono";
|
|
|
4
4
|
import { PWA_LIMITS as limits, type PwaDeviceView, type PwaErrorCode, type PwaPairingState, type PwaPlatform } from "../contracts/pwa";
|
|
5
5
|
import { isAccountCategoryAllowed } from "./account-category-policy";
|
|
6
6
|
import { audit } from "./audit";
|
|
7
|
+
import { logger } from "./logging";
|
|
7
8
|
import { pairingSecret } from "./pairing-secret";
|
|
8
9
|
import { type AuthenticatedSession, session } from "./session";
|
|
9
10
|
import { requireRecentWebSession } from "./session/recent";
|
|
@@ -32,6 +33,19 @@ export type PwaWebActor = { userId: string; sid: string };
|
|
|
32
33
|
/** Administration of another account's phones needs current admin authority. */
|
|
33
34
|
export type PwaDeviceAdministrator = { userId: string; admin: boolean };
|
|
34
35
|
export type PwaRevocationReason = "user" | "unpaired" | "admin" | "replaced" | "account_expired";
|
|
36
|
+
/**
|
|
37
|
+
* The "new phone paired" notice, sent once per device after its pairing committed. It carries no
|
|
38
|
+
* pairing code, secret or key. `locale` is the completing request's locale, if one was stored.
|
|
39
|
+
*/
|
|
40
|
+
export type PwaDevicePairedNotice = {
|
|
41
|
+
deviceId: string;
|
|
42
|
+
userId: string;
|
|
43
|
+
name: string;
|
|
44
|
+
platform: PwaPlatform;
|
|
45
|
+
pairedAt: string;
|
|
46
|
+
locale: string | null;
|
|
47
|
+
};
|
|
48
|
+
export type PwaDeviceNotifier = (notice: PwaDevicePairedNotice) => Promise<unknown>;
|
|
35
49
|
|
|
36
50
|
type PairingRow = {
|
|
37
51
|
id: string;
|
|
@@ -59,6 +73,8 @@ type DeviceRow = {
|
|
|
59
73
|
created_at: Date;
|
|
60
74
|
last_used_at: Date;
|
|
61
75
|
revoked_at: Date | null;
|
|
76
|
+
locale: string | null;
|
|
77
|
+
notified_at: Date | null;
|
|
62
78
|
};
|
|
63
79
|
type UserRow = {
|
|
64
80
|
id: string;
|
|
@@ -70,6 +86,7 @@ type UserRow = {
|
|
|
70
86
|
account_expires: Date | null;
|
|
71
87
|
};
|
|
72
88
|
|
|
89
|
+
const log = logger("pwa-devices");
|
|
73
90
|
const iso = (value: Date | string) => new Date(value).toISOString();
|
|
74
91
|
const passed = (value: Date | string) => new Date(value).getTime() <= Date.now();
|
|
75
92
|
const DEVICE_KEY = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([A-Za-z0-9_-]{43})$/;
|
|
@@ -123,6 +140,30 @@ const initiatorValid = async (tx: SQL, pairing: PairingRow): Promise<boolean> =>
|
|
|
123
140
|
};
|
|
124
141
|
|
|
125
142
|
const accountExpired = (user: UserRow) => Boolean(user.account_expires && passed(user.account_expires));
|
|
143
|
+
|
|
144
|
+
/** `name` is the one the owner confirmed; the phone can rename itself once it is paired. */
|
|
145
|
+
const pairedNotice = (device: DeviceRow, name: string): PwaDevicePairedNotice => ({
|
|
146
|
+
deviceId: device.id,
|
|
147
|
+
userId: device.user_id,
|
|
148
|
+
name,
|
|
149
|
+
platform: device.platform,
|
|
150
|
+
pairedAt: iso(device.created_at),
|
|
151
|
+
locale: device.locale,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Sends the "new phone paired" notice and marks it sent. The notification owner deduplicates by
|
|
156
|
+
* device id, so a crash between the two sends nothing twice. A failure leaves it to maintenance.
|
|
157
|
+
*/
|
|
158
|
+
const announce = async (notify: PwaDeviceNotifier, notice: PwaDevicePairedNotice) => {
|
|
159
|
+
try {
|
|
160
|
+
await notify(notice);
|
|
161
|
+
await sql`UPDATE auth.pwa_devices SET notified_at = now() WHERE id = ${notice.deviceId}::uuid AND notified_at IS NULL`;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
log.warn("App device notice failed", { error: error instanceof Error ? error.name : "UnknownError" });
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
126
167
|
const legalAccepted = async (tx: SQL, userId: string) =>
|
|
127
168
|
(await tx`SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid`).length > 0;
|
|
128
169
|
|
|
@@ -402,7 +443,9 @@ export const createPwaDeviceService = () => {
|
|
|
402
443
|
|
|
403
444
|
/**
|
|
404
445
|
* Finishes a confirmed pairing in the app. `webUserId` is the owner of a valid web session in
|
|
405
|
-
* the same cookie jar; `appSession` is a valid app session presented with the request.
|
|
446
|
+
* the same cookie jar; `appSession` is a valid app session presented with the request. The
|
|
447
|
+
* "new phone paired" notice goes out before the phone receives its credentials, so the phone
|
|
448
|
+
* can neither turn it off, rename itself in it, nor sign out before it is sent.
|
|
406
449
|
*/
|
|
407
450
|
completePairing: async (
|
|
408
451
|
c: Context,
|
|
@@ -411,6 +454,9 @@ export const createPwaDeviceService = () => {
|
|
|
411
454
|
deviceKey: string | null | undefined;
|
|
412
455
|
appSession: AuthenticatedSession | null;
|
|
413
456
|
webUserId: string | null;
|
|
457
|
+
/** The request locale, kept for the "new phone paired" notice. */
|
|
458
|
+
locale?: string;
|
|
459
|
+
notify: PwaDeviceNotifier;
|
|
414
460
|
},
|
|
415
461
|
): Promise<PwaCompletion> => {
|
|
416
462
|
if (!input.completionSecret) return fail("EXPIRED", 410);
|
|
@@ -428,7 +474,7 @@ export const createPwaDeviceService = () => {
|
|
|
428
474
|
if (snapshot.state !== "confirmed" && snapshot.state !== "completed") return fail("EXPIRED", 410);
|
|
429
475
|
// The device key of this app, read before the locks; it is rechecked under them.
|
|
430
476
|
const presented = await presentedDevice(input.deviceKey);
|
|
431
|
-
type Step = { kind: "already" } | { kind: "issued"; deviceKey: string };
|
|
477
|
+
type Step = { kind: "already" } | { kind: "issued"; deviceKey: string; notice: PwaDevicePairedNotice | null };
|
|
432
478
|
const { value, token } = await session.issueInTransaction<Step>(c, async (tx, issue) => {
|
|
433
479
|
const user = await lockUser(tx, snapshot.user_id);
|
|
434
480
|
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot.id}::uuid FOR UPDATE`;
|
|
@@ -447,7 +493,9 @@ export const createPwaDeviceService = () => {
|
|
|
447
493
|
const deviceKey = await newKey(tx, device.id, "rotate");
|
|
448
494
|
await issue(user.id, appSession(device.id));
|
|
449
495
|
await record(tx, "device.recover", { type: "pwa_device", id: device.id }, user.id);
|
|
450
|
-
|
|
496
|
+
// The first answer may have been lost before its notice went out.
|
|
497
|
+
const notice = device.notified_at ? null : pairedNotice(device, p.device_name ?? device.name);
|
|
498
|
+
return { kind: "issued", deviceKey, notice };
|
|
451
499
|
}
|
|
452
500
|
if (p.state !== "confirmed" || !p.platform || !p.device_name) return fail("EXPIRED", 410);
|
|
453
501
|
if (!(await initiatorValid(tx, p))) return fail("EXPIRED", 410);
|
|
@@ -466,16 +514,21 @@ export const createPwaDeviceService = () => {
|
|
|
466
514
|
if ((active?.count ?? 0) >= limits.devicesPerAccount) return fail("LIMIT_REACHED", 429);
|
|
467
515
|
const deviceId = crypto.randomUUID();
|
|
468
516
|
const secret = pairingSecret.create();
|
|
469
|
-
|
|
470
|
-
|
|
517
|
+
// An unusually long locale tag is dropped; the notice then uses the operator's locale.
|
|
518
|
+
const locale = input.locale && input.locale.length <= 35 ? input.locale : null;
|
|
519
|
+
const [device] = await tx<DeviceRow[]>`
|
|
520
|
+
INSERT INTO auth.pwa_devices (id, user_id, name, platform, auth_epoch, secret_hash, rotated_at, locale)
|
|
521
|
+
VALUES (${deviceId}::uuid, ${user.id}::uuid, ${p.device_name}, ${p.platform}, ${user.auth_epoch}, ${pairingSecret.hash(secret)}, now(), ${locale})
|
|
522
|
+
RETURNING *`;
|
|
471
523
|
await issue(user.id, appSession(deviceId));
|
|
472
524
|
await tx`UPDATE auth.pwa_pairings SET state = 'completed', device_id = ${deviceId}::uuid WHERE id = ${p.id}::uuid`;
|
|
473
525
|
await record(tx, "device.enroll", { type: "pwa_device", id: deviceId }, user.id, { platform: p.platform, pairingId: p.id });
|
|
474
|
-
return { kind: "issued", deviceKey: `${deviceId}.${secret}
|
|
526
|
+
return { kind: "issued", deviceKey: `${deviceId}.${secret}`, notice: pairedNotice(device!, p.device_name) };
|
|
475
527
|
});
|
|
476
528
|
const platform = snapshot.platform ?? "other";
|
|
477
529
|
if (value.kind === "already") return { state: "paired", credentials: null, platform };
|
|
478
530
|
if (!token) throw new Error("App session was not issued");
|
|
531
|
+
if (value.notice) await announce(input.notify, value.notice);
|
|
479
532
|
return { state: "paired", credentials: { deviceKey: value.deviceKey, sessionToken: token }, platform };
|
|
480
533
|
},
|
|
481
534
|
|
|
@@ -543,8 +596,11 @@ export const createPwaDeviceService = () => {
|
|
|
543
596
|
|
|
544
597
|
// ---------- Maintenance ----------
|
|
545
598
|
|
|
546
|
-
/**
|
|
547
|
-
|
|
599
|
+
/**
|
|
600
|
+
* Bounded to 100 rows per step; runs every minute from Core's scheduler. `notify` retries the
|
|
601
|
+
* "new phone paired" notices that completion could not send.
|
|
602
|
+
*/
|
|
603
|
+
maintain: async (notify: PwaDeviceNotifier, signal?: AbortSignal) => {
|
|
548
604
|
await sql`DELETE FROM auth.pwa_pairings WHERE id IN (
|
|
549
605
|
SELECT id FROM auth.pwa_pairings WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
550
606
|
signal?.throwIfAborted();
|
|
@@ -567,6 +623,16 @@ export const createPwaDeviceService = () => {
|
|
|
567
623
|
WHERE (d.revoked_at < now() - interval '30 days' OR d.rotated_at < now() - ${limits.idleDays + 30} * interval '1 day')
|
|
568
624
|
AND NOT EXISTS (SELECT 1 FROM auth.session_families f WHERE f.pwa_device_id = d.id)
|
|
569
625
|
ORDER BY d.id LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
626
|
+
signal?.throwIfAborted();
|
|
627
|
+
// Every pairing is announced, also of a phone removed since: it had access to the account.
|
|
628
|
+
const pending = await sql<DeviceRow[]>`
|
|
629
|
+
SELECT * FROM auth.pwa_devices WHERE notified_at IS NULL ORDER BY created_at, id LIMIT 100
|
|
630
|
+
`;
|
|
631
|
+
for (const row of pending) {
|
|
632
|
+
signal?.throwIfAborted();
|
|
633
|
+
// A failing notice stays pending for the next run and does not hold back the others.
|
|
634
|
+
await announce(notify, pairedNotice(row, row.name));
|
|
635
|
+
}
|
|
570
636
|
},
|
|
571
637
|
};
|
|
572
638
|
};
|