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>
152 lines
6.3 KiB
JavaScript
152 lines
6.3 KiB
JavaScript
// Public · Teams — the anonymous read surface (TEAMS.md §2.11).
|
|
//
|
|
// Every handler here is a projection over core's own tables; nothing calls the
|
|
// module. A Team page must render while the shard is down, showing a roster
|
|
// marked stale, because that is what the projection is for.
|
|
|
|
const teams = require('../../../model/teams/teams.model')
|
|
const teamActivity = require('../../../model/teams/teamActivity.model')
|
|
const teamPrefs = require('../../../model/teams/teamNotify.model')
|
|
const unsubscribeToken = require('../../../utils/unsubscribeToken')
|
|
|
|
const log = require('../../../utils/logger')('teams')
|
|
|
|
const fail = (res, err, what) => {
|
|
log.error(`public teams: ${what} failed`, { message: err.message })
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
|
|
async function listTeams(req, res) {
|
|
try {
|
|
const limit = Math.min(Number.parseInt(req.query.limit, 10) || 50, 200)
|
|
const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0)
|
|
return res.json(await teams.listPublic({ limit, offset }))
|
|
} catch (err) {
|
|
return fail(res, err, 'list')
|
|
}
|
|
}
|
|
|
|
async function getTeam(req, res) {
|
|
try {
|
|
const team = await teams.getPublic(req.params.slug)
|
|
// A hidden Team is indistinguishable from a missing one here, deliberately:
|
|
// "absent from every public surface" includes not confirming it exists.
|
|
if (!team) return res.status(404).json({ message: 'Team not found' })
|
|
return res.json(team)
|
|
} catch (err) {
|
|
return fail(res, err, 'get')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One Team, named the way the calling MODULE names it (§3.4 as amended).
|
|
*
|
|
* The one route that exists purely so a module's page can find core's Team
|
|
* without holding core's identifiers. Both parameters come from the path and the
|
|
* module id is MATCHED, not trusted: `external_id` is unique only within a
|
|
* module, so scoping the lookup is what stops one module reading another's Team
|
|
* by guessing a serial.
|
|
*/
|
|
async function getTeamByExternalId(req, res) {
|
|
try {
|
|
const team = await teams.getPublicByExternalId(req.params.moduleId, req.params.externalId)
|
|
if (!team) return res.status(404).json({ message: 'Team not found' })
|
|
return res.json(team)
|
|
} catch (err) {
|
|
return fail(res, err, 'by-external')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The roster, projected for whoever is asking (§3.3).
|
|
*
|
|
* The viewer is described to the module rather than handed over: it gets the
|
|
* caller's id and role, which is what a rung decision turns on, and not the user
|
|
* row — a module has `ctx.users.getById` if it needs more, and passing the whole
|
|
* record here would make every column of `users` part of this contract.
|
|
*/
|
|
async function getRoster(req, res) {
|
|
try {
|
|
const viewer = req.user ? { userId: req.user.id, role: req.user.role } : null
|
|
const roster = await teams.rosterPublic(req.params.slug, viewer)
|
|
if (!roster) return res.status(404).json({ message: 'Team not found' })
|
|
return res.json(roster)
|
|
} catch (err) {
|
|
return fail(res, err, 'roster')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A Team's activity feed (§4.3).
|
|
*
|
|
* The only handler in this tier that reads `req.user`, and it reads nothing else
|
|
* from the caller about what they may see: `limit` and `offset` are page
|
|
* controls, and the visibility filter is resolved from the session alone. A
|
|
* request parameter naming its own visibility is the bug the ENUM exists to
|
|
* prevent, so there is deliberately no way to ask for one.
|
|
*
|
|
* The cap is 100 rather than the index's 200 — every row carries a summary and an
|
|
* opaque payload, so a page of these is much larger than a page of Teams.
|
|
*/
|
|
async function getActivity(req, res) {
|
|
try {
|
|
const limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || 50, 1), 100)
|
|
const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0)
|
|
const feed = await teamActivity.feedFor(req.params.slug, req.user ? req.user.id : null, { limit, offset })
|
|
if (!feed) return res.status(404).json({ message: 'Team not found' })
|
|
return res.json(feed)
|
|
} catch (err) {
|
|
return fail(res, err, 'activity')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /public/teams/unsubscribe/:token — one-click unsubscribe (TEAMS.md §6.4).
|
|
*
|
|
* **The one write in this tier, and it is unauthenticated on purpose.** A person
|
|
* reading their mail is not logged into the site, and an unsubscribe that first
|
|
* demands a login is an unsubscribe most people do not complete. The token is what
|
|
* stands in for the session, and the capability it carries is deliberately the
|
|
* narrowest one that does the job: set `muted` for ONE (user, Team) pair. It reads
|
|
* nothing, cannot un-mute, and names no other Team.
|
|
*
|
|
* **Always 200, whatever the token was.** A response that distinguished a valid
|
|
* token from a forged one would turn this into an oracle for which (user, Team)
|
|
* pairs exist, on an endpoint with no session behind it. The page says "you will
|
|
* not receive further emails about this team" either way, which is true either way.
|
|
*
|
|
* Reached two ways with the same effect: a mail client's RFC 8058 one-click POST
|
|
* (the `List-Unsubscribe-Post` header), and the site's own /unsubscribe page,
|
|
* which POSTs here after a human clicks the link in the body.
|
|
*/
|
|
async function unsubscribe(req, res) {
|
|
const claim = unsubscribeToken.verify(req.params.token)
|
|
if (claim) {
|
|
try {
|
|
await teamPrefs.mute(claim.userId, claim.teamId)
|
|
} catch (err) {
|
|
// Logged, not surfaced. A failed write here is worth an operator's
|
|
// attention and is not worth telling an anonymous caller about — and a 500
|
|
// would make a mail client retry a request it should not repeat.
|
|
log.error('unsubscribe', err)
|
|
}
|
|
}
|
|
return res.json({ ok: true })
|
|
}
|
|
|
|
/**
|
|
* GET on the same path — for a mail client that shows the `List-Unsubscribe` URL
|
|
* as a link and has no one-click support.
|
|
*
|
|
* Redirects to the site's own page rather than acting, because a GET must not
|
|
* mutate: a link prefetcher or a mail client's link scanner would otherwise
|
|
* silently mute Teams nobody asked to leave. The page it lands on does the POST
|
|
* once a human is looking at it.
|
|
*/
|
|
function unsubscribeLanding(req, res) {
|
|
const base = (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
|
return res.redirect(302, `${base}/unsubscribe/${encodeURIComponent(req.params.token)}`)
|
|
}
|
|
|
|
module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity, unsubscribe, unsubscribeLanding }
|