@gotcos/glasses-server 6.99.33 → 7.0.18
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/.env.example +37 -0
- package/CHANGELOG.md +587 -0
- package/README.md +3 -1
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +12 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +155 -30
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +27 -14
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +43 -1
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +536 -0
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +31 -5
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +83 -0
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +640 -85
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +29 -1
- package/server/routes/agent-session-stream.ts +23 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +46 -1
- package/server/routes/live-events.ts +334 -0
- package/server/routes/permission-broker.ts +95 -5
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +223 -12
- package/server/routes/today-brief.ts +134 -44
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +28 -4
- package/server/routes/work-board.ts +9 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +76 -1
- package/server/web-control/app.js +2247 -98
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +21 -3
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +94 -9
- package/server/web-control/panes/cos-chat.js +628 -44
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +162 -21
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +97 -6
- package/server/web-control/panes/today-actions.css +46 -0
- package/server/web-control/panes/today-actions.js +119 -17
- package/server/web-control/panes/work.css +13 -0
- package/server/web-control/panes/work.js +188 -37
- package/server/web-control/shell.js +17 -14
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// The signed Control Web turn envelope, `cos-web-turn/1` (6.99.31, plan §4.4). PURE: no I/O, no
|
|
2
|
+
// crypto, no imports, so the phone signer, the server and the tests share one definition, and
|
|
3
|
+
// the Python verifier (MU-Chief-Staff operations/scripts/verify_g2_turn.py) mirrors it with golden
|
|
4
|
+
// fixtures.
|
|
5
|
+
//
|
|
6
|
+
// CANONICAL BYTES. The phone signs the RFC 8785 (JCS) encoding of the envelope: object keys
|
|
7
|
+
// sorted by UTF-16 code units, no whitespace, strings escaped as ES JSON.stringify escapes them,
|
|
8
|
+
// numbers as ES Number-to-string. Every number this protocol uses is a safe integer, so the
|
|
9
|
+
// Python mirror (json.dumps(sort_keys=True, separators=(',', ':'), ensure_ascii=False)) agrees
|
|
10
|
+
// byte for byte. The server refuses an envelope whose bytes are not already canonical: one
|
|
11
|
+
// message has exactly one signable form.
|
|
12
|
+
//
|
|
13
|
+
// CLOSED SHAPES. Every object here has an exact key set. An unknown key, an unknown `v`, an
|
|
14
|
+
// unknown scope kind or an approve action outside APPROVE_ACTIONS is refused, never ignored. The
|
|
15
|
+
// `approve` grammar has no spelling for the never-list (credentials, money, deleting data, sending
|
|
16
|
+
// messages, security or permission settings, the harness's permission prompts), and its free
|
|
17
|
+
// fields have strict patterns plus an action-to-channel table (review B4).
|
|
18
|
+
//
|
|
19
|
+
// `message` scope is CONVERSATION ONLY (MESSAGE_SCOPE_MEANING). Its words are free text, so a
|
|
20
|
+
// message can SAY "send the email"; that is not authority. Enforcement of what a message may
|
|
21
|
+
// cause is the receiving agent's policy, read from the verifier's `notAuthorized` list, exactly as
|
|
22
|
+
// for G2. The contract is docs/web-key-verifier-contract.md.
|
|
23
|
+
|
|
24
|
+
export const WEB_TURN_ENVELOPE_VERSION = 'cos-web-turn/1'
|
|
25
|
+
export const WEB_ENROLL_PROOF_VERSION = 'cos-web-enroll/2'
|
|
26
|
+
export const WEB_ENROLL_APPROVE_VERSION = 'cos-web-enroll-approve/2'
|
|
27
|
+
export const WEB_APPROVAL_PACKET_VERSION = 'cos-web-packet/1'
|
|
28
|
+
/** Approval packet timestamps are Unix milliseconds; a packet lives at most 30 minutes. */
|
|
29
|
+
export const WEB_APPROVAL_PACKET_MAX_LIFETIME_MS = 30 * 60_000
|
|
30
|
+
/** Canonical base64url encoding of the server's 12 random packet-id bytes. */
|
|
31
|
+
export const WEB_APPROVAL_PACKET_ID_RE = /^[A-Za-z0-9_-]{16}$/
|
|
32
|
+
export const WEB_PASSKEY_ASSERTION_VERSION = 'cos-web-passkey/1'
|
|
33
|
+
export const WEB_ANCHOR_STATEMENT_VERSION = 'cos-web-anchor/1'
|
|
34
|
+
|
|
35
|
+
/** The longest an envelope may live (exp - iat), in seconds. */
|
|
36
|
+
export const WEB_TURN_MAX_LIFETIME_S = 15 * 60
|
|
37
|
+
/** How far in the future `iat` may sit (clock skew between the phone and this Mac), in seconds. */
|
|
38
|
+
export const WEB_TURN_IAT_SKEW_S = 60
|
|
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'])
|
|
42
|
+
|
|
43
|
+
/** What a `message` scope means: conversation only (review B4). */
|
|
44
|
+
export const MESSAGE_SCOPE_MEANING = 'conversation'
|
|
45
|
+
|
|
46
|
+
/** Never authorized by a web turn, whatever its scope or words (CLAUDE.md G2 Authority never-list). */
|
|
47
|
+
export const WEB_KEY_NEVER_AUTHORIZED: readonly string[] = Object.freeze([
|
|
48
|
+
'credentials', 'payment-details', 'moving-money', 'deleting-data', 'sending-messages',
|
|
49
|
+
'security-or-permission-settings', 'harness-permission-prompts', 'authority-rule-changes',
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
/** What `message` scope never authorizes on top of the never-list: it is conversation only. */
|
|
53
|
+
export const MESSAGE_SCOPE_NOT_AUTHORIZED: readonly string[] = Object.freeze([
|
|
54
|
+
'install', 'publish', 'push-main', 'push-branch', 'hub-file',
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
/** 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({
|
|
59
|
+
install: Object.freeze(['mac-local']),
|
|
60
|
+
publish: Object.freeze(['npm-next', 'npm-latest', 'control-appcast', 'hub-alias', 'gotcos-site', 'control-downloads', 'release-docs']),
|
|
61
|
+
'push-main': Object.freeze(['git-main']),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
export const APPROVE_ARTIFACT_RE = /^[a-z0-9@][a-z0-9@._/-]{0,127}$/
|
|
65
|
+
export const APPROVE_VERSION_RE = /^[0-9]{1,5}\.[0-9]{1,5}\.[0-9]{1,6}(-[0-9A-Za-z.-]{1,32})?$/
|
|
66
|
+
/** sha256:<hex64>, sha512:<hex128>, or an npm SRI sha512-<base64>. Nothing else. */
|
|
67
|
+
export const APPROVE_DIGEST_RE = /^(sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128}|sha512-[A-Za-z0-9+/]{86}==)$/
|
|
68
|
+
/** These channels approve one reviewed canonical deployment manifest, never arbitrary URLs. */
|
|
69
|
+
export const PUBLISH_MANIFEST_ARTIFACTS: Readonly<Record<string, string>> = Object.freeze({
|
|
70
|
+
'gotcos-site': 'gotcos-site-manifest',
|
|
71
|
+
'control-downloads': 'cos-control-downloads-manifest',
|
|
72
|
+
'release-docs': 'cos-release-docs-manifest',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** Whether the four free approve fields match their patterns and the channel belongs to the action. */
|
|
76
|
+
export function approveFieldsValid(action: unknown, artifact: unknown, version: unknown, digest: unknown, channel: unknown): boolean {
|
|
77
|
+
if (typeof action !== 'string' || !APPROVE_ACTIONS.has(action)) return false
|
|
78
|
+
if (typeof artifact !== 'string' || !APPROVE_ARTIFACT_RE.test(artifact)) return false
|
|
79
|
+
if (typeof version !== 'string' || !APPROVE_VERSION_RE.test(version)) return false
|
|
80
|
+
if (typeof digest !== 'string' || !APPROVE_DIGEST_RE.test(digest)) return false
|
|
81
|
+
const channels = APPROVE_ACTION_CHANNELS[action as keyof typeof APPROVE_ACTION_CHANNELS]
|
|
82
|
+
if (typeof channel !== 'string' || !channels.includes(channel)) return false
|
|
83
|
+
if (Object.prototype.hasOwnProperty.call(PUBLISH_MANIFEST_ARTIFACTS, channel)
|
|
84
|
+
&& (artifact !== PUBLISH_MANIFEST_ARTIFACTS[channel] || !/^sha256:[0-9a-f]{64}$/.test(digest))) return false
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The verifier's `notAuthorized` list for a scope: the never-list plus what this scope does not cover. */
|
|
89
|
+
export function notAuthorizedFor(scope: WebTurnScope): string[] {
|
|
90
|
+
if (scope.kind === 'message') return [...WEB_KEY_NEVER_AUTHORIZED, ...MESSAGE_SCOPE_NOT_AUTHORIZED]
|
|
91
|
+
const others = [...APPROVE_ACTIONS].filter(a => a !== scope.action)
|
|
92
|
+
return [...WEB_KEY_NEVER_AUTHORIZED, ...others, 'push-branch', 'hub-file', 'other-artifact-version-digest-or-channel']
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type WebTurnScope =
|
|
96
|
+
| { kind: 'message' }
|
|
97
|
+
| { kind: 'approve'; action: 'install' | 'publish' | 'push-main'; artifact: string; version: string; digest: string; channel: string; packetSha256: string }
|
|
98
|
+
|
|
99
|
+
export interface WebTurnAttachment { id: string; sha256: string; bytes: number; name: string }
|
|
100
|
+
|
|
101
|
+
export interface WebTurnEnvelope {
|
|
102
|
+
v: typeof WEB_TURN_ENVELOPE_VERSION
|
|
103
|
+
aud: string
|
|
104
|
+
origin: string
|
|
105
|
+
kid: string
|
|
106
|
+
kver: number
|
|
107
|
+
epoch: number
|
|
108
|
+
turnId: string
|
|
109
|
+
nonce: string
|
|
110
|
+
iat: number
|
|
111
|
+
exp: number
|
|
112
|
+
provider: string
|
|
113
|
+
sessionId: string
|
|
114
|
+
bindingId: string | null
|
|
115
|
+
fork: boolean
|
|
116
|
+
wordsSha256: string
|
|
117
|
+
wordsLen: number
|
|
118
|
+
attachments: WebTurnAttachment[]
|
|
119
|
+
scope: WebTurnScope
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class CanonicalJsonError extends Error {}
|
|
123
|
+
|
|
124
|
+
function hasLoneSurrogate(s: string): boolean {
|
|
125
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
126
|
+
const c = s.charCodeAt(i)
|
|
127
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
128
|
+
const n = s.charCodeAt(i + 1)
|
|
129
|
+
if (!(n >= 0xdc00 && n <= 0xdfff)) return true
|
|
130
|
+
i += 1
|
|
131
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) return true
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* RFC 8785 JSON Canonicalization Scheme for the values this protocol carries: plain objects,
|
|
138
|
+
* arrays, strings, safe integers, booleans and null. Anything else throws (a float, NaN, a lone
|
|
139
|
+
* surrogate, undefined, a class instance): the protocol never signs it, so it never encodes it.
|
|
140
|
+
*/
|
|
141
|
+
export function canonicalJson(value: unknown): string {
|
|
142
|
+
if (value === null) return 'null'
|
|
143
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
144
|
+
if (typeof value === 'number') {
|
|
145
|
+
if (!Number.isSafeInteger(value)) throw new CanonicalJsonError('only safe integers are signable')
|
|
146
|
+
return JSON.stringify(value)
|
|
147
|
+
}
|
|
148
|
+
if (typeof value === 'string') {
|
|
149
|
+
if (hasLoneSurrogate(value)) throw new CanonicalJsonError('lone surrogate')
|
|
150
|
+
return JSON.stringify(value)
|
|
151
|
+
}
|
|
152
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
153
|
+
if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
154
|
+
const obj = value as Record<string, unknown>
|
|
155
|
+
const keys = Object.keys(obj).sort()
|
|
156
|
+
return `{${keys.map((k) => {
|
|
157
|
+
if (hasLoneSurrogate(k)) throw new CanonicalJsonError('lone surrogate in key')
|
|
158
|
+
return `${JSON.stringify(k)}:${canonicalJson(obj[k])}`
|
|
159
|
+
}).join(',')}}`
|
|
160
|
+
}
|
|
161
|
+
throw new CanonicalJsonError(`not signable: ${typeof value}`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The exact words as signed: CRLF becomes LF, and nothing else changes (no trim, no collapse). */
|
|
165
|
+
export function signedWordsText(words: string): string {
|
|
166
|
+
return words.replace(/\r\n/g, '\n')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const HEX64 = /^[0-9a-f]{64}$/
|
|
170
|
+
const B64URL = /^[A-Za-z0-9_-]+$/
|
|
171
|
+
const TURN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
|
|
172
|
+
const PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor'])
|
|
173
|
+
const SHORT = (s: unknown, max: number): s is string => typeof s === 'string' && s.length > 0 && s.length <= max
|
|
174
|
+
|
|
175
|
+
function exactKeys(obj: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
176
|
+
const have = Object.keys(obj)
|
|
177
|
+
return have.length === keys.length && keys.every((k) => Object.prototype.hasOwnProperty.call(obj, k))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
181
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const nonNegInt = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0
|
|
185
|
+
|
|
186
|
+
const APPROVE_SCOPE_KEYS = ['kind', 'action', 'artifact', 'version', 'digest', 'channel', 'packetSha256'] as const
|
|
187
|
+
|
|
188
|
+
/** The closed scope list. Null for anything outside it. */
|
|
189
|
+
export function parseWebTurnScope(v: unknown): WebTurnScope | null {
|
|
190
|
+
if (!isPlainObject(v)) return null
|
|
191
|
+
if (v.kind === 'message') return exactKeys(v, ['kind']) ? { kind: 'message' } : null
|
|
192
|
+
if (v.kind === 'approve') {
|
|
193
|
+
if (!exactKeys(v, APPROVE_SCOPE_KEYS)) return null
|
|
194
|
+
// The closed action list, the strict field patterns and the channel table (approveFieldsValid).
|
|
195
|
+
if (!approveFieldsValid(v.action, v.artifact, v.version, v.digest, v.channel)) return null
|
|
196
|
+
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 }
|
|
198
|
+
}
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const ENVELOPE_KEYS = ['v', 'aud', 'origin', 'kid', 'kver', 'epoch', 'turnId', 'nonce', 'iat', 'exp', 'provider', 'sessionId', 'bindingId', 'fork', 'wordsSha256', 'wordsLen', 'attachments', 'scope'] as const
|
|
203
|
+
const ATTACHMENT_KEYS = ['id', 'sha256', 'bytes', 'name'] as const
|
|
204
|
+
export const WEB_TURN_MAX_ATTACHMENTS = 16
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The envelope's exact shape, or the first reason it is not one. `scope_not_allowed` is kept
|
|
208
|
+
* apart from `envelope_malformed` so the phone can say which.
|
|
209
|
+
*/
|
|
210
|
+
export function parseWebTurnEnvelope(v: unknown): { ok: true; envelope: WebTurnEnvelope } | { ok: false; reason: 'envelope_malformed' | 'envelope_version_unknown' | 'scope_not_allowed' } {
|
|
211
|
+
if (!isPlainObject(v)) return { ok: false, reason: 'envelope_malformed' }
|
|
212
|
+
if (v.v !== WEB_TURN_ENVELOPE_VERSION) return { ok: false, reason: 'envelope_version_unknown' }
|
|
213
|
+
if (!exactKeys(v, ENVELOPE_KEYS)) return { ok: false, reason: 'envelope_malformed' }
|
|
214
|
+
if (typeof v.aud !== 'string' || !HEX64.test(v.aud)) return { ok: false, reason: 'envelope_malformed' }
|
|
215
|
+
if (!SHORT(v.origin, 256) || !/^https?:\/\/[^/\s]+$/.test(v.origin)) return { ok: false, reason: 'envelope_malformed' }
|
|
216
|
+
if (typeof v.kid !== 'string' || v.kid.length !== 22 || !B64URL.test(v.kid)) return { ok: false, reason: 'envelope_malformed' }
|
|
217
|
+
if (!nonNegInt(v.kver) || v.kver < 1 || !nonNegInt(v.epoch)) return { ok: false, reason: 'envelope_malformed' }
|
|
218
|
+
if (typeof v.turnId !== 'string' || !TURN_ID.test(v.turnId)) return { ok: false, reason: 'envelope_malformed' }
|
|
219
|
+
if (typeof v.nonce !== 'string' || !/^[A-Za-z0-9_-]{22,64}$/.test(v.nonce)) return { ok: false, reason: 'envelope_malformed' }
|
|
220
|
+
if (!nonNegInt(v.iat) || !nonNegInt(v.exp)) return { ok: false, reason: 'envelope_malformed' }
|
|
221
|
+
if (typeof v.provider !== 'string' || !PROVIDERS.has(v.provider)) return { ok: false, reason: 'envelope_malformed' }
|
|
222
|
+
if (!SHORT(v.sessionId, 128)) return { ok: false, reason: 'envelope_malformed' }
|
|
223
|
+
if (v.bindingId !== null && !SHORT(v.bindingId, 128)) return { ok: false, reason: 'envelope_malformed' }
|
|
224
|
+
if (typeof v.fork !== 'boolean') return { ok: false, reason: 'envelope_malformed' }
|
|
225
|
+
if (typeof v.wordsSha256 !== 'string' || !HEX64.test(v.wordsSha256) || !nonNegInt(v.wordsLen)) return { ok: false, reason: 'envelope_malformed' }
|
|
226
|
+
if (!Array.isArray(v.attachments) || v.attachments.length > WEB_TURN_MAX_ATTACHMENTS) return { ok: false, reason: 'envelope_malformed' }
|
|
227
|
+
const attachments: WebTurnAttachment[] = []
|
|
228
|
+
for (const a of v.attachments) {
|
|
229
|
+
if (!isPlainObject(a) || !exactKeys(a, ATTACHMENT_KEYS)) return { ok: false, reason: 'envelope_malformed' }
|
|
230
|
+
if (!SHORT(a.id, 128) || typeof a.sha256 !== 'string' || !HEX64.test(a.sha256) || !nonNegInt(a.bytes) || typeof a.name !== 'string' || a.name.length > 255) {
|
|
231
|
+
return { ok: false, reason: 'envelope_malformed' }
|
|
232
|
+
}
|
|
233
|
+
attachments.push({ id: a.id, sha256: a.sha256, bytes: a.bytes, name: a.name })
|
|
234
|
+
}
|
|
235
|
+
const scope = parseWebTurnScope(v.scope)
|
|
236
|
+
if (scope === null) return { ok: false, reason: 'scope_not_allowed' }
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
envelope: {
|
|
240
|
+
v: WEB_TURN_ENVELOPE_VERSION, aud: v.aud, origin: v.origin, kid: v.kid, kver: v.kver, epoch: v.epoch,
|
|
241
|
+
turnId: v.turnId, nonce: v.nonce, iat: v.iat, exp: v.exp, provider: v.provider, sessionId: v.sessionId,
|
|
242
|
+
bindingId: v.bindingId as string | null, fork: v.fork, wordsSha256: v.wordsSha256, wordsLen: v.wordsLen,
|
|
243
|
+
attachments, scope,
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** An approval packet a receiving session posts for the phone to sign against (plan §4.4 `approve`). */
|
|
249
|
+
export interface WebApprovalPacket {
|
|
250
|
+
v: typeof WEB_APPROVAL_PACKET_VERSION
|
|
251
|
+
packetId: string
|
|
252
|
+
action: 'install' | 'publish' | 'push-main'
|
|
253
|
+
artifact: string
|
|
254
|
+
version: string
|
|
255
|
+
digest: string
|
|
256
|
+
channel: string
|
|
257
|
+
provider: string
|
|
258
|
+
sessionId: string
|
|
259
|
+
createdAt: number
|
|
260
|
+
exp: number
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The exact packet preimage, shared with the receiving verifier. No URL or wildcard authority. */
|
|
264
|
+
export function parseWebApprovalPacket(value: unknown): WebApprovalPacket | null {
|
|
265
|
+
if (!isPlainObject(value) || !exactKeys(value, ['v', 'packetId', 'action', 'artifact', 'version', 'digest', 'channel', 'provider', 'sessionId', 'createdAt', 'exp'])) return null
|
|
266
|
+
if (value.v !== WEB_APPROVAL_PACKET_VERSION || typeof value.packetId !== 'string' || !WEB_APPROVAL_PACKET_ID_RE.test(value.packetId)) return null
|
|
267
|
+
if (!approveFieldsValid(value.action, value.artifact, value.version, value.digest, value.channel)) return null
|
|
268
|
+
if (typeof value.provider !== 'string' || !PROVIDERS.has(value.provider) || !SHORT(value.sessionId, 128) || /[\u0000-\u001f\u007f]/.test(value.sessionId)) return null
|
|
269
|
+
if (!nonNegInt(value.createdAt) || !nonNegInt(value.exp) || value.exp <= value.createdAt || value.exp - value.createdAt > WEB_APPROVAL_PACKET_MAX_LIFETIME_MS) return null
|
|
270
|
+
return { ...value } as unknown as WebApprovalPacket
|
|
271
|
+
}
|