feat(teams): the Team read API, the moderation routes, and Admin -> Teams
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 8m56s

The eighteen routes of docs/website/TEAMS.md §2.11, their OpenAPI annotations,
and the staff screen that drives them.

Two rules shape the read model. Hidden means absent from every public surface --
the index, the lookup and the roster alike, and a hidden Team 404s
indistinguishably from one that does not exist, because "absent" includes not
confirming it is there. And staleness is surfaced rather than silent: every
public payload carries { configured, stale, lastSyncAt }, so a page can say how
recently the projection was confirmed instead of presenting stale data as
current.

The public roster withholds both the member key and the user id -- one is a
game-internal identifier, the other names a site account. `linked` answers the
only question a public page has without publishing which account. The module's
per-audience field projection is phase 3's; this is a conservative core one.

The §2.9 gate is enforced per REQUEST, not per route. A moderator may call all
eighteen; three of them mean something different when they do, and the server
decides from the role it re-validates on every request rather than from a token
claim. The client has no "file as request" argument to get wrong.

Found by booting the real server against the real database, and not by any test:
**the index and the by-slug lookup disagreed about what exists.** listPublic was
keyed on a registered team provider while findBySlug is not, so with no module
installed `/teams` returned an empty list while `/teams/:slug/members` served a
full roster -- the index denying a Team that direct URLs answered for in full.
The rows are core's and they outlive the module that filled them: an uninstalled
module leaves a projection that is unmaintained, not one that stopped existing,
and `configured: false` is how a client learns that. The read side no longer
takes the provider into account at all. There is now a test named for the
property.

Also verified live: the public routes answer anonymously, an unknown and a hidden
slug both 404, the player and admin tiers 401 an anonymous caller, a seeded
roster projects correctly, and the reconciler logs that it is staying idle with
no provider registered rather than failing a boot.

