feat(teams): the activity feed, the roster projection, and the inverted slot #152

Merged
whitlocktech merged 5 commits from feat/teams-phase3-pages-activity into edge 2026-08-18 02:11:08 +00:00
30 changed files with 2524 additions and 21 deletions

View File

@@ -133,6 +133,25 @@ export const api = {
return req(`/public/wiki${withQs(s)}`)
},
wikiCategories: () => req('/public/wiki/categories'),
// ----- Teams (TEAMS.md §2.11, §4.3) -----
//
// Only the two calls CORE's own client makes. Core renders no Team pages — the
// vocabulary belongs to whichever module owns the surface — so the index, the
// roster and the player list are not here; a module that renders those calls
// the same public API from its own client.
//
// The lookup exists because a module names a Team in its own terms and core
// keys the feed by slug. Resolving that is core's job precisely so a module
// never has to hold core's identifiers.
teamByExternalId: (moduleId, externalId) =>
req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`),
teamActivity: (slug, opts = {}) => {
const qs = new URLSearchParams()
if (opts.limit != null) qs.set('limit', String(opts.limit))
if (opts.offset != null) qs.set('offset', String(opts.offset))
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
},
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link

View File

@@ -0,0 +1,100 @@
// What core's Team activity feed SAYS, separated from how it renders
// (docs/website/TEAMS.md §4.3).
//
// Core renders this feed into a slot a MODULE declares on its own page, because
// Teams is a contract primitive and not a surface: core owns the feed, its
// visibility rules and its wording; the module owns the page and the vocabulary
// around it. So this file is deliberately narrow — the roster and index
// presentation that once lived here went with the core Team pages, to whichever
// module renders them.
//
// Plain JS with tests, following lib/teamAdmin.js. Worth splitting for the same
// reason it was there: a feed that is filtered, or a projection that is stale,
// has to say so in words, and getting that wording right is logic rather than
// markup.
const MINUTE = 60_000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */
export function relativeTime(when, now = Date.now()) {
if (!when) return null
const ms = now - new Date(when).getTime()
if (!Number.isFinite(ms)) return null
if (ms < MINUTE) return 'just now'
if (ms < HOUR) {
const n = Math.floor(ms / MINUTE)
return `${n} ${n === 1 ? 'minute' : 'minutes'} ago`
}
if (ms < DAY) {
const n = Math.floor(ms / HOUR)
return `${n} ${n === 1 ? 'hour' : 'hours'} ago`
}
const n = Math.floor(ms / DAY)
return `${n} ${n === 1 ? 'day' : 'days'} ago`
}
/**
* How a public surface describes the projection's freshness (§2.4).
*
* Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
* debugging a sync. A visitor needs one sentence about whether what they are
* looking at is current, and specifically must never be shown an unconfirmed
* empty projection as though it were a confirmed empty shard.
*/
export function freshnessNote(sync = {}, now = Date.now()) {
// Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
// deployment with no game module is not a broken one.
if (!sync.configured) return null
if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' }
const ago = relativeTime(sync.lastSyncAt, now)
if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` }
return { tone: 'idle', text: `Last confirmed ${ago}.` }
}
/**
* Group feed items into days, newest first, preserving order within a day (§4.3).
*
* Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
* property of where the reader is sitting, and a shard's evening raid landing at
* 00:30 UTC belongs on the day the players experienced it.
*/
export function groupByDay(items = [], locale = undefined) {
const days = []
const byKey = new Map()
for (const item of items) {
const date = new Date(item.occurredAt)
if (Number.isNaN(date.getTime())) continue
const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
if (!byKey.has(key)) {
const day = {
key,
label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }),
items: [],
}
byKey.set(key, day)
days.push(day)
}
byKey.get(key).items.push(item)
}
return days
}
/**
* What to say under a feed that has been filtered.
*
* Only when there is something to say: a caller who saw everything is told
* nothing, and an anonymous caller is invited to sign in rather than simply
* informed that entries exist which they cannot have.
*
* The wording avoids core's own noun. The reader is looking at a page the module
* titled — a guild, a clan — and "this Team" would be core's vocabulary leaking
* onto a surface that deliberately does not use it.
*/
export function activityScopeNote(feed = {}, signedIn = false) {
if (feed.scope !== 'public') return null
return signedIn
? 'Some entries are visible to members only.'
: 'Sign in as a member to see the members-only entries.'
}

View File

@@ -3,7 +3,8 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot } from './modules/registry.js'
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -18,8 +19,6 @@ publishSharedDependencies()
// and namespace `uo`, so that the seam was exercised by real content from the
// day it was built. That prediction paid out exactly as written: the extraction
// deleted the registration and the hook it named, and SiteHeader was not touched.
// There is nothing for core to register now — no core nav row carries a
// `feature` — and the filter is a correct no-op until a module supplies one.
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
//
@@ -56,6 +55,26 @@ declareSlot('player.invite.accepted')
// all three, and core's own fills had to go for it to be able to — the first
// fill wins, and core registered first (§3.7).
// ── The inverted direction: core fills a MODULE's slot ─────────────────────
//
// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the
// tables, the sync, the access rules and the activity feed; it does not own the
// word for one — a UO shard says guild, and the module that comes after it will
// say clan. So core publishes no Team page and no Team nav row, and the module
// that owns the vocabulary owns the page.
//
// The activity feed is the one piece of that page core cannot hand over: only
// core can resolve whether this viewer is inside the Team, and the public/members
// split is a security boundary. So the module declares the place and core fills
// it. Registered here, applied at mount — `applyCoreFills` runs after every
// module chunk has evaluated, which is the only moment a module-declared slot
// exists to be filled.
//
// Naming a slot no installed module declares is not an error. On a deployment
// with no game module this fill simply never lands, which is the mirror of an
// unfilled slot rendering nothing.
fillModuleSlot('uo.guild.detail', TeamActivityFeed)
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
@@ -82,6 +101,10 @@ declareSlot('player.invite.accepted')
// static deferred script, so this branch is the genuine "the event has already
// been and gone" case and not a wrong guess about our own timing.
function mount() {
// Every module chunk has evaluated by now, so any slot a module declared is
// present and core's pending fills can land. Must happen before the first
// render: `extensionFor` is read during render and there is no subscription.
applyCoreFills()
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>

View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { activityScopeNote, freshnessNote, groupByDay } from '../lib/teamActivity.js'
// Core's Team activity feed, rendered into a slot a MODULE declares
// (TEAMS.md Part 4, §3.4 as amended).
//
// **This is the inverted slot direction, and this component is why it exists.**
// The feed is core's: core owns `team_activity`, writes the membership and rename
// items into it, enforces the public/members split, and is the only thing that
// can resolve whether this viewer is inside the Team. None of that is a module's
// to reimplement. But the PAGE is the module's, because Teams is a contract
// primitive and core does not own the word for one — a UO shard says guild, the
// next game will say something else. So the module declares the place and core
// puts the feed in it.
//
// The module passes the Team in ITS OWN vocabulary — `externalId` plus its module
// id — and core resolves the slug. A module never learns core's Team id and never
// needs to: it names the thing the way it already names it.
//
// Everything here degrades to rendering nothing. A slot that throws is contained
// by core's own boundary (Slot.jsx), but a slot that renders an error box would
// still be core putting a defect on a page it does not own — so a failed fetch is
// silence, not a message.
export default function TeamActivityFeed({ externalId, moduleId, limit = 25 }) {
const { user } = useAuth()
const [state, setState] = useState({ loading: true, feed: null, team: null })
useEffect(() => {
let active = true
if (!externalId || !moduleId) {
setState({ loading: false, feed: null, team: null })
return undefined
}
// Two calls because the module names the Team its way and the feed is keyed
// by core's slug. The lookup is core's job precisely so the module does not
// have to hold core's identifiers.
api.teamByExternalId(moduleId, externalId)
.then(async (team) => {
const feed = await api.teamActivity(team.slug, { limit })
if (active) setState({ loading: false, feed, team })
})
.catch(() => { if (active) setState({ loading: false, feed: null, team: null }) })
return () => { active = false }
}, [externalId, moduleId, limit])
const { loading, feed, team } = state
if (loading || !feed) return null
const days = groupByDay(feed.items || [])
const note = team ? freshnessNote(team) : null
const scopeNote = activityScopeNote(feed, Boolean(user))
// Nothing has happened and nothing to explain: render nothing rather than an
// empty heading on someone else's page.
if (days.length === 0 && !scopeNote) return null
return (
<section style={{ marginTop: 26 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', marginBottom: 4 }}>
Recent activity
</h2>
{note && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 12px' }}>{note.text}</p>
)}
{days.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.9rem' }}>Nothing has happened here yet.</p>
)}
{days.map((day) => (
<div key={day.key} style={{ marginBottom: 16 }}>
<h3
className="sans dim"
style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 6 }}
>
{day.label}
</h3>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 6 }}>
{day.items.map((item) => (
<li key={item.id} className="sans" style={{ fontSize: '0.92rem', color: 'var(--ink)' }}>
{item.summary}
</li>
))}
</ul>
</div>
))}
{scopeNote && (
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>{scopeNote}</p>
)}
</section>
)
}

View File

