@stelstone/server 0.30.0 → 0.31.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.30.0",
3
+ "version": "0.31.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": {
@@ -86,7 +86,15 @@ export function createBasicAuth({
86
86
  const p = decoded.slice(colon + 1);
87
87
  const entry = findUser(u, p);
88
88
  if (!entry) return null;
89
- return { login: entry.user, name: entry.name || entry.user, role: entry.role || "editor" };
89
+ // `email` is optional and deliberately not derived from the username: a
90
+ // made-up address in a commit looks like a real one, and git history is
91
+ // read as evidence. Without it the commit falls back to the token owner.
92
+ return {
93
+ login: entry.user,
94
+ name: entry.name || entry.user,
95
+ email: entry.email || null,
96
+ role: entry.role || "editor",
97
+ };
90
98
  }
91
99
 
92
100
  return {
@@ -52,6 +52,20 @@ import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue
52
52
  * @param {Object} [opts.list] Listing strategy config (see above)
53
53
  * @returns {import('./types.mjs').ContentAdapter}
54
54
  */
55
+ /**
56
+ * The `author` field for a git-data commit, or undefined.
57
+ *
58
+ * GitHub attributes a commit to the token owner when this is absent, which is
59
+ * what made every entry in the CMS's history read as the same person no
60
+ * matter who published it. A user with no configured email falls back to that
61
+ * rather than getting an invented address: history is read as evidence, and a
62
+ * plausible-looking address nobody owns is worse than an honest default.
63
+ */
64
+ function commitAuthor(actor) {
65
+ if (!actor?.email) return undefined;
66
+ return { name: actor.name || actor.login, email: actor.email };
67
+ }
68
+
55
69
  export function createGitHubContent({ token, owner, repo, branch, draftBranch, pagesDir, commitMessage, list = {}, collections = {} }) {
56
70
  const listConfig = {
57
71
  strategy: "index",
@@ -323,14 +337,19 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
323
337
  * write time — which is what this did before — discarded that
324
338
  * guarantee and silently overwrote a concurrent edit.
325
339
  */
326
- async writePage(collection, file, data, { expectedVersion } = {}) {
340
+ async writePage(collection, file, data, { expectedVersion, actor } = {}) {
327
341
  await ensureDraftBranch();
328
342
  const path = contentPath(collection, sanitize(file));
329
343
  const sha = expectedVersion ?? (await getFileSha(path));
330
344
  let result;
331
345
  try {
346
+ // The history screen lists commits on the draft branch, so this write
347
+ // is the one that answers "who edited this" — attributing it matters
348
+ // more here than on the publish commit.
349
+ const author = commitAuthor(actor);
332
350
  result = await apiPut(`/contents/${path}`, {
333
351
  message: commitMsg(commitMessage),
352
+ ...(author ? { author } : {}),
334
353
  content: encodeContent(data),
335
354
  branch: workBranch,
336
355
  ...(sha ? { sha } : {}),
@@ -447,7 +466,7 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
447
466
  * @param {string} [message]
448
467
  * @param {{ entries?: {collection: string, file: string}[], target?: string }} [opts]
449
468
  */
450
- async publish(message, { entries, target } = {}) {
469
+ async publish(message, { entries, target, actor } = {}) {
451
470
  const toBranch = target || branch;
452
471
  if (!draftMode) {
453
472
  // Writes are committed instantly; trigger is external (Netlify webhook on push)
@@ -490,6 +509,7 @@ export function createGitHubContent({ token, owner, repo, branch, draftBranch, p
490
509
  message: msg,
491
510
  tree: newTree.sha,
492
511
  parents: [baseCommitSha],
512
+ author: commitAuthor(actor),
493
513
  });
494
514
  await apiPatch(`/git/refs/heads/${toBranch}`, { sha: newCommit.sha });
495
515
  const shortSha = newCommit.sha.slice(0, 7);
@@ -116,7 +116,7 @@ export function createGitHubOAuth({
116
116
  if (!authHeader.startsWith("Bearer ")) return null;
117
117
  const claims = verifyToken(authHeader.slice(7));
118
118
  if (!claims || claims.type !== "session") return null;
119
- return { login: claims.sub, role: getRole(claims.sub), name: claims.name };
119
+ return { login: claims.sub, role: getRole(claims.sub), name: claims.name, email: claims.email ?? null };
120
120
  }
121
121
 
122
122
  function issueSessionToken(login, name) {
@@ -156,6 +156,12 @@ function checkAuth(config, report, { getSecret }) {
156
156
  `${entry.passEnv} is not set — user "${entry.user}" is DISABLED (check for a typo in the variable name)`,
157
157
  );
158
158
  }
159
+ // Optional, and only useful if it is real: it becomes the author of
160
+ // the commits this user makes. A malformed one would attribute their
161
+ // work to nobody, so say so rather than write it.
162
+ if (entry.email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(entry.email))) {
163
+ report.error(`auth.users[${i}].email`, "must be an email address — it becomes the git commit author");
164
+ }
159
165
  });
160
166
  } else if (!auth.passEnv && !auth.userEnv) {
161
167
  report.warn("auth.users", "no users configured — the CMS will run without authentication");
package/src/routes.mjs CHANGED
@@ -87,7 +87,12 @@ export const apiRoutes = [
87
87
  handler: ({ env, runtime, adminUiVersion }) =>
88
88
  ok({
89
89
  serverVersion: SERVER_VERSION,
90
- adminUiVersion: adminUiVersion ?? null,
90
+ // The Node server reads this off the package it resolved. A Worker
91
+ // cannot: the SPA is served by the assets binding and its package.json
92
+ // is not in the bundle. The deployment composed that directory, so it
93
+ // can state the version the way it states the commit below — a stated
94
+ // version beats a null on the About screen.
95
+ adminUiVersion: adminUiVersion ?? env("CMS_ADMIN_UI_VERSION") ?? null,
91
96
  // Declared by whoever constructed the handler, not sniffed: with
92
97
  // nodejs_compat enabled a Worker also exposes process.versions.node,
93
98
  // so feature detection reported "node" from inside a Worker.
@@ -226,7 +231,7 @@ export const apiRoutes = [
226
231
  method: "PUT",
227
232
  path: "/api/collections/:collection/:file",
228
233
  auth: "any",
229
- handler: async ({ adapters, params, body, header }) => {
234
+ handler: async ({ adapters, params, body, header, user }) => {
230
235
  const bodyErr = pageBodyError(body);
231
236
  if (bodyErr) return badRequest(bodyErr);
232
237
 
@@ -234,7 +239,7 @@ export const apiRoutes = [
234
239
  const expectedVersion = ifMatch ? ifMatch.replace(/^W\//, "").replace(/"/g, "") : undefined;
235
240
 
236
241
  try {
237
- await adapters.content.writePage(params.collection, params.file, body, { expectedVersion });
242
+ await adapters.content.writePage(params.collection, params.file, body, { expectedVersion, actor: user });
238
243
  } catch (err) {
239
244
  if (err.status === 412) {
240
245
  return {
@@ -580,11 +585,11 @@ export const apiRoutes = [
580
585
  method: "POST",
581
586
  path: "/api/publish",
582
587
  auth: "admin",
583
- handler: async ({ adapters, config, body }) => {
588
+ handler: async ({ adapters, config, body, user }) => {
584
589
  const target = resolveTarget(config, body?.target);
585
590
  if (target.error) return { status: 400, json: { ok: false, message: target.error } };
586
591
  try {
587
- return ok(await adapters.content.publish(null, { target: target.branch }));
592
+ return ok(await adapters.content.publish(null, { target: target.branch, actor: user }));
588
593
  } catch (err) {
589
594
  return { status: 500, json: { ok: false, message: err.message } };
590
595
  }
@@ -598,7 +603,7 @@ export const apiRoutes = [
598
603
  method: "POST",
599
604
  path: "/api/collections/:collection/:file/publish",
600
605
  auth: "admin",
601
- handler: async ({ adapters, params, config, body }) => {
606
+ handler: async ({ adapters, params, config, body, user }) => {
602
607
  const target = resolveTarget(config, body?.target);
603
608
  if (target.error) return { status: 400, json: { ok: false, message: target.error } };
604
609
  if (!adapters.content.capabilities?.perEntryPublish) {
@@ -612,6 +617,7 @@ export const apiRoutes = [
612
617
  await adapters.content.publish(null, {
613
618
  entries: [{ collection: params.collection, file: params.file }],
614
619
  target: target.branch,
620
+ actor: user,
615
621
  }),
616
622
  );
617
623
  } catch (err) {
@@ -643,11 +649,20 @@ export const apiRoutes = [
643
649
  method: "GET",
644
650
  path: "/api/deploy/status",
645
651
  auth: "any",
646
- handler: async ({ adapters, query }) => {
652
+ handler: async ({ adapters, config, query }) => {
647
653
  if (!adapters.build.configured) return ok({ configured: false });
654
+ // Which branch's build to watch. After publishing to a target the new
655
+ // commit exists only on that target's branch, so without this the run
656
+ // is looked for on the deploy branch and never found — the pill then
657
+ // spins until it gives up, for a build that actually succeeded.
658
+ const target = resolveTarget(config, query.target);
659
+ if (target.error) return { status: 400, json: { error: target.error } };
648
660
  try {
649
661
  return ok(
650
- await adapters.build.getDeployStatus({ branch: query.branch, sha: query.sha }),
662
+ await adapters.build.getDeployStatus({
663
+ branch: target.branch ?? query.branch,
664
+ sha: query.sha,
665
+ }),
651
666
  );
652
667
  } catch (err) {
653
668
  if (err.upstreamStatus) {
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.30.0";
8
+ export const SERVER_VERSION = "0.31.0";