@chidchanun/bcp 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ # JWT Cookie Sessions
2
+
3
+ BCP Framework provides server-only JWT cookie session helpers through `bcp/server`.
4
+
5
+ The current session implementation uses HS256 with Node.js `crypto`, requires a secret of at least 32 bytes, stores the token in an HttpOnly cookie by default and validates expiry before returning a session.
6
+
7
+ ## Environment
8
+
9
+ Set a strong session secret in the server environment:
10
+
11
+ ```env
12
+ BCP_SESSION_SECRET=replace-this-with-a-long-random-secret-at-least-32-bytes
13
+ ```
14
+
15
+ Do not expose this value to browser code or prefix it as a public environment variable.
16
+
17
+ ## Create a session
18
+
19
+ `createSession()` signs a JWT and stores it in a cookie. The default cookie name is `bcp_session` and the default lifetime is 12 hours.
20
+
21
+ ```ts
22
+ import {
23
+ createSession,
24
+ json,
25
+ } from "bcp/server";
26
+
27
+ export async function POST() {
28
+ await createSession(
29
+ {
30
+ userId: 42,
31
+ email: "user@example.com",
32
+ role: "admin",
33
+ },
34
+ {
35
+ issuer: "my-app",
36
+ audience: "my-app-users",
37
+ }
38
+ );
39
+
40
+ return json({
41
+ success: true,
42
+ });
43
+ }
44
+ ```
45
+
46
+ By default BCP sets:
47
+
48
+ - `HttpOnly`
49
+ - `SameSite=Lax`
50
+ - `Path=/`
51
+ - `Secure` when `NODE_ENV=production`
52
+ - `Max-Age` equal to the token lifetime
53
+
54
+ Cookie and token behavior can be customized:
55
+
56
+ ```ts
57
+ await createSession(
58
+ {
59
+ userId: 42,
60
+ },
61
+ {
62
+ cookieName: "access_token",
63
+ expiresIn: 60 * 60,
64
+ issuer: "my-app",
65
+ audience: [
66
+ "web",
67
+ "api",
68
+ ],
69
+ sameSite: "strict",
70
+ secure: true,
71
+ path: "/",
72
+ }
73
+ );
74
+ ```
75
+
76
+ ## Read a session
77
+
78
+ `getSession()` reads the cookie and verifies the JWT signature, expiration, issuer and audience when those options are provided.
79
+
80
+ ```ts
81
+ import {
82
+ getSession,
83
+ json,
84
+ } from "bcp/server";
85
+
86
+ export async function GET() {
87
+ const session =
88
+ await getSession<{
89
+ userId: number;
90
+ email: string;
91
+ role: string;
92
+ }>({
93
+ issuer: "my-app",
94
+ audience: "my-app-users",
95
+ });
96
+
97
+ if (!session) {
98
+ return json(
99
+ {
100
+ error: "Unauthorized",
101
+ },
102
+ {
103
+ status: 401,
104
+ }
105
+ );
106
+ }
107
+
108
+ return json({
109
+ userId:
110
+ session.userId,
111
+ email:
112
+ session.email,
113
+ role:
114
+ session.role,
115
+ });
116
+ }
117
+ ```
118
+
119
+ Invalid, expired or tampered session tokens return `null` instead of throwing.
120
+
121
+ ## Destroy a session
122
+
123
+ `destroySession()` expires the session cookie:
124
+
125
+ ```ts
126
+ import {
127
+ destroySession,
128
+ json,
129
+ } from "bcp/server";
130
+
131
+ export async function POST() {
132
+ await destroySession();
133
+
134
+ return json({
135
+ success: true,
136
+ });
137
+ }
138
+ ```
139
+
140
+ When a custom cookie name, path or domain is used while creating the session, use the same values when destroying it.
141
+
142
+ ## Low-level token APIs
143
+
144
+ BCP also exposes token-only helpers when an application needs to manage storage itself:
145
+
146
+ ```ts
147
+ import {
148
+ createSessionToken,
149
+ verifySessionToken,
150
+ } from "bcp/server";
151
+
152
+ const token =
153
+ await createSessionToken(
154
+ {
155
+ userId: 42,
156
+ },
157
+ {
158
+ expiresIn: 3600,
159
+ issuer: "my-app",
160
+ }
161
+ );
162
+
163
+ const claims =
164
+ await verifySessionToken<{
165
+ userId: number;
166
+ }>(
167
+ token,
168
+ {
169
+ issuer: "my-app",
170
+ }
171
+ );
172
+ ```
173
+
174
+ `createSessionToken()` and `verifySessionToken()` use `BCP_SESSION_SECRET` unless an explicit `secret` option is supplied.
175
+
176
+ ## Security model
177
+
178
+ BCP session tokens use HS256 and reject secrets shorter than 32 bytes. Verification validates the signature with a constant-time comparison and rejects malformed, expired or unsupported JWTs.
179
+
180
+ JWT payloads are signed, not encrypted. Do not place passwords, API secrets or other confidential values in the session payload.
181
+
182
+ Session revocation is not automatic for stateless JWTs. Applications that require immediate revocation should store a session identifier in the token and validate it against a server-side session table or another revocation store.
183
+
184
+ ## API route boundary
185
+
186
+ These helpers are exported from `bcp/server` and remain server-only. In the current 0.1.x page/client pipeline, call them from API routes rather than modules reachable from the browser bundle.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chidchanun/bcp",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,10 @@ import type {
22
22
  Route,
