The roster sync tickles at most ONCE per stream per run, not once per member: a tickle is content-free, so five people joining in one sweep is five identical notifications and one piece of information. Suppressed on a Team's FIRST roster, the same condition the activity feed uses and the half where it matters more — importing a 155-member guild would otherwise wake every one of their phones. Forum notifications fire from the CONTROLLER, not from the forum model. That file takes an already-resolved access decision and reads no membership table by design; the fan-out reads both to compute its recipients, so calling it from inside would make the forum model transitively depend on exactly what its header says it must not touch. The model returns a `notify` key the controller destructures out before the response, so the API's answer to "did my post save" is unchanged. `pageUrlTemplate` joins the team provider — the one thing phase 6 found that the design of record had not anticipated. Phase 3 left core with no Team page and therefore no way to LINK to one, so a notification email could name a Team and not take you to it. It is data rather than a callback: a function would put a module hook on the mail path to produce a string that never varies. Relative paths only, and protocol-relative is refused with absolute. The unsubscribe endpoint is the only write in the public tier and the only route with no `siteMode` — the reader is in their mail client, and the mail went out before the site went into maintenance. POST always answers 200, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET redirects and acts on nothing, so a mail client's link scanner cannot mute Teams nobody asked to leave. Co-Authored-By: Claude <noreply@anthropic.com>
128 lines
8.9 KiB
JavaScript
128 lines
8.9 KiB
JavaScript
// Public · Teams — the anonymous Team surface (TEAMS.md §2.11).
|
||
//
|
||
// Mounted at /api/v1/public/teams by public/index.js. No group gate: this is the
|
||
// anonymous surface, and `siteMode` is applied per route as everywhere else in
|
||
// this tier — during maintenance only an admin with a valid session sees content.
|
||
//
|
||
// Declaration order: '/' is literal and precedes the two :slug routes, and
|
||
// '/:slug/members' is deeper than '/:slug', so nothing here can shadow anything
|
||
// else.
|
||
|
||
const express = require('express')
|
||
|
||
const ctrl = require('./teams.controller')
|
||
const siteMode = require('../../../middleware/siteMode')
|
||
const { optionalAuth } = require('../../../auth/session.middleware')
|
||
|
||
const teamsRouter = express.Router()
|
||
|
||
teamsRouter.get(
|
||
'/',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'List active, publicly visible Teams'
|
||
// #swagger.description = 'Teams hidden by reserved-name screening or by staff are absent. The response carries { stale, lastSyncAt } so a client can say how recently the projection was confirmed against the game.'
|
||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 200 (default 50).' }
|
||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||
/* #swagger.responses[200] = { description: 'Publicly visible Teams, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamList" } } } } */
|
||
siteMode,
|
||
ctrl.listTeams,
|
||
)
|
||
|
||
// Declared before the ':slug' routes. It cannot be shadowed by them — it has
|
||
// three path segments and they have one or two — but keeping it above makes the
|
||
// relationship visible to whoever adds the next route here.
|
||
teamsRouter.get(
|
||
'/by-external/:moduleId/:externalId',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'Get one Team by the owning module’s own identifier'
|
||
// #swagger.description = 'Exists so a module’s page can find core’s Team without holding core’s identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module, so the scope is what stops one module reading another’s Team by guessing a serial. A hidden Team returns 404, like every other public lookup.'
|
||
// #swagger.parameters['moduleId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The module that owns the Team.' }
|
||
// #swagger.parameters['externalId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'That module’s own identifier for it.' }
|
||
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeam" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
siteMode,
|
||
ctrl.getTeamByExternalId,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/:slug',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'Get one Team by slug'
|
||
// #swagger.description = 'An archived Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A hidden Team returns 404, indistinguishable from one that does not exist.'
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeam" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
siteMode,
|
||
ctrl.getTeam,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/:slug/members',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'Get a Team roster'
|
||
// #swagger.description = 'In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published, whatever the module’s projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the module’s audience projection; sending a session is optional and may widen it.'
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'The roster, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamRoster" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
siteMode,
|
||
optionalAuth,
|
||
ctrl.getRoster,
|
||
)
|
||
|
||
// The one route in this tier that reads the caller's identity. `optionalAuth`
|
||
// serves anonymous callers rather than rejecting them, and identifies an
|
||
// authenticated one properly enough that a banned or logged-out account drops
|
||
// back to the public half of the feed at once (TEAMS.md §4.3).
|
||
teamsRouter.get(
|
||
'/:slug/activity',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'A Team’s activity feed, filtered to what the caller may see'
|
||
// #swagger.description = 'Items are `public` or `members`. Anyone who can see the Team gets the public ones; members and forum-granted users also get the members-only ones, and the response says which via `scope` so a client can render "some items are hidden" rather than presenting a filtered feed as the whole one. Sending a session is optional.'
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 100 (default 50).' }
|
||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'One page of the feed', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamActivity" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden from this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
siteMode,
|
||
optionalAuth,
|
||
ctrl.getActivity,
|
||
)
|
||
|
||
// ── One-click unsubscribe (TEAMS.md §6.4) ──────────────────────────────────
|
||
//
|
||
// Declared last, and the shadowing question is worth answering rather than
|
||
// assuming: these are two segments, so the one-segment '/:slug' cannot take them,
|
||
// and the two-segment '/:slug/members' and '/:slug/activity' both pin a LITERAL
|
||
// second segment. Only a token spelled exactly "members" or "activity" could
|
||
// collide, and a token is `<v>.<uid>.<tid>.<mac>`.
|
||
//
|
||
// No `siteMode`, unlike every other route in this file. An unsubscribe has to work
|
||
// while the site is in maintenance: the mail that carried the link went out before
|
||
// the site went down, and "we are doing maintenance" is not an answer to "stop
|
||
// emailing me".
|
||
teamsRouter.post(
|
||
'/unsubscribe/:token',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'Unsubscribe from one Team’s notification emails'
|
||
// #swagger.description = 'Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.'
|
||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||
// #swagger.security = [{}]
|
||
/* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||
ctrl.unsubscribe,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/unsubscribe/:token',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'Land a human on the unsubscribe page'
|
||
// #swagger.description = 'For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the site’s own confirmation page and changes nothing — a GET must not mutate, or a link scanner would mute Teams nobody asked to leave.'
|
||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||
// #swagger.security = [{}]
|
||
/* #swagger.responses[302] = { description: 'Redirect to the site’s unsubscribe page' } */
|
||
ctrl.unsubscribeLanding,
|
||
)
|
||
|
||
module.exports = teamsRouter
|