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:
2026-08-18 14:34:54 -05:00
parent 686a214979
commit 2a56cbf22a
12 changed files with 651 additions and 7 deletions

View File

@@ -8364,6 +8364,114 @@
}
}
},
"/api/v1/auth/me/notifications/teams": {
"get": {
"tags": [
"Auth · Me"
],
"summary": "Get the current users per-Team notification preferences",
"description": "One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they have a stored preference for. Defaults are applied server-side: `muted` false, `emailMode` \"off\".",
"responses": {
"200": {
"description": "Per-Team preferences",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TeamNotificationPrefs"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"put": {
"tags": [
"Auth · Me"
],
"summary": "Replace the current users per-Team notification preferences",
"description": "Replaces the whole set. The `teams` array is required even when empty. Entries naming a Team the caller has no access to are ignored; the stored set is echoed back.",
"responses": {
"200": {
"description": "Updated preferences",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TeamNotificationPrefs"
}
}
}
},
"400": {
"description": "Validation error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TeamNotificationPrefs"
}
}
}
}
}
},
"/api/v1/auth/me/sessions": {
"get": {
"tags": [
@@ -12070,6 +12178,67 @@
}
}
},
"/api/v1/public/teams/unsubscribe/{token}": {
"post": {
"tags": [
"Public · Teams"
],
"summary": "Unsubscribe from one Teams notification emails",
"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.",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The signed token from the email link."
}
],
"responses": {
"200": {
"description": "Acknowledged",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OkFlag"
}
}
}
}
},
"security": [
{}
]
},
"get": {
"tags": [
"Public · Teams"
],
"summary": "Land a human on the unsubscribe page",
"description": "For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the sites own confirmation page and changes nothing — a GET must not mutate, or a link scanner would mute Teams nobody asked to leave.",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The signed token from the email link."
}
],
"responses": {
"302": {
"description": "Redirect to the sites unsubscribe page"
}
},
"security": [
{}
]
}
},
"/api/v1/public/teams/{slug}": {
"get": {
"tags": [
@@ -16081,6 +16250,143 @@
}
}
},
"TeamNotificationPref": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`)."
},
"properties": {
"type": "object",
"properties": {
"teamId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3
}
}
},
"slug": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "the-silver-hand"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "The Silver Hand"
}
}
},
"archived": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"muted": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"emailMode": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"off",
"digest",
"immediate"
],
"items": {
"type": "string"
}
},
"example": {
"type": "string",
"example": "off"
}
}
}
}
}
}
},
"TeamNotificationPrefs": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Per-Team notification preferences (used for both GET and PUT). The `teams` array is required on PUT even when empty."
},
"properties": {
"type": "object",
"properties": {
"teams": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"$ref": "#/components/schemas/TeamNotificationPref"
}
}
}
}
}
}
},
"Appeal": {
"type": "object",
"properties": {