create-pmos 0.56.0 → 0.57.1

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.56.0",
3
+ "version": "0.57.1",
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@b024452
1
+ kernel-source: pmos@fd17645
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:33:25Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T11:33:15Z"}
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:33:25Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T11:33:15Z"}
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:33:25Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T11:33:15Z"}
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`.
@@ -18,9 +18,12 @@
18
18
  */
19
19
 
20
20
  // The budgets start at specimen A's counts. reports: 14 → 15 in Run 6 for Present, the one control the PM asked to keep
21
- // with the slides (2026-10-06). The calibration stays specimen A's own count: the specimen did not change.
21
+ // with the slides (2026-10-06). Every place +1 for Docs in the top bar (the PM, 2026-10-06, docs-three-way-sync): the
22
+ // budget is for the whole screen and the bar is on every screen. The calibration stays specimen A's own count: the
23
+ // specimen did not change.
22
24
  const SPECIMEN_A = Object.freeze({ today: 17, initiatives: 24, board: 33, reports: 14 });
23
- const EXPECT = Object.freeze({ ...SPECIMEN_A, reports: 15 });
25
+ const BAR_DOCS = 1;
26
+ const EXPECT = Object.freeze({ today: 17 + BAR_DOCS, initiatives: 24 + BAR_DOCS, board: 33 + BAR_DOCS, reports: 15 + BAR_DOCS });
24
27
 
25
28
  /* ── shoot.js audit(), copied verbatim (scratchpad/specimens2/shoot.js, 2026-10-05) ─────────────────────────────── */
26
29
  // runs in the page
@@ -6,7 +6,7 @@ timestamp: 2026-10-06
6
6
 
7
7
  # Docs
8
8
 
9
- Open Docs from ⌘K (**Docs**) or press `g d`.
9
+ Open Docs from the top bar, from ⌘K (**Docs**), or press `g d`.
10
10
 
11
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
12
 
@@ -12,7 +12,7 @@ This guide describes the PMOS workspace as it works today. It ships with the app
12
12
 
13
13
  On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
14
14
 
15
- 1. **The top bar.** Repository ▾, the four places, search (⌘K), Sync when the repository is not in sync, and Settings.
15
+ 1. **The top bar.** Repository ▾, the five places (Docs included), search (⌘K), Sync when the repository is not in sync, and Settings.
16
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.
@@ -15,7 +15,7 @@ The top bar has these parts, from left to right:
15
15
 
16
16
  - **Repository ▾** names the current repository. Press it to choose another repository, or **All repositories** to see the work of every repository at once. All repositories replaces the old Portfolio mode.
17
17
  - **Branches and sessions** is in ⌘K. It opens a sheet with every branch of the repository (main first) and how many sessions run in each. Press a branch to see its sessions, open one, end one, or start a session there. **+ branch** makes a branch, and **+ session** starts one with a branch, an agent and a profile. With All repositories, the sheet lists every repository.
18
- - **Today, Initiatives, Board and Reports** are the four places. Press one to show it. The Today count shows how many items need you.
18
+ - **Today, Initiatives, Board, Reports and Docs** are the five places. Press one to show it. The Today count shows how many items need you. Docs shows whether each document is the same as its Google Docs and Confluence copy.
19
19
  - **Search or capture ⌘K** opens the command palette. Type to find an initiative, a repository, an open tab or a command.
20
20
  - **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. If the repository has not fetched in the last 10 minutes, Sync also shows, because a push from another machine is not visible until a fetch. When the repository fetched recently and is in sync, Sync is not shown. If Sync stops, the message names the step that stopped.
21
21
  - **Sync on its own.** If you forget to press Sync, the workspace syncs for you. It syncs each repository once when the workspace starts. It also syncs a repository that has records not yet pushed when no record changed for 5 minutes. In a document repository it syncs every changed file on any branch. In a code repository it syncs only on the default branch (for example `main`), and only when every commit not yet pushed changes records and no code. If you close the tab while records are not yet pushed, the browser asks you first. To turn automatic sync off, start the workspace with `PMOS_AUTO_SYNC=0`.
@@ -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.
@@ -69,7 +70,7 @@ The workspace is neutral. A primary button is the text colour on the page colour
69
70
 
70
71
  | Key | What it does |
71
72
  |---|---|
72
- | g t, g i, g b, g r | Show Today, Initiatives, Board or Reports. |
73
+ | g t, g i, g b, g r, g d | Show Today, Initiatives, Board, Reports or Docs. |
73
74
  | ⌘K (Ctrl-K) | Open or close the command palette. |
74
75
  | ⌘J (Ctrl-J) | Open or fold the dock. |
75
76
  | ⌘⇧J (Ctrl-Shift-J) | Focus: give the dock the whole window, or show the place again. |
@@ -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 };
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+ /* online-mode.js — the Online switch: its settings record and the tunnel it runs (workspace-online-mode, PRD O1, O2).
3
+ *
4
+ * THE RECORD. `<runtime state>/online.json`, in the per-user runtime directory lib/state-root.js keeps outside every
5
+ * repository, written with mode 0600 through a temporary file and a rename (the typesafe-settings.js pattern):
6
+ * { on, host, tunnel, team, audience, email }
7
+ * host the public hostname, validated by remote-access.parsePublicHost (pmos.pm-os.app)
8
+ * tunnel the Cloudflare tunnel name or id that `cloudflared tunnel run` takes (pmos)
9
+ * team the Cloudflare team domain, <team>.cloudflareaccess.com
10
+ * audience the Access application's audience tag, 64 hex characters
11
+ * email the one address allowed in
12
+ * A field that fails its check is refused with the reason, and nothing is written.
13
+ *
14
+ * THE TUNNEL. createTunnel() runs `cloudflared tunnel --no-autoupdate run <tunnel>` as a child of the server, with its
15
+ * output appended to `<runtime state>/online-tunnel.log`. stop() ends it. If it exits while Online is on, it is started
16
+ * again after 5 seconds. cloudflared is the PM's own installed program; it is found on PATH or in the two Homebrew
17
+ * folders, because a workspace started at login has a short PATH.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const childProcess = require('child_process');
22
+ const remoteAccess = require('./remote-access');
23
+ const accessKeys = require('./access-keys');
24
+
25
+ const FILE = 'online.json';
26
+ const LOG = 'online-tunnel.log';
27
+ const FIELDS = ['host', 'tunnel', 'team', 'audience', 'email'];
28
+ const RESTART_MS = 5000;
29
+
30
+ function file(stateDir) { return path.join(stateDir, FILE); }
31
+
32
+ const CHECKS = {
33
+ host: (v) => {
34
+ if (!v) return 'give the public hostname, such as pmos.pm-os.app';
35
+ const r = remoteAccess.parsePublicHost(v);
36
+ return r.refused ? 'the hostname must be a bare DNS name, such as pmos.pm-os.app (no https://, port or path)' : null;
37
+ },
38
+ tunnel: (v) => (/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v) ? null : 'give the tunnel name you created with cloudflared, such as pmos'),
39
+ team: (v) => (accessKeys.validTeamDomain(v) ? null : 'give your Cloudflare team domain, such as myteam.cloudflareaccess.com'),
40
+ audience: (v) => (/^[a-f0-9]{64}$/.test(v) ? null : "give the Access application's Audience (AUD) tag: 64 letters and digits from the application's Overview"),
41
+ email: (v) => (/^[^\s@]{1,64}@[^\s@]+\.[^\s@]{2,}$/.test(v) && v.length <= 254 ? null : 'give the email address you sign in with'),
42
+ };
43
+ function norm(field, v) {
44
+ const s = String(v == null ? '' : v).trim();
45
+ return field === 'tunnel' ? s : s.toLowerCase();
46
+ }
47
+
48
+ function read(stateDir) {
49
+ const out = { on: false, host: '', tunnel: '', team: '', audience: '', email: '' };
50
+ try {
51
+ const st = fs.lstatSync(file(stateDir));
52
+ if (!st.isFile()) return out;
53
+ const o = JSON.parse(fs.readFileSync(file(stateDir), 'utf8')) || {};
54
+ for (const f of FIELDS) if (typeof o[f] === 'string' && !CHECKS[f](o[f])) out[f] = o[f];
55
+ out.on = o.on === true && complete(out);
56
+ } catch { /* none yet */ }
57
+ return out;
58
+ }
59
+
60
+ function complete(s) { return FIELDS.every((f) => s[f] && !CHECKS[f](s[f])); }
61
+
62
+ /** The first field that is missing or invalid, as { field, error }, or null when all five are good. */
63
+ function firstProblem(s) {
64
+ for (const f of FIELDS) { const e = CHECKS[f](s[f] || ''); if (e) return { field: f, error: e }; }
65
+ return null;
66
+ }
67
+
68
+ function write(stateDir, data) {
69
+ const target = file(stateDir);
70
+ try { if (fs.lstatSync(target).isSymbolicLink()) return { ok: false, error: 'the settings file is a symlink and was not written' }; }
71
+ catch { /* not there yet */ }
72
+ const tmp = target + '.' + process.pid + '.tmp';
73
+ try {
74
+ fs.mkdirSync(stateDir, { recursive: true });
75
+ fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
76
+ fs.chmodSync(tmp, 0o600);
77
+ fs.renameSync(tmp, target);
78
+ return { ok: true };
79
+ } catch (e) {
80
+ try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ }
81
+ return { ok: false, error: 'the online settings could not be saved (' + String((e && e.code) || 'write failed') + ')' };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * save(stateDir, patch) → { ok, settings } | { ok: false, field?, error }
87
+ * patch may carry any of the five fields and `on`. Fields are validated first; `on: true` then needs all five.
88
+ * Whether cloudflared is installed is the caller's check, because it is not a property of the record.
89
+ */
90
+ function save(stateDir, patch = {}) {
91
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, error: 'the body must be an object' };
92
+ const next = read(stateDir);
93
+ for (const f of FIELDS) {
94
+ if (!(f in patch)) continue;
95
+ const v = norm(f, patch[f]);
96
+ const e = CHECKS[f](v);
97
+ if (e) return { ok: false, field: f, error: e };
98
+ next[f] = v;
99
+ }
100
+ if ('on' in patch) {
101
+ if (typeof patch.on !== 'boolean') return { ok: false, field: 'on', error: 'on must be true or false' };
102
+ if (patch.on) { const p = firstProblem(next); if (p) return { ok: false, ...p }; }
103
+ next.on = patch.on;
104
+ }
105
+ const w = write(stateDir, next);
106
+ return w.ok ? { ok: true, settings: next } : { ok: false, error: w.error };
107
+ }
108
+
109
+ /** The cloudflared binary: PATH first, then the two Homebrew folders. null when absent. */
110
+ function findCloudflared({ env = process.env, exists = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } } } = {}) {
111
+ const dirs = String(env.PATH || '').split(path.delimiter).filter(Boolean).concat(['/opt/homebrew/bin', '/usr/local/bin']);
112
+ const name = process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared';
113
+ for (const d of dirs) { const p = path.join(d, name); if (exists(p)) return p; }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * createTunnel({ stateDir, spawn, find, setTimer }) → { start(name), stop(), status() }
119
+ * status() → { running, pid, since, lastExit } — lastExit is { code, at } of the last exit, or null.
120
+ */
121
+ function createTunnel({ stateDir, spawn = childProcess.spawn, find = findCloudflared, setTimer = setTimeout } = {}) {
122
+ let child = null; let want = null; let since = null; let lastExit = null; let timer = null;
123
+ function launch() {
124
+ timer = null;
125
+ if (!want || child) return;
126
+ const bin = find();
127
+ if (!bin) { lastExit = { code: null, at: new Date().toISOString(), error: 'cloudflared is not installed' }; return; }
128
+ let fd = 'ignore';
129
+ try { fs.mkdirSync(stateDir, { recursive: true }); fd = fs.openSync(path.join(stateDir, LOG), 'a', 0o600); } catch { fd = 'ignore'; }
130
+ try {
131
+ child = spawn(bin, ['tunnel', '--no-autoupdate', 'run', want], { stdio: ['ignore', fd, fd], detached: false });
132
+ } catch (e) {
133
+ child = null; lastExit = { code: null, at: new Date().toISOString(), error: 'cloudflared could not start' };
134
+ }
135
+ if (typeof fd === 'number') { try { fs.closeSync(fd); } catch { /* ignore */ } }
136
+ if (!child) return;
137
+ since = new Date().toISOString();
138
+ const mine = child;
139
+ mine.on('error', () => {});
140
+ mine.on('exit', (code) => {
141
+ if (child !== mine) return;
142
+ child = null; since = null; lastExit = { code, at: new Date().toISOString() };
143
+ if (want && !timer) timer = setTimer(launch, RESTART_MS);
144
+ });
145
+ }
146
+ return {
147
+ start(name) { want = name; if (!child && !timer) launch(); },
148
+ stop() {
149
+ want = null;
150
+ if (timer) { clearTimeout(timer); timer = null; }
151
+ const c = child; child = null; since = null;
152
+ if (c) { try { c.kill('SIGTERM'); } catch { /* already gone */ } }
153
+ },
154
+ status: () => ({ running: !!child, pid: child ? child.pid : null, since, lastExit }),
155
+ };
156
+ }
157
+
158
+ module.exports = { read, save, complete, firstProblem, findCloudflared, createTunnel, FIELDS, FILE, LOG };
@@ -373,9 +373,11 @@ function stripTokenParam(url) {
373
373
  // The cookie a valid token seeds. HttpOnly (no script needs it) + SameSite=Strict + the Origin/Host
374
374
  // allowlist keep a foreign site from riding it. No Secure flag on the tailnet: it speaks plain HTTP
375
375
  // inside WireGuard. On the public host the browser is on HTTPS (TLS ends at the edge), so the cookie
376
- // is Secure there and is never sent over plain HTTP. 7 days, unchanged from workspace-mobile-remote-access.
376
+ // is Secure there and is never sent over plain HTTP. 30 days (workspace-remember-device; was 7), so a
377
+ // device signs in about once a month: the PM's own phone, behind Cloudflare Access or the tailnet.
378
+ const SESSION_MAX_AGE = 30 * 24 * 60 * 60;
377
379
  function sessionCookie(token, { secure = false } = {}) {
378
- return `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Strict; Path=/; Max-Age=604800`;
380
+ return `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Strict; Path=/; Max-Age=${SESSION_MAX_AGE}`;
379
381
  }
380
382
 
381
383
  // The sign-in form posts application/x-www-form-urlencoded `token=<t>` to /auth.
@@ -425,7 +427,7 @@ module.exports = {
425
427
  LOOPBACK, isLoopbackAddr, isTailnetIp, detectTailnetIp, remoteRequested,
426
428
  resolveBinds, extractHttpToken, extractWsToken, tokenMatches, authorize, startupLines,
427
429
  checkHostOrigin, isAllowedOrigin, hostnameAllowed,
428
- parsePublicHost, PROXY_HEADERS, ACCESS_HEADER, isProxied, edgeAllowed,
430
+ parsePublicHost, PROXY_HEADERS, ACCESS_HEADER, isProxied, edgeAllowed, hostnameOf,
429
431
  COOKIE_NAME, extractCookieToken, presentedToken,
430
432
  phoneUrl, isPageNavigation, stripTokenParam, sessionCookie, AUTH_PATH, parseAuthForm, signInPage,
431
433
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.56.0",
3
+ "version": "0.57.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.56.0",
3
+ "version": "0.57.1",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -66,7 +66,7 @@ export const COPY = Object.freeze({
66
66
  started: 'Compare started. It reads every copy and changes nothing.',
67
67
  });
68
68
 
69
- // Docs is not in the top bar (the bar holds at most 7 controls); ⌘K and `g d` open it.
69
+ // Docs is in the top bar (the PM, 2026-10-06); ⌘K and `g d` open it too.
70
70
  registerCommand({ id: 'docs-open', label: 'Docs', hint: 'Is each document the same as its Google Docs and Confluence copy', cli: 'pmos docs list', run: () => openDestination('docs') });
71
71
 
72
72
  /* ── words ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
@@ -1,11 +1,12 @@
1
1
  /* pm/dest-settings.js — the Settings destination, the frame's ⚙ (pm-os-absorb Run 3, slice 3A; PRD R9).
2
2
  *
3
- * Settings holds settings only, in five sections and in this order:
3
+ * Settings holds settings only, in six sections and in this order:
4
4
  * Repositories every registered repository as a row (open it to make it current; ⋯ for more), and Add repository,
5
5
  * which draws the existing add-repository flow (view-addrepo.js) in place
6
6
  * Profiles one row that opens the Claude profiles in the detail
7
7
  * New sessions the default CLI and the default model the dock's + Session applies (dock.js)
8
8
  * Validate consent the current repository's consent switch (GET and POST /api/validate/repo)
9
+ * Online the Online switch and its five settings (GET and POST /api/online; workspace-online-mode, D89)
9
10
  * Theme Dark or Light
10
11
  * The work itself (what needs you, initiatives, items, reports) is never here.
11
12
  *
@@ -26,6 +27,7 @@ import { COPY } from './frame-copy.js';
26
27
  const P = COPY.page;
27
28
  const roots = new WeakMap(); // host -> { root, sig }
28
29
  const consentByWs = new Map(); // ws -> { on: boolean | null, error, at, pending }
30
+ const online = { data: null, error: null, note: null, at: 0, pending: false }; // GET /api/online, and the last refusal
29
31
  let addOpen = false;
30
32
  let addMod = null;
31
33
 
@@ -52,11 +54,29 @@ async function setConsent(ws, on) {
52
54
  setState({});
53
55
  }
54
56
 
57
+ function readOnline(force) {
58
+ if (FIXTURE || online.pending) return;
59
+ if (!force && Date.now() - online.at < 10000) return;
60
+ online.pending = true;
61
+ pmGet('/api/online').then((j) => {
62
+ online.pending = false; online.at = Date.now();
63
+ if (j && j.ok !== false) { online.data = j; online.error = null; } else online.error = P.onlineUnread;
64
+ setState({});
65
+ });
66
+ }
67
+
68
+ async function setOnline(patch) {
69
+ const j = await pmPost('/api/online', patch);
70
+ if (j && j.ok !== false) { online.data = j; online.note = null; } else online.note = (j && j.error) || P.onlineUnread;
71
+ online.at = Date.now();
72
+ setState({});
73
+ }
74
+
55
75
  const section = (id, title, kids, action) => el('section', { class: 'fr-set__section', 'data-fr-section': id },
56
76
  el('div', { class: 'fr-set__head' }, el('h2', { class: 'fr-set__title' }, title), action || null),
57
77
  kids);
58
78
 
59
- /* ── the five sections ──────────────────────────────────────────────────────────────────────────────────────────── */
79
+ /* ── the six sections ───────────────────────────────────────────────────────────────────────────────────────────── */
60
80
 
61
81
  function reposSection(scope) {
62
82
  const tuples = repoTuples();
@@ -166,6 +186,38 @@ function validateSection(scope) {
166
186
  el('span', { 'data-fr': 'consent-state', 'data-on': known ? String(c.on) : 'unread' }, line), toggle, open)]);
167
187
  }
168
188
 
189
+ function onlineSection() {
190
+ readOnline(false);
191
+ const d = online.data;
192
+ if (!d) return section('online', P.online, [el('p', { class: 'fr-set__note' }, online.error || '')]);
193
+ if (d.envHost) return section('online', P.online, [el('p', { class: 'fr-set__note', 'data-fr': 'online-state', 'data-on': 'env' }, P.onlineEnv(d.envHost))]);
194
+ const lines = [el('p', { class: 'fr-set__note', 'data-fr': 'online-state', 'data-on': String(!!d.on) }, d.on ? P.onlineOn(d.host) : P.onlineOff)];
195
+ if (d.on) {
196
+ const r = d.running || {};
197
+ lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-tunnel' },
198
+ r.running ? P.onlineRunning : r.lastExit ? P.onlineExited(r.lastExit.code) : P.onlineStarting));
199
+ if (d.keys && !d.keys.keys) lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-keys' }, d.keys.lastError ? P.onlineKeysError(d.keys.lastError) : P.onlineKeysWait));
200
+ }
201
+ if (!d.canChange) return section('online', P.online, [...lines, el('p', { class: 'fr-set__note' }, P.onlineRemote)]);
202
+ const f = (key, label, placeholder) => field({
203
+ label, value: d[key] || '', placeholder,
204
+ onCommit: (v) => setOnline({ [key]: v }),
205
+ attrs: { 'data-fr': 'online-' + key, disabled: d.on ? true : undefined },
206
+ });
207
+ const fields = el('div', { class: 'fr-set__fields' },
208
+ f('host', P.onlineHost, 'pmos.example.com'), f('tunnel', P.onlineTunnel, 'pmos'), f('team', P.onlineTeam, 'myteam.cloudflareaccess.com'),
209
+ f('audience', P.onlineAudience, ''), f('email', P.onlineEmail, 'you@example.com'));
210
+ const blocked = !d.on && (!d.complete ? P.onlineNeedFields : !d.cloudflared ? P.onlineNoCloudflared : null);
211
+ const toggle = button({
212
+ label: d.on ? P.turnOff : P.turnOn, variant: 'outline', size: 'sm', disabled: !!blocked,
213
+ attrs: { 'data-fr': 'online-toggle' }, onClick: () => setOnline({ on: !d.on }),
214
+ });
215
+ const kids = [el('p', { class: 'fr-set__note' }, P.onlineIntro), fields, el('div', { class: 'fr-set__line' }, ...lines, toggle)];
216
+ if (blocked) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-blocked' }, blocked));
217
+ if (online.note) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-error', role: 'alert' }, online.note));
218
+ return section('online', P.online, kids);
219
+ }
220
+
169
221
  function themeSection() {
170
222
  return section('theme', P.theme, [tabs({
171
223
  variant: 'segmented', role: 'radiogroup', ariaLabel: P.theme, value: state.theme,
@@ -182,7 +234,7 @@ function signature(scope) {
182
234
  const addKeys = addOpen ? Object.keys(state).filter((k) => /^add[A-Z]/.test(k)).sort().map((k) => [k, state[k]]) : null;
183
235
  let add = '';
184
236
  try { add = JSON.stringify(addKeys); } catch { add = String(Date.now()); }
185
- return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, addOpen, !!addMod]) + add;
237
+ return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, addOpen, !!addMod, online.data, online.error, online.note]) + add;
186
238
  }
187
239
 
188
240
  /** True while the PM is typing in the page or a menu of it is open: a rebuild then would take the focus away. */
@@ -208,6 +260,7 @@ export function mountDestination(host, ctx) {
208
260
  profilesSection(),
209
261
  sessionsSection(),
210
262
  validateSection(scope),
263
+ onlineSection(),
211
264
  themeSection(),
212
265
  ]);
213
266
  }
@@ -26,6 +26,7 @@ export const COPY = {
26
26
  initiatives: 'Initiatives',
27
27
  board: 'Board',
28
28
  reports: 'Reports',
29
+ docs: 'Docs',
29
30
  agent: 'Agent',
30
31
  settings: 'Settings',
31
32
  },
@@ -150,6 +151,25 @@ export const COPY = {
150
151
  theme: 'Theme',
151
152
  dark: 'Dark',
152
153
  light: 'Light',
154
+ online: 'Online',
155
+ onlineIntro: 'Reach this workspace from your phone at your own address. The workspace runs the Cloudflare tunnel, and you sign in with Cloudflare Access (Google or an email code). Only this computer can change it.',
156
+ onlineHost: 'Public address',
157
+ onlineTunnel: 'Tunnel name',
158
+ onlineTeam: 'Cloudflare team domain',
159
+ onlineAudience: 'Access audience (AUD) tag',
160
+ onlineEmail: 'Email allowed in',
161
+ onlineOn: (host) => `Online at https://${host}/`,
162
+ onlineOff: 'Offline. Only this computer can reach the workspace.',
163
+ onlineRunning: 'The tunnel is running.',
164
+ onlineStarting: 'The tunnel is starting.',
165
+ onlineExited: (code) => `The tunnel stopped${code == null ? '' : ` (exit code ${code})`}. It starts again in a few seconds. Its log is online-tunnel.log in the workspace state folder.`,
166
+ onlineKeysWait: 'Cloudflare\'s sign-in keys are not loaded yet. Sign-ins are refused until they are.',
167
+ onlineKeysError: (e) => `Cloudflare\'s sign-in keys did not load (${e}). Check the team domain. Sign-ins are refused until they load.`,
168
+ onlineNeedFields: 'Fill in all five fields to turn Online on.',
169
+ onlineNoCloudflared: 'cloudflared is not installed. Install it (brew install cloudflared), then turn Online on.',
170
+ onlineEnv: (host) => `Online at https://${host}/, set by PMOS_PUBLIC_HOST when the workspace started. It signs in with the token. Restart the workspace without PMOS_PUBLIC_HOST to use this switch.`,
171
+ onlineRemote: 'Only the computer that runs the workspace can change this.',
172
+ onlineUnread: 'The Online settings did not load.',
153
173
  },
