@k2b/cloud 0.21.1 → 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.
- package/package.json +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/define-app.ts +81 -7
- package/src/_internal/page-responses.ts +19 -3
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/client/controller.ts +17 -3
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/live-routes.ts +2 -2
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +161 -0
- package/src/api/pwa.ts +179 -0
- package/src/browser/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +826 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +150 -30
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pwa-devices.ts +574 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/extensions/info-blocks.ts +18 -47
- package/src/shared/markdown/index.ts +5 -14
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +9 -34
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +69 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/effects.css +6 -30
- package/src/styles/global.css +11 -0
- package/src/styles/tokens.css +5 -1
- package/src/styles/utilities-feedback.css +15 -5
- package/src/styles/utilities-layout.css +4 -0
- package/src/styles/utilities-markdown-editor.css +20 -2
- package/src/styles/utilities-markdown-table.css +35 -17
- 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
|
};
|
package/src/ai/live-routes.ts
CHANGED
|
@@ -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.
|
|
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();
|
package/src/ai/pdf-render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
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
|
|
228
|
-
* it by hours, and authorizing once at connect time means
|
|
229
|
-
* keeps delivering model output until
|
|
230
|
-
* Returning false closes the stream; 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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/api/app-approval.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
};
|