Skip to content

How the Hub works

Jaloliddin Ismailov edited this page Oct 4, 2026 · 8 revisions

The Hub is one application wearing three faces. A public website explains what YOUNGO is and publishes its working groups, conferences and resources. A member interface behind sign-in carries the constituency's daily work: group workspaces, events, opportunities, decisions, elections and membership housekeeping. And a set of API endpoints carries the staff and review workflows that never appear on the public site. All of it runs inside a single Next.js process backed by a single PostgreSQL database. The member interface is a set of App Router client routes under (site)/(member) — real URLs rendered by the components in src/member/, not an embedded single-page app.

The deliberate surprise is what is missing. There is no admin panel. Payload's generated admin UI is disabled, and no equivalent was built to replace it. Staff administration — verifying an account, reviewing a membership appeal, publishing a decision — happens through API endpoints protected by the same permission rules as everything else, or through terminal scripts that call the Payload local API directly. This is a security decision as much as an aesthetic one: every admin surface that exists must be defended, and a generic CRUD panel that can edit any row in any collection is exactly the surface you do not want reachable by accident.

The shape of the system

flowchart TB
    subgraph Clients
        Pub[Public visitors]
        Mem[Members]
        Staff[Staff & reviewers]
        Agents[MCP agents]
    end

    subgraph App["Next.js application (one process)"]
        Web[Public website & member SPA]
        API["REST endpoints<br/>/api/*"]
        Payload[Payload CMS<br/>local API]
        Access["Access engine<br/>src/lib/access.ts"]
    end

    subgraph Data["PostgreSQL"]
        Auth["authority_records"]
        Rest[Collections:<br/>accounts, decisions,<br/>content, events…]
    end

    MCP["mcp-content server<br/>(separate process)"]
    Mail["Email / push<br/>delivery"]

    Pub --> Web
    Mem --> Web
    Staff --> API
    Web --> API
    Agents --> MCP --> API
    API --> Access --> Payload
    Payload --> Data
    API --> Mail
Loading

Two arrows in that diagram deserve attention. The first is that every write — from a browser, a staff script, or an agent — funnels through the same access engine. There is no privileged path that skips src/lib/access.ts. The second is that mcp-content, the HTTP service that lets LLM agents create and review content, is a separate process that talks to the Hub over its public HTTP API. It holds no database credentials of its own; it cannot see anything an ordinary authenticated client could not.

One database, one authority model

Earlier iterations of the Hub kept two overlapping stores for "who is allowed to do what": a legacy participation ledger and a newer mandates table, with a layer of supersession logic keeping them coherent. That duplication has been retired. A single table, authority_records, now records every scoped responsibility in the constituency — working-group contact points, Council seats, body liaisons, team memberships, safeguarding officers — as a row with a role, a scope, a time window and provenance.

This matters because authority in YOUNGO is never ambient. A working-group contact point can manage their own group's events and records, but nothing in that mandate spills over to a neighbouring group. Modelling each responsibility as a row rather than a flag on the account is what makes that scoping enforceable — Organisation and permissions covers the model in full.

flowchart LR
    subgraph Record["authority_records row"]
        Role[role<br/>wg.contact_point]
        Scope["scope<br/>working_group:47"]
        Window["starts_at → ends_at"]
        Prov[provenance<br/>kind, migrated_from]
    end

    Record --> Derive["deriveAuthority()<br/>resolves role → capabilities"]
    Derive --> Cap["Capabilities used by<br/>endpoints & hooks"]
Loading

The request lifecycle

Every protected action travels the same route, whether it originates in a browser fetch, an MCP tool call or a script. The browser sends the request with the member's session; the endpoint resolves the account, derives their access profile from authority_records, and only then touches the data.

sequenceDiagram
    actor Member
    participant Browser as Member interface
    participant Server as Hub server
    participant Access as Access engine
    participant DB as PostgreSQL

    Member->>Browser: Open a group / submit a proposal
    Browser->>Server: Request + session cookie
    Server->>Access: Resolve account, derive profile
    Access->>DB: Read authority_records
    DB-->>Access: Scoped, time-bounded records
    Access-->>Server: Capabilities for this account

    alt Allowed
        Server->>DB: Read or write permitted rows
        DB-->>Server: Result
        Server-->>Browser: Permitted result
    else Not allowed
        Server-->>Browser: Sign-in or access error
    end
Loading

Interface-level hiding is never the security boundary. A hidden button is a courtesy; the same rule is enforced again server-side on every request. When you review a pull request that adds a member-facing feature, this is the question to ask: does the endpoint check, or only the component?

Where the rules come from

The permission vocabulary is not invented by the code — it encodes YOUNGO's actual governing documents. The role registry in src/lib/authority.ts cites its sources: the S13 Governance Policy for bodies and Council seats, the S25 mandate for working-group contact points, S17 for membership, S10 for elections, S23 and S04 for safeguarding. When the policy says a Council seat belongs to a working group rather than a person, the data model follows: seats resolve to scopes, and substitutes cover rather than duplicate.

The same discipline applies to data. The repository contains schema and code, never member data — there are no seed files full of invented names, no hard-coded officers, no fixture rows committed to source. A fresh deployment is empty until a provisioning script or an operator fills it, and demo environments get their casts generated into the database, not into git. Data and migrations explains why that matters and how migrations carry structure without carrying rows.

The moving parts, briefly

Beyond the core loop above, the Hub carries several self-contained domains, each with its own page here:

  • Governance — decision pipelines from draft to outcome, Council electorates, selections, and credential-based elections. See Governance and decisions.
  • Content — a draft → review → publish pipeline for pages, resources, announcements and opportunities, used identically by members and agents. See Content and publishing.
  • Negotiations — track workspaces, document versioning, extraction review and contribution projects for UNFCCC negotiation work.
  • Intelligence — read-only query adapters over Hub data with strict evidence rules, plus a narrow, human-approved writeback for research notes. Its invariants are documented in AGENTS.md and enforced in src/lib/access.ts.
  • Notifications — an outbox model that drains email and web-push through a cron endpoint, with categories members can opt out of individually. See Notifications and delivery.

Each of these is a consumer of the authority model, not a parallel system — a contact point's ability to publish to their group's page is the same authority_records row that lets them manage its events. That is the unifying idea of the Hub: one store of truth for who may do what, and every feature deriving from it rather than inventing its own answer.

Clone this wiki locally