feat(teams): fire the four events, and the routes that configure them
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>
This commit is contained in:
@@ -255,6 +255,10 @@ function checkLegShape(entry) {
|
||||
// implementing it means core fails CLOSED when the call cannot be made, so this
|
||||
// is a member to add deliberately rather than by habit.
|
||||
//
|
||||
// `pageUrlTemplate` is the fifth, also OPTIONAL, and is data rather than a method
|
||||
// — see its own comment below. A module that omits it costs its deployment
|
||||
// clickable links in Team notification email and nothing else.
|
||||
//
|
||||
// The copy is explicit rather than a spread: this object is what core calls, so
|
||||
// anything not named here is not part of the contract and must not survive
|
||||
// registration. A method that silently rode along would look implemented from the
|
||||
@@ -274,9 +278,43 @@ function checkTeamProviderShape(entry) {
|
||||
}
|
||||
out.projectRoster = provider.projectRoster
|
||||
}
|
||||
if (provider.pageUrlTemplate !== undefined) {
|
||||
out.pageUrlTemplate = checkPageUrlTemplate(provider.pageUrlTemplate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// `pageUrlTemplate` is the fifth member and OPTIONAL (TEAMS.md §6.4, phase 6).
|
||||
//
|
||||
// **Why a module has to supply this at all.** Teams are a contract primitive with
|
||||
// no core surface: core owns the tables and the access rules, and the MODULE owns
|
||||
// the page, because core does not own the word for a Team. That is settled and
|
||||
// right — but it leaves core unable to write a link to one, and a notification
|
||||
// email that cannot link to the thread it is about is most of the way to useless.
|
||||
// So the module that owns the page says where it is.
|
||||
//
|
||||
// **A template, not a callback.** Core substitutes `{externalId}` and `{slug}`
|
||||
// into a relative path and does nothing else with it. A function would be a
|
||||
// module hook on the mail path — one more thing that can hang or throw between a
|
||||
// forum reply and the mail about it — to produce a string that never varies.
|
||||
//
|
||||
// Validated hard, because the output goes into an email as a link. Relative only:
|
||||
// a template naming its own host would let a module redirect the site's outbound
|
||||
// mail somewhere else, and there is no reason for one to.
|
||||
// One leading slash, and the second character may not be another. `//evil.test/x`
|
||||
// passes an "is it rooted" check and is a PROTOCOL-RELATIVE url — core prefixing
|
||||
// its own base makes it harmless today, but a template is a string that ends up
|
||||
// in an href sooner or later, and this is a character class rather than a
|
||||
// judgement call about who concatenates it.
|
||||
const PAGE_URL_TEMPLATE = /^\/(?!\/)[A-Za-z0-9\-._~/{}]*$/
|
||||
|
||||
function checkPageUrlTemplate(value) {
|
||||
if (typeof value !== 'string' || !PAGE_URL_TEMPLATE.test(value)) {
|
||||
throw new Error(`registerTeamProvider: pageUrlTemplate must be a relative path, got "${value}"`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
|
||||
* required. A registration with neither is a subscription that can never fire,
|
||||
|
||||
Reference in New Issue
Block a user