@fluxfiles/node 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # FluxFiles for Node (server-side token SDK)
2
+
3
+ Mint [FluxFiles](https://github.com/thai-pc/fluxfiles) JWTs from any Node.js
4
+ backend (Express, Next.js, Nuxt/Nitro, NestJS, Fastify, …). Tokens are
5
+ **byte-compatible with the PHP core**, so non-PHP apps can issue access tokens —
6
+ including encrypted BYOB (Bring Your Own Bucket) credentials — without running PHP.
7
+
8
+ Zero runtime dependencies (built on `node:crypto`).
9
+
10
+ ## Requirements
11
+
12
+ - Node.js 16+
13
+ - The same **`FLUXFILES_SECRET`** your FluxFiles core server uses to verify tokens
14
+ (HS256, **must be ≥ 32 bytes**). Keep it server-side only.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @fluxfiles/node
20
+ # or
21
+ yarn add @fluxfiles/node
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### Mint a token
27
+
28
+ ```ts
29
+ import { createToken } from '@fluxfiles/node';
30
+
31
+ const token = createToken({
32
+ secret: process.env.FLUXFILES_SECRET, // or omit to read FLUXFILES_SECRET
33
+ userId: 'user-42',
34
+ perms: ['read', 'write'],
35
+ disks: ['local', 's3'],
36
+ prefix: 'users/42', // scope the user to their own directory
37
+ maxUploadMb: 25,
38
+ allowedExt: ['png', 'jpg', 'pdf'],
39
+ ttl: 3600, // seconds
40
+ });
41
+ ```
42
+
43
+ ### BYOB — encrypt a user's own bucket credentials
44
+
45
+ ```ts
46
+ import { createByobToken } from '@fluxfiles/node';
47
+
48
+ const token = createByobToken({
49
+ userId: 'user-42',
50
+ byobDisks: {
51
+ 'my-s3': {
52
+ driver: 's3',
53
+ key: process.env.USER_AWS_KEY!,
54
+ secret: process.env.USER_AWS_SECRET!,
55
+ bucket: 'user-personal-bucket',
56
+ region: 'us-east-1',
57
+ // endpoint: 'https://<acct>.r2.cloudflarestorage.com', // R2/MinIO/Spaces
58
+ },
59
+ },
60
+ });
61
+ ```
62
+
63
+ Credentials are AES-256-GCM encrypted into the token and decrypted only at
64
+ runtime by the FluxFiles server (which also re-validates the endpoint for SSRF).
65
+
66
+ ### Verify / decode (optional)
67
+
68
+ ```ts
69
+ import { verifyToken, decodeToken } from '@fluxfiles/node';
70
+
71
+ const claims = verifyToken(token); // checks HS256 signature + expiry, throws on failure
72
+ const peek = decodeToken(token); // decode only, NO verification — never trust for auth
73
+ ```
74
+
75
+ ### Next.js (App Router) — Route Handler
76
+
77
+ ```ts
78
+ // app/api/fluxfiles-token/route.ts
79
+ import { createToken } from '@fluxfiles/node';
80
+ import { auth } from '@/lib/auth';
81
+
82
+ export async function GET() {
83
+ const user = await auth();
84
+ const token = createToken({ userId: user.id, perms: ['read', 'write'], prefix: `users/${user.id}` });
85
+ return Response.json({ token });
86
+ }
87
+ ```
88
+
89
+ ### Express middleware
90
+
91
+ ```ts
92
+ import { createToken } from '@fluxfiles/node';
93
+
94
+ app.get('/fluxfiles/token', (req, res) => {
95
+ res.json({ token: createToken({ userId: req.user.id, perms: ['read', 'write'] }) });
96
+ });
97
+ ```
98
+
99
+ ## API
100
+
101
+ | Function | Description |
102
+ |----------|-------------|
103
+ | `createToken(opts)` | Standard token. Mirrors PHP `fluxfiles_token()`. |
104
+ | `createByobToken(opts)` | Token with encrypted BYOB disk credentials. Mirrors `fluxfiles_byob_token()`. |
105
+ | `verifyToken(token, secret?)` | Verify HS256 signature + expiry; returns decoded claims or throws. |
106
+ | `decodeToken(token)` | Decode without verifying (inspection/logging only). |
107
+
108
+ `createToken` options: `secret?`, `userId`, `perms?`, `disks?`, `prefix?`,
109
+ `maxUploadMb?`, `allowedExt?`, `ttl?`, `ownerOnly?`, `maxStorageMb?`, `maxFiles?`.
110
+ `createByobToken` replaces `disks` with `byobDisks` (a map of name → S3-compatible
111
+ config) and does not take `maxStorageMb`/`maxFiles` (matching the core).
112
+
113
+ ## Compatibility
114
+
115
+ Tokens and BYOB blobs are validated against the PHP core in CI: a Node-minted
116
+ token decodes in `JwtCompat::decode`, and BYOB credentials round-trip both ways
117
+ through `CredentialEncryptor` (HS256 + HKDF-SHA256 + AES-256-GCM). Always mint
118
+ tokens **on the server** — never ship `FLUXFILES_SECRET` to the browser.
119
+
120
+ ## License
121
+
122
+ MIT — see [LICENSE](LICENSE) for details.
123
+
124
+ ## Links
125
+
126
+ - Main repository: `https://github.com/thai-pc/fluxfiles`
127
+ - Documentation: `https://github.com/thai-pc/fluxfiles#node-server-side-token-sdk`
128
+ - Issues: `https://github.com/thai-pc/fluxfiles/issues`
@@ -0,0 +1,88 @@
1
+ /** A FluxFiles permission. */
2
+ type FluxPermission = 'read' | 'write' | 'delete';
3
+ /**
4
+ * A BYOB (Bring Your Own Bucket) disk config. Encrypted into the JWT and
5
+ * decrypted only at runtime by the FluxFiles server. Only S3-compatible
6
+ * storage is allowed — the server rejects the `local` driver.
7
+ */
8
+ interface ByobDiskConfig {
9
+ driver: 's3';
10
+ key: string;
11
+ secret: string;
12
+ bucket: string;
13
+ region?: string;
14
+ /** Custom S3 endpoint (R2, MinIO, Spaces, …). Omit for native AWS S3. */
15
+ endpoint?: string;
16
+ visibility?: 'private' | 'public';
17
+ /** Public base URL for direct (unsigned) object links on a public disk. */
18
+ public_url?: string;
19
+ }
20
+ /** Options shared by all token builders. */
21
+ interface BaseTokenOptions {
22
+ /** HS256 signing secret. Defaults to `process.env.FLUXFILES_SECRET`. Must be ≥ 32 bytes. */
23
+ secret?: string;
24
+ /** Subject — your application's user id. */
25
+ userId: string;
26
+ perms?: FluxPermission[];
27
+ /** Path prefix the user is scoped to (e.g. `users/42`). */
28
+ prefix?: string;
29
+ maxUploadMb?: number;
30
+ /** Allowed extensions (lowercase, no dot). `null`/omitted = all non-dangerous types. */
31
+ allowedExt?: string[] | null;
32
+ /** Time-to-live in seconds. */
33
+ ttl?: number;
34
+ /** Restrict destructive ops to files the user uploaded. */
35
+ ownerOnly?: boolean;
36
+ }
37
+ interface CreateTokenOptions extends BaseTokenOptions {
38
+ /** Disk names the token may access. */
39
+ disks?: string[];
40
+ maxStorageMb?: number;
41
+ /** Total file count cap under the prefix. `0` = unlimited. */
42
+ maxFiles?: number;
43
+ }
44
+ interface CreateByobTokenOptions extends BaseTokenOptions {
45
+ /** Map of disk name → S3-compatible credentials, encrypted into the token. */
46
+ byobDisks: Record<string, ByobDiskConfig>;
47
+ }
48
+ /** Decoded JWT payload (snake_case, as emitted by the PHP core). */
49
+ interface FluxClaims {
50
+ sub: string;
51
+ iat: number;
52
+ exp: number;
53
+ jti: string;
54
+ perms: string[];
55
+ disks: string[];
56
+ prefix: string;
57
+ max_upload: number;
58
+ allowed_ext: string[] | null;
59
+ max_storage?: number;
60
+ max_files?: number;
61
+ owner_only?: boolean;
62
+ byob_disks?: Record<string, string>;
63
+ }
64
+
65
+ /**
66
+ * Mint a standard FluxFiles JWT. The payload mirrors the PHP `fluxfiles_token()`
67
+ * helper exactly, so the FluxFiles core decodes it natively.
68
+ */
69
+ declare function createToken(opts: CreateTokenOptions): string;
70
+ /**
71
+ * Mint a BYOB token. Each disk's S3-compatible credentials are AES-256-GCM
72
+ * encrypted into the token (decrypted only at runtime by the server). Mirrors
73
+ * the PHP `fluxfiles_byob_token()` helper.
74
+ */
75
+ declare function createByobToken(opts: CreateByobTokenOptions): string;
76
+
77
+ /**
78
+ * Decode a token WITHOUT verifying the signature. Useful for inspection/logging;
79
+ * never trust the result for authorization — use {@link verifyToken}.
80
+ */
81
+ declare function decodeToken(token: string): FluxClaims;
82
+ /**
83
+ * Verify an HS256 FluxFiles token: signature + expiry. Returns the decoded
84
+ * claims, or throws on a bad signature, wrong algorithm, or an expired token.
85
+ */
86
+ declare function verifyToken(token: string, secret?: string): FluxClaims;
87
+
88
+ export { type BaseTokenOptions, type ByobDiskConfig, type CreateByobTokenOptions, type CreateTokenOptions, type FluxClaims, type FluxPermission, createByobToken, createToken, decodeToken, verifyToken };
@@ -0,0 +1,88 @@
1
+ /** A FluxFiles permission. */
2
+ type FluxPermission = 'read' | 'write' | 'delete';
3
+ /**
4
+ * A BYOB (Bring Your Own Bucket) disk config. Encrypted into the JWT and
5
+ * decrypted only at runtime by the FluxFiles server. Only S3-compatible
6
+ * storage is allowed — the server rejects the `local` driver.
7
+ */
8
+ interface ByobDiskConfig {
9
+ driver: 's3';
10
+ key: string;
11
+ secret: string;
12
+ bucket: string;
13
+ region?: string;
14
+ /** Custom S3 endpoint (R2, MinIO, Spaces, …). Omit for native AWS S3. */
15
+ endpoint?: string;
16
+ visibility?: 'private' | 'public';
17
+ /** Public base URL for direct (unsigned) object links on a public disk. */
18
+ public_url?: string;
19
+ }
20
+ /** Options shared by all token builders. */
21
+ interface BaseTokenOptions {
22
+ /** HS256 signing secret. Defaults to `process.env.FLUXFILES_SECRET`. Must be ≥ 32 bytes. */
23
+ secret?: string;
24
+ /** Subject — your application's user id. */
25
+ userId: string;
26
+ perms?: FluxPermission[];
27
+ /** Path prefix the user is scoped to (e.g. `users/42`). */
28
+ prefix?: string;
29
+ maxUploadMb?: number;
30
+ /** Allowed extensions (lowercase, no dot). `null`/omitted = all non-dangerous types. */
31
+ allowedExt?: string[] | null;
32
+ /** Time-to-live in seconds. */
33
+ ttl?: number;
34
+ /** Restrict destructive ops to files the user uploaded. */
35
+ ownerOnly?: boolean;
36
+ }
37
+ interface CreateTokenOptions extends BaseTokenOptions {
38
+ /** Disk names the token may access. */
39
+ disks?: string[];
40
+ maxStorageMb?: number;
41
+ /** Total file count cap under the prefix. `0` = unlimited. */
42
+ maxFiles?: number;
43
+ }
44
+ interface CreateByobTokenOptions extends BaseTokenOptions {
45
+ /** Map of disk name → S3-compatible credentials, encrypted into the token. */
46
+ byobDisks: Record<string, ByobDiskConfig>;
47
+ }
48
+ /** Decoded JWT payload (snake_case, as emitted by the PHP core). */
49
+ interface FluxClaims {
50
+ sub: string;
51
+ iat: number;
52
+ exp: number;
53
+ jti: string;
54
+ perms: string[];
55
+ disks: string[];
56
+ prefix: string;
57
+ max_upload: number;
58
+ allowed_ext: string[] | null;
59
+ max_storage?: number;
60
+ max_files?: number;
61
+ owner_only?: boolean;
62
+ byob_disks?: Record<string, string>;
63
+ }
64
+
65
+ /**
66
+ * Mint a standard FluxFiles JWT. The payload mirrors the PHP `fluxfiles_token()`
67
+ * helper exactly, so the FluxFiles core decodes it natively.
68
+ */
69
+ declare function createToken(opts: CreateTokenOptions): string;
70
+ /**
71
+ * Mint a BYOB token. Each disk's S3-compatible credentials are AES-256-GCM
72
+ * encrypted into the token (decrypted only at runtime by the server). Mirrors
73
+ * the PHP `fluxfiles_byob_token()` helper.
74
+ */
75
+ declare function createByobToken(opts: CreateByobTokenOptions): string;
76
+
77
+ /**
78
+ * Decode a token WITHOUT verifying the signature. Useful for inspection/logging;
79
+ * never trust the result for authorization — use {@link verifyToken}.
80
+ */
81
+ declare function decodeToken(token: string): FluxClaims;
82
+ /**
83
+ * Verify an HS256 FluxFiles token: signature + expiry. Returns the decoded
84
+ * claims, or throws on a bad signature, wrong algorithm, or an expired token.
85
+ */
86
+ declare function verifyToken(token: string, secret?: string): FluxClaims;
87
+
88
+ export { type BaseTokenOptions, type ByobDiskConfig, type CreateByobTokenOptions, type CreateTokenOptions, type FluxClaims, type FluxPermission, createByobToken, createToken, decodeToken, verifyToken };
package/dist/index.js ADDED
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ createByobToken: () => createByobToken,
24
+ createToken: () => createToken,
25
+ decodeToken: () => decodeToken,
26
+ verifyToken: () => verifyToken
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/token.ts
31
+ var import_node_crypto2 = require("crypto");
32
+
33
+ // src/crypto.ts
34
+ var import_node_crypto = require("crypto");
35
+ function base64url(input) {
36
+ return Buffer.from(input).toString("base64url");
37
+ }
38
+ function hmacSha256(data, secret) {
39
+ return (0, import_node_crypto.createHmac)("sha256", secret).update(data).digest();
40
+ }
41
+ function safeEqual(a, b) {
42
+ const ba = Buffer.from(a);
43
+ const bb = Buffer.from(b);
44
+ return ba.length === bb.length && (0, import_node_crypto.timingSafeEqual)(ba, bb);
45
+ }
46
+ var BYOB_INFO = "fluxfiles-byob-enc";
47
+ var NONCE_LEN = 12;
48
+ var TAG_LEN = 16;
49
+ function deriveByobKey(secret) {
50
+ const salt = Buffer.alloc(32, 0);
51
+ const dk = (0, import_node_crypto.hkdfSync)("sha256", Buffer.from(secret, "utf8"), salt, Buffer.from(BYOB_INFO, "utf8"), 32);
52
+ return Buffer.from(dk);
53
+ }
54
+ function encryptByob(config, secret) {
55
+ const key = deriveByobKey(secret);
56
+ const nonce = (0, import_node_crypto.randomBytes)(NONCE_LEN);
57
+ const cipher = (0, import_node_crypto.createCipheriv)("aes-256-gcm", key, nonce, { authTagLength: TAG_LEN });
58
+ const plaintext = JSON.stringify(config);
59
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
60
+ const tag = cipher.getAuthTag();
61
+ return Buffer.concat([nonce, ct, tag]).toString("base64");
62
+ }
63
+
64
+ // src/token.ts
65
+ var MIN_SECRET_BYTES = 32;
66
+ function resolveSecret(explicit) {
67
+ const secret = explicit ?? process.env.FLUXFILES_SECRET ?? "";
68
+ if (Buffer.byteLength(secret, "utf8") < MIN_SECRET_BYTES) {
69
+ throw new Error(
70
+ `FluxFiles: signing secret must be at least ${MIN_SECRET_BYTES} bytes (HS256 key requirement). Set \`secret\` or FLUXFILES_SECRET.`
71
+ );
72
+ }
73
+ return secret;
74
+ }
75
+ function sign(payload, secret) {
76
+ const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
77
+ const body = base64url(JSON.stringify(payload));
78
+ const sig = base64url(hmacSha256(`${header}.${body}`, secret));
79
+ return `${header}.${body}.${sig}`;
80
+ }
81
+ function newJti() {
82
+ return (0, import_node_crypto2.randomBytes)(12).toString("hex");
83
+ }
84
+ function createToken(opts) {
85
+ const secret = resolveSecret(opts.secret);
86
+ const now = Math.floor(Date.now() / 1e3);
87
+ const payload = {
88
+ sub: opts.userId,
89
+ iat: now,
90
+ exp: now + (opts.ttl ?? 3600),
91
+ jti: newJti(),
92
+ perms: opts.perms ?? ["read"],
93
+ disks: opts.disks ?? ["local"],
94
+ prefix: opts.prefix ?? "",
95
+ max_upload: opts.maxUploadMb ?? 10,
96
+ allowed_ext: opts.allowedExt ?? null,
97
+ max_storage: opts.maxStorageMb ?? 0,
98
+ max_files: opts.maxFiles ?? 0
99
+ };
100
+ if (opts.ownerOnly) payload.owner_only = true;
101
+ return sign(payload, secret);
102
+ }
103
+ function createByobToken(opts) {
104
+ const secret = resolveSecret(opts.secret);
105
+ const now = Math.floor(Date.now() / 1e3);
106
+ const encrypted = {};
107
+ const names = [];
108
+ for (const [name, config] of Object.entries(opts.byobDisks)) {
109
+ validateByobDisk(name, config);
110
+ encrypted[name] = encryptByob(config, secret);
111
+ names.push(name);
112
+ }
113
+ const payload = {
114
+ sub: opts.userId,
115
+ iat: now,
116
+ exp: now + (opts.ttl ?? 1800),
117
+ jti: newJti(),
118
+ perms: opts.perms ?? ["read", "write"],
119
+ disks: names,
120
+ prefix: opts.prefix ?? "",
121
+ max_upload: opts.maxUploadMb ?? 10,
122
+ allowed_ext: opts.allowedExt ?? null,
123
+ byob_disks: encrypted
124
+ };
125
+ if (opts.ownerOnly) payload.owner_only = true;
126
+ return sign(payload, secret);
127
+ }
128
+ function validateByobDisk(name, config) {
129
+ if (!config || config.driver !== "s3") {
130
+ throw new Error(`FluxFiles BYOB disk "${name}": driver must be "s3" (the server rejects "local").`);
131
+ }
132
+ for (const field of ["key", "secret", "bucket"]) {
133
+ if (!config[field]) {
134
+ throw new Error(`FluxFiles BYOB disk "${name}": missing required "${field}".`);
135
+ }
136
+ }
137
+ }
138
+
139
+ // src/verify.ts
140
+ function decodeSegment(seg) {
141
+ return JSON.parse(Buffer.from(seg, "base64url").toString("utf8"));
142
+ }
143
+ function decodeToken(token) {
144
+ const parts = token.split(".");
145
+ if (parts.length !== 3) throw new Error("FluxFiles: malformed token");
146
+ return decodeSegment(parts[1]);
147
+ }
148
+ function verifyToken(token, secret) {
149
+ const key = secret ?? process.env.FLUXFILES_SECRET ?? "";
150
+ if (!key) throw new Error("FluxFiles: no secret provided to verifyToken");
151
+ const parts = token.split(".");
152
+ if (parts.length !== 3) throw new Error("FluxFiles: malformed token");
153
+ const [header, body, sig] = parts;
154
+ const alg = decodeSegment(header).alg;
155
+ if (alg !== "HS256") throw new Error(`FluxFiles: unexpected JWT alg "${alg}"`);
156
+ const expected = base64url(hmacSha256(`${header}.${body}`, key));
157
+ if (!safeEqual(sig, expected)) throw new Error("FluxFiles: invalid token signature");
158
+ const claims = decodeSegment(body);
159
+ if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1e3)) {
160
+ throw new Error("FluxFiles: token has expired");
161
+ }
162
+ return claims;
163
+ }
164
+ // Annotate the CommonJS export names for ESM import in node:
165
+ 0 && (module.exports = {
166
+ createByobToken,
167
+ createToken,
168
+ decodeToken,
169
+ verifyToken
170
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,147 @@
1
+ // src/token.ts
2
+ import { randomBytes as randomBytes2 } from "crypto";
3
+
4
+ // src/crypto.ts
5
+ import {
6
+ createHmac,
7
+ hkdfSync,
8
+ randomBytes,
9
+ createCipheriv,
10
+ createDecipheriv,
11
+ timingSafeEqual
12
+ } from "crypto";
13
+ function base64url(input) {
14
+ return Buffer.from(input).toString("base64url");
15
+ }
16
+ function hmacSha256(data, secret) {
17
+ return createHmac("sha256", secret).update(data).digest();
18
+ }
19
+ function safeEqual(a, b) {
20
+ const ba = Buffer.from(a);
21
+ const bb = Buffer.from(b);
22
+ return ba.length === bb.length && timingSafeEqual(ba, bb);
23
+ }
24
+ var BYOB_INFO = "fluxfiles-byob-enc";
25
+ var NONCE_LEN = 12;
26
+ var TAG_LEN = 16;
27
+ function deriveByobKey(secret) {
28
+ const salt = Buffer.alloc(32, 0);
29
+ const dk = hkdfSync("sha256", Buffer.from(secret, "utf8"), salt, Buffer.from(BYOB_INFO, "utf8"), 32);
30
+ return Buffer.from(dk);
31
+ }
32
+ function encryptByob(config, secret) {
33
+ const key = deriveByobKey(secret);
34
+ const nonce = randomBytes(NONCE_LEN);
35
+ const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: TAG_LEN });
36
+ const plaintext = JSON.stringify(config);
37
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
38
+ const tag = cipher.getAuthTag();
39
+ return Buffer.concat([nonce, ct, tag]).toString("base64");
40
+ }
41
+
42
+ // src/token.ts
43
+ var MIN_SECRET_BYTES = 32;
44
+ function resolveSecret(explicit) {
45
+ const secret = explicit ?? process.env.FLUXFILES_SECRET ?? "";
46
+ if (Buffer.byteLength(secret, "utf8") < MIN_SECRET_BYTES) {
47
+ throw new Error(
48
+ `FluxFiles: signing secret must be at least ${MIN_SECRET_BYTES} bytes (HS256 key requirement). Set \`secret\` or FLUXFILES_SECRET.`
49
+ );
50
+ }
51
+ return secret;
52
+ }
53
+ function sign(payload, secret) {
54
+ const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
55
+ const body = base64url(JSON.stringify(payload));
56
+ const sig = base64url(hmacSha256(`${header}.${body}`, secret));
57
+ return `${header}.${body}.${sig}`;
58
+ }
59
+ function newJti() {
60
+ return randomBytes2(12).toString("hex");
61
+ }
62
+ function createToken(opts) {
63
+ const secret = resolveSecret(opts.secret);
64
+ const now = Math.floor(Date.now() / 1e3);
65
+ const payload = {
66
+ sub: opts.userId,
67
+ iat: now,
68
+ exp: now + (opts.ttl ?? 3600),
69
+ jti: newJti(),
70
+ perms: opts.perms ?? ["read"],
71
+ disks: opts.disks ?? ["local"],
72
+ prefix: opts.prefix ?? "",
73
+ max_upload: opts.maxUploadMb ?? 10,
74
+ allowed_ext: opts.allowedExt ?? null,
75
+ max_storage: opts.maxStorageMb ?? 0,
76
+ max_files: opts.maxFiles ?? 0
77
+ };
78
+ if (opts.ownerOnly) payload.owner_only = true;
79
+ return sign(payload, secret);
80
+ }
81
+ function createByobToken(opts) {
82
+ const secret = resolveSecret(opts.secret);
83
+ const now = Math.floor(Date.now() / 1e3);
84
+ const encrypted = {};
85
+ const names = [];
86
+ for (const [name, config] of Object.entries(opts.byobDisks)) {
87
+ validateByobDisk(name, config);
88
+ encrypted[name] = encryptByob(config, secret);
89
+ names.push(name);
90
+ }
91
+ const payload = {
92
+ sub: opts.userId,
93
+ iat: now,
94
+ exp: now + (opts.ttl ?? 1800),
95
+ jti: newJti(),
96
+ perms: opts.perms ?? ["read", "write"],
97
+ disks: names,
98
+ prefix: opts.prefix ?? "",
99
+ max_upload: opts.maxUploadMb ?? 10,
100
+ allowed_ext: opts.allowedExt ?? null,
101
+ byob_disks: encrypted
102
+ };
103
+ if (opts.ownerOnly) payload.owner_only = true;
104
+ return sign(payload, secret);
105
+ }
106
+ function validateByobDisk(name, config) {
107
+ if (!config || config.driver !== "s3") {
108
+ throw new Error(`FluxFiles BYOB disk "${name}": driver must be "s3" (the server rejects "local").`);
109
+ }
110
+ for (const field of ["key", "secret", "bucket"]) {
111
+ if (!config[field]) {
112
+ throw new Error(`FluxFiles BYOB disk "${name}": missing required "${field}".`);
113
+ }
114
+ }
115
+ }
116
+
117
+ // src/verify.ts
118
+ function decodeSegment(seg) {
119
+ return JSON.parse(Buffer.from(seg, "base64url").toString("utf8"));
120
+ }
121
+ function decodeToken(token) {
122
+ const parts = token.split(".");
123
+ if (parts.length !== 3) throw new Error("FluxFiles: malformed token");
124
+ return decodeSegment(parts[1]);
125
+ }
126
+ function verifyToken(token, secret) {
127
+ const key = secret ?? process.env.FLUXFILES_SECRET ?? "";
128
+ if (!key) throw new Error("FluxFiles: no secret provided to verifyToken");
129
+ const parts = token.split(".");
130
+ if (parts.length !== 3) throw new Error("FluxFiles: malformed token");
131
+ const [header, body, sig] = parts;
132
+ const alg = decodeSegment(header).alg;
133
+ if (alg !== "HS256") throw new Error(`FluxFiles: unexpected JWT alg "${alg}"`);
134
+ const expected = base64url(hmacSha256(`${header}.${body}`, key));
135
+ if (!safeEqual(sig, expected)) throw new Error("FluxFiles: invalid token signature");
136
+ const claims = decodeSegment(body);
137
+ if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1e3)) {
138
+ throw new Error("FluxFiles: token has expired");
139
+ }
140
+ return claims;
141
+ }
142
+ export {
143
+ createByobToken,
144
+ createToken,
145
+ decodeToken,
146
+ verifyToken
147
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@fluxfiles/node",
3
+ "version": "0.1.0",
4
+ "description": "Server-side Node/TypeScript SDK for minting FluxFiles JWTs (plain + BYOB), byte-compatible with the PHP core",
5
+ "license": "MIT",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.mjs",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "src"
19
+ ],
20
+ "engines": {
21
+ "node": ">=16"
22
+ },
23
+ "scripts": {
24
+ "build": "tsup",
25
+ "dev": "tsup --watch",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "vitest run",
28
+ "prepublishOnly": "npm run build"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^20.0.0",
32
+ "tsup": "^8.0.0",
33
+ "typescript": "^5.0.0",
34
+ "vitest": "^2.1.8"
35
+ },
36
+ "author": "thai-pc",
37
+ "homepage": "https://github.com/thai-pc/fluxfiles#node-server-side-token-sdk",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/thai-pc/fluxfiles.git",
41
+ "directory": "packages/node"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/thai-pc/fluxfiles/issues"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "keywords": [
50
+ "fluxfiles",
51
+ "jwt",
52
+ "token",
53
+ "file-manager",
54
+ "s3",
55
+ "r2",
56
+ "byob",
57
+ "server"
58
+ ]
59
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,69 @@
1
+ import {
2
+ createHmac,
3
+ hkdfSync,
4
+ randomBytes,
5
+ createCipheriv,
6
+ createDecipheriv,
7
+ timingSafeEqual,
8
+ } from 'node:crypto';
9
+
10
+ /** base64url-encode without padding (JWT segment encoding). */
11
+ export function base64url(input: Buffer | string): string {
12
+ return Buffer.from(input).toString('base64url');
13
+ }
14
+
15
+ export function hmacSha256(data: string, secret: string): Buffer {
16
+ return createHmac('sha256', secret).update(data).digest();
17
+ }
18
+
19
+ /** Constant-time comparison of two base64url signatures. */
20
+ export function safeEqual(a: string, b: string): boolean {
21
+ const ba = Buffer.from(a);
22
+ const bb = Buffer.from(b);
23
+ return ba.length === bb.length && timingSafeEqual(ba, bb);
24
+ }
25
+
26
+ // ── BYOB credential encryption (matches PHP CredentialEncryptor) ──────────────
27
+ // AES-256-GCM. Key = HKDF-SHA256(ikm = secret, salt = 32 zero bytes,
28
+ // info = "fluxfiles-byob-enc", len = 32). Blob = base64(nonce[12] | ct | tag[16]).
29
+ const BYOB_INFO = 'fluxfiles-byob-enc';
30
+ const NONCE_LEN = 12;
31
+ const TAG_LEN = 16;
32
+
33
+ /**
34
+ * Derive the BYOB key exactly like PHP `hash_hkdf('sha256', $secret, 32, $info)`.
35
+ * PHP's empty HKDF salt means "HashLen zero bytes" (RFC 5869), so we pass an
36
+ * explicit 32-byte zero salt — passing an empty Buffer to Node's hkdfSync would
37
+ * NOT match.
38
+ */
39
+ function deriveByobKey(secret: string): Buffer {
40
+ const salt = Buffer.alloc(32, 0);
41
+ const dk = hkdfSync('sha256', Buffer.from(secret, 'utf8'), salt, Buffer.from(BYOB_INFO, 'utf8'), 32);
42
+ return Buffer.from(dk);
43
+ }
44
+
45
+ export function encryptByob(config: unknown, secret: string): string {
46
+ const key = deriveByobKey(secret);
47
+ const nonce = randomBytes(NONCE_LEN);
48
+ const cipher = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: TAG_LEN });
49
+ // JSON.stringify does not escape "/", matching PHP's JSON_UNESCAPED_SLASHES.
50
+ const plaintext = JSON.stringify(config);
51
+ const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
52
+ const tag = cipher.getAuthTag();
53
+ return Buffer.concat([nonce, ct, tag]).toString('base64');
54
+ }
55
+
56
+ export function decryptByob<T = unknown>(blob: string, secret: string): T {
57
+ const key = deriveByobKey(secret);
58
+ const raw = Buffer.from(blob, 'base64');
59
+ if (raw.length < NONCE_LEN + TAG_LEN + 1) {
60
+ throw new Error('FluxFiles: invalid BYOB credential blob');
61
+ }
62
+ const nonce = raw.subarray(0, NONCE_LEN);
63
+ const tag = raw.subarray(raw.length - TAG_LEN);
64
+ const ct = raw.subarray(NONCE_LEN, raw.length - TAG_LEN);
65
+ const decipher = createDecipheriv('aes-256-gcm', key, nonce, { authTagLength: TAG_LEN });
66
+ decipher.setAuthTag(tag);
67
+ const pt = Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
68
+ return JSON.parse(pt) as T;
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { createToken, createByobToken } from './token';
2
+ export { verifyToken, decodeToken } from './verify';
3
+ export type {
4
+ CreateTokenOptions,
5
+ CreateByobTokenOptions,
6
+ ByobDiskConfig,
7
+ BaseTokenOptions,
8
+ FluxPermission,
9
+ FluxClaims,
10
+ } from './types';
package/src/token.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { base64url, hmacSha256, encryptByob } from './crypto';
3
+ import type { ByobDiskConfig, CreateByobTokenOptions, CreateTokenOptions } from './types';
4
+
5
+ const MIN_SECRET_BYTES = 32;
6
+
7
+ function resolveSecret(explicit?: string): string {
8
+ const secret = explicit ?? process.env.FLUXFILES_SECRET ?? '';
9
+ if (Buffer.byteLength(secret, 'utf8') < MIN_SECRET_BYTES) {
10
+ throw new Error(
11
+ `FluxFiles: signing secret must be at least ${MIN_SECRET_BYTES} bytes ` +
12
+ '(HS256 key requirement). Set `secret` or FLUXFILES_SECRET.',
13
+ );
14
+ }
15
+ return secret;
16
+ }
17
+
18
+ /** Sign a payload as a compact HS256 JWT. */
19
+ function sign(payload: Record<string, unknown>, secret: string): string {
20
+ const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
21
+ const body = base64url(JSON.stringify(payload));
22
+ const sig = base64url(hmacSha256(`${header}.${body}`, secret));
23
+ return `${header}.${body}.${sig}`;
24
+ }
25
+
26
+ function newJti(): string {
27
+ return randomBytes(12).toString('hex');
28
+ }
29
+
30
+ /**
31
+ * Mint a standard FluxFiles JWT. The payload mirrors the PHP `fluxfiles_token()`
32
+ * helper exactly, so the FluxFiles core decodes it natively.
33
+ */
34
+ export function createToken(opts: CreateTokenOptions): string {
35
+ const secret = resolveSecret(opts.secret);
36
+ const now = Math.floor(Date.now() / 1000);
37
+ const payload: Record<string, unknown> = {
38
+ sub: opts.userId,
39
+ iat: now,
40
+ exp: now + (opts.ttl ?? 3600),
41
+ jti: newJti(),
42
+ perms: opts.perms ?? ['read'],
43
+ disks: opts.disks ?? ['local'],
44
+ prefix: opts.prefix ?? '',
45
+ max_upload: opts.maxUploadMb ?? 10,
46
+ allowed_ext: opts.allowedExt ?? null,
47
+ max_storage: opts.maxStorageMb ?? 0,
48
+ max_files: opts.maxFiles ?? 0,
49
+ };
50
+ if (opts.ownerOnly) payload.owner_only = true;
51
+ return sign(payload, secret);
52
+ }
53
+
54
+ /**
55
+ * Mint a BYOB token. Each disk's S3-compatible credentials are AES-256-GCM
56
+ * encrypted into the token (decrypted only at runtime by the server). Mirrors
57
+ * the PHP `fluxfiles_byob_token()` helper.
58
+ */
59
+ export function createByobToken(opts: CreateByobTokenOptions): string {
60
+ const secret = resolveSecret(opts.secret);
61
+ const now = Math.floor(Date.now() / 1000);
62
+
63
+ const encrypted: Record<string, string> = {};
64
+ const names: string[] = [];
65
+ for (const [name, config] of Object.entries(opts.byobDisks)) {
66
+ validateByobDisk(name, config);
67
+ encrypted[name] = encryptByob(config, secret);
68
+ names.push(name);
69
+ }
70
+
71
+ const payload: Record<string, unknown> = {
72
+ sub: opts.userId,
73
+ iat: now,
74
+ exp: now + (opts.ttl ?? 1800),
75
+ jti: newJti(),
76
+ perms: opts.perms ?? ['read', 'write'],
77
+ disks: names,
78
+ prefix: opts.prefix ?? '',
79
+ max_upload: opts.maxUploadMb ?? 10,
80
+ allowed_ext: opts.allowedExt ?? null,
81
+ byob_disks: encrypted,
82
+ };
83
+ if (opts.ownerOnly) payload.owner_only = true;
84
+ return sign(payload, secret);
85
+ }
86
+
87
+ /**
88
+ * Light client-side validation. The server independently re-validates (incl.
89
+ * SSRF checks on the endpoint), so this only catches obvious mistakes early.
90
+ */
91
+ function validateByobDisk(name: string, config: ByobDiskConfig): void {
92
+ if (!config || config.driver !== 's3') {
93
+ throw new Error(`FluxFiles BYOB disk "${name}": driver must be "s3" (the server rejects "local").`);
94
+ }
95
+ for (const field of ['key', 'secret', 'bucket'] as const) {
96
+ if (!config[field]) {
97
+ throw new Error(`FluxFiles BYOB disk "${name}": missing required "${field}".`);
98
+ }
99
+ }
100
+ }
package/src/types.ts ADDED
@@ -0,0 +1,68 @@
1
+ /** A FluxFiles permission. */
2
+ export type FluxPermission = 'read' | 'write' | 'delete';
3
+
4
+ /**
5
+ * A BYOB (Bring Your Own Bucket) disk config. Encrypted into the JWT and
6
+ * decrypted only at runtime by the FluxFiles server. Only S3-compatible
7
+ * storage is allowed — the server rejects the `local` driver.
8
+ */
9
+ export interface ByobDiskConfig {
10
+ driver: 's3';
11
+ key: string;
12
+ secret: string;
13
+ bucket: string;
14
+ region?: string;
15
+ /** Custom S3 endpoint (R2, MinIO, Spaces, …). Omit for native AWS S3. */
16
+ endpoint?: string;
17
+ visibility?: 'private' | 'public';
18
+ /** Public base URL for direct (unsigned) object links on a public disk. */
19
+ public_url?: string;
20
+ }
21
+
22
+ /** Options shared by all token builders. */
23
+ export interface BaseTokenOptions {
24
+ /** HS256 signing secret. Defaults to `process.env.FLUXFILES_SECRET`. Must be ≥ 32 bytes. */
25
+ secret?: string;
26
+ /** Subject — your application's user id. */
27
+ userId: string;
28
+ perms?: FluxPermission[];
29
+ /** Path prefix the user is scoped to (e.g. `users/42`). */
30
+ prefix?: string;
31
+ maxUploadMb?: number;
32
+ /** Allowed extensions (lowercase, no dot). `null`/omitted = all non-dangerous types. */
33
+ allowedExt?: string[] | null;
34
+ /** Time-to-live in seconds. */
35
+ ttl?: number;
36
+ /** Restrict destructive ops to files the user uploaded. */
37
+ ownerOnly?: boolean;
38
+ }
39
+
40
+ export interface CreateTokenOptions extends BaseTokenOptions {
41
+ /** Disk names the token may access. */
42
+ disks?: string[];
43
+ maxStorageMb?: number;
44
+ /** Total file count cap under the prefix. `0` = unlimited. */
45
+ maxFiles?: number;
46
+ }
47
+
48
+ export interface CreateByobTokenOptions extends BaseTokenOptions {
49
+ /** Map of disk name → S3-compatible credentials, encrypted into the token. */
50
+ byobDisks: Record<string, ByobDiskConfig>;
51
+ }
52
+
53
+ /** Decoded JWT payload (snake_case, as emitted by the PHP core). */
54
+ export interface FluxClaims {
55
+ sub: string;
56
+ iat: number;
57
+ exp: number;
58
+ jti: string;
59
+ perms: string[];
60
+ disks: string[];
61
+ prefix: string;
62
+ max_upload: number;
63
+ allowed_ext: string[] | null;
64
+ max_storage?: number;
65
+ max_files?: number;
66
+ owner_only?: boolean;
67
+ byob_disks?: Record<string, string>;
68
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { base64url, hmacSha256, safeEqual } from './crypto';
2
+ import type { FluxClaims } from './types';
3
+
4
+ function decodeSegment<T>(seg: string): T {
5
+ return JSON.parse(Buffer.from(seg, 'base64url').toString('utf8')) as T;
6
+ }
7
+
8
+ /**
9
+ * Decode a token WITHOUT verifying the signature. Useful for inspection/logging;
10
+ * never trust the result for authorization — use {@link verifyToken}.
11
+ */
12
+ export function decodeToken(token: string): FluxClaims {
13
+ const parts = token.split('.');
14
+ if (parts.length !== 3) throw new Error('FluxFiles: malformed token');
15
+ return decodeSegment<FluxClaims>(parts[1]);
16
+ }
17
+
18
+ /**
19
+ * Verify an HS256 FluxFiles token: signature + expiry. Returns the decoded
20
+ * claims, or throws on a bad signature, wrong algorithm, or an expired token.
21
+ */
22
+ export function verifyToken(token: string, secret?: string): FluxClaims {
23
+ const key = secret ?? process.env.FLUXFILES_SECRET ?? '';
24
+ if (!key) throw new Error('FluxFiles: no secret provided to verifyToken');
25
+
26
+ const parts = token.split('.');
27
+ if (parts.length !== 3) throw new Error('FluxFiles: malformed token');
28
+ const [header, body, sig] = parts;
29
+
30
+ const alg = decodeSegment<{ alg?: string }>(header).alg;
31
+ if (alg !== 'HS256') throw new Error(`FluxFiles: unexpected JWT alg "${alg}"`);
32
+
33
+ const expected = base64url(hmacSha256(`${header}.${body}`, key));
34
+ if (!safeEqual(sig, expected)) throw new Error('FluxFiles: invalid token signature');
35
+
36
+ const claims = decodeSegment<FluxClaims>(body);
37
+ if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) {
38
+ throw new Error('FluxFiles: token has expired');
39
+ }
40
+ return claims;
41
+ }