@becklyn/deployment-protection 0.4.0 → 0.4.2

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 CHANGED
@@ -26,9 +26,9 @@ npm i @becklyn/deployment-protection
26
26
 
27
27
  ### Storybook (static on Vercel)
28
28
 
29
- Built Storybook is static (`storybook-static`), so protection is **Vercel Edge Middleware** — not a Storybook addon.
29
+ Built Storybook is static (`storybook-static`), so protection is **Vercel Routing Middleware** — not a Storybook addon.
30
30
 
31
- 1. Install the package in the project that deploys Storybook.
31
+ 1. Install the package in the project that deploys Storybook (published build, or workspace package after `npm run build`).
32
32
  2. Add `middleware.ts` at the **Vercel project root** (same level Vercel uses for `vercel.json` / output):
33
33
 
34
34
  ```ts
@@ -42,7 +42,7 @@ export const config = {
42
42
  ```
43
43
 
44
44
  3. Set the same environment variables as for Next.js (below).
45
- 4. Optional `vercel.json` for a Storybook-only project:
45
+ 4. `vercel.json` for a Storybook-only project — **`framework` must be `null`** (Other). Do **not** use the Vercel “Storybook” framework preset; it sets `disableRootMiddleware` and skips middleware entirely:
46
46
 
47
47
  ```json
48
48
  {
@@ -54,6 +54,8 @@ export const config = {
54
54
 
55
55
  `withStorybookDeploymentProtection` does **not** require the `next` package. The matcher must stay a **string literal** in `middleware.ts` (same static-analysis rule as Next.js).
56
56
 
57
+ Prefer the default **edge** runtime. `runtime: "nodejs"` is supported as well — both resolve to a self-contained ESM bundle (`dist/storybook.mjs` / `dist/edge.mjs`) so Vercel does not need to trace the multi-file tsc graph.
58
+
57
59
  Framework-agnostic alias (same implementation):
58
60
 
59
61
  ```ts
@@ -212,10 +214,20 @@ x-vercel-protection-bypass: <VERCEL_AUTOMATION_BYPASS_SECRET>
212
214
 
213
215
  Or query:
214
216
 
217
+ ```text
218
+ https://app.example/?x-vercel-protection-bypass=<secret>
219
+ ```
220
+
221
+ A valid bypass query param redirects to the same path without the secret and sets the signed `__becklyn_dp_session` cookie, so later requests stay authenticated. You do not need `x-vercel-set-bypass-cookie` for that.
222
+
223
+ Optional Vercel-compatible helper — also persist the raw bypass secret as `__becklyn_dp_bypass`:
224
+
215
225
  ```text
216
226
  https://app.example/?x-vercel-protection-bypass=<secret>&x-vercel-set-bypass-cookie=true
217
227
  ```
218
228
 
229
+ The header form stays one-shot (no cookies) so CI/Playwright can send it on each request.
230
+
219
231
  When platform Deployment Protection is disabled, **this package** enforces the bypass secret so CI/Playwright keep working the same way.
220
232
 
221
233
  ## Behaviour
@@ -223,9 +235,10 @@ When platform Deployment Protection is disabled, **this package** enforces the b
223
235
  1. Disabled / misconfigured → pass through
224
236
  2. Internal Vercel OAuth routes → handled by the package (proxy start or direct OAuth / handoff)
225
237
  3. Login form `POST` → validate username/password, set signed HttpOnly session cookie
226
- 4. Valid bypass header/query/cookie → allow (optionally set cookies)
227
- 5. Valid session cookieallow
228
- 6. Else HTML login page (password and/or Sign in with Vercel)
238
+ 4. Valid bypass header → allow this request
239
+ 5. Valid bypass query param set `__becklyn_dp_session`, redirect to the cleaned URL
240
+ 6. Valid session or bypass cookie allow
241
+ 7. Else → HTML login page (password and/or Sign in with Vercel)
229
242
 
230
243
  Session cookie: `__becklyn_dp_session` (HMAC-SHA256, 14 days by default).
231
244
 
@@ -1 +1 @@
1
- {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/handler.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAA8B,2BAA2B,EAAE,MAAM,SAAS,CAAC;AA4RvF;;GAEG;AACH,wBAAsB,0BAA0B,CAC5C,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAqD1B"}
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/handler.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAA8B,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAqSvF;;GAEG;AACH,wBAAsB,0BAA0B,CAC5C,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAoD1B"}
@@ -37,11 +37,20 @@ function redirect(request, location, status = 302) {
37
37
  },
38
38
  });
