@stelstone/server 0.28.0 → 0.30.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": "@stelstone/server",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Runtime-agnostic CMS server built on the Web Fetch API, with pluggable adapters for content, media, auth, and build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,4 +1,5 @@
1
1
  import crypto from "crypto";
2
+ import { issueMediaToken as mintMediaToken } from "./media-token.mjs";
2
3
 
3
4
  /**
4
5
  * HTTP Basic auth + HMAC-SHA256 JWT for media tokens.
@@ -24,6 +25,8 @@ export function createBasicAuth({
24
25
  users,
25
26
  jwtSecret,
26
27
  jwtTtl,
28
+ mediaTokenTtl,
29
+ mediaKeyVersion,
27
30
  realm = "Admin",
28
31
  }) {
29
32
  // Normalise: prefer `users` array, fall back to single user/pass pair.
@@ -51,23 +54,17 @@ export function createBasicAuth({
51
54
  }
52
55
 
53
56
  function issueMediaToken(tenantId) {
54
- if (!jwtSecret) throw new Error("JWT_SECRET not set");
55
- const header = Buffer.from(
56
- JSON.stringify({ alg: "HS256", typ: "JWT" }),
57
- ).toString("base64url");
58
- const payload = Buffer.from(
59
- JSON.stringify({
60
- sub: user,
61
- tenant_id: tenantId,
62
- iat: Math.floor(Date.now() / 1000),
63
- exp: Math.floor(Date.now() / 1000) + jwtTtl,
64
- }),
65
- ).toString("base64url");
66
- const sig = crypto
67
- .createHmac("sha256", jwtSecret)
68
- .update(`${header}.${payload}`)
69
- .digest("base64url");
70
- return `${header}.${payload}.${sig}`;
57
+ // Derived per tenant, and short-lived — see adapters/media-token.mjs.
58
+ // It used to be signed with the root secret and live as long as an admin
59
+ // session (8h in some configs), which is both wider and longer than an
60
+ // upload needs.
61
+ return mintMediaToken({
62
+ root: jwtSecret,
63
+ tenantId,
64
+ sub: user,
65
+ ttl: mediaTokenTtl,
66
+ keyVersion: mediaKeyVersion,
67
+ });
71
68
  }
72
69
 
73
70
  /**
@@ -408,13 +408,15 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
408
408
  ? { deferredPublish: true, perEntryPublish: true }
409
409
  : { deferredPublish: false, perEntryPublish: false },
410
410
 
411
- async pendingChanges() {
411
+ /** @param {string} [target] branch to compare against; defaults to the deploy branch */
412
+ async pendingChanges(target) {
412
413
  if (!draftMode) return { hasChanges: false, changedFiles: 0, files: [] };
413
414
  await ensureDraftBranch();
415
+ const against = target || branch;
414
416
  // One compare call: which files differ between published and draft?
415
417
  // (GitHub caps the file list at 300 — orders of magnitude above any
416
418
  // real collection here; still, say so rather than rely on it silently.)
417
- const cmp = await apiGet(`/compare/${branch}...${draftBranch}`);
419
+ const cmp = await apiGet(`/compare/${against}...${draftBranch}`);
418
420
  const files = (cmp?.files ?? [])
419
421
  .filter((f) => f.filename.startsWith(`${pagesDir}/`))
420
422
  // Index manifests live on the draft branch only — the site build
@@ -437,16 +439,22 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
437
439
  * references them by SHA — nothing is re-uploaded. A `removed` status
438
440
  * becomes a tree entry with `sha: null`, which is how git-data deletes.
439
441
  *
442
+ * `target` names the branch to land on, so one panel can push the same
443
+ * drafts to a preview site first and to the live site after. The caller
444
+ * resolves it from the configured list — an arbitrary branch name must
445
+ * never reach here from a request.
446
+ *
440
447
  * @param {string} [message]
441
- * @param {{ entries?: {collection: string, file: string}[] }} [opts]
448
+ * @param {{ entries?: {collection: string, file: string}[], target?: string }} [opts]
442
449
  */
443
- async publish(message, { entries } = {}) {
450
+ async publish(message, { entries, target } = {}) {
451
+ const toBranch = target || branch;
444
452
  if (!draftMode) {
445
453
  // Writes are committed instantly; trigger is external (Netlify webhook on push)
446
454
  return { ok: true, message: "All changes are already committed to GitHub" };
447
455
  }
448
456
  await ensureDraftBranch();
449
- const pending = await this.pendingChanges();
457
+ const pending = await this.pendingChanges(toBranch);
450
458
  if (!pending.hasChanges) return { ok: false, message: "No changes to publish" };
451
459
 
452
460
  const scoped = Array.isArray(entries) && entries.length > 0;
@@ -472,7 +480,7 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
472
480
  let lastErr;
473
481
  for (let attempt = 0; attempt < 2; attempt++) {
474
482
  try {
475
- const refData = await apiGet(`/git/ref/heads/${branch}`);
483
+ const refData = await apiGet(`/git/ref/heads/${toBranch}`);
476
484
  const baseCommitSha = refData.object.sha;
477
485
  const baseCommit = await apiGet(`/git/commits/${baseCommitSha}`);
478
486
  const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
@@ -483,11 +491,11 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
483
491
  tree: newTree.sha,
484
492
  parents: [baseCommitSha],
485
493
  });
486
- await apiPatch(`/git/refs/heads/${branch}`, { sha: newCommit.sha });
494
+ await apiPatch(`/git/refs/heads/${toBranch}`, { sha: newCommit.sha });
487
495
  const shortSha = newCommit.sha.slice(0, 7);
488
496
  return {
489
497
  ok: true,
490
- message: `Published ${targets.length} file(s) to ${branch} (${shortSha})`,
498
+ message: `Published ${targets.length} file(s) to ${toBranch} (${shortSha})`,
491
499
  sha: newCommit.sha,
492
500
  shortSha,
493
501
  branch,
@@ -1,4 +1,5 @@
1
1
  import crypto from "crypto";
2
+ import { issueMediaToken as mintMediaToken } from "./media-token.mjs";
2
3
 
3
4
  /**
4
5
  * GitHub OAuth adapter — drop-in replacement for createBasicAuth.
@@ -37,6 +38,8 @@ export function createGitHubOAuth({
37
38
  roles = {},
38
39
  jwtSecret,
39
40
  jwtTtl = 8 * 60 * 60,
41
+ mediaTokenTtl,
42
+ mediaKeyVersion,
40
43
  defaultRole = "editor",
41
44
  realm = "Admin",
42
45
  }) {
@@ -143,7 +146,14 @@ export function createGitHubOAuth({
143
146
  /** @param {string} capability */
144
147
  supports: (capability) => ["mediaToken", "session", "oauth"].includes(capability),
145
148
  issueMediaToken(tenantId) {
146
- return issueToken({ sub: "media", tenant_id: tenantId, type: "media" });
149
+ // Not issueToken(): that signs a session with the root secret and the
150
+ // session TTL. A media token is derived per tenant and short-lived.
151
+ return mintMediaToken({
152
+ root: jwtSecret,
153
+ tenantId,
154
+ ttl: mediaTokenTtl,
155
+ keyVersion: mediaKeyVersion,
156
+ });
147
157
  },
148
158
  verify,
149
159
  issueSessionToken,
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The token the admin sends to the media CDN when it uploads.
3
+ *
4
+ * It is signed with a key DERIVED from the CDN root secret, not with the root
5
+ * itself:
6
+ *
7
+ * key = HMAC-SHA256(root, "jwt:v1:<tenant_id>:<keyVersion>")
8
+ *
9
+ * The CDN derives the same key from its own copy of the root and compares. Two
10
+ * things follow, and both are the point:
11
+ *
12
+ * - The tenant id is bound into the key. A token minted for tenant A cannot
13
+ * be replayed as tenant B: changing the claim selects a key the holder
14
+ * cannot produce.
15
+ * - Rotating one tenant is a counter bump on that tenant's row, with a grace
16
+ * window in which the previous version still verifies. Nothing else has to
17
+ * be redeployed.
18
+ *
19
+ * Signing with the raw root — which is what this used to do — worked only
20
+ * because the CDN still accepts it (LEGACY_JWT). That path treats the root as
21
+ * a master key for every tenant at once, so it is being retired.
22
+ *
23
+ * `keyVersion` has to match what the CDN holds for the tenant. It defaults to
24
+ * 1, which is where a tenant starts; after a `tenants.mjs rotate-keys`, raise
25
+ * it here inside the grace window.
26
+ */
27
+ import crypto from "node:crypto";
28
+
29
+ /** Must match KEY_PURPOSE_JWT in the CDN's lambda/auth.mjs. */
30
+ const KEY_PURPOSE_JWT = "jwt";
31
+
32
+ /** Ten minutes. An upload takes seconds; the token has no reason to outlive it. */
33
+ export const DEFAULT_MEDIA_TOKEN_TTL = 600;
34
+
35
+ /**
36
+ * @param {string} root the CDN root secret
37
+ * @param {string} tenantId
38
+ * @param {number} keyVersion
39
+ */
40
+ export function deriveMediaKey(root, tenantId, keyVersion) {
41
+ return crypto
42
+ .createHmac("sha256", root)
43
+ .update(`${KEY_PURPOSE_JWT}:v1:${tenantId}:${keyVersion}`)
44
+ .digest();
45
+ }
46
+
47
+ /**
48
+ * @param {Object} opts
49
+ * @param {string} opts.root the CDN root secret (JWT_SECRET)
50
+ * @param {string} opts.tenantId
51
+ * @param {string} [opts.sub] who is uploading, for the CDN's logs
52
+ * @param {number} [opts.ttl] seconds; kept short on purpose
53
+ * @param {number} [opts.keyVersion]
54
+ * @returns {string} a compact JWS
55
+ */
56
+ export function issueMediaToken({ root, tenantId, sub = "media", ttl, keyVersion = 1 }) {
57
+ if (!root) throw new Error("JWT_SECRET not set");
58
+ if (!tenantId) throw new Error("media.tenantId not set — a media token names one tenant");
59
+
60
+ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
61
+ const now = Math.floor(Date.now() / 1000);
62
+
63
+ // `kid` is not what the CDN selects on — it tries the tenant's active
64
+ // versions — but it makes a rejected token readable in a log.
65
+ const header = b64({ alg: "HS256", typ: "JWT", kid: `${tenantId}.v${keyVersion}` });
66
+ const payload = b64({
67
+ sub,
68
+ tenant_id: tenantId,
69
+ type: "media",
70
+ iat: now,
71
+ exp: now + (ttl ?? DEFAULT_MEDIA_TOKEN_TTL),
72
+ });
73
+
74
+ const key = deriveMediaKey(root, tenantId, keyVersion);
75
+ const sig = crypto.createHmac("sha256", key).update(`${header}.${payload}`).digest("base64url");
76
+ return `${header}.${payload}.${sig}`;
77
+ }
@@ -93,6 +93,18 @@ export function githubTemplatesOptions(config, secrets) {
93
93
  * @param {SecretLookup} secrets
94
94
  * @returns {{ provider: "basic"|"github-oauth"|"cloudflare-access", options: Object }}
95
95
  */
96
+ /**
97
+ * How the admin's media tokens are signed. These live under `media` rather
98
+ * than `auth` because they describe the CDN tenant, not the login: the key is
99
+ * derived from the tenant id and the version the CDN holds for it.
100
+ */
101
+ function mediaTokenOptions(config) {
102
+ return {
103
+ mediaTokenTtl: config.media?.tokenTtl,
104
+ mediaKeyVersion: config.media?.keyVersion ?? 1,
105
+ };
106
+ }
107
+
96
108
  export function authOptions(config, secrets) {
97
109
  const auth = config.auth ?? {};
98
110
  const provider = auth.provider ?? "basic";
@@ -108,6 +120,7 @@ export function authOptions(config, secrets) {
108
120
  defaultRole: auth.defaultRole,
109
121
  jwtSecret: secrets(auth.jwtSecretEnv),
110
122
  jwtTtl: auth.jwtTtl,
123
+ ...mediaTokenOptions(config),
111
124
  },
112
125
  };
113
126
  }
@@ -135,6 +148,7 @@ export function authOptions(config, secrets) {
135
148
  })),
136
149
  jwtSecret: secrets(auth.jwtSecretEnv),
137
150
  jwtTtl: auth.jwtTtl,
151
+ ...mediaTokenOptions(config),
138
152
  },
139
153
  };
