All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
PLAN.md §13 phase 4: /features/, /architecture/, /modules/, /integrations/, and /community/ — plus the two scope items the phase table never assigned to anyone. Six decisions taken by the org lead before coding, recorded in PLAN.md §10 as D20-D25: - D20 /features/ is the homepage's list with a `detail` line, not a second list. One data file, two renderings, so they cannot disagree about what exists. - D21 /architecture/ draws reasons, not reference: three new inline SVGs, one per boundary. No endpoint tables, no config keys — those are phase 8's and stay canonical in docs/. - D22 The deliberate absences of §2 become one tagged data file, rendered on the three pages that promise them. - D23 Phase 4 absorbs /community/ (specified in §10 and §14 N3, linked from the header since phase 1, built by no phase) and checkLinks.mjs. - D24 `needsModule`: writing the Teams detail exposed a false claim phase 3 shipped. Teams are module-sourced only — teams.module_id is NOT NULL, there is no create route, sync is gated on providerModuleId() — so the Community group no longer says a bare core does all of it. - D25 The per-capability demo affordance brand.json had promised since phase 2 is a deep link, filled at boot from data-demo-path. checkLinks.mjs reads the built HTML rather than src/, because half these links are assembled from data files and template literals. Its PLANNED_ROUTES list is checked in both directions, so it cannot rot into a permanent exemption. applyBrand.mjs gained a pass that recomputes deep links from their immutable path, making it idempotent and reversible; checkBrand.mjs lifts that pattern out and runs it against the stock markup so the two cannot drift. Both proved against a real mount, in both directions. Fixes a cascade bug the checks could not see: [data-demo-url=''] and a scoped component class are both specificity 0,1,0, so .demo-link's `display` beat the hide rule and twelve links to a nonexistent demo rendered, each resolving to the current page. The rule is now !important. The four diagrams' shared SVG vocabulary moved to src/styles/diagram.css. Verified from a clean checkout: npm ci, all five checks, astro check (0 errors), production build, and a live browser pass at desktop and 390px. Co-Authored-By: Claude <noreply@anthropic.com>
302 lines
11 KiB
Plaintext
302 lines
11 KiB
Plaintext
---
|
||
import Base from '../layouts/Base.astro';
|
||
import PageHeader from '../components/PageHeader.astro';
|
||
import NotBuilt from '../components/NotBuilt.astro';
|
||
|
||
import platform from '../data/platform.json';
|
||
|
||
/**
|
||
* `/integrations/` — PLAN.md §13 phase 4.
|
||
*
|
||
* §10 gives it Discord, mobile and push, SSO, "with an explicit 'not built' list". The
|
||
* explicit list is the reason this page is worth writing carefully: an integrations page is
|
||
* the one a reader scans for the name of the thing they already use, and the honest answer
|
||
* for several of those names is no. §2 calls the absent-features list as load-bearing as the
|
||
* rest, and `NotBuilt` at the foot of this page is where that lands.
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* THE TRADE-OFFS ARE ON THE PAGE
|
||
* ---------------------------------------------------------------------------------------
|
||
* Each integration carries a `caveat` — the thing you would find out in week two. Discord
|
||
* voice channels make Team membership visible on a member's Discord profile, because they
|
||
* are granted by role; the mobile app has no server of ours to point at; SSO will not create
|
||
* an account. None of those is a defect and all three change whether someone wants the
|
||
* feature, so leaving them for the documentation would be the dishonest kind of brevity.
|
||
* That is D8's "understated honesty" doing something other than adjusting adjectives.
|
||
*
|
||
* No version numbers are typed here. The app version and the platform's own numbers come
|
||
* from `platform.json` (§12), which `checkFacts.mjs` re-reads from each repository's
|
||
* authority on every build.
|
||
*/
|
||
const title = 'Integrations';
|
||
const description =
|
||
'What Runic Gateway connects to — Discord, mobile push, single sign-on — and what it ' +
|
||
'deliberately does not.';
|
||
|
||
const integrations = [
|
||
{
|
||
id: 'discord',
|
||
name: 'Discord',
|
||
summary:
|
||
'A bot for the server your community is already sitting in, doing three separate jobs.',
|
||
points: [
|
||
{
|
||
title: 'Slash commands',
|
||
body:
|
||
'Commands registered with your guild that answer from your site — so the thing ' +
|
||
'somebody wants to look up is available where the conversation is happening, ' +
|
||
'rather than one tab away.',
|
||
},
|
||
{
|
||
title: 'Notifications into channels',
|
||
body:
|
||
'News and Team activity bridged into the channels you choose, with a per-Team ' +
|
||
'override so one group can route its own notifications somewhere else. Delivery ' +
|
||
'is best-effort and one-shot: a Discord outage never backs anything up on your ' +
|
||
'site.',
|
||
},
|
||
{
|
||
title: 'A voice channel per Team',
|
||
body:
|
||
'A Team can be granted its own voice channel, with membership maintained by the ' +
|
||
'bot rather than by whoever is online. The bot creates the category, and the ' +
|
||
'panel reports how many roles your guild has left before Discord’s own limit.',
|
||
},
|
||
],
|
||
caveat:
|
||
'Voice access is granted with a Discord role, and roles are visible on a member’s ' +
|
||
'profile — so a Team with a voice channel is a Team anyone in your guild can see the ' +
|
||
'membership of. That was a deliberate trade for a limit that counts per guild rather ' +
|
||
'than per channel, and it is the right one for most communities, but it is not private.',
|
||
},
|
||
{
|
||
id: 'mobile',
|
||
name: 'Mobile and push',
|
||
summary:
|
||
'A native Android app against the same documented API the website uses, with push ' +
|
||
'through a server you run.',
|
||
points: [
|
||
{
|
||
title: 'The same API, not a second one',
|
||
body:
|
||
'The app is a client of the API your deployment already publishes, authenticated ' +
|
||
'with short-lived tokens and rotated, revocable refresh tokens. There is no ' +
|
||
'mobile-only backend to keep in step.',
|
||
},
|
||
{
|
||
title: 'Push through your own ntfy',
|
||
body:
|
||
'Notifications are delivered by a self-hosted ntfy server rather than a vendor in ' +
|
||
'the middle. Each person chooses which streams reach them; push arrives by ' +
|
||
'default and can be switched off entirely.',
|
||
},
|
||
{
|
||
title: 'Trusted devices and two-factor',
|
||
body:
|
||
'The app shares the site’s account model, including time-based two-factor ' +
|
||
'codes, recovery codes, and devices you can mark as trusted and revoke later.',
|
||
},
|
||
],
|
||
caveat:
|
||
'The app points at no server of ours: the person installing it types the address of ' +
|
||
'the deployment they belong to. That is what makes one app work for every community ' +
|
||
'running this software, and it means the app is useless until somebody gives them a ' +
|
||
'URL — which is a thing worth putting in your welcome message.',
|
||
},
|
||
{
|
||
id: 'sso',
|
||
name: 'Single sign-on',
|
||
summary:
|
||
'OAuth2 and OIDC, against Google, Discord, or any provider you already run.',
|
||
points: [
|
||
{
|
||
title: 'Any OIDC provider',
|
||
body:
|
||
'Google and Discord are configured by name; anything else that speaks OIDC is ' +
|
||
'configured generically. Client secrets are encrypted at rest and never returned ' +
|
||
'to any client.',
|
||
},
|
||
{
|
||
title: 'It signs people in, not up',
|
||
body:
|
||
'An external identity has to be linked to an account that already exists on your ' +
|
||
'site. Signing in with a provider never creates a user — which means the way ' +
|
||
'someone joins your community stays a decision you make, not one Google makes.',
|
||
},
|
||
{
|
||
title: 'It respects the rest of the login rules',
|
||
body:
|
||
'Two-factor, trusted devices and bans all still apply. An identity provider ' +
|
||
'proves who someone is; it does not decide whether they may come in.',
|
||
},
|
||
],
|
||
caveat:
|
||
'Link-only is a policy, not a limitation to be worked around. If you were expecting ' +
|
||
'to open registration by turning on Google sign-in, this will not do that, and it is ' +
|
||
'not configurable.',
|
||
},
|
||
];
|
||
---
|
||
|
||
<Base title={title} description={description}>
|
||
<PageHeader eyebrow="What it connects to" title="The things it talks to, and the things it does not">
|
||
<p>
|
||
Three integrations exist and are in use. Each one below says what it does, and then the
|
||
thing you would otherwise discover in week two — because an integrations page that only
|
||
lists the good half is how somebody ends up rebuilding their community around an
|
||
assumption.
|
||
</p>
|
||
<p>
|
||
Everything here is configured on your own deployment, against services you already run
|
||
or already have an account with. Nothing routes through us; there is no us to route
|
||
through.
|
||
</p>
|
||
</PageHeader>
|
||
|
||
{
|
||
integrations.map((integration) => (
|
||
<section class="page section integ" id={integration.id}>
|
||
<h2>{integration.name}</h2>
|
||
<p class="prose integ__summary">{integration.summary}</p>
|
||
|
||
<ul class="integ__grid">
|
||
{integration.points.map((point) => (
|
||
<li class="panel">
|
||
<h3>{point.title}</h3>
|
||
<p>{point.body}</p>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<div class="panel integ__caveat">
|
||
<p class="integ__caveat-label">Worth knowing first</p>
|
||
<p>{integration.caveat}</p>
|
||
</div>
|
||
</section>
|
||
))
|
||
}
|
||
|
||
<section class="page section integ" id="email">
|
||
<h2>Email, deliberately quiet</h2>
|
||
<p class="prose integ__summary">
|
||
Your deployment can send email — Team notifications and newsletters, through an account
|
||
you connect — and it only ever sends to someone who asked for it. Email is the one
|
||
channel that is opt-in rather than opt-out, because an unwanted push notification is an
|
||
annoyance and an unwanted email is a complaint to somebody’s provider.
|
||
</p>
|
||
<p class="prose integ__note">
|
||
This website is a separate matter: <em>runicgateway.com</em> sends no email at all, has
|
||
no mailbox behind it and no account to make. The address in the footer is a human being.
|
||
</p>
|
||
</section>
|
||
|
||
<NotBuilt scope="integrations" title="Integrations that do not exist" />
|
||
|
||
<section class="page section integ" id="build">
|
||
<div class="panel integ__build">
|
||
<p class="eyebrow">If you need another one</p>
|
||
<h2>The API is the integration point</h2>
|
||
<p class="prose">
|
||
The whole backend is described by an OpenAPI 3.0 specification that ships with the
|
||
server, and an installed module merges its own routes into it — so whatever you build
|
||
against is documented by the thing that is actually running, at
|
||
{' '}Module API {platform.moduleApi}. The bridge to a game server is a documented wire
|
||
protocol on the same principle, currently protocol {platform.protocol}.
|
||
</p>
|
||
<div class="integ__actions">
|
||
<a class="btn btn--primary" href="/modules/">How modules work</a>
|
||
<a class="btn btn--ghost" href="/architecture/">The architecture</a>
|
||
<a class="btn btn--ghost" href="/docs/">The documentation</a>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</Base>
|
||
|
||
<style>
|
||
.integ h2 {
|
||
margin: 0 0 0.75rem;
|
||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||
}
|
||
|
||
.integ__summary {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.integ__note {
|
||
margin: 0.85rem 0 0;
|
||
color: var(--dim);
|
||
font-size: 0.94rem;
|
||
}
|
||
|
||
.integ__grid {
|
||
display: grid;
|
||
gap: 1rem;
|
||
margin: 2rem 0 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||
}
|
||
|
||
.integ__grid h3 {
|
||
margin: 0 0 0.5rem;
|
||
color: var(--gold);
|
||
font-size: 1.02rem;
|
||
}
|
||
|
||
.integ__grid p {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: 0.94rem;
|
||
}
|
||
|
||
/* The caveat is a panel like the others rather than a warning box. It is information
|
||
of the same kind and the same weight — the difference is that it is the half a
|
||
reader is not expecting, which is a reason to give it its own line, not a reason
|
||
to make it look like an error message. */
|
||
.integ__caveat {
|
||
margin-top: 1rem;
|
||
border-left: 3px solid var(--gold-deep);
|
||
}
|
||
|
||
.integ__caveat p {
|
||
margin: 0;
|
||
max-width: var(--measure);
|
||
color: var(--muted);
|
||
font-size: 0.94rem;
|
||
}
|
||
|
||
.integ__caveat-label {
|
||
color: var(--gold);
|
||
font-size: 0.74rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.integ__caveat .integ__caveat-label {
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.integ__build {
|
||
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||
}
|
||
|
||
.integ__build h2 {
|
||
margin: 0 0 0.75rem;
|
||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||
}
|
||
|
||
.integ__build .prose {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.integ__actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.7rem;
|
||
margin-top: 1.75rem;
|
||
}
|
||
</style>
|