create-pmos 0.55.5 → 0.57.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": "create-pmos",
3
- "version": "0.55.5",
3
+ "version": "0.57.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@39b0c74
1
+ kernel-source: pmos@d3d97f5
2
2
  assembled: 2026-10-06
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-06T10:22:39Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T11:08:49Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-06T10:22:39Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T11:08:49Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-06T10:22:39Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T11:08:49Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -64,7 +64,7 @@ The steps below do the same thing by hand, from a PMOS checkout.
64
64
 
65
65
  Under those lines it prints the phone URL as a **QR code**, and the same URL as text (`http://100.x.y.z:7433/?token=<token>`). The token is shown only at startup and never logged again. A token you supply with `PMOS_TOKEN` is never printed, so that mode prints no QR code.
66
66
 
67
- 3. **Open it from your phone.** Point the phone camera at the QR code and open the link. Or type the URL into the phone's browser. The server sets a sign-in cookie (7 days) and redirects to `http://100.x.y.z:7433/`, so the token does not stay in the address bar or the history.
67
+ 3. **Open it from your phone.** Point the phone camera at the QR code and open the link. Or type the URL into the phone's browser. The server sets a sign-in cookie (30 days) and redirects to `http://100.x.y.z:7433/`, so the token does not stay in the address bar or the history.
68
68
 
69
69
  If the phone has no valid cookie, it shows a **sign-in page**. Paste the token there. The field is a password field, so the phone's password manager can save it. This is also what you see after the cookie expires, or after a restart that generated a new token.
70
70
 
@@ -123,7 +123,40 @@ Two locks protect the workspace, and each one is enforced by the server:
123
123
 
124
124
  The Access check in the server is a **presence check**: it confirms that Access is in front, and it fails closed when you forget the Access application. It does not verify the assertion's signature, because that needs Cloudflare's signing keys fetched over the network, and the workspace makes no outbound requests (D79). The origin listens on loopback only, so the tunnel is the only way to reach it with the public hostname.
125
125
 
126
- ### Setup
126
+ ### The Online switch (recommended)
127
+
128
+ With the Online switch, the workspace runs the tunnel itself, and Cloudflare Access is the only login. You do not need a start line, a second terminal or the workspace token on your phone (D89).
129
+
130
+ How the switch protects the workspace:
131
+
132
+ - On the public hostname, the server checks the `Cf-Access-Jwt-Assertion` header on every request and on every terminal connection. The assertion must be signed by your Cloudflare team's key, be for your Access application's audience tag, be in date, and be for the one email you allow. If any check fails, the server returns `403`.
133
+ - To check the signature, the server gets your team's public keys from `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`. It does this only while Online is on, at most once an hour, plus once a minute at most for a new key. This is the only outbound request it makes for Online. It sends nothing of yours.
134
+ - Only the computer that runs the workspace can change the switch or its settings. A phone can see the state but cannot change it.
135
+
136
+ Do these steps one time:
137
+
138
+ 1. **Create the tunnel and its DNS record** on the computer that runs the workspace:
139
+
140
+ ```sh
141
+ brew install cloudflared # or your platform's package
142
+ cloudflared tunnel login
143
+ cloudflared tunnel create pmos
144
+ cloudflared tunnel route dns pmos pmos.example.com
145
+ ```
146
+
147
+ Write `~/.cloudflared/config.yml` as in step 4 of the manual setup below, with your hostname.
148
+
149
+ 2. **Create the Access application.** In Cloudflare Zero Trust, go to **Access › Applications › Add an application › Self-hosted**. Set the subdomain and domain to your hostname. Add one policy: **Allow**, **Emails**, your email address. On the application's **Overview**, copy the **Application Audience (AUD) Tag**.
150
+
151
+ 3. **Optional: sign in with Google.** Go to **Settings › Authentication › Login methods › Add new › Google**, and follow the steps there to create a Google OAuth client. In the Access application's **Login methods**, select Google and turn on **Instant Auth**, so the login goes directly to Google.
152
+
153
+ 4. **Fill in the Online section.** In the workspace on that computer, open **Settings › Online**. Enter the public address, the tunnel name (`pmos`), your team domain (`<team>.cloudflareaccess.com`, shown under **Settings › Custom Pages** in Zero Trust), the AUD tag and your email. Then select **Turn on**.
154
+
155
+ The status line shows when the tunnel runs. Open your hostname on the phone and sign in with Google or the email code. The workspace remembers the switch: if it restarts while Online is on, it goes online again. Select **Turn off** to stop the tunnel. The hostname then gets `403` at once. The tunnel's output is in `online-tunnel.log` in the workspace state folder.
156
+
157
+ If `PMOS_PUBLIC_HOST` is set when the workspace starts, the switch is not available, and the manual setup below applies.
158
+
159
+ ### Setup by hand (`PMOS_PUBLIC_HOST`, with the token)
127
160
 
