@bygghammar/cms-platform 0.48.0 → 0.49.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.49.0
4
+
5
+ - Public-site runtime and backend plumbing are package-owned. New `site/routes`: `createSiteRoutes(pages)` derives `resolveRoute`, `pageHeadMeta`, canonical/hreflang assembly and `sitemapIndexEntries`/`sitemapUrls` from a site's page registry, so every site gets identical SEO behaviour.
6
+ - New `site/PageRenderer`: `createPageRenderer(registry, fetchPublishedPage)` returns the published `PageRenderer`, the `DraftPageRenderer` and the page query options, keyed to a site's own block registry.
7
+ - New `server/env`, `server/clients` and `server/auth-context`: Supabase server key resolution (including named-key JSON bundles and the legacy service-role variable), the opaque `sb_` key `apikey` fetch shim, the lazy admin/public client factory and bearer-token auth-context resolution.
8
+ - New `auth/browser` (`createBrowserAuth`) and `auth/middleware` (`createAttachAuth`, `createRequireAuth`): the signed-out/configuration-error auth state model and the RPC bearer attachment, so a misconfigured backend can never blank the public shell.
9
+ - New HTTP handler cores under `server/http/*`: `robots`, `sitemap`, `health`, `fonts`, `backup` (analyze/estimate/export/import) and `cms-content`. Site route files drop to a `createFileRoute` calling the core.
10
+ - New `runtime/error-capture`, `runtime/error-page` and `runtime/error-reporting`: the out-of-band error capture with full cause chains, the dependency-free SSR failure page and the editor telemetry bridge.
11
+
3
12
  ## 0.48.0
4
13
 
5
14
  - Public-site media runtime is package-owned: new `media/FocalImage` (focus-aware `<img>`, `MediaFocusProvider`, `useFocalStyle`, `useObjectPosition`), `media/MediaMetaProvider` (alt/OG metadata context) and `media/LazyVideo` (bandwidth- and mobile-safe autoplay video). Sites pass their own focus/meta query options and register nothing else.
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
- "sideEffects": false,
6
+ "sideEffects": [
7
+ "./src/runtime/error-capture.ts"
8
+ ],
7
9
  "license": "UNLICENSED",
8
10
  "publishConfig": {
9
11
  "access": "public"
@@ -175,7 +177,23 @@
175
177
  "./media/MediaMetaProvider": "./src/media/MediaMetaProvider.tsx",
176
178
  "./media/LazyVideo": "./src/media/LazyVideo.tsx",
177
179
  "./media/use-media-upload": "./src/media/use-media-upload.ts",
178
- "./cms/page-draft-store": "./src/cms/page-draft-store.ts"
180
+ "./cms/page-draft-store": "./src/cms/page-draft-store.ts",
181
+ "./site/routes": "./src/site/routes.ts",
182
+ "./site/PageRenderer": "./src/site/PageRenderer.tsx",
183
+ "./server/env": "./src/server/env.ts",
184
+ "./server/clients": "./src/server/clients.ts",
185
+ "./server/auth-context": "./src/server/auth-context.ts",
186
+ "./auth/browser": "./src/auth/browser.ts",
187
+ "./auth/middleware": "./src/auth/middleware.ts",
188
+ "./server/http/robots": "./src/server/http/robots.ts",
189
+ "./server/http/sitemap": "./src/server/http/sitemap.ts",
190
+ "./server/http/health": "./src/server/http/health.ts",
191
+ "./server/http/fonts": "./src/server/http/fonts.ts",
192
+ "./server/http/backup": "./src/server/http/backup.ts",
193
+ "./server/http/cms-content": "./src/server/http/cms-content.ts",
194
+ "./runtime/error-capture": "./src/runtime/error-capture.ts",
195
+ "./runtime/error-page": "./src/runtime/error-page.ts",
196
+ "./runtime/error-reporting": "./src/runtime/error-reporting.ts"
179
197
  },
180
198
  "dependencies": {
181
199
  "fflate": "^0.8.3"
@@ -252,4 +270,4 @@
252
270
  "cms-platform-migrations": "scripts/sync-migrations.mjs",
253
271
  "create-cms-site": "./scripts/create-cms-site.mjs"
254
272
  }
