@hadoku/rooms-protocol 0.0.0-stage → 0.43.15

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Matthaeus Wolff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The game-bundle contract: how a room host mounts a game it knows nothing
3
+ * about.
4
+ *
5
+ * A host (watchparty's room) holds a list of `GameEntry` rows as DATA. For a
6
+ * `module` entry it dynamically imports `url`, expects the module to export
7
+ * `mount` (a `GameBundleModule`), and hands it an element plus a context. The
8
+ * bundle renders into that element — inside its own shadow root, so neither
9
+ * side's styles reach the other — and talks to its own server; the host only
10
+ * relays who is looking, what theme they are in, and whether they lead.
11
+ *
12
+ * Adding a game is therefore a data edit on the host, never host code.
13
+ * `iframe` is the one built-in kind: the host's own cross-origin iframe game
14
+ * (Bomberman), kept as it was.
15
+ */
16
+ /** Who is viewing. The same values the embeddable room chrome receives. */
17
+ export interface GameBundleIdentity {
18
+ /** The viewer's key-registry userId when known, else `public`. A host
19
+ * never hands a bundle the session id: it is a bearer credential. */
20
+ userId: string;
21
+ userType: string;
22
+ /** Display name, or null when the viewer has none yet. */
23
+ name: string | null;
24
+ }
25
+ export interface GameBundleContext {
26
+ /** The room the bundle is mounted in: the same value for every
27
+ * participant. It is the room's id, not the key a viewer joined with —
28
+ * a leader joins with a different key, and that key is a credential a
29
+ * bundle must never be handed. */
30
+ roomKey: string;
31
+ identity: GameBundleIdentity;
32
+ /** The current hadoku theme name. */
33
+ theme: string;
34
+ isLeader: boolean;
35
+ /** Ask the host to close the bundle. For the leader this closes it for
36
+ * everyone; for anyone else it closes it for them only. */
37
+ close(): void;
38
+ }
39
+ export interface MountedGameBundle {
40
+ /** Tear down everything `mount` created. Called once. */
41
+ unmount(): void;
42
+ /** The context changed (theme, identity, leadership). */
43
+ update(ctx: GameBundleContext): void;
44
+ }
45
+ /** What a `module` entry's URL must export. */
46
+ export interface GameBundleModule {
47
+ mount(el: HTMLElement, ctx: GameBundleContext): MountedGameBundle | Promise<MountedGameBundle>;
48
+ }
49
+ export type GameEntry =
50
+ /** The host's built-in iframe game. */
51
+ {
52
+ id: string;
53
+ name: string;
54
+ cover?: string;
55
+ kind: 'iframe';
56
+ }
57
+ /** An ES module exporting `GameBundleModule`, at a same-origin path. */
58
+ | {
59
+ id: string;
60
+ name: string;
61
+ cover?: string;
62
+ kind: 'module';
63
+ url: string;
64
+ };
65
+ /**
66
+ * Whether `value` is a module that satisfies `GameBundleModule`. A bundle is
67
+ * loaded from a URL at runtime, so its shape is a claim the host checks rather
68
+ * than a type it can trust.
69
+ */
70
+ export declare function isGameBundleModule(value: unknown): value is GameBundleModule;
71
+ /** Whether `value` is what a bundle's `mount` must resolve to. */
72
+ export declare function isMountedGameBundle(value: unknown): value is MountedGameBundle;
73
+ //# sourceMappingURL=game-bundle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"game-bundle.d.ts","sourceRoot":"","sources":["../src/game-bundle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,2EAA2E;AAC3E,MAAM,WAAW,kBAAkB;IACjC;0EACsE;IACtE,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,iBAAiB;IAChC;;;uCAGmC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,qCAAqC;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,OAAO,CAAC;IAClB;gEAC4D;IAC5D,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,iBAAiB;IAChC,yDAAyD;IACzD,OAAO,IAAI,IAAI,CAAC;IAChB,yDAAyD;IACzD,MAAM,CAAC,GAAG,EAAE,iBAAiB,GAAG,IAAI,CAAC;CACtC;AAED,+CAA+C;AAC/C,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CAAC,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,iBAAiB,GAAG,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;CAChG;AAED,MAAM,MAAM,SAAS;AACnB,uCAAuC;AACrC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE;AAC9D,wEAAwE;GACtE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9E;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAG5E;AAED,kEAAkE;AAClE,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,iBAAiB,CAI9E"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The game-bundle contract: how a room host mounts a game it knows nothing
3
+ * about.
4
+ *
5
+ * A host (watchparty's room) holds a list of `GameEntry` rows as DATA. For a
6
+ * `module` entry it dynamically imports `url`, expects the module to export
7
+ * `mount` (a `GameBundleModule`), and hands it an element plus a context. The
8
+ * bundle renders into that element — inside its own shadow root, so neither
9
+ * side's styles reach the other — and talks to its own server; the host only
10
+ * relays who is looking, what theme they are in, and whether they lead.
11
+ *
12
+ * Adding a game is therefore a data edit on the host, never host code.
13
+ * `iframe` is the one built-in kind: the host's own cross-origin iframe game
14
+ * (Bomberman), kept as it was.
15
+ */
16
+ /**
17
+ * Whether `value` is a module that satisfies `GameBundleModule`. A bundle is
18
+ * loaded from a URL at runtime, so its shape is a claim the host checks rather
19
+ * than a type it can trust.
20
+ */
21
+ export function isGameBundleModule(value) {
22
+ return typeof value === 'object' && value !== null
23
+ && typeof value.mount === 'function';
24
+ }
25
+ /** Whether `value` is what a bundle's `mount` must resolve to. */
26
+ export function isMountedGameBundle(value) {
27
+ return typeof value === 'object' && value !== null
28
+ && typeof value.unmount === 'function'
29
+ && typeof value.update === 'function';
30
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @wolffm/rooms-protocol — the wire contract for rooms.
3
+ *
4
+ * Deliberately dependency-free (not even zod yet) and free of any hosting
5
+ * app's vocabulary: no playhead, media, fragment, playlist, tierlist or
6
+ * sprite. Both `rooms-server` and a client bind to this, so anything that
7
+ * lands here becomes a promise to every consumer.
8
+ *
9
+ * Extracted from watchparty's `@watchparty/shared`, which keeps re-exporting
10
+ * these so the ~40 existing import sites did not all have to move in the same
11
+ * commit as the package boundary.
12
+ */
13
+ export * from './participantId.js';
14
+ export * from './seats.js';
15
+ export * from './resync.js';
16
+ export * from './game-bundle.js';
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,cAAc,oBAAoB,CAAC;AACnC,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @wolffm/rooms-protocol — the wire contract for rooms.
3
+ *
4
+ * Deliberately dependency-free (not even zod yet) and free of any hosting
5
+ * app's vocabulary: no playhead, media, fragment, playlist, tierlist or
6
+ * sprite. Both `rooms-server` and a client bind to this, so anything that
7
+ * lands here becomes a promise to every consumer.
8
+ *
9
+ * Extracted from watchparty's `@watchparty/shared`, which keeps re-exporting
10
+ * these so the ~40 existing import sites did not all have to move in the same
11
+ * commit as the package boundary.
12
+ */
13
+ export * from './participantId.js';
14
+ export * from './seats.js';
15
+ export * from './resync.js';
16
+ export * from './game-bundle.js';
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Participant identity — the FORMAT half (pure, isomorphic, zero deps).
3
+ *
4
+ * A `ParticipantId` names a *person in a room*, and it is deliberately NOT the
5
+ * socket id. Until this module existed, `clientId` WAS `socket.id`
6
+ * (`ws/join.ts`), so every identity in the system died on reconnect and the
7
+ * codebase carried three separate workarounds for it: the `fk:`/`cid:` name
8
+ * persistence keys in `rooms/store.ts`, the A/B bucketing that had to abandon
9
+ * `me.clientId` for a persisted localStorage id (`lib/fragmentDelivery.ts`),
10
+ * and the leader-takeover heuristics in `ws/join.ts` that exist to recognise a
11
+ * refreshing presenter whose new socket is a different "client".
12
+ *
13
+ * ## The tier is IN the id, on purpose
14
+ *
15
+ * fk:<digest> account — derived from the platform ACCOUNT id (the key
16
+ * registry's `userId`, resolved server-side through
17
+ * whoami). Survives reconnects, reloads, restarts,
18
+ * devices, networks — and KEY ROTATION. The `fk`
19
+ * spelling predates that and is kept because
20
+ * consumers parse it; read it as "account".
21
+ *
22
+ * ## Derive the account tier from the userId, never from the key
23
+ *
24
+ * The X-User-Key is a CREDENTIAL and it rotates. An id hashed from it changes
25
+ * the moment the key does, and everything keyed on the id — a remembered seat,
26
+ * a restored name, the same-presenter leader takeover — silently stops
27
+ * recognising the person. That is the same failure that detached every
28
+ * tierlist ballot in watchparty (#144), one layer up. `accountId` is the
29
+ * input; `friendKey` remains accepted ONLY so a consumer that has not yet
30
+ * resolved userIds keeps working, and loses rotation-stability while it does.
31
+ * dev:<digest> device — derived from a browser-persisted id. Survives a
32
+ * reload and a reconnect; not a new browser.
33
+ * eph:<socketId> ephemeral— anon with no usable storage. Dies with the socket.
34
+ *
35
+ * Encoding durability in the id means code that REQUIRES durability can assert
36
+ * on it (`requireDurable`) instead of hoping. A 45-minute draft seat can demand
37
+ * `account` or `device`; a demo room can accept `ephemeral`.
38
+ *
39
+ * ## `device` is NOT an authorization boundary
40
+ *
41
+ * The device id is **client-asserted** — it arrives in the join payload and
42
+ * nothing proves it. So a `dev:` identity is good enough to carry a display
43
+ * name or hold a seat among friends, and NOT good enough to gate a capability:
44
+ * anyone who learns another client's device id can present it. Only the
45
+ * `account` tier is server-authenticated (the edge resolves the hadoku_session
46
+ * cookie into X-User-Key; the browser never sees the key). Anything that grants
47
+ * power — leadership, ownership, a moderation action — must require
48
+ * `account`. `isAuthenticatedTier` is the check; do not widen it.
49
+ *
50
+ * ## Why the id can be logged and put on the wire
51
+ *
52
+ * The `fk:` and `dev:` bodies are non-reversible digests, not the source
53
+ * material (see the server's `deriveParticipantId`, which owns the hashing
54
+ * because it needs a synchronous crypto primitive this package cannot assume).
55
+ * The friend key IS the X-User-Key credential, so embedding it raw would hand
56
+ * every viewer everyone else's key while looking entirely normal on screen —
57
+ * the same trap `opaqueVoterId` was written to avoid in `tierlist/store.ts`.
58
+ * Hashing at derivation means there is exactly ONE id per participant rather
59
+ * than an internal id plus a wire handle to keep in sync.
60
+ */
61
+ /** Durability class of a participant identity. Ordered least → most durable. */
62
+ export type ParticipantIdTier = 'ephemeral' | 'device' | 'account';
63
+ /** `fk:…` | `dev:…` | `eph:…` — see this module's header. */
64
+ export type ParticipantId = string;
65
+ export declare const PARTICIPANT_ID_PREFIX: Readonly<Record<ParticipantIdTier, string>>;
66
+ /**
67
+ * Shortest friend key we treat as real. Matches the existing `>= 5` checks in
68
+ * `ws/handlers.ts` and `rooms/store.ts#hasFriendKey` — keep them in step.
69
+ */
70
+ export declare const MIN_FRIEND_KEY_LENGTH = 5;
71
+ /**
72
+ * Shortest device id we accept. Longer than the friend-key floor on purpose:
73
+ * a friend key is server-injected and known-good, while a device id is
74
+ * client-asserted, so a short one is far more likely to be junk (or a
75
+ * collision-farming attempt) than a real persisted id. Clients that can't do
76
+ * better fall through to `ephemeral`, which is honest rather than broken.
77
+ */
78
+ export declare const MIN_DEVICE_ID_LENGTH = 8;
79
+ /** Hex digest length used by the `account` and `device` bodies. */
80
+ export declare const PARTICIPANT_ID_DIGEST_LENGTH = 16;
81
+ /**
82
+ * Tier of an id, or `null` if it isn't one. Returns null rather than throwing
83
+ * or defaulting: an unrecognised id is absence of evidence, and defaulting it
84
+ * to `ephemeral` would silently admit a malformed id to every path that only
85
+ * refuses `ephemeral` — the failure would look like a working downgrade.
86
+ */
87
+ export declare function participantIdTier(id: string): ParticipantIdTier | null;
88
+ /** True for a well-formed id of any tier. */
89
+ export declare function isParticipantId(id: string): boolean;
90
+ /**
91
+ * Survives a reconnect (`account` or `device`). This is the check for "may hold
92
+ * a seat / may have a pending request replayed", NOT for "may do something
93
+ * privileged" — see `isAuthenticatedTier`.
94
+ */
95
+ export declare function isDurableParticipantId(id: string): boolean;
96
+ /**
97
+ * Server-authenticated identity (`account` only). The gate for anything that
98
+ * grants power. Deliberately narrower than `isDurableParticipantId` because a
99
+ * device id is client-asserted — see this module's header.
100
+ */
101
+ export declare function isAuthenticatedTier(id: string): boolean;
102
+ /** Rank comparison — `tierAtLeast(id, 'device')` admits device and account. */
103
+ export declare function participantTierAtLeast(id: string, minTier: ParticipantIdTier): boolean;
104
+ /**
105
+ * Whether a friend key is long enough to be treated as real. THE one
106
+ * definition — it was hand-copied as `length >= 5` across six files, which is
107
+ * how one of them ends up disagreeing with the rest.
108
+ *
109
+ * A type guard, so callers narrow instead of re-asserting with a cast.
110
+ */
111
+ export declare function isUsableFriendKey(friendKey: unknown): friendKey is string;
112
+ /**
113
+ * Whether a platform account id (the registry `userId`) is usable. Same floor
114
+ * as a friend key: it is server-resolved, never client-asserted, so the check
115
+ * exists only to reject an empty or junk value, not to judge trust.
116
+ */
117
+ export declare function isUsableAccountId(accountId: unknown): accountId is string;
118
+ /** Whether a client-supplied device id is long enough to be treated as real. */
119
+ export declare function isUsableDeviceId(deviceId: unknown): deviceId is string;
120
+ /**
121
+ * Which tier a set of inputs WOULD produce, without needing the hash. Lets the
122
+ * join path log and gate on the tier before (or without) deriving the id, and
123
+ * lets tests assert the tier decision independently of the digest.
124
+ */
125
+ export declare function resolveParticipantTier(input: {
126
+ accountId?: string | null;
127
+ friendKey?: string | null;
128
+ deviceId?: string | null;
129
+ }): ParticipantIdTier;
130
+ /**
131
+ * Assemble an id from an already-computed body. The server's
132
+ * `deriveParticipantId` is the only intended caller for the hashed tiers; it is
133
+ * exported so both halves share one spelling of the prefixes rather than
134
+ * concatenating them by hand at each site.
135
+ */
136
+ export declare function formatParticipantId(tier: ParticipantIdTier, body: string): ParticipantId;
137
+ /**
138
+ * The body after the scheme, or `null` for a malformed id. Useful for logs
139
+ * that want a short handle without the prefix noise; never for reconstructing
140
+ * the source material, which is not recoverable from a digest.
141
+ */
142
+ export declare function participantIdBody(id: string): string | null;
143
+ /** How much of a participant's body a handle shows. */
144
+ export declare const PARTICIPANT_HANDLE_LENGTH = 6;
145
+ /**
146
+ * A short, wire-safe handle for a participant — what you may SHOW a room.
147
+ *
148
+ * The one part of an identity that is safe to put in a seat row or a log line:
149
+ * the body is a non-reversible digest (or, at the ephemeral tier, a socket id
150
+ * every client already sees), and the prefix that names the tier is dropped
151
+ * because a roster has no business advertising who is signed in.
152
+ *
153
+ * Here rather than in each consumer so the truncation is ONE number. Two apps
154
+ * each picking their own produce handles that collide at different rates, and
155
+ * the one that picked too short finds out from a room where two people look
156
+ * like the same person. `'unknown'` for a malformed id, never a slice of it.
157
+ */
158
+ export declare function participantHandle(id: string): string;
159
+ //# sourceMappingURL=participantId.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"participantId.d.ts","sourceRoot":"","sources":["../src/participantId.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AAEH,gFAAgF;AAChF,MAAM,MAAM,iBAAiB,GAAG,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEnE,6DAA6D;AAC7D,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC;AAEnC,eAAO,MAAM,qBAAqB,EAAE,QAAQ,CAAC,MAAM,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAI7E,CAAC;AAaF;;;GAGG;AACH,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,mEAAmE;AACnE,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAS/C;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAItE;AAED,6CAA6C;AAC7C,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAG1D;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAItF;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,SAAS,IAAI,MAAM,CAEzE;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,SAAS,IAAI,MAAM,CAEzE;AAED,gFAAgF;AAChF,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,GAAG,QAAQ,IAAI,MAAM,CAEtE;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE;IAC5C,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B,GAAG,iBAAiB,CAKpB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,CAExF;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG3D;AAED,uDAAuD;AACvD,eAAO,MAAM,yBAAyB,IAAI,CAAC;AAE3C;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAGpD"}
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Participant identity — the FORMAT half (pure, isomorphic, zero deps).
3
+ *
4
+ * A `ParticipantId` names a *person in a room*, and it is deliberately NOT the
5
+ * socket id. Until this module existed, `clientId` WAS `socket.id`
6
+ * (`ws/join.ts`), so every identity in the system died on reconnect and the
7
+ * codebase carried three separate workarounds for it: the `fk:`/`cid:` name
8
+ * persistence keys in `rooms/store.ts`, the A/B bucketing that had to abandon
9
+ * `me.clientId` for a persisted localStorage id (`lib/fragmentDelivery.ts`),
10
+ * and the leader-takeover heuristics in `ws/join.ts` that exist to recognise a
11
+ * refreshing presenter whose new socket is a different "client".
12
+ *
13
+ * ## The tier is IN the id, on purpose
14
+ *
15
+ * fk:<digest> account — derived from the platform ACCOUNT id (the key
16
+ * registry's `userId`, resolved server-side through
17
+ * whoami). Survives reconnects, reloads, restarts,
18
+ * devices, networks — and KEY ROTATION. The `fk`
19
+ * spelling predates that and is kept because
20
+ * consumers parse it; read it as "account".
21
+ *
22
+ * ## Derive the account tier from the userId, never from the key
23
+ *
24
+ * The X-User-Key is a CREDENTIAL and it rotates. An id hashed from it changes
25
+ * the moment the key does, and everything keyed on the id — a remembered seat,
26
+ * a restored name, the same-presenter leader takeover — silently stops
27
+ * recognising the person. That is the same failure that detached every
28
+ * tierlist ballot in watchparty (#144), one layer up. `accountId` is the
29
+ * input; `friendKey` remains accepted ONLY so a consumer that has not yet
30
+ * resolved userIds keeps working, and loses rotation-stability while it does.
31
+ * dev:<digest> device — derived from a browser-persisted id. Survives a
32
+ * reload and a reconnect; not a new browser.
33
+ * eph:<socketId> ephemeral— anon with no usable storage. Dies with the socket.
34
+ *
35
+ * Encoding durability in the id means code that REQUIRES durability can assert
36
+ * on it (`requireDurable`) instead of hoping. A 45-minute draft seat can demand
37
+ * `account` or `device`; a demo room can accept `ephemeral`.
38
+ *
39
+ * ## `device` is NOT an authorization boundary
40
+ *
41
+ * The device id is **client-asserted** — it arrives in the join payload and
42
+ * nothing proves it. So a `dev:` identity is good enough to carry a display
43
+ * name or hold a seat among friends, and NOT good enough to gate a capability:
44
+ * anyone who learns another client's device id can present it. Only the
45
+ * `account` tier is server-authenticated (the edge resolves the hadoku_session
46
+ * cookie into X-User-Key; the browser never sees the key). Anything that grants
47
+ * power — leadership, ownership, a moderation action — must require
48
+ * `account`. `isAuthenticatedTier` is the check; do not widen it.
49
+ *
50
+ * ## Why the id can be logged and put on the wire
51
+ *
52
+ * The `fk:` and `dev:` bodies are non-reversible digests, not the source
53
+ * material (see the server's `deriveParticipantId`, which owns the hashing
54
+ * because it needs a synchronous crypto primitive this package cannot assume).
55
+ * The friend key IS the X-User-Key credential, so embedding it raw would hand
56
+ * every viewer everyone else's key while looking entirely normal on screen —
57
+ * the same trap `opaqueVoterId` was written to avoid in `tierlist/store.ts`.
58
+ * Hashing at derivation means there is exactly ONE id per participant rather
59
+ * than an internal id plus a wire handle to keep in sync.
60
+ */
61
+ export const PARTICIPANT_ID_PREFIX = {
62
+ account: 'fk:',
63
+ device: 'dev:',
64
+ ephemeral: 'eph:',
65
+ };
66
+ /**
67
+ * Tier ranking, least → most durable. Derived from one source so the
68
+ * vocabulary and the ordering cannot drift (the same reason `TIER_RANK` in the
69
+ * server's `whoami.ts` is a map rather than a list of comparisons).
70
+ */
71
+ const TIER_RANK = {
72
+ ephemeral: 0,
73
+ device: 1,
74
+ account: 2,
75
+ };
76
+ /**
77
+ * Shortest friend key we treat as real. Matches the existing `>= 5` checks in
78
+ * `ws/handlers.ts` and `rooms/store.ts#hasFriendKey` — keep them in step.
79
+ */
80
+ export const MIN_FRIEND_KEY_LENGTH = 5;
81
+ /**
82
+ * Shortest device id we accept. Longer than the friend-key floor on purpose:
83
+ * a friend key is server-injected and known-good, while a device id is
84
+ * client-asserted, so a short one is far more likely to be junk (or a
85
+ * collision-farming attempt) than a real persisted id. Clients that can't do
86
+ * better fall through to `ephemeral`, which is honest rather than broken.
87
+ */
88
+ export const MIN_DEVICE_ID_LENGTH = 8;
89
+ /** Hex digest length used by the `account` and `device` bodies. */
90
+ export const PARTICIPANT_ID_DIGEST_LENGTH = 16;
91
+ const PARSE_RE = /^(fk|dev|eph):(.+)$/;
92
+ const TIER_BY_SCHEME = {
93
+ fk: 'account',
94
+ dev: 'device',
95
+ eph: 'ephemeral',
96
+ };
97
+ /**
98
+ * Tier of an id, or `null` if it isn't one. Returns null rather than throwing
99
+ * or defaulting: an unrecognised id is absence of evidence, and defaulting it
100
+ * to `ephemeral` would silently admit a malformed id to every path that only
101
+ * refuses `ephemeral` — the failure would look like a working downgrade.
102
+ */
103
+ export function participantIdTier(id) {
104
+ const m = PARSE_RE.exec(id);
105
+ if (!m)
106
+ return null;
107
+ return TIER_BY_SCHEME[m[1]] ?? null;
108
+ }
109
+ /** True for a well-formed id of any tier. */
110
+ export function isParticipantId(id) {
111
+ return participantIdTier(id) !== null;
112
+ }
113
+ /**
114
+ * Survives a reconnect (`account` or `device`). This is the check for "may hold
115
+ * a seat / may have a pending request replayed", NOT for "may do something
116
+ * privileged" — see `isAuthenticatedTier`.
117
+ */
118
+ export function isDurableParticipantId(id) {
119
+ const tier = participantIdTier(id);
120
+ return tier !== null && TIER_RANK[tier] >= TIER_RANK.device;
121
+ }
122
+ /**
123
+ * Server-authenticated identity (`account` only). The gate for anything that
124
+ * grants power. Deliberately narrower than `isDurableParticipantId` because a
125
+ * device id is client-asserted — see this module's header.
126
+ */
127
+ export function isAuthenticatedTier(id) {
128
+ return participantIdTier(id) === 'account';
129
+ }
130
+ /** Rank comparison — `tierAtLeast(id, 'device')` admits device and account. */
131
+ export function participantTierAtLeast(id, minTier) {
132
+ const tier = participantIdTier(id);
133
+ if (tier === null)
134
+ return false;
135
+ return TIER_RANK[tier] >= TIER_RANK[minTier];
136
+ }
137
+ /**
138
+ * Whether a friend key is long enough to be treated as real. THE one
139
+ * definition — it was hand-copied as `length >= 5` across six files, which is
140
+ * how one of them ends up disagreeing with the rest.
141
+ *
142
+ * A type guard, so callers narrow instead of re-asserting with a cast.
143
+ */
144
+ export function isUsableFriendKey(friendKey) {
145
+ return typeof friendKey === 'string' && friendKey.length >= MIN_FRIEND_KEY_LENGTH;
146
+ }
147
+ /**
148
+ * Whether a platform account id (the registry `userId`) is usable. Same floor
149
+ * as a friend key: it is server-resolved, never client-asserted, so the check
150
+ * exists only to reject an empty or junk value, not to judge trust.
151
+ */
152
+ export function isUsableAccountId(accountId) {
153
+ return typeof accountId === 'string' && accountId.length >= MIN_FRIEND_KEY_LENGTH;
154
+ }
155
+ /** Whether a client-supplied device id is long enough to be treated as real. */
156
+ export function isUsableDeviceId(deviceId) {
157
+ return typeof deviceId === 'string' && deviceId.length >= MIN_DEVICE_ID_LENGTH;
158
+ }
159
+ /**
160
+ * Which tier a set of inputs WOULD produce, without needing the hash. Lets the
161
+ * join path log and gate on the tier before (or without) deriving the id, and
162
+ * lets tests assert the tier decision independently of the digest.
163
+ */
164
+ export function resolveParticipantTier(input) {
165
+ if (isUsableAccountId(input.accountId))
166
+ return 'account';
167
+ if (isUsableFriendKey(input.friendKey))
168
+ return 'account';
169
+ if (isUsableDeviceId(input.deviceId))
170
+ return 'device';
171
+ return 'ephemeral';
172
+ }
173
+ /**
174
+ * Assemble an id from an already-computed body. The server's
175
+ * `deriveParticipantId` is the only intended caller for the hashed tiers; it is
176
+ * exported so both halves share one spelling of the prefixes rather than
177
+ * concatenating them by hand at each site.
178
+ */
179
+ export function formatParticipantId(tier, body) {
180
+ return `${PARTICIPANT_ID_PREFIX[tier]}${body}`;
181
+ }
182
+ /**
183
+ * The body after the scheme, or `null` for a malformed id. Useful for logs
184
+ * that want a short handle without the prefix noise; never for reconstructing
185
+ * the source material, which is not recoverable from a digest.
186
+ */
187
+ export function participantIdBody(id) {
188
+ const m = PARSE_RE.exec(id);
189
+ return m ? m[2] : null;
190
+ }
191
+ /** How much of a participant's body a handle shows. */
192
+ export const PARTICIPANT_HANDLE_LENGTH = 6;
193
+ /**
194
+ * A short, wire-safe handle for a participant — what you may SHOW a room.
195
+ *
196
+ * The one part of an identity that is safe to put in a seat row or a log line:
197
+ * the body is a non-reversible digest (or, at the ephemeral tier, a socket id
198
+ * every client already sees), and the prefix that names the tier is dropped
199
+ * because a roster has no business advertising who is signed in.
200
+ *
201
+ * Here rather than in each consumer so the truncation is ONE number. Two apps
202
+ * each picking their own produce handles that collide at different rates, and
203
+ * the one that picked too short finds out from a room where two people look
204
+ * like the same person. `'unknown'` for a malformed id, never a slice of it.
205
+ */
206
+ export function participantHandle(id) {
207
+ const body = participantIdBody(id);
208
+ return body === null ? 'unknown' : body.slice(0, PARTICIPANT_HANDLE_LENGTH);
209
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Reconnect and resync — the CONTRACT, not the transport.
3
+ *
4
+ * A client that drops and returns has three problems, and they are separable:
5
+ * did it miss anything (sequence), is it even talking to the same room
6
+ * incarnation (epoch), and does the server still agree about who it is
7
+ * (identity). The rules for all three live here, pure and dependency-free, so
8
+ * both ends agree on them and either end can test them without a socket.
9
+ *
10
+ * **The implementations deliberately stay where they are.** Watchparty's socket
11
+ * lifecycle is in `apps/ui/src/lib/socket.ts` and is not extracted; a second
12
+ * consumer whose client is a cross-origin iframe relaying over `postMessage`
13
+ * has a different transport entirely. What both need to agree on is the RULES,
14
+ * which is this file.
15
+ *
16
+ * ## What watchparty actually stamps today — read this before binding
17
+ *
18
+ * `CoreRoomState.lastEventSeq` is incremented **only for playback control
19
+ * events** (`ws/control.ts`), and it reaches clients in the `snapshot` and
20
+ * `control_ack` payloads. Chat, presence, health and game envelopes carry no
21
+ * sequence at all. So `classifySeq` is a rule this package *guarantees*, not a
22
+ * behaviour watchparty currently exercises end to end — an app that wants gap
23
+ * detection stamps `SeqCursor` on its own envelopes and runs the classifier.
24
+ *
25
+ * That distinction was stated wrongly once (an earlier version of the
26
+ * extraction reply claimed "every envelope carries seq"), so it is spelled out
27
+ * here rather than left to be inferred.
28
+ */
29
+ /**
30
+ * Where a client is in a room's event stream.
31
+ *
32
+ * `epoch` identifies the room INCARNATION. It exists because a sequence number
33
+ * alone cannot tell "I missed events" from "the server restarted and started
34
+ * counting again" — and those need opposite responses: catch up versus discard
35
+ * everything and refetch. Any value works as long as it changes when the room
36
+ * is rebuilt and not otherwise (a boot id, a restore id, a uuid).
37
+ */
38
+ export interface SeqCursor {
39
+ epoch: string;
40
+ seq: number;
41
+ }
42
+ export type SeqVerdict =
43
+ /** Exactly the next event. Apply it. */
44
+ 'ok'
45
+ /** Already seen. Drop it — do NOT re-apply, and do not resync. */
46
+ | 'duplicate'
47
+ /** Events were missed. Request a resync. */
48
+ | 'gap'
49
+ /** Different room incarnation. Discard local state and refetch in full. */
50
+ | 'epoch-change';
51
+ /**
52
+ * Classify an incoming event against the last one applied.
53
+ *
54
+ * `last === null` means nothing has been applied yet, which is 'ok' rather than
55
+ * a gap: a client joining mid-stream has missed everything by definition, and
56
+ * its snapshot — not a resync request — is what establishes the baseline.
57
+ *
58
+ * **Sequence numbers are NEVER compared across epochs.** A restarted server
59
+ * counting from 1 while the client holds 500 would otherwise read as a flood
60
+ * of duplicates and the client would sit there discarding live events forever.
61
+ * The epoch check therefore comes first, before any arithmetic.
62
+ */
63
+ export declare function classifySeq(last: SeqCursor | null, incoming: SeqCursor): SeqVerdict;
64
+ /** How many events a gap skipped. 0 when the verdict is not 'gap'. */
65
+ export declare function missedCount(last: SeqCursor | null, incoming: SeqCursor): number;
66
+ /**
67
+ * Does this verdict require the client to ask the server for state?
68
+ *
69
+ * A helper rather than a comparison at each call site, because the answer is
70
+ * NOT "anything that isn't ok": a duplicate must not trigger a resync. Under
71
+ * any retransmit, duplicates arrive in bursts, and resyncing on each one turns
72
+ * a harmless replay into a request storm against the server that is already
73
+ * struggling enough to be retransmitting.
74
+ */
75
+ export declare function needsResync(verdict: SeqVerdict): boolean;
76
+ /** The server's view of us, from the room roster. */
77
+ export interface RosterIdentity {
78
+ name?: string | null;
79
+ avatarId?: string | null;
80
+ }
81
+ export interface LocalIdentity {
82
+ name?: string | null;
83
+ avatarId?: string | null;
84
+ }
85
+ export interface IdentityReassert {
86
+ name?: string;
87
+ avatarId?: string;
88
+ }
89
+ /**
90
+ * Should the client re-assert its local identity to the server?
91
+ *
92
+ * On each snapshot a client compares its own name/avatar against the server's
93
+ * roster entry FOR ITSELF and emits an identity update only for the fields that
94
+ * actually disagree, so later snapshots are no-ops.
95
+ *
96
+ * **Read `serverEntry` from the ROSTER, not from the snapshot.** Watchparty's
97
+ * snapshot carries seven fields and has never included a roster; a caller that
98
+ * looked itself up there got `undefined` permanently, both guards collapsed to
99
+ * local state alone, and every client with a saved identity re-emitted on
100
+ * every snapshot — including the room-wide ones. That bug survived a long time
101
+ * because broken and correct agree on the FIRST snapshot after join and
102
+ * diverge only afterwards.
103
+ *
104
+ * `serverEntry === undefined` still means "assert": that is the genuine
105
+ * first-snapshot case, where the client cannot know the server's view.
106
+ *
107
+ * Known quirk, preserved deliberately: an empty server name does not trigger a
108
+ * name assert, because an empty string is not a default name under any sane
109
+ * `isDefaultName`. Widening that is a separate decision with a different blast
110
+ * radius.
111
+ *
112
+ * @param isDefaultName recognises an auto-assigned name; a local name that is
113
+ * still a default is not worth asserting.
114
+ * @returns the fields to emit, or null when nothing differs.
115
+ */
116
+ export declare function resolveIdentityReassert(local: LocalIdentity, serverEntry: RosterIdentity | undefined, isDefaultName: (name: string) => boolean): IdentityReassert | null;
117
+ //# sourceMappingURL=resync.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resync.d.ts","sourceRoot":"","sources":["../src/resync.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;;;;;;;GAQG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,MAAM,UAAU;AACpB,wCAAwC;AACtC,IAAI;AACN,kEAAkE;GAChE,WAAW;AACb,4CAA4C;GAC1C,KAAK;AACP,2EAA2E;GACzE,cAAc,CAAC;AAEnB;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,EAAE,QAAQ,EAAE,SAAS,GAAG,UAAU,CAMnF;AAED,sEAAsE;AACtE,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,EAAE,QAAQ,EAAE,SAAS,GAAG,MAAM,CAG/E;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAExD;AAID,qDAAqD;AACrD,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,aAAa,EACpB,WAAW,EAAE,cAAc,GAAG,SAAS,EACvC,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,GACvC,gBAAgB,GAAG,IAAI,CAczB"}