@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.
Files changed (117) 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/capabilities.ts +230 -75
  7. package/src/_internal/define-app.ts +90 -7
  8. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  9. package/src/_internal/page-responses.ts +20 -4
  10. package/src/_internal/registry-validation.ts +6 -0
  11. package/src/_internal/registry.ts +78 -3
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/static-assets.ts +18 -3
  14. package/src/access/ResourceApiKeys.tsx +30 -30
  15. package/src/ai/capability-execution.ts +5 -0
  16. package/src/ai/chat/blocks.tsx +2 -1
  17. package/src/ai/chat/messages.ts +6 -0
  18. package/src/ai/client/controller.ts +80 -21
  19. package/src/ai/client/transport.ts +47 -3
  20. package/src/ai/code-mode-skill.ts +2 -2
  21. package/src/ai/index.ts +0 -7
  22. package/src/ai/live-events.ts +1 -262
  23. package/src/ai/live.ts +31 -2
  24. package/src/ai/migrate.ts +35 -16
  25. package/src/ai/pdf-render.ts +2 -22
  26. package/src/ai/routes.ts +5 -4
  27. package/src/ai/runtime.ts +1 -6
  28. package/src/ai/solid.ts +0 -5
  29. package/src/ai/stream.ts +89 -65
  30. package/src/api/app-approval.ts +5 -1
  31. package/src/api/help.ts +2 -2
  32. package/src/api/index.ts +3 -0
  33. package/src/api/me.ts +19 -5
  34. package/src/api/pwa-phone.ts +168 -0
  35. package/src/api/pwa.ts +179 -0
  36. package/src/api/search/schemas.ts +34 -0
  37. package/src/api/search.ts +118 -50
  38. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  39. package/src/browser/CloudResourceSearch.tsx +208 -80
  40. package/src/browser/app-session.ts +69 -0
  41. package/src/browser/live-connection.ts +211 -0
  42. package/src/browser/live-websocket.ts +31 -11
  43. package/src/browser/live.ts +2 -0
  44. package/src/browser/resource-search-messages.ts +15 -2
  45. package/src/browser/search-stream.ts +151 -0
  46. package/src/contracts/app.ts +10 -0
  47. package/src/contracts/capabilities.ts +89 -24
  48. package/src/contracts/capability-compatibility.ts +28 -14
  49. package/src/contracts/file-provider.ts +158 -0
  50. package/src/contracts/index.ts +2 -0
  51. package/src/contracts/pwa-paths.ts +6 -0
  52. package/src/contracts/pwa.ts +156 -0
  53. package/src/contracts/registry.ts +7 -0
  54. package/src/contracts/shared.ts +7 -1
  55. package/src/events/index.ts +2 -0
  56. package/src/events/live-engine.ts +830 -0
  57. package/src/events/live-protocol.ts +31 -0
  58. package/src/events/live.ts +298 -0
  59. package/src/server/actor.ts +7 -0
  60. package/src/server/api-client.ts +23 -2
  61. package/src/server/help.ts +3 -3
  62. package/src/server/index.ts +3 -1
  63. package/src/server/middleware/auth.ts +80 -8
  64. package/src/server/middleware/openapi.ts +2 -1
  65. package/src/server/services/access.ts +49 -0
  66. package/src/server/services/index.ts +1 -0
  67. package/src/services/app-approval.ts +6 -25
  68. package/src/services/audit/index.ts +2 -0
  69. package/src/services/branding/app-icon-source.ts +40 -0
  70. package/src/services/branding/app-icons.ts +112 -0
  71. package/src/services/branding/icon-render-worker.ts +69 -0
  72. package/src/services/branding/icon-svg.ts +22 -0
  73. package/src/services/identity/invocation-actor.ts +8 -5
  74. package/src/services/identity/invocation-authority.ts +1 -0
  75. package/src/services/identity/invocation-token.ts +6 -0
  76. package/src/services/index.ts +1 -0
  77. package/src/services/outbox.ts +114 -80
  78. package/src/services/pairing-secret.ts +16 -0
  79. package/src/services/pdf/markdown.ts +22 -2
  80. package/src/services/pwa-devices.ts +640 -0
  81. package/src/services/session/index.ts +212 -57
  82. package/src/services/session/recent.ts +42 -0
  83. package/src/services/session/user.ts +15 -5
  84. package/src/shared/index.ts +1 -0
  85. package/src/{browser → shared}/locale-preference.ts +1 -1
  86. package/src/shared/markdown/formula.ts +122 -26
  87. package/src/shared/markdown/index.ts +27 -23
  88. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  89. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  90. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  91. package/src/ssr/Layout.tsx +10 -12
  92. package/src/ssr/LayoutHeader.tsx +14 -2
  93. package/src/ssr/LayoutHelp.tsx +2 -4
  94. package/src/ssr/LayoutRail.tsx +13 -4
  95. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  96. package/src/ssr/MobileProfileActions.tsx +19 -13
  97. package/src/ssr/PageError.tsx +23 -1
  98. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  99. package/src/ssr/PwaLayout.tsx +70 -0
  100. package/src/ssr/PwaRuntime.island.tsx +105 -0
  101. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  102. package/src/ssr/app-navigation.ts +25 -10
  103. package/src/ssr/index.ts +8 -1
  104. package/src/ssr/layout-context.ts +1 -1
  105. package/src/ssr/preference-controller.ts +2 -2
  106. package/src/ssr/profile-actions.ts +3 -1
  107. package/src/ssr/profile-preferences-messages.ts +2 -0
  108. package/src/ssr/pwa-messages.ts +24 -0
  109. package/src/styles/global.css +6 -0
  110. package/src/styles/resource-search.css +19 -0
  111. package/src/styles/utilities-feedback.css +2 -2
  112. package/scripts/build-pdf-renderer.ts +0 -39
  113. package/src/ai/client/live-connection.ts +0 -141
  114. package/src/ai/live-messages.ts +0 -45
  115. package/src/ai/live-outbox.ts +0 -102
  116. package/src/ai/live-routes.ts +0 -412
  117. package/src/shared/markdown/extensions/info-blocks.ts +0 -108
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+
3
+ /** Messages a live socket accepts: subscriptions only. Writes go over HTTP. */
4
+ export const LiveClientMessageSchema = z.discriminatedUnion("t", [
5
+ z
6
+ .object({
7
+ t: z.literal("sub"),
8
+ id: z.string().min(1).max(32),
9
+ channel: z.string().min(1).max(64),
10
+ scope: z.unknown(),
11
+ after: z.string().min(1).max(256).optional(),
12
+ })
13
+ .strict(),
14
+ z.object({ t: z.literal("unsub"), id: z.string().min(1).max(32) }).strict(),
15
+ ]);
16
+ export type LiveClientMessage = z.infer<typeof LiveClientMessageSchema>;
17
+
18
+ /**
19
+ * Messages a live socket sends. Events of one subscription arrive in topic order
20
+ * and strictly after its `ready` or `resync` cursor; `progress` says every event
21
+ * up to its cursor was sent to this socket.
22
+ */
23
+ export const LiveServerMessageSchema = z.discriminatedUnion("t", [
24
+ z.object({ t: z.literal("ready"), id: z.string(), cursor: z.string() }),
25
+ z.object({ t: z.literal("event"), id: z.string(), cursor: z.string(), data: z.unknown() }),
26
+ z.object({ t: z.literal("progress"), cursor: z.string() }),
27
+ z.object({ t: z.literal("resync"), id: z.string(), cursor: z.string() }),
28
+ z.object({ t: z.literal("revoked"), id: z.string(), code: z.enum(["not_found", "access_denied"]) }),
29
+ z.object({ t: z.literal("error"), code: z.string(), message: z.string() }),
30
+ ]);
31
+ export type LiveServerMessage = z.infer<typeof LiveServerMessageSchema>;
@@ -0,0 +1,298 @@
1
+ import type { Topic, TopicConfig } from "@k2b/sync";
2
+ import { type ServerWebSocket, type SQL, sql } from "bun";
3
+ import { type Context, Hono } from "hono";
4
+ import { upgradeWebSocket } from "hono/bun";
5
+ import type { z } from "zod";
6
+ import { lazySync } from "../_internal/process-sync";
7
+ import { type AuthContext, auth } from "../server/middleware/auth";
8
+ import { rateLimit } from "../server/middleware/rate-limit";
9
+ import { logger } from "../services/logging";
10
+ import { createPgOutbox } from "../services/outbox";
11
+ import * as settings from "../services/settings";
12
+ import { publicCloudOrigin } from "../shared/app-url";
13
+ import {
14
+ createLiveEngine,
15
+ type LiveChannel,
16
+ type LiveConnectionHandle,
17
+ type LiveEngine,
18
+ type LiveEnvelope,
19
+ type LiveViewer,
20
+ } from "./live-engine";
21
+
22
+ export type { LiveChannel, LiveViewer } from "./live-engine";
23
+
24
+ const RECONCILE_INTERVAL_MS = 1_000;
25
+ const STALE_CHECK_INTERVAL_MS = 60_000;
26
+
27
+ /**
28
+ * The live topic of one application. Frozen: Sync rejects a declaration that
29
+ * differs from the existing stream, so two releases with different values
30
+ * would break each other during a rollout. A change ships under a new id.
31
+ */
32
+ export const liveTopicConfig = (appId: string) =>
33
+ ({
34
+ id: `cloud:live:${appId}`,
35
+ owner: "cloud",
36
+ retention: { maxAgeMs: 24 * 3_600_000, maxBytes: 64 * 1024 ** 2 },
37
+ maxPayloadBytes: 40 * 1024,
38
+ deadLetterRetention: { maxBytes: 1024 ** 2 },
39
+ }) satisfies TopicConfig;
40
+
41
+ type LiveOutboxRow = { id: string; attempts: number; ordering_key: string; payload: LiveEnvelope };
42
+
43
+ const log = logger("events:live");
44
+
45
+ const liveTopics = lazySync((sync) => {
46
+ const topics = new Map<string, Topic<LiveEnvelope>>();
47
+ return (appId: string): Topic<LiveEnvelope> => {
48
+ const existing = topics.get(appId);
49
+ if (existing) return existing;
50
+ const topic = sync.topic<LiveEnvelope>(liveTopicConfig(appId));
51
+ topics.set(appId, topic);
52
+ return topic;
53
+ };
54
+ });
55
+
56
+ /** Dispatcher of one application's live rows: ordered per key, deleted once published. */
57
+ export const liveOutbox = (appId: string, publish: (row: LiveOutboxRow) => Promise<unknown>) =>
58
+ createPgOutbox<LiveOutboxRow>({
59
+ table: "events.outbox",
60
+ name: `events:live:${appId}`,
61
+ where: { kind: "live", app_id: appId },
62
+ orderBy: "ordering_key",
63
+ sequence: "seq",
64
+ reconcileIntervalMs: RECONCILE_INTERVAL_MS,
65
+ publish,
66
+ });
67
+
68
+ /** Applications whose live updates this process defines, with the wake of their running dispatcher. */
69
+ const dispatchers = new Map<string, (() => void) | null>();
70
+ /** Applications whose live socket this process mounts; only the started application may be among them. */
71
+ const served = new Set<string>();
72
+ /** Live sockets served by this process; they close when the application stops. */
73
+ const engines = new Set<LiveEngine>();
74
+ /** Set when the application stops delivery: its engines stay stopped until delivery starts again. */
75
+ let stopped = false;
76
+
77
+ /** Closes every live socket of this process with 1012, so clients reconnect to another replica. */
78
+ export const stopLiveEngines = () => {
79
+ stopped = true;
80
+ for (const engine of engines) engine.stop();
81
+ };
82
+
83
+ const logStaleRows = async (appId: string): Promise<void> => {
84
+ const [row] = await sql<{ count: number; oldest_seconds: number | null }[]>`
85
+ SELECT COUNT(*)::int AS count, EXTRACT(EPOCH FROM now() - MIN(created_at))::int AS oldest_seconds
86
+ FROM events.outbox
87
+ WHERE kind = 'live' AND app_id = ${appId} AND created_at < now() - interval '60 seconds'
88
+ `;
89
+ if (row && row.count > 0) log.warn("Live updates wait to be published", { appId, count: row.count, oldestSeconds: row.oldest_seconds });
90
+ };
91
+
92
+ /**
93
+ * Publishes the rows of the started application's live definitions. `app.start()`
94
+ * calls it with the started application's ID. A definition of another
95
+ * application only reads that application's cursor, and its rows are published
96
+ * by that application. It does nothing without a definition of the started
97
+ * application, and fails when this process mounts the socket of another
98
+ * application or Core has not created the outbox yet.
99
+ */
100
+ export const startLiveOutbox = async (appId: string): Promise<(() => Promise<void>) | null> => {
101
+ const foreign = [...served].filter((other) => other !== appId);
102
+ if (foreign.length > 0) {
103
+ throw new Error(
104
+ `This process mounts the live socket of "${foreign.join('", "')}", but starts "${appId}". Use the ID from the application's declaration.`,
105
+ );
106
+ }
107
+ if (!dispatchers.has(appId)) return null;
108
+ const [installed] = await sql<{ ready: boolean }[]>`
109
+ SELECT to_regprocedure('events.enqueue(uuid,text,text,text,jsonb,text)') IS NOT NULL AS ready
110
+ `;
111
+ if (!installed?.ready) {
112
+ throw new Error(
113
+ `"${appId}" writes live updates to events.outbox, which does not exist. Update Cloud Core first: its migration creates the outbox.`,
114
+ );
115
+ }
116
+ // Delivery starts again: the engines stopped before give way to new ones.
117
+ if (stopped) {
118
+ engines.clear();
119
+ stopped = false;
120
+ }
121
+ const topic = liveTopics()(appId);
122
+ const outbox = liveOutbox(appId, (row) => topic.publish({ data: row.payload, orderingKey: row.ordering_key, idempotencyKey: row.id }));
123
+ outbox.start();
124
+ dispatchers.set(appId, () => void outbox.notify());
125
+ const staleCheck = setInterval(() => {
126
+ logStaleRows(appId).catch((error) =>
127
+ log.warn("Live outbox check failed", { appId, error: error instanceof Error ? error.message : String(error) }),
128
+ );
129
+ }, STALE_CHECK_INTERVAL_MS);
130
+ staleCheck.unref();
131
+ return async () => {
132
+ stopLiveEngines();
133
+ clearInterval(staleCheck);
134
+ dispatchers.set(appId, null);
135
+ await outbox.stop();
136
+ };
137
+ };
138
+
139
+ /**
140
+ * The viewer of an authenticated request. Its ID separates every credential of
141
+ * one principal that can decide differently: an app session holds no `admin`
142
+ * role, and an API key or OAuth token is limited by its scopes.
143
+ */
144
+ const viewerOf = <Env extends AuthContext>(c: Context<Env>): LiveViewer => {
145
+ const accessSubject = c.get("accessSubject");
146
+ const scopes = c.get("credentialScopes") ?? [];
147
+ const principal = accessSubject.type === "user" ? `user:${accessSubject.userId}` : `service_account:${accessSubject.serviceAccountId}`;
148
+ const credential =
149
+ c.get("credentialKind") === "session"
150
+ ? c.get("sessionKind") === "app"
151
+ ? ":app"
152
+ : ""
153
+ : `:scopes=${[...new Set(scopes)].sort().join(",")}`;
154
+ return { id: `${principal}${credential}`, actor: c.get("actor"), accessSubject, scopes };
155
+ };
156
+
157
+ type ProbeEnv = AuthContext & { Bindings: { found: (viewer: LiveViewer) => void } };
158
+
159
+ /** The ordinary credential check of every API request, run again for an open socket. */
160
+ const probe = new Hono<ProbeEnv>()
161
+ .onError((error) => {
162
+ throw error;
163
+ })
164
+ .get("*", auth.requireRole("authenticated"), (c) => {
165
+ c.env.found(viewerOf(c));
166
+ return c.body(null, 204);
167
+ });
168
+
169
+ /** Checks the socket's credential again: `null` once the session or token is no longer valid. */
170
+ const revalidator = (c: Context<AuthContext>) => {
171
+ const headers = new Headers();
172
+ for (const name of ["cookie", "authorization"]) {
173
+ const value = c.req.header(name);
174
+ if (value) headers.set(name, value);
175
+ }
176
+ const path = c.req.path;
177
+ return async (): Promise<LiveViewer | null> => {
178
+ let viewer: LiveViewer | null = null;
179
+ await probe.request(path, { headers }, { found: (found: LiveViewer) => (viewer = found) });
180
+ return viewer;
181
+ };
182
+ };
183
+
184
+ type Refusal = { code: "login_required" | "forbidden_origin" | "missing_scope"; message: string };
185
+
186
+ /**
187
+ * Why the socket may not serve this request. The refusal is sent on an
188
+ * accepted socket, with close code 1008: the gateway accepts the browser's
189
+ * socket before it reaches the application and would turn a refused handshake
190
+ * into a retryable 1012.
191
+ */
192
+ const refusalOf = async (c: Context<AuthContext>): Promise<Refusal | null> => {
193
+ if (!c.get("actor")) return { code: "login_required", message: "Sign in again to receive live updates." };
194
+ // A session cookie travels with every page of the browser, so a browser's socket must come from Cloud's own origin.
195
+ // Browsers always send Origin on a socket; a client without one is no page that another site could have opened.
196
+ const origin = c.req.header("Origin");
197
+ if (
198
+ c.get("credentialKind") === "session" &&
199
+ origin !== undefined &&
200
+ origin !== publicCloudOrigin(await settings.get<string>("app.url"))
201
+ ) {
202
+ return { code: "forbidden_origin", message: "Live sockets signed in with a session must come from the Cloud origin." };
203
+ }
204
+ const oauthScopes = c.get("oauthScopes");
205
+ if (oauthScopes && !oauthScopes.includes("read") && !oauthScopes.includes("admin")) {
206
+ return { code: "missing_scope", message: "Live updates need an OAuth token with the read scope." };
207
+ }
208
+ return null;
209
+ };
210
+
211
+ /**
212
+ * Live updates of one application: hints, optionally with data, for its own
213
+ * open tabs. Define them once at module scope; `app.start()` then publishes the
214
+ * rows that `publish()` writes, and `routes()` serves them to browsers.
215
+ * `appId` is required and is never derived from the process. Another
216
+ * application's process may hold the definition to read its `cursor()` or
217
+ * write updates; only the application itself publishes and serves them.
218
+ */
219
+ export const defineLive = <const Event extends z.ZodType>(definition: { appId: string; event: Event }) => {
220
+ const { appId, event } = definition;
221
+ if (!dispatchers.has(appId)) dispatchers.set(appId, null);
222
+ return {
223
+ /**
224
+ * Writes one update in `tx`, the transaction that makes the change: a
225
+ * rollback writes nothing, a commit publishes it at least once. Data above
226
+ * 32 KiB becomes a reload hint for `key`; `data` must not hold anything a
227
+ * reader of `key` may not see. `access: true` announces that who may read
228
+ * `key` changed, in a row of its own before the data: every replica checks
229
+ * its subscribers of `key` again before it delivers the data, and its
230
+ * collections right after.
231
+ */
232
+ publish: async (
233
+ tx: SQL,
234
+ input: { key: string; data: z.input<Event>; access?: true } | { key: string; data?: undefined; access: true },
235
+ ): Promise<void> => {
236
+ // Store the JSON form of the input, and validate exactly that form here:
237
+ // the browser parses it, and data that does not survive JSON fails this write.
238
+ const data: unknown = input.data === undefined ? undefined : JSON.parse(JSON.stringify(input.data));
239
+ if (data !== undefined) event.parse(data);
240
+ const enqueue = (envelope: LiveEnvelope) =>
241
+ tx`SELECT events.enqueue(${crypto.randomUUID()}::uuid, ${appId}, 'live', ${input.key}, ${JSON.stringify(envelope)}::text::jsonb)`;
242
+ // The access change is a row of its own, so data too large for the outbox cannot drop it.
243
+ if (input.access) await enqueue({ v: 1, k: input.key, a: true });
244
+ if (data !== undefined) await enqueue({ v: 1, k: input.key, d: data });
245
+ },
246
+ /** Publishes committed updates now instead of within the next second. Call it after the commit. */
247
+ wake: (): void => dispatchers.get(appId)?.(),
248
+ /** The topic head. Read it before loading the snapshot it belongs to. */
249
+ cursor: (): Promise<string> => liveTopics()(appId).head(),
250
+ /**
251
+ * The live socket with these channels. Mount it once, at `/api/<app>/live`,
252
+ * in the application's own process: `app.start()` of another application fails.
253
+ * It authenticates like any API request; a session needs the Cloud origin
254
+ * and an OAuth token the `read` scope. A refused socket receives `error`
255
+ * and closes with 1008. Channels are passed here, not to
256
+ * `defineLive()`, because they check access with the domain services that
257
+ * themselves publish updates.
258
+ */
259
+ routes: <const Scopes extends Record<string, z.ZodType>>(channels: { [Name in keyof Scopes]: LiveChannel<Scopes[Name]> }) => {
260
+ served.add(appId);
261
+ let engine: LiveEngine | null = null;
262
+ const serve = (): LiveEngine => {
263
+ if (engine && engines.has(engine)) return engine;
264
+ engine = createLiveEngine({ appId, topic: () => liveTopics()(appId), channels });
265
+ engines.add(engine);
266
+ // A socket that opens while the application stops closes with 1012 and starts nothing.
267
+ if (stopped) engine.stop();
268
+ return engine;
269
+ };
270
+ return new Hono<AuthContext>().get(
271
+ "/",
272
+ rateLimit({ limitPerSecond: 5 }),
273
+ auth.requireRole("*"),
274
+ upgradeWebSocket(async (c) => {
275
+ const refusal = await refusalOf(c);
276
+ if (refusal) {
277
+ return {
278
+ onOpen: (_event, ws) => {
279
+ ws.send(JSON.stringify({ t: "error", ...refusal }));
280
+ ws.close(1008, refusal.code);
281
+ },
282
+ };
283
+ }
284
+ const viewer = viewerOf(c);
285
+ const revalidate = revalidator(c);
286
+ let connection: LiveConnectionHandle | null = null;
287
+ return {
288
+ onOpen: (_event, ws) => {
289
+ connection = serve().open(ws.raw as ServerWebSocket<unknown>, viewer, revalidate);
290
+ },
291
+ onMessage: (message) => connection?.message(message.data),
292
+ onClose: () => connection?.closed(),
293
+ };
294
+ }),
295
+ );
296
+ },
297
+ };
298
+ };
@@ -24,6 +24,13 @@ export const userFromActor = (actor: RequestActor | undefined): ActingUser | nul
24
24
  return actor.kind === "user" ? actor.user : actor.delegatedUser;