154
174
  };
155
175
 
@@ -1,6 +1,6 @@
1
1
  /* pm/topbar.js — the frame's one top bar (pm-os-absorb Run 3, slice 3A; PRD R9, R10).
2
2
  *
3
- * Repository ▾ · Today Initiatives Board Reports · ⌘K · Sync (only when unsynced) · Settings
3
+ * Repository ▾ · Today Initiatives Board Reports Docs · ⌘K · Sync (only when unsynced) · Settings
4
4
  *
5
5
  * The bar is built once and updated on every render: app.js calls `bar.update(model)` and the bar changes only what
6
6
  * the model changed, so a focused control keeps its focus across a repaint. It holds no state of its own except the
@@ -8,7 +8,7 @@
8
8
  * runs `git status` against the remote-tracking ref the last fetch left).
9
9
  *
10
10
  * createTopbar({ go, openPalette, repoMenu, sync }) → { node, update(model) }
11
- * go(id) open destination id (today, initiatives, board, reports, settings)
11
+ * go(id) open destination id (today, initiatives, board, reports, docs, settings)
12
12
  * openPalette() open ⌘K
13
13
  * repoMenu(anchor) open the Repository menu under `anchor`
14
14
  * sync(repo) run Sync for `repo`; resolves to the server's answer
@@ -22,8 +22,9 @@ import { el, setChildren } from './dom.js';
22
22
  import { button, needsDot, needsCount } from './primitives.js';
23
23
  import { COPY } from './frame-copy.js';
24
24
 
25
- /** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. */
26
- export const BAR_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports']);
25
+ /** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. Docs joined the bar at the
26
+ * PM's word (2026-10-06, docs-three-way-sync: "I thought Docs is one of the main menu"), so the bar holds 8 controls. */
27
+ export const BAR_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'docs']);
27
28
 
