@stelstone/server 0.27.0 → 0.29.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/build-github-actions.mjs +104 -0
- package/src/adapters/github-oauth.mjs +11 -1
- package/src/adapters/index.mjs +1 -0
- package/src/adapters/media-token.mjs +77 -0
- package/src/core/adapter-options.mjs +76 -10
- package/src/core/config-schema.mjs +67 -1
- package/src/server.mjs +7 -2
- 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
|
/**
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions workflow runs as the deploy status source.
|
|
3
|
+
*
|
|
4
|
+
* For sites whose CI does the deploying — a Worker, a container, an rsync to
|
|
5
|
+
* a VPS — the workflow run IS the deploy, so its state is the honest thing to
|
|
6
|
+
* show. The adapter speaks the same vocabulary as the Netlify one so the
|
|
7
|
+
* admin's pill needs no per-provider knowledge.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} opts
|
|
10
|
+
* @param {string|undefined} opts.token repo-scoped token; `actions:read`
|
|
11
|
+
* @param {string|undefined} opts.owner
|
|
12
|
+
* @param {string|undefined} opts.repo
|
|
13
|
+
* @param {string|undefined} opts.workflow file name ("deploy.yml") or id;
|
|
14
|
+
* omit to watch every workflow
|
|
15
|
+
* @param {string|undefined} opts.defaultBranch
|
|
16
|
+
* @returns {import('./types.mjs').BuildAdapter}
|
|
17
|
+
*/
|
|
18
|
+
import { createGitHubApi } from "./github-api.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A finished run's conclusion, in the admin's state vocabulary.
|
|
22
|
+
*
|
|
23
|
+
* Anything that did not succeed but also did not fail outright — skipped by a
|
|
24
|
+
* path filter, superseded, stopped by hand — reads as "cancelled": the deploy
|
|
25
|
+
* did not happen, and that is not the same as a broken build.
|
|
26
|
+
*/
|
|
27
|
+
const CONCLUSION_STATE = {
|
|
28
|
+
success: "ready",
|
|
29
|
+
failure: "error",
|
|
30
|
+
timed_out: "error",
|
|
31
|
+
startup_failure: "error",
|
|
32
|
+
action_required: "error",
|
|
33
|
+
cancelled: "cancelled",
|
|
34
|
+
skipped: "cancelled",
|
|
35
|
+
stale: "cancelled",
|
|
36
|
+
neutral: "cancelled",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Queued and waiting-for-approval runs have not started; both are "queued". */
|
|
40
|
+
const PENDING_STATUSES = new Set(["queued", "pending", "waiting", "requested"]);
|
|
41
|
+
|
|
42
|
+
function runState(run) {
|
|
43
|
+
if (run.status === "completed") return CONCLUSION_STATE[run.conclusion] || "error";
|
|
44
|
+
return PENDING_STATUSES.has(run.status) ? "enqueued" : "building";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createGitHubActionsBuild({ token, owner, repo, workflow, defaultBranch }) {
|
|
48
|
+
const configured = Boolean(token && owner && repo);
|
|
49
|
+
const api = configured ? createGitHubApi({ token, owner, repo }) : null;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
configured,
|
|
53
|
+
|
|
54
|
+
async getDeployStatus({ branch, sha } = {}) {
|
|
55
|
+
if (!configured) return { configured: false };
|
|
56
|
+
|
|
57
|
+
const query = new URLSearchParams({ per_page: "20" });
|
|
58
|
+
const targetBranch = branch || defaultBranch;
|
|
59
|
+
if (targetBranch) query.set("branch", targetBranch);
|
|
60
|
+
const path = workflow
|
|
61
|
+
? `/actions/workflows/${encodeURIComponent(workflow)}/runs?${query}`
|
|
62
|
+
: `/actions/runs?${query}`;
|
|
63
|
+
|
|
64
|
+
const body = await api.apiGet(path);
|
|
65
|
+
// apiGet answers null on 404 — for a run listing that means the repo,
|
|
66
|
+
// the workflow name or the token is wrong, not "no runs yet". Say so
|
|
67
|
+
// instead of returning an empty list the caller would poll forever.
|
|
68
|
+
if (!body) {
|
|
69
|
+
const err = new Error(`GitHub API 404 GET ${path}`);
|
|
70
|
+
err.upstreamStatus = 404;
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const runs = body.workflow_runs || [];
|
|
75
|
+
let run = null;
|
|
76
|
+
if (sha) {
|
|
77
|
+
run = runs.find((r) => r.head_sha && r.head_sha.startsWith(sha));
|
|
78
|
+
// SHA not there yet — the push has not produced a run, keep waiting.
|
|
79
|
+
if (!run) return { configured: true, deploy: null };
|
|
80
|
+
}
|
|
81
|
+
if (!run) run = runs[0] || null;
|
|
82
|
+
if (!run) return { configured: true, deploy: null };
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
configured: true,
|
|
86
|
+
deploy: {
|
|
87
|
+
id: run.id,
|
|
88
|
+
state: runState(run),
|
|
89
|
+
branch: run.head_branch,
|
|
90
|
+
commitRef: run.head_sha,
|
|
91
|
+
// Actions knows what it ran, not where the result is published, so
|
|
92
|
+
// the run page is the only link worth offering.
|
|
93
|
+
deployUrl: null,
|
|
94
|
+
adminUrl: run.html_url || null,
|
|
95
|
+
createdAt: run.created_at,
|
|
96
|
+
updatedAt: run.updated_at,
|
|
97
|
+
// A run carries no failure text; the run page has the logs.
|
|
98
|
+
errorMessage: null,
|
|
99
|
+
title: run.display_title || run.name || null,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -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,
|
package/src/adapters/index.mjs
CHANGED
|
@@ -8,5 +8,6 @@ export { createBasicAuth } from "./basic-auth.mjs";
|
|
|
8
8
|
export { createGitHubOAuth } from "./github-oauth.mjs";
|
|
9
9
|
export { createCloudflareAccess } from "./cloudflare-access.mjs";
|
|
10
10
|
export { createNetlifyBuild } from "./build-netlify.mjs";
|
|
11
|
+
export { createGitHubActionsBuild } from "./build-github-actions.mjs";
|
|
11
12
|
export { createMediaUrl } from "./media-url.mjs";
|
|
12
13
|
export { createResendMail } from "./resend-mail.mjs";
|
|
@@ -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
|
+
}
|
|
@@ -16,20 +16,29 @@
|
|
|
16
16
|
/** @typedef {(name: string) => string|undefined} SecretLookup */
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* GITHUB_REPO ("owner/repo") overrides the config — set where the secrets
|
|
20
|
+
* already live, so a deploy-button user never edits a file. Lenient about
|
|
21
|
+
* pasted URLs: the last two path segments are the coordinates.
|
|
22
|
+
*
|
|
21
23
|
* @param {SecretLookup} secrets
|
|
24
|
+
* @returns {{owner?: string, repo?: string}}
|
|
22
25
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// already live, so a deploy-button user never edits a file. Lenient about
|
|
26
|
-
// pasted URLs: the last two path segments are the coordinates.
|
|
27
|
-
const repoEnv = String(secrets("GITHUB_REPO") ?? "")
|
|
26
|
+
function repoFromEnv(secrets) {
|
|
27
|
+
const parts = String(secrets("GITHUB_REPO") ?? "")
|
|
28
28
|
.replace(/\.git$/, "")
|
|
29
29
|
.split(/[/:]/)
|
|
30
30
|
.filter(Boolean)
|
|
31
31
|
.slice(-2);
|
|
32
|
-
|
|
32
|
+
return parts.length === 2 ? { owner: parts[0], repo: parts[1] } : {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options for `createGitHubContent`.
|
|
37
|
+
* @param {Object} config
|
|
38
|
+
* @param {SecretLookup} secrets
|
|
39
|
+
*/
|
|
40
|
+
export function githubContentOptions(config, secrets) {
|
|
41
|
+
const { owner: envOwner, repo: envRepo } = repoFromEnv(secrets);
|
|
33
42
|
return {
|
|
34
43
|
token: secrets(config.content.githubTokenEnv || "GITHUB_TOKEN"),
|
|
35
44
|
owner: envOwner || config.content.owner,
|
|
@@ -84,6 +93,18 @@ export function githubTemplatesOptions(config, secrets) {
|
|
|
84
93
|
* @param {SecretLookup} secrets
|
|
85
94
|
* @returns {{ provider: "basic"|"github-oauth"|"cloudflare-access", options: Object }}
|
|
86
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
|
+
|
|
87
108
|
export function authOptions(config, secrets) {
|
|
88
109
|
const auth = config.auth ?? {};
|
|
89
110
|
const provider = auth.provider ?? "basic";
|
|
@@ -99,6 +120,7 @@ export function authOptions(config, secrets) {
|
|
|
99
120
|
defaultRole: auth.defaultRole,
|
|
100
121
|
jwtSecret: secrets(auth.jwtSecretEnv),
|
|
101
122
|
jwtTtl: auth.jwtTtl,
|
|
123
|
+
...mediaTokenOptions(config),
|
|
102
124
|
},
|
|
103
125
|
};
|
|
104
126
|
}
|
|
@@ -126,6 +148,7 @@ export function authOptions(config, secrets) {
|
|
|
126
148
|
})),
|
|
127
149
|
jwtSecret: secrets(auth.jwtSecretEnv),
|
|
128
150
|
jwtTtl: auth.jwtTtl,
|
|
151
|
+
...mediaTokenOptions(config),
|
|
129
152
|
},
|
|
130
153
|
};
|
|
131
154
|
}
|
|
@@ -135,11 +158,54 @@ export function netlifyBuildOptions(config, secrets) {
|
|
|
135
158
|
return {
|
|
136
159
|
token: secrets(config.build?.netlifyTokenEnv || "NETLIFY_AUTH_TOKEN"),
|
|
137
160
|
siteId: secrets(config.build?.netlifySiteIdEnv || "NETLIFY_SITE_ID"),
|
|
138
|
-
defaultBranch:
|
|
139
|
-
secrets("STAGING_BRANCH") || config.content?.publishBranch || config.content?.branch,
|
|
161
|
+
defaultBranch: buildDefaultBranch(config, secrets),
|
|
140
162
|
};
|
|
141
163
|
}
|
|
142
164
|
|
|
165
|
+
/**
|
|
166
|
+
* The branch whose deploy the admin's status pill follows. `STAGING_BRANCH`
|
|
167
|
+
* wins for the same reason it does for content: it is what redirects a whole
|
|
168
|
+
* deployment onto another branch.
|
|
169
|
+
*/
|
|
170
|
+
function buildDefaultBranch(config, secrets) {
|
|
171
|
+
return secrets("STAGING_BRANCH") || config.content?.publishBranch || config.content?.branch;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Which build adapter to construct, and with what.
|
|
176
|
+
*
|
|
177
|
+
* Mirrors `authOptions`: the caller stays free of the naming rules and only
|
|
178
|
+
* has to map a provider name onto a factory. Netlify remains the default so
|
|
179
|
+
* existing configs keep working untouched.
|
|
180
|
+
*
|
|
181
|
+
* @param {Object} config
|
|
182
|
+
* @param {SecretLookup} secrets
|
|
183
|
+
* @returns {{provider: string, options: object}}
|
|
184
|
+
*/
|
|
185
|
+
export function buildOptions(config, secrets) {
|
|
186
|
+
const build = config.build ?? {};
|
|
187
|
+
const provider = build.provider ?? "netlify";
|
|
188
|
+
|
|
189
|
+
if (provider === "github-actions") {
|
|
190
|
+
// Defaults to the repo the content already comes from — for a site whose
|
|
191
|
+
// CI deploys it, that is nearly always the same repo, and the token that
|
|
192
|
+
// reads content can read its runs.
|
|
193
|
+
const { owner: envOwner, repo: envRepo } = repoFromEnv(secrets);
|
|
194
|
+
return {
|
|
195
|
+
provider,
|
|
196
|
+
options: {
|
|
197
|
+
token: secrets(build.githubTokenEnv || config.content?.githubTokenEnv || "GITHUB_TOKEN"),
|
|
198
|
+
owner: envOwner || build.owner || config.content?.owner,
|
|
199
|
+
repo: envRepo || build.repo || config.content?.repo,
|
|
200
|
+
workflow: build.workflow,
|
|
201
|
+
defaultBranch: buildDefaultBranch(config, secrets),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { provider: "netlify", options: netlifyBuildOptions(config, secrets) };
|
|
207
|
+
}
|
|
208
|
+
|
|
143
209
|
/**
|
|
144
210
|
* Options for `createCdnProxyMedia`.
|
|
145
211
|
* @param {Object} config
|
|
@@ -321,6 +321,53 @@ function checkForward(forward, at, report, getSecret) {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
/**
|
|
325
|
+
* `build` decides where the admin's deploy pill gets its status. It is
|
|
326
|
+
* optional — a site with no CI simply shows no pill — but a misspelled
|
|
327
|
+
* provider silently falls back to Netlify, which for a site that does not use
|
|
328
|
+
* Netlify means a pill that never resolves. Name it.
|
|
329
|
+
*/
|
|
330
|
+
function checkBuild(config, report, { getSecret }) {
|
|
331
|
+
const build = config.build;
|
|
332
|
+
if (build === undefined) return;
|
|
333
|
+
if (typeof build !== "object" || build === null) {
|
|
334
|
+
report.error("build", "must be an object");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const provider = build.provider ?? "netlify";
|
|
339
|
+
if (!["netlify", "github-actions"].includes(provider)) {
|
|
340
|
+
report.error("build.provider", `unknown provider "${provider}" — expected netlify or github-actions`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (provider === "github-actions") {
|
|
345
|
+
const owner = build.owner || config.content?.owner;
|
|
346
|
+
const repo = build.repo || config.content?.repo;
|
|
347
|
+
// GITHUB_REPO can supply both at runtime, so this is a warning: the
|
|
348
|
+
// coordinates may well arrive from the environment.
|
|
349
|
+
if (!owner || !repo) {
|
|
350
|
+
report.warn(
|
|
351
|
+
"build",
|
|
352
|
+
"owner/repo not in the config — deploy status needs them, from build.owner/build.repo, content, or GITHUB_REPO",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const tokenEnv = build.githubTokenEnv || config.content?.githubTokenEnv || "GITHUB_TOKEN";
|
|
356
|
+
if (!getSecret(tokenEnv)) {
|
|
357
|
+
report.warn("build.githubTokenEnv", `${tokenEnv} is not set — deploy status is unavailable`);
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (const [key, fallback] of [
|
|
363
|
+
["netlifyTokenEnv", "NETLIFY_AUTH_TOKEN"],
|
|
364
|
+
["netlifySiteIdEnv", "NETLIFY_SITE_ID"],
|
|
365
|
+
]) {
|
|
366
|
+
const name = build[key] || fallback;
|
|
367
|
+
if (!getSecret(name)) report.warn(`build.${key}`, `${name} is not set — deploy status is unavailable`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
324
371
|
function checkMisc(config, report) {
|
|
325
372
|
const { locales, defaultLocale } = config;
|
|
326
373
|
if (locales !== undefined && !Array.isArray(locales)) {
|
|
@@ -332,6 +379,21 @@ function checkMisc(config, report) {
|
|
|
332
379
|
if (config.media !== undefined) {
|
|
333
380
|
if (!isPlainObject(config.media)) report.error("media", "must be an object");
|
|
334
381
|
else if (!config.media.cdnBase) report.error("media.cdnBase", "is required when media is configured");
|
|
382
|
+
else {
|
|
383
|
+
const { tokenTtl, keyVersion } = config.media;
|
|
384
|
+
if (tokenTtl !== undefined && (!Number.isInteger(tokenTtl) || tokenTtl < 1)) {
|
|
385
|
+
report.error("media.tokenTtl", "must be a positive integer (seconds)");
|
|
386
|
+
}
|
|
387
|
+
// An upload takes seconds. A long-lived media token is a credential
|
|
388
|
+
// sitting in a browser for no reason, and the CDN may refuse it outright
|
|
389
|
+
// when MAX_TOKEN_TTL is set below this.
|
|
390
|
+
if (Number.isInteger(tokenTtl) && tokenTtl > 3600) {
|
|
391
|
+
report.warn("media.tokenTtl", `${tokenTtl}s is long for an upload — the CDN may cap it`);
|
|
392
|
+
}
|
|
393
|
+
if (keyVersion !== undefined && (!Number.isInteger(keyVersion) || keyVersion < 1)) {
|
|
394
|
+
report.error("media.keyVersion", "must be a positive integer — the CDN's key version for this tenant");
|
|
395
|
+
}
|
|
396
|
+
}
|
|
335
397
|
}
|
|
336
398
|
|
|
337
399
|
if (config.previewUrl !== undefined && typeof config.previewUrl !== "function") {
|
|
@@ -372,6 +434,7 @@ export function validateConfig(config, { runtime = "node", getSecret } = {}) {
|
|
|
372
434
|
checkAuth(config, report, { getSecret: lookup });
|
|
373
435
|
checkCollections(config, report);
|
|
374
436
|
checkForms(config, report, { getSecret: lookup });
|
|
437
|
+
checkBuild(config, report, { getSecret: lookup });
|
|
375
438
|
checkMisc(config, report);
|
|
376
439
|
|
|
377
440
|
return { errors: report.errors, warnings: report.warnings };
|
|
@@ -445,7 +508,10 @@ export function describeConfig(config, { adapters, getSecret, runtime = "node" }
|
|
|
445
508
|
lines.push(`media ${config.media.cdnBase}${config.media.tenantId ? ` (tenant: ${config.media.tenantId})` : ""}`);
|
|
446
509
|
}
|
|
447
510
|
if (adapters?.build) {
|
|
448
|
-
|
|
511
|
+
const buildProvider = config.build?.provider ?? "netlify";
|
|
512
|
+
lines.push(
|
|
513
|
+
`build ${buildProvider} — ${adapters.build.configured ? "configured" : "not configured"}`,
|
|
514
|
+
);
|
|
449
515
|
}
|
|
450
516
|
|
|
451
517
|
const collections = Object.keys(config.collections || {});
|
package/src/server.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
fsContentOptions,
|
|
13
13
|
githubTemplatesOptions,
|
|
14
14
|
authOptions,
|
|
15
|
-
|
|
15
|
+
buildOptions,
|
|
16
16
|
cdnMediaOptions,
|
|
17
17
|
mailOptions,
|
|
18
18
|
} from "./core/adapter-options.mjs";
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
createGitHubOAuth,
|
|
26
26
|
createCloudflareAccess,
|
|
27
27
|
createNetlifyBuild,
|
|
28
|
+
createGitHubActionsBuild,
|
|
28
29
|
createFsTemplates,
|
|
29
30
|
createGitHubTemplates,
|
|
30
31
|
createResendMail,
|
|
@@ -79,7 +80,11 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
|
|
|
79
80
|
// No media config → no CDN adapter; the media routes answer 404 instead
|
|
80
81
|
// of every /api request dying while the adapter bag is built.
|
|
81
82
|
const cdnMedia = config.media ? createCdnProxyMedia(cdnMediaOptions(config, auth)) : undefined;
|
|
82
|
-
const
|
|
83
|
+
const { provider: buildProvider, options: buildOpts } = buildOptions(config, secrets);
|
|
84
|
+
const build =
|
|
85
|
+
buildProvider === "github-actions"
|
|
86
|
+
? createGitHubActionsBuild(buildOpts)
|
|
87
|
+
: createNetlifyBuild(buildOpts);
|
|
83
88
|
|
|
84
89
|
const templates = useGitHub
|
|
85
90
|
? createGitHubTemplates(githubTemplatesOptions(config, secrets))
|
package/src/version.mjs
CHANGED