@k2b/cloud 0.22.0 → 0.23.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.
Files changed (88) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/define-app.ts +81 -7
  7. package/src/_internal/page-responses.ts +19 -3
  8. package/src/_internal/registry-validation.ts +6 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/static-assets.ts +18 -3
  11. package/src/access/ResourceApiKeys.tsx +30 -30
  12. package/src/ai/capability-execution.ts +5 -0
  13. package/src/ai/client/controller.ts +17 -3
  14. package/src/ai/client/transport.ts +47 -3
  15. package/src/ai/live-routes.ts +2 -2
  16. package/src/ai/pdf-render.ts +2 -22
  17. package/src/ai/routes.ts +5 -4
  18. package/src/ai/stream.ts +89 -65
  19. package/src/api/app-approval.ts +5 -1
  20. package/src/api/index.ts +3 -0
  21. package/src/api/me.ts +19 -5
  22. package/src/api/pwa-phone.ts +161 -0
  23. package/src/api/pwa.ts +179 -0
  24. package/src/browser/app-session.ts +69 -0
  25. package/src/browser/live-connection.ts +211 -0
  26. package/src/browser/live-websocket.ts +31 -11
  27. package/src/browser/live.ts +2 -0
  28. package/src/contracts/app.ts +10 -0
  29. package/src/contracts/index.ts +1 -0
  30. package/src/contracts/pwa-paths.ts +6 -0
  31. package/src/contracts/pwa.ts +156 -0
  32. package/src/contracts/registry.ts +7 -0
  33. package/src/contracts/shared.ts +7 -1
  34. package/src/events/index.ts +2 -0
  35. package/src/events/live-engine.ts +826 -0
  36. package/src/events/live-protocol.ts +31 -0
  37. package/src/events/live.ts +298 -0
  38. package/src/server/actor.ts +7 -0
  39. package/src/server/api-client.ts +23 -2
  40. package/src/server/index.ts +3 -1
  41. package/src/server/middleware/auth.ts +80 -8
  42. package/src/server/middleware/openapi.ts +2 -1
  43. package/src/server/services/access.ts +49 -0
  44. package/src/server/services/index.ts +1 -0
  45. package/src/services/app-approval.ts +6 -25
  46. package/src/services/audit/index.ts +2 -0
  47. package/src/services/branding/app-icon-source.ts +40 -0
  48. package/src/services/branding/app-icons.ts +112 -0
  49. package/src/services/branding/icon-render-worker.ts +69 -0
  50. package/src/services/branding/icon-svg.ts +22 -0
  51. package/src/services/identity/invocation-actor.ts +8 -5
  52. package/src/services/identity/invocation-authority.ts +1 -0
  53. package/src/services/identity/invocation-token.ts +6 -0
  54. package/src/services/index.ts +1 -0
  55. package/src/services/outbox.ts +150 -30
  56. package/src/services/pairing-secret.ts +16 -0
  57. package/src/services/pwa-devices.ts +574 -0
  58. package/src/services/session/index.ts +212 -57
  59. package/src/services/session/recent.ts +42 -0
  60. package/src/services/session/user.ts +15 -5
  61. package/src/shared/index.ts +1 -0
  62. package/src/{browser → shared}/locale-preference.ts +1 -1
  63. package/src/shared/markdown/extensions/info-blocks.ts +18 -47
  64. package/src/shared/markdown/index.ts +5 -14
  65. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  66. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  67. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  68. package/src/ssr/Layout.tsx +10 -12
  69. package/src/ssr/LayoutHeader.tsx +14 -2
  70. package/src/ssr/LayoutHelp.tsx +2 -4
  71. package/src/ssr/LayoutRail.tsx +13 -4
  72. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  73. package/src/ssr/MobileProfileActions.tsx +19 -13
  74. package/src/ssr/PageError.tsx +23 -1
  75. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  76. package/src/ssr/PwaLayout.tsx +69 -0
  77. package/src/ssr/PwaRuntime.island.tsx +105 -0
  78. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  79. package/src/ssr/app-navigation.ts +25 -10
  80. package/src/ssr/index.ts +8 -1
  81. package/src/ssr/layout-context.ts +1 -1
  82. package/src/ssr/preference-controller.ts +2 -2
  83. package/src/ssr/profile-actions.ts +3 -1
  84. package/src/ssr/profile-preferences-messages.ts +2 -0
  85. package/src/ssr/pwa-messages.ts +24 -0
  86. package/src/styles/global.css +6 -0
  87. package/src/styles/utilities-feedback.css +2 -2
  88. package/scripts/build-pdf-renderer.ts +0 -39
@@ -13,6 +13,38 @@ export type AiConversationStreamTransport = {
13
13
  }) => AiStreamHandle;
14
14
  };
15
15
 