28
29
  /** Is this sync reading "not in sync"? Behind, ahead, records not yet recorded, or `remote_stale`: no fetch in the last
29
30
  * few minutes, so another machine's push would be invisible here and "in sync" cannot be claimed. An unreadable status
@@ -69,6 +69,17 @@
69
69
  "names": [
70
70
  "(a) calibration: the audit on specimen A gives exactly Today 17, Initiatives 24, Board 33, Reports 14 outside the dock"
71
71
  ]
72
+ },
73
+ {
74
+ "id": "SOM-D89",
75
+ "file": "workspace/no-network.test.js",
76
+ "run": "workspace-online-mode",
77
+ "ref": "D89 (ratified by PM wawan on a decision card, 2026-10-06) and planning/prd/workspace-online-mode-prd.md O5",
78
+ "reason": "D89 adds a second named HTTP client, lib/access-keys.js (a GET of the PM's Cloudflare Access team's public key set while Online is on). The two assertions that named lib/typesafe.js as the only client now name both sanctioned modules; three new assertions pin the new module to cloudflareaccess.com, GET only, and its two requirers. The D79 assertions are unchanged.",
79
+ "names": [
80
+ "no-network: the server process opens no HTTP client outside the one TypeSafe module (D79)",
81
+ "no-network: lib/typesafe.js is the ONLY file that opens an HTTP client"
82
+ ]
72
83
  }
73
84
  ],
74
85
  "entries": [
@@ -288,6 +288,64 @@ const layoutModel = require('./lib/layout'); // the arrangement model
288
288
  // REFUSED (BIND.refused) so the server exits — there is no code path to a public interface.
289
289
  const BIND = remoteAccess.resolveBinds({ env: process.env });
290
290
  const TOKEN = BIND.token || null; // non-loopback peers must present it; never logged (D08)
291
+ // workspace-online-mode (D89): the Online switch. Its record lives in the per-user state folder; while it is on, the
292
+ // workspace runs the tunnel itself and admits the public hostname, and a request to that hostname passes on a verified
293
+ // Cloudflare Access assertion for the PM's email instead of the token. An environment-set PMOS_PUBLIC_HOST wins and keeps
294
+ // the token (PRD O7). ONLINE.keys is the team's public key set (lib/access-keys.js, the one D89 fetch), only while on.
295
+ const onlineMode = require('./lib/online-mode');
296
+ const accessJwt = require('./lib/access-jwt');
297
+ const accessKeys = require('./lib/access-keys');
298
+ const ONLINE = { settings: onlineMode.read(STATE), keys: null, keysTeam: null, fetchKeys: accessKeys.fetchCerts, find: onlineMode.findCloudflared };
299
+ const tunnel = onlineMode.createTunnel({ stateDir: STATE, find: () => ONLINE.find() });
300
+ /** The public hostname the guards admit right now: the environment's, else the Online switch's while it is on. */
301
+ function publicHost() { return BIND.publicHost || (ONLINE.settings.on ? ONLINE.settings.host : null); }
302
+ /** True when this request is addressed to the Online switch's hostname, so the Access assertion decides it (D89). */
303
+ function viaOnlineHost(headers) {
304
+ return !BIND.publicHost && ONLINE.settings.on && remoteAccess.hostnameOf((headers || {}).host) === ONLINE.settings.host;
305
+ }
306
+ /** Bring the tunnel and the key store in line with the record. Called at start, and after every change of the record. */
307
+ function applyOnline() {
308
+ const s = ONLINE.settings;
309
+ if (s.on && !BIND.publicHost) {
310
+ if (!ONLINE.keys || ONLINE.keysTeam !== s.team) {
311
+ ONLINE.keys = accessKeys.createStore({ teamDomain: s.team, fetch: (d) => ONLINE.fetchKeys(d) });
312
+ ONLINE.keysTeam = s.team;
313
+ ONLINE.keys.ensure().catch(() => {});
314
+ }
315
+ if (ONLINE.tunnelName && ONLINE.tunnelName !== s.tunnel) tunnel.stop();
316
+ ONLINE.tunnelName = s.tunnel;
317
+ tunnel.start(s.tunnel);
318
+ } else {
319
+ tunnel.stop();
320
+ ONLINE.tunnelName = null;
321
+ ONLINE.keys = null; ONLINE.keysTeam = null;
322
+ }
323
+ }
324
+ /** The D89 decision for one request: { ok: true } or { ok: false, reason }. The assertion is never logged (D08). */
325
+ async function accessVerdict(headers) {
326
+ const s = ONLINE.settings; const store = ONLINE.keys;
327
+ if (!store) return { ok: false, reason: 'no keys' };
328
+ try { await store.ensure(); } catch { /* the store records its own error */ }
329
+ const token = (headers || {})['cf-access-jwt-assertion'];
330
+ const opts = { keyFor: store.keyFor, teamDomain: s.team, audience: s.audience, email: s.email, now: Math.floor(Date.now() / 1000) };
331
+ let v = accessJwt.verify(token, opts);
332
+ if (!v.ok && v.reason === 'unknown key' && v.kid && await store.refetchFor(v.kid)) v = accessJwt.verify(token, opts);
333
+ return v;
334
+ }
335
+ /** The Online record and state, as GET /api/online returns it. Nothing secret is in it: the fields are not credentials. */
336
+ function onlineView(req) {
337
+ const s = ONLINE.settings;
338
+ return {
339
+ ok: true, on: s.on, host: s.host, tunnel: s.tunnel, team: s.team, audience: s.audience, email: s.email,
340
+ complete: onlineMode.complete(s), envHost: BIND.publicHost || null, cloudflared: !!ONLINE.find(),
341
+ running: tunnel.status(), keys: ONLINE.keys ? ONLINE.keys.status() : null,
342
+ canChange: onlineChangeAllowed(req),
343
+ };
344
+ }
345
+ /** PRD O3: only the desktop changes the switch — a loopback peer that came through no proxy. */
346
+ function onlineChangeAllowed(req) {
347
+ return remoteAccess.isLoopbackAddr(req.socket && req.socket.remoteAddress) && !remoteAccess.isProxied(req.headers || {}, publicHost());
348
+ }
291
349
  // Per-OS default shell. On Windows there is no `bash` — use ComSpec/PowerShell.
