@gotcos/glasses-server 7.0.27 → 7.0.30
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/CHANGELOG.md +93 -0
- package/docs/web-key-verifier-contract.md +34 -8
- package/package.json +1 -1
- package/server/index.ts +29 -6
- package/server/lib/provider-failure-text.ts +4 -1
- package/server/lib/tailnet-self-name.ts +57 -10
- package/server/lib/web-key-authority.ts +1 -1
- package/server/lib/web-key-registry.ts +1 -1
- package/server/lib/web-key-signer-app.ts +25 -6
- package/server/lib/web-key-signer-tls.ts +800 -0
- package/server/routes/web-key.ts +31 -2
- package/server/web-control/.manifest.json +16 -16
- package/server/web-control/app.css +58 -2
- package/server/web-control/app.js +478 -51
- package/server/web-control/control.css +32 -2
- package/server/web-control/index.html +46 -5
- package/server/web-control/panes/cos-chat.css +5 -1
- package/server/web-control/panes/cos-chat.js +9 -3
- package/server/web-control/panes/meetings.css +4 -0
- package/server/web-control/panes/meetings.js +25 -6
- package/server/web-control/panes/memories.js +2 -2
- package/server/web-control/panes/messages.js +7 -2
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/speakers.js +28 -3
- package/server/web-control/panes/today-actions.js +2 -2
- package/server/web-control/panes/work.js +1 -1
- package/server/web-control/shell.js +2 -1
- package/server/web-control/theme.js +169 -2
- package/server/web-signer/.manifest.json +2 -2
- package/server/web-signer/signer-proof.js +18 -3
- package/server/web-signer/signer.js +4 -2
- package/shared/web-turn-envelope.ts +33 -8
|
@@ -25,6 +25,32 @@
|
|
|
25
25
|
if (media && media.addEventListener) media.addEventListener('change', function () { if (read() === 'system') apply('system'); });
|
|
26
26
|
})();
|
|
27
27
|
|
|
28
|
+
/* 7.0.29 (Miles 21:42): "for shorter screens like the duo we should use the side rail when it's folded"; tall phones keep
|
|
29
|
+
the top menu. The test is the SCREEN's shape, never the window's: a screen whose short side is under 600 and whose long
|
|
30
|
+
side is under 1.6 times its short side is short (html.short-screen). app.css draws the side rail only with this class
|
|
31
|
+
AND a page under 600 px wide on a touch screen. Missing or odd numbers give no class: no rail, the layout as before.
|
|
32
|
+
MEASURED: his phone's own device reports (2026-11-10 and 11-11) give screen.width 466 and screen.height 678 in rows laid
|
|
33
|
+
out 449, 548 and 1119 px wide at visualViewport.scale 0.85, and the canary (11-11 21:56) read screen.width 466 in a
|
|
34
|
+
Safari tab, folded, at scale 1. 678 / 466 is 1.46. Every iPhone's published screen is 1.78 (SE, 8 Plus) or 2.16 and up.
|
|
35
|
+
ASSUMED, not measured: which of those rows were the Home Screen app and which a tab, that other zoom settings report
|
|
36
|
+
the same, and that folding or opening never changes the numbers. So this one function runs at load (before the first
|
|
37
|
+
paint, so the rail never moves the page on load) and again on every resize and orientation change, and the page's
|
|
38
|
+
device report and a 'rail' report on each change carry screen.width and screen.height, so his phone settles it. */
|
|
39
|
+
(function () {
|
|
40
|
+
function shortScreen() {
|
|
41
|
+
var de = document.documentElement, on = false;
|
|
42
|
+
try {
|
|
43
|
+
var s = window.screen, a = s ? Number(s.width) : 0, b = s ? Number(s.height) : 0;
|
|
44
|
+
if (a > 0 && b > 0 && isFinite(a) && isFinite(b)) { var lo = Math.min(a, b), hi = Math.max(a, b); on = lo < 600 && hi / lo < 1.6; }
|
|
45
|
+
} catch (_e) { on = false; }
|
|
46
|
+
try { if (de && de.classList) { if (on) de.classList.add('short-screen'); else de.classList.remove('short-screen'); } } catch (_e) { /* no class: no rail */ }
|
|
47
|
+
return on;
|
|
48
|
+
}
|
|
49
|
+
window.CosShortScreen = shortScreen;
|
|
50
|
+
shortScreen();
|
|
51
|
+
if (typeof window.addEventListener === 'function') { window.addEventListener('resize', shortScreen); window.addEventListener('orientationchange', shortScreen); }
|
|
52
|
+
})();
|
|
53
|
+
|
|
28
54
|
/* The app's scripts (index.html #boot-scripts), loaded one frame after the page is drawn so the first paint never waits
|
|
29
55
|
for them. Same files, same order (async=false keeps insertion order), and app.js's boot runs only once every one of
|
|
30
56
|
them has run, as when they were in the page: window.CosBoot.after(boot). The frame wait has a 50 ms fallback, because
|
|
@@ -40,10 +66,16 @@
|
|
|
40
66
|
list = Array.isArray(list) ? list.filter(function (u) { return typeof u === 'string' && u.indexOf('/control/') === 0; }) : [];
|
|
41
67
|
var left = list.length;
|
|
42
68
|
if (!left) { settle(); return; }
|
|
69
|
+
// 7.0.28: after a reload that the restyle check below made because a script had failed, ask for every script with a
|
|
70
|
+
// fresh query, so none comes from the browser's memory of the failed request (WebKit kept the failure across the
|
|
71
|
+
// reload). Read once.
|
|
72
|
+
var bust = '';
|
|
73
|
+
try { bust = sessionStorage.getItem('cos_web_bust') || ''; sessionStorage.removeItem('cos_web_bust'); } catch (_e) { bust = ''; }
|
|
43
74
|
list.forEach(function (src) {
|
|
44
75
|
var el = document.createElement('script');
|
|
45
|
-
el.src = src; el.async = false;
|
|
46
|
-
el.onload =
|
|
76
|
+
el.src = /^\d{1,15}$/.test(bust) ? src + (src.indexOf('?') >= 0 ? '&' : '?') + 'r=' + bust : src; el.async = false;
|
|
77
|
+
el.onload = function () { if (--left === 0) settle(); };
|
|
78
|
+
el.onerror = function () { if (window.CosRestyle) window.CosRestyle.scriptFailed(src); if (--left === 0) settle(); };
|
|
47
79
|
document.body.appendChild(el);
|
|
48
80
|
});
|
|
49
81
|
}
|
|
@@ -54,3 +86,138 @@
|
|
|
54
86
|
}
|
|
55
87
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', afterFrame); else afterFrame();
|
|
56
88
|
})();
|
|
89
|
+
|
|
90
|
+
/* 7.0.28 (Miles 19:28, "Connect this browser" with no styling): the page's own files must not stay missing. When the
|
|
91
|
+
phone opens the page before it can reach the Mac (the tunnel still coming up, or the page drawn from the browser's
|
|
92
|
+
copy), the HTML draws but a stylesheet or a script can fail, and nothing asked for it again. index.html's inline
|
|
93
|
+
critical CSS keeps the sign-in card right meanwhile; this brings the rest back. While anything failed, it asks the
|
|
94
|
+
Mac for the failed file after 1, 2, 4 and 8 s, then every 30 s, only while the page is in front. When the Mac
|
|
95
|
+
answers: a failed script reloads the page (app.js cannot be re-run in place); failed stylesheets are asked for again
|
|
96
|
+
in place. A file that keeps failing never loops: at most 2 reloads in 5 minutes. What happened is kept for app.js's numbers-only 'restyle' row (window.CosRestyle.last, and sessionStorage
|
|
97
|
+
across the reload).
|
|
98
|
+
7.0.29 (Q1 FIX 1 and 2, the lead's R1 and R2): the page never reloads by itself over his words. While any text box
|
|
99
|
+
holds words, focus is in a text box, or a send is in flight (app.js's window.CosBusy; when app.js is the script that
|
|
100
|
+
failed, the boxes alone), it shows one line with a Reload button instead, and only his tap reloads. The cap fails
|
|
101
|
+
closed: if the reload count cannot be read, or does not read back after writing (storage blocked), it gives up and
|
|
102
|
+
shows the same line rather than reloading. */
|
|
103
|
+
(function () {
|
|
104
|
+
var STEPS = [1000, 2000, 4000, 8000], SETTLED_MS = 30000, KEY = 'cos_web_restyle', RELOADS_KEY = 'cos_web_restyle_reloads';
|
|
105
|
+
var RELOAD_MAX = 2, RELOAD_WINDOW_MS = 5 * 60000;
|
|
106
|
+
var R = { scripts: [], sheets: 0, tries: 0, t0: 0, timer: null, done: false, last: null };
|
|
107
|
+
// Every real browser has these; a harness that runs this file without them gets no restyle check, not a throw that
|
|
108
|
+
// would stop the theme and the loader above from being tested.
|
|
109
|
+
if (typeof window.addEventListener !== 'function' || typeof document.querySelectorAll !== 'function' || typeof document.addEventListener !== 'function') return;
|
|
110
|
+
function missing() {
|
|
111
|
+
return [].filter.call(document.querySelectorAll('link[rel="stylesheet"]'), function (l) {
|
|
112
|
+
// A sheet that failed to load throws on read in WebKit (SecurityError); count it as missing.
|
|
113
|
+
try { return !l.sheet || !l.sheet.cssRules || l.sheet.cssRules.length === 0; } catch (_e) { return true; }
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function bust(href) { return href + (href.indexOf('?') >= 0 ? '&' : '?') + 'r=' + Date.now(); }
|
|
117
|
+
function record(how) {
|
|
118
|
+
R.last = { how: how, sheets: R.sheets, scripts: R.scripts.length, tries: R.tries, ms: R.t0 ? Date.now() - R.t0 : 0 };
|
|
119
|
+
try { window.dispatchEvent(new CustomEvent('cos-restyle', { detail: R.last })); } catch (_e) {}
|
|
120
|
+
}
|
|
121
|
+
// What would be lost by a reload: words in a text box he can see, focus in one, or a send on its way (app.js).
|
|
122
|
+
function busy() {
|
|
123
|
+
var out = { words: false, focus: false, sending: false };
|
|
124
|
+
try {
|
|
125
|
+
[].forEach.call(document.querySelectorAll('textarea, input, [contenteditable]'), function (el) {
|
|
126
|
+
if (el.getAttribute && el.getAttribute('aria-hidden') === 'true') return; // app.js's hidden measuring copy
|
|
127
|
+
if (el.tagName === 'INPUT' && !/^(|text|search|email|url|tel|password|number)$/i.test(el.getAttribute('type') || '')) return;
|
|
128
|
+
var v = el.isContentEditable ? el.textContent : el.value;
|
|
129
|
+
if (v && String(v).trim()) out.words = true;
|
|
130
|
+
});
|
|
131
|
+
var a = document.activeElement;
|
|
132
|
+
out.focus = !!(a && (a.tagName === 'TEXTAREA' || (a.tagName === 'INPUT' && /^(|text|search|email|url|tel|password|number)$/i.test(a.getAttribute('type') || '')) || a.isContentEditable));
|
|
133
|
+
} catch (_e) {}
|
|
134
|
+
try { out.sending = typeof window.CosBusy === 'function' && !!window.CosBusy(); } catch (_e) {}
|
|
135
|
+
out.any = out.words || out.focus || out.sending;
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
// The one line, in place of a reload. Inline styles with the theme's own colours where they exist, so it reads right
|
|
139
|
+
// on a page whose stylesheet failed too. At the top: a fixed bar near the bottom fights the phone browser's own bar.
|
|
140
|
+
// On the folded Duo it stops at the side rail (--rail-w is set only while that rail shows).
|
|
141
|
+
function offer(b) {
|
|
142
|
+
try { offerLine(b); } catch (_e) { /* no line, and still no reload by itself */ }
|
|
143
|
+
}
|
|
144
|
+
function offerLine(b) {
|
|
145
|
+
var line = document.getElementById('cos-reload-line');
|
|
146
|
+
if (!line) {
|
|
147
|
+
line = document.createElement('div');
|
|
148
|
+
line.id = 'cos-reload-line'; line.setAttribute('role', 'status');
|
|
149
|
+
line.style.cssText = 'position:fixed;left:12px;right:calc(12px + var(--rail-w, 0px));top:calc(env(safe-area-inset-top, 0px) + 8px);z-index:90;display:flex;align-items:center;gap:12px;' +
|
|
150
|
+
'padding:10px 10px 10px 14px;border-radius:12px;border:1px solid var(--panel-border, #a08a66);background:var(--panel, #ffffff);color:var(--ink, #1f1712);' +
|
|
151
|
+
'font:400 15px/1.4 -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;max-width:560px;margin:0 auto;';
|
|
152
|
+
var p = document.createElement('p'); p.style.cssText = 'margin:0;flex:1 1 auto;min-width:0;'; p.id = 'cos-reload-words';
|
|
153
|
+
var btn = document.createElement('button'); btn.type = 'button'; btn.textContent = 'Reload';
|
|
154
|
+
btn.style.cssText = 'flex:none;min-height:44px;padding:0 16px;border-radius:10px;border:0;background:var(--accent, #89662d);color:var(--on-accent, #ffffff);font-family:inherit;font-size:15px;font-weight:600;line-height:1;';
|
|
155
|
+
btn.addEventListener('click', function () { reloadNow(); });
|
|
156
|
+
line.appendChild(p); line.appendChild(btn);
|
|
157
|
+
(document.body || document.documentElement).appendChild(line);
|
|
158
|
+
}
|
|
159
|
+
var words = document.getElementById('cos-reload-words');
|
|
160
|
+
if (words) words.textContent = 'Part of this page did not load.' + (b && b.words ? ' Send or copy what you have typed first. A reload clears it.' : '');
|
|
161
|
+
}
|
|
162
|
+
function reloadNow() {
|
|
163
|
+
try { sessionStorage.setItem(KEY, JSON.stringify(R.last)); sessionStorage.setItem('cos_web_bust', String(Date.now())); } catch (_e) {}
|
|
164
|
+
location.reload();
|
|
165
|
+
}
|
|
166
|
+
function recover() {
|
|
167
|
+
if (R.scripts.length) {
|
|
168
|
+
// Never by itself over his words (R1): the line, and only his tap reloads.
|
|
169
|
+
var b = busy();
|
|
170
|
+
if (b.any) { R.done = true; record('offered'); offer(b); return; }
|
|
171
|
+
// At most 2 reloads in 5 minutes: a file that keeps failing must never become a reload loop. Fails closed (R2): a
|
|
172
|
+
// count that cannot be read, or that does not read back after writing, means no reload by itself.
|
|
173
|
+
var recent = null, back = null;
|
|
174
|
+
try { recent = JSON.parse(sessionStorage.getItem(RELOADS_KEY) || '[]').filter(function (t) { return Date.now() - t < RELOAD_WINDOW_MS; }); } catch (_e) { recent = null; }
|
|
175
|
+
if (!recent || recent.length >= RELOAD_MAX) { R.done = true; record('gave-up'); offer(b); return; }
|
|
176
|
+
var now = Date.now();
|
|
177
|
+
try { recent.push(now); sessionStorage.setItem(RELOADS_KEY, JSON.stringify(recent)); back = JSON.parse(sessionStorage.getItem(RELOADS_KEY) || 'null'); } catch (_e) { back = null; }
|
|
178
|
+
if (!back || back.length !== recent.length || back[back.length - 1] !== now) { R.done = true; record('gave-up'); offer(b); return; }
|
|
179
|
+
record('reload');
|
|
180
|
+
reloadNow();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
var gone = missing();
|
|
184
|
+
gone.forEach(function (old) {
|
|
185
|
+
var el = document.createElement('link');
|
|
186
|
+
el.rel = 'stylesheet'; el.href = bust(old.getAttribute('href') || old.href);
|
|
187
|
+
el.onload = function () { if (old.parentNode) old.parentNode.removeChild(old); };
|
|
188
|
+
old.parentNode.insertBefore(el, old.nextSibling);
|
|
189
|
+
});
|
|
190
|
+
R.done = true; record('sheets');
|
|
191
|
+
}
|
|
192
|
+
function schedule() {
|
|
193
|
+
if (R.timer || R.done || document.hidden) return;
|
|
194
|
+
var ms = R.tries < STEPS.length ? STEPS[R.tries] : SETTLED_MS;
|
|
195
|
+
R.timer = setTimeout(check, ms);
|
|
196
|
+
}
|
|
197
|
+
function check() {
|
|
198
|
+
R.timer = null;
|
|
199
|
+
if (R.done || document.hidden) return;
|
|
200
|
+
R.tries++;
|
|
201
|
+
// Ask for the file that failed (a script first): it answering is what matters, and a file that is not there (404)
|
|
202
|
+
// never counts as the Mac being back.
|
|
203
|
+
var gone = missing(), url = R.scripts[0] || (gone[0] && (gone[0].getAttribute('href') || gone[0].href)) || '/control/app.css';
|
|
204
|
+
var probe = window.fetch ? window.fetch(bust(url), { cache: 'no-store', credentials: 'same-origin' }) : Promise.reject(new Error('no fetch'));
|
|
205
|
+
probe.then(function (r) { if (r && r.ok) recover(); else schedule(); }, function () { schedule(); });
|
|
206
|
+
}
|
|
207
|
+
function start() {
|
|
208
|
+
if (R.done || R.t0) return;
|
|
209
|
+
R.sheets = missing().length;
|
|
210
|
+
if (!R.sheets && !R.scripts.length) return;
|
|
211
|
+
R.t0 = Date.now();
|
|
212
|
+
schedule();
|
|
213
|
+
}
|
|
214
|
+
window.CosRestyle = {
|
|
215
|
+
scriptFailed: function (src) { if (R.scripts.indexOf(src) < 0) R.scripts.push(src); if (R.t0) schedule(); else start(); },
|
|
216
|
+
get last() { return R.last; },
|
|
217
|
+
// After a reload that this caused: what the page that reloaded had seen, read once.
|
|
218
|
+
takeSaved: function () { try { var v = sessionStorage.getItem(KEY); sessionStorage.removeItem(KEY); return v ? JSON.parse(v) : null; } catch (_e) { return null; } }
|
|
219
|
+
};
|
|
220
|
+
document.addEventListener('visibilitychange', function () { if (!document.hidden && R.t0 && !R.done && !R.timer) check(); });
|
|
221
|
+
// Every <link> in the head has finished (loaded or failed) by the time the page's load event fires.
|
|
222
|
+
if (document.readyState === 'complete') start(); else window.addEventListener('load', start);
|
|
223
|
+
})();
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
"algorithm": "sha256",
|
|
4
4
|
"files": {
|
|
5
5
|
"/index.html": "3bea8ab14118e48c8bbf5f09de2e8b65ca1225b7e7b564982a84477bafc8341e",
|
|
6
|
-
"/signer-proof.js": "
|
|
6
|
+
"/signer-proof.js": "13b2d3f2709501b3bacff63b5a041282cacdf5bce6be168779c59ba89cf2e0c3",
|
|
7
7
|
"/signer-store.js": "ae123328f922a509eea4d8f9e8440c9ef6972eeb14bf90b9e8bde3c84f7132bc",
|
|
8
8
|
"/signer.css": "e19f5ec70c9dae28347ad40cd08742842e964b3d0b973a5c39f48f9cafdd319b",
|
|
9
|
-
"/signer.js": "
|
|
9
|
+
"/signer.js": "098f484b452c0c2b8ff33c9ca7764fb179e3e5f52c77bebf47eb540e73e7a566"
|
|
10
10
|
}
|
|
11
11
|
}
|
|
@@ -129,13 +129,28 @@
|
|
|
129
129
|
if (!Object.prototype.hasOwnProperty.call(channels, s.action) || channels[s.action].indexOf(s.channel) < 0
|
|
130
130
|
|| !/^[a-z0-9@][a-z0-9@._/-]{0,127}$/.test(s.artifact) || !/^[0-9]{1,5}\.[0-9]{1,5}\.[0-9]{1,6}(-[0-9A-Za-z.-]{1,32})?$/.test(s.version)
|
|
131
131
|
|| !/^(sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128}|sha512-[A-Za-z0-9+/]{86}==)$/.test(s.digest) || !HEX64.test(s.packetSha256)) throw fail('scope_not_allowed')
|
|
132
|
-
var
|
|
133
|
-
if (
|
|
132
|
+
var manifest = manifestArtifact(s.channel)
|
|
133
|
+
if (manifest && (s.artifact !== manifest || !/^sha256:[0-9a-f]{64}$/.test(s.digest))) throw fail('scope_not_allowed')
|
|
134
134
|
return { kind: 'approve', action: s.action, artifact: s.artifact, version: s.version, digest: s.digest, channel: s.channel, packetSha256: s.packetSha256 }
|
|
135
135
|
}
|
|
136
136
|
throw fail('scope_not_allowed')
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// What an approval on each channel does, one fixed line per channel (Miles, 2026-11-12: a publish approval covers
|
|
140
|
+
// its own push). A copy of APPROVE_CHANNEL_EFFECT in shared/web-turn-envelope.ts; signer-proof.test.ts pins the two.
|
|
141
|
+
var CHANNEL_EFFECT = { 'mac-local': 'Installs on this Mac. No git push.', 'npm-next': 'Publishes to npm under the next tag, from the tested package. No git push.', 'npm-latest': 'Publishes to npm under the latest tag, from the tested package. No git push.', 'control-appcast': 'Pushes ukaoma/cos-starter main (control/appcast.json): the COS Control update feed on gotcos.com.', 'hub-alias': 'Pushes ukaoma/evenhub-g2-cos main: moves the Even Hub alias to this package.', 'gotcos-site': 'Pushes ukaoma/cos-starter main: republishes www.gotcos.com.', 'control-downloads': 'Creates a GitHub Release on ukaoma/cos-starter (a tag, no branch push) with the ZIP and its SHA-256 file.', 'release-docs': 'Pushes ukaoma/cos-starter main (docs/): republishes the public COS docs.' }
|
|
142
|
+
// Channels that approve one canonical deployment manifest, and the artifact each must name. A copy of
|
|
143
|
+
// PUBLISH_MANIFEST_ARTIFACTS in shared/web-turn-envelope.ts; signer-proof.test.ts pins the two (Q1, 2026-11-12).
|
|
144
|
+
var MANIFEST_ARTIFACTS = { 'gotcos-site': 'gotcos-site-manifest', 'control-downloads': 'cos-control-downloads-manifest', 'release-docs': 'cos-release-docs-manifest' }
|
|
145
|
+
/** The manifest artifact a channel requires, or '' when the channel is not a manifest channel. */
|
|
146
|
+
function manifestArtifact(channel) {
|
|
147
|
+
return typeof channel === 'string' && Object.prototype.hasOwnProperty.call(MANIFEST_ARTIFACTS, channel) ? MANIFEST_ARTIFACTS[channel] : ''
|
|
148
|
+
}
|
|
149
|
+
/** The fixed line for a channel this page can sign, or '' for anything else. Never built from packet text. */
|
|
150
|
+
function channelEffect(channel) {
|
|
151
|
+
return typeof channel === 'string' && Object.prototype.hasOwnProperty.call(CHANNEL_EFFECT, channel) ? CHANNEL_EFFECT[channel] : ''
|
|
152
|
+
}
|
|
153
|
+
|
|
139
154
|
/** Verify the full packet before display AND again before signing. A packet is still only a request. */
|
|
140
155
|
function validateHandoffPacket(h, nowMs) {
|
|
141
156
|
return Promise.resolve().then(function () {
|
|
@@ -340,7 +355,7 @@
|
|
|
340
355
|
canonicalJson: canonicalJson, jcsBytes: jcsBytes,
|
|
341
356
|
signedWords: signedWords, wordsDigest: wordsDigest,
|
|
342
357
|
buildEnvelope: buildEnvelope, passkeyChallenge: passkeyChallenge, passkeyHeader: passkeyHeader,
|
|
343
|
-
assembleTurnProof: assembleTurnProof, validateHandoffPacket: validateHandoffPacket,
|
|
358
|
+
assembleTurnProof: assembleTurnProof, validateHandoffPacket: validateHandoffPacket, channelEffect: channelEffect, manifestArtifact: manifestArtifact,
|
|
344
359
|
sasCommit: sasCommit, sasCode: sasCode, kidOf: kidOf, fingerprintOf: fingerprintOf, enrollProofMessage: enrollProofMessage,
|
|
345
360
|
spkiFromXY: spkiFromXY, xyFromSpki: xyFromSpki, coseFromXY: coseFromXY, xyFromCose: xyFromCose,
|
|
346
361
|
xyFromAttestation: xyFromAttestation, passkeyXY: passkeyXY,
|
|
@@ -124,8 +124,10 @@
|
|
|
124
124
|
return 'Unknown'
|
|
125
125
|
}
|
|
126
126
|
function deploymentMeaning(scope) {
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
// The fixed line for the channel (P.channelEffect), never text from the packet's requester (2026-11-12).
|
|
128
|
+
var effect = scope && scope.kind === 'approve' ? P.channelEffect(scope.channel) : ''
|
|
129
|
+
if (!effect) return ''
|
|
130
|
+
return '\nWhat this approves: ' + effect + (P.manifestArtifact(scope.channel) ? '\nApproval is limited to this exact manifest SHA-256. The receiving release tool checks its files and destination.' : '')
|
|
129
131
|
}
|
|
130
132
|
function claim(id) {
|
|
131
133
|
show('loading')
|
|
@@ -37,8 +37,15 @@ export const WEB_TURN_MAX_LIFETIME_S = 15 * 60
|
|
|
37
37
|
/** How far in the future `iat` may sit (clock skew between the phone and this Mac), in seconds. */
|
|
38
38
|
export const WEB_TURN_IAT_SKEW_S = 60
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The only actions an `approve` scope can name. Anything else, including every never-list item, is unexpressible.
|
|
42
|
+
* Miles, 2026-11-12 11:11: a phone approval may sign everything the build allows except pushing main, which stays
|
|
43
|
+
* typed at the desk ("Keep allow for all action but the push to main. That way we can resolve when at the desk").
|
|
44
|
+
*/
|
|
45
|
+
export const APPROVE_ACTIONS: ReadonlySet<string> = new Set(['install', 'publish'])
|
|
46
|
+
|
|
47
|
+
/** Actions a phone can never sign. Not expressible, and still named in every `notAuthorized` list. */
|
|
48
|
+
export const APPROVE_ACTIONS_REMOVED: readonly string[] = Object.freeze(['push-main'])
|
|
42
49
|
|
|
43
50
|
/** What a `message` scope means: conversation only (review B4). */
|
|
44
51
|
export const MESSAGE_SCOPE_MEANING = 'conversation'
|
|
@@ -55,10 +62,28 @@ export const MESSAGE_SCOPE_NOT_AUTHORIZED: readonly string[] = Object.freeze([
|
|
|
55
62
|
])
|
|
56
63
|
|
|
57
64
|
/** The channels each approve action may name. Nothing else is signable (review B4). */
|
|
58
|
-
export const APPROVE_ACTION_CHANNELS: Readonly<Record<'install' | 'publish'
|
|
65
|
+
export const APPROVE_ACTION_CHANNELS: Readonly<Record<'install' | 'publish', readonly string[]>> = Object.freeze({
|
|
59
66
|
install: Object.freeze(['mac-local']),
|
|
60
67
|
publish: Object.freeze(['npm-next', 'npm-latest', 'control-appcast', 'hub-alias', 'gotcos-site', 'control-downloads', 'release-docs']),
|
|
61
|
-
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What an approval on each channel does, in one fixed line the phone shows (Miles, 2026-11-12: "Publish includes its
|
|
72
|
+
* own push"). A publish approval covers the push that publish consists of, and only that: cos-starter main for the
|
|
73
|
+
* site, the appcast and the docs; evenhub-g2-cos main for the Hub alias; npm goes out from the tested package with no
|
|
74
|
+
* push. Pushing the code main of any repo stays typed at the desk. The line is derived from the CHANNEL alone, never
|
|
75
|
+
* from text the requester sent. The phone's page keeps a copy (server/web-signer/signer-proof.js, CHANNEL_EFFECT),
|
|
76
|
+
* pinned to this one by signer-proof.test.ts.
|
|
77
|
+
*/
|
|
78
|
+
export const APPROVE_CHANNEL_EFFECT: Readonly<Record<string, string>> = Object.freeze({
|
|
79
|
+
'mac-local': 'Installs on this Mac. No git push.',
|
|
80
|
+
'npm-next': 'Publishes to npm under the next tag, from the tested package. No git push.',
|
|
81
|
+
'npm-latest': 'Publishes to npm under the latest tag, from the tested package. No git push.',
|
|
82
|
+
'control-appcast': 'Pushes ukaoma/cos-starter main (control/appcast.json): the COS Control update feed on gotcos.com.',
|
|
83
|
+
'hub-alias': 'Pushes ukaoma/evenhub-g2-cos main: moves the Even Hub alias to this package.',
|
|
84
|
+
'gotcos-site': 'Pushes ukaoma/cos-starter main: republishes www.gotcos.com.',
|
|
85
|
+
'control-downloads': 'Creates a GitHub Release on ukaoma/cos-starter (a tag, no branch push) with the ZIP and its SHA-256 file.',
|
|
86
|
+
'release-docs': 'Pushes ukaoma/cos-starter main (docs/): republishes the public COS docs.',
|
|
62
87
|
})
|
|
63
88
|
|
|
64
89
|
export const APPROVE_ARTIFACT_RE = /^[a-z0-9@][a-z0-9@._/-]{0,127}$/
|
|
@@ -88,13 +113,13 @@ export function approveFieldsValid(action: unknown, artifact: unknown, version:
|
|
|
88
113
|
/** The verifier's `notAuthorized` list for a scope: the never-list plus what this scope does not cover. */
|
|
89
114
|
export function notAuthorizedFor(scope: WebTurnScope): string[] {
|
|
90
115
|
if (scope.kind === 'message') return [...WEB_KEY_NEVER_AUTHORIZED, ...MESSAGE_SCOPE_NOT_AUTHORIZED]
|
|
91
|
-
const others = [...APPROVE_ACTIONS].filter(a => a !== scope.action)
|
|
116
|
+
const others = [...APPROVE_ACTIONS, ...APPROVE_ACTIONS_REMOVED].filter(a => a !== scope.action)
|
|
92
117
|
return [...WEB_KEY_NEVER_AUTHORIZED, ...others, 'push-branch', 'hub-file', 'other-artifact-version-digest-or-channel']
|
|
93
118
|
}
|
|
94
119
|
|
|
95
120
|
export type WebTurnScope =
|
|
96
121
|
| { kind: 'message' }
|
|
97
|
-
| { kind: 'approve'; action: 'install' | 'publish'
|
|
122
|
+
| { kind: 'approve'; action: 'install' | 'publish'; artifact: string; version: string; digest: string; channel: string; packetSha256: string }
|
|
98
123
|
|
|
99
124
|
export interface WebTurnAttachment { id: string; sha256: string; bytes: number; name: string }
|
|
100
125
|
|
|
@@ -194,7 +219,7 @@ export function parseWebTurnScope(v: unknown): WebTurnScope | null {
|
|
|
194
219
|
// The closed action list, the strict field patterns and the channel table (approveFieldsValid).
|
|
195
220
|
if (!approveFieldsValid(v.action, v.artifact, v.version, v.digest, v.channel)) return null
|
|
196
221
|
if (typeof v.packetSha256 !== 'string' || !HEX64.test(v.packetSha256)) return null
|
|
197
|
-
return { kind: 'approve', action: v.action as 'install' | 'publish'
|
|
222
|
+
return { kind: 'approve', action: v.action as 'install' | 'publish', artifact: v.artifact as string, version: v.version as string, digest: v.digest as string, channel: v.channel as string, packetSha256: v.packetSha256 }
|
|
198
223
|
}
|
|
199
224
|
return null
|
|
200
225
|
}
|
|
@@ -249,7 +274,7 @@ export function parseWebTurnEnvelope(v: unknown): { ok: true; envelope: WebTurnE
|
|
|
249
274
|
export interface WebApprovalPacket {
|
|
250
275
|
v: typeof WEB_APPROVAL_PACKET_VERSION
|
|
251
276
|
packetId: string
|
|
252
|
-
action: 'install' | 'publish'
|
|
277
|
+
action: 'install' | 'publish'
|
|
253
278
|
artifact: string
|
|
254
279
|
version: string
|
|
255
280
|
digest: string
|