128
161
  1. **Pick the machine.** Use a machine that stays on: your desktop, or a small Linux VPS. Install the workspace there as usual, and sign in to your agent CLI in a workspace terminal.
129
162
 
@@ -162,7 +195,7 @@ The Access check in the server is a **presence check**: it confirms that Access
162
195
 
163
196
  6. **Start the tunnel:** `cloudflared tunnel run pmos`. To keep it running after a reboot, install it as a service (`sudo cloudflared service install`).
164
197
 
165
- 7. **Open `https://pm-os.app/`.** Cloudflare asks for your login. The workspace then shows its sign-in page. Paste the token. The workspace sets a `Secure` session cookie for 7 days.
198
+ 7. **Open `https://pm-os.app/`.** Cloudflare asks for your login. The workspace then shows its sign-in page. Paste the token. The workspace sets a `Secure` session cookie for 30 days. Set the Access application's session duration to 1 month as well, so each device signs in about once a month.
166
199
 
167
200
  ### Check that it is closed
168
201
 
@@ -184,6 +217,8 @@ Run these from a machine that is **not** signed in to Access:
184
217
  - **`403` "the public host is only reachable through Cloudflare Access"** — the request reached the workspace without passing Access. Create the Access application for the hostname (Setup step 3).
185
218
  - **`403` "forbidden origin/host" on the public hostname** — `PMOS_PUBLIC_HOST` is not set, or it names another hostname, or the tunnel rewrites the `Host` header. Remove `httpHostHeader` from the tunnel's ingress rule.
186
219
  - **A local reverse proxy now gets `401`** — it adds a forwarding header, so its requests need the token. This is intended.
220
+ - **`403` "your Cloudflare sign-in could not be verified for this workspace"** (Online switch) — check the AUD tag, the team domain and the email in **Settings › Online**. The email must be the one you sign in with. If the Online section says the keys did not load, check the team domain.
221
+ - **Cloudflare error 1033** — the tunnel is not running. In **Settings › Online**, check the status line, and read `online-tunnel.log` in the workspace state folder. Another `cloudflared` for the same tunnel (for example a service you installed) also serves the hostname.
187
222
 
188
223
  - **"refusing to bind … only loopback or a Tailscale tailnet address (100.64.0.0/10) is permitted"** — you passed `PMOS_BIND_ADDR` (or `PMOS_TAILNET_IP`) that isn't loopback or tailnet. Use your real `tailscale ip -4` value.
189
224
  - **"no Tailscale tailnet address … was found"** — Tailscale isn't up on this machine. Run `tailscale up`, or set `PMOS_TAILNET_IP`.