292
350
  function defaultShell() {
293
351
  if (process.platform === 'win32') return process.env.COMSPEC || 'powershell.exe';
@@ -7132,6 +7190,23 @@ async function handleRequest(req, res) {
7132
7190
  if (p === '/api/typesafe' && req.method === 'GET') {
7133
7191
  return json(res, { ok: true, key: tsSettings.status(STATE), host: tsClient.HOST });
7134
7192
  }
7193
+ /* ── workspace-online-mode (D89): the Online switch. Any signed-in device may read it; only the desktop changes it (O3). ── */
7194
+ if (p === '/api/online' && req.method === 'GET') return json(res, onlineView(req));
7195
+ if (p === '/api/online' && req.method === 'POST') {
7196
+ let body = {};
7197
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
7198
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
7199
+ if (!onlineChangeAllowed(req)) return json(res, { ok: false, error: 'Online can only be changed on the computer that runs the workspace' }, 403);
7200
+ const press = pmGuard.requirePagePress(req, body);
7201
+ if (!press.ok) return json(res, { ok: false, error: press.error }, press.status || 403);
7202
+ if (BIND.publicHost && body.on === true) return json(res, { ok: false, error: `PMOS_PUBLIC_HOST=${BIND.publicHost} was set when the workspace started, so it is already online that way. Restart it without PMOS_PUBLIC_HOST to use the switch.` }, 409);
7203
+ if (body.on === true && !ONLINE.find()) return json(res, { ok: false, field: 'on', error: 'cloudflared is not installed. Install it (brew install cloudflared), then switch Online on again.' }, 409);
7204
+ const out = onlineMode.save(STATE, body);
7205
+ if (!out.ok) return json(res, { ok: false, field: out.field || null, error: out.error }, out.field ? 400 : 500);
7206
+ ONLINE.settings = out.settings;
7207
+ applyOnline();
7208
+ return json(res, onlineView(req));
7209
+ }
7135
7210
  if (p === '/api/typesafe/key' && (req.method === 'PUT' || req.method === 'DELETE')) {
7136
7211
  let body = {};
7137
7212
  if (req.method === 'PUT') {
@@ -7950,20 +8025,33 @@ function writeChangeContext(dir) {
7950
8025
  function httpGuard(req, res) {
7951
8026
  // Origin/Host allowlist FIRST (D64 A1): reject a cross-site / DNS-rebinding request before the
7952
8027
  // token exemption for loopback can be reached. Header values are never logged (D08).
7953
- if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost: BIND.publicHost })) {
8028
+ if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost: publicHost() })) {
7954
8029
  res.writeHead(403, { 'Content-Type': 'application/json' });
7955
8030
  res.end(JSON.stringify({ ok: false, error: 'forbidden origin/host' }));
7956
8031
  return;
7957
8032
  }
7958
8033
  // workspace-public-host: a request to the public hostname must have passed Cloudflare Access. A tunnel
7959
8034
  // with no Access application in front fails here, before even the sign-in form can be reached.
7960
- if (!remoteAccess.edgeAllowed({ headers: req.headers, publicHost: BIND.publicHost })) {
8035
+ if (!remoteAccess.edgeAllowed({ headers: req.headers, publicHost: publicHost() })) {
7961
8036
  res.writeHead(403, { 'Content-Type': 'application/json' });
7962
8037
  res.end(JSON.stringify({ ok: false, error: 'the public host is only reachable through Cloudflare Access' }));
7963
8038
  return;
7964
8039
  }
8040
+ // workspace-online-mode (D89): on the Online switch's hostname the verified Access assertion is the whole check.
8041
+ // It replaces the token, so neither the sign-in form nor the token cookie is reachable there.
8042
+ if (viaOnlineHost(req.headers)) {
8043
+ accessVerdict(req.headers).then((v) => {
8044
+ if (v.ok) return handleRequest(req, res);
8045
+ res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
8046
+ res.end(JSON.stringify({ ok: false, error: 'your Cloudflare sign-in could not be verified for this workspace' }));
8047
+ }, () => {
8048
+ res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
8049
+ res.end(JSON.stringify({ ok: false, error: 'your Cloudflare sign-in could not be verified for this workspace' }));
8050
+ });
8051
+ return;
8052
+ }
7965
8053
  // The cookie is Secure when the browser reached us on the public host (HTTPS at the edge).
7966
- const secureCookie = !!BIND.publicHost && remoteAccess.isProxied(req.headers, BIND.publicHost);
8054
+ const secureCookie = !!publicHost() && remoteAccess.isProxied(req.headers, publicHost());
7967
8055
  // Token may arrive as an Authorization header, a ?token= query param, or the seeded cookie. A
7968
8056
  // browser only carries the token on the FIRST navigation URL — it cannot attach a header/query to
7969
8057
  // sub-resource loads (CSS/JS) or the WS upgrade — so the documented "open http://…/?token=<t>"
@@ -7986,7 +8074,7 @@ function httpGuard(req, res) {
7986
8074
  });
7987
8075
  return;
7988
8076
  }