25
25
  };
26
26
 
27
+ /**
28
+ * True when the actor works through the mobile app's session (directly, through an invocation or
29
+ * in an Assistant turn started there). Such an actor must not create authority that outlives the
30
+ * phone: sign-in methods, API keys or background mandates.
31
+ */
32
+ export const isAppSessionActor = (actor: RequestActor | undefined): boolean => actor?.kind === "user" && actor.sessionKind === "app";
33
+
27
34
  /** Same, read off the request context. */
28
35
  export const getUserBackedActor = <T extends AuthContext>(c: Context<T>): ActingUser | null =>
29
36
  userFromActor(c.get("actor") as RequestActor | undefined);
@@ -1,5 +1,6 @@
1
1
  import type { Hono } from "hono";
2
2
  import { hc } from "hono/client";
3
+ import { insideMobileApp, renewAppSession } from "../browser/app-session";
3
4
 
4
5
  export type CreateApiClientConfig = {
5
6
  baseUrl?: string;
@@ -10,9 +11,29 @@ export type CreateApiClientConfig = {
10
11
  // ==========================
11
12
 
12
13
  /**
13
- * Creates a typed Hono API client.
14
+ * A request from a page of the mobile app that answers `401` met an ended app session. The client renews it and sends
15
+ * the request once more, so the action is not lost. A `401` means the server applied nothing: the auth middleware
16
+ * answers it before any handler runs, and a route that answers `401` itself must do so before it changes anything.
14
17
  */
15
- export const createApiClient = <TApi extends Hono<any, any, any>>(config: CreateApiClientConfig = {}) => hc<TApi>(config.baseUrl ?? "/api");
18
+ const fetchInMobileApp = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]): Promise<Response> => {
19
+ const response = await fetch(input, init);
20
+ if (response.status !== 401 || init?.body instanceof ReadableStream) return response;
21
+ // Also after a renewal that found the session current: another renewal on this page may have replaced the session
22
+ // after this request left with the old one.
23
+ if (!(await renewAppSession()).ok) return response;
24
+ return fetch(input, init);
25
+ };
26
+
27
+ // On the web, the client hands back fetch's own promise, so its answers arrive exactly as without the app session.
28
+ const fetchWithAppSession = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]): Promise<Response> =>
29
+ insideMobileApp() ? fetchInMobileApp(input, init) : fetch(input, init);
30
+
31
+ /**
32
+ * Creates a typed Hono API client. On a page of the mobile app, it renews an expired app session and repeats the
33
+ * request once.
34
+ */
35
+ export const createApiClient = <TApi extends Hono<any, any, any>>(config: CreateApiClientConfig = {}) =>
36
+ hc<TApi>(config.baseUrl ?? "/api", { fetch: fetchWithAppSession });
16
37
 
