caracms 0.2.0 → 0.3.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.
Files changed (50) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +64 -20
  6. package/package.json +1 -1
  7. package/skills/caracms-adopt/SKILL.md +5 -3
  8. package/skills/caracms-adopt/look-same.mjs +8 -4
  9. package/skills/caracms-blocks/SKILL.md +20 -2
  10. package/skills/caracms-tina/SKILL.md +14 -10
  11. package/src/components/Head.astro +3 -2
  12. package/src/content.ts +2 -0
  13. package/src/editor/client/editor.ts +24 -6
  14. package/src/editor/client/live/publish.tsx +20 -10
  15. package/src/editor/client/pane/parts.tsx +34 -2
  16. package/src/editor/client/state/keeper.ts +51 -6
  17. package/src/editor/client/state/live.ts +45 -6
  18. package/src/editor/middleware.ts +3 -2
  19. package/src/editor/routes/try-again.ts +2 -1
  20. package/src/editor/server/admin-props.ts +2 -1
  21. package/src/editor/server/mode.ts +13 -3
  22. package/src/editor/styles/levels/live.css +1 -1
  23. package/src/editor/types.ts +25 -6
  24. package/src/files-route.ts +9 -4
  25. package/src/forms/endpoint.ts +11 -16
  26. package/src/head.ts +10 -1
  27. package/src/index.ts +53 -7
  28. package/src/live/README.md +4 -2
  29. package/src/live/auth/auth.ts +4 -1
  30. package/src/live/auth/guard.ts +3 -2
  31. package/src/live/auth/index.ts +6 -7
  32. package/src/live/auth/login.ts +17 -8
  33. package/src/live/auth/middleware.ts +7 -3
  34. package/src/live/auth/request.ts +92 -11
  35. package/src/live/auth/views.ts +9 -4
  36. package/src/live/autosaves.ts +59 -16
  37. package/src/live/backend.ts +13 -1
  38. package/src/live/drafts-store.ts +4 -3
  39. package/src/live/editor.ts +92 -67
  40. package/src/live/git.ts +112 -34
  41. package/src/live/github.ts +128 -13
  42. package/src/live/locks.ts +92 -26
  43. package/src/live/mode.ts +131 -18
  44. package/src/live/notices.ts +100 -0
  45. package/src/live/redis.ts +161 -39
  46. package/src/live/runtime.ts +11 -3
  47. package/src/live/status-cache.ts +219 -0
  48. package/src/live/status.ts +14 -2
  49. package/src/live/timings.ts +24 -0
  50. package/src/media.ts +26 -1
package/src/live/git.ts CHANGED
@@ -42,9 +42,11 @@ import { movesKey } from './autosaves.ts';
42
42
  import type { WaitingChange } from '../editor/types.ts';
43
43
  import { MissingBranchError, StaleHeadError, type Branch, type CommitSummary, type Comparison, type GitBackend, type Head, type Heads, type TreeChange } from './backend.ts';
44
44
  import { HttpError } from './github.ts';
45
+ import { runningCommitFrom } from './mode.ts';
45
46
  import { mergeFile } from './merge.ts';
46
47
  import { draftMessage, mergeMessage, parseMessage, publishMessage, type Person } from './messages.ts';
47
48
  import type { RedisClient } from './redis.ts';
49
+ import { landed, StatusCache } from './status-cache.ts';
48
50
  import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
49
51
  import { publishState, waitingFromCommits, type DeploymentFact, type PublishRecord, type PublishState, type WaitingFile } from './status.ts';
50
52
 
@@ -103,14 +105,20 @@ export interface HistoryEntry {
103
105
  state: PublishState;
104
106
  }
105
107
 
106
- export interface RailwayConfig {
107
- /** CARACMS_RAILWAY_TOKEN: a project token (Decision 12). */
108
- token: string;
109
- serviceId: string;
110
- environmentId: string;
111
- /** For tests. Default: global fetch. */
112
- fetch?: typeof fetch;
113
- }
108
+ /**
109
+ * Try again's redeployer (runs-anywhere Decision 11): Railway's API with a project token
110
+ * (`CARACMS_RAILWAY_TOKEN`, M4 Decision 12), or a deploy hook (`CARACMS_DEPLOY_HOOK_URL`):
111
+ * one bare POST, any 2xx a success. The hook's URL is a secret: never logged or sent on.
112
+ */
113
+ export type Redeployer =
114
+ | { kind: 'railway'; token: string; serviceId: string; environmentId: string; fetch?: typeof fetch }
115
+ | { kind: 'hook'; url: string; fetch?: typeof fetch; timeoutMs?: number };
116
+
117
+ /** A deploy hook that hasn't answered in this long has failed (it's one POST; nothing retries by itself). */
118
+ export const DEPLOY_HOOK_TIMEOUT_MS = 20_000;
119
+
120
+ /** Try again's answer when no redeployer is set: no host's name (Decision 11). */
121
+ export const NO_REDEPLOYER_REASON = "Try again isn't set up on this site: a developer can redeploy it.";
114
122
 
