@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.
@@ -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 = el.onerror = function () { if (--left === 0) settle(); };
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": "a8f8f10608fa1ec0e8da0280fdb27c16c78c011e6ff935477593e25a8e6a26e0",
6
+ "/signer-proof.js": "13b2d3f2709501b3bacff63b5a041282cacdf5bce6be168779c59ba89cf2e0c3",
7
7
  "/signer-store.js": "ae123328f922a509eea4d8f9e8440c9ef6972eeb14bf90b9e8bde3c84f7132bc",
8
8
  "/signer.css": "e19f5ec70c9dae28347ad40cd08742842e964b3d0b973a5c39f48f9cafdd319b",
9
- "/signer.js": "c92dae47694a42aec0995a8cb0e70ba54979183711d840b2935528a197b11735"
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 manifests = { 'gotcos-site': 'gotcos-site-manifest', 'control-downloads': 'cos-control-downloads-manifest', 'release-docs': 'cos-release-docs-manifest' }
133
- if (Object.prototype.hasOwnProperty.call(manifests, s.channel) && (s.artifact !== manifests[s.channel] || !/^sha256:[0-9a-f]{64}$/.test(s.digest))) throw fail('scope_not_allowed')
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
- var targets = { 'gotcos-site': 'gotcos.com website (cos-starter Pages)', 'control-downloads': 'COS Control GitHub release downloads and approved latest aliases', 'release-docs': 'Public COS release documentation' }
128
- return scope && Object.prototype.hasOwnProperty.call(targets, scope.channel) ? '\nDeployment target: ' + targets[scope.channel] + '\nApproval is limited to this exact manifest SHA-256. The receiving release tool checks its files and destination.' : ''
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
- /** The only actions an `approve` scope can name. Anything else, including every never-list item, is unexpressible. */
41
- export const APPROVE_ACTIONS: ReadonlySet<string> = new Set(['install', 'publish', 'push-main'])
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' | 'push-main', readonly string[]>> = Object.freeze({
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
- 'push-main': Object.freeze(['git-main']),
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' | 'push-main'; artifact: string; version: string; digest: string; channel: string; packetSha256: string }
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' | 'push-main', artifact: v.artifact as string, version: v.version as string, digest: v.digest as string, channel: v.channel as string, packetSha256: v.packetSha256 }
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' | 'push-main'
277
+ action: 'install' | 'publish'
253
278
  artifact: string
254
279
  version: string
255
280
  digest: string