feat(teams): the activity feed, the roster projection, and the inverted slot #152
@@ -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
|
||||
|
||||
100
client/src/lib/teamActivity.js
Normal file
100
client/src/lib/teamActivity.js
Normal 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.'
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
96
client/src/modules/TeamActivityFeed.jsx
Normal file
96
client/src/modules/TeamActivityFeed.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
78
client/test/teamActivity.test.js
Normal file
78
client/test/teamActivity.test.js
Normal 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/)
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
133
server/src/model/teams/teamActivity.db.js
Normal file
133
server/src/model/teams/teamActivity.db.js
Normal 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,
|
||||
}
|
||||
312
server/src/model/teams/teamActivity.model.js
Normal file
312
server/src/model/teams/teamActivity.model.js
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 module’s own identifier'
|
||||
// #swagger.description = 'Exists so a module’s page can find core’s Team without holding core’s identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module, so the scope is what stops one module reading another’s Team by guessing a serial. A hidden Team returns 404, like every other public lookup.'
|
||||
// #swagger.parameters['moduleId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The module that owns the Team.' }
|
||||
// #swagger.parameters['externalId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'That module’s own identifier for it.' }
|
||||
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeam" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
ctrl.getTeamByExternalId,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:slug',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
@@ -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 module’s projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the module’s audience projection; sending a session is optional and may widen it.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The roster, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamRoster" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
optionalAuth,
|
||||
ctrl.getRoster,
|
||||
)
|
||||
|
||||
// The one route in this tier that reads the caller's identity. `optionalAuth`
|
||||
// serves anonymous callers rather than rejecting them, and identifies an
|
||||
// authenticated one properly enough that a banned or logged-out account drops
|
||||
// back to the public half of the feed at once (TEAMS.md §4.3).
|
||||
teamsRouter.get(
|
||||
'/:slug/activity',
|
||||
// #swagger.tags = ['Public · Teams']
|
||||
// #swagger.summary = 'A Team’s activity feed, filtered to what the caller may see'
|
||||
// #swagger.description = 'Items are `public` or `members`. Anyone who can see the Team gets the public ones; members and forum-granted users also get the members-only ones, and the response says which via `scope` so a client can render "some items are hidden" rather than presenting a filtered feed as the whole one. Sending a session is optional.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 100 (default 50).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'One page of the feed', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamActivity" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden from this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
siteMode,
|
||||
optionalAuth,
|
||||
ctrl.getActivity,
|
||||
)
|
||||
|
||||
module.exports = teamsRouter
|
||||
|
||||
@@ -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 {
|
||||
|
||||
64
server/src/utils/teamActivityPrune.js
Normal file
64
server/src/utils/teamActivityPrune.js
Normal 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 }
|
||||
@@ -10482,6 +10482,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/teams/by-external/{moduleId}/{externalId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Teams"
|
||||
],
|
||||
"summary": "Get one Team by the owning module’s own identifier",
|
||||
"description": "Exists so a module’s page can find core’s Team without holding core’s identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module, so the scope is what stops one module reading another’s Team by guessing a serial. A hidden Team returns 404, like every other public lookup.",
|
||||
"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 module’s 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 Team’s 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 module’s projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the module’s audience projection; sending a session is optional and may widen it.",
|
||||
"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 core’s 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 module’s `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 caller’s 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 core’s 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 module’s `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 caller’s 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: {
|
||||
|
||||
271
server/test/teamActivity.test.js
Normal file
271
server/test/teamActivity.test.js
Normal 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 })
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
122
server/test/teamRoster.test.js
Normal file
122
server/test/teamRoster.test.js
Normal 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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user