Process obligations, all done: #swagger.* annotations on every route, `npm run
swagger` regenerated (18 paths in the spec, no dangling $refs, and the schemas
they reference added), `npm run routes:manifest` regenerated -- additions only,
184 public routes -- and BACKEND_DESIGN.md updated across the schema section and
all three tier tables.

Admin -> Teams follows the ModulesAdmin precedent: everything that decides what a
row SAYS lives in lib/teamAdmin.js, which is plain JS with tests, and the view
renders it. That split earns itself here specifically -- the screen's job is to
make "the shard has no Teams" and "core has not been able to ask for two hours"
impossible to confuse, and those two produce the same empty table. The four
freshness states are named and tested for exactly that reason, and the last
provider error is shown verbatim rather than paraphrased.

The button labels follow the caller's role: a moderator sees "Request publish",
so the pending result is not a surprise. Hiding is offered to everyone with no
gate, matching the server.

Server 894 passed, client 206 passed, client build clean. 17 route tests, 20
client display tests.

Refs docs/website/TEAMS.md §2.11, Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:27:02 -05:00
parent 8fe2e01466
commit cf2666e5bc
22 changed files with 5763 additions and 0 deletions

View File

@@ -927,6 +927,310 @@ const doc = {
removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true },
},
},
// ── Teams (docs/website/TEAMS.md) ────────────────────────────────────
OkResponse: {
type: 'object',
properties: { ok: { type: 'boolean', example: true } },
},
TeamSyncFreshness: {
type: 'object',
description:
'Freshness of core\'s projection of the game\'s Teams. Carried on every public Team payload so a page can say how recently the roster was confirmed rather than presenting stale data as current. `configured` is false when no module supplies a Team provider — a deployment with no game module is not a stale one.',
properties: {
configured: { type: 'boolean', example: true },
stale: {
type: 'boolean',
description: 'True past twice the reconcile interval, or when the projection has never synced at all.',
example: false,
},
lastSyncAt: { type: 'string', format: 'date-time', nullable: true },
consecutiveFailures: { type: 'integer', example: 0 },
},
},
PublicTeam: {
type: 'object',
description:
'A Team as an anonymous caller sees it. `name` is what is DISPLAYED — a staff display-name override, when one is set — never the frozen identity behind it.',
properties: {
slug: { type: 'string', example: 'the-silver-hand' },
name: { type: 'string', example: 'The Silver Hand' },
abbr: { type: 'string', nullable: true, example: 'TSH' },
memberCount: { type: 'integer', example: 42 },
linkedCount: { type: 'integer', description: 'Members with a linked site account.', example: 11 },
onlineCount: { type: 'integer', example: 3 },
meta: { type: 'object', nullable: true, additionalProperties: true, description: 'Module-supplied and opaque to core.' },
status: { type: 'string', enum: ['active', 'archived'] },
createdAt: { type: 'string', format: 'date-time' },
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
archivedAt: { type: 'string', format: 'date-time', nullable: true },
archivedReason: { type: 'string', nullable: true, example: 'renamed' },
successor: {
type: 'object',
nullable: true,
description: 'Where an archived Team continued after a rename, so an old link explains itself instead of 404ing.',
properties: { slug: { type: 'string' }, name: { type: 'string' } },
},
},
},
PublicTeamList: {
type: 'object',
allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }],
properties: {
teams: { type: 'array', items: { $ref: '#/components/schemas/PublicTeam' } },
total: { type: 'integer', example: 12 },
},
},
PublicTeamMember: {
type: 'object',
description:
'A roster row as an anonymous caller sees it. The member key is a game-internal identifier and the user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.',
properties: {
displayName: { type: 'string', nullable: true, example: 'Aldric' },
rankLabel: { type: 'string', nullable: true, description: 'Module vocabulary, opaque to core.', example: 'Warlord' },
isLeader: { type: 'boolean', example: true },
online: { type: 'boolean', example: false },
linked: { type: 'boolean', example: true },
},
},
PublicTeamRoster: {
type: 'object',
allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }],
properties: {
members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } },
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
PlayerTeamList: {
type: 'object',
allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }],
properties: {
teams: {
type: 'array',
items: {
allOf: [{ $ref: '#/components/schemas/PublicTeam' }],
type: 'object',
properties: {
reason: {
type: 'string',
enum: ['membership', 'grant', 'both'],
description: 'Which authority path lists this Team for the caller. `both` is a real state and is kept: membership is the current reason while the grant survives as audit history.',
},
isLeader: { type: 'boolean' },
},
},
},
},
},
PlayerTeamAccess: {
type: 'object',
properties: {
slug: { type: 'string' },
allowed: { type: 'boolean' },
viaMembership: { type: 'boolean' },
viaGrant: { type: 'boolean', description: 'Reported even when membership also holds.' },
isLeader: { type: 'boolean', description: 'The synced value with any staff override applied.' },
},
},
AdminTeam: {
type: 'object',
description: 'The full staff view, including what a staff decision overrode.',
properties: {
id: { type: 'integer' },
moduleId: { type: 'string', example: 'uo' },
externalId: { type: 'string', description: 'The module\'s own stable id, opaque to core.' },
slug: { type: 'string' },
name: { type: 'string', description: 'The frozen identity. Immutable for the life of the row.' },
displayName: { type: 'string', description: 'What is rendered — the override when set, otherwise `name`.' },
displayNameOverride: { type: 'string', nullable: true },
abbr: { type: 'string', nullable: true },
status: { type: 'string', enum: ['active', 'archived'] },
hidden: { type: 'boolean' },
hiddenReason: { type: 'string', nullable: true, enum: ['reserved_name', 'staff', null] },
hiddenTerm: { type: 'string', nullable: true, description: 'Which reserved term matched.', example: 'admin' },
nameReviewedAt: { type: 'string', format: 'date-time', nullable: true, description: 'Set once a human has ruled on the name; a later sweep never re-hides it.' },
memberCount: { type: 'integer' },
linkedCount: { type: 'integer' },
onlineCount: { type: 'integer' },
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
membersEmptySince: { type: 'string', format: 'date-time', nullable: true, description: 'The per-Team empty-roster quarantine.' },
succeededBy: { type: 'integer', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
archivedAt: { type: 'string', format: 'date-time', nullable: true },
archivedReason: { type: 'string', nullable: true },
meta: { type: 'object', nullable: true, additionalProperties: true },
members: { type: 'array', items: { $ref: '#/components/schemas/AdminTeamMember' } },
grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } },
pendingRequests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } },
},
},
AdminTeamMember: {
type: 'object',
properties: {
memberKey: { type: 'string', example: '0x40012ab3' },
displayName: { type: 'string', nullable: true },
userId: { type: 'integer', nullable: true, description: 'Resolved by the module; null means unlinked.' },
rankLabel: { type: 'string', nullable: true },
isLeader: { type: 'boolean', description: 'The resolved answer — synced value with any override applied.' },
isLeaderSynced: { type: 'boolean', description: 'What the game actually said, so an override reads as a decision rather than as fact.' },
leaderOverride: {
type: 'object',
nullable: true,
properties: {
effect: { type: 'string', enum: ['grant', 'deny'] },
reason: { type: 'string', nullable: true },
by: { type: 'string', nullable: true },
at: { type: 'string', format: 'date-time' },
},
},
online: { type: 'boolean' },
status: { type: 'string', enum: ['active', 'departed'] },
firstSeenAt: { type: 'string', format: 'date-time' },
lastSeenAt: { type: 'string', format: 'date-time' },
departedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
AdminTeamList: {
type: 'object',
allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }],
properties: {
teams: { type: 'array', items: { $ref: '#/components/schemas/AdminTeam' } },
syncState: {
type: 'object',
nullable: true,
description: 'The module\'s sync row verbatim, including the last error — what an operator debugging a stale projection needs.',
properties: {
moduleId: { type: 'string' },
lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
lastSuccessAt: { type: 'string', format: 'date-time', nullable: true },
consecutiveFailures: { type: 'integer' },
lastError: { type: 'string', nullable: true },
pendingEmptySince: { type: 'string', format: 'date-time', nullable: true },
},
},
},
},
TeamGrant: {
type: 'object',
description:
'One row of the append-only forum grant/revoke ledger. The username snapshots keep the record readable after an account is deleted — the ids go SET NULL, the audit trail does not.',
properties: {
id: { type: 'integer' },
team_id: { type: 'integer' },
user_id: { type: 'integer', nullable: true },
username: { type: 'string', nullable: true },
granted_by: { type: 'integer', nullable: true },
granted_username: { type: 'string', nullable: true },
granted_at: { type: 'string', format: 'date-time' },
reason: { type: 'string', nullable: true },
revoked_by: { type: 'integer', nullable: true },
revoked_username: { type: 'string', nullable: true },
revoked_at: { type: 'string', format: 'date-time', nullable: true },
revoke_reason: { type: 'string', nullable: true },
},
},
TeamGrantLedger: {
type: 'object',
properties: { grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } } },
},
TeamModerationRequest: {
type: 'object',
properties: {
id: { type: 'integer' },
team_id: { type: 'integer' },
team_name: { type: 'string' },
team_slug: { type: 'string' },
action: { type: 'string', enum: ['unhide', 'display_name_override', 'clear_display_name_override'] },
payload: { type: 'object', nullable: true, additionalProperties: true },
reason: { type: 'string', nullable: true },
requested_by: { type: 'integer', nullable: true },
requested_username: { type: 'string', nullable: true },
requested_at: { type: 'string', format: 'date-time' },
status: { type: 'string', enum: ['pending', 'approved', 'rejected', 'withdrawn'] },
decided_by: { type: 'integer', nullable: true },
decided_username: { type: 'string', nullable: true },
decided_at: { type: 'string', format: 'date-time', nullable: true },
decision_note: { type: 'string', nullable: true },
},
},
TeamRequestQueue: {
type: 'object',
properties: { requests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } } },
},
TeamReviewQueue: {
type: 'object',
description: 'Teams auto-hidden by reserved-name screening and not yet ruled on by a human.',
properties: {
teams: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string', example: 'Admin' },
slug: { type: 'string' },
hidden_term: { type: 'string', example: 'admin' },
display_name_override: { type: 'string', nullable: true },
member_count: { type: 'integer' },
created_at: { type: 'string', format: 'date-time' },
},
},
},
},
},
TeamModerationResult: {
type: 'object',
description:
'The outcome of a gated action. `pending: true` means a moderator filed a request and nothing changed publicly; an admin\'s call applies at once and reports false.',
properties: {
ok: { type: 'boolean' },
pending: { type: 'boolean', example: false },
requestId: { type: 'integer', nullable: true },
},
},
TeamResyncResult: {
type: 'object',
description:
'A reconciliation outcome. `ok: false` carries the provider\'s own reason and means nothing was written. `quarantined` means an authoritative-but-empty answer was held back for confirmation rather than applied.',
properties: {
ok: { type: 'boolean' },
reason: { type: 'string', nullable: true },
quarantined: { type: 'boolean', nullable: true },
created: { type: 'integer', nullable: true },
renamed: { type: 'integer', nullable: true },
archived: { type: 'integer', nullable: true },
rosters: { type: 'integer', nullable: true, description: 'Rosters actually applied; a refused one is left untouched and not counted.' },
rehidden: { type: 'integer', nullable: true },
},
},
TeamReasonRequest: {
type: 'object',
properties: { reason: { type: 'string', maxLength: 255, example: 'impersonates staff' } },
},
TeamDisplayNameRequest: {
type: 'object',
properties: {
displayName: { type: 'string', nullable: true, maxLength: 160, description: 'Empty or null clears the override.', example: 'The Old Guard' },
reason: { type: 'string', maxLength: 255 },
},
},
TeamLeaderOverrideRequest: {
type: 'object',
required: ['memberKey', 'effect'],
properties: {
memberKey: { type: 'string', maxLength: 191, example: '0x40012ab3' },
effect: { type: 'string', enum: ['grant', 'deny'] },
reason: { type: 'string', maxLength: 255 },
},
},
TeamDecideRequest: {
type: 'object',
required: ['status'],
properties: {
status: { type: 'string', enum: ['approved', 'rejected'] },
note: { type: 'string', maxLength: 255 },
},
},
},
},
}