@@ -135,6 +135,69 @@ export function declareSlot(name) {
slots.set(name, { Component: null, filledBy: null })
}
/**
* The INVERTED direction: a MODULE declares a slot and CORE fills it.
*
* Added for Teams (TEAMS.md Part 3). The original direction assumes core owns
* the page and a module contributes to it, which is right for the footer and the
* admin user detail. Teams is the other shape: **Teams is a contract primitive,
* not a surface.** Core owns the tables, the sync, the access rules and the
* activity feed; it does not own the vocabulary — a UO shard calls them guilds
* and the next game will call them something else — so the PAGE is the module's
* and the content core contributes to it is core's.
*
* Without this, core would have to publish a `/teams` page under a word it
* invented, next to the module's own Guilds page saying the same thing twice.
*
* A module namespaces its slot under its own id (`uo.guild.detail`), which is
* what stops two modules colliding and what makes the owner readable at the fill
* site. The namespace is enforced rather than conventional.
*
* **Ordering is why this is a separate call and not just `declareSlot` exposed
* to modules.** Core's bundle evaluates BEFORE any module chunk (module scripts
* are deferred and injected after core's), so at the moment core would like to
* fill one of these, it does not exist yet. Core therefore registers its fills
* through `fillModuleSlot` below, which is applied after every module chunk has
* evaluated — see main.jsx.
*/
export function declareModuleSlot(id, name) {
if (!name.startsWith(`${id}.`)) {
throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
}
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
slots.set(name, { Component: null, filledBy: null, declaredBy: id })
}
// Core's pending fills for module-declared slots, applied once every module
// chunk has evaluated. Kept as a list rather than applied eagerly because the
// slot does not exist when core asks — see the ordering note above.
const coreFills = []
/**
* Core: "fill this module-declared slot when it turns up."
*
* Deliberately not an error when the slot never appears. A module that is not
* installed declares nothing, and core offering content for a page that does not
* exist is the ordinary case on any deployment — not a misconfiguration. That is
* the mirror of an unfilled slot rendering nothing.
*/
export function fillModuleSlot(name, Component) {
if (typeof Component !== 'function') throw new Error(`fillModuleSlot: ${name} is not a component`)
coreFills.push([name, Component])
}
/** Apply core's fills. Called once from main.jsx, after module chunks have run. */
export function applyCoreFills() {
for (const [name, Component] of coreFills) {
const entry = slots.get(name)
if (!entry) continue // the declaring module is not installed
if (entry.filledBy) continue // a module already claimed it; first fill wins
entry.Component = Component
entry.filledBy = 'core'
}
coreFills.length = 0
}
/**
* Fill a declared slot with a component.
*
@@ -207,6 +270,7 @@ export function _reset() {
nav[area].length = 0
}
providers.clear()
coreFills.length = 0
// Declarations go too, unlike the server's, where a slot is declared once at
// require time by the router that owns it. Core declares its slots in
// main.jsx — the one file no test loads — so on this side there is nothing
@@ -224,6 +288,8 @@ export const registry = {
registerNav,
registerFeatureProvider,
registerExtension,
// The inverted direction (TEAMS.md Part 3): the module declares, core fills.
declareModuleSlot,
routesFor,
navFor,
featureProviderFor,

View File

@@ -34,6 +34,7 @@ import { MODULE_API_VERSION } from './version.js'
import PublicLayout from '../components/PublicLayout.jsx'
import PageHeader from '../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
import Slot from './Slot.jsx'
import { useAsync } from '../lib/useAsync.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
@@ -66,6 +67,13 @@ const ui = {
useAsync,
useAuth,
useSite,
// The eighth member, for the INVERTED slot direction (TEAMS.md Part 3). A
// module that declares a slot on its own page needs the same component core
// renders its own with — the error boundary in particular, since the thing
// being contained here is CORE's content failing inside the MODULE's page.
// Shared rather than reimplemented for the reason the whole kit exists: two
// boundaries with different behaviour would be two bugs.
Slot,
}
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own

View File

@@ -160,6 +160,9 @@ test('the registry object handed to modules exposes the whole surface', () => {
// window.__rg.registry is the ONLY way a module reaches any of this, so a
// member missing from the object is a member that does not exist.
assert.deepEqual(Object.keys(registry).sort(), [
// `declareModuleSlot` is the INVERTED direction added in 1.6.0: the module
// declares a place on its own page and core fills it (TEAMS.md Part 3).
'declareModuleSlot',
'featureProviderFor',
'navFor',
'registerExtension',

View File

@@ -4,6 +4,9 @@ import assert from 'node:assert/strict'
import {
registry,
declareSlot,
declareModuleSlot,
fillModuleSlot,
applyCoreFills,
registerExtension,
extensionFor,
registeredIds,
@@ -92,3 +95,76 @@ test('declareSlot and extensionFor are not on the module-facing registry', () =>
assert.equal(registry.extensionFor, undefined)
assert.equal(typeof registry.registerExtension, 'function')
})
// ── The INVERTED direction: the module declares, core fills ────────────────
//
// Added in 1.6.0 for Teams (TEAMS.md Part 3). Teams are a core primitive with no
// core surface — core owns the tables and the activity feed, the module owns the
// page and the word "guild" — so the content flows the other way for the first
// time. The rules below are the ones that direction gets wrong.
const Feed = () => null
test('a module-declared slot must be namespaced under the declaring module', () => {
// Enforced rather than conventional: this is the only thing keeping two
// modules from claiming the same slot name.
assert.throws(() => declareModuleSlot('uo', 'guild.detail'), /must be namespaced/)
assert.doesNotThrow(() => declareModuleSlot('uo', 'uo.guild.detail'))
})
test('core fills a module slot only after the module has declared it', () => {
// The ordering that makes this a separate call: core's bundle evaluates BEFORE
// any module chunk, so at the moment core registers its fill the slot does not
// exist yet. Filling eagerly would silently do nothing.
fillModuleSlot('uo.guild.detail', Feed)
assert.equal(extensionFor('uo.guild.detail'), null, 'not filled before the module declared it')
declareModuleSlot('uo', 'uo.guild.detail')
assert.equal(extensionFor('uo.guild.detail'), null, 'and not before the fills are applied')
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), Feed)
})
test('a fill for a slot nobody declared is not an error', () => {
// The module is not installed. Core offering content for a page that does not
// exist is the ordinary case on any deployment, not a misconfiguration — the
// mirror of an unfilled slot rendering nothing.
fillModuleSlot('rust.clan.detail', Feed)
assert.doesNotThrow(() => applyCoreFills())
assert.equal(extensionFor('rust.clan.detail'), null)
})
test('a module that fills its own slot first keeps it', () => {
const Own = () => null
declareModuleSlot('uo', 'uo.guild.detail')
registerExtension('uo', 'uo.guild.detail', Own)
fillModuleSlot('uo.guild.detail', Feed)
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), Own, 'first fill wins, as everywhere else')
})
test('a module-declared slot cannot be declared twice', () => {
declareModuleSlot('uo', 'uo.guild.detail')
assert.throws(() => declareModuleSlot('uo', 'uo.guild.detail'), /already declared/)
})
test('applying the fills twice does not re-fill or throw', () => {
declareModuleSlot('uo', 'uo.guild.detail')
fillModuleSlot('uo.guild.detail', Feed)
applyCoreFills()
assert.doesNotThrow(() => applyCoreFills())
assert.equal(extensionFor('uo.guild.detail'), Feed)
})
test('a non-component fill is refused at the call site, not at render', () => {
assert.throws(() => fillModuleSlot('uo.guild.detail', 'nope'), /is not a component/)
})
test('_reset clears pending fills, so one test cannot leak into the next', () => {
fillModuleSlot('uo.guild.detail', Feed)
_reset()
declareModuleSlot('uo', 'uo.guild.detail')
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), null)
})

View File

@@ -0,0 +1,78 @@
// What core's Team activity feed says (client/src/lib/teamActivity.js).
//
// The test that earns this file: a projection nobody can tell is stale, and a
// feed nobody can tell is filtered, both look like complete information. Every
// case below is about saying which one the reader is looking at.
//
// Note the wording assertions avoid core's own noun. The feed renders inside a
// page a MODULE titled — Guilds today, Clans next — so "this Team" would be
// core's vocabulary leaking onto a surface that deliberately does not use it.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { activityScopeNote, freshnessNote, groupByDay, relativeTime } from '../src/lib/teamActivity.js'
const NOW = new Date('2026-08-17T12:00:00Z').getTime()
const ago = (ms) => new Date(NOW - ms).toISOString()
test('a deployment with no provider is not stale, it is uninvolved', () => {
assert.equal(freshnessNote({ configured: false }, NOW), null)
})
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
assert.equal(note.tone, 'warn')
assert.match(note.text, /Not yet confirmed/)
})
test('a stale projection says how old it is and that the game may have moved on', () => {
const note = freshnessNote({ configured: true, lastSyncAt: ago(14 * 60_000), stale: true }, NOW)
assert.equal(note.tone, 'warn')
assert.equal(note.text, 'Last confirmed 14 minutes ago — the game may have moved on.')
})
test('a current projection is stated quietly', () => {
const note = freshnessNote({ configured: true, lastSyncAt: ago(90_000), stale: false }, NOW)
assert.equal(note.tone, 'idle')
assert.equal(note.text, 'Last confirmed 1 minute ago.')
})
test('relative time singularises and steps through the units', () => {
assert.equal(relativeTime(ago(5_000), NOW), 'just now')
assert.equal(relativeTime(ago(60_000), NOW), '1 minute ago')
assert.equal(relativeTime(ago(3 * 3_600_000), NOW), '3 hours ago')
assert.equal(relativeTime(ago(2 * 86_400_000), NOW), '2 days ago')
assert.equal(relativeTime(null, NOW), null)
assert.equal(relativeTime('not a date', NOW), null)
})
test('items group into days, newest day first, order kept within a day', () => {
const days = groupByDay([
{ id: 3, occurredAt: '2026-08-17T09:00:00' },
{ id: 2, occurredAt: '2026-08-17T08:00:00' },
{ id: 1, occurredAt: '2026-08-16T22:00:00' },
], 'en-US')
assert.equal(days.length, 2)
assert.deepEqual(days[0].items.map((i) => i.id), [3, 2])
assert.deepEqual(days[1].items.map((i) => i.id), [1])
})
test('an unparseable timestamp is skipped rather than making a day called Invalid Date', () => {
assert.deepEqual(groupByDay([{ id: 1, occurredAt: 'nonsense' }], 'en-US'), [])
})
test('a caller who saw everything is told nothing', () => {
assert.equal(activityScopeNote({ scope: 'members' }, true), null)
})
test('a filtered feed says so, and invites an anonymous caller to sign in', () => {
assert.match(activityScopeNote({ scope: 'public' }, false), /Sign in/)
assert.match(activityScopeNote({ scope: 'public' }, true), /members only/)
})
test('the wording never says "Team" — that is core\'s noun, not the page\'s', () => {
for (const signedIn of [true, false]) {
assert.doesNotMatch(activityScopeNote({ scope: 'public' }, signedIn), /Team/)
}
assert.doesNotMatch(freshnessNote({ configured: true, lastSyncAt: null }, NOW).text, /Team/)
})

View File

@@ -1051,6 +1051,46 @@ CREATE TABLE IF NOT EXISTS team_moderation_requests (
INDEX idx_tmr_queue (status, requested_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The per-Team activity feed (TEAMS.md §4.2, phase 3). Two writers, one table:
-- core writes its own membership and rename items with source='core', and a module
-- pushes game items through ctx.teams.activity.push with source=<moduleId>. That
-- core writes here too is deliberate — the rendering path is exercised by core's
-- own content from day one, so the feed is never empty on a deployment whose
-- module pushes nothing.
--
-- `summary` is ALREADY-RENDERED text and core never composes one (§4.1). Core
-- cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know,
-- and a core that templated it would have re-acquired exactly the game semantics
-- the module system exists to remove. `kind` and `payload` are likewise opaque:
-- core stores and filters them, and only the module's `team.overview` slot renders
-- anything richer than the text.
CREATE TABLE IF NOT EXISTS team_activity (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
source VARCHAR(32) NOT NULL, -- 'core' or a module id
kind VARCHAR(64) NOT NULL, -- namespaced <source>.<name>, opaque to core
summary VARCHAR(255) NOT NULL, -- module-rendered; core never composes one
-- Defaults to 'members' — fail closed. The module CHOOSES visibility per item;
-- core ENFORCES it on the read path. Same shape as a module owning the
-- public-safety filter for its push streams (MODULE_API.md §2.4).
visibility ENUM('public','members') NOT NULL DEFAULT 'members',
actor_member_key VARCHAR(191) NULL,
actor_user_id INT NULL,
payload JSON NULL, -- opaque; rendered only by the module's slot
occurred_at DATETIME NOT NULL, -- when it happened in the game, not when it arrived
-- Optional idempotence key. INSERT IGNORE against this unique index is the same
-- trick shard_events already uses, and it is what makes a sidecar reconnect
-- backfill safe: replaying a window of events re-posts nothing.
dedupe_key CHAR(40) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_team_activity_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
-- Actor is SET NULL, not CASCADE (§2.10): deleting an account must not delete the
-- Team's history of what happened, only the attribution.
CONSTRAINT fk_team_activity_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key),
INDEX idx_team_activity_feed (team_id, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.

View File

@@ -1729,9 +1729,27 @@
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/activity",
"handlers": 3,
"gates": [
"siteMode",
"optionalAuth"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/members",
"handlers": 3,
"gates": [
"siteMode",
"optionalAuth"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId",
"handlers": 2,
"gates": [
"siteMode"

View File

@@ -705,10 +705,18 @@
"method": "GET",
"path": "/api/v1/public/teams/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/activity"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/members"
},
{
"method": "GET",
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId"
},
{
"method": "GET",
"path": "/api/v1/public/version"

View File

@@ -79,6 +79,44 @@ async function requireAuth(req, res, next) {
}
}
// Best-effort AUTHENTICATION, as opposed to attachSession's best-effort decode.
//
// For a PUBLIC route whose content — not merely its presentation — depends on who
// is asking. The Team activity feed is the first: `public` items go to everyone
// and `members` items only to members and forum-granted users (TEAMS.md §4.3), so
// an anonymous caller must be served, not rejected, and an authenticated one must
// be identified properly.
//
// "Properly" is why this is not attachSession. That one decodes the token and
// stops, which is right for reading back your own session but wrong here: a
// banned account, a password change, or a logout would all keep working against
// the private half of the feed until the JWT expired. This runs the same
// database re-validation requireAuth does — status, cutoff, revocation — and on
// any failure continues ANONYMOUSLY rather than 401ing. A caller whose session is
// no longer good sees the public feed, which is exactly what they are entitled to.
//
// A database error also degrades to anonymous. On a public route the safe
// direction is to serve less, and 500ing a page because a session lookup failed
// would take the whole Team page down for callers who never sent a token.
async function optionalAuth(req, res, next) {
const session = sessionService.validateSession(req)
if (!session) return next()
try {
const user = await users.getById(session.userId)
if (!user) return next()
if (user.status && user.status !== 'active') return next()
if (isBeforeCutoff(session, user.tokens_valid_after)) return next()
if (await sessionService.isSessionRevoked(session.sessionId)) return next()
req.user = user
req.session = session
req.authMethod = session.authMethod
} catch (err) {
log.warn('optionalAuth: continuing anonymously', { message: err.message })
}
return next()
}
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
// first so req.user is populated. Use for admin-only endpoints (users, site
// mode, settings) so a lower-privilege editor cannot reach them.
@@ -91,6 +129,7 @@ function requireRole(...roles) {
module.exports = {
attachSession,
optionalAuth,
requireAuth,
requireRole,
}

View File

@@ -0,0 +1,133 @@
// SQL for the per-Team activity feed (TEAMS.md §4.2). Statements only; every
// decision about what a caller may SEE lives in teamActivity.model.js.
const { query } = require('../../utils/db')
const ACTIVITY_COLUMNS = `
id, team_id, source, kind, summary, visibility,
actor_member_key, actor_user_id, payload, occurred_at, created_at`
/**
* Insert one item, idempotently when it carries a dedupe key.
*
* INSERT IGNORE against uq_team_activity_dedupe is what makes replay safe: a
* sidecar reconnect backfills a window of events it already delivered, and
* without this every reconnect would double-post the feed. The same trick
* `shard_events` uses, for the same reason.
*
* The unique key is (team_id, dedupe_key) and MariaDB treats NULL as distinct in
* a unique index, so items WITHOUT a key never collide with each other — an
* un-keyed push is always an insert, which is the documented contract (§4.1:
* `dedupeKey` is optional and "makes replay idempotent", so omitting it opts out).
*
* IGNORE would also swallow a genuine error — a bad FK, an over-long summary. The
* model validates and truncates before calling, so what reaches here can only fail
* on the dedupe key, and `affectedRows` reports which happened.
*/
async function insert(item) {
const res = await query(
`INSERT IGNORE INTO team_activity
(team_id, source, kind, summary, visibility, actor_member_key, actor_user_id, payload, occurred_at, dedupe_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
item.teamId,
item.source,
item.kind,
item.summary,
item.visibility,
item.actorMemberKey,
item.actorUserId,
item.payload === null ? null : JSON.stringify(item.payload),
new Date(item.occurredAt),
item.dedupeKey,
],
)
return Number(res.affectedRows) > 0
}
/**
* One page of a Team's feed, already narrowed to the visibilities the caller may
* see.
*
* `visibilities` is always supplied by the model and never by a request
* parameter — a caller naming its own visibility filter is the whole bug this
* table's ENUM exists to prevent. Ordered newest first by `occurred_at`, the
* game's clock, not `created_at`: a backfill that arrives late still sorts where
* it happened.
*/
async function page(teamId, visibilities, { limit, offset }) {
const slots = visibilities.map(() => '?').join(', ')
return query(
`SELECT ${ACTIVITY_COLUMNS} FROM team_activity
WHERE team_id = ? AND visibility IN (${slots})
ORDER BY occurred_at DESC, id DESC
LIMIT ? OFFSET ?`,
[teamId, ...visibilities, limit, offset],
)
}
/** Total matching rows, for the same filter — so a client can page honestly. */
async function count(teamId, visibilities) {
const slots = visibilities.map(() => '?').join(', ')
const rows = await query(
`SELECT COUNT(*) AS n FROM team_activity WHERE team_id = ? AND visibility IN (${slots})`,
[teamId, ...visibilities],
)
return Number(rows[0] ? rows[0].n : 0)
}
/** Everything older than the retention horizon, across every Team. */
async function deleteOlderThan(days) {
const res = await query(
'DELETE FROM team_activity WHERE occurred_at < (NOW() - INTERVAL ? DAY)',
[days],
)
return Number(res.affectedRows) || 0
}
/**
* Which Teams currently exceed the per-Team row cap, and by how much.
*
* Asked first so the trim only runs for Teams that need it. A feed fed by a game
* loop is the obvious unbounded-growth failure (§4.2), and on a shard with one
* busy guild and fifty quiet ones this keeps the nightly job proportional to the
* problem rather than to the number of Teams.
*/
async function overCap(cap) {
return query(
`SELECT team_id, COUNT(*) AS n FROM team_activity
GROUP BY team_id HAVING n > ?`,
[cap],
)
}
/**
* Trim one Team back to the newest `cap` rows.
*
* Expressed as "delete everything at or below the id of the cap-th newest row"
* rather than as a correlated subquery on the same table, which MariaDB refuses
* inside a DELETE (error 1093). The derived table is what makes it legal — the
* subquery is materialised before the delete runs.
*/
async function trimToCap(teamId, cap) {
const rows = await query(
`SELECT id FROM team_activity
WHERE team_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 OFFSET ?`,
[teamId, cap],
)
if (!rows[0]) return 0
const res = await query(
'DELETE FROM team_activity WHERE team_id = ? AND id <= ?',
[teamId, rows[0].id],
)
return Number(res.affectedRows) || 0
}
module.exports = {
insert,
page,
count,
deleteOlderThan,
overCap,
trimToCap,
}

View File

@@ -0,0 +1,312 @@
// ── The per-Team activity feed (TEAMS.md Part 4) ───────────────────────────
//
// Two writers, one table. A module pushes game items through
// `ctx.teams.activity.push` (§4.1); core writes its own membership and rename
// items directly (§4.2). Both land in `team_activity` with a `source`, and the
// read path treats them identically — which is the point of core writing here at
// all, since it means the rendering path is exercised from day one on a
// deployment whose module pushes nothing.
//
// **Three rules shape this file.**
//
// 1. *Core never composes a summary.* `summary` arrives already rendered and is
// stored verbatim (§4.1). Core cannot phrase "gained 15,000 gold" for a game
// whose vocabulary it does not know, and a core that templated it would have
// re-acquired the game semantics the module system exists to remove. Core's OWN
// five kinds are the sole exception, and they are about membership and renames
// — platform facts, not game ones.
//
// 2. *Visibility fails closed.* An item with no stated visibility is `members`,
// and the read path resolves what a caller may see from their access rather
// than from anything they send.
//
// 3. *A push never throws at its call site.* `ctx.teams.activity.push` is awaited
// by a module inside a game-event handler. A bad item is dropped and logged;
// an unknown Team is dropped and logged. The alternative — rejecting the batch
// — makes core's storage problem into the module's control flow, and the
// contract (MODULE_API.md §2.3) is that ctx pushes are fire-and-forget.
const activityDb = require('./teamActivity.db')
const teamsDb = require('./teams.db')
const access = require('./teamAccess.model')
const settings = require('../settings/settings.model')
const log = require('../../utils/logger')('teams')
// Column widths from schema.sql. Truncating rather than refusing: an over-long
// summary is a module being verbose, not a module being wrong, and dropping the
// item would lose a real event over a display detail.
const MAX_SUMMARY = 255
const MAX_KIND = 64
const MAX_MEMBER_KEY = 191
// CHAR(40) — a sha1 hex is the natural fit and what §4.1's example looks like,
// but the column is opaque and any stable string within the width works.
const MAX_DEDUPE = 40
const VISIBILITIES = ['public', 'members']
// Retention (§4.2). Both are settings so an operator can tighten a busy shard
// without a deploy; the defaults are the doc's.
const DEFAULT_RETAIN_DAYS = 90
const DEFAULT_ROW_CAP = 2000
/**
* Core's own kinds (§4.2).
*
* `core.forum.thread` is named in the doc and lands with the forum in phase 4 —
* there is nothing to emit it from yet. The four here are all core knows how to
* say without asking a game anything.
*/
const CORE_KINDS = {
MEMBER_JOINED: 'core.member.joined',
MEMBER_LEFT: 'core.member.left',
LEADER_CHANGED: 'core.leader.changed',
TEAM_RENAMED: 'core.team.renamed',
}
const clamp = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null)
/**
* Normalise one pushed item, or return null to drop it.
*
* `teamId` is resolved by the caller, not carried on the item: a module names its
* own `externalId` and core maps it (§4.1), so a module can never write into
* another module's Team by guessing an integer.
*/
function normalise(item, source, teamId) {
if (!item || typeof item !== 'object') return null
const kind = clamp(item.kind, MAX_KIND)
const summary = clamp(item.summary, MAX_SUMMARY)
// Both are load-bearing and neither has a safe default: an item with no kind
// cannot be filtered or rendered by a slot, and one with no summary is a blank
// row on a public page.
if (!kind || !summary) return null
// `occurredAt` is the game's clock and the feed's sort key. A missing or
// unparseable one becomes now — the item is real even when its timestamp is
// not, and dropping it would lose an event over metadata.
const occurredAt = Number.isFinite(item.occurredAt) ? Number(item.occurredAt) : Date.now()
return {
teamId,
source,
kind,
summary,
visibility: VISIBILITIES.includes(item.visibility) ? item.visibility : 'members',
actorMemberKey: clamp(item.actorMemberKey, MAX_MEMBER_KEY),
// Resolved BY THE MODULE, like every other user id crossing this boundary
// (§2.3) — core takes the number and never looks it up.
actorUserId: Number.isInteger(item.actorUserId) && item.actorUserId > 0 ? item.actorUserId : null,
payload: item.payload && typeof item.payload === 'object' ? item.payload : null,
occurredAt,
dedupeKey: clamp(item.dedupeKey, MAX_DEDUPE),
}
}
/**
* `ctx.teams.activity.push` — a module's whole write access to the feed.
*
* Items name their Team by the module's own `externalId`, and only ACTIVE Teams
* owned by THAT module resolve. An archived Team is deliberately not writable: its
* feed is a read-only record of what happened before the rename or the disband
* (§2.2), and letting a late-arriving event append to it would make a closed
* record grow.
*
* Returns the number of items actually stored. Dropped items are logged with the
* reason and never raised — see rule 3 above.
*/
async function push(source, items) {
if (!Array.isArray(items)) {
log.warn('teams activity push: not an array', { source })
return 0
}
if (!items.length) return 0
// One lookup per distinct externalId, not one per item: a champion spawn
// completing pushes a batch for a single Team, and re-resolving it per item
// would be a query per row.
const teamIds = new Map()
let stored = 0
let dropped = 0
for (const item of items) {
const externalId = item && typeof item.externalId === 'string' ? item.externalId.trim() : ''
if (!externalId) { dropped += 1; continue }
if (!teamIds.has(externalId)) {
// eslint-disable-next-line no-await-in-loop
const row = await teamsDb.findActive(source, externalId)
teamIds.set(externalId, row ? row.id : null)
}
const teamId = teamIds.get(externalId)
if (!teamId) { dropped += 1; continue }
const normalised = normalise(item, source, teamId)
if (!normalised) { dropped += 1; continue }
// eslint-disable-next-line no-await-in-loop
const inserted = await activityDb.insert(normalised)
// A dedupe collision is a SUCCESSFUL no-op, not a drop — it is the mechanism
// working. Counted as stored so a module replaying a backfill does not read
// its own idempotence as data loss.
if (inserted) stored += 1
}
if (dropped) {
log.warn('teams activity push: dropped items', { source, dropped, offered: items.length })
}
return stored
}
/**
* Core's own write path (§4.2), used by the reconciler and the rename rule.
*
* Separate from `push` because core names a Team by its own primary key — it is
* already holding the row — and because core's items are always `public`: a
* member joining or a Team being renamed is exactly what a public Team page is
* for. Nothing here is game vocabulary.
*/
async function logCore({ teamId, kind, summary, actorMemberKey = null, actorUserId = null, occurredAt = Date.now(), dedupeKey = null }) {
if (!teamId || !kind || !summary) return false
return activityDb.insert({
teamId,
source: 'core',
kind: clamp(kind, MAX_KIND),
summary: clamp(summary, MAX_SUMMARY),
visibility: 'public',
actorMemberKey: clamp(actorMemberKey, MAX_MEMBER_KEY),
actorUserId: Number.isInteger(actorUserId) && actorUserId > 0 ? actorUserId : null,
payload: null,
occurredAt,
dedupeKey: clamp(dedupeKey, MAX_DEDUPE),
})
}
/**
* Which visibilities a caller may see (§4.3).
*
* `members` items go to members and to forum-granted users — the same two
* authority paths `forumAccess` already resolves, reused rather than re-derived
* so the feed can never disagree with the forum about who is inside a Team.
* Anyone else, including every anonymous caller, sees `public` only.
*/
async function visibilitiesFor(teamId, userId) {
if (!userId) return ['public']
const resolved = await access.forumAccess(teamId, userId)
return resolved.allowed ? ['public', 'members'] : ['public']
}
/** The rendered shape. `payload` rides along for the module's slot (§4.3). */
function publicItem(row) {
return {
id: Number(row.id),
source: row.source,
kind: row.kind,
summary: row.summary,
visibility: row.visibility,
occurredAt: row.occurred_at,
payload: row.payload ?? null,
}
}
/**
* One page of a Team's feed for one viewer.
*
* A HIDDEN Team's feed is not served publicly, for the same reason its roster is
* not (§2.8.3): hidden means absent from every public surface, and a feed that
* answered while the page 404s would republish the suppressed name in every
* `core.team.renamed` summary.
*/
async function feedFor(slug, userId, { limit = 50, offset = 0 } = {}) {
const row = await teamsDb.findBySlug(slug)
if (!row) return null
const visibilities = await visibilitiesFor(row.id, userId)
// A member of a hidden Team still sees its feed — suppression is a
// public-surface rule, and a member is not a member of the public (§2.11).
if (row.hidden && visibilities.length === 1) return null
const [rows, total] = await Promise.all([
activityDb.page(row.id, visibilities, { limit, offset }),
activityDb.count(row.id, visibilities),
])
return {
items: rows.map(publicItem),
total,
limit,
offset,
// So a client can render "members-only items are hidden" rather than
// presenting a filtered feed as the whole one.
scope: visibilities.includes('members') ? 'members' : 'public',
}
}
// ── Retention (§4.2) ───────────────────────────────────────────────────────
const RETAIN_KEY = 'team_activity_retain_days'
const CAP_KEY = 'team_activity_row_cap'
/**
* Read both limits, falling back to the defaults on anything unreadable.
*
* Wrapped in a try like `teamSync.intervalSeconds`, and for the same reason: this
* runs on a timer with nobody watching, and a settings table that is briefly
* unavailable must yield the default rather than an exception that kills the
* nightly job. A misconfigured value fails the same way — a zero or a negative
* retention would delete the whole feed, so it is rejected rather than honoured.
*/
async function retentionConfig() {
let rawDays
let rawCap
try {
;[rawDays, rawCap] = await Promise.all([settings.get(RETAIN_KEY), settings.get(CAP_KEY)])
} catch {
return { days: DEFAULT_RETAIN_DAYS, cap: DEFAULT_ROW_CAP }
}
const days = Number.parseInt(rawDays, 10)
const cap = Number.parseInt(rawCap, 10)
return {
days: Number.isFinite(days) && days > 0 ? days : DEFAULT_RETAIN_DAYS,
cap: Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_ROW_CAP,
}
}
/**
* The nightly prune: an age horizon AND a per-Team row cap.
*
* Both, because either alone has a hole. Age alone lets one busy guild write a
* million rows inside the window; a cap alone keeps a dead Team's feed forever.
* Unbounded growth on a per-Team feed fed by a game loop is the obvious failure
* here and it is cheaper to bound it now than to discover it at cutover.
*/
async function prune() {
const { days, cap } = await retentionConfig()
const byAge = await activityDb.deleteOlderThan(days)
let byCap = 0
const over = await activityDb.overCap(cap)
for (const row of over) {
// eslint-disable-next-line no-await-in-loop
byCap += await activityDb.trimToCap(row.team_id, cap)
}
if (byAge || byCap) log.info('teams activity prune', { byAge, byCap, days, cap })
return { byAge, byCap, days, cap }
}
module.exports = {
push,
logCore,
feedFor,
visibilitiesFor,
publicItem,
prune,
retentionConfig,
RETAIN_KEY,
CAP_KEY,
CORE_KINDS,
VISIBILITIES,
DEFAULT_RETAIN_DAYS,
DEFAULT_ROW_CAP,
}

View File

@@ -163,10 +163,66 @@ function normaliseLeaders(answer) {
return { ok: true, leaders }
}
/**
* `{ ok, members: [memberKey] }` — WHICH rows the module permits this viewer.
*
* Deliberately a set of keys rather than a set of rows. Core already holds the
* rows and knows their public shape; asking the module for rows back would let a
* module widen what is published — re-adding a `userId` or a `memberKey` that
* §3.2 says is never published — and core's field guarantee would then rest on
* every module's good behaviour rather than on core. So the module answers the
* question it actually owns (who may be seen at this rung) and core keeps the
* question it owns (what a member row looks like in public).
*/
function normaliseVisibleKeys(answer) {
if (!Array.isArray(answer.members)) return fail('projectRoster() answered ok with no members array')
const keys = []
for (const raw of answer.members) {
const key = str(raw)
if (!key) return fail('a projectRoster() entry is not a member key')
if (!keys.includes(key)) keys.push(key)
}
return { ok: true, members: keys }
}
const getTeams = () => call('getTeams', normaliseTeams)
const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId)
const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId)
/**
* Ask the module which roster rows this viewer may see (§3.3).
*
* The per-audience projection is the module's because the visibility framework
* and its rung configuration are module-owned (§10.5) — core does not know what a
* rung is. Core supplies the roster and a description of the viewer; the module
* returns the member keys it permits.
*
* **"No audience model" and "could not answer" are different, and the caller must
* be able to tell them apart** — so the refusal carries `projects`.
*
* `projects: false` — no provider is registered, or the registered one does not
* implement `projectRoster`. There is no rung system to consult and nothing
* is being withheld; the roster is served at core's public shape. This is why
* the member is OPTIONAL: bare core, and a module with no audience model of
* its own, both render exactly the page core writes.
*
* `projects: true` — the module HAS an audience model and core could not reach
* it (refused, threw, timed out, answered malformed). Here the caller must
* fail CLOSED, because "leave it alone" would mean publishing the very rows
* the rungs exist to withhold. This is the one place in the Team subsystem
* where unavailability is not staleness: everywhere else a refused call
* leaves data alone, and doing that to a *visibility* question is a leak.
*/
async function projectRoster(externalId, members, viewer) {
const provider = registries.registeredTeamProvider()
if (!provider) return { ...fail('no team provider is registered'), projects: false }
if (typeof provider.projectRoster !== 'function') {
return { ...fail('provider does not project rosters'), projects: false }
}
const answer = await call('projectRoster', normaliseVisibleKeys, externalId, members, viewer)
return answer.ok ? answer : { ...answer, projects: true }
}
/** Which module is authoritative, or null. The reconciler keys sync state on it. */
const providerModuleId = () => {
const provider = registries.registeredTeamProvider()
@@ -177,6 +233,7 @@ module.exports = {
getTeams,
getTeamMembers,
getTeamLeaders,
projectRoster,
providerModuleId,
CALL_TIMEOUT_MS,
}

View File

@@ -32,6 +32,7 @@
const teamsDb = require('./teams.db')
const teamProvider = require('./teamProvider')
const moderation = require('./teamModeration.model')
const activity = require('./teamActivity.model')
const { slugify, uniqueSlug } = require('./teamSlug')
const settings = require('../settings/settings.model')
const log = require('../../utils/logger')('teams')
@@ -130,9 +131,66 @@ async function applyRename(moduleId, existing, team) {
log.info('team renamed; previous row archived', {
externalId: team.externalId, from: existing.name, to: team.name, archivedId: existing.id, successorId,
})
// §4.2's `core.team.renamed`, written to the SUCCESSOR rather than to the row
// that was renamed: the archived row is a read-only record of what happened
// before the rename (§2.2), and the person who wants to know a Team used to be
// called something else is looking at the live page.
//
// The old name is core's own, not game-sourced text a module handed us this
// run — it is the `name` column core has been serving all along — so §2.9's
// approval gate does not apply. It can still be a name staff suppressed, which
// is why a hidden Team's feed is not served publicly (teamActivity.feedFor).
await activity.logCore({
teamId: successorId,
kind: activity.CORE_KINDS.TEAM_RENAMED,
summary: `Renamed from ${existing.display_name_override || existing.name}`,
dedupeKey: `renamed:${existing.id}`,
}).catch((err) => log.warn('rename activity not recorded', { message: err.message }))
return successorId
}
/** Never a game-internal member key on a public page: that identifier is not published (§3.2). */
const memberLabel = (row) => (row && row.display_name) || 'A member'
/**
* Core's own membership items for one roster run (§4.2).
*
* **Suppressed on a Team's FIRST roster.** Importing a 155-member guild is one
* Team arriving, not 155 people joining, and emitting a join per member would
* bury every real event under the import and blow through the row cap on day one.
* `roster_synced_at IS NULL` is exactly "core has never held a roster for this
* Team", so the same condition covers a newly created Team and a newly installed
* module adopting an existing one.
*
* Never throws: the feed is a rendering of the sync, and a feed write failing
* must not abort the sync that is the actual source of truth.
*/
async function logRosterActivity(team, { joined, left, promoted, demoted }) {
if (!team.roster_synced_at) return
const items = [
...joined.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_JOINED, row, verb: 'joined' })),
...left.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_LEFT, row, verb: 'left' })),
...promoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'became a leader' })),
...demoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'stepped down as a leader' })),
]
for (const { kind, row, verb } of items) {
try {
// eslint-disable-next-line no-await-in-loop
await activity.logCore({
teamId: team.id,
kind,
summary: `${memberLabel(row)} ${verb}`,
actorMemberKey: row.member_key,
actorUserId: row.user_id ?? null,
})
} catch (err) {
log.warn('roster activity not recorded', { teamId: team.id, kind, message: err.message })
}
}
}
/**
* Sync one Team's roster and leadership. Gates 3 and 4 live here.
*
@@ -152,7 +210,13 @@ async function syncRoster(team) {
return false
}
const known = await teamsDb.memberKeys(team.id)
// The full rows rather than just the keys: the activity feed needs the display
// name and the prior `is_leader` of everyone who is about to change, and both
// are gone once the upsert below has run. One read either way — this replaces
// the `memberKeys` call rather than adding to it.
const knownRows = await teamsDb.membersByTeam(team.id)
const knownByKey = new Map(knownRows.map((row) => [row.member_key, row]))
const known = knownRows.map((row) => row.member_key)
// Gate 4, the per-Team twin of gate 2.
if (answer.complete && answer.members.length === 0 && known.length > 0) {
@@ -184,17 +248,35 @@ async function syncRoster(team) {
})
}
// Anyone the module reports that core was not already holding. Read from the
// module's shape, since a joiner has no row yet.
const joined = answer.members
.filter((m) => !knownByKey.has(m.memberKey))
.map((m) => ({ member_key: m.memberKey, display_name: m.displayName, user_id: m.userId }))
// Removals only from a COMPLETE answer. `complete: false` means "valid but
// partial", so additions and updates apply and nothing is taken away.
let left = []
if (answer.complete) {
const seen = new Set(answer.members.map((m) => m.memberKey))
await teamsDb.markDeparted(team.id, known.filter((key) => !seen.has(key)))
const departedKeys = known.filter((key) => !seen.has(key))
left = departedKeys.map((key) => knownByKey.get(key))
await teamsDb.markDeparted(team.id, departedKeys)
}
// Leadership is a separate question with a separate answer, and a provider that
// cannot answer it leaves the synced value alone rather than demoting everyone.
const leaders = await teamProvider.getTeamLeaders(team.external_id)
let promoted = []
let demoted = []
if (leaders.ok) {
// Diffed against the PRIOR rows, before setLeaders overwrites them. A member
// who joined this run as a leader is reported as joining, not as being
// promoted — they were never anything else here.
const nowLeader = new Set(leaders.leaders)
const departed = new Set(left.map((row) => row && row.member_key))
promoted = knownRows.filter((row) => nowLeader.has(row.member_key) && !row.is_leader)
demoted = knownRows.filter((row) => !nowLeader.has(row.member_key) && row.is_leader && !departed.has(row.member_key))
await teamsDb.setLeaders(team.id, leaders.leaders)
} else {
log.warn('leadership left untouched; provider could not answer', {
@@ -203,6 +285,8 @@ async function syncRoster(team) {
}
await teamsDb.recount(team.id)
// Read before `markRosterSynced` moves the stamp this decision turns on.
await logRosterActivity(team, { joined, left: left.filter(Boolean), promoted, demoted })
await teamsDb.markRosterSynced(team.id)
return true
}

View File

@@ -156,6 +156,16 @@ async function listPublic({ limit = 50, offset = 0 } = {}) {
teams: visible.slice(offset, offset + limit).map(publicTeam),
total: visible.length,
...sync,
// What the `teams` nav feature flag resolves from (§3.5). True if a provider
// is registered OR any Team exists — the second half matters because Team
// rows outlive the module that filled them, and hiding the nav entry the
// moment a module is uninstalled would make every existing Team page
// unreachable from the site while still answering by URL.
//
// False only when there is nothing and no prospect of anything, which is
// exactly the bare-core case the flag exists for: a link to a permanently
// empty page is worse than no link.
enabled: Boolean(sync.configured) || visible.length > 0,
}
}
@@ -174,6 +184,19 @@ async function getPublic(slug) {
const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null
return {
...publicTeam(row),
// The three props the `team.overview` extension slot is declared with
// (§3.4). A module's slot component runs in the browser and has to know
// WHICH Team it is looking at, in its own vocabulary — `slug` is core's name
// for it and resolves nothing on the module's side.
//
// On this route only, deliberately: the index has no slot and would
// otherwise publish a module-internal identifier per row for nothing. None
// of the three names a person — they are a core row id, a game-side group
// id and a module name, and the identifiers §3.2 withholds (member keys,
// site account ids) are not among them.
id: row.id,
externalId: row.external_id,
moduleId: row.module_id,
...sync,
successor: successor && !successor.hidden
? { slug: successor.slug, name: successor.display_name_override || successor.name }
@@ -181,14 +204,68 @@ async function getPublic(slug) {
}
}
async function rosterPublic(slug) {
/**
* A Team's roster, projected for the caller's audience rung (§3.3).
*
* The ROW filter is the module's: it owns the visibility framework and its
* configuration (§10.5), and core does not know what a rung is. The FIELD shape
* stays core's — every row that survives goes through `publicMember`, which
* withholds the member key and the user id whatever the module answers. So a
* module can narrow what is published and cannot widen it, and core's "neither is
* published" guarantee does not rest on every module's good behaviour.
*
* **A module that HAS a rung system and cannot answer withholds the roster.** That
* is the one Team call where a refusal is not staleness: leaving a visibility
* answer "alone" would publish the very rows the rungs exist to withhold. A
* deployment with no module, or one whose module does not project at all, is a
* different case entirely — nothing is being withheld there, so the roster is
* served whole at core's public shape (`projects: false`).
*/
/**
* One Team named the way the MODULE names it (§3.4 as amended).
*
* The lookup a module's page needs. A module holds its own identity for a Team —
* a ServUO guild serial — and never core's row id or slug, deliberately: core's
* identifiers are core-internal (§10.3), and handing them out is how a module
* ends up storing them and then depending on them.
*
* Scoped to the naming module's OWN Teams. `module_id` comes from the path and is
* matched, not trusted: it cannot be used to read another module's Team, which
* matters because `external_id` is only unique within a module.
*/
async function getPublicByExternalId(moduleId, externalId) {
const row = await teamsDb.findActive(moduleId, externalId)
if (!row || row.hidden) return null
return { ...publicTeam(row), id: row.id, externalId: row.external_id, moduleId: row.module_id, ...(await syncStatus()) }
}
async function rosterPublic(slug, viewer = null) {
const row = await teamsDb.findBySlug(slug)
if (!row || row.hidden) return null
const [members, sync] = await Promise.all([
access.rosterWithOverrides(row.id),
syncStatus(),
])
return { members: members.map(publicMember), ...sync, rosterSyncedAt: row.roster_synced_at }
// The module gets the rows as it supplied them — this is its own data coming
// home — plus who is asking, which is all a rung decision needs.
const answer = await teamProvider.projectRoster(row.external_id, members, viewer)
let visible
if (answer.ok) visible = members.filter((m) => answer.members.includes(m.member_key))
else if (answer.projects) visible = [] // fail closed: it has rungs and we could not ask
else visible = members // nothing to fail closed ABOUT
return {
members: visible.map(publicMember),
...sync,
rosterSyncedAt: row.roster_synced_at,
// Stated rather than implied. An empty roster has three quite different
// causes — a Team with no members, a rung that shows none, and a module that
// could not be asked — and a page that cannot tell them apart will report the
// last one as the first.
projected: answer.ok,
...(answer.ok || !answer.projects ? {} : { projectionUnavailable: true }),
}
}
// ── Player ─────────────────────────────────────────────────────────────────
@@ -282,6 +359,7 @@ async function getAdmin(id) {
module.exports = {
listPublic,
getPublic,
getPublicByExternalId,
rosterPublic,
listForUser,
accessForUser,

View File

@@ -120,6 +120,7 @@ function buildCtx(id, moduleRoot) {
const activity = require('../model/activity/activity.model')
const users = require('../model/users/users.model')
const teams = require('../model/teams/teamSync.model')
const teamActivity = require('../model/teams/teamActivity.model')
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
/* eslint-enable global-require */
@@ -190,14 +191,20 @@ function buildCtx(id, moduleRoot) {
teams: {
publish: (event) => teams.publish(event),
reconcile: (opts) => teams.request(opts),
// §4's activity feed, which lands with the Team pages in phase 3. Declared
// in 1.6.0 alongside the rest of the Team surface; calling it before phase 3
// throws rather than silently accepting items into a table that does not
// exist yet.
// §4's activity feed (phase 3). `source` is bound to the CALLING module and
// is never taken from the item — a module writes its own items, under its
// own name, and items name their Team by the module's own `externalId`, so
// there is no id a module could send that reaches another module's Team.
//
// Like `publish` and `reconcile` above, a failure here never reaches the
// module: this is called from inside a game-event handler, and a storage
// problem of core's must not become the module's control flow. A rejected
// write is logged and the promise still resolves.
activity: {
push: () => {
throw new Error('ctx.teams.activity.push is not available until the Team activity feed lands (TEAMS.md §4)')
},
push: (items) => teamActivity.push(id, items).then(
(stored) => { void stored },
(err) => { log.error('ctx.teams.activity.push failed', { module: id, message: err.message }) },
),
},
},
// One function, for one caller: the `admin.users.detail` slot router needs

View File

@@ -243,11 +243,22 @@ function checkLegShape(entry) {
return { leg, label: label || leg, dispatch, classify }
}
// All three methods are REQUIRED, with no optional half. A provider that could
// list Teams but not their members would leave core holding Teams it can never
// Three methods are REQUIRED, with no optional half. A provider that could list
// Teams but not their members would leave core holding Teams it can never
// populate, and the reconciler has no sensible behaviour for that — it is not the
// same as a call that fails, which is staleness and already handled (§2.4). A
// module unable to answer one of the three answers `{ ok: false }` at call time.
//
// `projectRoster` is the fourth and is OPTIONAL (TEAMS.md §3.3): it expresses an
// audience model, and a module with no rung system of its own has no opinion to
// express. Omitting it means core serves rosters at its own public shape;
// implementing it means core fails CLOSED when the call cannot be made, so this
// is a member to add deliberately rather than by habit.
//
// 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
// module's side and be invisible from core's.
function checkTeamProviderShape(entry) {
const provider = entry || {}
const out = {}
@@ -257,6 +268,12 @@ function checkTeamProviderShape(entry) {
}
out[name] = provider[name]
}
if (provider.projectRoster !== undefined) {
if (typeof provider.projectRoster !== 'function') {
throw new Error('registerTeamProvider: projectRoster must be a function if present')
}
out.projectRoster = provider.projectRoster
}
return out
}

View File

@@ -5,6 +5,7 @@
// 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 log = require('../../../utils/logger')('teams')
@@ -35,9 +36,37 @@ async function getTeam(req, res) {
}
}
/**
* 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 roster = await teams.rosterPublic(req.params.slug)
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) {
@@ -45,4 +74,28 @@ async function getRoster(req, res) {
}
}
module.exports = { listTeams, getTeam, getRoster }
/**
* 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')
}
}
module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity }

View File

@@ -12,6 +12,7 @@ const express = require('express')
const ctrl = require('./teams.controller')
const siteMode = require('../../../middleware/siteMode')
const { optionalAuth } = require('../../../auth/session.middleware')
const teamsRouter = express.Router()
@@ -27,6 +28,22 @@ teamsRouter.get(
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 modules own identifier'
// #swagger.description = 'Exists so a modules page can find cores Team without holding cores 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 anothers 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 modules 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']
@@ -43,12 +60,34 @@ 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. `linked` answers whether a character has an account behind it without saying which.'
// #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 modules projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the modules 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 Teams 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,
)
module.exports = teamsRouter

View File

@@ -9,6 +9,7 @@ const http = require('http')
// now because none of it reaches the loader's scan.
const botScore = require('./middleware/botScore')
const announceWorker = require('./utils/announceWorker')
const teamActivityPrune = require('./utils/teamActivityPrune')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
@@ -150,6 +151,11 @@ async function start() {
// retry per leg. No-op until a news post is actually published.
announceWorker.start()
// Bound the per-Team activity feed (TEAMS.md §4.2). A feed fed by a game loop
// is the obvious unbounded-growth failure, so retention starts with the feed
// rather than after someone notices. No-op on a deployment with no Teams.
teamActivityPrune.start()
setupShutdown(server, internalServer)
}
@@ -167,6 +173,7 @@ function setupShutdown(server, internalServer) {
await moduleLifecycle.shutdown()
botScore.stopSweeper() // stop the bot-store cleanup interval
announceWorker.stop() // stop the news-announcement dispatcher poller
teamActivityPrune.stop() // stop the Team activity retention timer
server.close(() => log.info('http server closed'))
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
try {

View File

@@ -0,0 +1,64 @@
// ── Team activity retention worker ──────────────────────────────────────────
//
// TEAMS.md §4.2's nightly prune: an age horizon (`team_activity_retain_days`,
// default 90) and a per-Team row cap (`team_activity_row_cap`, default 2000).
// Both live in `settings`, so an operator can tighten a busy shard without a
// deploy.
//
// Same in-process shape as utils/announceWorker and middleware/botScore's
// sweeper — setInterval + unref + stop(), wired into server.js start/shutdown.
// There is no cron in this stack and adding one for a single daily DELETE would
// be a dependency to justify at every future upgrade.
//
// **The first run is delayed rather than immediate.** A prune at boot would put a
// table-wide DELETE in front of the first request on every restart, and a
// deployment that is crash-looping would run it on every loop. Five minutes in is
// past the point where a boot has either succeeded or failed.
const teamActivity = require('../model/teams/teamActivity.model')
const log = require('./logger')('teams')
// Nightly, per §4.2. Not aligned to a wall-clock hour: the work is proportional
// to what arrived rather than to when it is done, and pinning it to 03:00 would
// mean a process that restarts each afternoon never prunes at all.
const INTERVAL_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_MS) || 24 * 60 * 60 * 1000
const FIRST_RUN_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_DELAY_MS) || 5 * 60 * 1000
let timer = null
let firstRun = null
/** One prune. Never throws — it runs on a timer with nobody to catch it. */
async function tick() {
try {
return await teamActivity.prune()
} catch (err) {
log.error('team activity prune failed', { message: err.message })
return null
}
}
function start() {
if (timer || firstRun) return timer
firstRun = setTimeout(() => {
firstRun = null
tick()
timer = setInterval(() => { tick() }, INTERVAL_MS)
if (timer.unref) timer.unref()
}, FIRST_RUN_MS)
if (firstRun.unref) firstRun.unref()
log.info('team activity retention started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
return timer
}
function stop() {
if (firstRun) {
clearTimeout(firstRun)
firstRun = null
}
if (timer) {
clearInterval(timer)
timer = null
}
}
module.exports = { start, stop, tick, INTERVAL_MS, FIRST_RUN_MS }

View File

@@ -10482,6 +10482,60 @@
}
}
},
"/api/v1/public/teams/by-external/{moduleId}/{externalId}": {
"get": {
"tags": [
"Public · Teams"
],
"summary": "Get one Team by the owning modules own identifier",
"description": "Exists so a modules page can find cores Team without holding cores 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 anothers Team by guessing a serial. A hidden Team returns 404, like every other public lookup.",
"parameters": [
{
"name": "moduleId",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The module that owns the Team."
},
{
"name": "externalId",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "That modules own identifier for it."
}
],
"responses": {
"200": {
"description": "The Team",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicTeam"
}
}
}
},
"404": {
"description": "No such Team, or it is hidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Service Unavailable"
}
}
}
},
"/api/v1/public/teams/{slug}": {
"get": {
"tags": [
@@ -10527,13 +10581,85 @@
}
}
},
"/api/v1/public/teams/{slug}/activity": {
"get": {
"tags": [
"Public · Teams"
],
"summary": "A Teams activity feed, filtered to what the caller may see",
"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.",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The Team slug."
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Page size, max 100 (default 50)."
},
{
"name": "offset",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Rows to skip (default 0)."
}
],
"responses": {
"200": {
"description": "One page of the feed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicTeamActivity"
}
}
}
},
"404": {
"description": "No such Team, or it is hidden from this caller",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Service Unavailable"
}
},
"security": [
{},
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/public/teams/{slug}/members": {
"get": {
"tags": [
"Public · Teams"
],
"summary": "Get a Team roster",
"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. `linked` answers whether a character has an account behind it without saying which.",
"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 modules projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the modules audience projection; sending a session is optional and may widen it.",
"parameters": [
{
"name": "slug",
@@ -10569,7 +10695,16 @@
"503": {
"description": "Service Unavailable"
}
},
"security": [
{},
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/public/version": {
@@ -17081,6 +17216,23 @@
"example": 12
}
}
},
"enabled": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Whether this deployment has Teams at all — a provider is registered, or Teams exist from one that since went away. The `teams` nav feature flag resolves from this; false means bare core, where a Teams link would lead to a permanently empty page."
},
"example": {
"type": "boolean",
"example": true
}
}
}
}
}
@@ -17225,6 +17377,238 @@
"example": true
}
}
},
"projected": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Whether the module applied its own audience projection to this roster. False means the module declined or does not project, and the roster was served at cores public shape — never the full one."
},
"example": {
"type": "boolean",
"example": true
}
}
}
}
}
}
},
"PublicTeamActivityItem": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "`summary` is already-rendered text supplied by whoever pushed the item; core never composes one. `kind` and `payload` are opaque to core — only the modules `team.overview` slot renders anything richer than the text."
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 4821
}
}
},
"source": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"description": {
"type": "string",
"example": "`core` or a module id."
},
"example": {
"type": "string",
"example": "uo"
}
}
},
"kind": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "uo.champion.completed"
}
}
},
"summary": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Completed Champion Neira"
}
}
},
"visibility": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"public",
"members"
],
"items": {
"type": "string"
}
}
}
},
"occurredAt": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
}
}
},
"payload": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"nullable": {
"type": "boolean",
"example": true
},
"additionalProperties": {
"type": "boolean",
"example": true
}
}
}
}
}
}
},
"PublicTeamActivity": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"$ref": "#/components/schemas/PublicTeamActivityItem"
}
}
},
"total": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "Matching rows for THIS callers visibility, so paging is honest."
},
"example": {
"type": "number",
"example": 137
}
}
},
"limit": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 50
}
}
},
"offset": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 0
}
}
},
"scope": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"public",
"members"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "Which visibilities this caller received. `public` means members-only items were withheld — render that fact rather than presenting a filtered feed as the whole one."
}
}
}
}
}

View File

@@ -979,6 +979,12 @@ const doc = {
properties: {
teams: { type: 'array', items: { $ref: '#/components/schemas/PublicTeam' } },
total: { type: 'integer', example: 12 },
enabled: {
type: 'boolean',
description:
'Whether this deployment has Teams at all — a provider is registered, or Teams exist from one that since went away. The `teams` nav feature flag resolves from this; false means bare core, where a Teams link would lead to a permanently empty page.',
example: true,
},
},
},
PublicTeamMember: {
@@ -999,6 +1005,41 @@ const doc = {
properties: {
members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } },
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
projected: {
type: 'boolean',
description:
'Whether the module applied its own audience projection to this roster. False means the module declined or does not project, and the roster was served at cores public shape — never the full one.',
example: true,
},
},
},
PublicTeamActivityItem: {
type: 'object',
description:
'`summary` is already-rendered text supplied by whoever pushed the item; core never composes one. `kind` and `payload` are opaque to core — only the modules `team.overview` slot renders anything richer than the text.',
properties: {
id: { type: 'integer', example: 4821 },
source: { type: 'string', description: '`core` or a module id.', example: 'uo' },
kind: { type: 'string', example: 'uo.champion.completed' },
summary: { type: 'string', example: 'Completed Champion Neira' },
visibility: { type: 'string', enum: ['public', 'members'] },
occurredAt: { type: 'string', format: 'date-time' },
payload: { type: 'object', nullable: true, additionalProperties: true },
},
},
PublicTeamActivity: {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamActivityItem' } },
total: { type: 'integer', description: 'Matching rows for THIS callers visibility, so paging is honest.', example: 137 },
limit: { type: 'integer', example: 50 },
offset: { type: 'integer', example: 0 },
scope: {
type: 'string',
enum: ['public', 'members'],
description:
'Which visibilities this caller received. `public` means members-only items were withheld — render that fact rather than presenting a filtered feed as the whole one.',
},
},
},
PlayerTeamList: {

View File

@@ -0,0 +1,271 @@
// The per-Team activity feed (docs/website/TEAMS.md Part 4).
//
// The db layer is stubbed and an in-memory table stands in for `team_activity`,
// so these are assertions about the RULES: what a module is allowed to write,
// what a caller is allowed to see, and what the prune takes away. The three worth
// protecting are the ones that are easy to "simplify" into a leak:
//
// 1. a module writes only into its OWN active Teams, named by external id;
// 2. visibility defaults to `members` and is resolved from the session, never
// from a request parameter;
// 3. a hidden Team's feed does not answer a public caller.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const activityDb = require('../src/model/teams/teamActivity.db')
const teamsDb = require('../src/model/teams/teams.db')
const access = require('../src/model/teams/teamAccess.model')
const settings = require('../src/model/settings/settings.model')
const activity = require('../src/model/teams/teamActivity.model')
let store
const saved = new Map()
function patch(mod, name, fn) {
if (!saved.has(mod)) saved.set(mod, new Map())
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
mod[name] = fn
}
function restore() {
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
saved.clear()
}
function stub() {
store = {
rows: [],
nextId: 1,
teams: [
{ id: 1, module_id: 'uo', external_id: 'g1', slug: 'the-guild', hidden: 0, status: 'active' },
{ id: 2, module_id: 'uo', external_id: 'g2', slug: 'hidden-guild', hidden: 1, status: 'active' },
],
allowed: new Set(), // userIds with member/grant access, keyed "teamId:userId"
}
patch(teamsDb, 'findActive', async (moduleId, externalId) =>
store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active'))
patch(teamsDb, 'findBySlug', async (slug) => store.teams.find((t) => t.slug === slug))
patch(access, 'forumAccess', async (teamId, userId) => ({
allowed: store.allowed.has(`${teamId}:${userId}`),
viaMembership: store.allowed.has(`${teamId}:${userId}`),
viaGrant: false,
isLeader: false,
}))
patch(activityDb, 'insert', async (item) => {
if (item.dedupeKey && store.rows.some((r) => r.team_id === item.teamId && r.dedupe_key === item.dedupeKey)) {
return false // the unique index doing its job
}
store.rows.push({
id: store.nextId++,
team_id: item.teamId,
source: item.source,
kind: item.kind,
summary: item.summary,
visibility: item.visibility,
actor_member_key: item.actorMemberKey,
actor_user_id: item.actorUserId,
payload: item.payload,
occurred_at: new Date(item.occurredAt),
dedupe_key: item.dedupeKey,
})
return true
})
patch(activityDb, 'page', async (teamId, visibilities, { limit, offset }) =>
store.rows
.filter((r) => r.team_id === teamId && visibilities.includes(r.visibility))
.sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id)
.slice(offset, offset + limit))
patch(activityDb, 'count', async (teamId, visibilities) =>
store.rows.filter((r) => r.team_id === teamId && visibilities.includes(r.visibility)).length)
patch(activityDb, 'deleteOlderThan', async (days) => {
const cutoff = Date.now() - days * 86400_000
const before = store.rows.length
store.rows = store.rows.filter((r) => r.occurred_at.getTime() >= cutoff)
return before - store.rows.length
})
patch(activityDb, 'overCap', async (cap) => {
const byTeam = new Map()
for (const r of store.rows) byTeam.set(r.team_id, (byTeam.get(r.team_id) || 0) + 1)
return [...byTeam].filter(([, n]) => n > cap).map(([team_id, n]) => ({ team_id, n }))
})
patch(activityDb, 'trimToCap', async (teamId, cap) => {
const mine = store.rows
.filter((r) => r.team_id === teamId)
.sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id)
const keep = new Set(mine.slice(0, cap).map((r) => r.id))
const before = store.rows.length
store.rows = store.rows.filter((r) => r.team_id !== teamId || keep.has(r.id))
return before - store.rows.length
})
patch(settings, 'get', async () => null) // defaults
}
const item = (extra = {}) => ({ externalId: 'g1', kind: 'uo.thing', summary: 'A thing happened', ...extra })
beforeEach(stub)
afterEach(restore)
// ── What a module may write ────────────────────────────────────────────────
test('a pushed item lands against the team its external id names', async () => {
const stored = await activity.push('uo', [item()])
assert.equal(stored, 1)
assert.equal(store.rows[0].team_id, 1)
assert.equal(store.rows[0].source, 'uo')
})
test('a module cannot write into another module\'s team', async () => {
// 'other' owns no team with external id g1, so there is nothing to resolve —
// and no integer the module could have sent instead, which is the point of
// naming Teams by external id on this path.
const stored = await activity.push('other', [item()])
assert.equal(stored, 0)
assert.equal(store.rows.length, 0)
})
test('an unknown external id is dropped rather than raised', async () => {
const stored = await activity.push('uo', [item({ externalId: 'nope' })])
assert.equal(stored, 0)
})
test('visibility defaults to members, and an unknown value does not widen it', async () => {
await activity.push('uo', [item(), item({ visibility: 'everyone' }), item({ visibility: 'public' })])
assert.deepEqual(store.rows.map((r) => r.visibility), ['members', 'members', 'public'])
})
test('an item with no kind or no summary is dropped, and the rest of the batch still lands', async () => {
const stored = await activity.push('uo', [item({ kind: '' }), item({ summary: ' ' }), item()])
assert.equal(stored, 1)
assert.equal(store.rows.length, 1)
})
test('an over-long summary is truncated rather than losing the event', async () => {
await activity.push('uo', [item({ summary: 'x'.repeat(400) })])
assert.equal(store.rows[0].summary.length, 255)
})
test('a replayed batch with dedupe keys stores each item once', async () => {
const batch = [item({ dedupeKey: 'champ:77' }), item({ dedupeKey: 'champ:78' })]
await activity.push('uo', batch)
await activity.push('uo', batch) // the sidecar reconnect backfill
assert.equal(store.rows.length, 2)
})
test('items without a dedupe key are never collapsed into each other', async () => {
await activity.push('uo', [item(), item()])
assert.equal(store.rows.length, 2)
})
test('a push is never rejected for being malformed at the top level', async () => {
assert.equal(await activity.push('uo', null), 0)
assert.equal(await activity.push('uo', []), 0)
})
// ── What a caller may see ──────────────────────────────────────────────────
test('an anonymous caller gets public items only, and is told the scope', async () => {
await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })])
const feed = await activity.feedFor('the-guild', null)
assert.equal(feed.items.length, 1)
assert.equal(feed.items[0].visibility, 'public')
assert.equal(feed.scope, 'public')
// `total` is the caller's total, not the table's — otherwise paging lies.
assert.equal(feed.total, 1)
})
test('a member sees both, via the same resolver the forum uses', async () => {
await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })])
store.allowed.add('1:7')
const feed = await activity.feedFor('the-guild', 7)
assert.equal(feed.items.length, 2)
assert.equal(feed.scope, 'members')
})
test('an authenticated non-member is exactly an anonymous caller here', async () => {
await activity.push('uo', [item({ visibility: 'members' })])
const feed = await activity.feedFor('the-guild', 99)
assert.equal(feed.items.length, 0)
assert.equal(feed.scope, 'public')
})
test('a hidden team\'s feed does not answer the public, but does answer its members', async () => {
await activity.push('uo', [item({ externalId: 'g2', visibility: 'public' })])
assert.equal(await activity.feedFor('hidden-guild', null), null)
store.allowed.add('2:7')
const feed = await activity.feedFor('hidden-guild', 7)
assert.equal(feed.items.length, 1)
})
test('an unknown slug is not found rather than empty', async () => {
assert.equal(await activity.feedFor('no-such-team', null), null)
})
test('the rendered item carries the payload and never the actor identifiers', async () => {
await activity.push('uo', [item({
visibility: 'public', payload: { serial: '0x77' }, actorMemberKey: '0x40012ab3', actorUserId: 7,
})])
const feed = await activity.feedFor('the-guild', null)
assert.deepEqual(feed.items[0].payload, { serial: '0x77' })
assert.equal('actorMemberKey' in feed.items[0], false)
assert.equal('actorUserId' in feed.items[0], false)
})
// ── Core's own items ───────────────────────────────────────────────────────
test('core writes as source=core and public', async () => {
await activity.logCore({ teamId: 1, kind: activity.CORE_KINDS.MEMBER_JOINED, summary: 'Aldric joined' })
assert.equal(store.rows[0].source, 'core')
assert.equal(store.rows[0].visibility, 'public')
})
test('core refuses an item with nothing to say', async () => {
assert.equal(await activity.logCore({ teamId: 1, kind: 'core.x' }), false)
assert.equal(store.rows.length, 0)
})
// ── Retention ──────────────────────────────────────────────────────────────
test('the prune drops rows past the age horizon', async () => {
const old = Date.now() - 100 * 86400_000
await activity.push('uo', [item({ occurredAt: old }), item()])
const res = await activity.prune()
assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS)
assert.equal(res.byAge, 1)
assert.equal(store.rows.length, 1)
})
test('the prune trims a team back to the row cap, newest kept', async () => {
patch(settings, 'get', async (key) => (key === activity.CAP_KEY ? '3' : null))
const base = Date.now()
for (let i = 0; i < 6; i++) {
// eslint-disable-next-line no-await-in-loop
await activity.push('uo', [item({ summary: `event ${i}`, occurredAt: base + i * 1000 })])
}
const res = await activity.prune()
assert.equal(res.byCap, 3)
assert.deepEqual(store.rows.map((r) => r.summary), ['event 3', 'event 4', 'event 5'])
})
test('a zero or negative retention setting is rejected rather than emptying the feed', async () => {
patch(settings, 'get', async (key) => (key === activity.RETAIN_KEY ? '0' : null))
await activity.push('uo', [item()])
const res = await activity.prune()
assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS)
assert.equal(store.rows.length, 1)
})
test('an unreadable settings table leaves the defaults standing', async () => {
patch(settings, 'get', async () => { throw new Error('pool down') })
const res = await activity.retentionConfig()
assert.deepEqual(res, { days: activity.DEFAULT_RETAIN_DAYS, cap: activity.DEFAULT_ROW_CAP })
})

View File

@@ -287,3 +287,90 @@ test('a hung call does not hold the process open until its deadline', async () =
test('the budget is the documented ten seconds', () => {
assert.equal(teamProvider.CALL_TIMEOUT_MS, 10_000)
})
// ── projectRoster: the optional fourth member (§3.3) ───────────────────────
//
// The one Team call where a refusal must NOT be treated as staleness. Every test
// below exists because the obvious implementation — reuse `call()` and serve the
// roster when it fails — silently publishes the rows the rungs exist to withhold.
const rows = [{ member_key: '0x1' }, { member_key: '0x2' }]
test('projectRoster is optional: a provider without it registers fine', () => {
const api = registries.stage('uo')
assert.doesNotThrow(() => api.registerTeamProvider(ok()))
})
test('a non-function projectRoster is rejected at registration, not at call time', () => {
const api = registries.stage('uo')
assert.throws(
() => api.registerTeamProvider({ ...ok(), projectRoster: 'yes please' }),
/projectRoster must be a function/,
)
})
test('an unregistered method cannot ride along into the provider core calls', () => {
register('uo', { ...ok(), somethingElse: async () => 'hi' })
assert.equal(registries.registeredTeamProvider().somethingElse, undefined)
})
test('no provider at all is projects:false — nothing is being withheld', async () => {
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.equal(answer.ok, false)
assert.equal(answer.projects, false)
})
test('a provider that does not project is projects:false, not a failure to fear', async () => {
register('uo', ok())
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.equal(answer.ok, false)
assert.equal(answer.projects, false)
})
test('a provider that HAS projectRoster and refuses is projects:true — the caller must fail closed', async () => {
register('uo', { ...ok(), projectRoster: async () => ({ ok: false, reason: 'atlas not loaded' }) })
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.equal(answer.ok, false)
assert.equal(answer.projects, true)
assert.equal(answer.reason, 'atlas not loaded')
})
test('a projectRoster that throws is projects:true as well — a bug is not permission', async () => {
register('uo', { ...ok(), projectRoster: async () => { throw new Error('boom') } })
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.equal(answer.projects, true)
})
test('the module receives the rows and the viewer, and answers with member keys', async () => {
let seen
register('uo', {
...ok(),
projectRoster: async (externalId, members, viewer) => {
seen = { externalId, members, viewer }
return { ok: true, members: ['0x2'] }
},
})
const answer = await teamProvider.projectRoster('g1', rows, { userId: 7, role: 'player' })
assert.deepEqual(seen.members, rows)
assert.deepEqual(seen.viewer, { userId: 7, role: 'player' })
assert.equal(seen.externalId, 'g1')
assert.deepEqual(answer.members, ['0x2'])
})
test('a malformed key list is a refusal, so the caller fails closed rather than serving garbage', async () => {
for (const bad of [{ ok: true }, { ok: true, members: ['ok', ''] }, { ok: true, members: 'all' }]) {
// eslint-disable-next-line no-await-in-loop
register('uo', { ...ok(), projectRoster: async () => bad })
// eslint-disable-next-line no-await-in-loop
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.equal(answer.ok, false, JSON.stringify(bad))
assert.equal(answer.projects, true)
registries._reset()
}
})
test('duplicate keys are collapsed', async () => {
register('uo', { ...ok(), projectRoster: async () => ({ ok: true, members: ['0x1', '0x1', '0x2'] }) })
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.deepEqual(answer.members, ['0x1', '0x2'])
})

View File

@@ -0,0 +1,122 @@
// The roster read and its audience projection (docs/website/TEAMS.md §3.2, §3.3).
//
// Two questions meet here and the file exists to keep them apart:
//
// WHICH ROWS is the module's — it owns the visibility framework and its rung
// configuration, and core does not know what a rung is.
// WHAT A ROW is core's — the member key and the user id are never published,
// LOOKS LIKE whatever the module answers.
//
// The dangerous simplification is to let the module return rows instead of keys:
// core's field guarantee would then rest on every module's good behaviour rather
// than on core, and one module re-adding a `userId` would publish site accounts
// against in-game characters on a public page.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const teamsDb = require('../src/model/teams/teams.db')
const access = require('../src/model/teams/teamAccess.model')
const teamProvider = require('../src/model/teams/teamProvider')
const teamSync = require('../src/model/teams/teamSync.model')
const teams = require('../src/model/teams/teams.model')
const saved = new Map()
function patch(mod, name, fn) {
if (!saved.has(mod)) saved.set(mod, new Map())
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
mod[name] = fn
}
function restore() {
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
saved.clear()
}
const ROWS = [
{ member_key: '0x1', display_name: 'Aldric', user_id: 7, is_leader: 1, rank_label: 'Leader', online: 1 },
{ member_key: '0x2', display_name: 'Brenna', user_id: null, is_leader: 0, rank_label: null, online: 0 },
{ member_key: '0x3', display_name: 'Cadfael', user_id: 9, is_leader: 0, rank_label: null, online: 0 },
]
beforeEach(() => {
patch(teamsDb, 'findBySlug', async (slug) =>
(slug === 'the-guild'
? { id: 1, external_id: 'g1', slug, hidden: 0, status: 'active', roster_synced_at: null }
: undefined))
patch(access, 'rosterWithOverrides', async () => ROWS.map((r) => ({ ...r })))
// syncStatus() reads sync state and the poll interval; neither is what this
// file is about, and both would otherwise reach the pool.
patch(teamProvider, 'providerModuleId', () => null)
patch(teamSync, 'intervalSeconds', async () => 900)
})
afterEach(restore)
test('with no module projecting, the whole roster is served at core\'s public shape', async () => {
patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: false, reason: 'no provider' }))
const roster = await teams.rosterPublic('the-guild', null)
assert.equal(roster.members.length, 3)
assert.equal(roster.projected, false)
assert.equal(roster.projectionUnavailable, undefined, 'nothing was withheld, so nothing to report')
})
test('the module chooses which rows a viewer sees', async () => {
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x2'] }))
const roster = await teams.rosterPublic('the-guild', null)
assert.deepEqual(roster.members.map((m) => m.displayName), ['Brenna'])
assert.equal(roster.projected, true)
})
test('a module that projects but cannot answer withholds the roster — it does not serve it', async () => {
// The whole point. "Leave it alone" is right for a roster SYNC and wrong for a
// visibility question: it would publish exactly what the rungs withhold.
patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: true, reason: 'sidecar down' }))
const roster = await teams.rosterPublic('the-guild', null)
assert.deepEqual(roster.members, [])
assert.equal(roster.projected, false)
assert.equal(roster.projectionUnavailable, true, 'an empty roster must be distinguishable from a silent one')
})
test('the module cannot widen the published fields, only narrow the rows', async () => {
// A module answering with keys it was given still yields core's shape. There is
// no answer it can give that puts a member key or a user id on a public page.
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0x2', '0x3'] }))
const roster = await teams.rosterPublic('the-guild', null)
for (const member of roster.members) {
assert.deepEqual(
Object.keys(member).sort(),
['displayName', 'isLeader', 'linked', 'online', 'rankLabel'],
'the public member shape is core\'s and is closed',
)
}
assert.deepEqual(roster.members.map((m) => m.linked), [true, false, true])
})
test('a key the module invents matches nothing rather than adding a row', async () => {
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0xNOPE'] }))
const roster = await teams.rosterPublic('the-guild', null)
assert.equal(roster.members.length, 1)
})
test('the viewer is described to the module, not handed over', async () => {
let seen
patch(teamProvider, 'projectRoster', async (externalId, members, viewer) => {
seen = viewer
return { ok: true, members: members.map((m) => m.member_key) }
})
await teams.rosterPublic('the-guild', { userId: 7, role: 'player' })
assert.deepEqual(seen, { userId: 7, role: 'player' })
})
test('an unknown slug is not found, and the module is never consulted about it', async () => {
let called = false
patch(teamProvider, 'projectRoster', async () => { called = true; return { ok: true, members: [] } })
assert.equal(await teams.rosterPublic('no-such-team', null), null)
assert.equal(called, false)
})
test('a hidden team\'s roster does not answer publicly at all', async () => {
patch(teamsDb, 'findBySlug', async () => ({ id: 1, external_id: 'g1', slug: 'x', hidden: 1, status: 'active' }))
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1'] }))
assert.equal(await teams.rosterPublic('x', null), null)
})

View File

@@ -12,6 +12,7 @@ const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const teamsDb = require('../src/model/teams/teams.db')
const moderation = require('../src/model/teams/teamModeration.model')
const activity = require('../src/model/teams/teamActivity.model')
const settings = require('../src/model/settings/settings.model')
const teamSync = require('../src/model/teams/teamSync.model')
@@ -113,6 +114,16 @@ function stubDb() {
patch(teamsDb, 'memberKeys', async (teamId) =>
[...membersOf(teamId).values()].filter((m) => m.status === 'active').map((m) => m.member_key))
// The sync reads the full rows, not just the keys: the activity feed needs each
// changing member's display name and PRIOR is_leader, both of which the upsert
// is about to overwrite. Stubbing this is not optional — an unstubbed seam here
// reaches the real pool, and the symptom is the suite hanging on dead-pool
// retries rather than failing (see test/_setup.js).
patch(teamsDb, 'membersByTeam', async (teamId, { includeDeparted = false } = {}) =>
[...membersOf(teamId).values()]
.filter((m) => includeDeparted || m.status === 'active')
.map((m) => ({ ...m })))
patch(teamsDb, 'upsertMember', async (m) => {
const existing = membersOf(m.teamId).get(m.memberKey)
membersOf(m.teamId).set(m.memberKey, {
@@ -182,6 +193,16 @@ function stubDb() {
return { hidden: false }
})
patch(moderation, 'rescreen', async () => 0)
// The activity feed is its own unit (teamActivity.test.js); here it is captured
// so the reconciler's side of §4.2 can be asserted without a database. Stubbing
// the MODEL rather than the db layer keeps these tests about which items the
// sync decides to emit, which is the reconciler's half of the contract.
store.activity = []
patch(activity, 'logCore', async (item) => {
store.activity.push(item)
return true
})
}
// A provider whose answers the test controls. Defaults are authoritative and
@@ -802,3 +823,155 @@ test('start() is inert with no provider registered', async () => {
await teamSync.start()
assert.equal(store.teams.length, 0)
})
// ── Core's own activity items (§4.2) ───────────────────────────────────────
//
// The reconciler's half of the feed: which items it DECIDES to emit. The feed's
// own rules — visibility, dedupe, retention — live in teamActivity.test.js.
const kinds = () => store.activity.map((a) => a.kind)
const summaries = () => store.activity.map((a) => a.summary)
const withMembers = (members, leaders = []) => ({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
getTeamLeaders: async () => ({ ok: true, leaders }),
})
// Re-provide between runs: the tests above establish that a provider registers
// once, so a second answer means a fresh registration.
async function resync(overrides, reason) {
registries._reset()
provide(overrides)
return teamSync.reconcileNow(reason)
}
test('the FIRST roster emits nothing — an import is not 155 people joining', async () => {
provide(withMembers([member('0x1'), member('0x2')]))
await teamSync.reconcileNow('setup')
assert.equal(activeMembers(1).length, 2, 'the members did land')
assert.deepEqual(store.activity, [], 'and none of them was announced')
})
test('a member arriving after the first roster is announced', async () => {
provide(withMembers([member('0x1')]))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2', { displayName: 'Brenna' })]), 'test')
assert.deepEqual(kinds(), ['core.member.joined'])
assert.deepEqual(summaries(), ['Brenna joined'])
assert.equal(store.activity[0].actorMemberKey, '0x2')
})
test('a member who leaves is announced by the name core last knew them by', async () => {
provide(withMembers([member('0x1'), member('0x2', { displayName: 'Brenna' })]))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1')]), 'test')
// The module no longer mentions them at all, so the display name can only come
// from the row core is about to depart — which is why the sync reads the ROWS
// before the upsert rather than just the keys.
assert.deepEqual(kinds(), ['core.member.left'])
assert.deepEqual(summaries(), ['Brenna left'])
})
test('an INCOMPLETE answer announces no departures, because it removed none', async () => {
provide(withMembers([member('0x1'), member('0x2')]))
await teamSync.reconcileNow('setup')
await resync({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, complete: false, members: [member('0x1')] }),
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
}, 'partial')
assert.equal(activeMembers(1).length, 2, 'nobody was removed')
assert.deepEqual(store.activity, [], 'so nobody is announced as leaving')
})
test('promotion and demotion are announced; unchanged leadership is not', async () => {
provide(withMembers([member('0x1'), member('0x2')], ['0x1']))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2')], ['0x2']), 'test')
assert.deepEqual(kinds(), ['core.leader.changed', 'core.leader.changed'])
assert.deepEqual(summaries().sort(), ['0x1 stepped down as a leader', '0x2 became a leader'])
})
test('a member who joins already a leader is announced once, as joining', async () => {
provide(withMembers([member('0x1')], ['0x1']))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2')], ['0x1', '0x2']), 'test')
assert.deepEqual(kinds(), ['core.member.joined'], 'never a non-leader here to be promoted from')
})
test('a departing leader is announced as leaving, not as stepping down', async () => {
provide(withMembers([member('0x1'), member('0x2')], ['0x1', '0x2']))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1')], ['0x1']), 'test')
assert.deepEqual(kinds(), ['core.member.left'], 'one event, not two')
})
test('a refused leadership answer announces nothing — it demoted nobody', async () => {
provide(withMembers([member('0x1')], ['0x1']))
await teamSync.reconcileNow('setup')
await resync({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
getTeamLeaders: async () => ({ ok: false, reason: 'unavailable' }),
}, 'test')
assert.deepEqual(store.activity, [])
assert.equal(activeMembers(1)[0].is_leader, 1, 'and left the stored value alone')
})
test('a rename is announced on the successor, naming the old name', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }),
getTeamMembers: async () => ({ ok: true, members: [] }),
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
})
await teamSync.reconcileNow('setup')
await resync({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Golden Hand')] }),
getTeamMembers: async () => ({ ok: true, members: [] }),
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
}, 'rename')
const renames = store.activity.filter((a) => a.kind === 'core.team.renamed')
assert.equal(renames.length, 1)
assert.equal(renames[0].summary, 'Renamed from The Silver Hand')
// The SUCCESSOR row, not the archived one: the archived row is a read-only
// record of what came before, and the reader is looking at the live page.
const successor = store.teams.find((t) => t.status === 'active')
assert.equal(renames[0].teamId, successor.id)
})
test('a member with no display name is announced without leaking the member key', async () => {
provide(withMembers([member('0x1')]))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2', { displayName: null })]), 'test')
assert.deepEqual(summaries(), ['A member joined'])
})
test('a feed write that fails never fails the sync', async () => {
patch(activity, 'logCore', async () => { throw new Error('table gone') })
provide(withMembers([member('0x1')]))
await teamSync.reconcileNow('setup')
const result = await resync(withMembers([member('0x1'), member('0x2')]), 'test')
assert.equal(result.ok, true)
assert.equal(activeMembers(1).length, 2, 'the roster is the source of truth and it applied')
})