@@ -0,0 +1,96 @@
1
+ ---
2
+ type: Guide
3
+ title: "Docs: is each document the same as its Google Docs and Confluence copy, and which version to use"
4
+ timestamp: 2026-10-06
5
+ ---
6
+
7
+ # Docs
8
+
9
+ Open Docs from ⌘K (**Docs**) or press `g d`.
10
+
11
+ Docs lists every document in the repository that has a copy in Google Docs or Confluence. For each copy, it shows whether the repository version (PMOS) or the copy is newer. You can then use one version over the other, one copy at a time.
12
+
13
+ Nothing syncs by itself. A copy changes only when you press a button for that copy.
14
+
15
+ ## The list
16
+
17
+ The copies are grouped by initiative. Copies of files that no initiative owns are under **Other**.
18
+
19
+ **Needs you** shows the copies that are not the same. **All** shows every copy.
20
+
21
+ Each copy is one line:
22
+
23
+ - a status dot;
24
+ - the document title, and a link that opens the copy in a new tab;
25
+ - one sentence, for example "PMOS is newer · You, today 09:12";
26
+ - at most one button.
27
+
28
+ | Sentence | Means | Button |
29
+ |---|---|---|
30
+ | Same | The last compare found the same text, and neither side changed since. | none |
31
+ | PMOS is newer | The repository file changed since the two sides last matched. The copy did not. | Use PMOS version |
32
+ | Confluence is newer (or Google Docs) | The copy changed since the two sides last matched. The repository file did not. | Use Confluence version |
33
+ | Edited on both sides | Both changed since the two sides last matched. | Choose… |
34
+ | Not compared yet | No compare has read the copy. | none |
35
+ | Can't open in Confluence | The compare could not read the copy. The reason is shown. | none |
36
+ | Not found in Confluence | The copy is deleted or in the trash. | none |
37
+ | Not in PMOS any more | The repository file is not there. | none |
38
+ | Sync failed | The last sync of this copy wrote nothing. The reason is shown. | as before the sync |
39
+
40
+ "by date" after a sentence means that the two sides have never matched. The newer modified date then decides.
41
+
42
+ Frontmatter is not part of the text. A change to the frontmatter only does not change the sentence.
43
+
44
+ ## The details
45
+
46
+ Click a line to see its details:
47
+
48
+ - **PMOS**: the repository path and its last edit;
49
+ - the copy: its address and its last edit;
50
+ - a **Use … version** button for each side;
51
+ - the first difference that the compare found, when it found one;
52
+ - **Compare again** and **Stop tracking this copy**.
53
+
54
+ **Stop tracking this copy** removes the link. The copy is not deleted. Press **Undo** in the message to link it again.
55
+
56
+ ## Use a version
57
+
58
+ **Use PMOS version** writes the repository text to the copy. **Use Confluence version** (or Google Docs) writes the copy's text to the repository file.
59
+
60
+ 1. Press the button. The line says "Updating Confluence…" and a message shows **Undo**.
61
+ 2. To cancel, press **Undo** within five seconds. Nothing is changed.
62
+ 3. After five seconds, PMOS starts a session with the `doc-sync` skill. The session writes one side only, with your own Google Drive or Atlassian connector. If PMOS asks you to allow the write, allow it in the session.
63
+ 4. When the session finishes, the line says **Same**.
64
+
65
+ A sync never creates, moves or deletes a page or a file. When the copy's text is written to the repository file, the file's frontmatter stays as it is. The session does not commit: use Sync in the bar to commit and push the change.
66
+
67
+ ## Compare
68
+
69
+ PMOS cannot read Google Docs or Confluence itself. **↻** next to "Compared … ago" starts a session that reads every copy with your own connectors. The session reads only. The sentences change when it finishes.
70
+
71
+ A compare runs only when you press it. There is no schedule. An edit to a repository file changes its sentence at once, without a compare.
72
+
73
+ ## Add a copy
74
+
75
+ Type in **Find a document**. The search finds every markdown file in the repository by its title or path, also files that have no copy. A file with no copy shows **No copy** and **Add copy**.
76
+
77
+ Press **Add copy**, paste the address of the Google Doc or Confluence page, and press Enter. A document can have one Google Doc and one Confluence page.
78
+
79
+ A link that you add in an initiative's artifacts is the same link and shows here too.
80
+
81
+ ## From a terminal
82
+
83
+ | Command | Does |
84
+ |---|---|
85
+ | `pmos docs list` | Each linked document and its signal per copy. `--attention` lists only what is not the same. |
86
+ | `pmos docs find [words]` | The repository's markdown files that match, and their copies. |
87
+ | `pmos docs link <file> <address>` | Links a repository file to a Google Doc or a Confluence page. |
88
+ | `pmos docs unlink <file> google` | Removes the link (`google` or `confluence`). The copy is not touched. |
89
+ | `pmos docs check-start` | Used by the compare session: opens a compare and lists what to read. |
90
+ | `pmos docs record <check> --file <readings.json>` | Used by the compare session: records what it read. |
91
+ | `pmos docs check-finish <check>` | Used by the compare session: closes the compare. |
92
+ | `pmos docs sync-start <file> confluence --use pmos` | Used by the sync session: opens a sync of one copy (`--use copy` for the other direction). |
93
+ | `pmos docs sync-finish <sync> --version <v>` | Used by the sync session: closes the sync. The two sides are now the same. |
94
+ | `pmos docs sync-fail <sync> --reason "…"` | Used by the sync session: closes the sync. Nothing was written. |
95
+
96
+ The session commands must name who read or wrote. In a workspace session that is the session. Outside the workspace, add `--by <profile>`.
@@ -13,7 +13,7 @@ This guide describes the PMOS workspace as it works today. It ships with the app
13
13
  On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
14
14
 
15
15
  1. **The top bar.** Repository ▾, the four places, search (⌘K), Sync when the repository is not in sync, and Settings.
