@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 +1 -1
- package/src/adapters/basic-auth.mjs +14 -17
- package/src/adapters/github-content.mjs +16 -8
- package/src/adapters/github-oauth.mjs +11 -1
- package/src/adapters/media-token.mjs +77 -0
- package/src/core/adapter-options.mjs +14 -0
- package/src/core/config-schema.mjs +49 -0
- package/src/default-public-config.mjs +5 -0
- package/src/routes.mjs +33 -5
- package/src/version.mjs +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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/${
|
|
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/${
|
|
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/${
|
|
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 ${
|
|
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
|
-
|
|
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