7989
- if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress, headers: req.headers, publicHost: BIND.publicHost,
8077
+ if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress, headers: req.headers, publicHost: publicHost(),
7990
8078
  presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
7991
8079
  // A phone's browser gets a page it can act on, not JSON. API calls, scripts and anything else
7992
8080
  // that is not a page load keep the JSON 401 they had.
@@ -8002,7 +8090,7 @@ function httpGuard(req, res) {
8002
8090
  // navigation, so the browser's subsequent sub-resource + WS-upgrade requests authenticate without it
8003
8091
  // in the URL. HttpOnly (JS never needs it) + SameSite=Strict + the D64 Origin/Host allowlist keep a
8004
8092
  // foreign site from riding it; no Secure flag because the tailnet speaks plain HTTP (WireGuard-encrypted).
8005
- if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress) || remoteAccess.isProxied(req.headers, BIND.publicHost)) {
8093
+ if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress) || remoteAccess.isProxied(req.headers, publicHost())) {
8006
8094
  let qTok = null;
8007
8095
  try { qTok = new URL(req.url || '', 'http://localhost').searchParams.get('token'); } catch { /* ignore */ }
8008
8096
  if (qTok && remoteAccess.tokenMatches(qTok.trim(), TOKEN)) {
@@ -8028,20 +8116,31 @@ function handleUpgrade(req, socket, head) {
8028
8116
  // Same origin/host allowlist on the WS upgrade handshake (D64 A1): a cross-origin page must not be
8029
8117
  // able to open ws://127.0.0.1:<port> and spawn/type into a shell. Rejected BEFORE the socket is
8030
8118
  // accepted, and before loopback's token exemption applies. Header values never logged (D08).
8031
- if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost: BIND.publicHost })
8032
- || !remoteAccess.edgeAllowed({ headers: req.headers, publicHost: BIND.publicHost })) {
8119
+ if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost: publicHost() })
8120
+ || !remoteAccess.edgeAllowed({ headers: req.headers, publicHost: publicHost() })) {
8033
8121
  socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
8034
8122
  socket.destroy();
8035
8123
  return;
8036
8124
  }