17
38
  /**
18
39
  * Untyped fallback API client for core-only browser code.
@@ -35,7 +35,7 @@ export type HelpCollection = {
35
35
 
36
36
  type ParsedHelpDocument = Omit<HelpDefinitionDocument, "order"> & { order?: number };
37
37
 
38
- const parseSource = (source: string): ParsedHelpDocument => {
38
+ const parseSource = (source: string, locale: string): ParsedHelpDocument => {
39
39
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(source);
40
40
  if (!match) throw new Error("Help documents require YAML frontmatter wrapped in --- markers");
41
41
 
@@ -46,7 +46,7 @@ const parseSource = (source: string): ParsedHelpDocument => {
46
46
  return {
47
47
  ...metadata,
48
48
  markdown,
49
- html: renderHelpMarkdown(markdown),
49
+ html: renderHelpMarkdown(markdown, locale),
50
50
  searchText: markdownToPlainText(markdown),
51
51
  };
52
52
  };
@@ -65,7 +65,7 @@ const parseDocuments = (
65
65
  baseById?: ReadonlyMap<string, HelpDefinitionDocument>,
66
66
  ): readonly HelpDefinitionDocument[] => {
67
67
  const documents = sources
68
- .map(parseSource)
68
+ .map((source) => parseSource(source, locale))
69
69
  .map((document): HelpDefinitionDocument => {
70
70
  const base = baseById?.get(document.id);
71
71
  if (!baseById) return { ...document, order: document.order ?? 100 };
@@ -1,4 +1,5 @@
1
- export { expectUserBackedActor, getUserBackedActor, userFromActor } from "./actor";
1
+ export { buildMetadata } from "../_internal/build-metadata";
2
+ export { expectUserBackedActor, getUserBackedActor, isAppSessionActor, userFromActor } from "./actor";
2
3
  export type { ApiErrorBody, ApiErrorResponse, ApiErrorStatus } from "./api";
3
4
  export { api, respond, respondMessage } from "./api";
4
5
  export type { CreateApiClientConfig } from "./api-client";
@@ -74,6 +75,7 @@ export {
74
75
  getEffectiveGroupIds,
75
76
  getEffectiveGroups,
76
77
  getEffectivePermission,
78
+ getEffectivePermissions,
77
79
  hasPermission,
78
80
  images,
79
81
  isServiceError,
@@ -7,8 +7,9 @@ import { isReservedWorkloadApiCredential } from "../../services/identity/workloa
7
7
  import { oauthTokens } from "../../services/oauth-tokens";
8
8
  import { serviceAccountCredentials } from "../../services/service-account-credentials";
9
9
  import type { ServiceAccount } from "../../services/service-accounts";
10
- import { session } from "../../services/session";
10
+ import { type AuthenticatedSession, isAppPagePath, type SessionKind, session } from "../../services/session";
11
11
  import { createLoginRedirectUrl } from "../../shared/redirect";
12
+ import { isAppSessionActor } from "../actor";
12
13
  import type { AccessSubject } from "../services/access";
13
14
 
14
15
  // ==========================
@@ -31,6 +32,12 @@ export type UserRequestActor = {
31
32
  kind: "user";
32
33
  user: User;
33
34
  delegation?: InvocationProvenance;
35
+ /**
36
+ * `"app"` when the person acts through the mobile app's session: directly, through an
37
+ * invocation, or in an Assistant turn started there. Such an actor never carries the
38
+ * `admin` role and must not create authority that outlives the phone.
39
+ */
40
+ sessionKind?: "app";
34
41
  };