16
+ /**
17
+ * Why a conversation stream ended for good. The codes match the live
18
+ * WebSocket's turn errors and revocations.
19
+ */
20
+ export type AiStreamErrorCode = "login_required" | "access_denied" | "not_found";
21
+
22
+ /** A stream that will not recover by reconnecting; `message` comes from the server when it sent one. */
23
+ export class AiStreamError extends Error {
24
+ readonly code: AiStreamErrorCode;
25
+
26
+ constructor(code: AiStreamErrorCode, message: string) {
27
+ super(message);
28
+ this.name = "AiStreamError";
29
+ this.code = code;
30
+ }
31
+ }
32
+
33
+ const TERMINAL_STATUS_CODES: Readonly<Record<number, AiStreamErrorCode>> = {
34
+ 401: "login_required",
35
+ 403: "access_denied",
36
+ 404: "not_found",
37
+ };
38
+
39
+ const terminalStreamError = async (response: Response, code: AiStreamErrorCode): Promise<AiStreamError> => {
40
+ const body: unknown = await response.json().catch(() => null);
41
+ const message =
42
+ body && typeof body === "object" && "message" in body && typeof body.message === "string" && body.message
43
+ ? body.message
44
+ : `AI stream failed: ${response.status}`;
45
+ return new AiStreamError(code, message);
46
+ };
47
+
16
48
  const RECONNECT_BASE_MS = 500;
17
49
  const RECONNECT_MAX_MS = 5_000;
18
50
  const CONNECT_TIMEOUT_MS = 10_000;
@@ -53,12 +85,15 @@ export async function* parseAiSse(response: Response, signal: AbortSignal): Asyn
53
85
  /**
54
86
  * Subscribe to a conversation's SSE stream with automatic reconnect. Each
55
87
  * (re)connect starts with a fresh `state` event, so the projection self-heals on
56
- * every reconnect without cursor bookkeeping.
88
+ * every reconnect without cursor bookkeeping. A 401, 403, or 404 cannot heal
89
+ * that way: the subscription stops and reports an `AiStreamError` once through
90
+ * `onError`, its only signal that the stream ended. Status stays where it was.
57
91
  */
