@stelstone/server 0.30.1 → 0.32.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.1",
3
+ "version": "0.32.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 {
@@ -49,8 +49,9 @@ export function createFsJsonContent({
49
49
  }
50
50
  }
51
51
 
52
- function git(cmd) {
53
- return execSync(`git ${cmd}`, { cwd: rootDir, encoding: "utf-8" }).trim();
52
+ function git(cmd, { trim = true } = {}) {
53
+ const out = execSync(`git ${cmd}`, { cwd: rootDir, encoding: "utf-8" });
54
+ return trim ? out.trim() : out;
54
55
  }
55
56
 
56
57
  function pagePath(collection, file) {
@@ -206,7 +207,11 @@ export function createFsJsonContent({
206
207
  capabilities: { deferredPublish: true, perEntryPublish: true },
207
208
 
208
209
  async pendingChanges() {
209
- const status = git(`status --porcelain ${PATHS_ARG}`);
210
+ // Untrimmed: a modified file's line starts with a space (" M path") and
211
+ // trimming ate it on the first line, which then lost the first letter
212
+ // of its path and was never attributed to its collection — so the
213
+ // first pending entry in a list never got its badge.
214
+ const status = git(`status --porcelain ${PATHS_ARG}`, { trim: false });
210
215
  const lines = status.split("\n").filter(Boolean);
211
216
  // Porcelain line: "XY path" (rename: "XY old -> new"). Only entries under
212
217
  // pagesDir are attributed to a collection/file; other publishPaths (e.g.
@@ -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
@@ -231,7 +231,7 @@ export const apiRoutes = [
231
231
  method: "PUT",
232
232
  path: "/api/collections/:collection/:file",
233
233
  auth: "any",
234
- handler: async ({ adapters, params, body, header }) => {
234
+ handler: async ({ adapters, params, body, header, user }) => {
235
235
  const bodyErr = pageBodyError(body);
236
236
  if (bodyErr) return badRequest(bodyErr);
237
237
 
@@ -239,7 +239,7 @@ export const apiRoutes = [
239
239
  const expectedVersion = ifMatch ? ifMatch.replace(/^W\//, "").replace(/"/g, "") : undefined;
240
240
 
241
241
  try {
242
- await adapters.content.writePage(params.collection, params.file, body, { expectedVersion });
242
+ await adapters.content.writePage(params.collection, params.file, body, { expectedVersion, actor: user });
243
243
  } catch (err) {
244
244
  if (err.status === 412) {
245
245
  return {
@@ -311,9 +311,20 @@ export const apiRoutes = [
311
311
  if (!data) return notFound();
312
312
 
313
313
  let url = null;
314
+ // Which publish target the preview shows, when the site can say: a
315
+ // draft previews on the staging site, a published entry on the live
316
+ // one. The admin needs this to tell whether the framed page is current
317
+ // or behind the saved draft.
318
+ let target = null;
314
319
  if (typeof config.previewUrl === "function") {
315
320
  try {
316
- url = config.previewUrl({ collection: params.collection, data });
321
+ const out = config.previewUrl({ collection: params.collection, data });
322
+ if (out && typeof out === "object") {
323
+ url = out.url ?? null;
324
+ target = out.target ?? null;
325
+ } else {
326
+ url = out;
327
+ }
317
328
  } catch (err) {
318
329
  return { status: 500, json: { error: `previewUrl(): ${err.message}` } };
319
330
  }
@@ -323,7 +334,7 @@ export const apiRoutes = [
323
334
  .replace("{slug}", data.slug || "")
324
335
  .replace("{lang}", data.lang || "");
325
336
  }
326
- return ok({ url });
337
+ return ok({ url, target });
327
338
  },
328
339
  },
329
340
 
@@ -585,11 +596,11 @@ export const apiRoutes = [
585
596
  method: "POST",
586
597
  path: "/api/publish",
587
598
  auth: "admin",
588
- handler: async ({ adapters, config, body }) => {
599
+ handler: async ({ adapters, config, body, user }) => {
589
600
  const target = resolveTarget(config, body?.target);
590
601
  if (target.error) return { status: 400, json: { ok: false, message: target.error } };
591
602
  try {
592
- return ok(await adapters.content.publish(null, { target: target.branch }));
603
+ return ok(await adapters.content.publish(null, { target: target.branch, actor: user }));
593
604
  } catch (err) {
594
605
  return { status: 500, json: { ok: false, message: err.message } };
595
606
  }
@@ -603,7 +614,7 @@ export const apiRoutes = [
603
614
  method: "POST",
604
615
  path: "/api/collections/:collection/:file/publish",
605
616
  auth: "admin",
606
- handler: async ({ adapters, params, config, body }) => {
617
+ handler: async ({ adapters, params, config, body, user }) => {
607
618
  const target = resolveTarget(config, body?.target);
608
619
  if (target.error) return { status: 400, json: { ok: false, message: target.error } };
609
620
  if (!adapters.content.capabilities?.perEntryPublish) {
@@ -617,6 +628,7 @@ export const apiRoutes = [
617
628
  await adapters.content.publish(null, {
618
629
  entries: [{ collection: params.collection, file: params.file }],
619
630
  target: target.branch,
631
+ actor: user,
620
632
  }),
621
633
  );
622
634
  } catch (err) {
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.1";
8
+ export const SERVER_VERSION = "0.32.0";