@k2b/cloud 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +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 +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/global.css +6 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
|
@@ -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
|
+
onDelivered: "delete",
|
|
65
|
+
reconcileIntervalMs: RECONCILE_INTERVAL_MS,
|
|
66
|
+
publish,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Applications whose live updates this process defines, with the wake of their running dispatcher. */
|
|
70
|
+
const dispatchers = new Map<string, (() => void) | null>();
|
|
71
|
+
/** Live sockets served by this process; they close when the application stops. */
|
|
72
|
+
const engines = new Set<LiveEngine>();
|
|
73
|
+
/** Set when the application stops delivery: its engines stay stopped until delivery starts again. */
|
|
74
|
+
let stopped = false;
|
|
75
|
+
|
|
76
|
+
/** Closes every live socket of this process with 1012, so clients reconnect to another replica. */
|
|
77
|
+
export const stopLiveEngines = () => {
|
|
78
|
+
stopped = true;
|
|
79
|
+
for (const engine of engines) engine.stop();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const logStaleRows = async (appId: string): Promise<void> => {
|
|
83
|
+
const [row] = await sql<{ count: number; oldest_seconds: number | null }[]>`
|
|
84
|
+
SELECT COUNT(*)::int AS count, EXTRACT(EPOCH FROM now() - MIN(created_at))::int AS oldest_seconds
|
|
85
|
+
FROM events.outbox
|
|
86
|
+
WHERE kind = 'live' AND app_id = ${appId} AND created_at < now() - interval '60 seconds'
|
|
87
|
+
`;
|
|
88
|
+
if (row && row.count > 0) log.warn("Live updates wait to be published", { appId, count: row.count, oldestSeconds: row.oldest_seconds });
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Publishes the rows of the live definitions in this process. `app.start()`
|
|
93
|
+
* calls it with the started application's ID; it does nothing without a
|
|
94
|
+
* definition, and fails when a definition names another application or Core
|
|
95
|
+
* has not created the outbox yet.
|
|
96
|
+
*/
|
|
97
|
+
export const startLiveOutbox = async (startedAppId: string): Promise<(() => Promise<void>) | null> => {
|
|
98
|
+
const appIds = [...dispatchers.keys()];
|
|
99
|
+
if (appIds.length === 0) return null;
|
|
100
|
+
const foreign = appIds.filter((appId) => appId !== startedAppId);
|
|
101
|
+
if (foreign.length > 0) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`defineLive() names "${foreign.join('", "')}", but this process starts "${startedAppId}". Use the ID from the application's declaration.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const [installed] = await sql<{ ready: boolean }[]>`
|
|
107
|
+
SELECT to_regprocedure('events.enqueue(uuid,text,text,text,jsonb,text)') IS NOT NULL AS ready
|
|
108
|
+
`;
|
|
109
|
+
if (!installed?.ready) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`"${appIds.join('", "')}" writes live updates to events.outbox, which does not exist. Update Cloud Core first: its migration creates the outbox.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
// Delivery starts again: the engines stopped before give way to new ones.
|
|
115
|
+
if (stopped) {
|
|
116
|
+
engines.clear();
|
|
117
|
+
stopped = false;
|
|
118
|
+
}
|
|
119
|
+
const stops = appIds.map((appId) => {
|
|
120
|
+
const topic = liveTopics()(appId);
|
|
121
|
+
const outbox = liveOutbox(appId, (row) => topic.publish({ data: row.payload, orderingKey: row.ordering_key, idempotencyKey: row.id }));
|
|
122
|
+
outbox.start();
|
|
123
|
+
dispatchers.set(appId, () => void outbox.notify());
|
|
124
|
+
const staleCheck = setInterval(() => {
|
|
125
|
+
logStaleRows(appId).catch((error) =>
|
|
126
|
+
log.warn("Live outbox check failed", { appId, error: error instanceof Error ? error.message : String(error) }),
|
|
127
|
+
);
|
|
128
|
+
}, STALE_CHECK_INTERVAL_MS);
|
|
129
|
+
staleCheck.unref();
|
|
130
|
+
return async () => {
|
|
131
|
+
clearInterval(staleCheck);
|
|
132
|
+
dispatchers.set(appId, null);
|
|
133
|
+
await outbox.stop();
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
return async () => {
|
|
137
|
+
stopLiveEngines();
|
|
138
|
+
await Promise.all(stops.map((stop) => stop()));
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The viewer of an authenticated request. Its ID separates every credential of
|
|
144
|
+
* one principal that can decide differently: an app session holds no `admin`
|
|
145
|
+
* role, and an API key or OAuth token is limited by its scopes.
|
|
146
|
+
*/
|
|
147
|
+
const viewerOf = <Env extends AuthContext>(c: Context<Env>): LiveViewer => {
|
|
148
|
+
const accessSubject = c.get("accessSubject");
|
|
149
|
+
const scopes = c.get("credentialScopes") ?? [];
|
|
150
|
+
const principal = accessSubject.type === "user" ? `user:${accessSubject.userId}` : `service_account:${accessSubject.serviceAccountId}`;
|
|
151
|
+
const credential =
|
|
152
|
+
c.get("credentialKind") === "session"
|
|
153
|
+
? c.get("sessionKind") === "app"
|
|
154
|
+
? ":app"
|
|
155
|
+
: ""
|
|
156
|
+
: `:scopes=${[...new Set(scopes)].sort().join(",")}`;
|
|
157
|
+
return { id: `${principal}${credential}`, actor: c.get("actor"), accessSubject, scopes };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
type ProbeEnv = AuthContext & { Bindings: { found: (viewer: LiveViewer) => void } };
|
|
161
|
+
|
|
162
|
+
/** The ordinary credential check of every API request, run again for an open socket. */
|
|
163
|
+
const probe = new Hono<ProbeEnv>()
|
|
164
|
+
.onError((error) => {
|
|
165
|
+
throw error;
|
|
166
|
+
})
|
|
167
|
+
.get("*", auth.requireRole("authenticated"), (c) => {
|
|
168
|
+
c.env.found(viewerOf(c));
|
|
169
|
+
return c.body(null, 204);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** Checks the socket's credential again: `null` once the session or token is no longer valid. */
|
|
173
|
+
const revalidator = (c: Context<AuthContext>) => {
|
|
174
|
+
const headers = new Headers();
|
|
175
|
+
for (const name of ["cookie", "authorization"]) {
|
|
176
|
+
const value = c.req.header(name);
|
|
177
|
+
if (value) headers.set(name, value);
|
|
178
|
+
}
|
|
179
|
+
const path = c.req.path;
|
|
180
|
+
return async (): Promise<LiveViewer | null> => {
|
|
181
|
+
let viewer: LiveViewer | null = null;
|
|
182
|
+
await probe.request(path, { headers }, { found: (found: LiveViewer) => (viewer = found) });
|
|
183
|
+
return viewer;
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
type Refusal = { code: "login_required" | "forbidden_origin" | "missing_scope"; message: string };
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Why the socket may not serve this request. The refusal is sent on an
|
|
191
|
+
* accepted socket, with close code 1008: the gateway accepts the browser's
|
|
192
|
+
* socket before it reaches the application and would turn a refused handshake
|
|
193
|
+
* into a retryable 1012.
|
|
194
|
+
*/
|
|
195
|
+
const refusalOf = async (c: Context<AuthContext>): Promise<Refusal | null> => {
|
|
196
|
+
if (!c.get("actor")) return { code: "login_required", message: "Sign in again to receive live updates." };
|
|
197
|
+
// A session cookie travels with every page of the browser, so a browser's socket must come from Cloud's own origin.
|
|
198
|
+
// Browsers always send Origin on a socket; a client without one is no page that another site could have opened.
|
|
199
|
+
const origin = c.req.header("Origin");
|
|
200
|
+
if (
|
|
201
|
+
c.get("credentialKind") === "session" &&
|
|
202
|
+
origin !== undefined &&
|
|
203
|
+
origin !== publicCloudOrigin(await settings.get<string>("app.url"))
|
|
204
|
+
) {
|
|
205
|
+
return { code: "forbidden_origin", message: "Live sockets signed in with a session must come from the Cloud origin." };
|
|
206
|
+
}
|
|
207
|
+
const oauthScopes = c.get("oauthScopes");
|
|
208
|
+
if (oauthScopes && !oauthScopes.includes("read") && !oauthScopes.includes("admin")) {
|
|
209
|
+
return { code: "missing_scope", message: "Live updates need an OAuth token with the read scope." };
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Live updates of one application: hints, optionally with data, for its own
|
|
216
|
+
* open tabs. Define them once at module scope; `app.start()` then publishes the
|
|
217
|
+
* rows that `publish()` writes, and `routes()` serves them to browsers.
|
|
218
|
+
* `appId` is required and must be the ID that the process starts; it is never
|
|
219
|
+
* derived from the process.
|
|
220
|
+
*/
|
|
221
|
+
export const defineLive = <const Event extends z.ZodType>(definition: { appId: string; event: Event }) => {
|
|
222
|
+
const { appId, event } = definition;
|
|
223
|
+
if (!dispatchers.has(appId)) dispatchers.set(appId, null);
|
|
224
|
+
return {
|
|
225
|
+
/**
|
|
226
|
+
* Writes one update in `tx`, the transaction that makes the change: a
|
|
227
|
+
* rollback writes nothing, a commit publishes it at least once. Data above
|
|
228
|
+
* 32 KiB becomes a reload hint for `key`; `data` must not hold anything a
|
|
229
|
+
* reader of `key` may not see. `access: true` announces that who may read
|
|
230
|
+
* `key` changed, in a row of its own before the data: every replica checks
|
|
231
|
+
* its subscribers of `key` again before it delivers the data, and its
|
|
232
|
+
* collections right after.
|
|
233
|
+
*/
|
|
234
|
+
publish: async (
|
|
235
|
+
tx: SQL,
|
|
236
|
+
input: { key: string; data: z.input<Event>; access?: true } | { key: string; data?: undefined; access: true },
|
|
237
|
+
): Promise<void> => {
|
|
238
|
+
// Store the JSON form of the input, and validate exactly that form here:
|
|
239
|
+
// the browser parses it, and data that does not survive JSON fails this write.
|
|
240
|
+
const data: unknown = input.data === undefined ? undefined : JSON.parse(JSON.stringify(input.data));
|
|
241
|
+
if (data !== undefined) event.parse(data);
|
|
242
|
+
const enqueue = (envelope: LiveEnvelope) =>
|
|
243
|
+
tx`SELECT events.enqueue(${crypto.randomUUID()}::uuid, ${appId}, 'live', ${input.key}, ${JSON.stringify(envelope)}::text::jsonb)`;
|
|
244
|
+
// The access change is a row of its own, so data too large for the outbox cannot drop it.
|
|
245
|
+
if (input.access) await enqueue({ v: 1, k: input.key, a: true });
|
|
246
|
+
if (data !== undefined) await enqueue({ v: 1, k: input.key, d: data });
|
|
247
|
+
},
|
|
248
|
+
/** Publishes committed updates now instead of within the next second. Call it after the commit. */
|
|
249
|
+
wake: (): void => dispatchers.get(appId)?.(),
|
|
250
|
+
/** The topic head. Read it before loading the snapshot it belongs to. */
|
|
251
|
+
cursor: (): Promise<string> => liveTopics()(appId).head(),
|
|
252
|
+
/**
|
|
253
|
+
* The live socket with these channels. Mount it once, at `/api/<app>/live`.
|
|
254
|
+
* It authenticates like any API request; a session needs the Cloud origin
|
|
255
|
+
* and an OAuth token the `read` scope. A refused socket receives `error`
|
|
256
|
+
* and closes with 1008. Channels are passed here, not to
|
|
257
|
+
* `defineLive()`, because they check access with the domain services that
|
|
258
|
+
* themselves publish updates.
|
|
259
|
+
*/
|
|
260
|
+
routes: <const Scopes extends Record<string, z.ZodType>>(channels: { [Name in keyof Scopes]: LiveChannel<Scopes[Name]> }) => {
|
|
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
|
+
};
|
package/src/server/actor.ts
CHANGED
|
@@ -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);
|
package/src/server/api-client.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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.
|
package/src/server/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export {
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
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 (
|
|
131
|
-
|
|
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:
|
|
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
|
*
|