16
- 2. **The place.** One of Today, Initiatives, Board or Reports. It fills the screen under the bar.
16
+ 2. **The place.** One of Today, Initiatives, Board or Reports, or Docs (⌘K or `g d`). It fills the screen under the bar.
17
17
  3. **The detail.** The item you opened: an initiative with its status, next steps and artifacts, or the profiles. It opens beside the place and closes with ×.
18
18
  4. **The agent dock.** Terminals, documents, conversations and web pages. It is open on Today and a thin rail elsewhere.
19
19
 
@@ -26,6 +26,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
26
26
  | The sessions of a repository, by initiative | The agent dock. ⌘K → **Branches and sessions** lists the branches. |
27
27
  | The items of an initiative | Board, or Breakdown in the initiative detail |
28
28
  | Run reports | Reports |
29
+ | Whether a document is the same as its Google Docs or Confluence copy | Docs: ⌘K → **Docs**, or press `g d` |
29
30
  | Repositories, Profiles, New sessions (the default CLI and model), Validate consent, Theme | Settings |
30
31
  | Validate | Settings, under Validate consent: **Open Validate** |
31
32
  | A document, a decision record, a memory entry, a page of this guide | ⌘K, with its scope: Knowledge, Memory or Guide |
@@ -35,6 +36,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
35
36
  ## Where to read next
36
37
 
37
38
  - [Start the workspace without a terminal](launcher.md): the PMOS app, the login item and `pmos launcher`.
39
+ - [Docs](docs.md): whether each linked document is the same as, ahead of or behind its Google Docs and Confluence copy.
38
40
  - [Layout](layout.md): the frame, the keys and the rule for where a list or a detail opens.
39
41
  - [Statuses](statuses.md): what each status means, where it comes from, and what you can change.
40
42
  - [Initiatives and next steps](initiatives-and-next-steps.md): the detail, the next steps, and the artifacts.
@@ -25,12 +25,13 @@ Each place is drawn when its run is built. Until then the place says which run b
25
25
 
26
26
  ## Settings
27
27
 
28
- Settings holds settings only, in five sections:
28
+ Settings holds settings only, in six sections:
29
29
 
30
30
  - **Repositories**: every registered repository. Press a repository to make it the current one. Press its ⋯ for **Rename** or **Remove from PMOS**. Each asks in a sheet, and a remove asks you to confirm. **Add repository** shows the scan of a folder before you add it.
31
31
  - **Profiles**: the Claude profiles a session can start with. They open in the detail.
32
32
  - **New sessions**: the default CLI and the default model for a new session in the dock.
33
33
  - **Validate consent**: the consent of the current repository. Turn it on or off here. **Open Validate** opens the Validate page as a tab.
34
+ - **Online**: puts this workspace at your own address, such as `pmos.pm-os.app`, through a Cloudflare tunnel. Enter the public address, the tunnel name, your Cloudflare team domain, the Access audience (AUD) tag and your email, then press **Turn on**. The workspace runs the tunnel and remembers the switch when it restarts. On your phone, open the address and sign in with Cloudflare Access (Google or an email code). The workspace does not ask for its token there. Only the computer that runs the workspace can change this section. A phone sees whether it is on. [REMOTE.md](../REMOTE.md) has the one-time Cloudflare steps.
34
35
  - **Theme**: Dark or Light.
35
36
 
