// 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 `...`. // // 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