Implement replay-safe browser synchronization

This commit is contained in:
ms2k 2026-07-18 00:16:21 +09:00
commit 459ffa04c6
3 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

View file

@ -204,7 +204,7 @@ flowchart TD
## API and Live Updates
- Mutable HTTP and WebSocket implementation contracts live under [`docs/api`](../api/). They are non-authoritative projections of `JobProtocol` and this document and must be corrected when they conflict with either source.
- Public mutation idempotency refines `RemoraApiMutation`: operation scope is the caller subject and operation, the request fingerprint is RFC 8785 canonical JSON hashed with SHA-256, and records are retained for at least the advertised 24-hour default window.
- Public domain mutation idempotency refines `RemoraApiMutation`: operation scope is the caller subject and operation, the request fingerprint is RFC 8785 canonical JSON hashed with SHA-256, and records are retained for at least the advertised 24-hour default window. Ephemeral authentication exchanges that do not refine a `JobProtocol` effect, including browser event-ticket issuance, independently mint short-lived credentials on retry and do not enter that ledger.
- Public job status projects `JobProtocol` state and adds deletion lifecycle, concrete progress, checkpoint, result, and failure fields.
- Job listing applies current authorization before stable keyset pagination. Cursors are opaque, integrity-protected, and bound to the subject, filters, and membership snapshot.
- HTTP snapshots carry a live-event cursor. WebSocket replay is ordered and at least once; invalid, expired, authorization-incompatible, or unavailable history requires a new HTTP snapshot. Event emission exposes progress only when it matches the protocol projection being published.