36
37
  Your work is never in Settings. It is in Today, Initiatives, Board and Reports.
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+ /* access-jwt.js — checks a Cloudflare Access assertion (workspace-online-mode, D89).
3
+ *
4
+ * Cloudflare Access adds `Cf-Access-Jwt-Assertion` to every request it lets through to the origin: a JSON Web
5
+ * Token signed RS256 with one of the team's keys. While Online is on, a request to the public hostname passes
6
+ * on this token instead of the workspace token, so every check below must hold or the request is refused:
7
+ *
8
+ * - three base64url parts, a JSON header and payload;
9
+ * - header.alg is RS256 (never `none`, never HMAC) and header.kid names a key the caller holds;
10
+ * - the RSA-SHA256 signature over `<header>.<payload>` verifies with that key;
11
+ * - iss is `https://<team domain>`;
12
+ * - aud (a string or a list) includes the configured audience tag;
13
+ * - exp is in the future and nbf (when present) is not, with 60 seconds of clock skew;
14
+ * - email is the one allowed address (compared without case).
15
+ *
16
+ * Pure: no I/O and no clock of its own (`now` is passed in, in seconds). The keys come from lib/access-keys.js.
17
+ * Nothing here logs the token, a claim or a key (D08); a refusal carries a short reason for tests and the
18
+ * status line, never the token.
19
+ */
20
+ const crypto = require('crypto');
21
+
22
+ const SKEW = 60; // seconds
23
+
24
+ function b64urlJson(part) {
25
+ try { return JSON.parse(Buffer.from(String(part), 'base64url').toString('utf8')); } catch { return null; }
26
+ }
27
+
28
+ /** Split a compact JWT. Returns null for anything that is not three non-empty base64url parts with JSON in the first two. */
29
+ function parse(token) {
30
+ if (typeof token !== 'string' || token.length > 16384) return null;
31
+ const parts = token.split('.');
32
+ if (parts.length !== 3 || parts.some((p) => !p || !/^[A-Za-z0-9_-]+$/.test(p))) return null;
33
+ const header = b64urlJson(parts[0]);
34
+ const payload = b64urlJson(parts[1]);
35
+ if (!header || typeof header !== 'object' || !payload || typeof payload !== 'object') return null;
36
+ return { header, payload, signingInput: parts[0] + '.' + parts[1], signature: Buffer.from(parts[2], 'base64url') };
37
+ }
38
+
39
+ /**
40
+ * verify(token, { keyFor, teamDomain, audience, email, now }) → { ok: true, email } | { ok: false, reason, kid? }
41
+ * keyFor(kid) → a crypto KeyObject or null. A refusal for an unknown key carries `kid`, so the caller can
42
+ * refetch the key set once and try again.
43
+ */
44
+ function verify(token, { keyFor, teamDomain, audience, email, now } = {}) {
45
+ const t = parse(token);
46
+ if (!t) return { ok: false, reason: 'malformed' };
47
+ if (t.header.alg !== 'RS256') return { ok: false, reason: 'algorithm' };
48
+ const kid = typeof t.header.kid === 'string' ? t.header.kid : '';
49
+ const key = kid && typeof keyFor === 'function' ? keyFor(kid) : null;
50
+ if (!key) return { ok: false, reason: 'unknown key', kid };
51
+ let good = false;
52
+ try { good = crypto.verify('RSA-SHA256', Buffer.from(t.signingInput), key, t.signature); } catch { good = false; }
53
+ if (!good) return { ok: false, reason: 'signature' };
54
+ const p = t.payload;
55
+ if (!teamDomain || p.iss !== 'https://' + teamDomain) return { ok: false, reason: 'issuer' };
56
+ const auds = Array.isArray(p.aud) ? p.aud : [p.aud];
57
+ if (!audience || !auds.includes(audience)) return { ok: false, reason: 'audience' };
58
+ const n = Number(now);
59
+ if (typeof p.exp !== 'number' || !(p.exp + SKEW > n)) return { ok: false, reason: 'expired' };
60
+ if (p.nbf != null && (typeof p.nbf !== 'number' || p.nbf - SKEW > n)) return { ok: false, reason: 'not yet valid' };
61
+ const who = typeof p.email === 'string' ? p.email.trim().toLowerCase() : '';
62
+ if (!email || !who || who !== String(email).trim().toLowerCase()) return { ok: false, reason: 'email' };
63
+ return { ok: true, email: who };
64
+ }
65
+
66
+ module.exports = { verify, parse, SKEW };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+ /* access-keys.js — the team's public signing keys for Cloudflare Access (workspace-online-mode, D89).
3
+ *
4
+ * THE ONE NETWORK CALL OF D89. This module, and no other file, may GET
5
+ * https://<team>.cloudflareaccess.com/cdn-cgi/access/certs
6
+ * where <team>.cloudflareaccess.com is the PM's configured team domain. The request carries no body, no
7
+ * cookie and no credential, and the answer is Cloudflare's PUBLIC key set. no-network.test.js names this
8
+ * file as the second exception beside D79's lib/typesafe.js.
9
+ *
10
+ * When it fetches (createStore):
11
+ * - only when asked: the server asks only while Online is on (D89 rule 1);
12
+ * - a key set older than an hour is refreshed on the next request that needs a key;
13
+ * - a key id the store does not hold triggers one refetch, at most once a minute;
14
+ * - a failed fetch keeps the keys already held. With none held, every lookup returns null and the guard
15
+ * refuses (fail closed).
16
+ * Keys are held in memory only. Nothing here logs a key, a response or a header (D08).
17
+ */
18
+ const crypto = require('crypto');
19
+ const https = require('https');
20
+
21
+ const TEAM_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.cloudflareaccess\.com$/;
22
+ const REFRESH_MS = 60 * 60 * 1000;
23
+ const MISS_MS = 60 * 1000;
24
+ const TIMEOUT_MS = 5000;
25
+ const MAX_BYTES = 64 * 1024;
26
+
27
+ function validTeamDomain(d) { return typeof d === 'string' && TEAM_RE.test(d); }
28
+ function certsUrl(teamDomain) { return 'https://' + teamDomain + '/cdn-cgi/access/certs'; }
29
+
30
+ /** GET the key set. Resolves with the parsed JSON, rejects on any failure. Refuses any host but a team domain. */
31
+ function fetchCerts(teamDomain) {
32
+ return new Promise((resolve, reject) => {
33
+ if (!validTeamDomain(teamDomain)) return reject(new Error('not a cloudflareaccess.com team domain'));
34
+ const req = https.get(certsUrl(teamDomain), { headers: { accept: 'application/json' }, timeout: TIMEOUT_MS }, (res) => {
35
+ if (res.statusCode !== 200) { res.resume(); return reject(new Error('status ' + res.statusCode)); }
36
+ let size = 0; const chunks = [];
37
+ res.on('data', (c) => { size += c.length; if (size > MAX_BYTES) { req.destroy(); reject(new Error('too large')); } else chunks.push(c); });
38
+ res.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(new Error('not JSON')); } });
39
+ });
40
+ req.on('timeout', () => req.destroy(new Error('timeout')));
41
+ req.on('error', reject);
42
+ });
43
+ }
44
+
45
+ /** A JWKS object → Map(kid → KeyObject). RSA keys only; anything unreadable is skipped. */
46
+ function keysFromJwks(jwks) {
47
+ const out = new Map();
48
+ for (const k of (jwks && Array.isArray(jwks.keys) ? jwks.keys : [])) {
49
+ if (!k || k.kty !== 'RSA' || typeof k.kid !== 'string' || !k.n || !k.e) continue;
50
+ try { out.set(k.kid, crypto.createPublicKey({ key: { kty: 'RSA', n: k.n, e: k.e }, format: 'jwk' })); } catch { /* skip */ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * createStore({ teamDomain, fetch = fetchCerts, clock = Date.now }) → {
57
+ * keyFor(kid) the KeyObject held for kid, or null (sync; what access-jwt.verify calls)
58
+ * ensure() Promise: refresh when the set is missing or older than an hour
59
+ * refetchFor(kid) Promise<boolean>: one refetch for an unknown kid, at most once a minute; true when kid is now held
60
+ * status() { keys, fetchedAt, lastError } — counts and times only, never a key
61
+ * }
62
+ */
63
+ function createStore({ teamDomain, fetch = fetchCerts, clock = Date.now } = {}) {
64
+ let keys = new Map();
65
+ let fetchedAt = 0;
66
+ let lastTry = 0;
67
+ let lastError = null;
68
+ let inflight = null;
69
+
70
+ function load() {
71
+ if (inflight) return inflight;
72
+ lastTry = clock();
73
+ inflight = Promise.resolve()
74
+ .then(() => fetch(teamDomain))
75
+ .then((jwks) => {
76
+ const next = keysFromJwks(jwks);
77
+ if (!next.size) throw new Error('no usable keys');
78
+ keys = next; fetchedAt = clock(); lastError = null;
79
+ })
80
+ .catch((e) => { lastError = String((e && e.message) || 'fetch failed'); })
81
+ .finally(() => { inflight = null; });
82
+ return inflight;
83
+ }
84
+ return {
85
+ keyFor: (kid) => keys.get(kid) || null,
86
+ ensure() {
87
+ if (inflight) return inflight; // a request that arrives during the first fetch waits for it
88
+ const now = clock();
89
+ if (keys.size && now - fetchedAt < REFRESH_MS) return Promise.resolve();
90
+ if (lastTry && now - lastTry < MISS_MS) return Promise.resolve(); // a failing fetch is retried at most once a minute
91
+ return load();
92
+ },
93
+ async refetchFor(kid) {
94
+ if (inflight) await inflight;
95
+ if (keys.has(kid)) return true;
96
+ if (clock() - lastTry < MISS_MS) return false;
97
+ await load();
98
+ return keys.has(kid);
99
+ },
100
+ status: () => ({ keys: keys.size, fetchedAt: fetchedAt || null, lastError }),
101
+ };
102
+ }
103
+
104
+ module.exports = { createStore, fetchCerts, keysFromJwks, validTeamDomain, certsUrl, REFRESH_MS, MISS_MS };