@gotcos/glasses-server 6.42.0 → 6.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -1
- package/README.md +23 -0
- package/SECURITY.md +32 -0
- package/package.json +4 -2
- package/server/index.ts +14 -3
- package/server/lib/api-auth.ts +5 -2
- package/server/lib/display-bus.ts +7 -2
- package/server/lib/display-ticket.ts +28 -8
- package/server/lib/morning-brief-config.ts +544 -0
- package/server/lib/morning-brief-prompt.ts +263 -0
- package/server/lib/morning-brief-runtime.ts +58 -0
- package/server/lib/morning-brief-schedule.ts +158 -0
- package/server/lib/morning-brief-scheduler.ts +361 -0
- package/server/routes/display.ts +43 -11
- package/server/routes/health.ts +32 -3
- package/server/routes/morning-brief.ts +80 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,119 @@
|
|
|
1
|
+
## 6.43.0
|
|
2
|
+
|
|
3
|
+
The brief is waiting before you ask for it.
|
|
4
|
+
|
|
5
|
+
A start-of-day brief now runs on a schedule inside the server and lands in
|
|
6
|
+
the inbox as an ordinary numbered reply — no prompt, no "run the good-morning
|
|
7
|
+
skill", no phone awake at 07:00. Jun Kiat Lee's first-week note said it
|
|
8
|
+
plainly: he deployed COS to his G2 expecting a push at the start of the day and
|
|
9
|
+
nothing came, because until now the only thing that could start a brief was a
|
|
10
|
+
person typing. The server is the one process awake at that hour, so it owns
|
|
11
|
+
the schedule.
|
|
12
|
+
|
|
13
|
+
What you get, by default: weekdays at 07:00 in the Mac's own timezone, four
|
|
14
|
+
sections any COS brain can fill — Calendar, From recent meetings (decisions,
|
|
15
|
+
deadlines, owed items), Due (tasks inside a seven-day horizon), and Waiting on
|
|
16
|
+
you (unanswered mentions and asks across whatever channels the brain can
|
|
17
|
+
read). Everything else in the catalog — Knowledge graph, Reflection, Health,
|
|
18
|
+
an Opening reading, a Metrics pulse, a workspace skill, a custom section — is
|
|
19
|
+
off until you turn it on. The brief is composed from the sources you choose,
|
|
20
|
+
in the order you choose, with the windows you choose, so it is yours rather
|
|
21
|
+
than a template with your name on it.
|
|
22
|
+
|
|
23
|
+
How it works, so the cost is legible:
|
|
24
|
+
|
|
25
|
+
- One durable query job per local calendar day, submitted to the same
|
|
26
|
+
coordinator every phone prompt uses. It survives the phone being asleep and
|
|
27
|
+
is projected into the conversation store with a reserved message number when
|
|
28
|
+
it completes, so the companion's history hydration surfaces it like any
|
|
29
|
+
reply.
|
|
30
|
+
- The fire is remembered in a ledger written BEFORE admission. A crash between
|
|
31
|
+
that write and the 202 is resumed by a per-day client identity, never re-run.
|
|
32
|
+
A failed admission (store degraded, server draining) retries at most three
|
|
33
|
+
times, two minutes apart, then gives the day up.
|
|
34
|
+
- A Mac asleep through the slot still fires inside a catch-up window (three
|
|
35
|
+
hours by default, up to twelve). Past that, the day is skipped rather than
|
|
36
|
+
delivered at lunch.
|
|
37
|
+
- "Run now" is capped at five a day and refused while a brief is live.
|
|
38
|
+
- The whole thing is inert when COS Control's "Background jobs" switch is off
|
|
39
|
+
or maintenance admissions are closed. The prompt is read-only by contract:
|
|
40
|
+
it forbids sending, creating, or editing anything.
|
|
41
|
+
|
|
42
|
+
The prompt itself carries the evidence discipline the good-morning routine
|
|
43
|
+
learned the hard way: a source that cannot be read produces one honest
|
|
44
|
+
"unavailable" line; nothing is invented; only items with a hard edge (a
|
|
45
|
+
decision, a date, a dollar figure, an owner) make the cut; the scribe's
|
|
46
|
+
extracted action-item list is never pasted verbatim. Enable the "Workspace
|
|
47
|
+
skill" source with `/good-morning` and that skill's output IS the brief —
|
|
48
|
+
which is how Miles's own routine rides this without being rewritten.
|
|
49
|
+
|
|
50
|
+
Surface:
|
|
51
|
+
|
|
52
|
+
- `GET /api/morning-brief` — config, the source catalog (labels, descriptions,
|
|
53
|
+
option schemas a settings screen can render), status with `nextRunAt`, and
|
|
54
|
+
recent runs with live job status and the message number each produced.
|
|
55
|
+
- `PUT /api/morning-brief` — patch any field; a bad time, zone, model, or
|
|
56
|
+
skill name is a 400 with a named code, never a silent keep.
|
|
57
|
+
- `POST /api/morning-brief/run` — fire one now (202; 409 while one runs; 429
|
|
58
|
+
past the daily cap).
|
|
59
|
+
- `GET /api/morning-brief/preview` — the exact prompt today's brief would send.
|
|
60
|
+
- `/api/health` advertises `features.morningBrief` and
|
|
61
|
+
`capabilities.morningBrief` (`enabled`, `time`, `timezone`, `nextRunAt`,
|
|
62
|
+
`lastRunAt`, `lastRunStatus`, `gate`) — times and gate only, no prompt, no
|
|
63
|
+
ids.
|
|
64
|
+
|
|
65
|
+
Config lives at `~/.cos-glasses/data/morning-brief/config.json` (0600), the
|
|
66
|
+
ledger beside it. `COS_MORNING_BRIEF_DIR` relocates both. Schedule arithmetic
|
|
67
|
+
uses Node's Intl in the configured IANA zone, and is covered across both US DST
|
|
68
|
+
transitions and a zone east of UTC.
|
|
69
|
+
|
|
70
|
+
## 6.42.1
|
|
71
|
+
|
|
72
|
+
Hardening of the 6.42.0 display-stream ticket, from a four-validator QA pass
|
|
73
|
+
that tested the FIX rather than the original finding. Nothing here changes the
|
|
74
|
+
wire contract; every 6.42.0 client keeps working unchanged.
|
|
75
|
+
|
|
76
|
+
- **A refused ticket now says why.** `explainDisplayTicket` returns
|
|
77
|
+
`expired | bad-signature | malformed`, and the throttled summary line counts
|
|
78
|
+
each. Before this the server could not tell "a client needs to re-mint"
|
|
79
|
+
(expired — expected after every native EventSource retry on a stale URL) from
|
|
80
|
+
"someone holds a ticket this token never signed". The author's own log had
|
|
81
|
+
seven unexplained rejections in three windows.
|
|
82
|
+
- **The allowlist uses `Object.hasOwn`.** The projection map is an object
|
|
83
|
+
literal and inherited `Object.prototype`; a type of `constructor` would have
|
|
84
|
+
resolved to a truthy identity and passed the event through whole. Unreachable
|
|
85
|
+
via the typed union; the allowlist must not depend on that.
|
|
86
|
+
- **Ticketless connects no longer materialise the replay buffer.** Gap detection
|
|
87
|
+
still runs; the up-to-200-event filter does not. A stale install retrying
|
|
88
|
+
every 3s was doing that filter and discarding it each time.
|
|
89
|
+
- **`?probe=1` skips the replay write.** The client's connection probe is
|
|
90
|
+
authorized (it sends the token) and was handed the whole buffer on every
|
|
91
|
+
reconnect, then aborted the socket — 1,164 "Replayed 200" lines in one day.
|
|
92
|
+
A 6.42.0 server ignores the flag (verified live), so a new client against an
|
|
93
|
+
old server loses nothing.
|
|
94
|
+
- **Comments corrected.** `api-auth.ts` no longer claims a path segment avoids
|
|
95
|
+
URL logs (it does not; the TTL is what bounds a leaked URL). `index.ts` names
|
|
96
|
+
both capability URLs. `health.ts` states plainly that authorization is decided
|
|
97
|
+
once per socket and that a per-event re-check would be a breaking change.
|
|
98
|
+
- **The 6.42.0 note "6.8.441 restores full content delivery" was too broad.**
|
|
99
|
+
It is true for the phone companion. The lens entrypoint was untouched in
|
|
100
|
+
6.8.441 and connects bare; app 6.9.442 ports the ticket to it.
|
|
101
|
+
- Tests: the adversarial event list is now derived from a `Record` keyed on the
|
|
102
|
+
union, so a twelfth event type is a compile error rather than a silent gap; a
|
|
103
|
+
projection that throws is proven not to reach the emitter; the probe is proven
|
|
104
|
+
to receive no replay; every verdict is exercised.
|
|
105
|
+
- **The probe no longer BUILDS the replay buffer either.** The first cut of this
|
|
106
|
+
release skipped only the write for `?probe=1`; `materialize` was still keyed
|
|
107
|
+
on authorization, so an authorized probe filtered up to 200 events and dropped
|
|
108
|
+
them. One term, matched to the write guard, closes it.
|
|
109
|
+
- **Two guards gained the tests that prove them.** A mutation run during
|
|
110
|
+
`/validate-plan` (2026-09-01) found the `Object.hasOwn` allowlist guard and
|
|
111
|
+
the per-reason counter both survived mutation with the suite green.
|
|
112
|
+
`display-ticketless.test.ts` now emits prototype-keyed event types
|
|
113
|
+
(`constructor`, `toString`, …) and asserts exact per-reason counts.
|
|
114
|
+
- `SECURITY.md` and a `bugs` field. Two security releases in a row, and there
|
|
115
|
+
was nowhere to report the next one.
|
|
116
|
+
|
|
1
117
|
## 6.42.0
|
|
2
118
|
|
|
3
119
|
The display stream stops broadcasting your meetings to the local network.
|
|
@@ -10,7 +126,7 @@ the whole bus. A subscriber that does not gets a live transport, the handshake,
|
|
|
10
126
|
the keepalive, replay-gap notices, and one projected lifecycle marker. It never
|
|
11
127
|
receives a transcript, an answer, a coaching cue, a tool status or an error.
|
|
12
128
|
|
|
13
|
-
**COS Glasses app 6.8.441
|
|
129
|
+
**COS Glasses app 6.8.441 restores full content delivery to the PHONE COMPANION (`index.html`).** It does not touch the lens entrypoint (`glasses.html` / `glasses-entry.ts`), which still connects bare and, wherever it can reach the server at all, now receives lifecycle events only. Whether that entrypoint reaches the server is unproven either way; 6.9.442 ports the ticket to it regardless. Older
|
|
14
130
|
builds keep working — that is the entire reason the connection is not rejected —
|
|
15
131
|
but they connect without a capability, so they will see the content-suppressed
|
|
16
132
|
stream: no live transcript on the lens and no streamed answers, while meeting
|
package/README.md
CHANGED
|
@@ -208,6 +208,29 @@ Telegram activity export is disabled by default even when a private COS
|
|
|
208
208
|
pipeline contains `.telegram_config.json`; enable it only with the explicit
|
|
209
209
|
`COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
|
|
210
210
|
|
|
211
|
+
## Morning brief (6.43.0)
|
|
212
|
+
|
|
213
|
+
A start-of-day brief runs on a schedule inside the server and waits in the
|
|
214
|
+
inbox as a numbered reply. Default: weekdays at 07:00 in the Mac's timezone,
|
|
215
|
+
with Calendar, recent-meeting decisions, tasks due this week, and what is
|
|
216
|
+
waiting on you. Turn on more sources (knowledge graph, reflection, health, an
|
|
217
|
+
opening reading, a metrics pulse, one of your own skills such as
|
|
218
|
+
`/good-morning`, a custom section), reorder them, and set their windows from
|
|
219
|
+
COS Control or the companion, or directly:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
curl -H "X-COS-Token: $COS_TOKEN" http://127.0.0.1:3141/api/morning-brief
|
|
223
|
+
curl -H "X-COS-Token: $COS_TOKEN" -X PUT -H 'Content-Type: application/json' \
|
|
224
|
+
-d '{"time":"06:30","sources":[{"id":"skill","enabled":true,"options":{"name":"/good-morning"}}]}' \
|
|
225
|
+
http://127.0.0.1:3141/api/morning-brief
|
|
226
|
+
curl -H "X-COS-Token: $COS_TOKEN" -X POST http://127.0.0.1:3141/api/morning-brief/run
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
One provider run per local day, remembered in a ledger so a restart never
|
|
230
|
+
doubles it; a Mac asleep at the slot still fires inside a three-hour catch-up
|
|
231
|
+
window; "Run now" is capped at five a day. Off entirely when Background jobs
|
|
232
|
+
are off. The brief is read-only by contract.
|
|
233
|
+
|
|
211
234
|
## Speaker diarization (opt-in)
|
|
212
235
|
|
|
213
236
|
Without a voiceprint model this server does not classify speakers at all — it
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
COS Glasses server runs on your own machine and holds your meeting transcripts,
|
|
4
|
+
so a flaw here is a flaw in your privacy, not ours. Reports are welcome and
|
|
5
|
+
taken seriously.
|
|
6
|
+
|
|
7
|
+
## Reporting
|
|
8
|
+
|
|
9
|
+
Open an issue at <https://github.com/ukaoma/cos-glasses-server/issues> titled
|
|
10
|
+
`security` with **no details in the body**. A maintainer will reply with a
|
|
11
|
+
private channel within two business days. Do not post the finding publicly
|
|
12
|
+
until a fixed version is on npm.
|
|
13
|
+
|
|
14
|
+
Please include the server version (`/api/health` → `server_version`), the
|
|
15
|
+
client (COS Glasses EHPK version or COS Control version), and the smallest
|
|
16
|
+
reproduction you have. Do not include real transcripts.
|
|
17
|
+
|
|
18
|
+
## What to expect
|
|
19
|
+
|
|
20
|
+
- Acknowledgement within two business days.
|
|
21
|
+
- A fix released as a patch version, with the finding described in
|
|
22
|
+
`CHANGELOG.md` once a fixed version is on npm.
|
|
23
|
+
- Credit in the changelog if you want it. TJ's 2026-08 report on the display
|
|
24
|
+
stream (fixed in 6.42.0 and hardened in 6.42.1) is the model.
|
|
25
|
+
|
|
26
|
+
## Scope notes
|
|
27
|
+
|
|
28
|
+
- The pairing token (`X-Cos-Token`) is the only credential. Rotating it
|
|
29
|
+
invalidates every display-stream ticket; there is no server-side ticket store.
|
|
30
|
+
- `GET /api/display-stream` is public by design and returns lifecycle events
|
|
31
|
+
only. Content requires a ticket or the token header. See
|
|
32
|
+
`server/routes/display.ts` for the allowlist.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.43.0",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
".cos-profile.example.json",
|
|
41
41
|
"README.md",
|
|
42
42
|
"LICENSE",
|
|
43
|
-
"CHANGELOG.md"
|
|
43
|
+
"CHANGELOG.md",
|
|
44
|
+
"SECURITY.md"
|
|
44
45
|
],
|
|
45
46
|
"author": "COS Contributors",
|
|
46
47
|
"license": "MIT",
|
|
@@ -49,6 +50,7 @@
|
|
|
49
50
|
"url": "git+https://github.com/ukaoma/cos-glasses-server.git"
|
|
50
51
|
},
|
|
51
52
|
"homepage": "https://www.gotcos.com",
|
|
53
|
+
"bugs": { "url": "https://github.com/ukaoma/cos-glasses-server/issues" },
|
|
52
54
|
"publishConfig": {
|
|
53
55
|
"access": "public"
|
|
54
56
|
},
|
package/server/index.ts
CHANGED
|
@@ -83,6 +83,8 @@ import { initializeServerInstanceId } from './lib/server-instance-id.js'
|
|
|
83
83
|
import { appendPrivateEnvBlock, UnsafeUserConfigPathError } from './lib/secure-user-config.js'
|
|
84
84
|
import { getTranscriptionProfileStatus } from './lib/profile.js'
|
|
85
85
|
import { createQueryJobsRouter } from './routes/query-jobs.js'
|
|
86
|
+
import { createMorningBriefRouter } from './routes/morning-brief.js'
|
|
87
|
+
import { getMorningBriefScheduler, startMorningBriefScheduler, stopMorningBriefScheduler } from './lib/morning-brief-runtime.js'
|
|
86
88
|
import {
|
|
87
89
|
initQueryJobRuntime,
|
|
88
90
|
preparePublicDurableQueryAdmission,
|
|
@@ -181,9 +183,10 @@ app.use(cors({
|
|
|
181
183
|
}))
|
|
182
184
|
// Auth middleware — always active (token is auto-generated if not set).
|
|
183
185
|
// Mounted before body parsers so rejected uploads cannot consume parse memory.
|
|
184
|
-
//
|
|
185
|
-
// authenticated /tts/prepare
|
|
186
|
-
//
|
|
186
|
+
// Two capability-URL exceptions: a canonical /tts/play/<UUID> GET/HEAD (minted by
|
|
187
|
+
// authenticated /tts/prepare for native audio players) and
|
|
188
|
+
// /display-stream/<exp>.<hmac> GET/HEAD (minted on authenticated /api/models for
|
|
189
|
+
// EventSource, which cannot set X-Cos-Token either). See api-auth.ts.
|
|
187
190
|
app.use('/api', requireApiToken(API_TOKEN))
|
|
188
191
|
|
|
189
192
|
// Fail-closed catch-all for mutation routes that do not own a more specific
|
|
@@ -487,6 +490,9 @@ app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
|
|
|
487
490
|
prepareAdmission: preparePublicDurableQueryAdmission,
|
|
488
491
|
}))
|
|
489
492
|
app.use('/api', queryRouter)
|
|
493
|
+
// The scheduled start-of-day brief: settings, status, run-now. Same auth as
|
|
494
|
+
// every other settings route; the brief itself is an ordinary durable job.
|
|
495
|
+
app.use('/api', createMorningBriefRouter(getMorningBriefScheduler))
|
|
490
496
|
app.use('/api', providerProofRouter)
|
|
491
497
|
app.use('/api', transcribeRouter)
|
|
492
498
|
// Ported from cos-glasses-app in 6.24.0. The companion's Sessions tab has been
|
|
@@ -712,6 +718,7 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
712
718
|
gracefulShutdownStarted = true
|
|
713
719
|
const forceExit = setTimeout(() => process.exit(1), 8_000)
|
|
714
720
|
forceExit.unref?.()
|
|
721
|
+
stopMorningBriefScheduler()
|
|
715
722
|
try {
|
|
716
723
|
await shutdownQueryJobRuntime('server_shutdown')
|
|
717
724
|
} catch (error) {
|
|
@@ -908,6 +915,10 @@ listenRequiredServers(listeners).then(() => {
|
|
|
908
915
|
} else {
|
|
909
916
|
console.log('[COS API] Durable query jobs: disabled by COS_DURABLE_QUERY_JOBS=0')
|
|
910
917
|
}
|
|
918
|
+
// The morning brief rides the durable coordinator, so it starts only once
|
|
919
|
+
// that store is ready. Its own tick checks the durable-jobs switch and the
|
|
920
|
+
// maintenance gate, so starting it here is safe when either is off.
|
|
921
|
+
startMorningBriefScheduler()
|
|
911
922
|
}).catch(error => {
|
|
912
923
|
// The store remains degraded and rejects admission. Legacy /api/query is
|
|
913
924
|
// still mounted, so the kill switch is an immediate rollback.
|
package/server/lib/api-auth.ts
CHANGED
|
@@ -13,12 +13,15 @@ const PUBLIC_API_PATHS = new Set([
|
|
|
13
13
|
// Native HTML audio requests cannot attach X-Cos-Token. The UUID minted by
|
|
14
14
|
// authenticated POST /tts/prepare is therefore a short-lived bearer
|
|
15
15
|
// capability. Keep this exception exact: GET/HEAD only, one canonical v4 UUID
|
|
16
|
-
// path segment, and no query-token fallback
|
|
16
|
+
// path segment, and no query-token fallback. (A path segment reaches access and
|
|
17
|
+
// proxy logs exactly as a query string does — the TTL is what bounds a leaked
|
|
18
|
+
// URL; the point of refusing a query form is that it is trivially added back by
|
|
19
|
+
// accident and routinely forwarded.)
|
|
17
20
|
const TTS_PLAYBACK_CAPABILITY_PATH = /^\/tts\/play\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
18
21
|
|
|
19
22
|
// EventSource has the identical constraint, so the display stream reuses the same
|
|
20
23
|
// shape: `/display-stream/<expUnixSeconds>.<hex hmac>`, GET/HEAD only, path segment,
|
|
21
|
-
// no query-token fallback. Admission here is SHAPE ONLY — the signature is verified
|
|
24
|
+
// no query-token fallback (same reasoning as above — this is not log hygiene). Admission here is SHAPE ONLY — the signature is verified
|
|
22
25
|
// in the route, which is the only place that holds the API token.
|
|
23
26
|
//
|
|
24
27
|
// `/display-stream` itself STAYS PUBLIC, deliberately. Removing it would make a
|
|
@@ -52,7 +52,12 @@ export function getDisplayWatermark(): { bootId: string; eventId: number } {
|
|
|
52
52
|
return { bootId: serverMetrics.bootId, eventId }
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function replayDisplayEvents(
|
|
55
|
+
export function replayDisplayEvents(
|
|
56
|
+
bootId: string | null,
|
|
57
|
+
afterEventId: number,
|
|
58
|
+
opts: { materialize?: boolean } = {},
|
|
59
|
+
): DisplayReplayResult {
|
|
60
|
+
const materialize = opts.materialize !== false
|
|
56
61
|
const oldestEventId = replayBuffer[0]?.eventId ?? eventId + 1
|
|
57
62
|
const latestEventId = eventId
|
|
58
63
|
if (bootId && bootId !== serverMetrics.bootId) {
|
|
@@ -65,7 +70,7 @@ export function replayDisplayEvents(bootId: string | null, afterEventId: number)
|
|
|
65
70
|
return { events: [], gap: true, reason: 'buffer_overflow', oldestEventId, latestEventId }
|
|
66
71
|
}
|
|
67
72
|
return {
|
|
68
|
-
events: replayBuffer.filter(item => item.eventId > afterEventId),
|
|
73
|
+
events: materialize ? replayBuffer.filter(item => item.eventId > afterEventId) : [],
|
|
69
74
|
gap: false,
|
|
70
75
|
oldestEventId,
|
|
71
76
|
latestEventId,
|
|
@@ -79,21 +79,41 @@ export function mintDisplayTicket(
|
|
|
79
79
|
* itself, so a forged expiry changes the signed message and fails the HMAC — the
|
|
80
80
|
* claim cannot be edited without the key.
|
|
81
81
|
*/
|
|
82
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Why a ticket was refused. `verifyDisplayTicket` collapsed every refusal to
|
|
84
|
+
* `false`, so the server's only signal was "N rejected" with no way to tell an
|
|
85
|
+
* EXPIRED ticket (a client that needs to re-mint — expected on every native
|
|
86
|
+
* EventSource retry after a WebView suspension) from a BAD SIGNATURE (a rotated
|
|
87
|
+
* token, or a forgery). QA on 2026-09-01 found >=7 rejections in three
|
|
88
|
+
* consecutive windows on the author's own device and could not say which.
|
|
89
|
+
*/
|
|
90
|
+
export type DisplayTicketVerdict = 'ok' | 'malformed' | 'expired' | 'bad-signature'
|
|
91
|
+
|
|
92
|
+
export function explainDisplayTicket(
|
|
83
93
|
apiToken: string,
|
|
84
94
|
ticket: unknown,
|
|
85
95
|
nowMs: number = Date.now(),
|
|
86
|
-
):
|
|
87
|
-
if (typeof ticket !== 'string' || !apiToken) return
|
|
96
|
+
): DisplayTicketVerdict {
|
|
97
|
+
if (typeof ticket !== 'string' || !apiToken) return 'malformed'
|
|
88
98
|
const separator = ticket.indexOf('.')
|
|
89
|
-
if (separator <= 0) return
|
|
99
|
+
if (separator <= 0) return 'malformed'
|
|
90
100
|
const expRaw = ticket.slice(0, separator)
|
|
91
101
|
const provided = ticket.slice(separator + 1)
|
|
92
|
-
if (!/^\d{1,15}$/.test(expRaw) || !/^[0-9a-f]{64}$/.test(provided)) return
|
|
102
|
+
if (!/^\d{1,15}$/.test(expRaw) || !/^[0-9a-f]{64}$/.test(provided)) return 'malformed'
|
|
93
103
|
const exp = Number(expRaw)
|
|
94
|
-
|
|
104
|
+
// Unreachable while the regex caps exp at 15 digits (MAX_SAFE_INTEGER has 16);
|
|
105
|
+
// kept as the belt for a widened regex. A mutation test proved it dead.
|
|
106
|
+
if (!Number.isSafeInteger(exp)) return 'malformed'
|
|
95
107
|
// Expiry is checked BEFORE the compare so a stale ticket cannot be probed for
|
|
96
108
|
// signature validity, and so the common rejection costs no hashing.
|
|
97
|
-
if (Math.floor(nowMs / 1000) >= exp) return
|
|
98
|
-
return timingSafeTokenEqual(provided, signature(apiToken, exp))
|
|
109
|
+
if (Math.floor(nowMs / 1000) >= exp) return 'expired'
|
|
110
|
+
return timingSafeTokenEqual(provided, signature(apiToken, exp)) ? 'ok' : 'bad-signature'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function verifyDisplayTicket(
|
|
114
|
+
apiToken: string,
|
|
115
|
+
ticket: unknown,
|
|
116
|
+
nowMs: number = Date.now(),
|
|
117
|
+
): boolean {
|
|
118
|
+
return explainDisplayTicket(apiToken, ticket, nowMs) === 'ok'
|
|
99
119
|
}
|