140
154
  }
@@ -86,6 +86,39 @@ function checkContent(config, report, { runtime }) {
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * `content.publishTargets` gives one panel more than one place to land the
91
+ * drafts — a preview site first, the live site after. Each id is what a
92
+ * request may ask for; the branch is never taken from the request.
93
+ */
94
+ function checkPublishTargets(config, report) {
95
+ const targets = config.content?.publishTargets;
96
+ if (targets === undefined) return;
97
+ if (!Array.isArray(targets) || targets.length === 0) {
98
+ report.error("content.publishTargets", "must be a non-empty array");
99
+ return;
100
+ }
101
+ if (!config.content?.draftBranch) {
102
+ report.error(
103
+ "content.publishTargets",
104
+ "needs content.draftBranch — without a draft branch saving already publishes, so there is nothing to send anywhere",
105
+ );
106
+ }
107
+ const seen = new Set();
108
+ targets.forEach((t, i) => {
109
+ const at = `content.publishTargets[${i}]`;
110
+ if (!t || typeof t !== "object") return report.error(at, "must be an object");
111
+ if (!t.id) report.error(`${at}.id`, "is required — this is what the panel sends");
112
+ if (!t.branch) report.error(`${at}.branch`, "is required — the branch this target lands on");
113
+ if (!t.label) report.warn(`${at}.label`, "is missing — the button will show the id");
114
+ if (t.id && seen.has(t.id)) report.error(`${at}.id`, `duplicate target id "${t.id}"`);
115
+ if (t.id) seen.add(t.id);
116
+ if (t.branch && t.branch === config.content?.draftBranch) {
117
+ report.error(`${at}.branch`, "is the draft branch — publishing onto it would be a no-op");
118
+ }
119
+ });
120
+ }
121
+
89
122
  function checkAuth(config, report, { getSecret }) {
90
123
  const auth = config.auth;
91
124
  if (!isPlainObject(auth)) {
@@ -379,6 +412,21 @@ function checkMisc(config, report) {
379
412
  if (config.media !== undefined) {
380
413
  if (!isPlainObject(config.media)) report.error("media", "must be an object");
381
414
  else if (!config.media.cdnBase) report.error("media.cdnBase", "is required when media is configured");
415
+ else {
416
+ const { tokenTtl, keyVersion } = config.media;
417
+ if (tokenTtl !== undefined && (!Number.isInteger(tokenTtl) || tokenTtl < 1)) {
418
+ report.error("media.tokenTtl", "must be a positive integer (seconds)");
419
+ }
420
+ // An upload takes seconds. A long-lived media token is a credential
421
+ // sitting in a browser for no reason, and the CDN may refuse it outright
422
+ // when MAX_TOKEN_TTL is set below this.
423
+ if (Number.isInteger(tokenTtl) && tokenTtl > 3600) {
424
+ report.warn("media.tokenTtl", `${tokenTtl}s is long for an upload — the CDN may cap it`);
425
+ }
426
+ if (keyVersion !== undefined && (!Number.isInteger(keyVersion) || keyVersion < 1)) {
427
+ report.error("media.keyVersion", "must be a positive integer — the CDN's key version for this tenant");
428
+ }
429
+ }
382
430
  }
383
431
 
384
432
  if (config.previewUrl !== undefined && typeof config.previewUrl !== "function") {
@@ -420,6 +468,7 @@ export function validateConfig(config, { runtime = "node", getSecret } = {}) {
420
468
  checkCollections(config, report);
421
469
  checkForms(config, report, { getSecret: lookup });
422
470
  checkBuild(config, report, { getSecret: lookup });
471
+ checkPublishTargets(config, report);
423
472
  checkMisc(config, report);
424
473
 
425
474
  return { errors: report.errors, warnings: report.warnings };
@@ -22,6 +22,11 @@ export function defaultPublicConfig(config, overrides = {}) {
22
22
  blocks: config.blocks,
23
23
  content: {
24
24
  provider: config.content?.provider || "fs",
25
+ // Ids and labels only — the branch each maps to stays server-side.
26
+ publishTargets: (config.content?.publishTargets ?? []).map((t) => ({
27
+ id: t.id,
28
+ label: t.label || t.id,
29
+ })),
25
30
  // Whether saving and publishing are separate steps. False only on the
26
31
  // github backend without a draft branch — the admin hides Publish there,
27
32
  // because saving already published.
package/src/routes.mjs CHANGED
@@ -580,9 +580,11 @@ export const apiRoutes = [
580
580
  method: "POST",
581
581
  path: "/api/publish",
582
582
  auth: "admin",
583
- handler: async ({ adapters }) => {
583
+ handler: async ({ adapters, config, body }) => {
584
+ const target = resolveTarget(config, body?.target);
585
+ if (target.error) return { status: 400, json: { ok: false, message: target.error } };
584
586
  try {
585
- return ok(await adapters.content.publish());
587
+ return ok(await adapters.content.publish(null, { target: target.branch }));
586
588
  } catch (err) {
587
589
  return { status: 500, json: { ok: false, message: err.message } };
588
590
  }
@@ -596,7 +598,9 @@ export const apiRoutes = [
596
598
  method: "POST",
597
599
  path: "/api/collections/:collection/:file/publish",
598
600
  auth: "admin",
599
- handler: async ({ adapters, params }) => {
601
+ handler: async ({ adapters, params, config, body }) => {
602
+ const target = resolveTarget(config, body?.target);
603
+ if (target.error) return { status: 400, json: { ok: false, message: target.error } };
600
604
  if (!adapters.content.capabilities?.perEntryPublish) {
601
605
  return {
602
606
  status: 501,
@@ -607,6 +611,7 @@ export const apiRoutes = [
607
611
  return ok(
608
612
  await adapters.content.publish(null, {
609
613
  entries: [{ collection: params.collection, file: params.file }],
614
+ target: target.branch,
610
615
  }),
611
616
  );
612
617
  } catch (err) {
@@ -619,10 +624,12 @@ export const apiRoutes = [
619
624
  method: "GET",
620
625
  path: "/api/publish/status",
621
626
  auth: "any",
622
- handler: async ({ adapters }) => {
627
+ handler: async ({ adapters, config, query }) => {
628
+ const target = resolveTarget(config, query?.target);
629
+ if (target.error) return { status: 400, json: { error: target.error } };
623
630
  try {
624
631
  return ok({
625
- ...(await adapters.content.pendingChanges()),
632
+ ...(await adapters.content.pendingChanges(target.branch)),
626
633
  perEntryPublish: !!adapters.content.capabilities?.perEntryPublish,
627
634
  });
628
635
  } catch (err) {
@@ -652,6 +659,27 @@ export const apiRoutes = [
652
659
  },
653
660
  ];
654
661
 
662
+ /**
663
+ * Turns a target id from a request into a branch name.
664
+ *
665
+ * The id is matched against `content.publishTargets`; a branch name is never
666
+ * taken from the request itself, or an admin could push the drafts onto any
667
+ * ref in the repo. No id, or no targets configured, means the deploy branch —
668
+ * which is what every existing config does.
669
+ */
670
+ function resolveTarget(config, id) {
671
+ const targets = config?.content?.publishTargets;
672
+ if (!id) return { branch: undefined };
673
+ if (!Array.isArray(targets) || targets.length === 0) {
674
+ return { error: `Unknown publish target "${id}" — content.publishTargets is not configured` };
675
+ }
676
+ const hit = targets.find((t) => t.id === id);
677
+ if (!hit) {
678
+ return { error: `Unknown publish target "${id}" — expected one of: ${targets.map((t) => t.id).join(", ")}` };
679
+ }
680
+ return { branch: hit.branch };
681
+ }
682
+
655
683
  /**
656
684
  * Media endpoints need the auth adapter to mint a CDN token. Ask the port
657
685
  * whether it can, instead of calling and handling the exception as a 500.
package/src/version.mjs CHANGED
@@ -5,4 +5,4 @@
5
5
  * require() and no import.meta.url, so reading the manifest at runtime yields
6
6
  * "unknown" there.
7
7
  */
8
- export const SERVER_VERSION = "0.28.0";
8
+ export const SERVER_VERSION = "0.30.0";