115
123
  export interface LiveGitOptions {
116
124
  backend: GitBackend;
@@ -119,12 +127,16 @@ export interface LiveGitOptions {
119
127
  now?: () => number;
120
128
  sleep?: (ms: number) => Promise<void>;
121
129
  log?: (line: string) => void;
122
- /** The running server's commit, read on every status (tests move it). Default: CARACMS_RUNNING_COMMIT or RAILWAY_GIT_COMMIT_SHA. */
130
+ /** The running server's commit, read on every status (tests move it). Default: mode.ts → `runningCommitFrom` over the environment and `bakedCommit`. */
123
131
  runningCommit?: () => string | null;
132
+ /** The commit baked in at build (runs-anywhere Decision 9), for the default `runningCommit`. */
133
+ bakedCommit?: string | null;
124
134
  timings?: Partial<LiveTimings>;
125
- railway?: RailwayConfig | null;
135
+ redeployer?: Redeployer | null;
126
136
  /** Redis key prefix. Default `caracms:`. */
127
137
  keyPrefix?: string;
138
+ /** The status poll's cache (Redis diet), told of the publish lock, the publish record and the moves Publish clears. Default: one of its own. */
139
+ statusCache?: StatusCache;
128
140
  }
129
141
 
130
142
  /** The site file that holds Site Settings, relative to the site (Decision 15). Defined in config.ts (M5a: the reserved `settings` collection). */
@@ -153,6 +165,12 @@ export class LiveGit {
153
165
  readonly redis: RedisClient;
154
166
  readonly timings: LiveTimings;
155
167
  readonly now: () => number;
168
+ /**
169
+ * The status poll's write-through cache (status-cache.ts, Redis diet Decisions 1–5),
170
+ * one per process: kept here because this module writes some of what it holds; the
171
+ * live editor's autosaves, locks and notices write through the same one.
172
+ */
173
+ readonly statusCache: StatusCache;
156
174
  /** Phase 2 fills this: commit every pending autosave (Decision 10 step 1; Decision 8 before a merge). */
157
175
  commitPending: () => Promise<void> = async () => {};
158
176
  /**
@@ -168,7 +186,7 @@ export class LiveGit {
168
186
  private readonly sleep: (ms: number) => Promise<void>;
169
187
  private readonly log: (line: string) => void;
170
188
  private readonly runningCommit: () => string | null;
171
- private readonly railway: RailwayConfig | null;
189
+ private readonly redeployer: Redeployer | null;
172
190
  private readonly keyPrefix: string;
173
191
 
174
192
  private copyHead: Head | null = null;
@@ -194,9 +212,11 @@ export class LiveGit {
194
212
  this.now = options.now ?? Date.now;
195
213
  this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
196
214
  this.log = options.log ?? ((line) => console.log(`[caracms] ${line}`));
197
- this.runningCommit = options.runningCommit ?? (() => process.env.CARACMS_RUNNING_COMMIT || process.env.RAILWAY_GIT_COMMIT_SHA || null);
198
- this.railway = options.railway ?? null;
215
+ const baked = options.bakedCommit ?? null;
216
+ this.runningCommit = options.runningCommit ?? (() => runningCommitFrom(process.env, baked));
217
+ this.redeployer = options.redeployer ?? null;
199
218
  this.keyPrefix = options.keyPrefix ?? 'caracms:';
219
+ this.statusCache = options.statusCache ?? new StatusCache({ redis: this.redis, maxAgeMs: this.timings.statusCacheMs, now: this.now });
200
220
  this.prefix = repoPrefix(options.site);
201
221
  // Every collection's folder, shared blocks' included (M5a Decision 8); Site Settings is its one file (inScope).
202
222
  this.folders = Object.values(options.site.collections)
@@ -241,6 +261,20 @@ export class LiveGit {
241
261
  return `${this.keyPrefix}${name}`;
242
262
  }
243
263
 
264
+ /** The Publish lock's key (one Publish at a time) and the newest publish's record's (Decision 10, 12). */
265
+ get publishLockKey(): string {
266
+ return this.key('publish:lock');
267
+ }
268
+ get publishRecordKey(): string {
269
+ return this.key('publish:last');
270
+ }
271
+
272
+ private async setPublishRecord(record: PublishRecord): Promise<void> {
273
+ const raw = JSON.stringify(record);
274
+ await landed(this.statusCache, this.publishRecordKey, () => this.redis.set(this.publishRecordKey, raw));
275
+ this.statusCache.string(this.publishRecordKey, raw);
276
+ }
277
+
244
278
  /** Run `fn` alone among this server's git operations, so the copy is never changed by two at once. */
245
279
  private exclusive<T>(fn: () => Promise<T>): Promise<T> {
246
280
  const run = this.queue.then(fn, fn);
@@ -642,9 +676,11 @@ export class LiveGit {
642
676
  * Redis lock); a second press while one runs joins it.
643
677
  */
644
678
  async publish(by: Person): Promise<PublishResult> {
645
- const lock = this.key('publish:lock');
679
+ const lock = this.publishLockKey;
646
680
  const token = randomBytes(12).toString('base64url');
647
- if (!(await this.redis.set(lock, token, { nx: true, px: this.timings.publishLockMs }))) {
681
+ const taken = await landed(this.statusCache, lock, () => this.redis.set(lock, token, { nx: true, px: this.timings.publishLockMs }));
682
+ if (taken) this.statusCache.string(lock, token);
683
+ else {
648
684
  const until = this.now() + this.timings.publishLockMs;
649
685
  while (this.now() < until && (await this.redis.get(lock)) !== null) await this.sleep(500);
650
686
  return { kind: 'joined', record: await this.lastPublish() };
@@ -659,7 +695,10 @@ export class LiveGit {
659
695
  throw new Error(`caracms: publish gave up after ${MAX_ATTEMPTS} tries (main or drafts kept moving).`);
660
696
  });
661
697
  } finally {
662
- await this.redis.compareAndDelete(lock, token).catch(() => {});
698
+ // Let go: gone when it was still ours; otherwise (it expired and someone else took it, or the call failed) not known here.
699
+ const gone = await this.redis.compareAndDelete(lock, token).catch(() => false);
700
+ if (gone) this.statusCache.string(lock, null);
701
+ else this.statusCache.drop(lock);
663
702
  }
664
703
  }
665
704
 
@@ -710,16 +749,28 @@ export class LiveGit {
710
749
  this.historyCache = null;
711
750
  // The moves this publish took live are done with (a move made since stays waiting).
712
751
  const published = [...records].filter(([old]) => files.includes(old)).map(([old]) => old);
713
- if (published.length) await this.redis.hdel(movesKey(this.keyPrefix), ...published).catch(() => 0);
752
+ if (published.length) {
753
+ const moves = movesKey(this.keyPrefix);
754
+ const cleared = await this.redis.hdel(moves, ...published).then(
755
+ () => true,
756
+ () => false,
757
+ );
758
+ if (cleared) this.statusCache.hash(moves, {}, published);
759
+ else this.statusCache.drop(moves);
760
+ }
714
761
  const record: PublishRecord = { commit, by, at: new Date(this.now()).toISOString(), files, titles, tryAgainAt: null };
715
- await this.redis.set(this.key('publish:last'), JSON.stringify(record));
762
+ await this.setPublishRecord(record);
716
763
  this.log(`published ${commit.slice(0, 7)} (attempt ${attempt}): ${titles.join(', ')}`);
717
764
  return { kind: 'published', record, attempts: attempt, fileTitles };
718
765
  }
719
766
 
720
767
  /** The newest publish, from Redis. */
721
768
  async lastPublish(): Promise<PublishRecord | null> {
722
- const raw = await this.redis.get(this.key('publish:last'));
769
+ return LiveGit.publishRecordFrom(await this.redis.get(this.publishRecordKey));
770
+ }
771
+
772
+ /** A publish record from its text as read (the status cache's, on a poll). */
773
+ static publishRecordFrom(raw: string | null): PublishRecord | null {
723
774
  if (!raw) return null;
724
775
  try {
725
776
  return JSON.parse(raw) as PublishRecord;
@@ -842,14 +893,15 @@ export class LiveGit {
842
893
  // ── Publish status (Decision 12) ────────────────────────────────────────────
843
894
 
844
895
  /** The newest publish's state. Asks GitHub at most every `deploymentReadMs`, and only while it isn't live. */
845
- async publishStatus(): Promise<PublishStatus> {
846
- const record = await this.lastPublish();
896
+ async publishStatus(options: { record?: PublishRecord | null } = {}): Promise<PublishStatus> {
897
+ // `record`: the newest publish as the caller read it (the status poll's cache); otherwise read here.
898
+ const record = options.record !== undefined ? options.record : await this.lastPublish();
847
899
  if (!record) return { state: 'none', record: null, assumed: false, canTryAgain: false };
848
900
  const running = this.runningCommit();
849
901
  if (running === null) {
850
902
  if (!this.assumedLogged) {
851
903
  this.assumedLogged = true;
852
- this.log('no RAILWAY_GIT_COMMIT_SHA or CARACMS_RUNNING_COMMIT: no deploy pipeline, so a publish reads as live once its refs moved');
904
+ this.log('no running commit known (CARACMS_RUNNING_COMMIT, a host\'s commit variable, or one baked in at build): a publish reads as live once its refs moved');
853
905
  }
854
906
  return { state: 'live', record, assumed: true, canTryAgain: false };
855
907
  }
@@ -858,8 +910,8 @@ export class LiveGit {
858
910
  let facts: DeploymentFact[] = [];
859
911
  let later = new Set<string>();
860
912
  if (!includes) ({ facts, later } = await this.deploymentFacts(record));
861
- const { state } = publishState({ record, running, runningIncludesPublish: includes, laterOnMain: later, deployments: facts });
862
- return { state, record, assumed: false, canTryAgain: state === 'failed' && this.railway !== null };
913
+ const { state } = publishState({ record, running, runningIncludesPublish: includes, laterOnMain: later, deployments: facts, now: this.now(), slowAfterMs: this.timings.publishSlowMs });
914
+ return { state, record, assumed: false, canTryAgain: (state === 'failed' || state === 'slow') && this.redeployer !== null };
863
915
  });
864
916
  }
865
917
 
@@ -904,7 +956,7 @@ export class LiveGit {
904
956
  } catch (error) {
905
957
  if (error instanceof HttpError && (error.status === 403 || error.status === 404)) {
906
958
  this.deploymentsDenied = true;
907
- this.log(`can't read GitHub Deployments (${error.status}): the App needs "Deployments: read". A publish will show as publishing until the server runs it.`);
959
+ this.log(`can't read GitHub Deployments (${error.status}): the site's GitHub token or App needs "Deployments: read" to tell a failed publish. Without it, a publish shows as publishing until the server runs it, then as taking longer than usual.`);
908
960
  return { facts: [], later: new Set() };
909
961
  }
910
962
  throw error;
@@ -930,7 +982,7 @@ export class LiveGit {
930
982
  const parsed = parseMessage(c.message);
931
983
  if (parsed.kind !== 'publish') return;
932
984
  const live = running === null || (runningAt !== -1 && i >= runningAt) || (status.record?.commit === c.sha && status.state === 'live');
933
- const pending: PublishState = status.state === 'failed' ? 'failed' : 'publishing';
985
+ const pending: PublishState = status.state === 'failed' || status.state === 'slow' ? status.state : 'publishing';
934
986
  entries.push({ commit: c.sha, by: parsed.publishedBy, at: c.date, titles: parsed.titles, files: parsed.publishedFiles, state: live ? 'live' : pending });
935
987
  });
936
988
  this.historyCache = { at: this.now(), entries };
@@ -940,13 +992,26 @@ export class LiveGit {
940
992
 
941
993
  // ── Try again (Decision 12) ─────────────────────────────────────────────────
942
994
 
943
- /** Redeploy through Railway's API with the project token. Never publishes again. */
995
+ /**
996
+ * Redeploy through the redeployer (runs-anywhere Decision 11): Railway's API with the
997
+ * project token, or the deploy hook's one POST. Never publishes again, and never
998
+ * retries by itself. A success restarts the "taking longer than usual" clock.
999
+ */
944
1000
  async tryAgain(): Promise<{ ok: true } | { ok: false; reason: string }> {
945
- if (!this.railway) return { ok: false, reason: 'No CARACMS_RAILWAY_TOKEN: a developer can redeploy from Railway.' };
1001
+ if (!this.redeployer) return { ok: false, reason: NO_REDEPLOYER_REASON };
946
1002
  const record = await this.lastPublish();
947
1003
  if (!record) return { ok: false, reason: 'Nothing has been published.' };
948
- const { token, serviceId, environmentId } = this.railway;
949
- const res = await (this.railway.fetch ?? fetch)(RAILWAY_API, {
1004
+ const r = this.redeployer.kind === 'hook' ? await this.callHook(this.redeployer) : await this.redeployOnRailway(this.redeployer);
1005
+ if (!r.ok) return r;
1006
+ await this.setPublishRecord({ ...record, tryAgainAt: new Date(this.now()).toISOString() });
1007
+ this.deploymentsCache = null;
1008
+ this.log(`try again: ${this.redeployer.kind === 'hook' ? 'the deploy hook took it' : 'Railway is redeploying'} (publish ${record.commit.slice(0, 7)})`);
1009
+ return { ok: true };
1010
+ }
1011
+
1012
+ private async redeployOnRailway(railway: Extract<Redeployer, { kind: 'railway' }>): Promise<{ ok: true } | { ok: false; reason: string }> {
1013
+ const { token, serviceId, environmentId } = railway;
1014
+ const res = await (railway.fetch ?? fetch)(RAILWAY_API, {
950
1015
  method: 'POST',
951
1016
  headers: { 'content-type': 'application/json', 'project-access-token': token },
952
1017
  body: JSON.stringify({
@@ -956,15 +1021,28 @@ export class LiveGit {
956
1021
  });
957
1022
  const body = (await res.json().catch(() => ({}))) as { data?: { serviceInstanceRedeploy?: boolean }; errors?: { message: string }[] };
958
1023
  if (!res.ok || body.errors?.length || !body.data?.serviceInstanceRedeploy) {
959
- const reason = body.errors?.map((e) => e.message).join('; ') || `Railway answered ${res.status}`;
1024
+ const reason = body.errors?.map((e) => e.message).join('; ') || `the host answered ${res.status}`;
960
1025
  this.log(`try again: Railway refused the redeploy (${reason})`);
961
1026
  return { ok: false, reason };
962
1027
  }
963
- await this.redis.set(this.key('publish:last'), JSON.stringify({ ...record, tryAgainAt: new Date(this.now()).toISOString() }));
964
- this.deploymentsCache = null;
965
- this.log(`try again: Railway is redeploying (publish ${record.commit.slice(0, 7)})`);
966
1028
  return { ok: true };
967
1029
  }
1030
+
1031
+ /** The deploy hook: one POST, no body, no extra headers; any 2xx is success. The URL is never logged or returned. */
1032
+ private async callHook(hook: Extract<Redeployer, { kind: 'hook' }>): Promise<{ ok: true } | { ok: false; reason: string }> {
1033
+ let reason: string;
1034
+ try {
1035
+ const res = await (hook.fetch ?? fetch)(hook.url, { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(hook.timeoutMs ?? DEPLOY_HOOK_TIMEOUT_MS) });
1036
+ await res.body?.cancel().catch(() => {});
1037
+ if (res.status >= 200 && res.status < 300) return { ok: true };
1038
+ reason = `the deploy hook answered ${res.status}`;
1039
+ } catch (error) {
1040
+ const name = error instanceof Error ? error.name : '';
1041
+ reason = name === 'TimeoutError' || name === 'AbortError' ? "the deploy hook didn't answer in time" : "the deploy hook couldn't be reached";
1042
+ }
1043
+ this.log(`try again: ${reason}`);
1044
+ return { ok: false, reason };
1045
+ }
968
1046
  }
969
1047
 
970
1048
  export interface Moves {
@@ -1,6 +1,9 @@
1
1
  /**
2
- * GitHub as the site's App (M4 Phase 1; lifted from M1's spike, `spikes/git/src/github.ts`).
2
+ * GitHub as the site's App or a fine-grained token (M4 Phase 1, lifted from M1's spike,
3
+ * `spikes/git/src/github.ts`; the token from runs-anywhere Decision 3).
3
4
  *
5
+ * - **A fine-grained token** (`CARACMS_GITHUB_TOKEN`) is the bearer as it is. GitHub sends
6
+ * its expiry on every response, which is kept for the editor (Decision 5).
4
7
  * - **The App's JWT**: RS256, signed with the App's private key, `iat` backdated a
5
8
  * minute for clock drift, nine minutes long (GitHub's limit is ten).
6
9
  * - **An installation token scoped to the one repo**: the installation is found with
@@ -14,16 +17,18 @@
14
17
  * that stops answering fails the operation instead of holding it (and Publish's or
15
18
  * the check's callers) forever.
16
19
  *
17
- * The private key is never logged.
20
+ * - **Access problems** (Decision 5): every response is watched for a refused credential
21
+ * (401), a write refused for lack of a permission (403 "not accessible"), and, for a
22
+ * token, an expiry within 14 days. `accessProblem()` says which holds now; a later
23
+ * success clears the first two.
24
+ *
25
+ * The private key and the token are never logged.
18
26
  */
19
27
  import { createSign } from 'node:crypto';
20
28
 
21
29
  const API = 'https://api.github.com';
22
30
 
23
- export interface AppConnection {
24
- appId: string;
25
- /** The PEM. Railway variables often carry it with literal `\n`s; those are turned back into newlines. */
26
- privateKey: string;
31
+ interface ConnectionBase {
27
32
  owner: string;
28
33
  repo: string;
29
34
  /** For tests. Default: global fetch. */
@@ -34,6 +39,63 @@ export interface AppConnection {
34
39
  timeoutMs?: number;
35
40
  }
36
41
 
42
+ export interface AppConnection extends ConnectionBase {
43
+ appId: string;
44
+ /** The PEM. Railway variables often carry it with literal `\n`s; those are turned back into newlines. */
45
+ privateKey: string;
46
+ }
47
+
48
+ /** A fine-grained personal access token on the one repo (Decision 3). */
49
+ export interface TokenConnection extends ConnectionBase {
50
+ token: string;
51
+ }
52
+
53
+ export type GitHubConnection = AppConnection | TokenConnection;
54
+
55
+ /**
56
+ * What's wrong with GitHub access right now (Decision 5), shown in the editor while it holds.
57
+ * `credential` says which one the site uses, so the sentence names the right fix.
58
+ */
59
+ export type GitHubAccessProblem =
60
+ | { kind: 'refused'; credential: 'app' | 'token' }
61
+ | { kind: 'permission'; credential: 'app' | 'token'; permission: string }
62
+ | { kind: 'expiring'; credential: 'token'; expires: string };
63
+
64
+ /** A token expiring within this long shows in the editor (Decision 5). */
65
+ export const TOKEN_EXPIRY_WARNING_MS = 14 * 24 * 60 * 60_000;
66
+
67
+ /** The permission every write needs; Deployments read is optional and never shown (Decision 10). */
68
+ const CONTENTS_PERMISSION = 'Contents: Read and write';
69
+
70
+ /**
71
+ * `github-authentication-token-expiration`: "2026-11-03 10:00:00 UTC" or "… -0700".
72
+ * Null when it doesn't parse.
73
+ */
74
+ export function parseTokenExpiry(value: string | null): number | null {
75
+ const m = value && /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2})?)\s*(UTC|Z|[+-]\d{2}:?\d{2})?$/i.exec(value.trim());
76
+ if (!m) return null;
77
+ const zone = !m[3] || /^(utc|z)$/i.test(m[3]) ? 'Z' : m[3].replace(/^([+-]\d{2}):?(\d{2})$/, '$1:$2');
78
+ const at = Date.parse(`${m[1]}T${m[2]}${zone}`);
79
+ return Number.isNaN(at) ? null : at;
80
+ }
81
+
82
+ /** The sentence the editor shows for an access problem, for the developer it's forwarded to. */
83
+ export function accessProblemMessage(problem: GitHubAccessProblem): string {
84
+ const what = problem.credential === 'token' ? 'GitHub token (CARACMS_GITHUB_TOKEN)' : 'GitHub App';
85
+ switch (problem.kind) {
86
+ case 'refused':
87
+ return problem.credential === 'token'
88
+ ? `GitHub refused this site's ${what}: it may have expired or been revoked. Changes can't be saved to GitHub until a developer makes a new fine-grained token and sets it.`
89
+ : `GitHub refused this site's ${what}: it may have been uninstalled from the repo or suspended. Changes can't be saved to GitHub until a developer reinstalls it.`;
90
+ case 'permission':
91
+ return `GitHub refused a change because this site's ${what} lacks the "${problem.permission}" permission. A developer can add it in the ${problem.credential === 'token' ? "token's settings" : "App's settings"}.`;
92
+ case 'expiring': {
93
+ const date = new Date(problem.expires).toLocaleDateString('en-IE', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
94
+ return `This site's ${what} expires on ${date}. After that, changes can't be saved to GitHub: a developer can regenerate it, or make a new one, and set it before then.`;
95
+ }
96
+ }
97
+ }
98
+
37
99
  /** A GitHub request that takes longer than this fails (its caller treats it as any failure). */
38
100
  export const GITHUB_TIMEOUT_MS = 20_000;
39
101
 
@@ -143,6 +205,8 @@ export class GitHubClient {
143
205
  readonly tracker: Tracker;
144
206
  readonly owner: string;
145
207
  readonly repo: string;
208
+ /** Which credential this client signs in with. */
209
+ readonly credential: 'app' | 'token';
146
210
  private readonly appId: string;
147
211
  private readonly key: string;
148
212
  private readonly fetcher: typeof fetch;
@@ -151,10 +215,23 @@ export class GitHubClient {
151
215
  private token = '';
152
216
  private tokenExpires = 0;
153
217
  private signingIn: Promise<void> | null = null;
218
+ /** Decision 5: what the last responses said about access. */
219
+ private refused = false;
220
+ private missingPermission: string | null = null;
221
+ private personalTokenExpires: number | null = null;
154
222
 
155
- constructor(connection: AppConnection, tracker: Tracker) {
156
- this.appId = connection.appId;
157
- this.key = connection.privateKey.includes('\\n') ? connection.privateKey.replace(/\\n/g, '\n') : connection.privateKey;
223
+ constructor(connection: GitHubConnection, tracker: Tracker) {
224
+ if ('token' in connection) {
225
+ this.credential = 'token';
226
+ this.appId = '';
227
+ this.key = '';
228
+ this.token = connection.token;
229
+ this.tokenExpires = Number.POSITIVE_INFINITY;
230
+ } else {
231
+ this.credential = 'app';
232
+ this.appId = connection.appId;
233
+ this.key = connection.privateKey.includes('\\n') ? connection.privateKey.replace(/\\n/g, '\n') : connection.privateKey;
234
+ }
158
235
  this.owner = connection.owner;
159
236
  this.repo = connection.repo;
160
237
  this.fetcher = connection.fetch ?? fetch;
@@ -163,6 +240,37 @@ export class GitHubClient {
163
240
  this.tracker = tracker;
164
241
  }
165
242
 
243
+ /** What's wrong with GitHub access now, the most serious first; null when nothing is (Decision 5). */
244
+ accessProblem(): GitHubAccessProblem | null {
245
+ if (this.refused) return { kind: 'refused', credential: this.credential };
246
+ if (this.missingPermission) return { kind: 'permission', credential: this.credential, permission: this.missingPermission };
247
+ const expires = this.personalTokenExpires;
248
+ if (this.credential === 'token' && expires !== null && expires - this.now() < TOKEN_EXPIRY_WARNING_MS) {
249
+ return { kind: 'expiring', credential: 'token', expires: new Date(expires).toISOString() };
250
+ }
251
+ return null;
252
+ }
253
+
254
+ /**
255
+ * Keep what a response says about access. A 401 is a refused credential. A write
256
+ * refused for lack of a permission (`notPermitted`: a 403 or a GraphQL error saying
257
+ * "not accessible") is a missing permission, except on Deployments (optional,
258
+ * Decision 10); a rate limit's 403 isn't one. Any success clears a refusal; a
259
+ * successful write clears a missing permission.
260
+ */
261
+ private observe(res: Response, path: string, write: boolean, notPermitted: boolean, hadErrors = false): void {
262
+ if (this.credential === 'token') {
263
+ const expires = parseTokenExpiry(res.headers.get('github-authentication-token-expiration'));
264
+ if (expires !== null) this.personalTokenExpires = expires;
265
+ }
266
+ if (res.status === 401) this.refused = true;
267
+ else if (res.ok && !notPermitted) {
268
+ this.refused = false;
269
+ if (write && !hadErrors) this.missingPermission = null; // a GraphQL 200 carrying errors didn't land
270
+ }
271
+ if (notPermitted && write && !/\/deployments/.test(path)) this.missingPermission = CONTENTS_PERMISSION;
272
+ }
273
+
166
274
  get nameWithOwner() {
167
275
  return `${this.owner}/${this.repo}`;
168
276
  }
@@ -176,6 +284,7 @@ export class GitHubClient {
176
284
  const jwt = appJwt(this.appId, this.key, Math.floor(this.now() / 1000));
177
285
  const install = (await this.request('GET', this.repoPath('/installation'), undefined, jwt).catch((error: unknown) => {
178
286
  if (error instanceof HttpError && error.status === 404) {
287
+ this.refused = true;
179
288
  throw new Error(`The GitHub App (id ${this.appId}) isn't installed on ${this.nameWithOwner}. Install it on that repo.`);
180
289
  }
181
290
  throw error;
@@ -188,8 +297,9 @@ export class GitHubClient {
188
297
  this.tokenExpires = Date.parse(tok.expires_at);
189
298
  }
190
299
 
191
- /** The installation token, refreshed five minutes before it expires. */
300
+ /** The installation token, refreshed five minutes before it expires; a personal token as it is. */
192
301
  private async auth(): Promise<string> {
302
+ if (this.credential === 'token') return this.token;
193
303
  if (!this.token || this.now() > this.tokenExpires - 5 * 60_000) {
194
304
  this.signingIn ??= this.signIn().finally(() => {
195
305
  this.signingIn = null;
@@ -207,6 +317,7 @@ export class GitHubClient {
207
317
  async bytes(path: string): Promise<Buffer> {
208
318
  const res = await this.fetcher(`${API}${path}`, { headers: this.headers(await this.auth()), redirect: 'follow', signal: this.signal() });
209
319
  this.tracker.record({ endpoint: `GET ${shorten(path.replace(/^\/repos\/[^/]+\/[^/]+/, '').replace(/\?.*$/, ''))}`, status: res.status, creating: false });
320
+ this.observe(res, path, false, false);
210
321
  if (!res.ok) throw new HttpError(res.status, `GET ${path} -> ${res.status}`, await res.text().catch(() => ''));
211
322
  return Buffer.from(await res.arrayBuffer());
212
323
  }
@@ -234,7 +345,8 @@ export class GitHubClient {
234
345
  signal: this.signal(),
235
346
  });
236
347
  const endpointPath = path.replace(/^\/repos\/[^/]+\/[^/]+/, '').replace(/\?.*$/, '');
237
- this.tracker.record({ endpoint: `${method} ${shorten(endpointPath)}`, status: res.status, creating: !notCreating && isContentCreating(method, path, false) });
348
+ const creating = !notCreating && isContentCreating(method, path, false);
349
+ this.tracker.record({ endpoint: `${method} ${shorten(endpointPath)}`, status: res.status, creating });
238
350
  const text = await res.text();
239
351
  let json: unknown = null;
240
352
  try {
@@ -242,14 +354,15 @@ export class GitHubClient {
242
354
  } catch {
243
355
  json = text;
244
356
  }
357
+ const message = (json as { message?: string } | null)?.message ?? text.slice(0, 200);
358
+ this.observe(res, path, creating, res.status === 403 && /not accessible/i.test(message));
245
359
  if (!res.ok) {
246
- const message = (json as { message?: string } | null)?.message ?? text.slice(0, 200);
247
360
  throw new HttpError(res.status, `${method} ${endpointPath} -> ${res.status}: ${message}`, json);
248
361
  }
249
362
  return json;
250
363
  }
251
364
 
252
- /** GraphQL as the installation. Throws GraphQLError when the answer has `errors`. */
365
+ /** GraphQL as the installation (or the token). Throws GraphQLError when the answer has `errors`. */
253
366
  async graphql<T = unknown>(query: string, variables: Record<string, unknown>, name: string): Promise<T> {
254
367
  const isMutation = /^\s*mutation\b/.test(query);
255
368
  const res = await this.fetcher(`${API}/graphql`, {
@@ -264,6 +377,8 @@ export class GitHubClient {
264
377
  if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) throw error; // timed out mid-body
265
378
  return {};
266
379
  })) as { data?: T; errors?: { type?: string; message?: string }[] };
380
+ const notPermitted = (json.errors ?? []).some((e) => e.type === 'FORBIDDEN' || /not accessible/i.test(e.message ?? ''));
381
+ this.observe(res, '/graphql', isMutation, notPermitted, (json.errors?.length ?? 0) > 0);
267
382
  if (!res.ok && !json.errors) throw new HttpError(res.status, `graphql ${name} -> ${res.status}`, json);
268
383
  if (json.errors?.length) throw new GraphQLError(json.errors);
269
384
  return json.data as T;