@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.13.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.0",
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 function infoBlocksExtension(): MarkedExtension {
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 = marked.parse(content);
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 = marked.parse(content);
173
+ const html = markedFor(options).parse(content);
165
174
  if (typeof html !== "string") return "";
166
175
 
167
176
  return sanitizeRenderedHtml(html);