8125
+ // workspace-online-mode (D89): the same verified-assertion rule as the HTTP guard, before the socket is accepted.
8126
+ if (viaOnlineHost(req.headers)) {
8127
+ accessVerdict(req.headers).then((v) => {
8128
+ if (!v.ok) { socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); socket.destroy(); return; }
8129
+ acceptUpgrade(req, socket, head);
8130
+ }, () => { socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); socket.destroy(); });
8131
+ return;
8132
+ }
8037
8133
  // A browser WebSocket cannot set a header or a ?token= — it authenticates via the same-origin cookie
8038
8134
  // seeded on the page load. presentedToken() resolves header → query → subprotocol → cookie.
8039
- if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress, headers: req.headers, publicHost: BIND.publicHost,
8135
+ if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress, headers: req.headers, publicHost: publicHost(),
8040
8136
  presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
8041
8137
  socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
8042
8138
  socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
8043
8139
  return;
8044
8140
  }
8141
+ acceptUpgrade(req, socket, head);
8142
+ }
8143
+ function acceptUpgrade(req, socket, head) {
8045
8144
  // R25 (bind): a session started with an initiative is bound to it, a PM-only act. Only a socket the page opened with
8046
8145
  // this launch's nonce (`?pmos-nonce=`) may bind; any other socket still spawns, unbound, and is told why.
8047
8146
  const bind = pmGuard.requirePmUpgrade(req, { act: 'bind' });
@@ -8184,6 +8283,7 @@ if (require.main === module) {
8184
8283
  if (shuttingDown) return; shuttingDown = true;
8185
8284
  for (const s of sessions.values()) if (s.pty) { try { persist(s); } catch {} }
8186
8285
  for (const l of listeners) { try { l.close(); } catch {} }
8286
+ try { tunnel.stop(); } catch {} // workspace-online-mode: the tunnel is this server's child and ends with it
8187
8287
  try { fs.unlinkSync(PID_FILE); } catch {} // stop being discoverable the moment we stop serving
8188
8288
  process.exit(0);
8189
8289
  };
@@ -8216,12 +8316,15 @@ if (require.main === module) {
8216
8316
  // one started by `pmos` — otherwise the next `pmos` cannot see it and starts a second server.
8217
8317
  try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
8218
8318
  for (const line of remoteAccess.startupLines(BIND, PORT, { ansi: !!process.stdout.isTTY && !process.env.NO_COLOR })) console.log(line);
8319
+ // workspace-online-mode: a workspace that stopped while Online was on comes back online (PRD O2)
8320
+ applyOnline();
8321
+ if (ONLINE.settings.on && !BIND.publicHost) console.log(` online: https://${ONLINE.settings.host}/ (Cloudflare Access sign-in, tunnel ${ONLINE.settings.tunnel})`);
8219
8322
  // bring in what another machine pushed while this one was off (D70 as amended); never on the request path
8220
8323
  { const t = setTimeout(() => { autoSync.startup().catch(() => {}); }, 3000); if (t.unref) t.unref(); }
8221
8324
  });
8222
8325
  });
8223
8326
  }
8224
- module.exports = { server, spawnSession, removeSession, renameSession, sessions, libraryTree, readTextDoc,
8327
+ module.exports = { server, online: { ONLINE, applyOnline, tunnel, publicHost }, spawnSession, removeSession, renameSession, sessions, libraryTree, readTextDoc,
8225
8328
  registerWorkspace, forgetWorkspace, workspaces, library, initiativeIndex, fsList,
8226
8329
  // R1 — exported so the suite can assert the CARRY-THROUGH: branchInfo separates an ordinary
8227
8330
  // non-repo folder from a real git fault, and the whole legibility defect was that distinction
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "workspace-online-mode (D89): the Cloudflare Access assertion check, the key store's fetch rules, the Online record and tunnel process, and the guards and /api/online driven on server.js as a library. No request leaves the process.",
3
+ "modules": ["online-mode.test.js"],
4
+ "seconds": { "online-mode.test.js": 4 }
5
+ }