5b07bb4e14
Wires the gateway's signed SubscribeEvents stream end-to-end:
- backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every
current_turn advance, addressed to every active membership, push-only
channel, idempotency key turn-ready:<game_id>:<turn>;
- ui: single EventStream singleton replaces revocation-watcher.ts and
carries both per-event dispatch and revocation detection; toast
primitive (store + host) lives in lib/; GameStateStore gains
pendingTurn/markPendingTurn/advanceToPending and a persisted
lastViewedTurn so a return after multiple turns surfaces the same
"view now" affordance as a live push event;
- mandatory event-signature verification through ui/core
(verifyPayloadHash + verifyEvent), full-jitter exponential backoff
1s -> 30s on transient failure, signOut("revoked") on
Unauthenticated or clean end-of-stream;
- catalog and migration accept the new kind; tests cover producer
(testcontainers + capturing publisher), consumer (Vitest event
stream, toast, game-state extensions), and a Playwright e2e
delivering a signed frame to the live UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
133 lines
4.5 KiB
Go
133 lines
4.5 KiB
Go
package notification
|
|
|
|
// Kind constants name every supported notification kind. The implementation // trims the README §10 catalog to the set with active producers in
|
|
// the codebase; further kinds (`game.*`, `mail.dead_lettered`) require
|
|
// an additive change here together with a producer.
|
|
const (
|
|
KindLobbyInviteReceived = "lobby.invite.received"
|
|
KindLobbyInviteRevoked = "lobby.invite.revoked"
|
|
KindLobbyApplicationSubmitted = "lobby.application.submitted"
|
|
KindLobbyApplicationApproved = "lobby.application.approved"
|
|
KindLobbyApplicationRejected = "lobby.application.rejected"
|
|
KindLobbyMembershipRemoved = "lobby.membership.removed"
|
|
KindLobbyMembershipBlocked = "lobby.membership.blocked"
|
|
KindLobbyRaceNameRegistered = "lobby.race_name.registered"
|
|
KindLobbyRaceNamePending = "lobby.race_name.pending"
|
|
KindLobbyRaceNameExpired = "lobby.race_name.expired"
|
|
KindRuntimeImagePullFailed = "runtime.image_pull_failed"
|
|
KindRuntimeContainerStartFailed = "runtime.container_start_failed"
|
|
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
|
|
KindGameTurnReady = "game.turn.ready"
|
|
)
|
|
|
|
// CatalogEntry describes the per-kind delivery policy: which channels
|
|
// fan out and whether the kind targets the platform admin recipient
|
|
// instead of per-user accounts.
|
|
type CatalogEntry struct {
|
|
// Channels lists the channels this kind fans out to, in the order
|
|
// rows are materialised in `notification_routes`. The closed set is
|
|
// {`push`, `email`}.
|
|
Channels []string
|
|
|
|
// Admin reports whether the email channel targets the configured
|
|
// admin recipient (`BACKEND_NOTIFICATION_ADMIN_EMAIL`) rather than
|
|
// per-user accounts. Admin-targeted kinds carry an empty Recipients
|
|
// slice on the producer side.
|
|
Admin bool
|
|
|
|
// MailTemplateID is the template_id passed to `mail.EnqueueTemplate`
|
|
// for email routes. The catalog uses the kind itself by convention,
|
|
// matching `mail.TemplateLoginCode`'s use of `auth.login_code`.
|
|
MailTemplateID string
|
|
}
|
|
|
|
// catalog maps each supported kind to its delivery policy. The map is
|
|
// queried by Submit and by the dispatcher worker; producers do not
|
|
// inspect it directly.
|
|
var catalog = map[string]CatalogEntry{
|
|
KindLobbyInviteReceived: {
|
|
Channels: []string{ChannelPush, ChannelEmail},
|
|
MailTemplateID: KindLobbyInviteReceived,
|
|
},
|
|
KindLobbyInviteRevoked: {
|
|
Channels: []string{ChannelPush},
|
|
},
|
|
KindLobbyApplicationSubmitted: {
|
|
Channels: []string{ChannelPush},
|
|
},
|
|
KindLobbyApplicationApproved: {
|
|
Channels: []string{ChannelPush, ChannelEmail},
|
|
MailTemplateID: KindLobbyApplicationApproved,
|
|
},
|
|
KindLobbyApplicationRejected: {
|
|
Channels: []string{ChannelPush, ChannelEmail},
|
|
MailTemplateID: KindLobbyApplicationRejected,
|
|
},
|
|
KindLobbyMembershipRemoved: {
|
|
Channels: []string{ChannelPush, ChannelEmail},
|
|
MailTemplateID: KindLobbyMembershipRemoved,
|
|
},
|
|
KindLobbyMembershipBlocked: {
|
|
Channels: []string{ChannelPush, ChannelEmail},
|
|
MailTemplateID: KindLobbyMembershipBlocked,
|
|
},
|
|
KindLobbyRaceNameRegistered: {
|
|
Channels: []string{ChannelPush},
|
|
},
|
|
KindLobbyRaceNamePending: {
|
|
Channels: []string{ChannelPush, ChannelEmail},
|
|
MailTemplateID: KindLobbyRaceNamePending,
|
|
},
|
|
KindLobbyRaceNameExpired: {
|
|
Channels: []string{ChannelPush},
|
|
},
|
|
KindRuntimeImagePullFailed: {
|
|
Channels: []string{ChannelEmail},
|
|
Admin: true,
|
|
MailTemplateID: KindRuntimeImagePullFailed,
|
|
},
|
|
KindRuntimeContainerStartFailed: {
|
|
Channels: []string{ChannelEmail},
|
|
Admin: true,
|
|
MailTemplateID: KindRuntimeContainerStartFailed,
|
|
},
|
|
KindRuntimeStartConfigInvalid: {
|
|
Channels: []string{ChannelEmail},
|
|
Admin: true,
|
|
MailTemplateID: KindRuntimeStartConfigInvalid,
|
|
},
|
|
KindGameTurnReady: {
|
|
Channels: []string{ChannelPush},
|
|
},
|
|
}
|
|
|
|
// LookupCatalog returns the per-kind policy and a boolean reporting
|
|
// whether the kind exists. Callers (Submit, Worker) branch on the
|
|
// boolean rather than receiving a sentinel error.
|
|
func LookupCatalog(kind string) (CatalogEntry, bool) {
|
|
entry, ok := catalog[kind]
|
|
return entry, ok
|
|
}
|
|
|
|
// SupportedKinds returns the closed kind set in deterministic order.
|
|
// The function exists to back tests and the migration CHECK constraint
|
|
// audit; it is not on the hot path.
|
|
func SupportedKinds() []string {
|
|
return []string{
|
|
KindLobbyInviteReceived,
|
|
KindLobbyInviteRevoked,
|
|
KindLobbyApplicationSubmitted,
|
|
KindLobbyApplicationApproved,
|
|
KindLobbyApplicationRejected,
|
|
KindLobbyMembershipRemoved,
|
|
KindLobbyMembershipBlocked,
|
|
KindLobbyRaceNameRegistered,
|
|
KindLobbyRaceNamePending,
|
|
KindLobbyRaceNameExpired,
|
|
KindRuntimeImagePullFailed,
|
|
KindRuntimeContainerStartFailed,
|
|
KindRuntimeStartConfigInvalid,
|
|
KindGameTurnReady,
|
|
}
|
|
}
|