create-pmos 0.53.1 → 0.53.2
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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/REMOTE.md +87 -6
- package/workspace/first-paint-audit.js +6 -2
- package/workspace/fixtures/initiatives-api/list.json +13 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +13 -0
- package/workspace/fixtures/report-api/draft.json +105 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +12 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiatives-roadmap.md +1 -0
- package/workspace/guide/initiatives-screen.md +1 -0
- package/workspace/guide/layout.md +3 -2
- package/workspace/guide/report-slides.md +28 -0
- package/workspace/guide/reports.md +2 -0
- package/workspace/lib/auto-sync.js +83 -0
- package/workspace/lib/remote-access.js +96 -16
- package/workspace/lib/report-draft.js +33 -4
- package/workspace/lib/report-slides-data.js +141 -0
- package/workspace/lib/routes-initiatives.js +35 -2
- package/workspace/lib/routes-reports.js +3 -3
- package/workspace/lib/sync.js +61 -6
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +16 -1
- package/workspace/public/pm/components.css +783 -0
- package/workspace/public/pm/dest-reports.js +8 -2
- package/workspace/public/pm/initiative-list.js +18 -1
- package/workspace/public/pm/initiatives-copy.js +4 -0
- package/workspace/public/pm/print.css +11 -0
- package/workspace/public/pm/report-slides.js +438 -0
- package/workspace/public/pm/report-view.js +8 -2
- package/workspace/public/pm/reports-copy.js +3 -0
- package/workspace/public/pm/roadmap.js +23 -0
- package/workspace/public/pm/slides-copy.js +52 -0
- package/workspace/public/pm/tokens.css +13 -0
- package/workspace/public/pm/topbar.js +5 -2
- package/workspace/replaced-tests.json +23 -1
- package/workspace/server.js +33 -13
- package/workspace/skills/report-biweekly.skill +1 -0
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/run6.json +9 -0
- package/workspace/test-lists/workspace-public-host.json +5 -0
|
@@ -27,6 +27,7 @@ import { loadSortable, baseOptions, dragSet } from './bd-drag.js';
|
|
|
27
27
|
import { COPY, shortDate } from './initiatives-copy.js';
|
|
28
28
|
import { getRoadmap, sigKey, place, placed, leaveNow } from './initiatives-api.js';
|
|
29
29
|
import { healthSignal, say, checkBySheet, onContextMenu } from './initiatives-ui.js';
|
|
30
|
+
import { openDoc } from './panel-initiatives.js';
|
|
30
31
|
|
|
31
32
|
const HORIZONS = ['now', 'next', 'later'];
|
|
32
33
|
|
|
@@ -37,6 +38,7 @@ export function mountRoadmap(host, env) {
|
|
|
37
38
|
let data = null;
|
|
38
39
|
let error = null;
|
|
39
40
|
let showDormant = false;
|
|
41
|
+
let showUnrecorded = false;
|
|
40
42
|
let dragging = false;
|
|
41
43
|
let deferred = null;
|
|
42
44
|
let focusId = null;
|
|
@@ -150,6 +152,18 @@ export function mountRoadmap(host, env) {
|
|
|
150
152
|
});
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
// A unit with documents and no record (routes-initiatives.js unrecordedOf). It opens its PRD, or its first document; it
|
|
156
|
+
// has no menu and no grip, because only a record can be placed.
|
|
157
|
+
function unrecordedCard(u) {
|
|
158
|
+
const doc = u.artifacts.find((a) => a.role === 'PRD') || u.artifacts[0];
|
|
159
|
+
const roles = [...new Set(u.artifacts.map((a) => a.role).filter(Boolean))];
|
|
160
|
+
return card({
|
|
161
|
+
title: u.title, meta: [COPY.noRecord, ...(roles.length ? [roles.join(' · ')] : [])],
|
|
162
|
+
onOpen: () => openDoc(null, doc.path, doc.role || 'DOC'), openLabel: COPY.aria.open(u.title),
|
|
163
|
+
muted: true, attrs: { class: 'in-card', 'data-in-unrecorded-card': u.id }, openAttrs: { 'data-in-unrecorded-open': u.id },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
153
167
|
function column(h) {
|
|
154
168
|
const rows = data.horizons[h] || [];
|
|
155
169
|
const list = el('div', { class: 'in-list', 'data-in-list': h, role: 'list', 'aria-label': COPY.aria.column(COPY.horizons[h]) }, rows.map((r) => cardOf(r, { grip: true })));
|
|
@@ -166,6 +180,15 @@ export function mountRoadmap(host, env) {
|
|
|
166
180
|
}));
|
|
167
181
|
if (showDormant) kids.push(el('div', { class: 'in-list in-list--dormant', 'data-in-dormant': '' }, data.dormant.map((r) => cardOf(r, { grip: false }))));
|
|
168
182
|
}
|
|
183
|
+
if (h === 'later' && data.unrecorded && data.unrecorded.length) {
|
|
184
|
+
const n = data.unrecorded.length;
|
|
185
|
+
kids.push(button({
|
|
186
|
+
label: showUnrecorded ? COPY.hideUnrecorded(n) : COPY.unrecorded(n), variant: 'ghost', size: 'sm',
|
|
187
|
+
icon: showUnrecorded ? 'chevron-down' : 'chevron-right', attrs: { 'data-in-unrecorded-toggle': '', 'aria-expanded': showUnrecorded ? 'true' : 'false' },
|
|
188
|
+
onClick: () => { showUnrecorded = !showUnrecorded; render(); },
|
|
189
|
+
}));
|
|
190
|
+
if (showUnrecorded) kids.push(el('div', { class: 'in-list in-list--dormant', 'data-in-unrecorded': '' }, data.unrecorded.map(unrecordedCard)));
|
|
191
|
+
}
|
|
169
192
|
return el('section', { class: 'in-col', 'data-in-col': h, 'aria-label': COPY.horizons[h] }, kids);
|
|
170
193
|
}
|
|
171
194
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/* pm/slides-copy.js — every word on the biweekly slides and their deck (pm-os-absorb Run 6; PRD R22.7).
|
|
2
|
+
*
|
|
3
|
+
* Pure data and pure functions. No DOM and no import, so glossary.test.js loads this file in Node and checks each string
|
|
4
|
+
* against the glossary rules. report-slides.js draws the slides from these strings and nothing else.
|
|
5
|
+
*
|
|
6
|
+
* The slides copy the PM-OS biweekly deck word for word: the PM asked for the slide format "exactly" as PM-OS has it
|
|
7
|
+
* (2026-10-06). So GLOSSARY.exempt lifts two rules for this file only: the column and slide names "Release" and "Release
|
|
8
|
+
* notes", and the empty column "Nothing shipped.", are the PM-OS deck's own words. Everywhere else in the workspace the
|
|
9
|
+
* rules hold. A slide is still ISSUED with its report, never sent (D71): the issued-not-sent rule applies here too.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const COPY = {
|
|
13
|
+
slidesTitle: 'Slides',
|
|
14
|
+
slidesMissing: 'This report has no slides. Redraft it to get them.',
|
|
15
|
+
slidesPrev: 'Previous slide',
|
|
16
|
+
slidesNext: 'Next slide',
|
|
17
|
+
slidesClose: 'Close the slides',
|
|
18
|
+
slidesCount: (n, of) => n + ' / ' + of,
|
|
19
|
+
print: 'Print / PDF',
|
|
20
|
+
more: (n) => '+' + n + ' more',
|
|
21
|
+
roadmap: 'Roadmap',
|
|
22
|
+
list: 'Initiative progress',
|
|
23
|
+
changelog: 'Release notes',
|
|
24
|
+
issues: 'Issues',
|
|
25
|
+
duration: 'Duration',
|
|
26
|
+
initiative: 'Initiative',
|
|
27
|
+
status: 'Status',
|
|
28
|
+
nextMilestone: 'Next milestone',
|
|
29
|
+
release: 'Release',
|
|
30
|
+
overdue: (d) => 'Overdue · ' + d,
|
|
31
|
+
noBreakdown: 'No breakdown',
|
|
32
|
+
noInitiatives: 'No initiatives in this window.',
|
|
33
|
+
lastCycle: 'Last cycle',
|
|
34
|
+
nextCycle: 'Next cycle',
|
|
35
|
+
milestone: 'Milestone',
|
|
36
|
+
nothingShipped: 'Nothing shipped.',
|
|
37
|
+
nothingPlanned: 'Nothing planned.',
|
|
38
|
+
noteType: { note: 'Note', info: 'Info', warning: 'Warning' },
|
|
39
|
+
noReleaseNotes: 'No release notes logged.',
|
|
40
|
+
entries: (n) => n + (n === 1 ? ' entry' : ' entries'),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const GLOSSARY = {
|
|
44
|
+
exempt: {
|
|
45
|
+
release: 'The PM-OS deck names its column "Release" and its slide "Release notes"; the PM asked for that format exactly (2026-10-06).',
|
|
46
|
+
claims: 'The PM-OS deck says "Nothing shipped." in an empty Last cycle; the PM asked for that format exactly (2026-10-06).',
|
|
47
|
+
},
|
|
48
|
+
extra: [
|
|
49
|
+
{ id: 'issued-not-sent', re: /\b(?:sent|sends?|sending|e-?mail(?:s|ed|ing)?|shar(?:e|es|ed|ing)|post(?:s|ed|ing)? to)\b/i, why: 'A report is issued, never sent (D71). The slides have no control that moves a report out of the workspace.' },
|
|
50
|
+
],
|
|
51
|
+
guide: 'report-slides.md',
|
|
52
|
+
};
|
|
@@ -66,6 +66,19 @@
|
|
|
66
66
|
--term-bg:oklch(0.148 0.004 228.8);
|
|
67
67
|
--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
68
68
|
--sans:"Space Grotesk",ui-sans-serif,system-ui,sans-serif;
|
|
69
|
+
/* the biweekly slides (Run 6): the PM-OS slide palette, the same in both themes because a slide is a printed page */
|
|
70
|
+
--slide-bg:oklch(1 0 0);--slide-fg:oklch(0.148 0.004 228.8);--slide-muted:oklch(0.56 0.021 213.5);
|
|
71
|
+
--slide-foot:oklch(0.556 0 0);--slide-rule:#d8d6cb;--slide-border:oklch(0.925 0.005 214.3);
|
|
72
|
+
--slide-secondary:oklch(0.967 0.001 286.375);--slide-track:oklch(0.963 0.002 197.1);
|
|
73
|
+
--slide-brand:#2433a4;--slide-primary:oklch(0.488 0.243 264.376);--slide-overdue:oklch(0.577 0.245 27.325);
|
|
74
|
+
--slide-stage:oklch(0 0 0);--slide-open:#94a3b8;--slide-done:#10b981;
|
|
75
|
+
--slide-ph-intake:#8b5cf6;--slide-ph-spec:#f59e0b;--slide-ph-build:#3b82f6;--slide-ph-evaluate:#06b6d4;
|
|
76
|
+
--slide-ph-handoff:#10b981;--slide-ph-other:#64748b;
|
|
77
|
+
--slide-now:#1a56db;--slide-next:#d97706;--slide-later:#64748b;
|
|
78
|
+
--slide-launch:#047857;--slide-launch-bg:#10b9811a;--slide-improvement:#2f6feb;--slide-improvement-bg:#2f6feb1a;
|
|
79
|
+
--slide-fix:#92400e;--slide-fix-bg:#f59e0b1f;--slide-security:#b91c1c;--slide-security-bg:#dc26261a;
|
|
80
|
+
--slide-deprecation:#4b5563;--slide-deprecation-bg:#4b55631a;--slide-beta:#6d28d9;--slide-beta-bg:#6d28d91a;
|
|
81
|
+
--slide-note-info:#2563eb;--slide-note-warning:#b45309;
|
|
69
82
|
}
|
|
70
83
|
[data-pm-theme="light"],html[data-pm-page-theme="light"]{
|
|
71
84
|
--background:oklch(1 0 0);--foreground:oklch(0.148 0.004 228.8);
|
|
@@ -25,9 +25,12 @@ import { COPY } from './frame-copy.js';
|
|
|
25
25
|
/** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. */
|
|
26
26
|
export const BAR_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports']);
|
|
27
27
|
|
|
28
|
-
/** Is this sync reading "not in sync"? Behind, ahead
|
|
28
|
+
/** Is this sync reading "not in sync"? Behind, ahead, records not yet recorded, or `remote_stale`: no fetch in the last
|
|
29
|
+
* few minutes, so another machine's push would be invisible here and "in sync" cannot be claimed. An unreadable status
|
|
30
|
+
* is not shown. */
|
|
29
31
|
export function unsyncedOf(st) {
|
|
30
|
-
return !!st && st.ok !== false
|
|
32
|
+
return !!st && st.ok !== false
|
|
33
|
+
&& ((st.behind | 0) > 0 || (st.ahead | 0) > 0 || (st.dirty_records | 0) > 0 || st.remote_stale === true);
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export function createTopbar({ go, openPalette, repoMenu, sync } = {}) {
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"run": "2A",
|
|
24
24
|
"ref": "contract-run2 done_criteria 14 (the plan-routes.test.js Origin cases)",
|
|
25
25
|
"reason": "R9 asserted the route's own no-Origin text, which named the terminal route. After Run 2 the route runs pm-guard first, so R9 now asserts pm-guard's refusal; the 403 and the nothing-written check stay.",
|
|
26
|
-
"names": [
|
|
26
|
+
"names": [
|
|
27
|
+
"R9 a POST with no Origin is 403 and names the terminal route; nothing is written"
|
|
28
|
+
]
|
|
27
29
|
},
|
|
28
30
|
{
|
|
29
31
|
"id": "STG-SLACK",
|
|
@@ -37,6 +39,26 @@
|
|
|
37
39
|
"G14 the new settings are checked: name length, source names, booleans",
|
|
38
40
|
"I6 an unknown brief source is refused with the allowed names"
|
|
39
41
|
]
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "S6-D86",
|
|
45
|
+
"file": "workspace/d86-seams.test.js",
|
|
46
|
+
"run": "6",
|
|
47
|
+
"ref": "Run 6 contract, 'Deliberate test and budget changes' (planning/evals/pm-os-absorb-eval-rubric.md)",
|
|
48
|
+
"reason": "The draft golden gains the slides key: the drafter now writes the slide data with the draft (PRD R22.7). The assertion keeps its name and still checks every D78 section and the reason rule.",
|
|
49
|
+
"names": [
|
|
50
|
+
"10 the draft golden has every section of the D78 frame (headline, timeline, blocks, new, decided, needs your call, sources), and every block not on track has a reason"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "S6-FP",
|
|
55
|
+
"file": "workspace/first-paint-count.test.js",
|
|
56
|
+
"run": "6",
|
|
57
|
+
"ref": "Run 6 contract, 'Deliberate test and budget changes' (planning/evals/pm-os-absorb-eval-rubric.md)",
|
|
58
|
+
"reason": "The Reports budget rose to 15 for Present, but specimen A did not change. The calibration now reads SPECIMEN_A, specimen A's own counts, instead of EXPECT, the budgets. The name and the four numbers are unchanged.",
|
|
59
|
+
"names": [
|
|
60
|
+
"(a) calibration: the audit on specimen A gives exactly Today 17, Initiatives 24, Board 33, Reports 14 outside the dock"
|
|
61
|
+
]
|
|
40
62
|
}
|
|
41
63
|
],
|
|
42
64
|
"entries": [
|
package/workspace/server.js
CHANGED
|
@@ -1789,6 +1789,14 @@ function readLedger(w, basename) {
|
|
|
1789
1789
|
// D70 — the lifecycle ledger: the latest line for an initiative wins; `active` reopens. The same rule
|
|
1790
1790
|
// as scripts/workflow-state.js#readLifecycle (a parity case in workspace/test.js keeps them in step).
|
|
1791
1791
|
const syncing = new Set(); // repositories with a sync in flight — one at a time
|
|
1792
|
+
// D70 as amended 2026-10-06: the same Sync, run without the press at startup and when work sits unpushed and quiet.
|
|
1793
|
+
// Every rule it must pass is in lib/auto-sync.js; a scratch server (its own PMOS_STATE_DIR) does not auto-sync.
|
|
1794
|
+
const autoSync = require('./lib/auto-sync').createAutoSync({
|
|
1795
|
+
roots: () => [...new Set([...workspaces.values()].map((w) => w.root))],
|
|
1796
|
+
busy: syncing,
|
|
1797
|
+
onDone: (root) => boardCache.invalidate(root),
|
|
1798
|
+
label: `${os.hostname()} (auto-sync)`,
|
|
1799
|
+
});
|
|
1792
1800
|
const STOPPED_STATES = ['closed', 'on-hold', 'cancelled'];
|
|
1793
1801
|
function readLifecycle(w) {
|
|
1794
1802
|
const latest = new Map();
|
|
@@ -2317,7 +2325,7 @@ function checkDueSchedules() {
|
|
|
2317
2325
|
// PMOS_TICK_MS exists for the suites (same footing as PMOS_LIVENESS_MS): proactive-prepare.test.js
|
|
2318
2326
|
// used to wait on two real minutes of this clock. Unset, the interval is the 60 seconds it always was.
|
|
2319
2327
|
const TICK_MS = Math.max(200, Number(process.env.PMOS_TICK_MS) || 60000);
|
|
2320
|
-
try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } }, TICK_MS); if (_tick.unref) _tick.unref(); } catch {}
|
|
2328
|
+
try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } autoSync.tick().catch(() => {}); }, TICK_MS); if (_tick.unref) _tick.unref(); } catch {}
|
|
2321
2329
|
|
|
2322
2330
|
// ── Sessions: real PTYs, owned by the server, with disk-backed scrollback + metadata. ──
|
|
2323
2331
|
/** @type {Map<string, {id,ws,name,agent,cwd,state,note,created,pty,buf,cols,rows,clients:Set}>} */
|
|
@@ -6486,11 +6494,11 @@ async function handleRequest(req, res) {
|
|
|
6486
6494
|
// Local only — no network. Safe to poll; it is what tells the PM whether a press is needed.
|
|
6487
6495
|
const w = getWs(url.searchParams.get('ws'));
|
|
6488
6496
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
6489
|
-
return json(res, { ws: w.id, name: w.name, ...(await syncLib.status(w.root)) });
|
|
6497
|
+
return json(res, { ws: w.id, name: w.name, ...(await syncLib.status(w.root)), auto: autoSync.lastOf(w.root) });
|
|
6490
6498
|
}
|
|
6491
6499
|
if (p === '/api/sync' && req.method === 'POST') {
|
|
6492
|
-
// D70 — the
|
|
6493
|
-
//
|
|
6500
|
+
// D70 — the PM's press. The only other caller of the same sync is lib/auto-sync.js (D70 as amended
|
|
6501
|
+
// 2026-10-06), under its own rules; neither is on any other action's request path.
|
|
6494
6502
|
let body = {};
|
|
6495
6503
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6496
6504
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
@@ -7478,7 +7486,7 @@ async function handleRequest(req, res) {
|
|
|
7478
7486
|
return json(res, { ok: true, changes: rows.slice(-200).reverse(), total: rows.length });
|
|
7479
7487
|
}
|
|
7480
7488
|
// pm-os-absorb Runs 2–5 — the planning layer's routes, one file per run (lib/route-modules.js). Last, so none shadows a route above.
|
|
7481
|
-
if (await routeModules.dispatch(req, res, url, { json, readBody, getWs, invalidateNeedsAgg, boardCache, consoleState, stateDirFor, sessionList })) return;
|
|
7489
|
+
if (await routeModules.dispatch(req, res, url, { json, readBody, getWs, invalidateNeedsAgg, boardCache, consoleState, stateDirFor, sessionList, initiativeIndex })) return;
|
|
7482
7490
|
res.writeHead(404); res.end('not found');
|
|
7483
7491
|
}
|
|
7484
7492
|
|
|
@@ -7544,18 +7552,27 @@ function writeChangeContext(dir) {
|
|
|
7544
7552
|
catch { return null; }
|
|
7545
7553
|
}
|
|
7546
7554
|
|
|
7547
|
-
// ── Access guard (D62): loopback is token-free; every non-loopback
|
|
7555
|
+
// ── Access guard (D62): loopback is token-free; every non-loopback or proxied request needs the bearer token. ──
|
|
7548
7556
|
// Peer-based: what matters is where the request came FROM, not which listener accepted it. With the
|
|
7549
7557
|
// remote flag off there is no non-loopback listener, so this is transparent for local dev. Headers
|
|
7550
7558
|
// and the token are never logged (D08).
|
|
7551
7559
|
function httpGuard(req, res) {
|
|
7552
7560
|
// Origin/Host allowlist FIRST (D64 A1): reject a cross-site / DNS-rebinding request before the
|
|
7553
7561
|
// token exemption for loopback can be reached. Header values are never logged (D08).
|
|
7554
|
-
if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds })) {
|
|
7562
|
+
if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost: BIND.publicHost })) {
|
|
7555
7563
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
7556
7564
|
res.end(JSON.stringify({ ok: false, error: 'forbidden origin/host' }));
|
|
7557
7565
|
return;
|
|
7558
7566
|
}
|
|
7567
|
+
// workspace-public-host: a request to the public hostname must have passed Cloudflare Access. A tunnel
|
|
7568
|
+
// with no Access application in front fails here, before even the sign-in form can be reached.
|
|
7569
|
+
if (!remoteAccess.edgeAllowed({ headers: req.headers, publicHost: BIND.publicHost })) {
|
|
7570
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
7571
|
+
res.end(JSON.stringify({ ok: false, error: 'the public host is only reachable through Cloudflare Access' }));
|
|
7572
|
+
return;
|
|
7573
|
+
}
|
|
7574
|
+
// The cookie is Secure when the browser reached us on the public host (HTTPS at the edge).
|
|
7575
|
+
const secureCookie = !!BIND.publicHost && remoteAccess.isProxied(req.headers, BIND.publicHost);
|
|
7559
7576
|
// Token may arrive as an Authorization header, a ?token= query param, or the seeded cookie. A
|
|
7560
7577
|
// browser only carries the token on the FIRST navigation URL — it cannot attach a header/query to
|
|
7561
7578
|
// sub-resource loads (CSS/JS) or the WS upgrade — so the documented "open http://…/?token=<t>"
|
|
@@ -7570,7 +7587,7 @@ function httpGuard(req, res) {
|
|
|
7570
7587
|
req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
|
|
7571
7588
|
req.on('end', () => {
|
|
7572
7589
|
if (remoteAccess.tokenMatches(remoteAccess.parseAuthForm(body), TOKEN)) {
|
|
7573
|
-
res.writeHead(303, { Location: '/', 'Set-Cookie': remoteAccess.sessionCookie(TOKEN), 'Cache-Control': 'no-store' });
|
|
7590
|
+
res.writeHead(303, { Location: '/', 'Set-Cookie': remoteAccess.sessionCookie(TOKEN, { secure: secureCookie }), 'Cache-Control': 'no-store' });
|
|
7574
7591
|
return res.end();
|
|
7575
7592
|
}
|
|
7576
7593
|
res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
@@ -7578,7 +7595,7 @@ function httpGuard(req, res) {
|
|
|
7578
7595
|
});
|
|
7579
7596
|
return;
|
|
7580
7597
|
}
|
|
7581
|
-
if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress,
|
|
7598
|
+
if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress, headers: req.headers, publicHost: BIND.publicHost,
|
|
7582
7599
|
presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
|
|
7583
7600
|
// A phone's browser gets a page it can act on, not JSON. API calls, scripts and anything else
|
|
7584
7601
|
// that is not a page load keep the JSON 401 they had.
|
|
@@ -7594,11 +7611,11 @@ function httpGuard(req, res) {
|
|
|
7594
7611
|
// navigation, so the browser's subsequent sub-resource + WS-upgrade requests authenticate without it
|
|
7595
7612
|
// in the URL. HttpOnly (JS never needs it) + SameSite=Strict + the D64 Origin/Host allowlist keep a
|
|
7596
7613
|
// foreign site from riding it; no Secure flag because the tailnet speaks plain HTTP (WireGuard-encrypted).
|
|
7597
|
-
if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress)) {
|
|
7614
|
+
if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress) || remoteAccess.isProxied(req.headers, BIND.publicHost)) {
|
|
7598
7615
|
let qTok = null;
|
|
7599
7616
|
try { qTok = new URL(req.url || '', 'http://localhost').searchParams.get('token'); } catch { /* ignore */ }
|
|
7600
7617
|
if (qTok && remoteAccess.tokenMatches(qTok.trim(), TOKEN)) {
|
|
7601
|
-
res.setHeader('Set-Cookie', remoteAccess.sessionCookie(TOKEN));
|
|
7618
|
+
res.setHeader('Set-Cookie', remoteAccess.sessionCookie(TOKEN, { secure: secureCookie }));
|
|
7602
7619
|
// workspace-phone-app: on a page load, redirect to the same URL without the token, so it does
|
|
7603
7620
|
// not stay in the address bar, the history, or the start_url of a home-screen install.
|
|
7604
7621
|
const clean = navigation ? remoteAccess.stripTokenParam(req.url) : null;
|
|
@@ -7620,14 +7637,15 @@ function handleUpgrade(req, socket, head) {
|
|
|
7620
7637
|
// Same origin/host allowlist on the WS upgrade handshake (D64 A1): a cross-origin page must not be
|
|
7621
7638
|
// able to open ws://127.0.0.1:<port> and spawn/type into a shell. Rejected BEFORE the socket is
|
|
7622
7639
|
// accepted, and before loopback's token exemption applies. Header values never logged (D08).
|
|
7623
|
-
if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds })
|
|
7640
|
+
if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost: BIND.publicHost })
|
|
7641
|
+
|| !remoteAccess.edgeAllowed({ headers: req.headers, publicHost: BIND.publicHost })) {
|
|
7624
7642
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
7625
7643
|
socket.destroy();
|
|
7626
7644
|
return;
|
|
7627
7645
|
}
|
|
7628
7646
|
// A browser WebSocket cannot set a header or a ?token= — it authenticates via the same-origin cookie
|
|
7629
7647
|
// seeded on the page load. presentedToken() resolves header → query → subprotocol → cookie.
|
|
7630
|
-
if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress,
|
|
7648
|
+
if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress, headers: req.headers, publicHost: BIND.publicHost,
|
|
7631
7649
|
presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
|
|
7632
7650
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
7633
7651
|
socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
|
|
@@ -7798,6 +7816,8 @@ if (require.main === module) {
|
|
|
7798
7816
|
// one started by `pmos` — otherwise the next `pmos` cannot see it and starts a second server.
|
|
7799
7817
|
try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
|
|
7800
7818
|
for (const line of remoteAccess.startupLines(BIND, PORT, { ansi: !!process.stdout.isTTY && !process.env.NO_COLOR })) console.log(line);
|
|
7819
|
+
// bring in what another machine pushed while this one was off (D70 as amended); never on the request path
|
|
7820
|
+
{ const t = setTimeout(() => { autoSync.startup().catch(() => {}); }, 3000); if (t.unref) t.unref(); }
|
|
7801
7821
|
});
|
|
7802
7822
|
});
|
|
7803
7823
|
}
|
|
@@ -39,6 +39,7 @@ Procedure:
|
|
|
39
39
|
- For each `<!-- pmos:note <initiative-id> -->`, one or two sentences: what moved and what it means for the reader. Start with "Warning:" when the block is off track or at risk. A note that already has text is the PM's or an earlier run's: leave it.
|
|
40
40
|
- For `<!-- pmos:note report.headline -->`, one or two sentences: what the reader most needs to know. If the definition is a draft, say so in the first line.
|
|
41
41
|
- For `<!-- pmos:note report.asks -->`, if the file has it: the decisions only the audience can give, one per line, each line starting with "- ". Never ask the audience for a decision that is the PM's own.
|
|
42
|
+
- Never write in `<!-- pmos:note report.changelog -->`. It holds the PM's own release notes for the changelog slide, and only the PM writes them. Leave it empty or as the PM left it.
|
|
42
43
|
7. Run `pmos report draft biweekly --redraft`. The script is the one writer of the draft file: it writes the file again and keeps every note, so a line you changed outside a note marker is put back. Read the file once more and check that each note you wrote is still in place. Do not commit; the PM's Sync carries the file. Finish by saying in one line where the draft is.
|
|
43
44
|
- It refuses (the slot was issued since step 4, or a block has no reason for its health): quote the refusal and stop. Say that your notes are in the file and that the file is not re-rendered, so a line outside a note marker may differ from the script's. Do not edit the file again.
|
|
44
45
|
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"start-policy.test.js",
|
|
31
31
|
"state-isolation.test.js",
|
|
32
32
|
"sync.test.js",
|
|
33
|
+
"auto-sync.test.js",
|
|
33
34
|
"schedule-cli.test.js",
|
|
34
35
|
"initiative-surface.test.js",
|
|
35
36
|
"guide.test.js",
|
|
@@ -126,6 +127,7 @@
|
|
|
126
127
|
"start-policy.test.js": 1,
|
|
127
128
|
"state-isolation.test.js": 4,
|
|
128
129
|
"sync.test.js": 4,
|
|
130
|
+
"auto-sync.test.js": 4,
|
|
129
131
|
"schedule-cli.test.js": 3,
|
|
130
132
|
"initiative-surface.test.js": 3,
|
|
131
133
|
"guide.test.js": 1,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "pm-os-absorb Run 6 (the biweekly as slides, PRD R22.7). The browser suites run only through runs/browser-run.sh, two at a time machine-wide.",
|
|
3
|
+
"modules": ["report-slides-data.test.js"],
|
|
4
|
+
"panels": ["pm-report-slides-test.js"],
|
|
5
|
+
"seconds": {
|
|
6
|
+
"report-slides-data.test.js": 2,
|
|
7
|
+
"pm-report-slides-test.js": 45
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "workspace-public-host: the proxied-request branches of the access guard (no loopback exemption through a proxy, the one public hostname, the Cloudflare Access presence check, the Secure cookie), driven through httpGuard and the upgrade guard as a library.",
|
|
3
|
+
"modules": ["public-host.test.js"],
|
|
4
|
+
"seconds": { "public-host.test.js": 3 }
|
|
5
|
+
}
|