@k2b/cloud 0.22.0 → 0.24.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/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +90 -7
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +20 -4
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/registry.ts +78 -3
- 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/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +80 -21
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/migrate.ts +35 -16
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/runtime.ts +1 -6
- package/src/ai/solid.ts +0 -5
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/help.ts +2 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +168 -0
- package/src/api/pwa.ts +179 -0
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- 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/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +2 -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 +830 -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/help.ts +3 -3
- 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 +114 -80
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +640 -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/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -23
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- 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 +70 -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/global.css +6 -0
- package/src/styles/resource-search.css +19 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- package/src/shared/markdown/extensions/info-blocks.ts +0 -108
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/help.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { helpApiMessages } from "./help-messages";
|
|
|
8
8
|
export type HelpRouteDependencies = {
|
|
9
9
|
help?: HelpReaderFactory;
|
|
10
10
|
authenticate?: MiddlewareHandler<AuthContext>;
|
|
11
|
-
renderMarkdown?: (markdown: string) => string;
|
|
11
|
+
renderMarkdown?: (markdown: string, locale: string) => string;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
export const createHelpRoutes = (dependencies: HelpRouteDependencies = {}) => {
|
|
@@ -34,7 +34,7 @@ export const createHelpRoutes = (dependencies: HelpRouteDependencies = {}) => {
|
|
|
34
34
|
id: document.documentId,
|
|
35
35
|
title: document.title,
|
|
36
36
|
markdown: document.markdown,
|
|
37
|
-
html: (dependencies.renderMarkdown ?? renderHelpMarkdown)(document.markdown),
|
|
37
|
+
html: (dependencies.renderMarkdown ?? renderHelpMarkdown)(document.markdown, document.locale),
|
|
38
38
|
};
|
|
39
39
|
return c.json(payload);
|
|
40
40
|
});
|
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,168 @@
|
|
|
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, getLocale, rateLimit, v } from "../server";
|
|
6
|
+
import { type PwaCredentials, type PwaDeviceNotifier, 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
|
+
export type PwaPhoneRouteOptions = PwaRouteOptions & {
|
|
62
|
+
/** Sends the "new phone paired" notice; completion awaits it before the phone gets its credentials. */
|
|
63
|
+
notify: PwaDeviceNotifier;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Phone side of the mobile app (preview), mounted by Core at `/pwa/_auth` before its page
|
|
68
|
+
* catch-all. The device key and the completion secret use cookie paths below `/pwa/_auth`,
|
|
69
|
+
* which only Core serves; other applications see only the 24-hour app session.
|
|
70
|
+
*/
|
|
71
|
+
export const createPwaPhoneRoutes = (options: PwaPhoneRouteOptions) => {
|
|
72
|
+
const service = options.service ?? pwaDevices;
|
|
73
|
+
const shellAvailable = options.shellAvailable ?? defaultShellAvailable;
|
|
74
|
+
const limit = rateLimit({ keyBy: "ip" });
|
|
75
|
+
const requireShell = (c: Context) => {
|
|
76
|
+
if (!shellAvailable(c)) throw new PwaError("UNAVAILABLE", 503);
|
|
77
|
+
};
|
|
78
|
+
return new Hono<AuthContext>()
|
|
79
|
+
.onError(handlePwaError)
|
|
80
|
+
.use("*", ...pwaTransport())
|
|
81
|
+
.use("*", async (c, next) => {
|
|
82
|
+
const limited = await limit(c, next);
|
|
83
|
+
// A navigation always gets a page, never JSON: a limited launch shows the unavailable state, which retries.
|
|
84
|
+
if (limited instanceof Response && c.req.method === "GET" && c.req.path.endsWith("/session/launch")) {
|
|
85
|
+
return c.redirect(stateUrl("unavailable"), 302);
|
|
86
|
+
}
|
|
87
|
+
return limited;
|
|
88
|
+
})
|
|
89
|
+
.post("/pairings/claim", v("json", PwaClaimSchema, pwaInvalidRequest), async (c) => {
|
|
90
|
+
requireShell(c);
|
|
91
|
+
const input = c.req.valid("json");
|
|
92
|
+
const claimed = await service.claimPairing({ ...input, userAgent: c.req.header("User-Agent") });
|
|
93
|
+
setCookie(c, PWA_COOKIES.pairing, claimed.completionSecret, {
|
|
94
|
+
...cookie,
|
|
95
|
+
sameSite: "Strict",
|
|
96
|
+
path: PAIRING_COOKIE_PATH,
|
|
97
|
+
maxAge: Math.max(1, Math.ceil((Date.parse(claimed.expiresAt) - Date.now()) / 1000)),
|
|
98
|
+
});
|
|
99
|
+
return c.json({ code: claimed.code, account: claimed.account, expiresAt: claimed.expiresAt });
|
|
100
|
+
})
|
|
101
|
+
.post("/pairings/complete", async (c) => {
|
|
102
|
+
requireShell(c);
|
|
103
|
+
const web = await webSession(c);
|
|
104
|
+
try {
|
|
105
|
+
const result = await service.completePairing(c, {
|
|
106
|
+
completionSecret: getCookie(c, PWA_COOKIES.pairing),
|
|
107
|
+
deviceKey: getCookie(c, PWA_COOKIES.device),
|
|
108
|
+
appSession: await appSession(c),
|
|
109
|
+
webUserId: web?.user.id ?? null,
|
|
110
|
+
locale: getLocale(c),
|
|
111
|
+
notify: options.notify,
|
|
112
|
+
});
|
|
113
|
+
if (result.state === "waiting") return c.json(result, 202);
|
|
114
|
+
if (result.credentials) setAppCredentials(c, result.credentials);
|
|
115
|
+
clearPairing(c);
|
|
116
|
+
// A Home Screen app on iOS has its own cookie jar; a web session there is only a copy from Safari.
|
|
117
|
+
if (result.platform === "ios" && auth.session.getWebToken(c)) deleteCookie(c, "session_token", { path: "/" });
|
|
118
|
+
return c.json({ state: "paired" as const });
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error instanceof PwaError && error.code === "EXPIRED") clearPairing(c);
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
.get("/session/launch", async (c) => {
|
|
125
|
+
const to = launchTarget(c.req.query("to"));
|
|
126
|
+
if (!shellAvailable(c)) return c.redirect(stateUrl("unavailable"), 302);
|
|
127
|
+
try {
|
|
128
|
+
const result = await service.renew(c, { deviceKey: getCookie(c, PWA_COOKIES.device), appSession: await appSession(c) });
|
|
129
|
+
if (result.outcome === "renewed") setAppCredentials(c, result.credentials);
|
|
130
|
+
if (result.outcome === "renewed" || result.outcome === "current") return c.redirect(to, 302);
|
|
131
|
+
if (result.outcome === "blocked") return c.redirect(stateUrl("blocked"), 302);
|
|
132
|
+
if (result.outcome === "ended") clearAppCredentials(c);
|
|
133
|
+
return c.redirect(stateUrl(result.outcome === "missing" ? "new" : "ended"), 302);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// Logs an unexpected failure by name only; a navigation always gets a page, never JSON.
|
|
136
|
+
handlePwaError(error instanceof Error ? error : new Error("UnknownError"), c);
|
|
137
|
+
return c.redirect(stateUrl("unavailable"), 302);
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
.post("/session/renew", async (c) => {
|
|
141
|
+
// Without the app, no phone may renew; 503 never signs a phone out.
|
|
142
|
+
requireShell(c);
|
|
143
|
+
const [result, web] = await Promise.all([
|
|
144
|
+
service.renew(c, { deviceKey: getCookie(c, PWA_COOKIES.device), appSession: await appSession(c) }),
|
|
145
|
+
webSession(c),
|
|
146
|
+
]);
|
|
147
|
+
if (result.outcome === "missing" || result.outcome === "ended") {
|
|
148
|
+
clearAppCredentials(c);
|
|
149
|
+
return pwaErrorResponse(c, new PwaError("UNPAIRED", 401));
|
|
150
|
+
}
|
|
151
|
+
if (result.outcome === "blocked") return pwaErrorResponse(c, new PwaError("ACCOUNT_BLOCKED", 403));
|
|
152
|
+
if (result.outcome === "renewed") setAppCredentials(c, result.credentials);
|
|
153
|
+
// Android shares Chrome's cookies with the app: fetches may run as that other web account.
|
|
154
|
+
const otherAccount = web && web.user.id !== result.userId ? { name: web.user.displayName || web.user.uid } : undefined;
|
|
155
|
+
return c.json({ renewed: result.outcome === "renewed", ...(otherAccount ? { otherAccount } : {}) });
|
|
156
|
+
})
|
|
157
|
+
.patch("/session", v("json", PwaRenameSchema, pwaInvalidRequest), async (c) => {
|
|
158
|
+
await service.rename(getCookie(c, PWA_COOKIES.device), c.req.valid("json").name);
|
|
159
|
+
return c.body(null, 204);
|
|
160
|
+
})
|
|
161
|
+
.delete("/session", async (c) => {
|
|
162
|
+
await service.unpair(getCookie(c, PWA_COOKIES.device));
|
|
163
|
+
clearAppCredentials(c);
|
|
164
|
+
// A pairing confirmed before the sign-out must not finish on its own later.
|
|
165
|
+
clearPairing(c);
|
|
166
|
+
return c.body(null, 204);
|
|
167
|
+
});
|
|
168
|
+
};
|