35
42
 
36
43
  export type ServiceAccountRequestActor =
@@ -73,6 +80,8 @@ export type AuthContext = {
73
80
  sessionToken?: string;
74
81
  /** Credential class resolved once for downstream delegation. */
75
82
  credentialKind?: RequestCredentialKind;
83
+ /** With `credentialKind: "session"`: `"app"` for a paired phone in the mobile app, otherwise `"web"`. */
84
+ sessionKind?: SessionKind;
76
85
  /** Constraints carried by OAuth/API credentials; absent sessions are unrestricted by transport scope. */
77
86
  credentialScopes?: string[];
78
87
  /** OAuth scopes for bearer-token requests. Absent for sessions and API credentials. */
@@ -123,20 +132,28 @@ const loadAuthenticatedActorUncached = async (
123
132
  c: Context<AuthContext>,
124
133
  options: Pick<RoleOptions, "oauthAudience"> = {},
125
134
  ): Promise<AuthenticatedActorResult> => {
126
- const token = session.getToken(c);
127
- const authenticatedSession = token ? await session.authenticateRequest(c, token) : null;
128
- const user = authenticatedSession?.user ?? null;
135
+ let token = session.getToken(c);
136
+ let authenticatedSession: AuthenticatedSession | null = token ? await session.authenticateRequest(c, token) : null;
137
+ const fallback = authenticatedSession ? null : session.appSessionFallback(c, token);
138
+ if (fallback) {
139
+ token = fallback;
140
+ authenticatedSession = await session.authenticateRequest(c, fallback);
141
+ }
142
+ // Below /pwa/ only an app session counts: no web session, bearer or API key reaches app pages.
143
+ if (isAppPagePath(c.req.path) && authenticatedSession?.data.kind !== "app") return { token: null, user: null, actor: null };
129
144
 
130
- if (user && token) {
131
- c.set("actor", { kind: "user", user });
145
+ if (authenticatedSession && token) {
146
+ const { user } = authenticatedSession;
147
+ const actor: UserRequestActor = { kind: "user", user, ...(authenticatedSession.data.kind === "app" ? { sessionKind: "app" } : {}) };
148
+ c.set("actor", actor);
132
149
  c.set("accessSubject", { type: "user", userId: user.id });
133
150
  c.set("user", user);
134
151
  c.set("sessionToken", token);
135
152
  c.set("credentialKind", "session");
153
+ c.set("sessionKind", authenticatedSession.data.kind);
154
+ return { token, user, actor };
136
155
  }
137
156
 
138
- if (user) return { token, user, actor: { kind: "user", user } };
139
-
140
157
  const bearer = session.getBearerToken(c);
141
158
  if (bearer && serviceAccountCredentials.isApiToken(bearer)) {
142
159
  const authResult = await serviceAccountCredentials.authenticateApiToken(bearer);
@@ -224,6 +241,44 @@ const loadAuthenticatedActor = (
224
241
  return pending;
225
242
  };
226
243
 
244
+ /**
245
+ * Re-checks the credential that admitted this request, past the per-request
246
+ * cache, for a response that outlives its request such as a stream. True while
247
+ * that credential still authenticates the same actor; pass the same
248
+ * `oauthAudience` the route admitted with. Invocation credentials are bound to
249
+ * one call and always fail.
250
+ */
251
+ export const isRequestCredentialCurrent = async (
252
+ c: Context<AuthContext>,
253
+ options: Pick<RoleOptions, "oauthAudience"> = {},
254
+ ): Promise<boolean> => {
255
+ const actor = c.get("actor");
256
+ if (!actor) return false;
257
+ const credentialKind = c.get("credentialKind");
258
+ if (credentialKind === "session") {
259
+ const token = c.get("sessionToken");
260
+ if (!token || actor.kind !== "user") return false;
261
+ return (await session.authenticateUserId(token)) === actor.user.id;
262
+ }
263
+
264
+ const bearer = session.getBearerToken(c);
265
+ if (!bearer) return false;
266
+ if (credentialKind === "api_key") {
267
+ if (actor.kind !== "service_account") return false;
268
+ const current = await serviceAccountCredentials.authenticateApiToken(bearer);
269
+ if (current?.delegatedUser && isAccountExpired(current.delegatedUser.accountExpires)) return false;
270
+ return current?.credential.id === actor.credentialId;
271
+ }
272
+ if (credentialKind === "oauth") {
273
+ const expectedAudience = typeof options.oauthAudience === "function" ? await options.oauthAudience() : options.oauthAudience;
274
+ const current = await oauthTokens.verifyAccessToken(bearer, expectedAudience);
275
+ if (!current) return false;
276
+ if (current.kind === "user") return actor.kind === "user" && current.user.id === actor.user.id;
277
+ return actor.kind === "service_account" && current.serviceAccount.id === actor.serviceAccount.id;
278
+ }
279
+ return false;
280
+ };
281
+
227
282
  /**
228
283
  * Universal auth middleware. Handles authentication AND authorization.
229
284
  *
@@ -342,6 +397,21 @@ const getAuthority = (c: Context<AuthContext>): RequestAuthority => {
342
397
  };
343
398
  };
344
399
 
400
+ /**
401
+ * True when the request acts through the mobile app's session, directly or through an
402
+ * invocation made for one. Such requests cannot create authority that outlives the phone.
403
+ */
404
+ const isAppSession = (c: Context<AuthContext>): boolean => isAppSessionActor(c.get("actor"));
405
+
406
+ /** The standard answer for actions the mobile app may not take. */
407
+ export const APP_SESSION_FORBIDDEN = { code: "FORBIDDEN", message: "Use Cloud on the web for this." } as const;
408
+
409
+ /**
410
+ * Rejects the mobile app's session with `403` {@link APP_SESSION_FORBIDDEN}. Use it on every route
411
+ * that creates authority outliving the phone: sign-in methods, API keys, background mandates.
412
+ */
413
+ const rejectAppSession = createMiddleware<AuthContext>(async (c, next) => (isAppSession(c) ? c.json(APP_SESSION_FORBIDDEN, 403) : next()));
414
+
345
415
  /** Preset: Redirect to a fixed URL on rejection */
346
416
  const redirect = (url: string): RoleOptions => ({
347
417
  onReject: () => url,
@@ -379,6 +449,8 @@ export const auth = {
379
449
  session,
380
450
  requireRole,
381
451
  requireUser,
452
+ isAppSession,
453
+ rejectAppSession,
382
454
  requireOAuthScope,
383
455
  getAuthority,
384
456
  requireAccount,
@@ -69,7 +69,8 @@ export const openApiMeta: Partial<GenerateSpecOptions> = {
69
69
  type: "apiKey",
70
70
  in: "cookie",
71
71
  name: "session_token",
72
- description: "Session cookie (automatically set after login)",
72
+ description:
73
+ "Session cookie (automatically set after login). Requests from the mobile app carry the `pwa_session` cookie instead; it is accepted the same way, except where an endpoint needs the web.",
73
74
  },
74
75
  bearerAuth: {
75
76
  type: "http",
@@ -394,6 +394,55 @@ export const getEffectivePermission = async (params: {
394
394
  return rows[0]?.permission ?? "none";
395
395
  };
396
396
 
397
+ /** A Postgres UUID[] literal that keeps `null` entries in place. */
398
+ const uuidArrayWithNulls = (values: readonly (string | null)[]): string => `{${values.map((value) => value ?? "NULL").join(",")}}`;
399
+
400
+ /**
401
+ * The effective permission of many subjects on one resource, with one query.
402
+ * Each result is what `getEffectivePermission()` returns for the subject at the
403
+ * same position: the same direct, nested-group, authenticated, and public rules.
404
+ * Use it where one decision is needed for many readers, such as a live
405
+ * channel's `authorize`.
406
+ */
407
+ export const getEffectivePermissions = async (params: {
408
+ accessIds: string[];
409
+ subjects: readonly AccessSubject[];
410
+ }): Promise<PermissionLevel[]> => {
411
+ const { accessIds, subjects } = params;
412
+ if (subjects.length === 0) return [];
413
+ if (accessIds.length === 0) return subjects.map(() => "none");
414
+ const userIds = subjects.map((subject) => (subject.type === "user" ? subject.userId : null));
415
+ const serviceAccountIds = subjects.map((subject) => (subject.type === "service_account" ? subject.serviceAccountId : null));
416
+ // The tiers of buildAccessPrincipalTierConditions, evaluated per row of `s`; every subject here is authenticated.
417
+ const rows = await sql<{ position: number; permission: PermissionLevel | null }[]>`
418
+ SELECT s.position::int AS position, (
419
+ SELECT a.permission
420
+ FROM auth.access a
421
+ WHERE a.id = ANY(${toPgUuidArray(accessIds)}::uuid[])
422
+ AND (
423
+ a.service_account_id = s.service_account_id
424
+ OR a.user_id = s.user_id
425
+ OR (s.user_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(sql`s.user_id`)}))
426
+ OR a.authenticated_only = true
427
+ OR (a.user_id IS NULL AND a.group_id IS NULL AND a.service_account_id IS NULL AND a.authenticated_only = false)
428
+ )
429
+ ORDER BY
430
+ CASE a.permission
431
+ WHEN 'admin' THEN 4
432
+ WHEN 'write' THEN 3
433
+ WHEN 'read' THEN 2
434
+ WHEN 'none' THEN 1
435
+ END DESC
436
+ LIMIT 1
437
+ ) AS permission
438
+ FROM unnest(${uuidArrayWithNulls(userIds)}::uuid[], ${uuidArrayWithNulls(serviceAccountIds)}::uuid[])
439
+ WITH ORDINALITY AS s(user_id, service_account_id, position)
440
+ `;
441
+ const permissions = subjects.map((): PermissionLevel => "none");
442
+ for (const row of rows) permissions[row.position - 1] = row.permission ?? "none";
443
+ return permissions;
444
+ };
445
+
397
446
  /**
398
447
  * Lists concrete users reachable from auth.access entries.
399
448
  *
@@ -23,6 +23,7 @@ export {
23
23
  getEffectiveGroupIds,
24
24
  getEffectiveGroups,
25
25
  getEffectivePermission,
26
+ getEffectivePermissions,
26
27
  hasPermission,
27
28
  listUsersWithAccess,
28
29
  PERMISSION_LEVELS,