255
- }
273
+ }
File without changes
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Browser auth (site-agnostic).
3
+ *
4
+ * Wraps a lazily-loaded Supabase browser client so no component imports the
5
+ * client directly and anonymous public pages never initialise it.
6
+ *
7
+ * A site creates one instance:
8
+ * export const { getAuthUser, signInWithPassword, ... } =
9
+ * createBrowserAuth(() => import("@/integrations/supabase/client").then(m => m.supabase));
10
+ */
11
+ type MinimalAuthClient = {
12
+ auth: {
13
+ getSession(): Promise<{ data: { session: { user?: AuthSessionUser | null } | null } }>;
14
+ onAuthStateChange(
15
+ cb: (event: string, session: { user?: AuthSessionUser | null } | null) => void,
16
+ ): { data: { subscription: { unsubscribe(): void } } };
17
+ signInWithPassword(input: { email: string; password: string }): Promise<{
18
+ error: { message: string } | null;
19
+ }>;
20
+ signOut(): Promise<unknown>;
21
+ };
22
+ };
23
+
24
+ type AuthSessionUser = { id: string; email?: string | null };
25
+
26
+ export type AuthUser = { id: string; email: string | null };
27
+
28
+ /**
29
+ * Auth state model.
30
+ *
31
+ * `configuration_error` is reserved for a genuinely missing/invalid backend
32
+ * configuration. A missing token, expired session, hydration timeout, network
33
+ * blip or any signed-out state is NOT a configuration error.
34
+ */
35
+ export type AuthStatus = "signed_in" | "signed_out" | "configuration_error";
36
+
37
+ export type BrowserAuth = {
38
+ getAuthUser: () => Promise<AuthUser | null>;
39
+ getAuthStatus: () => Promise<{ status: AuthStatus; user: AuthUser | null }>;
40
+ isAuthConfigBroken: () => boolean;
41
+ onAuthUserChange: (cb: (user: AuthUser | null) => void) => () => void;
42
+ signInWithPassword: (email: string, password: string) => Promise<{ error: string | null }>;
43
+ signOut: () => Promise<void>;
44
+ };
45
+
46
+ export function createBrowserAuth(loadClient: () => Promise<MinimalAuthClient>): BrowserAuth {
47
+ let configErrorLogged = false;
48
+
49
+ /** True when the failure is a real backend configuration problem. */
50
+ const isConfigurationError = (error: unknown): boolean => {
51
+ const message = error instanceof Error ? error.message : String(error ?? "");
52
+ return /Missing Supabase environment variable/i.test(message);
53
+ };
54
+
55
+ /** Log a configuration failure once, without hiding it. */
56
+ const reportAuthConfigError = (error: unknown): boolean => {
57
+ if (!isConfigurationError(error)) return false;
58
+ if (!configErrorLogged) {
59
+ configErrorLogged = true;
60
+ console.error(
61
+ "[auth] Authentication is unavailable — the backend configuration is missing or invalid. Visitors are treated as signed out.",
62
+ );
63
+ }
64
+ return true;
65
+ };
66
+
67
+ const clearAuthConfigError = () => {
68
+ configErrorLogged = false;
69
+ };
70
+
71
+ const isAuthConfigBroken = () => configErrorLogged;
72
+
73
+ const getAuthUser = async (): Promise<AuthUser | null> => {
74
+ try {
75
+ const { data } = await (await loadClient()).auth.getSession();
76
+ clearAuthConfigError();
77
+ const user = data.session?.user;
78
+ return user ? { id: user.id, email: user.email ?? null } : null;
79
+ } catch (error) {
80
+ // Non-configuration failures (expired token, network) mean signed out.
81
+ reportAuthConfigError(error);
82
+ return null;
83
+ }
84
+ };
85
+
86
+ const getAuthStatus = async (): Promise<{ status: AuthStatus; user: AuthUser | null }> => {
87
+ const user = await getAuthUser();
88
+ if (user) return { status: "signed_in", user };
89
+ return { status: isAuthConfigBroken() ? "configuration_error" : "signed_out", user: null };
90
+ };
91
+
92
+ const onAuthUserChange = (cb: (user: AuthUser | null) => void): (() => void) => {
93
+ let unsubscribe: (() => void) | null = null;
94
+ let cancelled = false;
95
+ void loadClient()
96
+ .then((client) => {
97
+ clearAuthConfigError();
98
+ const { data: sub } = client.auth.onAuthStateChange((_e, session) =>
99
+ cb(session?.user ? { id: session.user.id, email: session.user.email ?? null } : null),
100
+ );
101
+ if (cancelled) sub.subscription.unsubscribe();
102
+ else unsubscribe = () => sub.subscription.unsubscribe();
103
+ })
104
+ .catch((error: unknown) => {
105
+ // A misconfiguration must never reject unhandled and blank the public
106
+ // shell: log once and treat the visitor as signed out.
107
+ reportAuthConfigError(error);
108
+ cb(null);
109
+ });
110
+
111
+ return () => {
112
+ cancelled = true;
113
+ unsubscribe?.();
114
+ };
115
+ };
116
+
117
+ const signInWithPassword = async (
118
+ email: string,
119
+ password: string,
120
+ ): Promise<{ error: string | null }> => {
121
+ try {
122
+ // The login screen may need to initialise the client even though no token
123
+ // exists yet — that is a normal signed-out flow, not an error.
124
+ const { error } = await (await loadClient()).auth.signInWithPassword({ email, password });
125
+ clearAuthConfigError();
126
+ return { error: error?.message ?? null };
127
+ } catch (error) {
128
+ if (reportAuthConfigError(error)) {
129
+ return {
130
+ error:
131
+ "Authentication is not configured for this environment. Contact the administrator.",
132
+ };
133
+ }
134
+ return { error: error instanceof Error ? error.message : "Sign-in failed" };
135
+ }
136
+ };
137
+
138
+ const signOut = async (): Promise<void> => {
139
+ try {
140
+ await (await loadClient()).auth.signOut();
141
+ } catch (error) {
142
+ reportAuthConfigError(error);
143
+ }
144
+ };
145
+
146
+ return {
147
+ getAuthUser,
148
+ getAuthStatus,
149
+ isAuthConfigBroken,
150
+ onAuthUserChange,
151
+ signInWithPassword,
152
+ signOut,
153
+ };
154
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Server-function auth middleware factories (site-agnostic).
3
+ *
4
+ * `createAttachAuth` attaches the Supabase bearer token to every RPC without
5
+ * ever being able to blank the app: a missing/unavailable auth client just
6
+ * sends the request unauthenticated and the server decides.
7
+ *
8
+ * `createRequireAuth` guards a protected server function. The context resolver
9
+ * is injected (and normally dynamically imported) so nothing server-only ends
10
+ * up in the client bundle.
11
+ */
12
+ import { createMiddleware } from "@tanstack/react-start";
13
+
14
+ type TokenLoader = () => Promise<string | null | undefined>;
15
+
16
+ export function createAttachAuth(loadToken: TokenLoader) {
17
+ return createMiddleware({ type: "function" }).client(async ({ next }) => {
18
+ let headers: Record<string, string> = {};
19
+ try {
20
+ const token = await loadToken();
21
+ if (token) headers = { Authorization: `Bearer ${token}` };
22
+ } catch {
23
+ // No usable auth client — continue as an anonymous request.
24
+ }
25
+ return next({ headers });
26
+ });
27
+ }
28
+
29
+ export function createRequireAuth<C extends Record<string, unknown>>(
30
+ resolveContext: () => Promise<C>,
31
+ ) {
32
+ return createMiddleware({ type: "function" }).server(async ({ next }) => {
33
+ const context = await resolveContext();
34
+ return next({ context });
35
+ });
36
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Captures the original Error out-of-band so the server entry can recover the
3
+ * stack when the HTTP layer has already swallowed the throw into a generic 500.
4
+ *
5
+ * Import this once from the server entry (side-effecting).
6
+ */
7
+ let lastCapturedError: { error: unknown; at: number } | undefined;
8
+ const TTL_MS = 5_000;
9
+
10
+ function record(error: unknown) {
11
+ lastCapturedError = { error, at: Date.now() };
12
+ }
13
+
14
+ // h3's HTTPError serializes to {"status":500,"unhandled":true,"message":"HTTPError"} —
15
+ // no stack, no cause — so a plain console.error(error) reaches the log pipeline with
16
+ // the failure detail stripped. Expand Error-like args into a string that keeps the
17
+ // message, stack, and the full cause chain.
18
+ const CAUSE_DEPTH_LIMIT = 5;
19
+ const DESCRIPTION_LENGTH_LIMIT = 8_000;
20
+
21
+ export function describeError(error: unknown): string {
22
+ const parts: string[] = [];
23
+ let current: unknown = error;
24
+ for (let depth = 0; depth < CAUSE_DEPTH_LIMIT && current != null; depth++) {
25
+ if (!(current instanceof Error)) {
26
+ parts.push(typeof current === "string" ? current : safeStringify(current));
27
+ break;
28
+ }
29
+ const label = depth === 0 ? "" : "caused by: ";
30
+ const status = describeStatus(current);
31
+ parts.push(`${label}${current.stack ?? `${current.name}: ${current.message}`}${status}`);
32
+ current = current.cause;
33
+ }
34
+ return parts.join("\n").slice(0, DESCRIPTION_LENGTH_LIMIT);
35
+ }
36
+
37
+ function describeStatus(error: Error): string {
38
+ const { status, statusCode } = error as { status?: unknown; statusCode?: unknown };
39
+ const value = status ?? statusCode;
40
+ return typeof value === "number" ? ` (status ${value})` : "";
41
+ }
42
+
43
+ function safeStringify(value: unknown): string {
44
+ try {
45
+ return JSON.stringify(value) ?? String(value);
46
+ } catch {
47
+ return String(value);
48
+ }
49
+ }
50
+
51
+ function isErrorLike(value: unknown): value is Error {
52
+ return value instanceof Error;
53
+ }
54
+
55
+ // Wrap console.error so errors logged by any layer — including the HTTP layer's
56
+ // internal unhandled-error logging, which cannot be hooked directly — are both
57
+ // recorded for consumeLastCapturedError and expanded before serialization.
58
+ const originalConsoleError = console.error.bind(console);
59
+ console.error = (...args: unknown[]) => {
60
+ const expanded = args.map((arg) => {
61
+ if (!isErrorLike(arg)) return arg;
62
+ record(arg);
63
+ return describeError(arg);
64
+ });
65
+ originalConsoleError(...expanded);
66
+ };
67
+
68
+ if (typeof globalThis.addEventListener === "function") {
69
+ globalThis.addEventListener("error", (event) => record((event as ErrorEvent).error ?? event));
70
+ globalThis.addEventListener("unhandledrejection", (event) =>
71
+ record((event as PromiseRejectionEvent).reason),
72
+ );
73
+ }
74
+
75
+ export function consumeLastCapturedError(): unknown {
76
+ if (!lastCapturedError) return undefined;
77
+ if (Date.now() - lastCapturedError.at > TTL_MS) {
78
+ lastCapturedError = undefined;
79
+ return undefined;
80
+ }
81
+ const { error } = lastCapturedError;
82
+ lastCapturedError = undefined;
83
+ return error;
84
+ }
@@ -0,0 +1,31 @@
1
+ /** Minimal, dependency-free HTML shown when SSR itself failed. */
2
+ export function renderErrorPage(): string {
3
+ return `<!doctype html>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="utf-8" />
7
+ <title>This page didn't load</title>
8
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
9
+ <style>
10
+ body { font: 15px/1.5 system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: grid; place-items: center; min-height: 100vh; margin: 0; padding: 1.5rem; }
11
+ .card { max-width: 28rem; width: 100%; text-align: center; padding: 2rem; }
12
+ h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
13
+ p { color: #4b5563; margin: 0 0 1.5rem; }
14
+ .actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
15
+ a, button { padding: 0.5rem 1rem; border-radius: 0.375rem; font: inherit; cursor: pointer; text-decoration: none; border: 1px solid transparent; }
16
+ .primary { background: #111; color: #fff; }
17
+ .secondary { background: #fff; color: #111; border-color: #d1d5db; }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <div class="card">
22
+ <h1>This page didn't load</h1>
23
+ <p>Something went wrong on our end. You can try refreshing or head back home.</p>
24
+ <div class="actions">
25
+ <button class="primary" onclick="location.reload()">Try again</button>
26
+ <a class="secondary" href="/">Go home</a>
27
+ </div>
28
+ </div>
29
+ </body>
30
+ </html>`;
31
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Forwards React error-boundary failures to the Lovable editor's telemetry
3
+ * hooks when present. A no-op outside the browser and outside the editor.
4
+ */
5
+ type LovableErrorOptions = {
6
+ mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
7
+ handled?: boolean;
8
+ severity?: "error" | "warning" | "info";
9
+ };
10
+
11
+ type LovableEvents = {
12
+ captureException?: (
13
+ error: unknown,
14
+ context?: Record<string, unknown>,
15
+ options?: LovableErrorOptions,
16
+ ) => void;
17
+ };
18
+
19
+ declare global {
20
+ interface Window {
21
+ __lovableEvents?: LovableEvents;
22
+ __lovableReportRuntimeError?: (payload: {
23
+ message: string;
24
+ stack?: string;
25
+ filename?: string;
26
+ }) => void;
27
+ }
28
+ }
29
+
30
+ export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
31
+ if (typeof window === "undefined") return;
32
+ window.__lovableEvents?.captureException?.(
33
+ error,
34
+ {
35
+ source: "react_error_boundary",
36
+ route: window.location.pathname,
37
+ ...context,
38
+ },
39
+ {
40
+ mechanism: "react_error_boundary",
41
+ handled: false,
42
+ severity: "error",
43
+ },
44
+ );
45
+ // Prod React does not rethrow boundary-caught errors to window.onerror, so the
46
+ // editor's telemetry never sees them. Forward to lovable.js's reporting hook,
47
+ // which is present only inside the editor preview.
48
+ // Loaders and server fns commonly throw a raw Response; String(it) is the
49
+ // opaque "[object Response]", so pull out the status and URL instead.
50
+ const message =
51
+ error instanceof Response
52
+ ? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
53
+ : error instanceof Error
54
+ ? error.message
55
+ : String(error);
56
+ window.__lovableReportRuntimeError?.({
57
+ message,
58
+ stack: error instanceof Error ? error.stack : undefined,
59
+ filename: window.location.pathname,
60
+ });
61
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Request authentication for protected server functions (site-agnostic).
3
+ *
4
+ * Validates the Supabase bearer token on the incoming request and returns
5
+ * `{ supabase, userId, claims }`. The returned client acts as the signed-in
6
+ * user, so RLS applies normally.
7
+ */
8
+ import { createClient, type SupabaseClient } from "@supabase/supabase-js";
9
+
10
+ import { resolveSupabaseServerEnvironment } from "./env";
11
+
12
+ export type PlatformAuthContext<DB = unknown> = {
13
+ supabase: SupabaseClient<DB>;
14
+ userId: string;
15
+ claims: Record<string, unknown>;
16
+ };
17
+
18
+ /**
19
+ * Resolve the auth context from a request's `Authorization: Bearer` header.
20
+ * Throws on any missing/invalid token.
21
+ */
22
+ export async function resolveAuthContextCore<DB = unknown>(
23
+ request: Request | undefined,
24
+ ): Promise<PlatformAuthContext<DB>> {
25
+ if (!request?.headers) throw new Error("Unauthorized: No request headers available");
26
+
27
+ const { url, publishableKey: key } = resolveSupabaseServerEnvironment();
28
+ if (!url || !key) throw new Error("Missing Supabase environment variables.");
29
+
30
+ const header = request.headers.get("authorization");
31
+ if (!header?.startsWith("Bearer "))
32
+ throw new Error("Unauthorized: No authorization header provided");
33
+ const token = header.slice(7);
34
+ if (token.split(".").length !== 3) throw new Error("Unauthorized: Invalid token");
35
+
36
+ const supabase = createClient<DB>(url, key, {
37
+ global: {
38
+ headers: { Authorization: `Bearer ${token}` },
39
+ // Opaque sb_ keys are not JWTs: send only `apikey`.
40
+ fetch: (input: RequestInfo | URL, init?: RequestInit) => {
41
+ const h = new Headers(init?.headers);
42
+ if (key.startsWith("sb_") && h.get("Authorization") === `Bearer ${key}`)
43
+ h.delete("Authorization");
44
+ h.set("apikey", key);
45
+ return fetch(input, { ...init, headers: h });
46
+ },
47
+ },
48
+ auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
49
+ });
50
+
51
+ const { data, error } = await supabase.auth.getClaims(token);
52
+ if (error || !data?.claims?.sub) throw new Error("Unauthorized: Invalid token");
53
+ return {
54
+ supabase,
55
+ userId: data.claims.sub as string,
56
+ claims: data.claims as Record<string, unknown>,
57
+ };
58
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Server-side Supabase clients (site-agnostic).
3
+ *
4
+ * Server-only. Sites call `createBackendClients<Database>()` once and expose the
5
+ * result, so privilege level is explicit at every call site and both current
6
+ * and legacy server keys are supported.
7
+ */
8
+ import { createClient, type SupabaseClient } from "@supabase/supabase-js";
9
+
10
+ import { createServerFetch, resolveSupabaseServerEnvironment } from "./env";
11
+
12
+ export type BackendClients<DB> = {
13
+ /** Privileged client: bypasses RLS. Trusted server paths only. */
14
+ admin: SupabaseClient<DB>;
15
+ /** Async accessor for call sites using the dynamic-import pattern. */
16
+ adminClient: () => Promise<SupabaseClient<DB>>;
17
+ /** Anonymous/public client: RLS applies as the anon role. */
18
+ publicClient: () => Promise<SupabaseClient<DB>>;
19
+ };
20
+
21
+ export function createBackendClients<DB>(): BackendClients<DB> {
22
+ let resolved: SupabaseClient<DB> | undefined;
23
+
24
+ function build(): SupabaseClient<DB> {
25
+ const { url, secretKey } = resolveSupabaseServerEnvironment();
26
+ if (!url || !secretKey) {
27
+ const missing = [
28
+ ...(!url ? ["SUPABASE_URL"] : []),
29
+ ...(!secretKey
30
+ ? ["SUPABASE_SECRET_KEYS, SUPABASE_SECRET_KEY, or SUPABASE_SERVICE_ROLE_KEY"]
31
+ : []),
32
+ ];
33
+ throw new Error(`Missing Supabase server environment: ${missing.join("; ")}.`);
34
+ }
35
+
36
+ return createClient<DB>(url, secretKey, {
37
+ global: { fetch: createServerFetch(secretKey) },
38
+ auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
39
+ });
40
+ }
41
+
42
+ // Resolved on property access so nothing is initialised at module scope.
43
+ const admin = new Proxy({} as SupabaseClient<DB>, {
44
+ get(_t, prop, receiver) {
45
+ if (!resolved) resolved = build();
46
+ return Reflect.get(resolved as object, prop, receiver);
47
+ },
48
+ });
49
+
50
+ return {
51
+ admin,
52
+ adminClient: async () => admin,
53
+ publicClient: async () => {
54
+ const { url, publishableKey: key } = resolveSupabaseServerEnvironment();
55
+ if (!url || !key) {
56
+ throw new Error("Missing SUPABASE_URL or Supabase publishable server key.");
57
+ }
58
+ return createClient<DB>(url, key, {
59
+ auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
60
+ global: { fetch: createServerFetch(key) },
61
+ });
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Supabase server environment resolution.
3
+ *
4
+ * Supports both the current key names and the named-key JSON bundles used by
5
+ * self-hosted stacks, plus the legacy service-role variable.
6
+ */
7
+ export type SupabaseServerEnv = Partial<
8
+ Record<
9
+ | "SUPABASE_URL"
10
+ | "SUPABASE_PUBLISHABLE_KEYS"
11
+ | "SUPABASE_PUBLISHABLE_KEY"
12
+ | "SUPABASE_SECRET_KEYS"
13
+ | "SUPABASE_SECRET_KEY"
14
+ | "SUPABASE_SERVICE_ROLE_KEY",
15
+ string
16
+ >
17
+ >;
18
+
19
+ function defaultNamedKey(value: string | undefined): string | undefined {
20
+ if (!value) return undefined;
21
+
22
+ try {
23
+ const parsed = JSON.parse(value) as unknown;
24
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
25
+
26
+ const key = (parsed as Record<string, unknown>).default;
27
+ return typeof key === "string" && key.length > 0 ? key : undefined;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ /** Resolve both current Supabase keys and the legacy self-host service-role key. */
34
+ export function resolveSupabaseServerEnvironment(
35
+ env: SupabaseServerEnv = process.env as SupabaseServerEnv,
36
+ ): {
37
+ url: string | undefined;
38
+ publishableKey: string | undefined;
39
+ secretKey: string | undefined;
40
+ } {
41
+ return {
42
+ url: env.SUPABASE_URL,
43
+ publishableKey: defaultNamedKey(env.SUPABASE_PUBLISHABLE_KEYS) ?? env.SUPABASE_PUBLISHABLE_KEY,
44
+ secretKey:
45
+ defaultNamedKey(env.SUPABASE_SECRET_KEYS) ??
46
+ env.SUPABASE_SECRET_KEY ??
47
+ env.SUPABASE_SERVICE_ROLE_KEY,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * `fetch` shim for Supabase server clients.
53
+ *
54
+ * Opaque `sb_` keys are API keys, not bearer JWTs: PostgREST rejects them in an
55
+ * `Authorization` header, so send them only as `apikey`.
56
+ */
57
+ export function createServerFetch(key: string): typeof fetch {
58
+ return (input, init) => {
59
+ const headers = new Headers(
60
+ typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined,
61
+ );
62
+
63
+ if (init?.headers) {
64
+ new Headers(init.headers).forEach((value, name) => headers.set(name, value));
65
+ }
66
+
67
+ if (key.startsWith("sb_") && headers.get("Authorization") === `Bearer ${key}`) {
68
+ headers.delete("Authorization");
69
+ }
70
+
71
+ headers.set("apikey", key);
72
+ return fetch(input, { ...init, headers });
73
+ };
74
+ }