@palbase/web 4.0.1 → 5.0.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/README.md +20 -3
- package/dist/{analytics-facade-DwOtlXPP.d.cts → analytics-facade-CaluA4bW.d.cts} +26 -1
- package/dist/{analytics-facade-Nk6J9Ncm.d.ts → analytics-facade-Cp3d4QI-.d.ts} +26 -1
- package/dist/chunk-BNAGRUIQ.js +381 -0
- package/dist/chunk-BNAGRUIQ.js.map +1 -0
- package/dist/{chunk-NXEPAEF5.js → chunk-CFDU23TB.js} +3 -3
- package/dist/chunk-CFDU23TB.js.map +1 -0
- package/dist/chunk-OWLTZG2V.js +24 -0
- package/dist/chunk-OWLTZG2V.js.map +1 -0
- package/dist/{chunk-TGDS6VMT.js → chunk-XTQXFZUP.js} +68 -376
- package/dist/chunk-XTQXFZUP.js.map +1 -0
- package/dist/gen/cli.cjs +5 -5
- package/dist/gen/cli.cjs.map +1 -1
- package/dist/gen/cli.js +5 -5
- package/dist/gen/cli.js.map +1 -1
- package/dist/index.cjs +55 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +6 -4
- package/dist/internal.cjs +57 -5
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +3 -3
- package/dist/internal.d.ts +3 -3
- package/dist/internal.js +3 -2
- package/dist/next/client.cjs +57 -5
- package/dist/next/client.cjs.map +1 -1
- package/dist/next/client.js +3 -2
- package/dist/next/client.js.map +1 -1
- package/dist/next/index.cjs +61 -93
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +4 -64
- package/dist/next/index.d.ts +4 -64
- package/dist/next/index.js +9 -103
- package/dist/next/index.js.map +1 -1
- package/dist/next/middleware.cjs +263 -0
- package/dist/next/middleware.cjs.map +1 -0
- package/dist/next/middleware.d.cts +101 -0
- package/dist/next/middleware.d.ts +101 -0
- package/dist/next/middleware.js +107 -0
- package/dist/next/middleware.js.map +1 -0
- package/dist/{pb-FG_nV7NR.d.ts → pb-DUK5qy81.d.ts} +1 -1
- package/dist/{pb-C9v5dEO6.d.cts → pb-DvV6ftmf.d.cts} +1 -1
- package/dist/react/index.cjs +10 -2
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +3 -2
- package/dist/react/index.js.map +1 -1
- package/package.json +11 -1
- package/dist/chunk-NXEPAEF5.js.map +0 -1
- package/dist/chunk-TGDS6VMT.js.map +0 -1
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/next/middleware.ts
|
|
31
|
+
var middleware_exports = {};
|
|
32
|
+
__export(middleware_exports, {
|
|
33
|
+
palbeMiddleware: () => palbeMiddleware
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(middleware_exports);
|
|
36
|
+
|
|
37
|
+
// src/api-key.ts
|
|
38
|
+
var PUBLISHABLE_API_KEY_RE = /^pb_([a-z0-9]{4,24})_c[A-Za-z0-9]{20}$/;
|
|
39
|
+
function environmentRefFromPublishableApiKey(apiKey) {
|
|
40
|
+
const match = PUBLISHABLE_API_KEY_RE.exec(apiKey);
|
|
41
|
+
return match ? match[1] ?? "" : "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/errors.ts
|
|
45
|
+
var BackendError = class _BackendError extends Error {
|
|
46
|
+
kind;
|
|
47
|
+
code;
|
|
48
|
+
status;
|
|
49
|
+
requestId;
|
|
50
|
+
fields;
|
|
51
|
+
retryAfter;
|
|
52
|
+
data;
|
|
53
|
+
constructor(kind, params) {
|
|
54
|
+
super(params.message);
|
|
55
|
+
this.name = "BackendError";
|
|
56
|
+
this.kind = kind;
|
|
57
|
+
this.code = params.code;
|
|
58
|
+
this.status = params.status ?? 0;
|
|
59
|
+
this.requestId = params.requestId;
|
|
60
|
+
this.fields = params.fields;
|
|
61
|
+
this.retryAfter = params.retryAfter;
|
|
62
|
+
this.data = params.data;
|
|
63
|
+
}
|
|
64
|
+
static notConfigured() {
|
|
65
|
+
return new _BackendError("notConfigured", {
|
|
66
|
+
code: "not_configured",
|
|
67
|
+
message: "Palbe is not configured. Run 'palbase web link' in your project and make sure palbe.gen.ts is imported once at app startup."
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// src/next/cookie-codec.ts
|
|
73
|
+
var MAX_COOKIE_VALUE = 3500;
|
|
74
|
+
var SESSION_COOKIE_ATTRS = {
|
|
75
|
+
path: "/",
|
|
76
|
+
sameSite: "lax",
|
|
77
|
+
secure: true,
|
|
78
|
+
maxAge: 2592e3
|
|
79
|
+
};
|
|
80
|
+
function sessionCookieName(environmentRef) {
|
|
81
|
+
return `palbe-session-${environmentRef}`;
|
|
82
|
+
}
|
|
83
|
+
function encodeSessionCookies(environmentRef, session) {
|
|
84
|
+
const name = sessionCookieName(environmentRef);
|
|
85
|
+
const value = encodeURIComponent(
|
|
86
|
+
JSON.stringify({ a: session.accessToken, r: session.refreshToken, e: session.expiresAt })
|
|
87
|
+
);
|
|
88
|
+
if (value.length <= MAX_COOKIE_VALUE) {
|
|
89
|
+
return { set: [{ name, value }], clear: [`${name}.0`] };
|
|
90
|
+
}
|
|
91
|
+
const chunks = [];
|
|
92
|
+
for (let start = 0; start < value.length; ) {
|
|
93
|
+
let end = Math.min(start + MAX_COOKIE_VALUE, value.length);
|
|
94
|
+
if (end < value.length) {
|
|
95
|
+
if (value[end - 1] === "%") end -= 1;
|
|
96
|
+
else if (value[end - 2] === "%") end -= 2;
|
|
97
|
+
}
|
|
98
|
+
chunks.push({ name: `${name}.${chunks.length}`, value: value.slice(start, end) });
|
|
99
|
+
start = end;
|
|
100
|
+
}
|
|
101
|
+
return { set: chunks, clear: [`${name}.${chunks.length}`] };
|
|
102
|
+
}
|
|
103
|
+
function encodeSessionCookiesDecoded(environmentRef, session) {
|
|
104
|
+
const { set, clear } = encodeSessionCookies(environmentRef, session);
|
|
105
|
+
return {
|
|
106
|
+
set: set.map(({ name, value }) => ({ name, value: decodeURIComponent(value) })),
|
|
107
|
+
clear
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function decodeSessionCookies(get, environmentRef) {
|
|
111
|
+
const base = sessionCookieName(environmentRef);
|
|
112
|
+
let encoded = get(base);
|
|
113
|
+
if (encoded === void 0) {
|
|
114
|
+
let joined = "";
|
|
115
|
+
for (let i = 0; ; i++) {
|
|
116
|
+
const part = get(`${base}.${i}`);
|
|
117
|
+
if (part === void 0) break;
|
|
118
|
+
joined += part;
|
|
119
|
+
}
|
|
120
|
+
if (joined === "") return null;
|
|
121
|
+
encoded = joined;
|
|
122
|
+
}
|
|
123
|
+
const raw = parseStoredSession(encoded);
|
|
124
|
+
if (raw !== null) return raw;
|
|
125
|
+
try {
|
|
126
|
+
return parseStoredSession(decodeURIComponent(encoded));
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function parseStoredSession(json) {
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(json);
|
|
134
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
135
|
+
const obj = parsed;
|
|
136
|
+
if (typeof obj.a === "string" && typeof obj.r === "string" && typeof obj.e === "number") {
|
|
137
|
+
return { accessToken: obj.a, refreshToken: obj.r, expiresAt: obj.e };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function clearedSessionCookieNames(environmentRef, present) {
|
|
146
|
+
const base = sessionCookieName(environmentRef);
|
|
147
|
+
const names = [];
|
|
148
|
+
if (present(base)) names.push(base);
|
|
149
|
+
for (let i = 0; ; i++) {
|
|
150
|
+
const chunk = `${base}.${i}`;
|
|
151
|
+
if (!present(chunk)) break;
|
|
152
|
+
names.push(chunk);
|
|
153
|
+
}
|
|
154
|
+
return names;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/next/shared.ts
|
|
158
|
+
var nextServerModule;
|
|
159
|
+
async function importNextServer(caller) {
|
|
160
|
+
nextServerModule ??= import("next/server");
|
|
161
|
+
try {
|
|
162
|
+
return await nextServerModule;
|
|
163
|
+
} catch (cause) {
|
|
164
|
+
nextServerModule = void 0;
|
|
165
|
+
const detail = cause instanceof Error && cause.message ? ` (${cause.message})` : "";
|
|
166
|
+
throw new BackendError("validation", {
|
|
167
|
+
code: "next_required",
|
|
168
|
+
message: `${caller}() requires Next.js \u2014 'next/server' could not be resolved${detail}.`
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/next/middleware.ts
|
|
174
|
+
var DEFAULT_REFRESH_MARGIN_MS = 6e4;
|
|
175
|
+
var inflightRefreshes = /* @__PURE__ */ new Map();
|
|
176
|
+
function refreshSingleFlight(config, refreshToken) {
|
|
177
|
+
const existing = inflightRefreshes.get(refreshToken);
|
|
178
|
+
if (existing) return existing;
|
|
179
|
+
const pending = refresh(config, refreshToken).finally(() => {
|
|
180
|
+
inflightRefreshes.delete(refreshToken);
|
|
181
|
+
});
|
|
182
|
+
inflightRefreshes.set(refreshToken, pending);
|
|
183
|
+
return pending;
|
|
184
|
+
}
|
|
185
|
+
async function refresh(config, refreshToken) {
|
|
186
|
+
try {
|
|
187
|
+
const res = await fetch(`${config.url}/auth/token/refresh`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { apikey: config.apiKey, "content-type": "application/json" },
|
|
190
|
+
body: JSON.stringify({ refresh_token: refreshToken })
|
|
191
|
+
});
|
|
192
|
+
if (res.status === 400 || res.status === 401 || res.status === 403) {
|
|
193
|
+
return { kind: "terminal" };
|
|
194
|
+
}
|
|
195
|
+
if (!res.ok) return { kind: "transient" };
|
|
196
|
+
const raw = await res.json();
|
|
197
|
+
if (typeof raw === "object" && raw !== null) {
|
|
198
|
+
const obj = raw;
|
|
199
|
+
if (typeof obj.access_token === "string" && typeof obj.refresh_token === "string" && typeof obj.expires_in === "number") {
|
|
200
|
+
return {
|
|
201
|
+
kind: "rotated",
|
|
202
|
+
session: {
|
|
203
|
+
accessToken: obj.access_token,
|
|
204
|
+
refreshToken: obj.refresh_token,
|
|
205
|
+
expiresAt: Date.now() + obj.expires_in * 1e3
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { kind: "transient" };
|
|
211
|
+
} catch {
|
|
212
|
+
return { kind: "transient" };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function palbeMiddleware(request, config, opts) {
|
|
216
|
+
const ref = environmentRefFromPublishableApiKey(config.apiKey);
|
|
217
|
+
if (ref === "") {
|
|
218
|
+
throw new BackendError("validation", {
|
|
219
|
+
code: "validation_error",
|
|
220
|
+
message: "palbeMiddleware() needs the publishable API key from palbe.gen.ts (pb_<environmentRef>_c\u2026). Re-run `palbase web link` and copy the generated values."
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const { NextResponse } = await importNextServer("palbeMiddleware");
|
|
224
|
+
const passThrough = () => opts?.response ?? NextResponse.next({ request });
|
|
225
|
+
const session = decodeSessionCookies((name) => request.cookies.get(name)?.value, ref);
|
|
226
|
+
if (!session) return passThrough();
|
|
227
|
+
if (session.expiresAt - Date.now() > (opts?.refreshMarginMs ?? DEFAULT_REFRESH_MARGIN_MS)) {
|
|
228
|
+
return passThrough();
|
|
229
|
+
}
|
|
230
|
+
const outcome = await refreshSingleFlight(config, session.refreshToken);
|
|
231
|
+
if (outcome.kind === "transient") return passThrough();
|
|
232
|
+
const staleNames = clearedSessionCookieNames(ref, (name) => request.cookies.has(name));
|
|
233
|
+
if (outcome.kind === "terminal") {
|
|
234
|
+
for (const name of staleNames) request.cookies.delete(name);
|
|
235
|
+
const response2 = opts?.response ?? NextResponse.next({ request });
|
|
236
|
+
for (const name of staleNames) {
|
|
237
|
+
response2.cookies.set(name, "", { ...SESSION_COOKIE_ATTRS, maxAge: 0 });
|
|
238
|
+
}
|
|
239
|
+
response2.headers.set("cache-control", "private, no-store");
|
|
240
|
+
return response2;
|
|
241
|
+
}
|
|
242
|
+
const write = encodeSessionCookiesDecoded(ref, outcome.session);
|
|
243
|
+
for (const name of staleNames) request.cookies.delete(name);
|
|
244
|
+
for (const { name, value } of write.set) request.cookies.set(name, value);
|
|
245
|
+
for (const name of write.clear) request.cookies.delete(name);
|
|
246
|
+
const response = opts?.response ?? NextResponse.next({ request });
|
|
247
|
+
const written = new Set(write.set.map((c) => c.name));
|
|
248
|
+
for (const { name, value } of write.set) {
|
|
249
|
+
response.cookies.set(name, value, SESSION_COOKIE_ATTRS);
|
|
250
|
+
}
|
|
251
|
+
for (const name of /* @__PURE__ */ new Set([...write.clear, ...staleNames])) {
|
|
252
|
+
if (!written.has(name)) {
|
|
253
|
+
response.cookies.set(name, "", { ...SESSION_COOKIE_ATTRS, maxAge: 0 });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
response.headers.set("cache-control", "private, no-store");
|
|
257
|
+
return response;
|
|
258
|
+
}
|
|
259
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
260
|
+
0 && (module.exports = {
|
|
261
|
+
palbeMiddleware
|
|
262
|
+
});
|
|
263
|
+
//# sourceMappingURL=middleware.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/next/middleware.ts","../../src/api-key.ts","../../src/errors.ts","../../src/next/cookie-codec.ts","../../src/next/shared.ts"],"sourcesContent":["/**\n * '@palbase/web/next/middleware' — proactive cookie-session refresh in\n * Next.js middleware.\n *\n * REQUIRED — not optional — for session-bearing RSC apps. Server Components\n * cannot write cookies, so pbServer's reactive refresh cannot PERSIST a\n * rotated session there: every RSC render would re-refresh from the SAME\n * cookie refresh token, and once two of those land more than 30s apart\n * (outside palauth's rotation grace) the reuse detector REVOKES THE WHOLE\n * TOKEN FAMILY — the user is force-logged-out everywhere. This middleware\n * refreshes the cookie BEFORE the RSC tree renders, so pbServer hydrates a\n * still-valid session and never burns the token itself.\n *\n * EDGE-BUNDLE RULE — why this is a SEPARATE entry taking an EXPLICIT config\n * instead of reading the process-global one like pbServer does:\n *\n * Next compiles middleware into its OWN Edge bundle, so the generated\n * palbe.gen.ts imported by app/layout.tsx does not reach it. Importing\n * palbe.gen.ts from middleware.ts to fix that would drag `__configure` →\n * buildRuntime → messaging/wasm/loader.ts into the Edge graph. Measured on\n * Next 16.2.9 with a real app: the webpack builder FAILS the build outright\n * (\"Dynamic Code Evaluation ... not allowed in Edge Runtime\", caused by\n * importing '@palbase/web/dist/internal.js' — a compilation error, not a\n * warning), and Turbopack, which does not enforce that, instead ships a\n * 2.4 MB Edge bundle where this entry ships 168 KB.\n *\n * So the middleware never touches the runtime: it takes the two published\n * identity values it actually needs and speaks the one auth wire directly.\n * Nothing on this module's import graph may reach ../runtime.js —\n * __tests__/next-middleware-bundle.test.ts locks that against the real\n * build output.\n *\n * 'next/server' is imported only lazily (the type-only NextRequest/\n * NextResponse imports erase at build time).\n *\n * ```ts\n * // middleware.ts\n * import { palbeMiddleware } from '@palbase/web/next/middleware';\n * import type { NextRequest } from 'next/server';\n *\n * export function middleware(request: NextRequest) {\n * return palbeMiddleware(request, {\n * url: 'https://abc12345m.palbase.studio',\n * apiKey: 'pb_abc12345m_c...',\n * });\n * }\n *\n * // `config` MUST be declared here, in this file: Next reads it off THIS\n * // file's AST (export const + literal initializer). A re-exported or\n * // imported config is invisible and silently degrades to a catch-all.\n * export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };\n * ```\n */\nimport type { NextRequest, NextResponse } from 'next/server';\nimport { environmentRefFromPublishableApiKey } from '../api-key.js';\nimport { BackendError } from '../errors.js';\nimport {\n clearedSessionCookieNames,\n decodeSessionCookies,\n encodeSessionCookiesDecoded,\n SESSION_COOKIE_ATTRS,\n type StoredSession,\n} from './cookie-codec.js';\nimport { importNextServer } from './shared.js';\n\nconst DEFAULT_REFRESH_MARGIN_MS = 60_000;\n\n/**\n * The published identity palbeMiddleware needs — the same two values\n * `palbase web link` bakes into palbe.gen.ts. Both are publishable (the\n * anon key already ships in every browser bundle); a service_role key is\n * REJECTED, it has no business in a request-path bundle.\n *\n * A `PalbeConfig` is structurally assignable, so apps that already hold one\n * can pass it straight through.\n */\nexport interface PalbeMiddlewareConfig {\n /** Environment URL, e.g. `https://abc12345m.palbase.studio`. */\n url: string;\n /** Publishable (anon) API key: `pb_<environmentRef>_c<random>`. */\n apiKey: string;\n}\n\nexport interface PalbeMiddlewareOptions {\n /** Refresh when the access token expires within this window. Default 60s. */\n refreshMarginMs?: number;\n /**\n * Pre-built response to decorate (e.g. from chained middleware) instead of\n * building `NextResponse.next({ request })`. Pass-through cases return it\n * UNMODIFIED. Caveat: a pre-built response snapshotted the request headers\n * at ITS creation, so a rotated cookie reaches the browser (Set-Cookie)\n * but not the downstream RSC render of THIS request — prefer omitting this\n * and building your response after palbeMiddleware when possible.\n */\n response?: NextResponse;\n}\n\ntype RefreshOutcome =\n | { kind: 'rotated'; session: StoredSession }\n | { kind: 'terminal' } // 400/401/403 — refresh token dead, sign out\n | { kind: 'transient' }; // network / 5xx / malformed — keep the session\n\n/**\n * Per-process single-flight: concurrent middleware invocations holding the\n * SAME refresh token (parallel tabs / prefetches) share one refresh call —\n * a per-token thundering herd would race palauth rotation. Multi-pod\n * concurrency is covered by palauth's 30s rotation grace (concurrent\n * refreshes of one token all succeed); per-process dedup is all that's\n * needed here (Studio precedent — don't over-engineer).\n */\nconst inflightRefreshes = new Map<string, Promise<RefreshOutcome>>();\n\nfunction refreshSingleFlight(\n config: PalbeMiddlewareConfig,\n refreshToken: string,\n): Promise<RefreshOutcome> {\n const existing = inflightRefreshes.get(refreshToken);\n if (existing) return existing;\n const pending = refresh(config, refreshToken).finally(() => {\n inflightRefreshes.delete(refreshToken);\n });\n inflightRefreshes.set(refreshToken, pending);\n return pending;\n}\n\n/** Direct palauth refresh call (no runtime: middleware needs only this one wire). */\nasync function refresh(\n config: PalbeMiddlewareConfig,\n refreshToken: string,\n): Promise<RefreshOutcome> {\n try {\n const res = await fetch(`${config.url}/auth/token/refresh`, {\n method: 'POST',\n headers: { apikey: config.apiKey, 'content-type': 'application/json' },\n body: JSON.stringify({ refresh_token: refreshToken }),\n });\n if (res.status === 400 || res.status === 401 || res.status === 403) {\n return { kind: 'terminal' };\n }\n if (!res.ok) return { kind: 'transient' };\n const raw: unknown = await res.json();\n if (typeof raw === 'object' && raw !== null) {\n const obj = raw as Record<string, unknown>;\n if (\n typeof obj.access_token === 'string' &&\n typeof obj.refresh_token === 'string' &&\n typeof obj.expires_in === 'number'\n ) {\n return {\n kind: 'rotated',\n session: {\n accessToken: obj.access_token,\n refreshToken: obj.refresh_token,\n expiresAt: Date.now() + obj.expires_in * 1000,\n },\n };\n }\n }\n return { kind: 'transient' }; // malformed 200 — never destroy a session over it\n } catch {\n return { kind: 'transient' }; // network failure\n }\n}\n\n/**\n * Keep the session cookie fresh for the request. No session / still-fresh\n * session → untouched pass-through (no cache-control changes). Stale or\n * expired → single-flight refresh, then the rotated (or cleared, on a dead\n * token) cookies are written onto BOTH the request (for the downstream RSC\n * render via `NextResponse.next({ request })`) and the response (for the\n * browser), with `Cache-Control: private, no-store` on touched responses.\n *\n * Cookie writes go through Next's cookie APIs with the DECODED codec values:\n * Next percent-encodes on serialization, so the wire bytes come out exactly\n * as the shared codec's (see encodeSessionCookiesDecoded). NextRequest's\n * cookie store is NOT a CookieStoreLike (its set is the 2-arg/RequestCookie\n * form), hence the direct codec calls here instead of the pbServer adapter.\n */\nexport async function palbeMiddleware(\n request: NextRequest,\n config: PalbeMiddlewareConfig,\n opts?: PalbeMiddlewareOptions,\n): Promise<NextResponse> {\n // Fail LOUDLY on a bad key. A silently empty ref would name the cookie\n // `palbe-session-` — a name the browser SDK never writes — so every\n // request would look session-less and the middleware would degrade to a\n // no-op, which is exactly the token-family revocation this exists to stop.\n const ref = environmentRefFromPublishableApiKey(config.apiKey);\n if (ref === '') {\n throw new BackendError('validation', {\n code: 'validation_error',\n message:\n 'palbeMiddleware() needs the publishable API key from palbe.gen.ts (pb_<environmentRef>_c…). Re-run `palbase web link` and copy the generated values.',\n });\n }\n const { NextResponse } = await importNextServer('palbeMiddleware');\n const passThrough = (): NextResponse => opts?.response ?? NextResponse.next({ request });\n\n const session = decodeSessionCookies((name) => request.cookies.get(name)?.value, ref);\n if (!session) return passThrough();\n if (session.expiresAt - Date.now() > (opts?.refreshMarginMs ?? DEFAULT_REFRESH_MARGIN_MS)) {\n return passThrough();\n }\n\n const outcome = await refreshSingleFlight(config, session.refreshToken);\n if (outcome.kind === 'transient') return passThrough(); // keep the session over blips\n\n // Session-cookie names in the incoming jar — the browser's state mirror.\n const staleNames = clearedSessionCookieNames(ref, (name) => request.cookies.has(name));\n\n if (outcome.kind === 'terminal') {\n for (const name of staleNames) request.cookies.delete(name);\n const response = opts?.response ?? NextResponse.next({ request });\n for (const name of staleNames) {\n response.cookies.set(name, '', { ...SESSION_COOKIE_ATTRS, maxAge: 0 });\n }\n response.headers.set('cache-control', 'private, no-store');\n return response;\n }\n\n const write = encodeSessionCookiesDecoded(ref, outcome.session);\n for (const name of staleNames) request.cookies.delete(name);\n for (const { name, value } of write.set) request.cookies.set(name, value);\n for (const name of write.clear) request.cookies.delete(name);\n // Build AFTER the request-cookie mutation: NextResponse.next({ request })\n // snapshots the request headers for the downstream RSC render.\n const response = opts?.response ?? NextResponse.next({ request });\n const written = new Set(write.set.map((c) => c.name));\n for (const { name, value } of write.set) {\n response.cookies.set(name, value, SESSION_COOKIE_ATTRS);\n }\n // Browser-side cleanup: stale names not part of the new run (a shrunken /\n // grown chunk layout) + the codec's one-past-the-end overflow guard.\n for (const name of new Set([...write.clear, ...staleNames])) {\n if (!written.has(name)) {\n response.cookies.set(name, '', { ...SESSION_COOKIE_ATTRS, maxAge: 0 });\n }\n }\n response.headers.set('cache-control', 'private, no-store');\n return response;\n}\n","/**\n * `pb_<environmentRef>_<scope><random>` → environmentRef ('' if malformed).\n * Utility for callers that only have a raw key. App-bound runtime storage and\n * palbe/next cookie naming use the explicit `PalbeConfig.environmentRef` emitted\n * by `palbe-gen`.\n *\n * The key is shape-validated (prefix `pb`, a 4-24 character lowercase ASCII\n * alphanumeric environmentRef, and a `<scope><20-base62-random>` secret segment\n * where scope ∈ {c,s}) before a ref is returned. A structurally-invalid key returns\n * '' so session storage stays\n * UNSCOPED rather than getting bound to a partially-parsed ref — a loose parse\n * (e.g. `pb_victim_cXX`) must not scope persisted sessions to `victim` and\n * hydrate a session belonging to a different, real key. The backend remains the\n * authority that rejects a bad key on the first request; this keeps the client's\n * local session-storage scoping honest. (Audit finding apikey-ref-mismatch.)\n */\nconst API_KEY_RE = /^pb_([a-z0-9]{4,24})_[cs][A-Za-z0-9]{20}$/;\nconst PUBLISHABLE_API_KEY_RE = /^pb_([a-z0-9]{4,24})_c[A-Za-z0-9]{20}$/;\n\nexport function environmentRefFromApiKey(apiKey: string): string {\n const m = API_KEY_RE.exec(apiKey);\n return m ? (m[1] ?? '') : '';\n}\n\n/** Internal app-runtime parser: browser config accepts publishable keys only. */\nexport function environmentRefFromPublishableApiKey(apiKey: string): string {\n const match = PUBLISHABLE_API_KEY_RE.exec(apiKey);\n return match ? (match[1] ?? '') : '';\n}\n","import { PalbaseError, type PalbaseResponse } from '@palbase/core';\n\nexport interface FieldError {\n field: string;\n message: string;\n}\n\nexport type BackendErrorKind =\n | 'notConfigured'\n | 'validation'\n | 'unauthorized'\n | 'rateLimited'\n | 'server'\n | 'network'\n | 'decode';\n\ninterface BackendErrorParams {\n code: string;\n message: string;\n status?: number;\n requestId?: string;\n fields?: FieldError[];\n retryAfter?: number;\n data?: unknown;\n}\n\nexport class BackendError extends Error {\n readonly kind: BackendErrorKind;\n readonly code: string;\n readonly status: number;\n readonly requestId?: string;\n readonly fields?: FieldError[];\n readonly retryAfter?: number;\n readonly data?: unknown;\n\n constructor(kind: BackendErrorKind, params: BackendErrorParams) {\n super(params.message);\n this.name = 'BackendError';\n this.kind = kind;\n this.code = params.code;\n this.status = params.status ?? 0;\n this.requestId = params.requestId;\n this.fields = params.fields;\n this.retryAfter = params.retryAfter;\n this.data = params.data;\n }\n\n static notConfigured(): BackendError {\n return new BackendError('notConfigured', {\n code: 'not_configured',\n message:\n \"Palbe is not configured. Run 'palbase web link' in your project and make sure palbe.gen.ts is imported once at app startup.\",\n });\n }\n}\n\nfunction isFieldErrorArray(value: unknown): value is FieldError[] {\n return (\n Array.isArray(value) &&\n value.length > 0 &&\n value.every(\n (v) =>\n typeof v === 'object' &&\n v !== null &&\n typeof (v as Record<string, unknown>).field === 'string' &&\n typeof (v as Record<string, unknown>).message === 'string',\n )\n );\n}\n\nfunction pickField(value: unknown, key: string): unknown {\n if (typeof value === 'object' && value !== null) {\n return (value as Record<string, unknown>)[key];\n }\n return undefined;\n}\n\nfunction pickNumber(value: unknown, key: string): number | undefined {\n const n = pickField(value, key);\n return typeof n === 'number' ? n : undefined;\n}\n\nfunction pickString(value: unknown, key: string): string | undefined {\n const s = pickField(value, key);\n return typeof s === 'string' ? s : undefined;\n}\n\nexport function fromPalbaseError(err: PalbaseError): BackendError {\n // HttpClient stores the WHOLE wire envelope as err.details:\n // { error, error_description, status, request_id, retry_after?, details?, data? }\n const base: BackendErrorParams = {\n code: err.code,\n message: err.message,\n status: err.status,\n requestId: pickString(err.details, 'request_id'),\n data: pickField(err.details, 'data'),\n };\n if (err.code === 'network_error') return new BackendError('network', base);\n if (err.status === 401) return new BackendError('unauthorized', base);\n if (err.status === 429)\n return new BackendError('rateLimited', {\n ...base,\n retryAfter: pickNumber(err.details, 'retry_after'),\n });\n // Field-error array lives at the envelope's nested `details` key.\n const nested = pickField(err.details, 'details');\n if (err.status === 400 && isFieldErrorArray(nested))\n return new BackendError('validation', { ...base, fields: nested });\n // Fallthrough also covers status-0 non-network errors (e.g. auth client's\n // synthetic 'no_refresh_token' with status 0) — those intentionally map to 'server'.\n return new BackendError('server', base);\n}\n\n/** Decode a raw wire body (used by paths that bypass HttpClient, e.g. upload). */\nexport function fromEnvelope(status: number, body: unknown): BackendError {\n const code = pickString(body, 'error') ?? 'http_error';\n const message = pickString(body, 'error_description') ?? `HTTP ${status}`;\n const requestId = pickString(body, 'request_id');\n const details = pickField(body, 'details');\n const params: BackendErrorParams = {\n code,\n message,\n status,\n requestId,\n data: pickField(body, 'data'),\n };\n if (status === 401) return new BackendError('unauthorized', params);\n if (status === 429)\n return new BackendError('rateLimited', {\n ...params,\n // Real 429 wire body has TOP-LEVEL retry_after; nested details is a fallback.\n retryAfter: pickNumber(body, 'retry_after') ?? pickNumber(details, 'retry_after'),\n });\n if (status === 400 && isFieldErrorArray(details))\n return new BackendError('validation', { ...params, fields: details });\n return new BackendError('server', params);\n}\n\n/**\n * Type guard for BackendError that survives module-identity splits.\n * This package ships dual ESM + CJS builds; if both end up loaded (or the\n * package is installed twice), two distinct BackendError classes coexist and\n * `instanceof` fails for errors thrown by \"the other\" copy. Falls back to a\n * structural check on `name` + `kind`.\n */\nexport function isBackendError(e: unknown): e is BackendError {\n return (\n e instanceof BackendError ||\n (typeof e === 'object' &&\n e !== null &&\n (e as Record<string, unknown>).name === 'BackendError' &&\n typeof (e as Record<string, unknown>).kind === 'string')\n );\n}\n\n/**\n * Structural fallback after instanceof for PalbaseError — same dual ESM+CJS\n * identity-split rationale as isBackendError above: two loaded PalbaseError\n * classes break `instanceof` across copies; the name check bridges that.\n * Returns the error as PalbaseError, or null when it isn't one.\n */\nexport function asPalbaseError(e: unknown): PalbaseError | null {\n if (e instanceof PalbaseError) return e;\n if (e instanceof Error && e.name === 'PalbaseError') return e as PalbaseError;\n return null;\n}\n\n/** Convert an internal {data,error} envelope into data-or-throw. */\nexport function unwrap<T>(res: PalbaseResponse<T>): T {\n if (res.error) throw fromPalbaseError(res.error);\n return res.data as T;\n}\n","/**\n * Session-cookie codec shared by EVERY palbe/next surface (browser storage\n * adapter, pbServer cookie adapter, middleware, auth callback). Pure\n * functions — no 'next' imports, no palbe runtime imports — so both sides\n * provably read and write the SAME bytes.\n *\n * Format (P3 design contract):\n * - name: `palbe-session-<environmentRef>`; overflow chunk names append `.0`,\n * `.1`, … (cookie names otherwise avoid dots — tooling friendliness).\n * - value: `encodeURIComponent(JSON.stringify({ a, r, e }))` with a=access\n * token, r=refresh token, e=expiry (ms epoch).\n * - one cookie when the encoded value fits MAX_COOKIE_VALUE chars, else\n * chunks ONLY (the base name must not coexist with chunks — callers clear\n * stale names via clearedSessionCookieNames before writing, then honor the\n * encode result's `clear` overflow guard after writing).\n */\n\nexport { environmentRefFromApiKey } from '../api-key.js';\n\nexport interface StoredSession {\n accessToken: string;\n refreshToken: string;\n /** ms epoch */\n expiresAt: number;\n}\n\n/** Browsers cap a cookie at ~4096 bytes incl. name + attributes; 3500 leaves headroom. */\nconst MAX_COOKIE_VALUE = 3500;\n\n/**\n * The P3 cookie contract attributes, shared by every server-side writer\n * (pbServer adapter, middleware, callback); the browser writer mirrors them\n * in document.cookie string form (next/client.ts). NOT HttpOnly by design —\n * the browser SDK must read/write the session (Supabase-paradigm tradeoff);\n * Max-Age = 30d = the refresh-token TTL.\n */\nexport const SESSION_COOKIE_ATTRS = {\n path: '/',\n sameSite: 'lax',\n secure: true,\n maxAge: 2_592_000,\n} as const;\n\nexport function sessionCookieName(environmentRef: string): string {\n return `palbe-session-${environmentRef}`;\n}\n\nexport interface SessionCookieWrite {\n /** Cookies to SET, in order. */\n set: Array<{ name: string; value: string }>;\n /**\n * Names to DELETE alongside the set: the one-past-the-end OVERFLOW GUARD\n * (base write → `.0`; chunks `.0..n` → `.(n+1)`). Decode stops at the\n * first missing chunk, so deleting exactly one-past-the-end guarantees a\n * stale orphan chunk (left behind a gap, where presence-probing cleanup\n * cannot see it) can never concatenate into the freshly written run.\n * Writers MUST honor both lists.\n */\n clear: string[];\n}\n\nexport function encodeSessionCookies(\n environmentRef: string,\n session: StoredSession,\n): SessionCookieWrite {\n const name = sessionCookieName(environmentRef);\n const value = encodeURIComponent(\n JSON.stringify({ a: session.accessToken, r: session.refreshToken, e: session.expiresAt }),\n );\n if (value.length <= MAX_COOKIE_VALUE) {\n return { set: [{ name, value }], clear: [`${name}.0`] };\n }\n const chunks: Array<{ name: string; value: string }> = [];\n // Chunk WITHOUT splitting a %XX escape (every '%' in the encoded value\n // starts one — encodeURIComponent never emits a bare '%'). Next's cookie\n // layer percent-DECODES values on read and silently DROPS a cookie whose\n // value is an invalid escape sequence, so a split escape would lose that\n // chunk server-side; independent decodability also lets\n // encodeSessionCookiesDecoded mirror this run byte-exactly.\n for (let start = 0; start < value.length; ) {\n let end = Math.min(start + MAX_COOKIE_VALUE, value.length);\n if (end < value.length) {\n if (value[end - 1] === '%') end -= 1;\n else if (value[end - 2] === '%') end -= 2;\n }\n chunks.push({ name: `${name}.${chunks.length}`, value: value.slice(start, end) });\n start = end;\n }\n return { set: chunks, clear: [`${name}.${chunks.length}`] };\n}\n\n/**\n * encodeSessionCookies for percent-ENCODING cookie stores. Next's request\n * and response cookie APIs apply encodeURIComponent to values when\n * serializing (and decodeURIComponent on read) — handing them the codec's\n * already-encoded values would DOUBLE-encode the wire bytes and break the\n * browser-side reader. This variant returns the same cookie run with\n * DECODED values: `encodeURIComponent(value)` reproduces the\n * encodeSessionCookies bytes exactly (chunk boundaries never split an\n * escape), so an encoding store writes byte-identical cookies to a raw\n * writer like document.cookie.\n */\nexport function encodeSessionCookiesDecoded(\n environmentRef: string,\n session: StoredSession,\n): SessionCookieWrite {\n const { set, clear } = encodeSessionCookies(environmentRef, session);\n return {\n set: set.map(({ name, value }) => ({ name, value: decodeURIComponent(value) })),\n clear,\n };\n}\n\n/**\n * Read the session back from a cookie getter: the base name wins; otherwise\n * `.0`, `.1`, … are concatenated until the first missing chunk. Tolerant:\n * malformed URI escapes / JSON / shape → null (treated as \"no session\").\n *\n * Accepts BOTH value forms. Next's cookie stores percent-DECODE values once\n * on read, so server-side callers (pbServer adapter, middleware) hand us the\n * already-decoded JSON; raw readers (document.cookie) hand us the encoded\n * form. The raw (already-decoded) parse runs FIRST because it is\n * unambiguous — an encoded value can never raw-parse as JSON\n * (encodeURIComponent escapes `{` itself, so it starts with `%7B`) — whereas\n * decode-first would silently corrupt a literal `%XX` run inside a token\n * (decodeURIComponent('%41') → 'A', no throw) or throw on an invalid escape.\n */\nexport function decodeSessionCookies(\n get: (name: string) => string | undefined,\n environmentRef: string,\n): StoredSession | null {\n const base = sessionCookieName(environmentRef);\n let encoded = get(base);\n if (encoded === undefined) {\n let joined = '';\n for (let i = 0; ; i++) {\n const part = get(`${base}.${i}`);\n if (part === undefined) break;\n joined += part;\n }\n if (joined === '') return null;\n encoded = joined;\n }\n const raw = parseStoredSession(encoded);\n if (raw !== null) return raw;\n try {\n return parseStoredSession(decodeURIComponent(encoded));\n } catch {\n return null;\n }\n}\n\n/** JSON-parse + shape-check one candidate value; malformed → null. */\nfunction parseStoredSession(json: string): StoredSession | null {\n try {\n const parsed: unknown = JSON.parse(json);\n if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {\n const obj = parsed as Record<string, unknown>;\n if (typeof obj.a === 'string' && typeof obj.r === 'string' && typeof obj.e === 'number') {\n return { accessToken: obj.a, refreshToken: obj.r, expiresAt: obj.e };\n }\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Names a writer must delete before/while writing: the base name (when\n * present) plus every existing `.N` chunk, probed in order until the first\n * missing one (writes are always a contiguous `.0..n` run, so a gap means\n * the end of palbe-owned chunks).\n */\nexport function clearedSessionCookieNames(\n environmentRef: string,\n present: (name: string) => boolean,\n): string[] {\n const base = sessionCookieName(environmentRef);\n const names: string[] = [];\n if (present(base)) names.push(base);\n for (let i = 0; ; i++) {\n const chunk = `${base}.${i}`;\n if (!present(chunk)) break;\n names.push(chunk);\n }\n return names;\n}\n\n/**\n * Parse the canonical chunk index out of `palbe-session-<ref>.N`; non-chunk\n * names (the bare base, a foreign ref, junk suffixes like `.x` / `.01` /\n * `.-1`) → null. The index is a non-negative integer with NO leading zeros\n * (the encoder emits `.0`, `.1`, … verbatim), so `String(n) === suffix`\n * round-trips it exactly.\n */\nfunction chunkIndex(base: string, name: string): number | null {\n if (!name.startsWith(`${base}.`)) return null;\n const suffix = name.slice(base.length + 1);\n if (!/^\\d+$/.test(suffix)) return null;\n const idx = Number.parseInt(suffix, 10);\n return String(idx) === suffix ? idx : null;\n}\n\n/**\n * getAll-powered orphan sweep — the deletion list a writer with the FULL\n * cookie jar should clear, catching crash-orphans that presence-probing\n * (clearedSessionCookieNames, which stops at the first gap) CANNOT see.\n *\n * Given every cookie name in the jar, returns the `palbe-session-<ref>`-family\n * names that are NOT part of a single valid session:\n * - A valid session is EITHER the bare base alone, OR the contiguous chunk\n * run `.0, .1, …, n` (starting at `.0`, no gaps).\n * - Any `.N` beyond the first gap from `.0` is an orphan (incl. ALL chunks\n * when `.0` is absent — no run exists).\n * - The base coexisting with ANY chunk is inconsistent (the contract forbids\n * it; decode lets the base win and silently strands the chunks): the base\n * is kept, the chunks are swept (a fresh write replaces the base in place).\n *\n * Only canonical `.N` names count; foreign refs and malformed suffixes are\n * ignored. Output order: base (if inconsistent) first, then chunks ascending.\n */\nexport function orphanSessionCookieNames(environmentRef: string, allNames: string[]): string[] {\n const base = sessionCookieName(environmentRef);\n const hasBase = allNames.includes(base);\n const indices = new Set<number>();\n for (const name of allNames) {\n const idx = chunkIndex(base, name);\n if (idx !== null) indices.add(idx);\n }\n if (indices.size === 0) return []; // unchunked (base alone) or no palbe cookies\n\n // Length of the contiguous run from .0 (0 when .0 is missing).\n let runLen = 0;\n while (indices.has(runLen)) runLen++;\n\n const orphans: string[] = [];\n // Base coexisting with chunks: base wins on decode → chunks are stale.\n // Sweep the base too only when there are NO chunks behind the run to keep\n // (the contract forbids the mix; clearing chunks alone resolves it, but if\n // the run itself is invalid—no .0—the base is the real session and stays).\n // Decision: when base coexists with ANY chunk, the chunks are orphans; the\n // base is the live session and is kept — UNLESS the chunk set forms a valid\n // run AND there is also a gap-orphan, in which case both base+gap-orphan are\n // inconsistent leftovers. Concretely: keep it simple and SAFE — the base is\n // only swept when it sits alongside a partial/gapped chunk set (a crash mid\n // base→chunk migration), never when chunks form a clean run the base shadows.\n const everyChunkContiguous = indices.size === runLen && runLen > 0;\n if (hasBase && !everyChunkContiguous) orphans.push(base);\n\n for (const idx of [...indices].sort((a, b) => a - b)) {\n if (idx >= runLen) orphans.push(`${base}.${idx}`);\n }\n // Base + clean contiguous run: base wins on decode, chunks are stranded.\n if (hasBase && everyChunkContiguous) {\n for (let i = 0; i < runLen; i++) orphans.push(`${base}.${i}`);\n }\n return orphans;\n}\n","/**\n * The lazy 'next/server' import, shared by every palbe/next surface that\n * builds a NextResponse (palbeMiddleware, handleAuthCallback).\n *\n * MODULE-GRAPH RULE: this module must stay reachable from the middleware\n * entry, which Next compiles into its own Edge bundle — so it may import\n * NOTHING that leads to ../runtime.js. Keep it at errors.js and below.\n * (Global-config lookup, which does reach the runtime, lives in\n * ./global-config.ts.)\n */\nimport { BackendError } from '../errors.js';\n\nlet nextServerModule: Promise<typeof import('next/server')> | undefined;\n\n/**\n * Lazy 'next/server' import (MODULE-GRAPH RULE: never top-level — importing\n * palbe/next must work without next installed) with a guided failure.\n * The module promise is memoized: bundler dynamic-import machinery can take\n * macrotask-scale hops PER CALL, so without the cache two near-simultaneous\n * middleware invocations could straddle a refresh — every call after the\n * first now resolves in pure microtasks.\n */\nexport async function importNextServer(caller: string): Promise<typeof import('next/server')> {\n nextServerModule ??= import('next/server');\n try {\n return await nextServerModule;\n } catch (cause) {\n nextServerModule = undefined; // never poison later calls with a cached rejection\n const detail = cause instanceof Error && cause.message ? ` (${cause.message})` : '';\n throw new BackendError('validation', {\n code: 'next_required',\n message: `${caller}() requires Next.js — 'next/server' could not be resolved${detail}.`,\n });\n }\n}\n"],"mappings":"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","names":["response"]}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { NextResponse, NextRequest } from 'next/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* '@palbase/web/next/middleware' — proactive cookie-session refresh in
|
|
5
|
+
* Next.js middleware.
|
|
6
|
+
*
|
|
7
|
+
* REQUIRED — not optional — for session-bearing RSC apps. Server Components
|
|
8
|
+
* cannot write cookies, so pbServer's reactive refresh cannot PERSIST a
|
|
9
|
+
* rotated session there: every RSC render would re-refresh from the SAME
|
|
10
|
+
* cookie refresh token, and once two of those land more than 30s apart
|
|
11
|
+
* (outside palauth's rotation grace) the reuse detector REVOKES THE WHOLE
|
|
12
|
+
* TOKEN FAMILY — the user is force-logged-out everywhere. This middleware
|
|
13
|
+
* refreshes the cookie BEFORE the RSC tree renders, so pbServer hydrates a
|
|
14
|
+
* still-valid session and never burns the token itself.
|
|
15
|
+
*
|
|
16
|
+
* EDGE-BUNDLE RULE — why this is a SEPARATE entry taking an EXPLICIT config
|
|
17
|
+
* instead of reading the process-global one like pbServer does:
|
|
18
|
+
*
|
|
19
|
+
* Next compiles middleware into its OWN Edge bundle, so the generated
|
|
20
|
+
* palbe.gen.ts imported by app/layout.tsx does not reach it. Importing
|
|
21
|
+
* palbe.gen.ts from middleware.ts to fix that would drag `__configure` →
|
|
22
|
+
* buildRuntime → messaging/wasm/loader.ts into the Edge graph. Measured on
|
|
23
|
+
* Next 16.2.9 with a real app: the webpack builder FAILS the build outright
|
|
24
|
+
* ("Dynamic Code Evaluation ... not allowed in Edge Runtime", caused by
|
|
25
|
+
* importing '@palbase/web/dist/internal.js' — a compilation error, not a
|
|
26
|
+
* warning), and Turbopack, which does not enforce that, instead ships a
|
|
27
|
+
* 2.4 MB Edge bundle where this entry ships 168 KB.
|
|
28
|
+
*
|
|
29
|
+
* So the middleware never touches the runtime: it takes the two published
|
|
30
|
+
* identity values it actually needs and speaks the one auth wire directly.
|
|
31
|
+
* Nothing on this module's import graph may reach ../runtime.js —
|
|
32
|
+
* __tests__/next-middleware-bundle.test.ts locks that against the real
|
|
33
|
+
* build output.
|
|
34
|
+
*
|
|
35
|
+
* 'next/server' is imported only lazily (the type-only NextRequest/
|
|
36
|
+
* NextResponse imports erase at build time).
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* // middleware.ts
|
|
40
|
+
* import { palbeMiddleware } from '@palbase/web/next/middleware';
|
|
41
|
+
* import type { NextRequest } from 'next/server';
|
|
42
|
+
*
|
|
43
|
+
* export function middleware(request: NextRequest) {
|
|
44
|
+
* return palbeMiddleware(request, {
|
|
45
|
+
* url: 'https://abc12345m.palbase.studio',
|
|
46
|
+
* apiKey: 'pb_abc12345m_c...',
|
|
47
|
+
* });
|
|
48
|
+
* }
|
|
49
|
+
*
|
|
50
|
+
* // `config` MUST be declared here, in this file: Next reads it off THIS
|
|
51
|
+
* // file's AST (export const + literal initializer). A re-exported or
|
|
52
|
+
* // imported config is invisible and silently degrades to a catch-all.
|
|
53
|
+
* export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The published identity palbeMiddleware needs — the same two values
|
|
59
|
+
* `palbase web link` bakes into palbe.gen.ts. Both are publishable (the
|
|
60
|
+
* anon key already ships in every browser bundle); a service_role key is
|
|
61
|
+
* REJECTED, it has no business in a request-path bundle.
|
|
62
|
+
*
|
|
63
|
+
* A `PalbeConfig` is structurally assignable, so apps that already hold one
|
|
64
|
+
* can pass it straight through.
|
|
65
|
+
*/
|
|
66
|
+
interface PalbeMiddlewareConfig {
|
|
67
|
+
/** Environment URL, e.g. `https://abc12345m.palbase.studio`. */
|
|
68
|
+
url: string;
|
|
69
|
+
/** Publishable (anon) API key: `pb_<environmentRef>_c<random>`. */
|
|
70
|
+
apiKey: string;
|
|
71
|
+
}
|
|
72
|
+
interface PalbeMiddlewareOptions {
|
|
73
|
+
/** Refresh when the access token expires within this window. Default 60s. */
|
|
74
|
+
refreshMarginMs?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Pre-built response to decorate (e.g. from chained middleware) instead of
|
|
77
|
+
* building `NextResponse.next({ request })`. Pass-through cases return it
|
|
78
|
+
* UNMODIFIED. Caveat: a pre-built response snapshotted the request headers
|
|
79
|
+
* at ITS creation, so a rotated cookie reaches the browser (Set-Cookie)
|
|
80
|
+
* but not the downstream RSC render of THIS request — prefer omitting this
|
|
81
|
+
* and building your response after palbeMiddleware when possible.
|
|
82
|
+
*/
|
|
83
|
+
response?: NextResponse;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Keep the session cookie fresh for the request. No session / still-fresh
|
|
87
|
+
* session → untouched pass-through (no cache-control changes). Stale or
|
|
88
|
+
* expired → single-flight refresh, then the rotated (or cleared, on a dead
|
|
89
|
+
* token) cookies are written onto BOTH the request (for the downstream RSC
|
|
90
|
+
* render via `NextResponse.next({ request })`) and the response (for the
|
|
91
|
+
* browser), with `Cache-Control: private, no-store` on touched responses.
|
|
92
|
+
*
|
|
93
|
+
* Cookie writes go through Next's cookie APIs with the DECODED codec values:
|
|
94
|
+
* Next percent-encodes on serialization, so the wire bytes come out exactly
|
|
95
|
+
* as the shared codec's (see encodeSessionCookiesDecoded). NextRequest's
|
|
96
|
+
* cookie store is NOT a CookieStoreLike (its set is the 2-arg/RequestCookie
|
|
97
|
+
* form), hence the direct codec calls here instead of the pbServer adapter.
|
|
98
|
+
*/
|
|
99
|
+
declare function palbeMiddleware(request: NextRequest, config: PalbeMiddlewareConfig, opts?: PalbeMiddlewareOptions): Promise<NextResponse>;
|
|
100
|
+
|
|
101
|
+
export { type PalbeMiddlewareConfig, type PalbeMiddlewareOptions, palbeMiddleware };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { NextResponse, NextRequest } from 'next/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* '@palbase/web/next/middleware' — proactive cookie-session refresh in
|
|
5
|
+
* Next.js middleware.
|
|
6
|
+
*
|
|
7
|
+
* REQUIRED — not optional — for session-bearing RSC apps. Server Components
|
|
8
|
+
* cannot write cookies, so pbServer's reactive refresh cannot PERSIST a
|
|
9
|
+
* rotated session there: every RSC render would re-refresh from the SAME
|
|
10
|
+
* cookie refresh token, and once two of those land more than 30s apart
|
|
11
|
+
* (outside palauth's rotation grace) the reuse detector REVOKES THE WHOLE
|
|
12
|
+
* TOKEN FAMILY — the user is force-logged-out everywhere. This middleware
|
|
13
|
+
* refreshes the cookie BEFORE the RSC tree renders, so pbServer hydrates a
|
|
14
|
+
* still-valid session and never burns the token itself.
|
|
15
|
+
*
|
|
16
|
+
* EDGE-BUNDLE RULE — why this is a SEPARATE entry taking an EXPLICIT config
|
|
17
|
+
* instead of reading the process-global one like pbServer does:
|
|
18
|
+
*
|
|
19
|
+
* Next compiles middleware into its OWN Edge bundle, so the generated
|
|
20
|
+
* palbe.gen.ts imported by app/layout.tsx does not reach it. Importing
|
|
21
|
+
* palbe.gen.ts from middleware.ts to fix that would drag `__configure` →
|
|
22
|
+
* buildRuntime → messaging/wasm/loader.ts into the Edge graph. Measured on
|
|
23
|
+
* Next 16.2.9 with a real app: the webpack builder FAILS the build outright
|
|
24
|
+
* ("Dynamic Code Evaluation ... not allowed in Edge Runtime", caused by
|
|
25
|
+
* importing '@palbase/web/dist/internal.js' — a compilation error, not a
|
|
26
|
+
* warning), and Turbopack, which does not enforce that, instead ships a
|
|
27
|
+
* 2.4 MB Edge bundle where this entry ships 168 KB.
|
|
28
|
+
*
|
|
29
|
+
* So the middleware never touches the runtime: it takes the two published
|
|
30
|
+
* identity values it actually needs and speaks the one auth wire directly.
|
|
31
|
+
* Nothing on this module's import graph may reach ../runtime.js —
|
|
32
|
+
* __tests__/next-middleware-bundle.test.ts locks that against the real
|
|
33
|
+
* build output.
|
|
34
|
+
*
|
|
35
|
+
* 'next/server' is imported only lazily (the type-only NextRequest/
|
|
36
|
+
* NextResponse imports erase at build time).
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* // middleware.ts
|
|
40
|
+
* import { palbeMiddleware } from '@palbase/web/next/middleware';
|
|
41
|
+
* import type { NextRequest } from 'next/server';
|
|
42
|
+
*
|
|
43
|
+
* export function middleware(request: NextRequest) {
|
|
44
|
+
* return palbeMiddleware(request, {
|
|
45
|
+
* url: 'https://abc12345m.palbase.studio',
|
|
46
|
+
* apiKey: 'pb_abc12345m_c...',
|
|
47
|
+
* });
|
|
48
|
+
* }
|
|
49
|
+
*
|
|
50
|
+
* // `config` MUST be declared here, in this file: Next reads it off THIS
|
|
51
|
+
* // file's AST (export const + literal initializer). A re-exported or
|
|
52
|
+
* // imported config is invisible and silently degrades to a catch-all.
|
|
53
|
+
* export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The published identity palbeMiddleware needs — the same two values
|
|
59
|
+
* `palbase web link` bakes into palbe.gen.ts. Both are publishable (the
|
|
60
|
+
* anon key already ships in every browser bundle); a service_role key is
|
|
61
|
+
* REJECTED, it has no business in a request-path bundle.
|
|
62
|
+
*
|
|
63
|
+
* A `PalbeConfig` is structurally assignable, so apps that already hold one
|
|
64
|
+
* can pass it straight through.
|
|
65
|
+
*/
|
|
66
|
+
interface PalbeMiddlewareConfig {
|
|
67
|
+
/** Environment URL, e.g. `https://abc12345m.palbase.studio`. */
|
|
68
|
+
url: string;
|
|
69
|
+
/** Publishable (anon) API key: `pb_<environmentRef>_c<random>`. */
|
|
70
|
+
apiKey: string;
|
|
71
|
+
}
|
|
72
|
+
interface PalbeMiddlewareOptions {
|
|
73
|
+
/** Refresh when the access token expires within this window. Default 60s. */
|
|
74
|
+
refreshMarginMs?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Pre-built response to decorate (e.g. from chained middleware) instead of
|
|
77
|
+
* building `NextResponse.next({ request })`. Pass-through cases return it
|
|
78
|
+
* UNMODIFIED. Caveat: a pre-built response snapshotted the request headers
|
|
79
|
+
* at ITS creation, so a rotated cookie reaches the browser (Set-Cookie)
|
|
80
|
+
* but not the downstream RSC render of THIS request — prefer omitting this
|
|
81
|
+
* and building your response after palbeMiddleware when possible.
|
|
82
|
+
*/
|
|
83
|
+
response?: NextResponse;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Keep the session cookie fresh for the request. No session / still-fresh
|
|
87
|
+
* session → untouched pass-through (no cache-control changes). Stale or
|
|
88
|
+
* expired → single-flight refresh, then the rotated (or cleared, on a dead
|
|
89
|
+
* token) cookies are written onto BOTH the request (for the downstream RSC
|
|
90
|
+
* render via `NextResponse.next({ request })`) and the response (for the
|
|
91
|
+
* browser), with `Cache-Control: private, no-store` on touched responses.
|
|
92
|
+
*
|
|
93
|
+
* Cookie writes go through Next's cookie APIs with the DECODED codec values:
|
|
94
|
+
* Next percent-encodes on serialization, so the wire bytes come out exactly
|
|
95
|
+
* as the shared codec's (see encodeSessionCookiesDecoded). NextRequest's
|
|
96
|
+
* cookie store is NOT a CookieStoreLike (its set is the 2-arg/RequestCookie
|
|
97
|
+
* form), hence the direct codec calls here instead of the pbServer adapter.
|
|
98
|
+
*/
|
|
99
|
+
declare function palbeMiddleware(request: NextRequest, config: PalbeMiddlewareConfig, opts?: PalbeMiddlewareOptions): Promise<NextResponse>;
|
|
100
|
+
|
|
101
|
+
export { type PalbeMiddlewareConfig, type PalbeMiddlewareOptions, palbeMiddleware };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
importNextServer
|
|
3
|
+
} from "../chunk-OWLTZG2V.js";
|
|
4
|
+
import {
|
|
5
|
+
SESSION_COOKIE_ATTRS,
|
|
6
|
+
clearedSessionCookieNames,
|
|
7
|
+
decodeSessionCookies,
|
|
8
|
+
encodeSessionCookiesDecoded
|
|
9
|
+
} from "../chunk-6VGKMNXO.js";
|
|
10
|
+
import {
|
|
11
|
+
BackendError
|
|
12
|
+
} from "../chunk-BNAGRUIQ.js";
|
|
13
|
+
import {
|
|
14
|
+
environmentRefFromPublishableApiKey
|
|
15
|
+
} from "../chunk-CFDU23TB.js";
|
|
16
|
+
import "../chunk-PZ5AY32C.js";
|
|
17
|
+
|
|
18
|
+
// src/next/middleware.ts
|
|
19
|
+
var DEFAULT_REFRESH_MARGIN_MS = 6e4;
|
|
20
|
+
var inflightRefreshes = /* @__PURE__ */ new Map();
|
|
21
|
+
function refreshSingleFlight(config, refreshToken) {
|
|
22
|
+
const existing = inflightRefreshes.get(refreshToken);
|
|
23
|
+
if (existing) return existing;
|
|
24
|
+
const pending = refresh(config, refreshToken).finally(() => {
|
|
25
|
+
inflightRefreshes.delete(refreshToken);
|
|
26
|
+
});
|
|
27
|
+
inflightRefreshes.set(refreshToken, pending);
|
|
28
|
+
return pending;
|
|
29
|
+
}
|
|
30
|
+
async function refresh(config, refreshToken) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`${config.url}/auth/token/refresh`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { apikey: config.apiKey, "content-type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ refresh_token: refreshToken })
|
|
36
|
+
});
|
|
37
|
+
if (res.status === 400 || res.status === 401 || res.status === 403) {
|
|
38
|
+
return { kind: "terminal" };
|
|
39
|
+
}
|
|
40
|
+
if (!res.ok) return { kind: "transient" };
|
|
41
|
+
const raw = await res.json();
|
|
42
|
+
if (typeof raw === "object" && raw !== null) {
|
|
43
|
+
const obj = raw;
|
|
44
|
+
if (typeof obj.access_token === "string" && typeof obj.refresh_token === "string" && typeof obj.expires_in === "number") {
|
|
45
|
+
return {
|
|
46
|
+
kind: "rotated",
|
|
47
|
+
session: {
|
|
48
|
+
accessToken: obj.access_token,
|
|
49
|
+
refreshToken: obj.refresh_token,
|
|
50
|
+
expiresAt: Date.now() + obj.expires_in * 1e3
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { kind: "transient" };
|
|
56
|
+
} catch {
|
|
57
|
+
return { kind: "transient" };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function palbeMiddleware(request, config, opts) {
|
|
61
|
+
const ref = environmentRefFromPublishableApiKey(config.apiKey);
|
|
62
|
+
if (ref === "") {
|
|
63
|
+
throw new BackendError("validation", {
|
|
64
|
+
code: "validation_error",
|
|
65
|
+
message: "palbeMiddleware() needs the publishable API key from palbe.gen.ts (pb_<environmentRef>_c\u2026). Re-run `palbase web link` and copy the generated values."
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const { NextResponse } = await importNextServer("palbeMiddleware");
|
|
69
|
+
const passThrough = () => opts?.response ?? NextResponse.next({ request });
|
|
70
|
+
const session = decodeSessionCookies((name) => request.cookies.get(name)?.value, ref);
|
|
71
|
+
if (!session) return passThrough();
|
|
72
|
+
if (session.expiresAt - Date.now() > (opts?.refreshMarginMs ?? DEFAULT_REFRESH_MARGIN_MS)) {
|
|
73
|
+
return passThrough();
|
|
74
|
+
}
|
|
75
|
+
const outcome = await refreshSingleFlight(config, session.refreshToken);
|
|
76
|
+
if (outcome.kind === "transient") return passThrough();
|
|
77
|
+
const staleNames = clearedSessionCookieNames(ref, (name) => request.cookies.has(name));
|
|
78
|
+
if (outcome.kind === "terminal") {
|
|
79
|
+
for (const name of staleNames) request.cookies.delete(name);
|
|
80
|
+
const response2 = opts?.response ?? NextResponse.next({ request });
|
|
81
|
+
for (const name of staleNames) {
|
|
82
|
+
response2.cookies.set(name, "", { ...SESSION_COOKIE_ATTRS, maxAge: 0 });
|
|
83
|
+
}
|
|
84
|
+
response2.headers.set("cache-control", "private, no-store");
|
|
85
|
+
return response2;
|
|
86
|
+
}
|
|
87
|
+
const write = encodeSessionCookiesDecoded(ref, outcome.session);
|
|
88
|
+
for (const name of staleNames) request.cookies.delete(name);
|
|
89
|
+
for (const { name, value } of write.set) request.cookies.set(name, value);
|
|
90
|
+
for (const name of write.clear) request.cookies.delete(name);
|
|
91
|
+
const response = opts?.response ?? NextResponse.next({ request });
|
|
92
|
+
const written = new Set(write.set.map((c) => c.name));
|
|
93
|
+
for (const { name, value } of write.set) {
|
|
94
|
+
response.cookies.set(name, value, SESSION_COOKIE_ATTRS);
|
|
95
|
+
}
|
|
96
|
+
for (const name of /* @__PURE__ */ new Set([...write.clear, ...staleNames])) {
|
|
97
|
+
if (!written.has(name)) {
|
|
98
|
+
response.cookies.set(name, "", { ...SESSION_COOKIE_ATTRS, maxAge: 0 });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
response.headers.set("cache-control", "private, no-store");
|
|
102
|
+
return response;
|
|
103
|
+
}
|
|
104
|
+
export {
|
|
105
|
+
palbeMiddleware
|
|
106
|
+
};
|
|
107
|
+
//# sourceMappingURL=middleware.js.map
|