23
23
  } from "../../router/src/index.js";
24
24
 
25
+ import {
26
+ findPageLoaderFile,
27
+ } from "../../server/src/page-loader.js";
28
+
25
29
  import type {
26
30
  PartialHydrationBuildManifest,
27
31
  } from "./partial-hydration.js";
@@ -416,6 +420,26 @@ function createServerEntry(
416
420
  `const ${pageName} = ${pageModuleName}.default;`
417
421
  );
418
422
 
423
+ const loaderFile =
424
+ findPageLoaderFile(
425
+ route.filePath
426
+ );
427
+ let loaderName =
428
+ "null";
429
+
430
+ if (loaderFile) {
431
+ loaderName =
432
+ `Loader${routeIndex}`;
433
+ imports.push(
434
+ `import { loader as ${loaderName} } from ${JSON.stringify(
435
+ toImportSpecifier(
436
+ entryDirectory,
437
+ loaderFile
438
+ )
439
+ )};`
440
+ );
441
+ }
442
+
419
443
  const layoutModules:
420
444
  Array<{
421
445
  name: string;
@@ -630,6 +654,7 @@ function createServerEntry(
630
654
  error: ${errorName},
631
655
  notFound: ${notFoundName},
632
656
  metadataSources: [${metadataSources.join(", ")}],
657
+ loader: ${loaderName},
633
658
  hydration: ${JSON.stringify(hydration)},
634
659
  client: ${JSON.stringify(client)}
635
660
  }`);
@@ -17,6 +17,10 @@ export {
17
17
  type NavigationState,
18
18
  } from "./navigation-state.js";
19
19
 
20
+ export {
21
+ useLoaderData,
22
+ } from "./loader-data.js";
23
+
20
24
  export {
21
25
  createIsland,
22
26
  type BcpIslandMetadata,
@@ -0,0 +1,99 @@
1
+ import {
2
+ createContext,
3
+ createElement,
4
+ useContext,
5
+ type ReactNode,
6
+ } from "react";
7
+
8
+ const LOADER_DATA_PARAM =
9
+ "__bcp_loader_data";
10
+ const MISSING_LOADER_DATA =
11
+ Symbol(
12
+ "bcp-loader-data"
13
+ );
14
+
15
+ const LoaderDataContext =
16
+ createContext<unknown>(
17
+ MISSING_LOADER_DATA
18
+ );
19
+
20
+ export interface BcpLoaderDataProviderProps {
21
+ data: unknown;
22
+ children?: ReactNode;
23
+ }
24
+
25
+ export function BcpLoaderDataProvider({
26
+ data,
27
+ children,
28
+ }: BcpLoaderDataProviderProps) {
29
+ return createElement(
30
+ LoaderDataContext.Provider,
31
+ {
32
+ value:
33
+ data,
34
+ },
35
+ children
36
+ );
37
+ }
38
+
39
+ export function useLoaderData<
40
+ T = unknown
41
+ >(): T {
42
+ const contextValue =
43
+ useContext(
44
+ LoaderDataContext
45
+ );
46
+
47
+ if (
48
+ contextValue !==
49
+ MISSING_LOADER_DATA
50
+ ) {
51
+ return contextValue as T;
52
+ }
53
+
54
+ if (
55
+ typeof document !==
56
+ "undefined"
57
+ ) {
58
+ const element =
59
+ document.getElementById(
60
+ "__BCP_DATA__"
61
+ );
62
+
63
+ if (element) {
64
+ try {
65
+ const frameworkData =
66
+ JSON.parse(
67
+ element.textContent ||
68
+ "{}"
69
+ ) as {
70
+ params?: Record<
71
+ string,
72
+ unknown
73
+ >;
74
+ };
75
+ const params =
76
+ frameworkData.params;
77
+
78
+ if (
79
+ params &&
80
+ Object.prototype
81
+ .hasOwnProperty.call(
82
+ params,
83
+ LOADER_DATA_PARAM
84
+ )
85
+ ) {
86
+ return params[
87
+ LOADER_DATA_PARAM
88
+ ] as T;
89
+ }
90
+ } catch {
91
+ // Fall through to the framework error below.
92
+ }
93
+ }
94
+ }
95
+
96
+ throw new Error(
97
+ "BCP Framework: useLoaderData() was used on a route without loader data. Add loader.ts next to page.tsx."
98
+ );
99
+ }