@natilon/cms-server 0.3.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natilon/cms-server",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
4
4
  "description": "Express-based CMS server with pluggable adapters for content, media, auth, and build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "bin"
36
36
  ],
37
37
  "dependencies": {
38
- "@natilon/admin-ui": "^0.2.0",
38
+ "@natilon/admin-ui": ">=0.5.0",
39
39
  "express": "^4.21.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -21,11 +21,28 @@ function safeEq(a, b) {
21
21
  export function createBasicAuth({
22
22
  user,
23
23
  pass,
24
+ users,
24
25
  jwtSecret,
25
26
  jwtTtl,
26
27
  realm = "Admin",
27
28
  }) {
28
- const configured = Boolean(pass);
29
+ // Normalise: prefer `users` array, fall back to single user/pass pair.
30
+ // Each entry may use `pass` (plaintext) or `passEnv` (env var name).
31
+ const userList = (users?.length
32
+ ? users
33
+ : pass ? [{ user: user || "admin", pass, role: "admin" }] : []
34
+ ).map((entry) => ({
35
+ ...entry,
36
+ pass: entry.passEnv ? process.env[entry.passEnv] : entry.pass,
37
+ }));
38
+
39
+ const configured = userList.length > 0;
40
+
41
+ function findUser(u, p) {
42
+ return userList.find(
43
+ (entry) => safeEq(u, entry.user) && safeEq(p, entry.pass),
44
+ ) ?? null;
45
+ }
29
46
 
30
47
  function issueMediaToken(tenantId) {
31
48
  if (!jwtSecret) throw new Error("JWT_SECRET not set");
@@ -55,11 +72,13 @@ export function createBasicAuth({
55
72
  res.set("WWW-Authenticate", `Basic realm="${realm}"`);
56
73
  return res.status(401).send("Authentication required");
57
74
  }
58
- const [u, p] = Buffer.from(auth.slice(6), "base64")
59
- .toString()
60
- .split(":");
61
- if (safeEq(u, user) && safeEq(p, pass)) {
62
- req.cmsUser = { login: user, role: "admin" };
75
+ const decoded = Buffer.from(auth.slice(6), "base64").toString();
76
+ const colon = decoded.indexOf(":");
77
+ const u = decoded.slice(0, colon);
78
+ const p = decoded.slice(colon + 1);
79
+ const entry = findUser(u, p);
80
+ if (entry) {
81
+ req.cmsUser = { login: entry.user, name: entry.name || entry.user, role: entry.role || "editor" };
63
82
  return next();
64
83
  }
65
84
  res.set("WWW-Authenticate", `Basic realm="${realm}"`);
@@ -68,14 +87,15 @@ export function createBasicAuth({
68
87
  }
69
88
 
70
89
  function verify(authHeader) {
71
- if (!configured) return { login: user, role: "admin" };
90
+ if (!configured) return { login: "admin", role: "admin" };
72
91
  if (!authHeader?.startsWith("Basic ")) return null;
73
92
  const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
74
93
  const colon = decoded.indexOf(":");
75
94
  const u = decoded.slice(0, colon);
76
95
  const p = decoded.slice(colon + 1);
77
- if (safeEq(u, user) && safeEq(p, pass)) return { login: user, role: "admin" };
78
- return null;
96
+ const entry = findUser(u, p);
97
+ if (!entry) return null;
98
+ return { login: entry.user, name: entry.name || entry.user, role: entry.role || "editor" };
79
99
  }
80
100
 
81
101
  return {
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Cloudflare Access auth adapter.
3
+ *
4
+ * Verifies the `Cf-Access-Jwt-Assertion` header (or `CF_Authorization` cookie)
5
+ * signed by your Cloudflare Access team using RS256. No third-party libraries —
6
+ * uses Node's built-in `crypto` module.
7
+ *
8
+ * cms.config.mjs:
9
+ * auth: {
10
+ * provider: "cloudflare-access",
11
+ * teamDomain: "https://yourteam.cloudflareaccess.com",
12
+ * audience: "your-application-audience-tag", // from Access app settings
13
+ * roles: { // optional: map email → role
14
+ * "fatih@example.com": "admin",
15
+ * },
16
+ * defaultRole: "editor", // role when email not in roles map
17
+ * }
18
+ */
19
+
20
+ import crypto from "crypto";
21
+
22
+ const JWKS_TTL_MS = 60 * 60 * 1000; // 1 hour
23
+
24
+ /**
25
+ * @param {Object} opts
26
+ * @param {string} opts.teamDomain e.g. "https://yourteam.cloudflareaccess.com"
27
+ * @param {string} opts.audience Application Audience tag from Cloudflare Access
28
+ * @param {Object} [opts.roles] { "email": "admin" | "editor" }
29
+ * @param {string} [opts.defaultRole]
30
+ */
31
+ export function createCloudflareAccess({ teamDomain, audience, roles = {}, defaultRole = "editor" }) {
32
+ const domain = teamDomain.replace(/\/$/, "");
33
+ const certsUrl = `${domain}/cdn-cgi/access/certs`;
34
+
35
+ // JWKS cache
36
+ let jwksCache = null;
37
+ let jwksCachedAt = 0;
38
+
39
+ async function getJwks() {
40
+ if (jwksCache && Date.now() - jwksCachedAt < JWKS_TTL_MS) return jwksCache;
41
+ const res = await fetch(certsUrl);
42
+ if (!res.ok) throw new Error(`Failed to fetch Cloudflare Access JWKS: ${res.status}`);
43
+ jwksCache = await res.json();
44
+ jwksCachedAt = Date.now();
45
+ return jwksCache;
46
+ }
47
+
48
+ function base64urlDecode(str) {
49
+ return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
50
+ }
51
+
52
+ function importPublicKey(jwk) {
53
+ // Cloudflare Access JWKS uses x5c (certificate chain) or n/e (RSA components).
54
+ if (jwk.x5c?.length) {
55
+ const pem = `-----BEGIN CERTIFICATE-----\n${jwk.x5c[0].match(/.{1,64}/g).join("\n")}\n-----END CERTIFICATE-----`;
56
+ return crypto.createPublicKey(pem);
57
+ }
58
+ if (jwk.n && jwk.e) {
59
+ return crypto.createPublicKey({ key: jwk, format: "jwk" });
60
+ }
61
+ throw new Error("Unsupported JWK format — no x5c or n/e fields");
62
+ }
63
+
64
+ async function verifyToken(token) {
65
+ const parts = token.split(".");
66
+ if (parts.length !== 3) throw new Error("Invalid JWT format");
67
+
68
+ const [headerB64, payloadB64, sigB64] = parts;
69
+ const header = JSON.parse(base64urlDecode(headerB64).toString());
70
+ const payload = JSON.parse(base64urlDecode(payloadB64).toString());
71
+
72
+ // Claim validation
73
+ const now = Math.floor(Date.now() / 1000);
74
+ if (payload.exp && payload.exp < now) throw new Error("JWT expired");
75
+ if (payload.nbf && payload.nbf > now) throw new Error("JWT not yet valid");
76
+ if (payload.iss !== domain) throw new Error(`JWT issuer mismatch: ${payload.iss}`);
77
+ if (audience && payload.aud !== audience && !payload.aud?.includes?.(audience)) {
78
+ throw new Error("JWT audience mismatch");
79
+ }
80
+
81
+ // Signature verification
82
+ const jwks = await getJwks();
83
+ const jwk = jwks.keys?.find((k) => k.kid === header.kid) ?? jwks.keys?.[0];
84
+ if (!jwk) throw new Error("No matching JWK found");
85
+
86
+ const pubKey = importPublicKey(jwk);
87
+ const verify = crypto.createVerify("RSA-SHA256");
88
+ verify.update(`${headerB64}.${payloadB64}`);
89
+ const valid = verify.verify(pubKey, base64urlDecode(sigB64));
90
+ if (!valid) throw new Error("JWT signature invalid");
91
+
92
+ return payload;
93
+ }
94
+
95
+ function extractToken(req) {
96
+ const header = req.headers["cf-access-jwt-assertion"];
97
+ if (header) return header;
98
+ // Fall back to cookie
99
+ const cookie = req.headers.cookie || "";
100
+ const match = cookie.match(/(?:^|;\s*)CF_Authorization=([^;]+)/);
101
+ return match ? match[1] : null;
102
+ }
103
+
104
+ function resolveUser(payload) {
105
+ const email = payload.email || payload.sub;
106
+ const role = roles[email] ?? defaultRole;
107
+ return { login: email, name: email, role };
108
+ }
109
+
110
+ function middlewareWith(allowlist) {
111
+ return async (req, res, next) => {
112
+ if (allowlist && allowlist(req)) return next();
113
+ const token = extractToken(req);
114
+ if (!token) {
115
+ return res.status(401).json({ error: "Cloudflare Access token required" });
116
+ }
117
+ try {
118
+ const payload = await verifyToken(token);
119
+ req.cmsUser = resolveUser(payload);
120
+ return next();
121
+ } catch (err) {
122
+ return res.status(401).json({ error: `Access denied: ${err.message}` });
123
+ }
124
+ };
125
+ }
126
+
127
+ async function verify(authHeader) {
128
+ // authHeader unused — Cloudflare Access uses its own header/cookie
129
+ return null;
130
+ }
131
+
132
+ return {
133
+ configured: Boolean(teamDomain && audience),
134
+ middleware: middlewareWith(null),
135
+ middlewareWith,
136
+ verify,
137
+ // issueMediaToken not supported for Cloudflare Access — use Cloudflare's own CDN auth
138
+ issueMediaToken: () => { throw new Error("issueMediaToken not supported for cloudflare-access"); },
139
+ };
140
+ }
@@ -6,5 +6,6 @@ export { createLocalAssetsMedia } from "./local-assets-media.mjs";
6
6
  export { createCdnProxyMedia } from "./cdn-proxy-media.mjs";
7
7
  export { createBasicAuth } from "./basic-auth.mjs";
8
8
  export { createGitHubOAuth } from "./github-oauth.mjs";
9
+ export { createCloudflareAccess } from "./cloudflare-access.mjs";
9
10
  export { createNetlifyBuild } from "./build-netlify.mjs";
10
11
  export { createMediaUrl } from "./media-url.mjs";
package/src/locks.mjs ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * In-memory content lock store.
3
+ *
4
+ * A lock is keyed by "collection/file" and holds the user who acquired it
5
+ * plus an expiry timestamp. Clients must send a heartbeat every ~30 s;
6
+ * locks that go 60 s without a heartbeat are considered stale and released
7
+ * automatically.
8
+ */
9
+
10
+ const LOCK_TTL_MS = 60_000;
11
+
12
+ /** @type {Map<string, { user: string, name: string, expiresAt: number }>} */
13
+ const locks = new Map();
14
+
15
+ function key(collection, file) {
16
+ return `${collection}/${file}`;
17
+ }
18
+
19
+ function pruneExpired() {
20
+ const now = Date.now();
21
+ for (const [k, lock] of locks) {
22
+ if (lock.expiresAt <= now) locks.delete(k);
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Try to acquire (or renew) a lock.
28
+ * Returns `{ ok: true, lock }` on success, `{ ok: false, lock }` if held by
29
+ * someone else.
30
+ */
31
+ export function acquireLock(collection, file, userLogin, userName) {
32
+ pruneExpired();
33
+ const k = key(collection, file);
34
+ const existing = locks.get(k);
35
+ if (existing && existing.user !== userLogin) {
36
+ return { ok: false, lock: existing };
37
+ }
38
+ const lock = { user: userLogin, name: userName || userLogin, expiresAt: Date.now() + LOCK_TTL_MS };
39
+ locks.set(k, lock);
40
+ return { ok: true, lock };
41
+ }
42
+
43
+ /** Renew an existing lock. Returns false if not held by this user. */
44
+ export function renewLock(collection, file, userLogin) {
45
+ pruneExpired();
46
+ const k = key(collection, file);
47
+ const existing = locks.get(k);
48
+ if (!existing || existing.user !== userLogin) return false;
49
+ existing.expiresAt = Date.now() + LOCK_TTL_MS;
50
+ return true;
51
+ }
52
+
53
+ /** Release a lock. No-op if not held or held by someone else. */
54
+ export function releaseLock(collection, file, userLogin) {
55
+ const k = key(collection, file);
56
+ const existing = locks.get(k);
57
+ if (existing && existing.user === userLogin) locks.delete(k);
58
+ }
59
+
60
+ /** Check who holds a lock, or null if free. */
61
+ export function getLock(collection, file) {
62
+ pruneExpired();
63
+ return locks.get(key(collection, file)) ?? null;
64
+ }
package/src/routes.mjs CHANGED
@@ -24,6 +24,8 @@
24
24
  */
25
25
 
26
26
 
27
+ import { acquireLock, renewLock, releaseLock, getLock } from "./locks.mjs";
28
+
27
29
  function ok(json) {
28
30
  return { json };
29
31
  }
@@ -87,7 +89,12 @@ export const apiRoutes = [
87
89
  method: "PUT",
88
90
  path: "/api/collections/:collection/:file",
89
91
  auth: "any",
90
- handler: async ({ adapters, params, body }) => {
92
+ handler: async ({ adapters, params, body, user }) => {
93
+ const login = user?.login || "admin";
94
+ const held = getLock(params.collection, params.file);
95
+ if (held && held.user !== login) {
96
+ return { status: 423, json: { error: `Locked by ${held.name}` } };
97
+ }
91
98
  await adapters.content.writePage(params.collection, params.file, body);
92
99
  return ok({ ok: true });
93
100
  },
@@ -125,6 +132,44 @@ export const apiRoutes = [
125
132
  },
126
133
  },
127
134
 
135
+ // ── Locks ──────────────────────────────────────────────────────────────
136
+ {
137
+ method: "POST",
138
+ path: "/api/locks/:collection/:file",
139
+ auth: "any",
140
+ handler: ({ params, user }) => {
141
+ const login = user?.login || "admin";
142
+ const name = user?.name || login;
143
+ const result = acquireLock(params.collection, params.file, login, name);
144
+ if (!result.ok) {
145
+ return { status: 423, json: { error: `Locked by ${result.lock.name}`, lock: result.lock } };
146
+ }
147
+ return ok({ ok: true, lock: result.lock });
148
+ },
149
+ },
150
+
151
+ {
152
+ method: "PUT",
153
+ path: "/api/locks/:collection/:file",
154
+ auth: "any",
155
+ handler: ({ params, user }) => {
156
+ const login = user?.login || "admin";
157
+ const renewed = renewLock(params.collection, params.file, login);
158
+ if (!renewed) return { status: 409, json: { error: "Lock not held" } };
159
+ return ok({ ok: true });
160
+ },
161
+ },
162
+
163
+ {
164
+ method: "DELETE",
165
+ path: "/api/locks/:collection/:file",
166
+ auth: "any",
167
+ handler: ({ params, user }) => {
168
+ releaseLock(params.collection, params.file, user?.login || "admin");
169
+ return ok({ ok: true });
170
+ },
171
+ },
172
+
128
173
  // ── History ────────────────────────────────────────────────────────────
129
174
  {
130
175
  method: "GET",
package/src/server.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  createCdnProxyMedia,
13
13
  createBasicAuth,
14
14
  createGitHubOAuth,
15
+ createCloudflareAccess,
15
16
  createNetlifyBuild,
16
17
  createFsTemplates,
17
18
  createGitHubTemplates,
@@ -61,13 +62,21 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
61
62
  jwtTtl: config.auth.jwtTtl,
62
63
  realm,
63
64
  })
64
- : createBasicAuth({
65
- user: process.env[config.auth.userEnv] || "admin",
66
- pass: process.env[config.auth.passEnv],
67
- jwtSecret: process.env[config.auth.jwtSecretEnv],
68
- jwtTtl: config.auth.jwtTtl,
69
- realm,
70
- });
65
+ : config.auth?.provider === "cloudflare-access"
66
+ ? createCloudflareAccess({
67
+ teamDomain: config.auth.teamDomain,
68
+ audience: config.auth.audience,
69
+ roles: config.auth.roles || {},
70
+ defaultRole: config.auth.defaultRole || "editor",
71
+ })
72
+ : createBasicAuth({
73
+ user: process.env[config.auth.userEnv] || "admin",
74
+ pass: process.env[config.auth.passEnv],
75
+ users: config.auth.users,
76
+ jwtSecret: process.env[config.auth.jwtSecretEnv],
77
+ jwtTtl: config.auth.jwtTtl,
78
+ realm,
79
+ });
71
80
 
72
81
  const cdnMedia = createCdnProxyMedia({
73
82
  baseUrl: config.media.cdnBase,