fix(liveshare-protocol): keep the protocol vocabulary off the client and normalize the withdrawn tier

viewer-states publishes only each condition's retry decision: HTTP statuses and error codes belong to the Relay and copy keys to the client, so the mismatched accessDeniedTitle key is gone with the wire discriminator. Both parsers read the withdrawn fork-readonly tier back as view while keeping allowsFork, matching the documented treatment, and the protocol version is pinned in a test. The coverage-exclusion comment now names this package's own deferred lane instead of another package's.
This commit is contained in:
mektpoy committed 2026-10-23 16:13:22 +08:00
1 parent eef30f8462
commit 7139e5cd8d
5 files changed
+60 -53

No files matched your search

@@ -373,9 +373,20 @@ export function parseTunnelWorkerCommand(value: unknown): TunnelWorkerCommand |
return typeof value.shareId === 'string' && value.shareId !== ''
? { type: 'remove-share', shareId: value.shareId }
: undefined
case 'add-share':
return isRegistrationToken(value.registrationToken) && isTunnelShareSpec(value.share)
? { type: 'add-share', registrationToken: value.registrationToken, share: value.share } : undefined
case 'add-share': {
if (!isRegistrationToken(value.registrationToken) || !isTunnelShareSpec(value.share)) return undefined
const share = value.share
return {
type: 'add-share',
registrationToken: value.registrationToken,
share: {
...share,
...(share.sessionMode === undefined
? {}
: { sessionMode: normalizeShareSessionMode(share.sessionMode) }),
},
}
}
case 'share-rpc-response': {
if (!isTunnelRequestIdentity(value) || !Number.isSafeInteger(value.status)
|| Number(value.status) < 100 || Number(value.status) > 599) return undefined
@@ -441,6 +452,15 @@ export function parseTunnelWorkerEvent(value: unknown): TunnelWorkerEvent | unde
* @param value Candidate spec.
* @returns True when every required field is present with the declared type.
*/
/**
* Read the withdrawn `fork-readonly` tier as `view`; the forking half is `allowsFork`.
* @param mode - Tier a wire or IPC value names.
* @returns The current tier name.
*/
function normalizeShareSessionMode(mode: 'view' | 'fork-readonly' | 'write'): 'view' | 'write' {
return mode === 'fork-readonly' ? 'view' : mode
}
function isTunnelShareSpec(value: unknown): value is TunnelShareSpec {
if (!isRecord(value)) return false
const mode = value.sessionMode
@@ -511,7 +531,7 @@ export function parseShareAccessState(value: unknown): ShareAccessStateFrame | u
return undefined
}
return { type: 'share-access-state', version: Number(value.version), accessMode: value.accessMode,
allowedSubjectIds: [...value.allowedSubjectIds], sessionMode: value.sessionMode,
allowedSubjectIds: [...value.allowedSubjectIds], sessionMode: normalizeShareSessionMode(value.sessionMode),
...(value.allowsFork === undefined ? {} : { allowsFork: value.allowsFork }),
...(value.expiresAt === undefined ? {} : { expiresAt: value.expiresAt }) }
}
@@ -31,52 +31,18 @@ export type ViewerState =
export type ViewerStateReach = 'retryable' | 'terminal'
/**
* One answer that carries exactly one condition.
* Whether the page may ask again, or the answer is the peer's verdict, for every condition.
*
* The two carriers are the two places a viewer page learns anything: a refused read, and the copy the
* served page states for a failure that never reached the Relay.
* This is the whole vocabulary the protocol layer publishes: HTTP statuses and error codes belong to
* the Relay, and copy keys belong to the client. A page, a test, and an operator read one name per
* condition from here and get the retry decision with it.
*/
export type ViewerStateAnswer =
| {
readonly wire: 'http'
readonly state: ViewerState
/** HTTP status of the refused read. */
readonly status: number
/** `error` code the JSON body carries. */
readonly code: string
readonly reach: ViewerStateReach
}
| {
readonly wire: 'page'
readonly state: ViewerState
/** Copy the served page states when the failure never reached the Relay. */
readonly copy: string
readonly reach: ViewerStateReach
}
/**
* Every answer a viewer page must tell apart, one entry per condition and carrier.
*
* This list is the whole of it: a page that knows these names can state what happened, and a check
* over this list proves no two conditions share an answer.
*/
export const VIEWER_STATE_ANSWERS: readonly ViewerStateAnswer[] = [
{ wire: 'http', state: 'share-not-found', status: 404, code: 'share-not-found', reach: 'terminal' },
{ wire: 'http', state: 'share-stopped', status: 410, code: 'share-stopped', reach: 'terminal' },
{ wire: 'http', state: 'share-expired', status: 403, code: 'share-expired', reach: 'terminal' },
{ wire: 'http', state: 'owner-offline', status: 503, code: 'owner-offline', reach: 'retryable' },
{ wire: 'http', state: 'sign-in-expired', status: 401, code: 'authentication-required', reach: 'terminal' },
{ wire: 'http', state: 'access-denied', status: 403, code: 'access-denied', reach: 'terminal' },
{ wire: 'page', state: 'relay-unreachable', copy: 'accessDeniedTitle', reach: 'retryable' },
]
/**
* The state one refused read carries.
* @param status - HTTP status the read was refused with.
* @param code - `error` code of the refusing body, when it carried one.
* @returns The state that answer means, or undefined for an answer outside this vocabulary.
*/
export function viewerStateOfRefusal(status: number, code: string | undefined): ViewerState | undefined {
return VIEWER_STATE_ANSWERS.find(answer =>
answer.wire === 'http' && answer.status === status && answer.code === code)?.state
export const VIEWER_STATE_REACH: Readonly<Record<ViewerState, ViewerStateReach>> = {
'share-not-found': 'terminal',
'share-stopped': 'terminal',
'share-expired': 'terminal',
'owner-offline': 'retryable',
'sign-in-expired': 'terminal',
'access-denied': 'terminal',
'relay-unreachable': 'retryable',
}
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { parseTunnelWorkerCommand, parseTunnelWorkerEvent } from '../src/tunnel-protocol.ts'
@@ -62,3 +63,15 @@ describe('Host/worker registration identity', () => {
}
})
})
describe('withdrawn tier at the IPC boundary', () => {
it('reads fork-readonly back as view and keeps the caller switch', () => {
const share = {
shareId: 'share', sessionId: 'session', sessionTitle: 'Source', accessMode: 'selected' as const,
relay: 'https://relay.test', gatewayToken: 'token', ownerDisplayName: 'Owner',
sessionMode: 'fork-readonly' as const, allowsFork: true, allowedSubjectIds: [],
}
const parsed = parseTunnelWorkerCommand({ type: 'add-share', registrationToken: randomUUID(), share })
expect(parsed).toMatchObject({ share: { sessionMode: 'view', allowsFork: true } })
})
})
@@ -4,6 +4,7 @@ import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
LIVESHARE_PROTOCOL_VERSION,
parseGatewayFrame,
parseRelayFrame,
parseTunnelWorkerCommand,
@@ -175,3 +176,9 @@ describe('recorded frames', () => {
}
})
})
describe('wire protocol version', () => {
it('states protocol version 2 for the narrowed access-mode vocabulary', () => {
expect(LIVESHARE_PROTOCOL_VERSION).toBe(2)
})
})
+4 -3
View File
@@ -385,9 +385,10 @@ export default defineConfig({
// by decision: its correctness signal is its uninstrumented suite and
// the packer's end-to-end image spec.
'packages/experimental/webworker-runtime/src/**/*.ts',
// The wire codec and identity providers this layer publishes are the
// same surfaces, and their value-consuming half runs inside the
// separately deployed Relay package, so the same lane owns them.
// The protocol layer's codec/refusal branches are covered by its own
// package suite, but the repo-wide per-file gate is deferred until this
// package ships in-repo with the Relay; tracked in the LiveShare stack
// pull requests (protocol layer) rather than another package's lane.
'packages/experimental/liveshare-protocol/src/**/*.ts',
// Projection/command round: executor lifecycle branches and the
// registry's drive tails need the same maturing lanes. TODO(gui):