@checkstack/status-page-backend 0.1.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.
@@ -0,0 +1,276 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { z } from "zod";
3
+ import type { Logger, RpcClient, SafeDatabase } from "@checkstack/backend-api";
4
+ import { StatusPageService } from "./service";
5
+ import type {
6
+ RegisteredWidgetType,
7
+ WidgetTypeRegistry,
8
+ } from "./widget-registry";
9
+ import type { StatusPageRow } from "./schema";
10
+ import * as schema from "./schema";
11
+
12
+ /** Minimal chainable fake of the drizzle query used by resolvePublished. */
13
+ function fakeDb(row: StatusPageRow | undefined): SafeDatabase<typeof schema> {
14
+ const result = row ? [row] : [];
15
+ const chain = {
16
+ from: () => chain,
17
+ where: () => chain,
18
+ limit: () => Promise.resolve(result),
19
+ };
20
+ // Cast: a hand-rolled stub of the SafeDatabase surface the service touches.
21
+ return { select: () => chain } as unknown as SafeDatabase<typeof schema>;
22
+ }
23
+
24
+ const noopLogger: Logger = {
25
+ debug: () => {},
26
+ info: () => {},
27
+ warn: () => {},
28
+ error: () => {},
29
+ } as unknown as Logger;
30
+
31
+ const noRpc: RpcClient = {
32
+ forPlugin: () => ({}) as never,
33
+ };
34
+
35
+ function widget(
36
+ over: Partial<RegisteredWidgetType> & { id: string },
37
+ ): RegisteredWidgetType {
38
+ return {
39
+ id: over.id,
40
+ qualifiedId: over.qualifiedId ?? `test.${over.id}`,
41
+ ownerPluginId: "test",
42
+ displayName: over.id,
43
+ description: "",
44
+ category: "Test",
45
+ binding: "none",
46
+ configSchema: z.unknown(),
47
+ dtoSchema: over.dtoSchema ?? z.object({ value: z.string() }),
48
+ boundResources: over.boundResources ?? (() => []),
49
+ resolvePublic:
50
+ over.resolvePublic ?? (async () => ({ value: "ok" })),
51
+ assertBindingsReadable: over.assertBindingsReadable,
52
+ };
53
+ }
54
+
55
+ function registryOf(widgets: RegisteredWidgetType[]): WidgetTypeRegistry {
56
+ const map = new Map(widgets.map((w) => [w.qualifiedId, w]));
57
+ return {
58
+ register: () => {},
59
+ get: (id) => map.get(id),
60
+ list: () => [...map.values()],
61
+ };
62
+ }
63
+
64
+ function row(over: Partial<StatusPageRow>): StatusPageRow {
65
+ return {
66
+ id: "p1",
67
+ slug: "acme",
68
+ title: "Acme",
69
+ visibility: "public",
70
+ theme: { mode: "auto" },
71
+ draftLayout: [],
72
+ publishedLayout: null,
73
+ publishedAt: null,
74
+ createdAt: new Date("2026-06-01T00:00:00Z"),
75
+ updatedAt: new Date("2026-06-01T00:00:00Z"),
76
+ ...over,
77
+ } as StatusPageRow;
78
+ }
79
+
80
+ function service(args: {
81
+ row?: StatusPageRow;
82
+ widgets?: RegisteredWidgetType[];
83
+ }): StatusPageService {
84
+ return new StatusPageService({
85
+ db: fakeDb(args.row),
86
+ registry: registryOf(args.widgets ?? []),
87
+ rpcClient: noRpc,
88
+ logger: noopLogger,
89
+ });
90
+ }
91
+
92
+ describe("resolvePublished — isolation + visibility", () => {
93
+ test("returns null for a page that is not published", async () => {
94
+ const svc = service({ row: row({ publishedLayout: null }) });
95
+ expect(
96
+ await svc.resolvePublished({ slug: "acme", isAuthenticated: false }),
97
+ ).toBeNull();
98
+ });
99
+
100
+ test("an authenticated-only page is hidden from anonymous callers", async () => {
101
+ const svc = service({
102
+ row: row({ visibility: "authenticated", publishedLayout: [] }),
103
+ });
104
+ expect(
105
+ await svc.resolvePublished({ slug: "acme", isAuthenticated: false }),
106
+ ).toBeNull();
107
+ expect(
108
+ await svc.resolvePublished({ slug: "acme", isAuthenticated: true }),
109
+ ).not.toBeNull();
110
+ });
111
+
112
+ test("only blocks in the PUBLISHED layout are resolved", async () => {
113
+ const svc = service({
114
+ row: row({
115
+ publishedLayout: [{ id: "b1", type: "test.ok", config: {} }],
116
+ // Draft has an extra block that must NEVER appear publicly.
117
+ draftLayout: [
118
+ { id: "b1", type: "test.ok", config: {} },
119
+ { id: "secret", type: "test.ok", config: {} },
120
+ ],
121
+ }),
122
+ widgets: [widget({ id: "ok" })],
123
+ });
124
+ const result = await svc.resolvePublished({
125
+ slug: "acme",
126
+ isAuthenticated: false,
127
+ });
128
+ expect(result?.blocks.map((b) => b.id)).toEqual(["b1"]);
129
+ });
130
+
131
+ test("the DTO schema is the allow-list — extra resolver fields are stripped", async () => {
132
+ const svc = service({
133
+ row: row({ publishedLayout: [{ id: "b1", type: "test.ok", config: {} }] }),
134
+ widgets: [
135
+ widget({
136
+ id: "ok",
137
+ dtoSchema: z.object({ value: z.string() }),
138
+ resolvePublic: async () => ({ value: "shown", secret: "LEAK" }),
139
+ }),
140
+ ],
141
+ });
142
+ const result = await svc.resolvePublished({
143
+ slug: "acme",
144
+ isAuthenticated: false,
145
+ });
146
+ expect(result?.blocks[0].data).toEqual({ value: "shown" });
147
+ expect(JSON.stringify(result)).not.toContain("LEAK");
148
+ });
149
+
150
+ test("a failing widget degrades to data:null, never crashing the page", async () => {
151
+ const svc = service({
152
+ row: row({
153
+ publishedLayout: [
154
+ { id: "bad", type: "test.boom", config: {} },
155
+ { id: "good", type: "test.ok", config: {} },
156
+ ],
157
+ }),
158
+ widgets: [
159
+ widget({
160
+ id: "boom",
161
+ qualifiedId: "test.boom",
162
+ resolvePublic: async () => {
163
+ throw new Error("resolver blew up");
164
+ },
165
+ }),
166
+ widget({ id: "ok" }),
167
+ ],
168
+ });
169
+ const result = await svc.resolvePublished({
170
+ slug: "acme",
171
+ isAuthenticated: false,
172
+ });
173
+ expect(result?.blocks.find((b) => b.id === "bad")?.data).toBeNull();
174
+ expect(result?.blocks.find((b) => b.id === "good")?.data).toEqual({
175
+ value: "ok",
176
+ });
177
+ });
178
+
179
+ test("unknown widget types are skipped, not rendered", async () => {
180
+ const svc = service({
181
+ row: row({
182
+ publishedLayout: [{ id: "x", type: "ghost.widget", config: {} }],
183
+ }),
184
+ widgets: [],
185
+ });
186
+ const result = await svc.resolvePublished({
187
+ slug: "acme",
188
+ isAuthenticated: false,
189
+ });
190
+ expect(result?.blocks).toEqual([]);
191
+ });
192
+ });
193
+
194
+ describe("publish gate — delegates to the widget, fails closed", () => {
195
+ test("propagates a widget's access-check failure as FORBIDDEN", async () => {
196
+ const sysWidget = widget({
197
+ id: "sys",
198
+ qualifiedId: "test.sys",
199
+ boundResources: () => [
200
+ { resourceType: "catalog.system", resourceId: "s1" },
201
+ ],
202
+ assertBindingsReadable: async () => {
203
+ throw new Error("System s1 is not accessible");
204
+ },
205
+ });
206
+ const svc = service({
207
+ row: row({ draftLayout: [{ id: "b", type: "test.sys", config: {} }] }),
208
+ widgets: [sysWidget],
209
+ });
210
+ await expect(
211
+ svc.publish({ id: "p1", userClient: noRpc }),
212
+ ).rejects.toThrow(/not accessible/i);
213
+ });
214
+
215
+ test("FAILS CLOSED when a binding widget provides no access check", async () => {
216
+ const noCheck = widget({
217
+ id: "nocheck",
218
+ qualifiedId: "test.nocheck",
219
+ boundResources: () => [
220
+ { resourceType: "catalog.system", resourceId: "s1" },
221
+ ],
222
+ // no assertBindingsReadable
223
+ });
224
+ const svc = service({
225
+ row: row({ draftLayout: [{ id: "b", type: "test.nocheck", config: {} }] }),
226
+ widgets: [noCheck],
227
+ });
228
+ await expect(
229
+ svc.publish({ id: "p1", userClient: noRpc }),
230
+ ).rejects.toThrow(/cannot be published/i);
231
+ });
232
+
233
+ test("allows publish when the widget's access check passes", async () => {
234
+ let checked = false;
235
+ const okWidget = widget({
236
+ id: "ok",
237
+ qualifiedId: "test.okbind",
238
+ boundResources: () => [
239
+ { resourceType: "catalog.system", resourceId: "s1" },
240
+ ],
241
+ assertBindingsReadable: async () => {
242
+ checked = true;
243
+ },
244
+ });
245
+ const svc = service({
246
+ row: row({ draftLayout: [{ id: "b", type: "test.okbind", config: {} }] }),
247
+ widgets: [okWidget],
248
+ });
249
+ // The select-only fake DB can't service the post-gate UPDATE, so we only
250
+ // assert the gate ran + passed (the update path is covered elsewhere).
251
+ await svc.publish({ id: "p1", userClient: noRpc }).catch(() => undefined);
252
+ expect(checked).toBe(true);
253
+ });
254
+ });
255
+
256
+ describe("collectBoundResources", () => {
257
+ test("dedupes bound resources across blocks", () => {
258
+ const svc = service({
259
+ widgets: [
260
+ widget({
261
+ id: "w",
262
+ boundResources: () => [
263
+ { resourceType: "catalog.system", resourceId: "s1" },
264
+ ],
265
+ }),
266
+ ],
267
+ });
268
+ const bound = svc.collectBoundResources([
269
+ { id: "a", type: "test.w", config: {} },
270
+ { id: "b", type: "test.w", config: {} },
271
+ ]);
272
+ expect(bound).toEqual([
273
+ { resourceType: "catalog.system", resourceId: "s1" },
274
+ ]);
275
+ });
276
+ });
package/src/service.ts ADDED
@@ -0,0 +1,337 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { ORPCError } from "@orpc/server";
3
+ import { extractErrorMessage } from "@checkstack/common";
4
+ import type { SafeDatabase, RpcClient, Logger } from "@checkstack/backend-api";
5
+ import {
6
+ StatusPageVisibilitySchema,
7
+ StatusPageThemeSchema,
8
+ type StatusPage,
9
+ type StatusPageSummary,
10
+ type StatusPageLayout,
11
+ type StatusPageTheme,
12
+ type StatusPageVisibility,
13
+ type PublishedStatusPage,
14
+ type ResolvedBlock,
15
+ type WidgetTypeDescriptor,
16
+ } from "@checkstack/status-page-common";
17
+ import * as schema from "./schema";
18
+ import { statusPages, type StatusPageRow } from "./schema";
19
+ import type {
20
+ BoundResource,
21
+ WidgetResolveContext,
22
+ WidgetTypeRegistry,
23
+ } from "./widget-registry";
24
+
25
+ type Db = SafeDatabase<typeof schema>;
26
+
27
+ function iso(value: Date | null): string | null {
28
+ return value ? value.toISOString() : null;
29
+ }
30
+
31
+ function rowVisibility(row: StatusPageRow): StatusPageVisibility {
32
+ const parsed = StatusPageVisibilitySchema.safeParse(row.visibility);
33
+ return parsed.success ? parsed.data : "public";
34
+ }
35
+
36
+ function rowTheme(row: StatusPageRow): StatusPageTheme {
37
+ const parsed = StatusPageThemeSchema.safeParse(row.theme);
38
+ return parsed.success ? parsed.data : { mode: "auto" };
39
+ }
40
+
41
+ function rowToPage(row: StatusPageRow): StatusPage {
42
+ return {
43
+ id: row.id,
44
+ slug: row.slug,
45
+ title: row.title,
46
+ visibility: rowVisibility(row),
47
+ theme: rowTheme(row),
48
+ draftLayout: row.draftLayout,
49
+ publishedLayout: row.publishedLayout ?? null,
50
+ published: row.publishedLayout !== null,
51
+ publishedAt: iso(row.publishedAt),
52
+ createdAt: row.createdAt.toISOString(),
53
+ updatedAt: row.updatedAt.toISOString(),
54
+ };
55
+ }
56
+
57
+ function rowToSummary(row: StatusPageRow): StatusPageSummary {
58
+ return {
59
+ id: row.id,
60
+ slug: row.slug,
61
+ title: row.title,
62
+ visibility: rowVisibility(row),
63
+ published: row.publishedLayout !== null,
64
+ publishedAt: iso(row.publishedAt),
65
+ updatedAt: row.updatedAt.toISOString(),
66
+ };
67
+ }
68
+
69
+
70
+ export interface StatusPageServiceDeps {
71
+ db: Db;
72
+ registry: WidgetTypeRegistry;
73
+ /** Trusted service client for the public resolver (reads bound resources). */
74
+ rpcClient: RpcClient;
75
+ logger: Logger;
76
+ }
77
+
78
+ export class StatusPageService {
79
+ constructor(private readonly deps: StatusPageServiceDeps) {}
80
+
81
+ async list(): Promise<StatusPageSummary[]> {
82
+ const rows = await this.deps.db.select().from(statusPages);
83
+ return rows.map((row) => rowToSummary(row));
84
+ }
85
+
86
+ async get(id: string): Promise<StatusPage | null> {
87
+ const [row] = await this.deps.db
88
+ .select()
89
+ .from(statusPages)
90
+ .where(eq(statusPages.id, id))
91
+ .limit(1);
92
+ return row ? rowToPage(row) : null;
93
+ }
94
+
95
+ private async requireRow(id: string): Promise<StatusPageRow> {
96
+ const [row] = await this.deps.db
97
+ .select()
98
+ .from(statusPages)
99
+ .where(eq(statusPages.id, id))
100
+ .limit(1);
101
+ if (!row) throw new ORPCError("NOT_FOUND", { message: "Status page not found." });
102
+ return row;
103
+ }
104
+
105
+ private async assertSlugFree(slug: string, exceptId?: string): Promise<void> {
106
+ const [row] = await this.deps.db
107
+ .select({ id: statusPages.id })
108
+ .from(statusPages)
109
+ .where(eq(statusPages.slug, slug))
110
+ .limit(1);
111
+ if (row && row.id !== exceptId) {
112
+ throw new ORPCError("CONFLICT", {
113
+ message: `The slug "${slug}" is already in use.`,
114
+ });
115
+ }
116
+ }
117
+
118
+ async create(input: { title: string; slug: string }): Promise<StatusPage> {
119
+ await this.assertSlugFree(input.slug);
120
+ const [row] = await this.deps.db
121
+ .insert(statusPages)
122
+ .values({ title: input.title, slug: input.slug })
123
+ .returning();
124
+ return rowToPage(row);
125
+ }
126
+
127
+ async update(input: {
128
+ id: string;
129
+ title?: string;
130
+ slug?: string;
131
+ visibility?: StatusPageVisibility;
132
+ theme?: StatusPageTheme;
133
+ draftLayout?: StatusPageLayout;
134
+ }): Promise<StatusPage> {
135
+ await this.requireRow(input.id);
136
+ if (input.slug !== undefined) await this.assertSlugFree(input.slug, input.id);
137
+ const set: Partial<StatusPageRow> = { updatedAt: new Date() };
138
+ if (input.title !== undefined) set.title = input.title;
139
+ if (input.slug !== undefined) set.slug = input.slug;
140
+ if (input.visibility !== undefined) set.visibility = input.visibility;
141
+ if (input.theme !== undefined) set.theme = input.theme;
142
+ if (input.draftLayout !== undefined) set.draftLayout = input.draftLayout;
143
+ const [row] = await this.deps.db
144
+ .update(statusPages)
145
+ .set(set)
146
+ .where(eq(statusPages.id, input.id))
147
+ .returning();
148
+ return rowToPage(row);
149
+ }
150
+
151
+ /** Every resource bound by the widgets in a layout (deduped per type+id). */
152
+ collectBoundResources(layout: StatusPageLayout): BoundResource[] {
153
+ const seen = new Set<string>();
154
+ const out: BoundResource[] = [];
155
+ for (const block of layout) {
156
+ const widget = this.deps.registry.get(block.type);
157
+ if (!widget) continue;
158
+ let bound: BoundResource[] = [];
159
+ try {
160
+ bound = widget.boundResources(block.config);
161
+ } catch {
162
+ // A malformed stored config can't bind anything; ignore for collection.
163
+ bound = [];
164
+ }
165
+ for (const b of bound) {
166
+ const key = `${b.resourceType}:${b.resourceId}`;
167
+ if (!seen.has(key)) {
168
+ seen.add(key);
169
+ out.push(b);
170
+ }
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Publish the draft. The middleware already verified `manage` on the page;
178
+ * this additionally enforces the editor can READ every bound resource (you
179
+ * cannot publish what you cannot see) using the USER-scoped client, then
180
+ * snapshots draft -> published. Returns the exposed resources for the audit.
181
+ */
182
+ async publish(input: {
183
+ id: string;
184
+ userClient: RpcClient;
185
+ }): Promise<{ page: StatusPage; exposed: BoundResource[] }> {
186
+ const row = await this.requireRow(input.id);
187
+ await this.assertEditorCanAccess(input.userClient, row.draftLayout);
188
+ const bound = this.collectBoundResources(row.draftLayout);
189
+ const now = new Date();
190
+ const [updated] = await this.deps.db
191
+ .update(statusPages)
192
+ .set({ publishedLayout: row.draftLayout, publishedAt: now, updatedAt: now })
193
+ .where(eq(statusPages.id, input.id))
194
+ .returning();
195
+ return { page: rowToPage(updated), exposed: bound };
196
+ }
197
+
198
+ async unpublish(id: string): Promise<StatusPage> {
199
+ await this.requireRow(id);
200
+ const [row] = await this.deps.db
201
+ .update(statusPages)
202
+ .set({ publishedLayout: null, publishedAt: null, updatedAt: new Date() })
203
+ .where(eq(statusPages.id, id))
204
+ .returning();
205
+ return rowToPage(row);
206
+ }
207
+
208
+ async remove(id: string): Promise<boolean> {
209
+ const deleted = await this.deps.db
210
+ .delete(statusPages)
211
+ .where(eq(statusPages.id, id))
212
+ .returning({ id: statusPages.id });
213
+ return deleted.length > 0;
214
+ }
215
+
216
+ listWidgetTypes(): WidgetTypeDescriptor[] {
217
+ return this.deps.registry.list().map((w) => ({
218
+ id: w.qualifiedId,
219
+ displayName: w.displayName,
220
+ description: w.description,
221
+ category: w.category,
222
+ binding: w.binding,
223
+ }));
224
+ }
225
+
226
+ /**
227
+ * "Cannot publish what you cannot see." For each widget that binds resources,
228
+ * delegate the access check to the widget's own `assertBindingsReadable` (the
229
+ * OWNING plugin knows how to verify its resource types). FAILS CLOSED: a
230
+ * binding widget that provides no such check rejects the publish — the
231
+ * platform never silently passes an unverifiable binding.
232
+ */
233
+ private async assertEditorCanAccess(
234
+ userClient: RpcClient,
235
+ layout: StatusPageLayout,
236
+ ): Promise<void> {
237
+ for (const block of layout) {
238
+ const widget = this.deps.registry.get(block.type);
239
+ if (!widget) continue;
240
+ let binds: BoundResource[] = [];
241
+ try {
242
+ binds = widget.boundResources(block.config);
243
+ } catch {
244
+ binds = [];
245
+ }
246
+ if (binds.length === 0) continue;
247
+ if (!widget.assertBindingsReadable) {
248
+ throw new ORPCError("FORBIDDEN", {
249
+ message:
250
+ `The "${widget.displayName}" widget binds resources but provides ` +
251
+ "no access check, so it cannot be published.",
252
+ });
253
+ }
254
+ try {
255
+ await widget.assertBindingsReadable({ userClient, config: block.config });
256
+ } catch (error) {
257
+ throw new ORPCError("FORBIDDEN", {
258
+ message: extractErrorMessage(
259
+ error,
260
+ "You can only publish widgets bound to resources you can access.",
261
+ ),
262
+ });
263
+ }
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Resolve a PUBLISHED page for the public surface. Returns null when no page
269
+ * matches, the page is unpublished, or its visibility excludes the caller —
270
+ * never revealing that an unpublished/private page exists. Each block is
271
+ * resolved via its widget's `resolvePublic` (trusted reads) and re-validated
272
+ * against the widget's DTO schema, so only allow-listed fields are emitted; a
273
+ * resolver error degrades that one block to `data: null`, never the page.
274
+ */
275
+ async resolvePublished(input: {
276
+ slug: string;
277
+ isAuthenticated: boolean;
278
+ }): Promise<PublishedStatusPage | null> {
279
+ const [row] = await this.deps.db
280
+ .select()
281
+ .from(statusPages)
282
+ .where(eq(statusPages.slug, input.slug))
283
+ .limit(1);
284
+ if (!row || row.publishedLayout === null) return null;
285
+ const visibility = rowVisibility(row);
286
+ if (visibility === "authenticated" && !input.isAuthenticated) return null;
287
+
288
+ // Per-resolve memo so a page with many widgets shares expensive reads (e.g.
289
+ // the full catalog) instead of re-fetching per widget on every public hit.
290
+ const memo = new Map<string, Promise<unknown>>();
291
+ const ctx: WidgetResolveContext = {
292
+ rpcClient: this.deps.rpcClient,
293
+ cache: <T,>(key: string, loader: () => Promise<T>): Promise<T> => {
294
+ const existing = memo.get(key);
295
+ if (existing) return existing as Promise<T>;
296
+ const created = loader();
297
+ memo.set(key, created);
298
+ return created;
299
+ },
300
+ };
301
+
302
+ const blocks: ResolvedBlock[] = [];
303
+ for (const block of row.publishedLayout) {
304
+ const widget = this.deps.registry.get(block.type);
305
+ if (!widget) continue;
306
+ let data: unknown = null;
307
+ try {
308
+ const raw = await widget.resolvePublic({
309
+ config: block.config,
310
+ ctx,
311
+ });
312
+ data = widget.dtoSchema.parse(raw);
313
+ } catch (error) {
314
+ this.deps.logger.warn("Status page widget failed to resolve", {
315
+ slug: row.slug,
316
+ blockType: block.type,
317
+ error: extractErrorMessage(error),
318
+ });
319
+ data = null;
320
+ }
321
+ blocks.push({
322
+ id: block.id,
323
+ type: block.type,
324
+ ...(block.label === undefined ? {} : { label: block.label }),
325
+ data,
326
+ });
327
+ }
328
+
329
+ return {
330
+ slug: row.slug,
331
+ title: row.title,
332
+ theme: rowTheme(row),
333
+ blocks,
334
+ generatedAt: new Date().toISOString(),
335
+ };
336
+ }
337
+ }
@@ -0,0 +1,39 @@
1
+ import { createORPCClient } from "@orpc/client";
2
+ import { RPCLink } from "@orpc/client/fetch";
3
+ import type { RpcClient } from "@checkstack/backend-api";
4
+
5
+ /**
6
+ * Build a USER-SCOPED RpcClient that re-enters the live router AS the calling
7
+ * user (forwarding their cookie / bearer), never as a trusted service. Used by
8
+ * the publish-time gate to verify the editor can actually READ every resource a
9
+ * widget binds — "you cannot publish what you cannot see". Mirrors the AI
10
+ * chat read-invoker; only the user's own auth headers are forwarded.
11
+ */
12
+ export function createUserScopedRpcClient({
13
+ internalUrl,
14
+ forwardHeaders,
15
+ }: {
16
+ internalUrl: string;
17
+ forwardHeaders: Record<string, string>;
18
+ }): RpcClient {
19
+ const link = new RPCLink({ url: `${internalUrl}/api`, headers: forwardHeaders });
20
+ const client = createORPCClient(link);
21
+ return {
22
+ forPlugin(def) {
23
+ return (client as Record<string, unknown>)[def.pluginId] as never;
24
+ },
25
+ };
26
+ }
27
+
28
+ /** Extract ONLY the forwardable auth headers (cookie + bearer) from a request. */
29
+ export function forwardableAuthHeadersFrom(
30
+ headers: Headers | undefined,
31
+ ): Record<string, string> {
32
+ const out: Record<string, string> = {};
33
+ if (!headers) return out;
34
+ const cookie = headers.get("cookie");
35
+ if (cookie) out.cookie = cookie;
36
+ const auth = headers.get("authorization");
37
+ if (auth) out.authorization = auth;
38
+ return out;
39
+ }