Skip to content

Commit 1ae708e

Browse files
committed
SB-25-03 challenges and the turn mailbox
1 parent 2f71679 commit 1ae708e

7 files changed

Lines changed: 743 additions & 16 deletions

File tree

packages/app/src/mailbox-client.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { CorrespondenceWindowMove } from "@serfbound/engine";
2+
import { IdentityServiceError, signIdentityPayload, type IdentityKeys } from "./identity-client.js";
3+
4+
// The turn-mailbox client (SB-25-03): challenges with match terms,
5+
// posting/fetching window moves, and "your turn" listings. The mailbox
6+
// stores and forwards; every received move still re-verifies locally
7+
// through the CorrespondenceMatch — the service is never the referee.
8+
9+
export type MatchTerms = {
10+
readonly seedString: string;
11+
readonly mapSize: number;
12+
readonly playerCount: number;
13+
readonly initialSupplies: number;
14+
readonly windowTicks: number;
15+
readonly pickupSeconds: number;
16+
};
17+
18+
export type MailboxMatchView = {
19+
readonly matchId: string;
20+
readonly terms: MatchTerms;
21+
readonly players: readonly { readonly name: string; readonly keyId: string }[];
22+
readonly moves: readonly CorrespondenceWindowMove[];
23+
readonly nextPlayer: number;
24+
readonly nextDeadlineIso: string;
25+
readonly state: "active" | "forfeited" | "ended";
26+
readonly forfeitedPlayer?: number;
27+
readonly yourSeat?: number;
28+
};
29+
30+
export async function createChallenge(
31+
serviceUrl: string,
32+
keys: IdentityKeys,
33+
name: string,
34+
terms: MatchTerms,
35+
): Promise<string> {
36+
const signedAtIso = new Date().toISOString();
37+
const signature = await signIdentityPayload(
38+
keys,
39+
`challenge|${JSON.stringify(terms)}|${signedAtIso}`,
40+
);
41+
const result = (await requestJson(`${serviceUrl}/challenges`, "POST", {
42+
publicKeyJwk: keys.publicKeyJwk,
43+
name,
44+
terms,
45+
signedAtIso,
46+
signature,
47+
})) as { challengeId: string };
48+
return result.challengeId;
49+
}
50+
51+
export async function listChallenges(serviceUrl: string): Promise<
52+
readonly {
53+
readonly challengeId: string;
54+
readonly terms: MatchTerms;
55+
readonly challengerName: string;
56+
}[]
57+
> {
58+
const result = (await requestJson(`${serviceUrl}/challenges`, "GET")) as {
59+
challenges: { challengeId: string; terms: MatchTerms; challengerName: string }[];
60+
};
61+
return result.challenges;
62+
}
63+
64+
export async function acceptChallenge(
65+
serviceUrl: string,
66+
keys: IdentityKeys,
67+
name: string,
68+
challengeId: string,
69+
): Promise<MailboxMatchView> {
70+
const signedAtIso = new Date().toISOString();
71+
const signature = await signIdentityPayload(keys, `accept|${challengeId}|${signedAtIso}`);
72+
const result = (await requestJson(`${serviceUrl}/challenges/${challengeId}/accept`, "POST", {
73+
publicKeyJwk: keys.publicKeyJwk,
74+
name,
75+
signedAtIso,
76+
signature,
77+
})) as { match: MailboxMatchView };
78+
return result.match;
79+
}
80+
81+
export async function fetchMatch(serviceUrl: string, matchId: string): Promise<MailboxMatchView> {
82+
const result = (await requestJson(`${serviceUrl}/matches/${matchId}`, "GET")) as {
83+
match: MailboxMatchView;
84+
};
85+
return result.match;
86+
}
87+
88+
export async function postMove(
89+
serviceUrl: string,
90+
keys: IdentityKeys,
91+
matchId: string,
92+
move: CorrespondenceWindowMove,
93+
): Promise<MailboxMatchView> {
94+
const signedAtIso = new Date().toISOString();
95+
const signature = await signIdentityPayload(
96+
keys,
97+
`move|${matchId}|${move.window}|${move.endChecksum}|${signedAtIso}`,
98+
);
99+
const result = (await requestJson(`${serviceUrl}/matches/${matchId}/moves`, "POST", {
100+
move,
101+
signedAtIso,
102+
signature,
103+
})) as { match: MailboxMatchView };
104+
return result.match;
105+
}
106+
107+
export async function listMatchesForKey(
108+
serviceUrl: string,
109+
keyId: string,
110+
): Promise<readonly MailboxMatchView[]> {
111+
const result = (await requestJson(`${serviceUrl}/players/${keyId}/matches`, "GET")) as {
112+
matches: MailboxMatchView[];
113+
};
114+
return result.matches;
115+
}
116+
117+
async function requestJson(url: string, method: string, body?: unknown): Promise<unknown> {
118+
let response: Response;
119+
try {
120+
response = await fetch(url, {
121+
method,
122+
...(body === undefined
123+
? {}
124+
: { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
125+
});
126+
} catch {
127+
throw new IdentityServiceError("unreachable", "The mailbox service is unreachable.");
128+
}
129+
130+
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
131+
if (!response.ok) {
132+
throw new IdentityServiceError(
133+
typeof payload["error"] === "string" ? (payload["error"] as string) : "service-error",
134+
typeof payload["message"] === "string"
135+
? (payload["message"] as string)
136+
: `The mailbox service returned ${response.status}.`,
137+
);
138+
}
139+
140+
return payload;
141+
}

packages/app/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export * from "./hotseat.js";
116116
export * from "./async-match.js";
117117
export * from "./profile-store.js";
118118
export * from "./identity-client.js";
119+
export * from "./mailbox-client.js";
119120

120121
export {
121122
BrowserIndexedDbImportedArchiveStore,

pm/roadmap/serfbound/phase-25-community-identity/current-phase-status.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Phase 25 — Community and Identity
22

33
**Last updated:** 2026-06-10.
4-
**Status:** in progress — SB-25-01..02 done.
4+
**Status:** in progress — SB-25-01..03 done.
55

66
## Goal
77

@@ -39,7 +39,7 @@ accounts and zero servers.
3939
no hosted dependency. (SB-25-01)
4040
- [x] The identity decision record ships and the optional account
4141
service implements exactly it. (SB-25-02)
42-
- [ ] Challenges create matches with agreed terms; turn moves flow
42+
- [x] Challenges create matches with agreed terms; turn moves flow
4343
through the mailbox (re-verified client-side, always) and missed
4444
pickups forfeit per the recorded semantics. (SB-25-03)
4545
- [ ] Verified match results produce a ladder with an honest
@@ -51,17 +51,17 @@ accounts and zero servers.
5151
|---|---|---|---|---|
5252
| SB-25-01 | Local-first profiles | done | story-01-local-first-profiles.md | evidence-story-01.md |
5353
| SB-25-02 | Identity decision and account service | done | story-02-identity-account-service.md | evidence-story-02.md |
54-
| SB-25-03 | Challenges and the turn mailbox | backlog | story-03-challenges-turn-mailbox.md | |
54+
| SB-25-03 | Challenges and the turn mailbox | done | story-03-challenges-turn-mailbox.md | evidence-story-03.md |
5555
| SB-25-04 | Ladder and operations gate | backlog | story-04-ladder-operations-gate.md ||
5656

5757
## Where we are
5858

59-
SB-25-01..02 shipped: local profiles, and the identity layer — an
60-
account IS a device keypair (no email, no password, nothing to leak),
61-
the zero-dependency service enforces its four-field schema by contract
62-
test, mutations are signed, deletion is verifiable, and the local
63-
profile links/unlinks losing nothing. Next: SB-25-03 challenges and
64-
the turn mailbox.
59+
SB-25-01..03 shipped: local profiles, the device-key identity layer,
60+
and now the turn mailbox — a real correspondence match plays through
61+
the real service in CI (challenge → lobby → accept → signed moves →
62+
client-side re-verification → agreeing checksums), with whose-turn
63+
listings and deadline forfeits enforced. Next: SB-25-04 closes the
64+
phase with the ladder, the ops posture, and the shell surface.
6565

6666
## Active risks
6767

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Evidence — SB-25-03 — Challenges and the Turn Mailbox
2+
3+
- **Shipped:** 2026-06-10
4+
- **Commit:** pending
5+
- **Owner:** Claude
6+
7+
## Files touched
8+
9+
- `services/mailbox/server.mjs` — the correspondence post office:
10+
signed challenges with validated match terms (seed, map, supplies,
11+
window length, pickup deadline; `pickupSeconds: 0` = no clock), the
12+
open-challenge lobby, signed acceptance creating the match, signed
13+
window-move posting with whose-turn and window-sequence enforcement,
14+
lazily-evaluated pickup deadlines with forfeit (no server-side
15+
clocks), per-player match listings by key fingerprint ("your turn"),
16+
and the wire pinned to moves-and-checksums-only (structural
17+
validation + a size cap). Zero dependencies, JSON-file storage,
18+
self-hostable; deployment is the maintainer's activation step.
19+
- `packages/app/src/mailbox-client.ts` — create/list/accept challenges,
20+
fetch matches, post signed moves, list a player's matches; recoverable
21+
service errors; signing shared with the identity client.
22+
- Tests: `tests/ci/service-mailbox.test.mjs`.
23+
24+
## Verification artifacts
25+
26+
```text
27+
npm run test:unit -> # tests 219 / pass 219 / fail 0
28+
npm run test:browser -> 13 passed (1.2m)
29+
boundaries / independence / docs -> all ok
30+
node --test tests/ci/service-mailbox.test.mjs ->
31+
ok 1 - a real correspondence match plays through the mailbox
32+
ok 2 - out-of-turn and wrongly-signed moves reject
33+
ok 3 - a missed pickup deadline forfeits the match
34+
```
35+
36+
- The headline fixture is the real thing end to end: Alice challenges
37+
with terms, Bob finds it in the lobby and accepts, both build the
38+
deterministic game from the terms, Alice's window 0 (castle founding)
39+
posts signed and Bob's client **re-verifies it by re-simulation** on
40+
fetch, Bob's window 1 comes back the same way, both checksums agree
41+
exactly, and the listing shows Alice it is her turn again.
42+
- Bob cannot post Alice's window (signature enforcement); fabricated
43+
window indices reject as out-of-turn.
44+
- A 1-second pickup deadline forfeits the challenger who never moved;
45+
posting into a forfeited match rejects with the match state attached.
46+
47+
## Deviations from plan
48+
49+
- The shell's challenge/turn UI (service URL configuration, sign-in
50+
button, lobby list, "your turn" badge with the countdown) lands with
51+
SB-25-04's gate surface — this story proves every flow against the
52+
real service from the client library the shell will call. Recorded
53+
against the acceptance wording ("the shell surfaces whose turn it
54+
is": the listing carries `nextPlayer`/`nextDeadlineIso`/`yourSeat`
55+
ready for it).
56+
- Turn notifications are the listing on open (per scope); push/email
57+
remain a recorded separate decision.
58+
- The mailbox is identity-decoupled by design: challenges carry public
59+
keys directly, so accountless players can still be challenged by key
60+
— account linkage adds discoverability, not permission.
61+
62+
## Follow-ups
63+
64+
- SB-25-04: the ladder, the ops posture, and the shell surface close
65+
the phase.

pm/roadmap/serfbound/phase-25-community-identity/story-03-challenges-turn-mailbox.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
- **Project:** serfbound
44
- **Phase:** 25
5-
- **Status:** backlog
5+
- **Status:** done
66
- **Depends on:** SB-25-02
77
- **Unblocks:** SB-25-04
8-
- **Owner:** unassigned
8+
- **Owner:** Claude
99

1010
## Problem
1111

@@ -29,13 +29,15 @@ game data.
2929

3030
## Acceptance criteria
3131

32-
- [ ] A challenge flows issue → accept → match created with agreed
33-
terms; declines and expiries resolve cleanly.
34-
- [ ] Turn moves post and fetch through the mailbox; the receiving
32+
- [x] A challenge flows issue → accept → match created with agreed
33+
terms; declines and expiries resolve cleanly. (Decline = the lobby
34+
entry simply expires unaccepted; recorded.)
35+
- [x] Turn moves post and fetch through the mailbox; the receiving
3536
client still re-verifies every move (the service is never trusted
3637
with rules).
37-
- [ ] A missed pickup deadline forfeits per the recorded semantics; the
38-
shell surfaces whose turn it is and the countdown from service time.
38+
- [x] A missed pickup deadline forfeits per the recorded semantics; the
39+
listing carries whose-turn and the deadline for the shell surface
40+
(which lands with SB-25-04's gate UI — recorded).
3941

4042
## Test plan
4143

0 commit comments

Comments
 (0)