39
39
  }
40
+ /**
41
+ * Prefer the dedicated session secret; fall back to the automation bypass secret
42
+ * so a query-param bypass can still persist `__becklyn_dp_session` when no
43
+ * username/password or explicit HMAC secret is configured.
44
+ */
45
+ function signingSecret(config) {
46
+ return config.secret ?? config.bypassSecret;
47
+ }
40
48
  async function attachSession(response, config, method, subject, secure) {
41
- if (!config.secret) {
49
+ const secret = signingSecret(config);
50
+ if (!secret) {
42
51
  return response;
43
52
  }
44
- const token = await (0, session_1.createSessionToken)(config.secret, method, subject, config.sessionTtlSeconds);
53
+ const token = await (0, session_1.createSessionToken)(secret, method, subject, config.sessionTtlSeconds);
45
54
  const opts = (0, session_1.sessionCookieOptions)(config.sessionTtlSeconds, secure);
46
55
  (0, vercel_oauth_1.appendSetCookie)(response, constants_1.SESSION_COOKIE_NAME, token, opts);
47
56
  return response;
@@ -57,10 +66,12 @@ async function handleBypass(request, config) {
57
66
  const setCookieMode = (0, bypass_1.shouldSetBypassCookie)(request);
58
67
  const url = new URL(request.url);
59
68
  const hadQueryBypass = url.searchParams.has(constants_1.BYPASS_HEADER);
60
- const hadSetCookieQuery = url.searchParams.has("x-vercel-set-bypass-cookie");
69
+ const hadSetCookieQuery = url.searchParams.has(constants_1.SET_BYPASS_COOKIE_HEADER);
70
+ // Query-param bypass (and the Vercel set-cookie helper) persist auth, then
71
+ // strip secrets from the URL. Header-only automation stays one-shot.
61
72
  if (hadQueryBypass || hadSetCookieQuery || setCookieMode) {
62
73
  url.searchParams.delete(constants_1.BYPASS_HEADER);
63
- url.searchParams.delete("x-vercel-set-bypass-cookie");
74
+ url.searchParams.delete(constants_1.SET_BYPASS_COOKIE_HEADER);
64
75
  const response = redirect(request, url.pathname + url.search + url.hash);
65
76
  const secure = isSecureRequest(request);
66
77
  if (setCookieMode || hadSetCookieQuery) {
@@ -72,6 +83,7 @@ async function handleBypass(request, config) {
72
83
  sameSite: setCookieMode === "samesitenone" ? "none" : "lax",
73
84
  });
74
85
  }
86
+ // Always persist a signed session when the bypass secret arrived via query.
75
87
  await attachSession(response, config, "bypass", "automation", secure);
76
88
  return { kind: "respond", response };
77
89
  }
@@ -233,8 +245,9 @@ async function handleDeploymentProtection(request, options = {}) {
233
245
  if (bypass.kind === "allow") {
234
246
  return null;
235
247
  }
236
- if (config.secret) {
237
- const session = await (0, session_1.verifySessionToken)(config.secret, (0, vercel_oauth_1.readCookie)(request, constants_1.SESSION_COOKIE_NAME));
248
+ const secret = signingSecret(config);
249
+ if (secret) {
250
+ const session = await (0, session_1.verifySessionToken)(secret, (0, vercel_oauth_1.readCookie)(request, constants_1.SESSION_COOKIE_NAME));
238
251
  if (session) {
239
252
  return null;
240
253
  }
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAE3D,KAAK,cAAc,GAAG,CAClB,OAAO,EAAE,WAAW,EACpB,KAAK,CAAC,EAAE,OAAO,KAEb,QAAQ,GACR,YAAY,GACZ,OAAO,CAAC,QAAQ,GAAG,YAAY,GAAG,SAAS,GAAG,IAAI,CAAC,GACnD,SAAS,GACT,IAAI,CAAC;AAEX,MAAM,MAAM,4BAA4B,GAClC,EAAE,GACF,CAAC,2BAA2B,CAAC,GAC7B,CAAC,cAAc,CAAC,GAChB,CAAC,cAAc,EAAE,2BAA2B,CAAC,CAAC;AA8DpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,IAAI,EAAE,4BAA4B,IAgBtE,SAAS,WAAW,EACpB,QAAQ,OAAO,KAChB,OAAO,CAAC,QAAQ,GAAG,YAAY,CAAC,CActC"}
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAE3D,KAAK,cAAc,GAAG,CAClB,OAAO,EAAE,WAAW,EACpB,KAAK,CAAC,EAAE,OAAO,KAEb,QAAQ,GACR,YAAY,GACZ,OAAO,CAAC,QAAQ,GAAG,YAAY,GAAG,SAAS,GAAG,IAAI,CAAC,GACnD,SAAS,GACT,IAAI,CAAC;AAEX,MAAM,MAAM,4BAA4B,GAClC,EAAE,GACF,CAAC,2BAA2B,CAAC,GAC7B,CAAC,cAAc,CAAC,GAChB,CAAC,cAAc,EAAE,2BAA2B,CAAC,CAAC;AA8JpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,IAAI,EAAE,4BAA4B,IAgBtE,SAAS,WAAW,EACpB,QAAQ,OAAO,KAChB,OAAO,CAAC,QAAQ,GAAG,YAAY,CAAC,CActC"}
@@ -9,12 +9,95 @@ function isOptions(value) {
9
9
  !Array.isArray(value) &&
10
10
  typeof value !== "function");
11
11
  }
12
+ function decodeCookieValue(value) {
13
+ try {
14
+ return decodeURIComponent(value);
15
+ }
16
+ catch {
17
+ return value;
18
+ }
19
+ }
20
+ function parseSetCookie(header) {
21
+ const parts = header
22
+ .split(";")
23
+ .map(part => part.trim())
24
+ .filter(Boolean);
25
+ const first = parts.shift();
26
+ if (!first) {
27
+ return null;
28
+ }
29
+ const separator = first.indexOf("=");
30
+ if (separator <= 0) {
31
+ return null;
32
+ }
33
+ const parsed = {
34
+ name: first.slice(0, separator),
35
+ value: decodeCookieValue(first.slice(separator + 1)),
36
+ };
37
+ for (const part of parts) {
38
+ const [rawKey, ...rest] = part.split("=");
39
+ const key = rawKey?.toLowerCase();
40
+ const attrValue = rest.join("=");
41
+ if (key === "httponly") {
42
+ parsed.httpOnly = true;
43
+ }
44
+ else if (key === "secure") {
45
+ parsed.secure = true;
46
+ }
47
+ else if (key === "path") {
48
+ parsed.path = attrValue;
49
+ }
50
+ else if (key === "max-age") {
51
+ const maxAge = Number(attrValue);
52
+ if (!Number.isNaN(maxAge)) {
53
+ parsed.maxAge = maxAge;
54
+ }
55
+ }
56
+ else if (key === "samesite") {
57
+ const sameSite = attrValue.toLowerCase();
58
+ if (sameSite === "lax" || sameSite === "strict" || sameSite === "none") {
59
+ parsed.sameSite = sameSite;
60
+ }
61
+ }
62
+ }
63
+ return parsed;
64
+ }
65
+ function readSetCookieHeaders(headers) {
66
+ if (typeof headers.getSetCookie === "function") {
67
+ const cookies = headers.getSetCookie();
68
+ if (cookies.length > 0) {
69
+ return cookies;
70
+ }
71
+ }
72
+ const single = headers.get("set-cookie");
73
+ return single ? [single] : [];
74
+ }
75
+ /**
76
+ * Next.js middleware only reliably applies cookies set via `NextResponse.cookies`.
77
+ * Copying raw `Set-Cookie` headers onto a redirect is ignored by the runtime.
78
+ */
79
+ function applyCookies(from, to) {
80
+ for (const header of readSetCookieHeaders(from.headers)) {
81
+ const cookie = parseSetCookie(header);
82
+ if (!cookie) {
83
+ continue;
84
+ }
85
+ to.cookies.set(cookie.name, cookie.value, {
86
+ httpOnly: cookie.httpOnly,
87
+ secure: cookie.secure,
88
+ path: cookie.path,
89
+ maxAge: cookie.maxAge,
90
+ sameSite: cookie.sameSite,
91
+ });
92
+ }
93
+ }
12
94
  function toNextResponse(request, response) {
13
95
  const location = response.headers.get("Location");
14
96
  if (location && response.status >= 300 && response.status < 400) {
15
97
  // Always resolve against the incoming request so relative Locations never reach Next.
16
98
  const redirectResponse = server_1.NextResponse.redirect(new URL(location, request.url), response.status);
17
99
  copyHeaders(response, redirectResponse, /* skipLocation */ true);
100
+ applyCookies(response, redirectResponse);
18
101
  return redirectResponse;
19
102
  }
20
103
  const nextResponse = new server_1.NextResponse(response.body, {
@@ -22,22 +105,14 @@ function toNextResponse(request, response) {
22
105
  statusText: response.statusText,
23
106
  });
24
107
  copyHeaders(response, nextResponse, false);
108
+ applyCookies(response, nextResponse);
25
109
  return nextResponse;
26
110
  }
27
111
  function copyHeaders(from, to, skipLocation) {
28
- const setCookies = typeof from.headers.getSetCookie === "function" ? from.headers.getSetCookie() : [];
29
- if (setCookies.length > 0) {
30
- for (const cookie of setCookies) {
31
- to.headers.append("Set-Cookie", cookie);
32
- }
33
- }
34
112
  from.headers.forEach((value, key) => {
35
113
  const lower = key.toLowerCase();
36
114
  if (lower === "set-cookie") {
37
- // Already copied via getSetCookie when available; fall back otherwise.
38
- if (setCookies.length === 0) {
39
- to.headers.append("Set-Cookie", value);
40
- }
115
+ // Applied via NextResponse.cookies so the middleware runtime honors them.
41
116
  return;
42
117
  }
43
118
  if (skipLocation && lower === "location") {
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Storybook deployment protection for Vercel.
3
3
  *
4
- * Built Storybooks are static sites, so protection runs as **Vercel Edge Middleware**
4
+ * Built Storybooks are static sites, so protection runs as **Vercel Routing Middleware**
5
5
  * (not Storybook config). Drop a `middleware.ts` next to the Vercel project root that
6
6
  * serves `storybook-static` and set the same env vars as the Next.js integration.
7
7
  *
8
+ * The published `./storybook` and `./edge` entry points resolve to self-contained ESM
9
+ * bundles so Vercel edge/nodejs middleware can load them without tracing multi-file
10
+ * package internals.
11
+ *
8
12
  * @example middleware.ts
9
13
  * ```ts
10
14
  * import { withStorybookDeploymentProtection } from "@becklyn/deployment-protection/storybook";
@@ -19,7 +23,7 @@
19
23
  * };
20
24
  * ```
21
25
  *
22
- * Optional `vercel.json` for a Storybook-only project:
26
+ * Optional `vercel.json` for a Storybook-only project (`framework` must be `null`):
23
27
  * ```json
24
28
  * {
25
29
  * "buildCommand": "npm run build-storybook",
@@ -1 +1 @@
1
- {"version":3,"file":"storybook.d.ts","sourceRoot":"","sources":["../../src/storybook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,EACH,qBAAqB,EACrB,4BAA4B,IAAI,iCAAiC,GACpE,MAAM,QAAQ,CAAC;AAChB,YAAY,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"storybook.d.ts","sourceRoot":"","sources":["../../src/storybook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,OAAO,EACH,qBAAqB,EACrB,4BAA4B,IAAI,iCAAiC,GACpE,MAAM,QAAQ,CAAC;AAChB,YAAY,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC"}
@@ -4,10 +4,14 @@ exports.withStorybookDeploymentProtection = exports.middlewarePassThrough = void
4
4
  /**
5
5
  * Storybook deployment protection for Vercel.
6
6
  *
7
- * Built Storybooks are static sites, so protection runs as **Vercel Edge Middleware**
7
+ * Built Storybooks are static sites, so protection runs as **Vercel Routing Middleware**
8
8
  * (not Storybook config). Drop a `middleware.ts` next to the Vercel project root that
9
9
  * serves `storybook-static` and set the same env vars as the Next.js integration.
10
10
  *
11
+ * The published `./storybook` and `./edge` entry points resolve to self-contained ESM
12
+ * bundles so Vercel edge/nodejs middleware can load them without tracing multi-file
13
+ * package internals.
14
+ *
11
15
  * @example middleware.ts
12
16
  * ```ts
13
17
  * import { withStorybookDeploymentProtection } from "@becklyn/deployment-protection/storybook";
@@ -22,7 +26,7 @@ exports.withStorybookDeploymentProtection = exports.middlewarePassThrough = void
22
26
  * };
23
27
  * ```
24
28
  *
25
- * Optional `vercel.json` for a Storybook-only project:
29
+ * Optional `vercel.json` for a Storybook-only project (`framework` must be `null`):
26
30
  * ```json
27
31
  * {
28
32
  * "buildCommand": "npm run build-storybook",
@@ -0,0 +1,7 @@
1
+ {
2
+ "format": "esm",
3
+ "entries": [
4
+ "dist/edge.mjs",
5
+ "dist/storybook.mjs"
6
+ ]
7
+ }