@k2b/cloud 0.33.0 → 0.35.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 -3
- package/src/_internal/app-presentation.ts +40 -11
- package/src/_internal/define-app.ts +1 -0
- package/src/_internal/registry.ts +10 -1
- package/src/_internal/widget-declarations.ts +16 -3
- package/src/_internal/widget-settings.ts +93 -0
- package/src/ai/capabilities.ts +130 -16
- package/src/ai/firecrawl-tools.ts +3 -30
- package/src/ai/resource-refs.ts +35 -3
- package/src/ai/system-prompt.ts +3 -2
- package/src/api/index.ts +2 -0
- package/src/api/mcp.ts +48 -17
- package/src/api/pwa.ts +7 -2
- package/src/api/resource-links.ts +43 -0
- package/src/api/widgets.ts +163 -82
- package/src/browser/CloudResourceSearch.tsx +6 -6
- package/src/browser/command-shortcuts.ts +2 -11
- package/src/browser/resource-picker.tsx +1 -0
- package/src/browser/resource-search.ts +7 -2
- package/src/browser/widgets.ts +23 -5
- package/src/capabilities/testing.ts +13 -1
- package/src/cli/capabilities.ts +61 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/resource-links.ts +13 -0
- package/src/contracts/app.ts +7 -1
- package/src/contracts/capabilities.ts +25 -5
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/resource-href.ts +7 -0
- package/src/contracts/widgets.ts +80 -6
- package/src/desktop/index.ts +3 -2
- package/src/desktop/solid.tsx +7 -5
- package/src/server/widget-request.ts +34 -5
- package/src/services/app-approval.ts +4 -2
- package/src/services/approvals/index.ts +40 -11
- package/src/services/approvals/list.ts +1 -0
- package/src/services/approvals/migrate.ts +10 -0
- package/src/services/network-security.ts +33 -9
- package/src/services/pdf/markdown.ts +11 -7
- package/src/services/pwa-devices.ts +68 -15
- package/src/shared/app-presentation.ts +49 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/markdown/callout-sanitizer.ts +37 -0
- package/src/shared/markdown/index.ts +8 -2
- package/src/shared/network-address.ts +46 -45
- package/src/ssr/Layout.tsx +3 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +320 -183
- package/src/ssr/LayoutHelpTrigger.tsx +1 -3
- package/src/ssr/LayoutPreferences.tsx +15 -5
- package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
- package/src/ssr/MobileProfileActions.tsx +3 -8
- package/src/ssr/ProfilePreferences.tsx +8 -10
- package/src/ssr/ShellRuntime.island.tsx +1 -5
- package/src/ssr/ThemeCycleButton.tsx +46 -0
- package/src/ssr/app-appearance.ts +6 -1
- package/src/ssr/layout-help-url.ts +39 -0
- package/src/ssr/platform-messages.ts +0 -8
- package/src/ssr/preference-controller.ts +53 -19
- package/src/ssr/profile-actions.ts +12 -16
- package/src/ssr/profile-preferences-messages.ts +4 -2
- package/src/ssr/rail-navigation.ts +3 -0
- package/src/ssr/shell-data.tsx +2 -2
- package/src/styles/effects.css +13 -3
- package/src/styles/global.css +47 -12
- package/src/styles/input.css +0 -8
- package/src/styles/tokens.css +0 -2
- package/src/styles/utilities-navigation.css +10 -13
- package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
|
@@ -72,6 +72,8 @@ export type ApprovalRequest = {
|
|
|
72
72
|
reviewDigest: string;
|
|
73
73
|
status: ApprovalStatus;
|
|
74
74
|
decidedVia: ApprovalDecisionChannel | null;
|
|
75
|
+
decidedDeviceId: string | null;
|
|
76
|
+
approverAuthEpoch: string | null;
|
|
75
77
|
decidedAt: Date | null;
|
|
76
78
|
consumedAt: Date | null;
|
|
77
79
|
createdAt: Date;
|
|
@@ -83,11 +85,23 @@ const columns = () => sql`id, app_id AS "appId", action, approval_scope AS "appr
|
|
|
83
85
|
requester_kind AS "requesterKind", requester_ref AS "requesterRef", requester_app_id AS "requesterAppId",
|
|
84
86
|
requester_title AS "requesterTitle", subject_id AS "subjectId", approver_user_id AS "approverUserId",
|
|
85
87
|
assurance, sentence, details, args_digest AS "argsDigest", review_digest AS "reviewDigest", status,
|
|
88
|
+
decided_device_id AS "decidedDeviceId", approver_auth_epoch::text AS "approverAuthEpoch",
|
|
86
89
|
decided_via AS "decidedVia", decided_at AS "decidedAt", consumed_at AS "consumedAt",
|
|
87
90
|
created_at AS "createdAt", expires_at AS "expiresAt"`;
|
|
88
91
|
|
|
89
92
|
const transaction = <T>(db: SQL | undefined, run: (tx: SQL) => Promise<T>): Promise<T> => (db && db !== sql ? run(db) : sql.begin(run));
|
|
90
93
|
|
|
94
|
+
const lockDecider = async (tx: SQL, userId: string, deviceId: string | null) => {
|
|
95
|
+
// User before device before request: sign-out everywhere updates the user first, then devices.
|
|
96
|
+
const [user] = await tx<{ epoch: string }[]>`SELECT auth_epoch::text AS epoch FROM auth.users WHERE id=${userId}::uuid FOR SHARE`;
|
|
97
|
+
const [device] =
|
|
98
|
+
deviceId === null
|
|
99
|
+
? []
|
|
100
|
+
: await tx`SELECT 1 FROM auth.app_devices
|
|
101
|
+
WHERE id=${deviceId}::uuid AND user_id=${userId}::uuid AND revoked_at IS NULL FOR SHARE`;
|
|
102
|
+
return { epoch: user?.epoch ?? null, deviceActive: !!device };
|
|
103
|
+
};
|
|
104
|
+
|
|
91
105
|
export type ApprovalRequestResult = { ok: true; id: string } | { ok: false; code: "CONFLICT" };
|
|
92
106
|
/**
|
|
93
107
|
* Records the question once per requester reference. Asking again, for example after a worker restart, returns the
|
|
@@ -103,10 +117,10 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
|
|
|
103
117
|
return transaction(db, async (tx): Promise<ApprovalRequestResult> => {
|
|
104
118
|
const [row] = await tx<{ id: string }[]>`
|
|
105
119
|
INSERT INTO approvals.requests (app_id, action, approval_scope, requester_kind, requester_ref, requester_app_id,
|
|
106
|
-
requester_title, subject_id, approver_user_id, assurance, sentence, details, args_digest, review_digest, expires_at)
|
|
120
|
+
requester_title, subject_id, approver_user_id, approver_auth_epoch, assurance, sentence, details, args_digest, review_digest, expires_at)
|
|
107
121
|
VALUES (${input.appId}, ${input.action}, ${input.approvalScope ?? null}, ${input.requester.kind}, ${input.requester.ref},
|
|
108
122
|
${input.requester.appId}, ${input.requester.title ?? null}, ${input.subjectId}::uuid, ${input.approverUserId}::uuid,
|
|
109
|
-
${input.assurance}, ${input.sentence}, ${JSON.stringify(input.review ?? null)}::text::jsonb,
|
|
123
|
+
(SELECT auth_epoch FROM auth.users WHERE id=${input.approverUserId}::uuid), ${input.assurance}, ${input.sentence}, ${JSON.stringify(input.review ?? null)}::text::jsonb,
|
|
110
124
|
${approvalArgsDigest(input.input)}, ${approvalReviewDigest(input.review)}, now() + (${expiresInMs} * interval '1 millisecond'))
|
|
111
125
|
ON CONFLICT (requester_kind, requester_ref) DO NOTHING
|
|
112
126
|
RETURNING id
|
|
@@ -115,6 +129,7 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
|
|
|
115
129
|
const [open] = await tx<{ id: string }[]>`SELECT id FROM approvals.requests
|
|
116
130
|
WHERE requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}
|
|
117
131
|
AND status='pending' AND expires_at > now() AND subject_id=${input.subjectId}::uuid
|
|
132
|
+
AND approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${input.approverUserId}::uuid)
|
|
118
133
|
AND approver_user_id=${input.approverUserId}::uuid AND app_id=${input.appId} AND action=${input.action}`;
|
|
119
134
|
return open ? { ok: true, id: open.id } : { ok: false, code: "CONFLICT" };
|
|
120
135
|
});
|
|
@@ -137,12 +152,9 @@ export const decide = async (
|
|
|
137
152
|
db?: SQL,
|
|
138
153
|
): Promise<ApprovalDecisionResult> => {
|
|
139
154
|
const result = await transaction(db, async (tx): Promise<ApprovalDecisionResult> => {
|
|
140
|
-
// Lock the device before the request so revocation and
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
WHERE id=${actor.deviceId}::uuid AND user_id=${actor.userId}::uuid AND revoked_at IS NULL FOR SHARE`;
|
|
144
|
-
if (!device) return { ok: false, code: "NOT_FOUND" };
|
|
145
|
-
}
|
|
155
|
+
// Lock the approver and device before the request so revocation and decisions serialize.
|
|
156
|
+
const decider = await lockDecider(tx, actor.userId, actor.deviceId ?? null);
|
|
157
|
+
if (actor.deviceId !== undefined && !decider.deviceActive) return { ok: false, code: "NOT_FOUND" };
|
|
146
158
|
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
|
|
147
159
|
WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid FOR UPDATE`;
|
|
148
160
|
if (!row) return { ok: false, code: "NOT_FOUND" };
|
|
@@ -151,8 +163,9 @@ export const decide = async (
|
|
|
151
163
|
const [won] = rejectOnly
|
|
152
164
|
? []
|
|
153
165
|
: await tx<ApprovalRequest[]>`
|
|
154
|
-
UPDATE approvals.requests SET status=${status}, decided_via=${actor.via}, decided_at=now()
|
|
166
|
+
UPDATE approvals.requests SET status=${status}, decided_via=${actor.via}, decided_device_id=${actor.deviceId ?? null}::uuid, decided_at=now()
|
|
155
167
|
WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid AND status='pending' AND expires_at > now()
|
|
168
|
+
AND approver_auth_epoch=${decider.epoch}::bigint
|
|
156
169
|
RETURNING ${columns()}
|
|
157
170
|
`;
|
|
158
171
|
if (won) {
|
|
@@ -178,7 +191,8 @@ export const decide = async (
|
|
|
178
191
|
}
|
|
179
192
|
if (row.status === "pending") {
|
|
180
193
|
const [expired] = await tx<{ id: string }[]>`UPDATE approvals.requests SET status='expired'
|
|
181
|
-
WHERE id=${id}::uuid AND status='pending'
|
|
194
|
+
WHERE id=${id}::uuid AND status='pending'
|
|
195
|
+
AND (expires_at <= now() OR approver_auth_epoch IS DISTINCT FROM ${decider.epoch}::bigint) RETURNING id`;
|
|
182
196
|
return { ok: false, code: expired || !rejectOnly ? "EXPIRED" : "REJECT_ONLY" };
|
|
183
197
|
}
|
|
184
198
|
if (
|
|
@@ -194,7 +208,7 @@ export const decide = async (
|
|
|
194
208
|
|
|
195
209
|
export type ApprovalConsumeResult =
|
|
196
210
|
| { ok: true }
|
|
197
|
-
| { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" };
|
|
211
|
+
| { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" | "REVOKED" };
|
|
198
212
|
export const consume = async (
|
|
199
213
|
id: string,
|
|
200
214
|
input: { input: unknown; review: CapabilityActionReview | null; requester: { kind: ApprovalRequesterKind; ref: string } },
|
|
@@ -203,11 +217,26 @@ export const consume = async (
|
|
|
203
217
|
const argsDigest = approvalArgsDigest(input.input),
|
|
204
218
|
reviewDigest = approvalReviewDigest(input.review);
|
|
205
219
|
return transaction(db, async (tx): Promise<ApprovalConsumeResult> => {
|
|
220
|
+
const [initial] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
|
|
221
|
+
WHERE id=${id}::uuid AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}`;
|
|
222
|
+
if (!initial) return { ok: false, code: "NOT_FOUND" };
|
|
223
|
+
if (initial.status === "consumed") return { ok: false, code: "ALREADY_CONSUMED" };
|
|
224
|
+
if (initial.status !== "approved") return { ok: false, code: "NOT_APPROVED" };
|
|
225
|
+
const decider = await lockDecider(tx, initial.approverUserId, initial.decidedDeviceId);
|
|
206
226
|
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
|
|
207
227
|
WHERE id=${id}::uuid AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref} FOR UPDATE`;
|
|
208
228
|
if (!row) return { ok: false, code: "NOT_FOUND" };
|
|
209
229
|
if (row.status === "consumed") return { ok: false, code: "ALREADY_CONSUMED" };
|
|
210
230
|
if (row.status !== "approved") return { ok: false, code: "NOT_APPROVED" };
|
|
231
|
+
if (
|
|
232
|
+
(row.decidedVia === "cloud_login" && row.decidedDeviceId === null) ||
|
|
233
|
+
(row.decidedDeviceId !== null && !decider.deviceActive) ||
|
|
234
|
+
row.approverAuthEpoch === null ||
|
|
235
|
+
row.approverAuthEpoch !== decider.epoch
|
|
236
|
+
) {
|
|
237
|
+
await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=${id}::uuid AND status='approved'`;
|
|
238
|
+
return { ok: false, code: "REVOKED" };
|
|
239
|
+
}
|
|
211
240
|
if (row.argsDigest !== argsDigest || row.reviewDigest !== reviewDigest) {
|
|
212
241
|
await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=${id}::uuid AND status='approved'`;
|
|
213
242
|
return { ok: false, code: row.argsDigest !== argsDigest ? "INPUT_CHANGED" : "REVIEW_CHANGED" };
|
|
@@ -33,6 +33,7 @@ export type PendingApprovalRow = {
|
|
|
33
33
|
// Start with this person's indexed pending rows; a Studio page alone cannot continue a chat.
|
|
34
34
|
const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
|
|
35
35
|
WHERE r.approver_user_id=${approverUserId}::uuid AND r.status='pending' AND r.expires_at > now()
|
|
36
|
+
AND r.approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${approverUserId}::uuid)
|
|
36
37
|
AND (${id ?? null}::uuid IS NULL OR r.id=${id ?? null}::uuid)
|
|
37
38
|
AND EXISTS (SELECT 1 FROM ai.pending_actions p WHERE p.approval_id=r.id AND p.status='pending')`;
|
|
38
39
|
const matches = (appId?: string, link?: string) => sql`
|
|
@@ -38,4 +38,14 @@ export const migrateApprovals = async (): Promise<void> => {
|
|
|
38
38
|
END $$`.simple();
|
|
39
39
|
await sql`CREATE INDEX IF NOT EXISTS approvals_requests_pending ON approvals.requests(approver_user_id, created_at) WHERE status='pending'`.simple();
|
|
40
40
|
await sql`CREATE INDEX IF NOT EXISTS approvals_requests_expiry ON approvals.requests(expires_at)`.simple();
|
|
41
|
+
await sql`ALTER TABLE approvals.requests ADD COLUMN IF NOT EXISTS decided_device_id UUID`.simple();
|
|
42
|
+
// Backfill pending rows once at column creation; approved rows and older-release rows keep NULL and count as ended.
|
|
43
|
+
await sql`DO $$ BEGIN
|
|
44
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
45
|
+
WHERE table_schema='approvals' AND table_name='requests' AND column_name='approver_auth_epoch') THEN
|
|
46
|
+
ALTER TABLE approvals.requests ADD COLUMN approver_auth_epoch BIGINT;
|
|
47
|
+
UPDATE approvals.requests r SET approver_auth_epoch=u.auth_epoch FROM auth.users u
|
|
48
|
+
WHERE u.id=r.approver_user_id AND r.status='pending';
|
|
49
|
+
END IF;
|
|
50
|
+
END $$`.simple();
|
|
41
51
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
-
import {
|
|
2
|
+
import type { LookupFunction } from "node:net";
|
|
3
|
+
import { isUnsafeNetworkAddress, isUnsafeNetworkHostname, normalizeNetworkHostname, parseNetworkAddress } from "../shared/network-address";
|
|
3
4
|
|
|
4
5
|
export type PublicNetworkAddress = { address: string; family: 4 | 6 };
|
|
5
6
|
export type NetworkLookup = (
|
|
@@ -7,25 +8,48 @@ export type NetworkLookup = (
|
|
|
7
8
|
options: { all: true; verbatim: true },
|
|
8
9
|
) => Promise<Array<{ address: string; family: number }>>;
|
|
9
10
|
|
|
11
|
+
export class NetworkTargetBlockedError extends Error {
|
|
12
|
+
constructor(message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "NetworkTargetBlockedError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
export const resolvePublicNetworkAddresses = async (
|
|
11
19
|
rawHostname: string,
|
|
12
20
|
lookup: NetworkLookup = (hostname) => dnsLookup(hostname, { all: true, verbatim: true }),
|
|
13
21
|
): Promise<PublicNetworkAddress[]> => {
|
|
14
22
|
const hostname = normalizeNetworkHostname(rawHostname);
|
|
15
|
-
if (
|
|
23
|
+
if (isUnsafeNetworkHostname(rawHostname)) throw new NetworkTargetBlockedError("Network target host is not allowed");
|
|
16
24
|
|
|
17
|
-
const
|
|
18
|
-
const addresses =
|
|
25
|
+
const literal = parseNetworkAddress(rawHostname);
|
|
26
|
+
const addresses = literal ? [literal] : await lookup(hostname, { all: true, verbatim: true });
|
|
19
27
|
if (addresses.length === 0) throw new Error("Network target could not be resolved");
|
|
20
28
|
|
|
21
29
|
const normalized: PublicNetworkAddress[] = addresses.map((entry) => {
|
|
22
|
-
const
|
|
23
|
-
if (entry.family !== 4 && entry.family !== 6)
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
const literal = parseNetworkAddress(entry.address);
|
|
31
|
+
if (entry.family !== 4 && entry.family !== 6)
|
|
32
|
+
throw new NetworkTargetBlockedError("Network target returned an unsupported address family");
|
|
33
|
+
if (!literal || literal.family !== entry.family) throw new NetworkTargetBlockedError("Network target returned an invalid address");
|
|
34
|
+
return literal;
|
|
26
35
|
});
|
|
27
36
|
if (normalized.some((entry) => isUnsafeNetworkAddress(entry.address))) {
|
|
28
|
-
throw new
|
|
37
|
+
throw new NetworkTargetBlockedError("Network target resolved to a private or reserved address");
|
|
29
38
|
}
|
|
30
39
|
return normalized;
|
|
31
40
|
};
|
|
41
|
+
|
|
42
|
+
/** Socket lookup using only addresses already checked by the public resolver. */
|
|
43
|
+
export const pinnedNetworkLookup =
|
|
44
|
+
(addresses: readonly PublicNetworkAddress[]): LookupFunction =>
|
|
45
|
+
(_hostname, options, callback) => {
|
|
46
|
+
const family = options.family === 4 || options.family === 6 ? options.family : 0;
|
|
47
|
+
const candidates = family ? addresses.filter((entry) => entry.family === family) : [...addresses];
|
|
48
|
+
const selected = candidates[0];
|
|
49
|
+
if (!selected) {
|
|
50
|
+
callback(Object.assign(new Error("No validated address for requested IP family"), { code: "ENOTFOUND" }), "", family || undefined);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (options.all) callback(null, candidates);
|
|
54
|
+
else callback(null, selected.address, selected.family);
|
|
55
|
+
};
|
|
@@ -127,19 +127,23 @@ hr { border: 0; border-top: 1px solid #d1d5db; margin: .9em 0; }
|
|
|
127
127
|
};
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
|
-
* Markdown
|
|
131
|
-
*
|
|
130
|
+
* Markdown callouts print with a light tint and no border. The inline icon
|
|
131
|
+
* and title use print-safe tone ink; the body keeps the preset's text colour.
|
|
132
132
|
* The tints are a step stronger than on screen because paper and printers wash
|
|
133
133
|
* them out. Every preset and application document shares them.
|
|
134
134
|
*/
|
|
135
135
|
const INFO_BLOCK_PRINT_CSS = `
|
|
136
136
|
.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; }
|
|
137
137
|
.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; }
|
|
138
|
-
.k2b-notice-card[data-tone="neutral"] { --notice-tint: #f1f2f4; }
|
|
139
|
-
.k2b-notice-card[data-tone="info"] { --notice-tint: #e9f1fe; }
|
|
140
|
-
.k2b-notice-card[data-tone="success"] { --notice-tint: #e6f6ee; }
|
|
141
|
-
.k2b-notice-card[data-tone="warning"] { --notice-tint: #fcf2da; }
|
|
142
|
-
.k2b-notice-card[data-tone="danger"] { --notice-tint: #fcebeb; }
|
|
138
|
+
.k2b-notice-card[data-tone="neutral"] { --notice-tint: #f1f2f4; --notice-ink: #4b5563; }
|
|
139
|
+
.k2b-notice-card[data-tone="info"] { --notice-tint: #e9f1fe; --notice-ink: #1d4ed8; }
|
|
140
|
+
.k2b-notice-card[data-tone="success"] { --notice-tint: #e6f6ee; --notice-ink: #15803d; }
|
|
141
|
+
.k2b-notice-card[data-tone="warning"] { --notice-tint: #fcf2da; --notice-ink: #92400e; }
|
|
142
|
+
.k2b-notice-card[data-tone="danger"] { --notice-tint: #fcebeb; --notice-ink: #b91c1c; }
|
|
143
|
+
.k2b-callout { display: grid; grid-template-columns: 1.25em minmax(0, 1fr); column-gap: .7em; }
|
|
144
|
+
.k2b-callout > * { grid-column: 2; }
|
|
145
|
+
.k2b-callout > .k2b-callout__icon { grid-column: 1; grid-row: 1 / span 2; width: 1.25em; height: 1lh; color: var(--notice-ink); }
|
|
146
|
+
.k2b-callout > .k2b-notice-card__title { color: var(--notice-ink); }
|
|
143
147
|
.k2b-notice-card__title { margin: 0 0 .25em; font-weight: 600; }
|
|
144
148
|
.k2b-notice-card__body > :first-child { margin-top: 0; }
|
|
145
149
|
.k2b-notice-card__body > :last-child { margin-bottom: 0; }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { type SQL, sql } from "bun";
|
|
3
3
|
import type { Context } from "hono";
|
|
4
|
+
import { lazySync } from "../_internal/process-sync";
|
|
4
5
|
import { PWA_LIMITS as limits, type PwaDeviceView, type PwaErrorCode, type PwaPairingState, type PwaPlatform } from "../contracts/pwa";
|
|
5
6
|
import { isAccountCategoryAllowed } from "./account-category-policy";
|
|
6
7
|
import { audit } from "./audit";
|
|
@@ -30,6 +31,13 @@ const fail = (code: PwaErrorCode, status: PwaError["status"], attemptsLeft?: num
|
|
|
30
31
|
|
|
31
32
|
/** The initiating web session of a pairing. */
|
|
32
33
|
export type PwaWebActor = { userId: string; sid: string };
|
|
34
|
+
/** Best-effort wake-ups for browsers waiting on a pairing claim. Postgres stays authoritative. */
|
|
35
|
+
export type PwaPairingClaimHints = {
|
|
36
|
+
/** Call after the claim commits. Must not throw or delay the caller. */
|
|
37
|
+
publish(pairingId: string): void;
|
|
38
|
+
/** Settles on the first hint for this pairing, or when `signal` aborts. */
|
|
39
|
+
wait(pairingId: string, signal: AbortSignal): Promise<void>;
|
|
40
|
+
};
|
|
33
41
|
/** Administration of another account's phones needs current admin authority. */
|
|
34
42
|
export type PwaDeviceAdministrator = { userId: string; admin: boolean };
|
|
35
43
|
export type PwaRevocationReason = "user" | "unpaired" | "admin" | "replaced" | "account_expired";
|
|
@@ -87,6 +95,25 @@ type UserRow = {
|
|
|
87
95
|
};
|
|
88
96
|
|
|
89
97
|
const log = logger("pwa-devices");
|
|
98
|
+
const claimTopic = lazySync((sync) =>
|
|
99
|
+
sync.topic<null>({
|
|
100
|
+
id: "cloud:pwa-pairing-claims",
|
|
101
|
+
owner: "cloud",
|
|
102
|
+
retention: { maxAgeMs: 120_000, maxBytes: 1_048_576 },
|
|
103
|
+
maxPayloadBytes: 2000,
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
/** Core NATS broadcast reaches the waiting browser on any replica. */
|
|
107
|
+
export const syncPairingClaimHints: PwaPairingClaimHints = {
|
|
108
|
+
publish: (pairingId) => {
|
|
109
|
+
void Promise.resolve()
|
|
110
|
+
.then(() => claimTopic().publish({ tenantId: pairingId, data: null }))
|
|
111
|
+
.catch((error) => log.warn("App pairing claim hint failed", { error: error instanceof Error ? error.name : "UnknownError" }));
|
|
112
|
+
},
|
|
113
|
+
wait: async (pairingId, signal) => {
|
|
114
|
+
for await (const _event of claimTopic().live({ tenantId: pairingId, signal })) return;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
90
117
|
const iso = (value: Date | string) => new Date(value).toISOString();
|
|
91
118
|
const passed = (value: Date | string) => new Date(value).getTime() <= Date.now();
|
|
92
119
|
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})$/;
|
|
@@ -243,7 +270,7 @@ export type PwaCompletion =
|
|
|
243
270
|
| { state: "paired"; credentials: PwaCredentials | null; platform: PwaPlatform }
|
|
244
271
|
| { state: "waiting"; code: string; account: { name: string }; expiresAt: string };
|
|
245
272
|
|
|
246
|
-
export const createPwaDeviceService = () => {
|
|
273
|
+
export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairingClaimHints) => {
|
|
247
274
|
const appSession = (deviceId: string) => ({ ttlSeconds: limits.sessionSeconds, pwaDeviceId: deviceId, recordLogin: false });
|
|
248
275
|
|
|
249
276
|
/**
|
|
@@ -334,21 +361,46 @@ export const createPwaDeviceService = () => {
|
|
|
334
361
|
},
|
|
335
362
|
|
|
336
363
|
/** Never returns the code: the person types what the phone shows. */
|
|
337
|
-
inspectPairing: async (actor: PwaWebActor, id: string) => {
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
364
|
+
inspectPairing: async (actor: PwaWebActor, id: string, hold?: { ms: number; signal?: AbortSignal }) => {
|
|
365
|
+
const read = async (validateInitiator = false) => {
|
|
366
|
+
const [p] = await sql<(PairingRow & { current_epoch: string })[]>`
|
|
367
|
+
SELECT p.*, u.auth_epoch AS current_epoch FROM auth.pwa_pairings p JOIN auth.users u ON u.id = p.user_id
|
|
368
|
+
WHERE p.id = ${id}::uuid AND p.user_id = ${actor.userId}::uuid AND p.initiator_sid = ${actor.sid}::uuid
|
|
369
|
+
`;
|
|
370
|
+
if (!p) return fail("NOT_FOUND", 404);
|
|
371
|
+
if (passed(p.expires_at) || p.current_epoch !== p.auth_epoch || (p.state === "pending" && passed(p.claim_until)))
|
|
372
|
+
return fail("EXPIRED", 410);
|
|
373
|
+
if (validateInitiator && !(await initiatorValid(sql, p))) return fail("EXPIRED", 410);
|
|
374
|
+
return {
|
|
375
|
+
state: p.state,
|
|
376
|
+
claimUntil: iso(p.claim_until),
|
|
377
|
+
expiresAt: iso(p.expires_at),
|
|
378
|
+
device: p.state === "pending" || !p.device_name || !p.platform ? null : { name: p.device_name, platform: p.platform },
|
|
379
|
+
attemptsLeft: Math.max(0, limits.confirmAttempts - p.failed_attempts),
|
|
380
|
+
};
|
|
351
381
|
};
|
|
382
|
+
if (!hold) return read();
|
|
383
|
+
// Listen before the first read so a claim committed in between usually wakes this request.
|
|
384
|
+
// live() has no subscription-ready barrier, so the hint is best effort; the deadline re-read is the guarantee.
|
|
385
|
+
// The read after the hold also checks the initiating web session, which may have ended meanwhile.
|
|
386
|
+
const waiting = new AbortController();
|
|
387
|
+
const ended = new Promise((resolve) => waiting.signal.addEventListener("abort", resolve, { once: true }));
|
|
388
|
+
const stop = () => waiting.abort();
|
|
389
|
+
hold.signal?.addEventListener("abort", stop, { once: true });
|
|
390
|
+
if (hold.signal?.aborted) stop();
|
|
391
|
+
const hint = hints.wait(id, waiting.signal).catch(() => {});
|
|
392
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
393
|
+
try {
|
|
394
|
+
const first = await read();
|
|
395
|
+
if (first.state !== "pending") return first;
|
|
396
|
+
timer = setTimeout(stop, Math.min(hold.ms, new Date(first.claimUntil).getTime() - Date.now()));
|
|
397
|
+
await Promise.race([hint, ended]);
|
|
398
|
+
return await read(true);
|
|
399
|
+
} finally {
|
|
400
|
+
clearTimeout(timer);
|
|
401
|
+
hold.signal?.removeEventListener("abort", stop);
|
|
402
|
+
stop();
|
|
403
|
+
}
|
|
352
404
|
},
|
|
353
405
|
|
|
354
406
|
confirmPairing: async (actor: PwaWebActor, id: string, code: string) => {
|
|
@@ -438,6 +490,7 @@ export const createPwaDeviceService = () => {
|
|
|
438
490
|
WHERE id = ${p.id}::uuid`;
|
|
439
491
|
return { account: { name: accountName(user) }, expiresAt: p.expires_at };
|
|
440
492
|
});
|
|
493
|
+
hints.publish(snapshot.id);
|
|
441
494
|
return { code, account: result.account, expiresAt: iso(result.expiresAt), completionSecret };
|
|
442
495
|
},
|
|
443
496
|
|
|
@@ -26,7 +26,22 @@ const mergedTranslation = (catalog: AppPresentationCatalog, requestedLocale: str
|
|
|
26
26
|
widgets: Object.fromEntries(
|
|
27
27
|
[...new Set([...Object.keys(current.widgets ?? {}), ...Object.keys(next.widgets ?? {})])].map((id) => [
|
|
28
28
|
id,
|
|
29
|
-
{
|
|
29
|
+
{
|
|
30
|
+
...current.widgets?.[id],
|
|
31
|
+
...next.widgets?.[id],
|
|
32
|
+
settings: Object.fromEntries(
|
|
33
|
+
[...new Set([...Object.keys(current.widgets?.[id]?.settings ?? {}), ...Object.keys(next.widgets?.[id]?.settings ?? {})])].map(
|
|
34
|
+
(name) => [
|
|
35
|
+
name,
|
|
36
|
+
{
|
|
37
|
+
...current.widgets?.[id]?.settings?.[name],
|
|
38
|
+
...next.widgets?.[id]?.settings?.[name],
|
|
39
|
+
options: { ...current.widgets?.[id]?.settings?.[name]?.options, ...next.widgets?.[id]?.settings?.[name]?.options },
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
},
|
|
30
45
|
]),
|
|
31
46
|
),
|
|
32
47
|
};
|
|
@@ -65,6 +80,39 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
|
|
|
65
80
|
...widget,
|
|
66
81
|
title: translation.widgets?.[widget.id]?.title ?? widget.title,
|
|
67
82
|
description: translation.widgets?.[widget.id]?.description ?? widget.description,
|
|
83
|
+
...(widget.settings && typeof widget.settings === "object" && !Array.isArray(widget.settings)
|
|
84
|
+
? {
|
|
85
|
+
settings: Object.fromEntries(
|
|
86
|
+
Object.entries(widget.settings).map(([name, field]) => {
|
|
87
|
+
if (!field || typeof field !== "object") return [name, field];
|
|
88
|
+
const translated = translation.widgets?.[widget.id]?.settings?.[name];
|
|
89
|
+
return [
|
|
90
|
+
name,
|
|
91
|
+
{
|
|
92
|
+
...field,
|
|
93
|
+
label: translated?.label ?? field.label,
|
|
94
|
+
...(field.kind === "text" ? { placeholder: translated?.placeholder ?? field.placeholder } : {}),
|
|
95
|
+
...(field.kind === "choice" && Array.isArray(field.options)
|
|
96
|
+
? {
|
|
97
|
+
options: field.options.map((option) =>
|
|
98
|
+
option && typeof option === "object"
|
|
99
|
+
? {
|
|
100
|
+
...option,
|
|
101
|
+
label:
|
|
102
|
+
translated?.options && Object.hasOwn(translated.options, option.value)
|
|
103
|
+
? (translated.options[option.value] ?? option.label)
|
|
104
|
+
: option.label,
|
|
105
|
+
}
|
|
106
|
+
: option,
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
: {}),
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
}
|
|
115
|
+
: {}),
|
|
68
116
|
})),
|
|
69
117
|
};
|
|
70
118
|
};
|
package/src/shared/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ export * from "./locale";
|
|
|
44
44
|
export * from "./locale-preference";
|
|
45
45
|
export * from "./login-method";
|
|
46
46
|
export { markdown } from "./markdown";
|
|
47
|
+
export { markdownCalloutSanitizeOptions } from "./markdown/callout-sanitizer";
|
|
47
48
|
export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "./markdown/formula";
|
|
48
49
|
export { createProgressValue, evaluateFormula, formatValue, isFormula, isTotalRow, parseProgressValue } from "./markdown/formula";
|
|
49
50
|
export type { MockCover, MockCoverIcon, MockCoverOptions, MockCoverTheme } from "./mock-cover";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MARKDOWN_CALLOUT_ICON_ATTRIBUTES } from "@k2b/ui";
|
|
2
|
+
import type sanitizeHtml from "sanitize-html";
|
|
3
|
+
|
|
4
|
+
/** Fresh sanitize-html options for one document: SVG is limited to fixed callout icons. */
|
|
5
|
+
export const markdownCalloutSanitizeOptions = () => {
|
|
6
|
+
const elements: { inIcon: boolean; discard: boolean }[] = [];
|
|
7
|
+
let closed: { inIcon: boolean; discard: boolean } | undefined;
|
|
8
|
+
return {
|
|
9
|
+
allowedTags: ["svg", "path"],
|
|
10
|
+
allowedAttributes: { svg: Object.keys(MARKDOWN_CALLOUT_ICON_ATTRIBUTES), path: ["d"] },
|
|
11
|
+
// onOpenTag sees raw attributes before transforms; discarded SVG must keep ordinary HTML and text.
|
|
12
|
+
onOpenTag: (tag, attrs) => {
|
|
13
|
+
const parent = elements.at(-1);
|
|
14
|
+
elements.push({
|
|
15
|
+
inIcon: !!parent?.inIcon || (tag === "svg" && attrs.class === MARKDOWN_CALLOUT_ICON_ATTRIBUTES.class),
|
|
16
|
+
discard: !!parent?.discard || (!!parent?.inIcon && tag !== "path"),
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
// sanitize-html closes a tag before calling exclusiveFilter for that element.
|
|
20
|
+
onCloseTag: () => {
|
|
21
|
+
closed = elements.pop();
|
|
22
|
+
},
|
|
23
|
+
// excludeTag preserves ordinary content, including after an unclosed author SVG.
|
|
24
|
+
exclusiveFilter: ({ tag }) => closed?.discard || ((tag === "svg" || tag === "path") && !closed?.inIcon ? "excludeTag" : false),
|
|
25
|
+
textFilter: (text) => (elements.at(-1)?.inIcon ? "" : text),
|
|
26
|
+
transformTags: {
|
|
27
|
+
svg: (tagName) => ({
|
|
28
|
+
tagName,
|
|
29
|
+
attribs: { ...MARKDOWN_CALLOUT_ICON_ATTRIBUTES },
|
|
30
|
+
}),
|
|
31
|
+
path: (tagName, attrs) => ({
|
|
32
|
+
tagName,
|
|
33
|
+
attribs: Object.fromEntries(Object.entries(attrs).filter(([name]) => name === "d")),
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
} satisfies sanitizeHtml.IOptions;
|
|
37
|
+
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { markdownInfoBlocks } from "@k2b/ui";
|
|
9
9
|
import { Marked, type MarkedExtension } from "marked";
|
|
10
10
|
import sanitizeHtml from "sanitize-html";
|
|
11
|
+
import { markdownCalloutSanitizeOptions } from "./callout-sanitizer";
|
|
11
12
|
import { markdownClient } from "./client";
|
|
12
13
|
import { codeExtension, plainCodeExtension } from "./extensions/code";
|
|
13
14
|
import { guidedHelpExtension } from "./extensions/guided-help";
|
|
@@ -73,8 +74,10 @@ export type MarkdownRenderOptions = {
|
|
|
73
74
|
locale?: string;
|
|
74
75
|
};
|
|
75
76
|
|
|
76
|
-
const sanitizeRenderedHtml = (html: string): string =>
|
|
77
|
-
|
|
77
|
+
const sanitizeRenderedHtml = (html: string): string => {
|
|
78
|
+
const callouts = markdownCalloutSanitizeOptions();
|
|
79
|
+
return sanitizeHtml(html, {
|
|
80
|
+
...callouts,
|
|
78
81
|
allowedTags: [
|
|
79
82
|
...sanitizeHtml.defaults.allowedTags,
|
|
80
83
|
"annotation",
|
|
@@ -101,6 +104,7 @@ const sanitizeRenderedHtml = (html: string): string =>
|
|
|
101
104
|
"mtext",
|
|
102
105
|
"semantics",
|
|
103
106
|
"span",
|
|
107
|
+
...callouts.allowedTags,
|
|
104
108
|
"sub",
|
|
105
109
|
"sup",
|
|
106
110
|
"table",
|
|
@@ -129,6 +133,7 @@ const sanitizeRenderedHtml = (html: string): string =>
|
|
|
129
133
|
input: ["checked", "class", "disabled", "type"],
|
|
130
134
|
math: ["xmlns"],
|
|
131
135
|
pre: ["class"],
|
|
136
|
+
...callouts.allowedAttributes,
|
|
132
137
|
span: ["aria-hidden", "class", "style", "title"],
|
|
133
138
|
},
|
|
134
139
|
allowedSchemes: ["http", "https", "mailto", "tel", "note", "attach"],
|
|
@@ -150,6 +155,7 @@ const sanitizeRenderedHtml = (html: string): string =>
|
|
|
150
155
|
},
|
|
151
156
|
},
|
|
152
157
|
});
|
|
158
|
+
};
|
|
153
159
|
|
|
154
160
|
/**
|
|
155
161
|
* Render markdown to HTML for server-side display.
|