@k2b/cloud 0.13.0 → 0.14.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"@tailwindcss/typography": "0.5.20",
|
|
100
100
|
"@k2b/nessi": "0.12.1",
|
|
101
101
|
"@k2b/ssr": "0.14.0",
|
|
102
|
-
"@k2b/ui": "0.6.
|
|
102
|
+
"@k2b/ui": "0.6.1",
|
|
103
103
|
"@k2b/stdlib": "0.26.0",
|
|
104
104
|
"@k2b/sync": "6.5.0",
|
|
105
105
|
"@nats-io/transport-node": "3.4.0",
|
|
@@ -215,6 +215,12 @@ const connect = async (options: {
|
|
|
215
215
|
throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
216
216
|
return result;
|
|
217
217
|
},
|
|
218
|
+
/** Hands this device's push token to the Cloud. Older Clouds answer HTTP 400. */
|
|
219
|
+
push: async (device: AppApprovalDevice, token: string, signal?: AbortSignal) => {
|
|
220
|
+
const result = await command(device, { operation: "push", token }, signal);
|
|
221
|
+
if (!("state" in result) || result.state !== "updated") throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
222
|
+
return result;
|
|
223
|
+
},
|
|
218
224
|
revoke: async (device: AppApprovalDevice, signal?: AbortSignal) => {
|
|
219
225
|
const result = await command(device, { operation: "revoke" }, signal);
|
|
220
226
|
if (!("state" in result) || result.state !== "revoked") throw new AppApprovalClientError("INVALID_RESPONSE");
|
|
@@ -53,6 +53,8 @@ export const AppDeviceMutationSchema = z.discriminatedUnion("operation", [
|
|
|
53
53
|
export const AppDeviceCommandSchema = z.discriminatedUnion("operation", [
|
|
54
54
|
z.object({ operation: z.literal("pending") }).strict(),
|
|
55
55
|
z.object({ operation: z.literal("revoke") }).strict(),
|
|
56
|
+
/** Stores the authenticator's opaque push token for sign-in wake-ups on this device. */
|
|
57
|
+
z.object({ operation: z.literal("push"), token: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict(),
|
|
56
58
|
z
|
|
57
59
|
.object({
|
|
58
60
|
operation: z.literal("decide"),
|
|
@@ -91,6 +93,7 @@ export const appDeviceProofMessage = (proof: AppDeviceProof): string => {
|
|
|
91
93
|
proof.expiresAt,
|
|
92
94
|
c.operation,
|
|
93
95
|
...(c.operation === "decide" ? [c.requestId, c.challenge, c.comparison, c.decision] : []),
|
|
96
|
+
...(c.operation === "push" ? [c.token] : []),
|
|
94
97
|
]);
|
|
95
98
|
};
|
|
96
99
|
export const appPairingProofMessage = (issuer: string, claim: Omit<z.infer<typeof AppPairingClaimSchema>, "signature">): string =>
|
|
@@ -182,7 +185,7 @@ export const AppDeviceResponseSchema = z.union([
|
|
|
182
185
|
pollAfterSeconds: z.number().positive(),
|
|
183
186
|
})
|
|
184
187
|
.strict(),
|
|
185
|
-
z.object({ state: z.enum(["approved", "denied", "revoked"]) }).strict(),
|
|
188
|
+
z.object({ state: z.enum(["approved", "denied", "revoked", "updated"]) }).strict(),
|
|
186
189
|
]);
|
|
187
190
|
export const AppLoginStartResultSchema = z
|
|
188
191
|
.object({ requestId: Id, browserSecret: Secret, comparison: Comparison, expiresAt: DateTime, pollAfterSeconds: z.number().positive() })
|
|
@@ -145,6 +145,19 @@ export type AppLoginDecisionHints = {
|
|
|
145
145
|
/** Settles on the first hint for this request, or when `signal` aborts. */
|
|
146
146
|
wait(requestId: string, signal: AbortSignal): Promise<void>;
|
|
147
147
|
};
|
|
148
|
+
/** Best-effort sign-in wake-ups through the trusted authenticator; returns the HTTP status. */
|
|
149
|
+
export type AppLoginPushSender = (appOrigin: string, body: { token: string; cloudOrigin: string; requestRef: string }) => Promise<number>;
|
|
150
|
+
export const fetchPushSender: AppLoginPushSender = async (appOrigin, body) => {
|
|
151
|
+
const response = await fetch(`${appOrigin}/push/notify`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "Content-Type": "application/json" },
|
|
154
|
+
body: JSON.stringify(body),
|
|
155
|
+
redirect: "error",
|
|
156
|
+
signal: AbortSignal.timeout(5_000),
|
|
157
|
+
});
|
|
158
|
+
await response.body?.cancel();
|
|
159
|
+
return response.status;
|
|
160
|
+
};
|
|
148
161
|
const log = logger("app-approval");
|
|
149
162
|
const decisionTopic = lazySync((sync) =>
|
|
150
163
|
sync.topic<null>({
|
|
@@ -193,6 +206,7 @@ export const createAppApprovalService = (
|
|
|
193
206
|
db: SQL = sql,
|
|
194
207
|
configuration = (requireEnabled = true) => readAppApprovalConfig(db, requireEnabled),
|
|
195
208
|
hints: AppLoginDecisionHints = syncLoginDecisionHints,
|
|
209
|
+
push: AppLoginPushSender = fetchPushSender,
|
|
196
210
|
) => {
|
|
197
211
|
const config = async (requireEnabled = true) => {
|
|
198
212
|
const value = await configuration(requireEnabled);
|
|
@@ -245,6 +259,24 @@ export const createAppApprovalService = (
|
|
|
245
259
|
return device;
|
|
246
260
|
};
|
|
247
261
|
|
|
262
|
+
/** Runs after the login commit and never delays or fails it. The body names only
|
|
263
|
+
* this Cloud and the request id; a 410 means the phone's subscription is gone. */
|
|
264
|
+
const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string) => {
|
|
265
|
+
try {
|
|
266
|
+
const rows = await db<{ push_token: string }[]>`SELECT DISTINCT push_token FROM auth.app_devices
|
|
267
|
+
WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL LIMIT ${limits.devicesPerAccount}`;
|
|
268
|
+
await Promise.all(
|
|
269
|
+
rows.map(async ({ push_token: token }) => {
|
|
270
|
+
const status = await push(cfg.appOrigin, { token, cloudOrigin: cfg.issuer, requestRef: requestId });
|
|
271
|
+
if (status === 410)
|
|
272
|
+
await db`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
log.warn("App login push wake-up failed", { error: error instanceof Error ? error.name : "UnknownError" });
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
248
280
|
return {
|
|
249
281
|
config,
|
|
250
282
|
cleanup,
|
|
@@ -399,6 +431,7 @@ export const createAppApprovalService = (
|
|
|
399
431
|
challenge = secret(),
|
|
400
432
|
code = comparison(),
|
|
401
433
|
expiresAt = future(limits.loginSeconds);
|
|
434
|
+
let owner: string | undefined;
|
|
402
435
|
await db.begin(async (tx) => {
|
|
403
436
|
const candidates = await tx<
|
|
404
437
|
AccountRow[]
|
|
@@ -422,7 +455,10 @@ export const createAppApprovalService = (
|
|
|
422
455
|
// public shape and a decoy pending transaction; no account enumeration.
|
|
423
456
|
await tx`INSERT INTO auth.app_logins(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at)
|
|
424
457
|
VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},${category},${hash(browserSecret)},${challenge},${code},${expiresAt})`;
|
|
458
|
+
owner = candidate?.id;
|
|
425
459
|
});
|
|
460
|
+
// Not awaited: decoy and real requests answer alike, and push is only a wake-up.
|
|
461
|
+
if (owner) void wake(cfg, owner, id);
|
|
426
462
|
return { requestId: id, browserSecret, comparison: code, expiresAt: iso(expiresAt), pollAfterSeconds: limits.pollSeconds };
|
|
427
463
|
},
|
|
428
464
|
deviceCommand: async (request: AppDeviceRequest) => {
|
|
@@ -448,6 +484,10 @@ export const createAppApprovalService = (
|
|
|
448
484
|
if (!used.length) return reject("CONFLICT", 409);
|
|
449
485
|
await tx`UPDATE auth.app_devices SET last_used_at=now() WHERE id=${device.id}::uuid`;
|
|
450
486
|
const command = proof.command;
|
|
487
|
+
if (command.operation === "push") {
|
|
488
|
+
await tx`UPDATE auth.app_devices SET push_token=${command.token} WHERE id=${device.id}::uuid`;
|
|
489
|
+
return { state: "updated" as const };
|
|
490
|
+
}
|
|
451
491
|
if (command.operation === "revoke") {
|
|
452
492
|
await tx`UPDATE auth.app_devices SET revoked_at=now() WHERE id=${device.id}::uuid`;
|
|
453
493
|
await record(tx, "device.revoke", device.user_id, device.id);
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* `:::warning Before deleting`.
|
|
12
12
|
*
|
|
13
13
|
* Supported types: note, info, success, warning, danger
|
|
14
|
+
*
|
|
15
|
+
* `notices: "minimal"` renders only the tone colour: no icon and no automatic
|
|
16
|
+
* label. The type name stays available to screen readers, and an explicit
|
|
17
|
+
* title is still shown.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
import { NOTICE_CARD_CLASSES, NOTICE_CARD_ICONS, type NoticeTone } from "@k2b/ui";
|
|
@@ -50,7 +54,9 @@ const renderInlineContent = (content: string): string => {
|
|
|
50
54
|
.replace(/\n/g, "<br>");
|
|
51
55
|
};
|
|
52
56
|
|
|
53
|
-
export
|
|
57
|
+
export type NoticeStyle = "card" | "minimal";
|
|
58
|
+
|
|
59
|
+
export function infoBlocksExtension(notices: NoticeStyle = "card"): MarkedExtension {
|
|
54
60
|
return {
|
|
55
61
|
extensions: [
|
|
56
62
|
{
|
|
@@ -78,9 +84,14 @@ export function infoBlocksExtension(): MarkedExtension {
|
|
|
78
84
|
const blockType = token.blockType as BlockType;
|
|
79
85
|
const config = blockConfig[blockType];
|
|
80
86
|
const content = escapeHtml(token.content as string);
|
|
81
|
-
const title = escapeHtml((token.title as string | undefined) ?? config.label);
|
|
82
87
|
const renderedContent = renderInlineContent(content);
|
|
83
88
|
|
|
89
|
+
if (notices === "minimal") {
|
|
90
|
+
const title = token.title ? `<p class="${NOTICE_CARD_CLASSES.title}">${escapeHtml(token.title as string)}</p>` : "";
|
|
91
|
+
return `<aside class="${NOTICE_CARD_CLASSES.root}" data-tone="${config.tone}" role="note"><span class="sr-only">${config.label}: </span>${title}<div class="${NOTICE_CARD_CLASSES.body}">${renderedContent}</div></aside>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const title = escapeHtml((token.title as string | undefined) ?? config.label);
|
|
84
95
|
return `<aside class="${NOTICE_CARD_CLASSES.root}" data-tone="${config.tone}">
|
|
85
96
|
<div class="${NOTICE_CARD_CLASSES.inner}">
|
|
86
97
|
<i class="${NOTICE_CARD_ICONS[config.tone]} ${NOTICE_CARD_CLASSES.icon}" aria-hidden="true"></i>
|
|
@@ -11,7 +11,7 @@ import { markdownClient } from "./client";
|
|
|
11
11
|
import { codeExtension } from "./extensions/code";
|
|
12
12
|
import { guidedHelpExtension } from "./extensions/guided-help";
|
|
13
13
|
import { imagesExtension } from "./extensions/images";
|
|
14
|
-
import { infoBlocksExtension } from "./extensions/info-blocks";
|
|
14
|
+
import { infoBlocksExtension, type NoticeStyle } from "./extensions/info-blocks";
|
|
15
15
|
import { katexExtension } from "./extensions/katex";
|
|
16
16
|
import { linksExtension } from "./extensions/links";
|
|
17
17
|
import { markExtension } from "./extensions/mark";
|
|
@@ -22,7 +22,7 @@ import { taskListExtension } from "./extensions/task-list";
|
|
|
22
22
|
// Create a configured marked instance
|
|
23
23
|
type MarkdownProfile = "content" | "help";
|
|
24
24
|
|
|
25
|
-
const createMarked = (profile: MarkdownProfile = "content") => {
|
|
25
|
+
const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle = "card") => {
|
|
26
26
|
const marked = new Marked();
|
|
27
27
|
|
|
28
28
|
marked.use({
|
|
@@ -32,7 +32,7 @@ const createMarked = (profile: MarkdownProfile = "content") => {
|
|
|
32
32
|
|
|
33
33
|
// Apply extensions in order
|
|
34
34
|
// Note: katexExtension must come before codeExtension to handle ```math blocks
|
|
35
|
-
marked.use(infoBlocksExtension());
|
|
35
|
+
marked.use(infoBlocksExtension(notices));
|
|
36
36
|
marked.use(taskListExtension());
|
|
37
37
|
marked.use(tablesExtension());
|
|
38
38
|
marked.use(linksExtension({ internalTarget: profile === "help" ? "_self" : "_blank" }));
|
|
@@ -48,8 +48,16 @@ const createMarked = (profile: MarkdownProfile = "content") => {
|
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
const marked = createMarked();
|
|
51
|
+
const minimalNoticeMarked = createMarked("content", "minimal");
|
|
51
52
|
const helpMarked = createMarked("help");
|
|
52
53
|
|
|
54
|
+
export type MarkdownRenderOptions = {
|
|
55
|
+
/** `"minimal"` shows notices as tone colour only; the type name stays for screen readers. */
|
|
56
|
+
notices?: NoticeStyle;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const markedFor = (options: MarkdownRenderOptions): Marked => (options.notices === "minimal" ? minimalNoticeMarked : marked);
|
|
60
|
+
|
|
53
61
|
const sanitizeRenderedHtml = (html: string): string =>
|
|
54
62
|
sanitizeHtml(html, {
|
|
55
63
|
allowedTags: [
|
|
@@ -91,6 +99,7 @@ const sanitizeRenderedHtml = (html: string): string =>
|
|
|
91
99
|
"*": ["aria-hidden", "aria-label", "class", "data-help-icon", "data-tone", "id", "title"],
|
|
92
100
|
a: ["href", "name", "rel", "target", "title"],
|
|
93
101
|
annotation: ["encoding"],
|
|
102
|
+
aside: [{ name: "role", multiple: false, values: ["note"] }],
|
|
94
103
|
code: ["class"],
|
|
95
104
|
div: ["class", "data-block-name", "style"],
|
|
96
105
|
img: ["alt", "class", "height", "loading", "src", "title", "width", "style"],
|
|
@@ -146,10 +155,10 @@ const sanitizeRenderedHtml = (html: string): string =>
|
|
|
146
155
|
* @see MarkdownView component for displaying the rendered HTML
|
|
147
156
|
* @see initMarkdownEnhancements for client-side Mermaid support
|
|
148
157
|
*/
|
|
149
|
-
export function renderMarkdown(content: string): string {
|
|
158
|
+
export function renderMarkdown(content: string, options: MarkdownRenderOptions = {}): string {
|
|
150
159
|
if (!content || typeof content !== "string") return "";
|
|
151
160
|
|
|
152
|
-
const html =
|
|
161
|
+
const html = markedFor(options).parse(content);
|
|
153
162
|
if (typeof html !== "string") return "";
|
|
154
163
|
|
|
155
164
|
return sanitizeRenderedHtml(html);
|
|
@@ -158,10 +167,10 @@ export function renderMarkdown(content: string): string {
|
|
|
158
167
|
/**
|
|
159
168
|
* Render markdown to HTML synchronously.
|
|
160
169
|
*/
|
|
161
|
-
export function renderMarkdownSync(content: string): string {
|
|
170
|
+
export function renderMarkdownSync(content: string, options: MarkdownRenderOptions = {}): string {
|
|
162
171
|
if (!content || typeof content !== "string") return "";
|
|
163
172
|
|
|
164
|
-
const html =
|
|
173
|
+
const html = markedFor(options).parse(content);
|
|
165
174
|
if (typeof html !== "string") return "";
|
|
166
175
|
|
|
167
176
|
return sanitizeRenderedHtml(html);
|