Implement replay-safe browser synchronization

This commit is contained in:
ms2k 2026-07-18 00:16:21 +09:00
commit 459ffa04c6
2 changed files with 2734 additions and 25 deletions

View file

@ -13,13 +13,17 @@ path or media type.
## Idempotency
Every public mutation requires `Idempotency-Key`. The identity is caller
Every public domain mutation requires `Idempotency-Key`. The identity is caller
subject, operation, and key. The server canonicalizes the semantic request,
including the authorization target, with RFC 8785 and stores its SHA-256
fingerprint and complete response atomically with the mutation. The default
retention is 24 hours and is never shorter than the advertised
`Idempotency-Expires-At`.
Event-ticket issuance is an ephemeral authentication exchange rather than a
domain mutation. Each retry independently mints a short-lived credential; it
does not refine a `JobProtocol` effect or enter the public mutation ledger.
The same identity and fingerprint replays the original status, resource, and
body after duplicate or ambiguous delivery. A different fingerprint returns
`idempotency_conflict` and cannot affect either request. Naturally idempotent
@ -118,7 +122,15 @@ An HTTP job or list snapshot carries `event_cursor` at its consistency
watermark. The client connects to `/v1/events` with that cursor; there is no
subscription frame. Server frames are ordered by increasing `sequence` for a
connection and carry an opaque next cursor. Delivery is at least once, and
clients deduplicate event IDs.
clients deduplicate event IDs. Each connection starts at sequence one and
increments it by one for every frame, including heartbeats and replayed event
IDs; a client resnapshots when it observes a gap.
Browser clients first create a short-lived, single-use event ticket through an
OIDC bearer-authenticated HTTP request. The server stores only its digest and
binds it to the authenticated principal and exact allowed origin. Redemption
atomically consumes it during the WebSocket handshake. Native clients may
authenticate that handshake directly with the OIDC bearer header.
`job_changed` is an invalidation with an attempt-aware display hint, not a
resource replacement or patch. A client compares `job_revision` with the last

View file

@ -384,10 +384,35 @@ paths:
$ref: '#/components/responses/NotFound'
default:
$ref: '#/components/responses/Error'
/v1/event-tickets:
post:
operationId: createEventTicket
description: >-
Creates an independent short-lived, single-use browser WebSocket
credential bound to the authenticated principal and request origin.
This ephemeral authentication exchange does not enter the public
domain mutation idempotency ledger.
responses:
'201':
description: Event ticket created. The response is not cacheable.
content:
application/json:
schema:
$ref: '#/components/schemas/EventTicket'
default:
$ref: '#/components/responses/Error'
/v1/events:
get:
operationId: connectEvents
security:
- oidc: []
- eventTicket: []
parameters:
- name: ticket
in: query
description: Short-lived single-use browser credential created by createEventTicket. Native clients may use OIDC bearer authentication instead.
schema:
$ref: '#/components/schemas/OpaqueId'
- name: cursor
in: query
description: Opaque authorization-bound replay cursor from an HTTP snapshot or prior frame.
@ -643,6 +668,10 @@ components:
openIdConnectUrl: https://identity.invalid/.well-known/openid-configuration
serviceAuth:
type: mutualTLS
eventTicket:
type: apiKey
in: query
name: ticket
parameters:
IdempotencyKey:
name: Idempotency-Key
@ -1619,6 +1648,16 @@ components:
updated_at:
type: string
format: date-time
EventTicket:
type: object
additionalProperties: false
required: [ticket, expires_at]
properties:
ticket:
$ref: '#/components/schemas/OpaqueId'
expires_at:
type: string
format: date-time
ApiError:
type: object
additionalProperties: false