feat(teams): the roster's audience projection, and optionalAuth to resolve it

TEAMS.md §3.3, as the eighth member of MODULE_API 1.6.0 — amended in place per
the org lead, on the rule Protocol 4 was given in phase 2: a contract owes a
bump only once it has landed on `main`.

Two questions meet on the roster and they belong to different owners. WHICH
ROWS a viewer may see is the module's, because the audience rungs and their
configuration live there and core does not know what a rung is. WHAT A ROW
LOOKS LIKE stays core's.

So `projectRoster` answers with member KEYS, not rows. §3.3 said rows, and rows
would let a module widen what is published — handing back a `userId` core had
withheld — leaving core's field guarantee resting on every module's good
behaviour. Core asks which rows and re-normalises the answer through its own
public shape, so a module can narrow and cannot widen.

"The module declines" needed splitting before it could be implemented. No
module at all and a module whose rungs could not be consulted are opposite
situations: the first withholds nothing and must serve the roster whole, the
second must serve none of it. The refusal carries `projects`, and only
`projects: true` fails closed. Without the split, bare core serves an empty
roster on every Team page.

This is also the first public route whose CONTENT depends on identity, which
needed a middleware core did not have. `attachSession` only decodes a token, so
a banned account, a password change or a logout would have kept working against
the private half of a feed until the JWT expired. `optionalAuth` runs
requireAuth's full database re-validation and, on any failure, continues
ANONYMOUSLY rather than rejecting — a caller whose session is no longer good
sees the public view, which is what they are entitled to.

`GET /public/teams/:slug/activity` lands here for the same reason: §2.11's route
table had no activity endpoint though §4.3 describes a filtered feed. Paged,
with the visibility resolved from the session and never from a parameter.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:15:36 -05:00
parent aa332eda82
commit 03631d7d40
9 changed files with 486 additions and 7 deletions

View File

@@ -979,6 +979,12 @@ const doc = {
properties: {
teams: { type: 'array', items: { $ref: '#/components/schemas/PublicTeam' } },
total: { type: 'integer', example: 12 },
enabled: {
type: 'boolean',
description:
'Whether this deployment has Teams at all — a provider is registered, or Teams exist from one that since went away. The `teams` nav feature flag resolves from this; false means bare core, where a Teams link would lead to a permanently empty page.',
example: true,
},
},
},
PublicTeamMember: {
@@ -999,6 +1005,41 @@ const doc = {
properties: {
members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } },
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
projected: {
type: 'boolean',
description:
'Whether the module applied its own audience projection to this roster. False means the module declined or does not project, and the roster was served at cores public shape — never the full one.',
example: true,
},
},
},
PublicTeamActivityItem: {
type: 'object',
description:
'`summary` is already-rendered text supplied by whoever pushed the item; core never composes one. `kind` and `payload` are opaque to core — only the modules `team.overview` slot renders anything richer than the text.',
properties: {
id: { type: 'integer', example: 4821 },
source: { type: 'string', description: '`core` or a module id.', example: 'uo' },
kind: { type: 'string', example: 'uo.champion.completed' },
summary: { type: 'string', example: 'Completed Champion Neira' },
visibility: { type: 'string', enum: ['public', 'members'] },
occurredAt: { type: 'string', format: 'date-time' },
payload: { type: 'object', nullable: true, additionalProperties: true },
},
},
PublicTeamActivity: {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamActivityItem' } },
total: { type: 'integer', description: 'Matching rows for THIS callers visibility, so paging is honest.', example: 137 },
limit: { type: 'integer', example: 50 },
offset: { type: 'integer', example: 0 },
scope: {
type: 'string',
enum: ['public', 'members'],
description:
'Which visibilities this caller received. `public` means members-only items were withheld — render that fact rather than presenting a filtered feed as the whole one.',
},
},
},
PlayerTeamList: {