58
92
  export const subscribeAiStream = (input: {
59
93
  url: string;
60
94
  onEvent: (event: AiStreamEvent) => void;
61
95
  onStatus?: (status: AiStreamConnectionStatus) => void;
96
+ onError?: (error: AiStreamError) => void;
62
97
  fetch?: AiStreamFetch;
63
98
  }): AiStreamHandle => {
64
99
  const fetchStream: AiStreamFetch = input.fetch ?? fetch;
@@ -80,8 +115,17 @@ export const subscribeAiStream = (input: {
80
115
  try {
81
116
  input.onStatus?.(reconnectDelay === RECONNECT_BASE_MS ? "connecting" : "reconnecting");
82
117
  const response = await fetchStream(input.url, { signal: attempt.signal, headers: { Accept: "text/event-stream" } });
83
- clearConnectTimer();
84
118
  if (stopped) return;
119
+ const terminalCode = TERMINAL_STATUS_CODES[response.status];
120
+ if (terminalCode) {
121
+ // The connect timeout still bounds reading the error body.
122
+ const error = await terminalStreamError(response, terminalCode);
123
+ if (stopped) return;
124
+ stopped = true;
125
+ input.onError?.(error);
126
+ return;
127
+ }
128
+ clearConnectTimer();
85
129
  if (!response.ok || !response.body) throw new Error(`AI stream failed: ${response.status}`);
86
130
  input.onStatus?.("open");
87
131
  reconnectDelay = RECONNECT_BASE_MS;
@@ -113,5 +157,5 @@ export const subscribeAiStream = (input: {
113
157
  };
114
158
 
115
159
  export const aiSseConversationStreamTransport: AiConversationStreamTransport = {
116
- subscribe: ({ url, onEvent, onStatus }) => subscribeAiStream({ url, onEvent, onStatus }),
160
+ subscribe: ({ url, onEvent, onStatus, onError }) => subscribeAiStream({ url, onEvent, onStatus, onError }),
117
161
  };
@@ -353,8 +353,8 @@ const buildAiLiveRoutes = (config: AiLiveRoutesConfig = {}) => {
353
353
 
354
354
  return new Hono<AuthContext>().get(
355
355
  "/",
356
- upgradeWebSocket((c) => {
357
- const sessionToken = auth.session.getToken(c);
356
+ upgradeWebSocket(async (c) => {
357
+ const sessionToken = await auth.session.resolveToken(c);
358
358
  const messages = aiLiveMessages(getLocale(c));
359
359
  let ctx: WsContext | null = null;
360
360
  let processing: Promise<void> = Promise.resolve();
@@ -1,5 +1,5 @@
1
- import { fileURLToPath } from "node:url";
2
1
  import { z } from "zod";
2
+ import { spawnCanvasWorker } from "../_internal/canvas-worker";
3
3
 
4
4
  declare const __CLOUD_PDF_RENDER_WORKER__: string | undefined;
5
5
  let activeRenders = 0;
@@ -31,27 +31,7 @@ export async function renderPdfPages(bytes: Uint8Array, pages: number[] = [1], s
31
31
  typeof __CLOUD_PDF_RENDER_WORKER__ === "string"
32
32
  ? new URL(__CLOUD_PDF_RENDER_WORKER__, import.meta.url)
33
33
  : new URL("./pdf-render-worker.ts", import.meta.url);
34
- // RLIMIT_DATA bounds the decoder heap/native writable mappings on Linux.
35
- // Positional arguments keep paths out of shell code. No elevated privileges.
36
- const child = Bun.spawn(
37
- ["/bin/sh", "-c", 'ulimit -d 524288 || exit 70; exec "$@"', "pdf-decoder", process.execPath, "--no-env-file", fileURLToPath(worker)],
38
- {
39
- stdin: new Blob([JSON.stringify({ pdf: Buffer.from(bytes).toString("base64"), pages })]),
40
- stdout: "pipe",
41
- stderr: "ignore",
42
- // No application credentials or configuration reach the decoder, only fixed
43
- // thread pool sizes: the Bun work pool, canvas tokio runtime, and JSC GC
44
- // markers otherwise grow with the host's cores, and RLIMIT_DATA also counts
45
- // each thread's stack and allocator memory.
46
- env: {
47
- PATH: process.env.PATH,
48
- LANG: "C.UTF-8",
49
- UV_THREADPOOL_SIZE: "2",
50
- TOKIO_WORKER_THREADS: "2",
51
- BUN_JSC_numberOfGCMarkers: "2",
52
- },
53
- },
54
- );
34
+ const child = spawnCanvasWorker(worker, "pdf-decoder", JSON.stringify({ pdf: Buffer.from(bytes).toString("base64"), pages }));
55
35
  const stop = () => child.kill("SIGKILL");
56
36
  boundedSignal.addEventListener("abort", stop, { once: true });
57
37
  if (boundedSignal.aborted) stop();
package/src/ai/routes.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  respond,
17
17
  v,
18
18
  } from "../server";
19
+ import { isRequestCredentialCurrent } from "../server/middleware/auth";
19
20
  import { logger } from "../services/logging";
20
21
  import { coreSettings } from "../services/settings/api";
21
22
  import type { AiToolApprovalContext } from "./approvals";
@@ -1108,11 +1109,11 @@ export const aiRoutes = (() => {
1108
1109
  return createAiConversationStreamResponse({
1109
1110
  conversation,
1110
1111
  signal: c.req.raw.signal,
1111
- // Both calls hit the database: resolveContext re-runs the app's own
1112
- // access check, loadConversation re-checks ownership and resource
1113
- // scope. The actor itself is a request-time snapshot, so this catches
1114
- // a withdrawn grant but not a revoked credential — see r9358ceb.
1112
+ // The actor is a request-time snapshot: re-authenticate its
1113
+ // credential first, so a revoked session or API key ends the stream,
1114
+ // then re-check ownership and resource scope of the conversation.
1115
1115
  revalidate: async () => {
1116
+ if (!(await isRequestCredentialCurrent(c))) return false;
1116
1117
  const current = await resolveContext(c);
1117
1118
  if (current instanceof Response) return false;
1118
1119
  return Boolean(await loadConversation(c, current));
package/src/ai/stream.ts CHANGED
@@ -16,6 +16,16 @@ import type { AiConversation } from "./types";
16
16
 
17
17
  const log = logger("ai:stream");
18
18
 
19
+ /**
20
+ * Bytes an SSE reader may leave unread before its stream ends, the live
21
+ * socket's send-buffer limit. The check runs before each event, so the queue
22
+ * can exceed it by the event that crosses it, and a state snapshot or a
23
+ * finished turn with its messages can be larger than one live event. The
24
+ * reconnect starts from a fresh state snapshot, so ending the stream loses
25
+ * nothing durable.
26
+ */
27
+ const AI_STREAM_MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
28
+
19
29
  /**
20
30
  * Live fanout for wire events. Events carry their full payload so the SSE hot
21
31
  * path never touches Postgres; durable state lives in ai.messages plus the
@@ -224,11 +234,11 @@ export const createAiConversationStreamResponse = (input: {
224
234
  signal?: AbortSignal;
225
235
  heartbeatMs?: number;
226
236
  /**
227
- * Re-checked on every heartbeat. A stream can outlive the grant that opened
228
- * it by hours, and authorizing once at connect time means a withdrawn grant
229
- * keeps delivering model output until the client happens to disconnect.
230
- * Returning false closes the stream; the client's reconnect then meets the
231
- * ordinary 403.
237
+ * Re-checked on every heartbeat. A stream can outlive the credential and the
238
+ * grant that opened it by hours, and authorizing once at connect time means
239
+ * a revoked session or withdrawn grant keeps delivering model output until
240
+ * the client happens to disconnect. Returning false closes the stream; the
241
+ * client's reconnect then meets the ordinary 401, 403, or 404.
232
242
  */
233
243
  revalidate?: () => Promise<boolean>;
234
244
  }): Response => {
@@ -241,73 +251,87 @@ export const createAiConversationStreamResponse = (input: {
241
251
  if (input.signal?.aborted) abortLive();
242
252
  else input.signal?.addEventListener("abort", abortLive, { once: true });
243
253
 
244
- const stream = new ReadableStream<Uint8Array>({
245
- async start(controller) {
246
- const enqueue = (chunk: Uint8Array): boolean => {
247
- if (closed) return false;
254
+ const stream = new ReadableStream<Uint8Array>(
255
+ {
256
+ async start(controller) {
257
+ const enqueue = (chunk: Uint8Array): boolean => {
258
+ if (closed) return false;
259
+ // desiredSize is the budget left after what the reader has not taken
260
+ // yet. A reader that falls this far behind gets a fresh snapshot on
261
+ // reconnect instead of an ever-growing queue on the server.
262
+ if ((controller.desiredSize ?? 0) <= 0) {
263
+ log.warn("AI conversation stream closed: the reader fell behind", {
264
+ conversationId: input.conversation.id,
265
+ maxBufferedBytes: AI_STREAM_MAX_BUFFERED_BYTES,
266
+ });
267
+ close();
268
+ return false;
269
+ }
270
+ try {
271
+ controller.enqueue(chunk);
272
+ return true;
273
+ } catch {
274
+ close();
275
+ return false;
276
+ }
277
+ };
278
+ const close = () => {
279
+ if (closed) return;
280
+ closed = true;
281
+ if (heartbeat) clearInterval(heartbeat);
282
+ heartbeat = undefined;
283
+ input.signal?.removeEventListener("abort", abortLive);
284
+ abortLive();
285
+ try {
286
+ controller.close();
287
+ } catch {
288
+ // The client may already have cancelled the stream.
289
+ }
290
+ };
291
+
292
+ if (heartbeatMs > 0) {
293
+ heartbeat = setInterval(() => {
294
+ if (!enqueue(encodeSseHeartbeat())) return;
295
+ if (!input.revalidate) return;
296
+ // Fail closed: a revalidation that throws is not a pass.
297
+ void input
298
+ .revalidate()
299
+ .catch((error) => {
300
+ log.warn("AI conversation stream revalidation failed", {
301
+ conversationId: input.conversation.id,
302
+ error: error instanceof Error ? error.message : "revalidation failed",
303
+ });
304
+ return false;
305
+ })
306
+ .then((allowed) => {
307
+ if (!allowed) close();
308
+ });
309
+ }, heartbeatMs);
310
+ }
311
+
248
312
  try {
249
- controller.enqueue(chunk);
250
- return true;
251
- } catch {
313
+ for await (const event of streamAiConversationEvents({ conversation: input.conversation, signal: liveAbort.signal })) {
314
+ if (!enqueue(encodeSseEvent(event))) return;
315
+ }
316
+ } catch (error) {
317
+ if (!liveAbort.signal.aborted) {
318
+ log.warn("AI conversation stream failed", {
319
+ conversationId: input.conversation.id,
320
+ error: error instanceof Error ? error.message : "AI conversation stream failed",
321
+ });
322
+ }
323
+ } finally {
252
324
  close();
253
- return false;
254
325
  }
255
- };
256
- const close = () => {
257
- if (closed) return;
326
+ },
327
+ cancel() {
258
328
  closed = true;
259
329
  if (heartbeat) clearInterval(heartbeat);
260
- heartbeat = undefined;
261
- input.signal?.removeEventListener("abort", abortLive);
262
330
  abortLive();
263
- try {
264
- controller.close();
265
- } catch {
266
- // The client may already have cancelled the stream.
267
- }
268
- };
269
-
270
- if (heartbeatMs > 0) {
271
- heartbeat = setInterval(() => {
272
- if (!enqueue(encodeSseHeartbeat())) return;
273
- if (!input.revalidate) return;
274
- // Fail closed: a revalidation that throws is not a pass.
275
- void input
276
- .revalidate()
277
- .catch((error) => {
278
- log.warn("AI conversation stream revalidation failed", {
279
- conversationId: input.conversation.id,
280
- error: error instanceof Error ? error.message : "revalidation failed",
281
- });
282
- return false;
283
- })
284
- .then((allowed) => {
285
- if (!allowed) close();
286
- });
287
- }, heartbeatMs);
288
- }
289
-
290
- try {
291
- for await (const event of streamAiConversationEvents({ conversation: input.conversation, signal: liveAbort.signal })) {
292
- if (!enqueue(encodeSseEvent(event))) return;
293
- }
294
- } catch (error) {
295
- if (!liveAbort.signal.aborted) {
296
- log.warn("AI conversation stream failed", {
297
- conversationId: input.conversation.id,
298
- error: error instanceof Error ? error.message : "AI conversation stream failed",
299
- });
300
- }
301
- } finally {
302
- close();
303
- }
304
- },
305
- cancel() {
306
- closed = true;
307
- if (heartbeat) clearInterval(heartbeat);
308
- abortLive();
331
+ },
309
332
  },
310
- });
333
+ new ByteLengthQueuingStrategy({ highWaterMark: AI_STREAM_MAX_BUFFERED_BYTES }),
334
+ );
311
335
 
312
336
  return new Response(stream, { headers: sseHeaders });
313
337
  };
@@ -24,9 +24,13 @@ type Service = ReturnType<typeof createAppApprovalService>;
24
24
  /** This is a separate credential surface, never a user API-key or OAuth scope. */
25
25
  export const createAppApprovalRoutes = (service: Service = appApproval) => {
26
26
  const actor = async (c: Parameters<typeof auth.session.getToken>[0]): Promise<AppApprovalActor> => {
27
- const token = auth.session.getToken(c);
27
+ // The web session (or an explicit bearer session), never the mobile app's session: a phone
28
+ // that pairs sign-in devices would hand out authority that outlives its removal.
29
+ const token = auth.session.getBearerToken(c) ?? auth.session.getWebToken(c);
30
+ if (!token && auth.session.getAppToken(c)) throw new AppApprovalError("FORBIDDEN", 403);
28
31
  const session = token ? await auth.session.authenticateRequest(c, token) : null;
29
32
  if (!session) throw new AppApprovalError("REAUTHENTICATE", 403);
33
+ if (session.data.kind === "app") throw new AppApprovalError("FORBIDDEN", 403);
30
34
  return { userId: session.user.id, sid: session.data.sid, admin: session.user.roles.includes("admin") };
31
35
  };
32
36
  return new Hono<AuthContext>()
package/src/api/index.ts CHANGED
@@ -38,12 +38,14 @@ import { createCapabilityRoutes } from "./capabilities";
38
38
  import { createHelpRoutes } from "./help";
39
39
  import { createMcpRoutes } from "./mcp";
40
40
  import meRoutes from "./me";
41
+ import { createPwaRoutes } from "./pwa";
41
42
  import { createSearchRoutes } from "./search";
42
43
  import { createSyncOpsProxyRoutes } from "./sync-ops";
43
44
  import { createWidgetRoutes } from "./widgets";
44
45
 
45
46
  export { type CapabilityDispatchDependencies, dispatchCapability } from "./capabilities";
46
47
  export { cliPluginListRoutes } from "./cli-plugins";
48
+ export { createPwaPhoneRoutes } from "./pwa-phone";
47
49
 
48
50
  /**
49
51
  * Single-expression chain so `typeof buildCoreApi()` captures every route's
@@ -64,6 +66,7 @@ const buildCoreApi = (options: CoreApiOptions) => {
64
66
  return new Hono()
65
67
  .use(prettyJSON())
66
68
  .route("/auth/app-approval/v1", createAppApprovalRoutes())
69
+ .route("/auth/pwa/v1", createPwaRoutes())
67
70
  .route("/auth", createAuthRoutes(options.notifications))
68
71
  .route("/me", meRoutes)
69
72
  .route("/accounts", accountsEntitiesRoutes)
package/src/api/me.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { ok } from "@k2b/stdlib";
8
- import { Hono, type MiddlewareHandler } from "hono";
8
+ import { type Context, Hono, type MiddlewareHandler } from "hono";
9
9
  import { createMiddleware } from "hono/factory";
10
10
  import { describeRoute } from "hono-openapi";
11
11
  import { z } from "zod";
@@ -37,6 +37,7 @@ import {
37
37
  } from "../contracts";
38
38
  import { CapabilityAppIdSchema } from "../contracts/capabilities";
39
39
  import { type AuthContext, auth, getLocale, jsonResponse, rateLimit, requiresAuth, respond, v } from "../server";
40
+ import { APP_SESSION_FORBIDDEN } from "../server/middleware/auth";
40
41
  import {
41
42
  accountLifecycle,
42
43
  accountsAppService as accountsService,
@@ -59,6 +60,9 @@ const toAccountsActor = (user: AuthContext["Variables"]["user"]) => ({
59
60
  provider: user.provider,
60
61
  });
61
62
 
63
+ /** The answer for actions the mobile app's session may not take. */
64
+ const webOnly = (c: Context) => c.json(APP_SESSION_FORBIDDEN, 403);
65
+
62
66
  /**
63
67
  * Guards the endpoints that manage how the account is authenticated: passkeys,
64
68
  * API keys, the password, and account deletion.
@@ -71,7 +75,8 @@ const toAccountsActor = (user: AuthContext["Variables"]["user"]) => ({
71
75
  *
72
76
  * A credential may still *read* its account's passkeys and keys; only the
73
77
  * mutations are gated. Browser sessions and user-issued OAuth access tokens
74
- * both resolve to a `user` actor and are unaffected.
78
+ * both resolve to a `user` actor and are unaffected. The mobile app's session
79
+ * is rejected too: whatever it created would outlive removing the phone.
75
80
  */
76
81
  const requireDirectUserActor = createMiddleware<AuthContext>(async (c, next) => {
77
82
  if (c.get("actor")?.kind !== "user") {
@@ -83,6 +88,7 @@ const requireDirectUserActor = createMiddleware<AuthContext>(async (c, next) =>
83
88
  403,
84
89
  );
85
90
  }
91
+ if (auth.isAppSession(c)) return webOnly(c);
86
92
  return next();
87
93
  });
88
94
 
@@ -134,6 +140,8 @@ const requireSessionMandateCreation = createMiddleware<AuthContext>(async (c, ne
134
140
  if (c.get("credentialKind") !== "session" || c.get("actor")?.kind !== "user") {
135
141
  return c.json({ code: "FORBIDDEN", message: "Sign in with a browser session to create background authority" }, 403);
136
142
  }
143
+ // A mandate would keep acting after the phone is removed.
144
+ if (auth.isAppSession(c)) return webOnly(c);
137
145
  return next();
138
146
  });
139
147
 
@@ -231,6 +239,7 @@ const app = new Hono<AuthContext>()
231
239
 
232
240
  .post(
233
241
  "/notifications/browser/endpoints",
242
+ auth.rejectAppSession,
234
243
  describeRoute({
235
244
  tags: ["Me"],
236
245
  summary: "Register browser notification endpoint",
@@ -240,6 +249,7 @@ const app = new Hono<AuthContext>()
240
249
  200: jsonResponse(BrowserNotificationEndpointSchema, "Registered browser notification endpoint"),
241
250
  400: jsonResponse(ErrorResponseSchema, "Invalid browser subscription"),
242
251
  401: jsonResponse(ErrorResponseSchema, "Authentication required"),
252
+ 403: jsonResponse(ErrorResponseSchema, "Not available in the mobile app"),
243
253
  },
244
254
  }),
245
255
  v("json", RegisterBrowserNotificationEndpointSchema),
@@ -400,17 +410,21 @@ const app = new Hono<AuthContext>()
400
410
  200: jsonResponse(MessageResponseSchema, "Profile updated"),
401
411
  400: jsonResponse(ErrorResponseSchema, "Failed to update profile"),
402
412
  401: jsonResponse(ErrorResponseSchema, "Authentication required"),
413
+ 403: jsonResponse(ErrorResponseSchema, "SSH keys cannot be changed in the mobile app"),
403
414
  },
404
415
  }),
405
416
  v("json", UpdateProfileSchema),
406
- async (c) =>
407
- respond(c, async () => {
417
+ async (c) => {
418
+ // SSH keys sign in to the directory's hosts and would outlive removing the phone.
419
+ if (c.req.valid("json").ipa?.sshPublicKeys !== undefined && auth.isAppSession(c)) return webOnly(c);
420
+ return respond(c, async () => {
408
421
  const user = c.get("user");
409
422
  const data = c.req.valid("json");
410
423
  const result = await accountsService.user.update({ actor: toAccountsActor(user), id: user.id, data });
411
424
  if (!result.ok) return result;
412
425
  return ok({ message: "Profile updated." });
413
- }),
426
+ });
427
+ },
414
428
  )
415
429
 
416
430
  .put(
@@ -0,0 +1,161 @@
1
+ import { type Context, Hono } from "hono";
2
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3
+ import { env } from "../config/env";
4
+ import { PWA_AUTH_PATH, PWA_COOKIES, PWA_LIMITS, PWA_SCOPE, PwaClaimSchema, type PwaLaunchState, PwaRenameSchema } from "../contracts/pwa";
5
+ import { type AuthContext, auth, rateLimit, v } from "../server";
6
+ import { type PwaCredentials, PwaError, pwaDevices } from "../services/pwa-devices";
7
+ import { defaultShellAvailable, handlePwaError, type PwaRouteOptions, pwaErrorResponse, pwaInvalidRequest, pwaTransport } from "./pwa";
8
+
9
+ const PAIRING_COOKIE_PATH = `${PWA_AUTH_PATH}/pairings`;
10
+ const cookie = { httpOnly: true, secure: !env.IS_DEVELOPMENT } as const;
11
+
12
+ const setAppCredentials = (c: Context, credentials: PwaCredentials) => {
13
+ setCookie(c, PWA_COOKIES.session, credentials.sessionToken, { ...cookie, sameSite: "Lax", path: "/", maxAge: PWA_LIMITS.sessionSeconds });
14
+ // Only Core's /pwa/_auth endpoints ever receive the device key.
15
+ setCookie(c, PWA_COOKIES.device, credentials.deviceKey, {
16
+ ...cookie,
17
+ sameSite: "Lax",
18
+ path: PWA_AUTH_PATH,
19
+ maxAge: PWA_LIMITS.idleDays * 86_400,
20
+ });
21
+ };
22
+ const clearAppCredentials = (c: Context) => {
23
+ deleteCookie(c, PWA_COOKIES.session, { ...cookie, path: "/" });
24
+ deleteCookie(c, PWA_COOKIES.device, { ...cookie, path: PWA_AUTH_PATH });
25
+ };
26
+ const clearPairing = (c: Context) => deleteCookie(c, PWA_COOKIES.pairing, { ...cookie, path: PAIRING_COOKIE_PATH });
27
+
28
+ const authenticated = async (c: Context, token: string | null) => (token ? auth.session.authenticateRequest(c, token) : null);
29
+ const appSession = async (c: Context) => {
30
+ const session = await authenticated(c, auth.session.getAppToken(c));
31
+ return session?.data.kind === "app" ? session : null;
32
+ };
33
+ const webSession = async (c: Context) => {
34
+ const session = await authenticated(c, auth.session.getWebToken(c));
35
+ return session?.data.kind === "web" ? session : null;
36
+ };
37
+
38
+ /**
39
+ * A path inside the app to return to after the launch bounce; anything else becomes `/pwa/`.
40
+ * The `pwa_launch` marker lets the page stop instead of bouncing again.
41
+ */
42
+ export const launchTarget = (to: string | undefined): string => {
43
+ const fallback = `${PWA_SCOPE}?pwa_launch=1`;
44
+ if (!to || to.length > 2048 || !to.startsWith(PWA_SCOPE) || to.includes("//") || to.includes("\\")) return fallback;
45
+ const url = new URL(to, "http://launch.invalid");
46
+ // Judge the path the browser will request: dot segments and escapes are resolved by then.
47
+ let path: string;
48
+ try {
49
+ path = decodeURIComponent(url.pathname);
50
+ } catch {
51
+ return fallback;
52
+ }
53
+ const inside = (value: string) => value.startsWith(PWA_SCOPE) && value !== PWA_AUTH_PATH && !value.startsWith(`${PWA_AUTH_PATH}/`);
54
+ if (url.origin !== "http://launch.invalid" || !inside(url.pathname) || !inside(path) || path.includes("//")) return fallback;
55
+ url.searchParams.set("pwa_launch", "1");
56
+ return url.pathname + url.search;
57
+ };
58
+
59
+ const stateUrl = (state: PwaLaunchState) => `${PWA_SCOPE}?pwa=${state}`;
60
+
61
+ /**
62
+ * Phone side of the mobile app (preview), mounted by Core at `/pwa/_auth` before its page
63
+ * catch-all. The device key and the completion secret use cookie paths below `/pwa/_auth`,
64
+ * which only Core serves; other applications see only the 24-hour app session.
65
+ */
66
+ export const createPwaPhoneRoutes = (options: PwaRouteOptions = {}) => {
67
+ const service = options.service ?? pwaDevices;
68
+ const shellAvailable = options.shellAvailable ?? defaultShellAvailable;
69
+ const limit = rateLimit({ keyBy: "ip" });
70
+ const requireShell = (c: Context) => {
71
+ if (!shellAvailable(c)) throw new PwaError("UNAVAILABLE", 503);
72
+ };
73
+ return new Hono<AuthContext>()
74
+ .onError(handlePwaError)
75
+ .use("*", ...pwaTransport())
76
+ .use("*", async (c, next) => {
77
+ const limited = await limit(c, next);
78
+ // A navigation always gets a page, never JSON: a limited launch shows the unavailable state, which retries.
79
+ if (limited instanceof Response && c.req.method === "GET" && c.req.path.endsWith("/session/launch")) {
80
+ return c.redirect(stateUrl("unavailable"), 302);
81
+ }
82
+ return limited;
83
+ })
84
+ .post("/pairings/claim", v("json", PwaClaimSchema, pwaInvalidRequest), async (c) => {
85
+ requireShell(c);
86
+ const input = c.req.valid("json");
87
+ const claimed = await service.claimPairing({ ...input, userAgent: c.req.header("User-Agent") });
88
+ setCookie(c, PWA_COOKIES.pairing, claimed.completionSecret, {
89
+ ...cookie,
90
+ sameSite: "Strict",
91
+ path: PAIRING_COOKIE_PATH,
92
+ maxAge: Math.max(1, Math.ceil((Date.parse(claimed.expiresAt) - Date.now()) / 1000)),
93
+ });
94
+ return c.json({ code: claimed.code, account: claimed.account, expiresAt: claimed.expiresAt });
95
+ })
96
+ .post("/pairings/complete", async (c) => {
97
+ requireShell(c);
98
+ const web = await webSession(c);
99
+ try {
100
+ const result = await service.completePairing(c, {
101
+ completionSecret: getCookie(c, PWA_COOKIES.pairing),
102
+ deviceKey: getCookie(c, PWA_COOKIES.device),
103
+ appSession: await appSession(c),
104
+ webUserId: web?.user.id ?? null,
105
+ });
106
+ if (result.state === "waiting") return c.json(result, 202);
107
+ if (result.credentials) setAppCredentials(c, result.credentials);
108
+ clearPairing(c);
109
+ // A Home Screen app on iOS has its own cookie jar; a web session there is only a copy from Safari.
110
+ if (result.platform === "ios" && auth.session.getWebToken(c)) deleteCookie(c, "session_token", { path: "/" });
111
+ return c.json({ state: "paired" as const });
112
+ } catch (error) {
113
+ if (error instanceof PwaError && error.code === "EXPIRED") clearPairing(c);
114
+ throw error;
115
+ }
116
+ })
117
+ .get("/session/launch", async (c) => {
118
+ const to = launchTarget(c.req.query("to"));
119
+ if (!shellAvailable(c)) return c.redirect(stateUrl("unavailable"), 302);
120
+ try {
121
+ const result = await service.renew(c, { deviceKey: getCookie(c, PWA_COOKIES.device), appSession: await appSession(c) });
122
+ if (result.outcome === "renewed") setAppCredentials(c, result.credentials);
123
+ if (result.outcome === "renewed" || result.outcome === "current") return c.redirect(to, 302);
124
+ if (result.outcome === "blocked") return c.redirect(stateUrl("blocked"), 302);
125
+ if (result.outcome === "ended") clearAppCredentials(c);
126
+ return c.redirect(stateUrl(result.outcome === "missing" ? "new" : "ended"), 302);
127
+ } catch (error) {
128
+ // Logs an unexpected failure by name only; a navigation always gets a page, never JSON.
129
+ handlePwaError(error instanceof Error ? error : new Error("UnknownError"), c);
130
+ return c.redirect(stateUrl("unavailable"), 302);
131
+ }
132
+ })
133
+ .post("/session/renew", async (c) => {
134
+ // Without the app, no phone may renew; 503 never signs a phone out.
135
+ requireShell(c);
136
+ const [result, web] = await Promise.all([
137
+ service.renew(c, { deviceKey: getCookie(c, PWA_COOKIES.device), appSession: await appSession(c) }),
138
+ webSession(c),
139
+ ]);
140
+ if (result.outcome === "missing" || result.outcome === "ended") {
141
+ clearAppCredentials(c);
142
+ return pwaErrorResponse(c, new PwaError("UNPAIRED", 401));
143
+ }
144
+ if (result.outcome === "blocked") return pwaErrorResponse(c, new PwaError("ACCOUNT_BLOCKED", 403));
145
+ if (result.outcome === "renewed") setAppCredentials(c, result.credentials);
146
+ // Android shares Chrome's cookies with the app: fetches may run as that other web account.
147
+ const otherAccount = web && web.user.id !== result.userId ? { name: web.user.displayName || web.user.uid } : undefined;
148
+ return c.json({ renewed: result.outcome === "renewed", ...(otherAccount ? { otherAccount } : {}) });
149
+ })
150
+ .patch("/session", v("json", PwaRenameSchema, pwaInvalidRequest), async (c) => {
151
+ await service.rename(getCookie(c, PWA_COOKIES.device), c.req.valid("json").name);
152
+ return c.body(null, 204);
153
+ })
154
+ .delete("/session", async (c) => {
155
+ await service.unpair(getCookie(c, PWA_COOKIES.device));
156
+ clearAppCredentials(c);
157
+ // A pairing confirmed before the sign-out must not finish on its own later.
158
+ clearPairing(c);
159
+ return c.body(null, 204);
160
+ });
161
+ };