SGIPv0.1

Build with Next.js

This walkthrough turns a fresh Next.js app into a game platform that joins SGIP. It uses Next.js route handlers and Node's built-in crypto, and nothing else. Follow it through and the hub's conformance check passes every check.

You'll build four things: a descriptor that says who you are, an inbox the hub delivers to, a visit page for players from other games and a way for them to leave, and a script that registers you with the hub. You need Node.js 24 or newer, which runs the TypeScript scripts directly.

1. Create the app

Shell
npx create-next-app@latest my-game --ts --app --empty --use-npm --yes
cd my-game

The code below imports files with their .ts extension, so the scripts can run straight in Node. Allow that in tsconfig.json:

JSON
{
  "compilerOptions": {
    "allowImportingTsExtensions": true
  }
}

In package.json, mark the project as ES modules and add four scripts:

JSON
{
  "type": "module",
  "scripts": {
    "keys": "node scripts/keys.ts",
    "hello": "node --env-file=.env.local scripts/hello.ts",
    "check": "node --env-file=.env.local scripts/check.ts",
    "vectors": "node scripts/check-vectors.ts"
  }
}

2. Settings

Create .env.example, copy it to .env.local, and fill it in. Your platform id is your game's domain: players are addressed handle@yourgame.example. You don't set the hub's key: the library looks it up at <hub>/.well-known/sgip-hub.json and keeps it until the expiry the hub publishes.

Shell
# .env.example
# Your game's domain: players are addressed handle@SGIP_PLATFORM_ID.
SGIP_PLATFORM_ID=yourgame.example
SGIP_PLATFORM_NAME="Your Game"
SGIP_PLATFORM_ICON="🎮"
# Where the app is reachable over HTTPS: the hub fetches https://SGIP_PLATFORM_ID/.well-known/sgip.json.
APP_URL=https://yourgame.example

# From `npm run keys`. Never commit the secret.
SGIP_PUBLIC_KEY=
SGIP_SECRET_KEY=

# The hub. Its public key is looked up at <hub>/.well-known/sgip-hub.json.
SGIP_HUB_URL=

To make your key pair, add this script and run npm run keys, then paste the two lines into .env.local. Never commit the secret.

TypeScript
// scripts/keys.ts
// Prints a fresh Ed25519 key pair as .env lines. Keep SGIP_SECRET_KEY secret.
// Usage: npm run keys
import { generateKeyPairSync } from 'node:crypto';

const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const raw = (b64url: string) => Buffer.from(b64url, 'base64url').toString('base64');

console.log(`SGIP_PUBLIC_KEY=${raw(publicKey.export({ format: 'jwk' }).x!)}`);
console.log(`SGIP_SECRET_KEY=${raw(privateKey.export({ format: 'jwk' }).d!)}`);

3. The SGIP library

One file does the protocol's crypto and talks to the hub: key ids, the five-line canonical string, signing and checking requests, looking up the hub's key, ULIDs, passports, callHub and the hub's directory of games. Each part names the spec section it follows.

TypeScript
// lib/sgip.ts
// Everything SGIP needs, using only Node's built-in crypto: keys, request
// signatures, passports, ULIDs and calls to the hub. Spec sections in brackets.
import { createHash, createPrivateKey, createPublicKey, randomBytes, sign, verify } from 'node:crypto';

export const config = {
    platformId: process.env.SGIP_PLATFORM_ID ?? 'yourgame.example',
    name: process.env.SGIP_PLATFORM_NAME ?? 'Your Game',
    icon: process.env.SGIP_PLATFORM_ICON ?? '🎮',
    appUrl: (process.env.APP_URL ?? 'http://localhost:3000').replace(/\/$/, ''),
    publicKey: process.env.SGIP_PUBLIC_KEY ?? '',
    secretKey: process.env.SGIP_SECRET_KEY ?? '',
    hubUrl: (process.env.SGIP_HUB_URL ?? '').replace(/\/$/, ''),
};

/** The ops this game answers at its inbox. The first three are required [9]. */
export const CAPABILITIES = ['hub.ping@1', 'player.lookup@1', 'message.send@1', 'visit.end@1'];

export type Endpoint = { platform: string; player?: string };
export type Envelope = { sgip: string; id: string; op: string; from: Endpoint; to: Endpoint; reply_to?: string | null; payload: Record<string, unknown> };
export type Reply = { ok: true; id: string; result: Record<string, unknown> } | { ok: false; id: string; error: { code: string; message: string; retryable: boolean } };

// --- Keys [6] -------------------------------------------------------------------------

const b64url = (base64: string) => Buffer.from(base64, 'base64').toString('base64url');

/** The first 16 hex characters of the SHA-256 of the public key's base64 text. */
export const keyId = (publicKey: string) => createHash('sha256').update(publicKey).digest('hex').slice(0, 16);

const privateKey = (seed: string, pub: string) => createPrivateKey({ key: { kty: 'OKP', crv: 'Ed25519', d: b64url(seed), x: b64url(pub) }, format: 'jwk' });
const publicKey = (pub: string) => createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: b64url(pub) }, format: 'jwk' });

// --- Request signatures [6] --------------------------------------------------------------

/** Five lines: method, path, timestamp, nonce, SHA-256 of the exact body. */
export function canonical(method: string, path: string, ts: number, nonce: string, body: string): string {
    return [method.toUpperCase(), path, ts, nonce, createHash('sha256').update(body).digest('hex')].join('\n');
}

export function signRequest(method: string, path: string, body: string, ts = Math.floor(Date.now() / 1000), nonce = ulid()): string {
    const sig = sign(null, Buffer.from(canonical(method, path, ts, nonce, body)), privateKey(config.secretKey, config.publicKey)).toString('base64');

    return `keyId=${keyId(config.publicKey)},ts=${ts},nonce=${nonce},sig=${sig}`;
}

export type Signature = { keyId: string; ts: number; nonce: string; sig: string };

export function parseSignature(header: string | null): Signature | null {
    const parts = Object.fromEntries((header ?? '').split(',').map((pair) => pair.trim().split(/=(.*)/).slice(0, 2)));

    // ed25519 is the only algorithm, and the default when alg is absent [6].
    const known = (parts.alg ?? 'ed25519') === 'ed25519';

    return known && parts.keyId && /^\d+$/.test(parts.ts ?? '') && parts.nonce && parts.sig ? { keyId: parts.keyId, ts: Number(parts.ts), nonce: parts.nonce, sig: parts.sig } : null;
}

/** 'ok', or the error code a receiver must answer with. Nonce replay is checked by the caller. */
export function checkSignature(header: string | null, method: string, path: string, body: string, signerKey: string, now = Math.floor(Date.now() / 1000)): 'ok' | 'bad_signature' | 'expired' {
    const signature = parseSignature(header);

    if (!signature || signature.keyId !== keyId(signerKey)) {
        return 'bad_signature';
    }

    const valid = verify(null, Buffer.from(canonical(method, path, signature.ts, signature.nonce, body)), publicKey(signerKey), Buffer.from(signature.sig, 'base64'));

    if (!valid) {
        return 'bad_signature';
    }

    return Math.abs(now - signature.ts) > 300 ? 'expired' : 'ok';
}

// --- The hub's key [6.1] -------------------------------------------------------------------

let published: { publicKey: string; keyId: string; expiresAt: number } | undefined;
let lookedUpAt = 0;

/** The hub's public key, looked up at its well-known file and cached until it expires. A key id we don't know is looked up again, at most once a minute. */
export async function hubKey(id?: string): Promise<string | null> {
    const stale = !published || published.expiresAt <= Date.now();

    if (stale || (id && id !== published?.keyId && Date.now() - lookedUpAt > 60_000)) {
        lookedUpAt = Date.now();
        const data = await fetch(`${config.hubUrl}/.well-known/sgip-hub.json`).then((response) => response.json()).catch(() => null);
        const expiresAt = Date.parse(data?.expires_at);

        // Never trust a key past its expiry, nor one whose id doesn't match it.
        if (typeof data?.public_key === 'string' && keyId(data.public_key) === data.key_id && expiresAt > Date.now()) {
            published = { publicKey: data.public_key, keyId: data.key_id, expiresAt };
        }
    }

    return published && published.expiresAt > Date.now() && (!id || id === published.keyId) ? published.publicKey : null;
}

// --- ULIDs [4] ---------------------------------------------------------------------------

const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';

/** 48 bits of milliseconds, then 80 random bits, in Crockford base32. */
export function ulid(time = Date.now()): string {
    let out = '';

    for (let i = 9, t = time; i >= 0; i--, t = Math.floor(t / 32)) {
        out = CROCKFORD[t % 32] + out;
    }

    for (const byte of randomBytes(16)) {
        out += CROCKFORD[byte % 32];
    }

    return out;
}

export const isUlid = (value: unknown): value is string => typeof value === 'string' && /^[0-9A-HJKMNP-TV-Z]{26}$/.test(value);

// --- Passports [7.1] ---------------------------------------------------------------------

export type Passport = {
    iss: string; sub: string; player: string; home: string; aud: string; visit_id: string; jti: string;
    scopes: string[]; display: { name: string; color?: string; avatar_url?: string; badges?: string[] }; arrival?: string; iat: number; exp: number;
};

/** The id of the hub key that signed a passport (its `kid`), to look the key up first. */
export function passportKeyId(token: string): string | undefined {
    try {
        return JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8')).kid;
    } catch {
        return undefined;
    }
}

/** Checks a passport offline with the hub's key. Throws with the reason if it must be refused. */
export function verifyPassport(token: string, hubPublicKey: string, audience = config.platformId, now = Math.floor(Date.now() / 1000)): Passport {
    const [header, claims, signature] = token.split('.');

    if (!header || !claims || !signature) {
        throw new Error('Not a passport.');
    }

    if (!verify(null, Buffer.from(`${header}.${claims}`), publicKey(hubPublicKey), Buffer.from(signature, 'base64url'))) {
        throw new Error('Not signed by the hub.');
    }

    const passport = JSON.parse(Buffer.from(claims, 'base64url').toString('utf8')) as Passport;

    if (passport.iss !== 'sgip-hub' || passport.aud !== audience) {
        throw new Error('Meant for another game.');
    }

    if (passport.exp < now || passport.exp - passport.iat > 900) {
        throw new Error('Expired.');
    }

    return passport;
}

// --- Talking to the hub [3, 4] -------------------------------------------------------------

export function envelope(op: string, to: Endpoint, payload: Record<string, unknown>, fromPlayer?: string): Envelope {
    return { sgip: '0.1', id: ulid(), op, from: { platform: config.platformId, ...(fromPlayer ? { player: fromPlayer } : {}) }, to, reply_to: null, payload };
}

/** POST one envelope to the hub, signed, and read the reply. */
export async function callHub(message: Envelope): Promise<Reply> {
    const body = JSON.stringify(message);
    const url = new URL('/ops', config.hubUrl);

    try {
        const response = await fetch(url, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'SGIP-Signature': signRequest('POST', url.pathname, body) },
            body,
        });

        return (await response.json()) as Reply;
    } catch (error) {
        return { ok: false, id: message.id, error: { code: 'platform_offline', message: String(error), retryable: true } };
    }
}

// --- The hub's directory [7.2, 9] ------------------------------------------------------------

export type Platform = { platform_id: string; name: string; origin: string | null; return_url?: string | null };

let listed: { platforms: Platform[]; at: number } | undefined;

/** The games on the network, from platform.directory@1, kept for five minutes. */
export async function directory(): Promise<Platform[]> {
    if (!listed || Date.now() - listed.at > 300_000) {
        const reply = await callHub(envelope('platform.directory@1', { platform: 'sgip-hub' }, {}));

        if (!reply.ok) {
            return listed?.platforms ?? [];
        }

        listed = { platforms: reply.result.platforms as Platform[], at: Date.now() };
    }

    return listed.platforms;
}

4. Check it against the test vectors

Ed25519 signatures are deterministic, so a correct implementation reproduces every expected signature in the spec's test vectors exactly. This script downloads them and checks your library:

TypeScript
// scripts/check-vectors.ts
// Checks lib/sgip.ts against the spec's own test vectors [6, 7.1].
// Usage: npm run vectors. Set SGIP_VECTORS to a folder or URL to use another copy.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { canonical, checkSignature, keyId, parseSignature, verifyPassport } from '../lib/sgip.ts';

const source = process.env.SGIP_VECTORS ?? 'https://sgip.dev/files/vectors/';
const load = async (name: string) =>
    source.startsWith('http') ? (await fetch(new URL(name, source))).json() : JSON.parse(readFileSync(join(source, name), 'utf8'));
const signing = await load('signing.json');
const passports = await load('passports.json');
let failures = 0;
const check = (pass: boolean, label: string) => {
    console.log(`${pass ? '✓' : '✗'} ${label}`);
    failures += pass ? 0 : 1;
};

for (const key of Object.values<{ public_key: string; key_id: string }>(signing.keys)) {
    check(keyId(key.public_key) === key.key_id, `key id ${key.key_id}`);
}

for (const c of signing.valid) {
    const signature = parseSignature(c.header)!;
    check(canonical(c.method, c.path, signature.ts, signature.nonce, c.body) === c.canonical, `canonical: ${c.name}`);
    check(checkSignature(c.header, c.method, c.path, c.body, signing.keys[c.signer].public_key, c.ts) === 'ok', `verifies: ${c.name}`);
}

for (const c of signing.invalid) {
    check(checkSignature(c.header, c.method, c.path, c.body, signing.keys[c.verify_with].public_key, c.now ?? 1791480060) === c.expect, `rejects (${c.expect}): ${c.name}`);
}

for (const c of passports.cases) {
    let outcome = 'valid';

    try {
        verifyPassport(c.token, passports.keys.hub.public_key, c.audience, c.now);
    } catch {
        outcome = 'invalid';
    }

    check(outcome === c.expect, `passport ${c.expect}: ${c.name}`);
}

console.log(failures ? `\n${failures} failed` : '\nAll vectors pass');
process.exit(failures ? 1 : 0);
Shell
npm run vectors
# ✓ key id ff73a0647bbc29aa … ✓ passport invalid: not a JWS
# All vectors pass

Don't go further until every line passes.

5. A place to keep things

The inbox has to remember nonces for 10 minutes and envelope ids for 24 hours, and visitors need guest records. This keeps them in memory to stay short. In a real game, use your database, so they survive restarts and are shared by every server.

TypeScript
// lib/store.ts
// An in-memory store, so the example stays short. A real game keeps these in
// its database: they must survive restarts, and nonces and processed
// envelopes must be shared by every server that answers the inbox.
import { createHash } from 'node:crypto';
import { config, ulid, type Passport, type Reply } from './sgip.ts';

export type Player = { handle: string; name: string; color: string; sub: string; discoverable: boolean; home?: string; visit?: Visit };
export type Visit = { id: string; home: string; arrivedAt: number };
export type Message = { id: string; from: string; fromName: string; to: string; text: string; at: number };

type Store = {
    players: Map<string, Player>;
    messages: Message[];
    nonces: Map<string, number>;
    processed: Map<string, Reply>;
};

const g = globalThis as unknown as { sgipStore?: Store };

export const store: Store = (g.sgipStore ??= {
    // One local player to message and look up.
    players: new Map([['ada', { handle: 'ada', name: 'Ada', color: '#3d7ea6', sub: ulid(), discoverable: true }]]),
    messages: [],
    nonces: new Map(),
    processed: new Map(),
});

export const addressOf = (player: Player) => player.home ?? `${player.handle}@${config.platformId}`;

/** True the first time a nonce is seen in the last 600 seconds [6]. */
export function freshNonce(nonce: string, now = Math.floor(Date.now() / 1000)): boolean {
    for (const [seen, at] of store.nonces) {
        if (now - at > 600) {
            store.nonces.delete(seen);
        }
    }

    if (store.nonces.has(nonce)) {
        return false;
    }

    store.nonces.set(nonce, now);

    return true;
}

/** A visitor from another game, keyed on sub@home so they keep the same guest identity [7.2]. */
export function admitGuest(passport: Passport): Player {
    const handle = 'g_' + createHash('sha256').update(`${passport.sub}@${passport.home}`).digest('hex').slice(0, 16);
    const current = store.players.get(handle)?.visit;
    const visit = current?.id === passport.visit_id ? current : { id: passport.visit_id, home: passport.home, arrivedAt: Date.now() };
    const guest: Player = { handle, name: passport.display.name, color: passport.display.color ?? '#888888', sub: passport.sub, discoverable: false, home: passport.player, visit };
    store.players.set(handle, guest);

    return guest;
}

6. Publish your descriptor

The hub fetches /.well-known/sgip.json from your domain to confirm the domain vouches for your key. capabilities lists the ops your inbox answers, icon is a short emoji other games show for yours until you add an image (icon_url), and return is where your players land when they come back from a visit.

TypeScript
// app/.well-known/sgip.json/route.ts
// GET /.well-known/sgip.json: who this game is, its key, and where to reach it [2].
import { connection } from 'next/server';
import { CAPABILITIES, config } from '@/lib/sgip.ts';

export async function GET() {
    await connection(); // answer from the settings at request time, not a copy made at build

    return Response.json({
        sgip: '0.1',
        platform_id: config.platformId,
        name: config.name,
        icon: config.icon,
        public_key: config.publicKey,
        inbox: `${config.appUrl}/sgip/inbox`,
        visit: `${config.appUrl}/sgip/visit`,
        return: `${config.appUrl}/`,
        capabilities: CAPABILITIES,
        embed: { allowed: true },
    });
}

7. Answer your inbox

The hub POSTs every operation here, signed with its key. The route checks the signature, the timestamp, the nonce and the envelope in the order the spec gives, returns the stored reply for an id it has seen, and refuses ops it never declared. Then each op gets its answer.

TypeScript
// app/sgip/inbox/route.ts
// POST /sgip/inbox: where the hub delivers operations. The checks run in the
// order the spec gives them, then each declared op gets an answer [5, 6, 9].
import { addressOf, freshNonce, store } from '@/lib/store.ts';
import { CAPABILITIES, checkSignature, config, hubKey, isUlid, parseSignature, type Envelope, type Reply } from '@/lib/sgip.ts';

const STATUS: Record<string, number> = {
    invalid_payload: 422, op_unsupported: 422, bad_signature: 401, expired: 401, forbidden: 403, not_found: 404, rate_limited: 429, platform_offline: 503,
};

const ok = (id: string, result: Record<string, unknown> = {}): Reply => ({ ok: true, id, result });
const fail = (id: string, code: string, message: string): Reply => ({ ok: false, id, error: { code, message, retryable: code === 'rate_limited' || code === 'platform_offline' } });
const respond = (reply: Reply) => Response.json(reply, { status: reply.ok ? 200 : STATUS[reply.error.code] ?? 400 });

export async function POST(request: Request) {
    const body = await request.text();
    let message: Envelope | null = null;

    try {
        message = JSON.parse(body);
    } catch {
        // Answered below as invalid_payload, once the signature has been checked.
    }

    const id = typeof message?.id === 'string' ? message.id : '';

    // 1. Signed by the hub, recently, with a nonce we haven't seen.
    const header = request.headers.get('SGIP-Signature');
    const signature = checkSignature(header, 'POST', new URL(request.url).pathname, body, (await hubKey(parseSignature(header)?.keyId)) ?? '');

    if (signature !== 'ok') {
        return respond(fail(id, signature, signature === 'expired' ? 'Request is more than five minutes old.' : 'Missing or invalid SGIP-Signature.'));
    }

    if (!freshNonce(parseSignature(header)!.nonce)) {
        return respond(fail(id, 'bad_signature', 'Nonce already used.'));
    }

    // 2. A well-formed envelope.
    if (!message || !isUlid(message.id) || typeof message.op !== 'string' || typeof message.from?.platform !== 'string' || typeof message.to?.platform !== 'string' || !String(message.sgip).startsWith('0.')) {
        return respond(fail(id, 'invalid_payload', 'The envelope does not match the SGIP schema.'));
    }

    // 3. Seen this id before? Same answer, nothing done twice.
    const previous = store.processed.get(message.id);

    if (previous) {
        return respond(previous);
    }

    // 4. Only ops we declared.
    const reply = CAPABILITIES.includes(message.op) ? handle(message) : fail(message.id, 'op_unsupported', `${message.op} is not supported here.`);
    store.processed.set(message.id, reply);

    return respond(reply);
}

function handle({ id, op, from, to, payload }: Envelope): Reply {
    switch (op) {
        case 'hub.ping@1':
            return ok(id, { platform: config.platformId, time: new Date().toISOString().replace(/\.\d+Z$/, 'Z') });

        case 'player.lookup@1': {
            const player = localPlayer(payload.player);

            // Players who haven't opted in are indistinguishable from players who don't exist [11].
            return player?.discoverable
                ? ok(id, { player: addressOf(player), sub: player.sub, display_name: player.name, avatar: { color: player.color }, badges: [] })
                : fail(id, 'not_found', `No player ${String(payload.player)}`);
        }

        case 'message.send@1': {
            const player = localPlayer(to.player);
            const text = payload.text;

            if (typeof text !== 'string' || text.length < 1 || text.length > 1000 || !from.player) {
                return fail(id, 'invalid_payload', 'message.send@1 needs from.player and 1 to 1,000 characters of text.');
            }

            if (!player) {
                return fail(id, 'not_found', `No player ${String(to.player)}`);
            }

            store.messages.push({ id, from: from.player, fromName: typeof payload.from_name === 'string' ? payload.from_name : from.player, to: addressOf(player), text, at: Date.now() });

            return ok(id, { delivered: true });
        }

        case 'visit.end@1':
            // One of our players came home: apply their souvenirs here, each capped at ±20 [7.3].
            return ok(id, { returned: true });
    }

    return fail(id, 'op_unsupported', `${op} is not supported here.`);
}

function localPlayer(address: unknown) {
    if (typeof address !== 'string' || !address.endsWith(`@${config.platformId}`)) {
        return null;
    }

    const player = store.players.get(address.split('@')[0]);

    return player && !player.home ? player : null;
}

8. Welcome visitors

A visitor arrives with a passport in the URL fragment (a new tab) or by postMessage (an iframe), never in the query string. The page reads it, takes it out of the address bar and posts it to your server. A passport sent by postMessage is only taken from a game in the hub's directory.

TypeScript
// app/sgip/visit/page.tsx
// GET /sgip/visit: where visitors from other games land [7.2].
import { connection } from 'next/server';
import { Suspense } from 'react';
import { directory } from '@/lib/sgip.ts';
import { Arrival } from './arrival';

export default function VisitPage() {
    return (
        <Suspense fallback={null}>
            <VisitWithOrigins />
        </Suspense>
    );
}

async function VisitWithOrigins() {
    await connection(); // read settings per request, not at build time

    // Only games in the hub's directory may hand over a passport by postMessage.
    const origins = (await directory()).flatMap((platform) => (platform.origin ? [platform.origin] : []));

    return <Arrival origins={origins} />;
}
TypeScript
// app/sgip/visit/arrival.tsx
'use client';

// The passport arrives in the URL fragment (new tab) or by postMessage (iframe),
// never in the query string, so it never reaches a server log [7.2].
import { useEffect, useRef, useState } from 'react';

export function Arrival({ origins }: { origins: string[] }) {
    const [status, setStatus] = useState('Checking your passport…');
    const sent = useRef(false);

    useEffect(() => {
        async function arrive(passport: string) {
            if (sent.current) {
                return;
            }

            sent.current = true;
            const response = await fetch('/sgip/visit/arrive', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ passport }) });

            if (response.ok) {
                window.location.replace('/');
            } else {
                setStatus(((await response.json()) as { message: string }).message);
            }
        }

        const fromFragment = new URLSearchParams(window.location.hash.slice(1)).get('passport');

        if (fromFragment) {
            history.replaceState(null, '', window.location.pathname); // take it out of the address bar
            void arrive(fromFragment);

            return;
        }

        const onMessage = (event: MessageEvent) => {
            if (origins.includes(event.origin) && event.data?.type === 'sgip.passport') {
                void arrive(String(event.data.token));
            }
        };

        window.addEventListener('message', onMessage);

        return () => window.removeEventListener('message', onMessage);
    }, [origins]);

    return <main style={{ fontFamily: 'system-ui', padding: 48 }}>{status}</main>;
}

Your server checks the passport offline with the hub's key: its signature, that it's meant for you, and that it hasn't expired. Only then does it create a guest, keyed on sub@home so a returning visitor keeps the same identity.

TypeScript
// app/sgip/visit/arrive/route.ts
// POST /sgip/visit/arrive: check the passport offline, then start a guest session [7.1, 7.2].
import { cookies } from 'next/headers';
import { admitGuest } from '@/lib/store.ts';
import { hubKey, passportKeyId, verifyPassport } from '@/lib/sgip.ts';

export async function POST(request: Request) {
    const { passport } = (await request.json()) as { passport?: string };

    const token = String(passport ?? '');

    try {
        const key = await hubKey(passportKeyId(token));

        if (!key) {
            throw new Error('Not signed by the hub.');
        }

        // Signature, audience and expiry, all checked before any guest record exists.
        const guest = admitGuest(verifyPassport(token, key));
        (await cookies()).set('session', guest.handle, { httpOnly: true, sameSite: 'lax', path: '/' });

        return Response.json({ welcome: guest.name });
    } catch (error) {
        const reason = error instanceof Error ? error.message : 'Not a passport.';

        return Response.json({ message: `Passport refused: ${reason}` }, { status: 403 });
    }
}

To see who has arrived and the messages they've had, replace app/page.tsx. A visitor also gets a Leave button:

TypeScript
// app/page.tsx
// A tiny view of what has arrived: who is here and the messages they've had.
import { cookies } from 'next/headers';
import { Suspense } from 'react';
import { addressOf, store } from '@/lib/store.ts';
import { config } from '@/lib/sgip.ts';
import { leave } from '@/app/sgip/visit/actions.ts';

export default function Home() {
    return (
        <main style={{ fontFamily: 'system-ui', padding: 48, maxWidth: 720 }}>
            <h1>{config.name}</h1>
            <Suspense fallback={<p>Loading…</p>}>
                <WhatArrived />
            </Suspense>
        </main>
    );
}

async function WhatArrived() {
    const me = store.players.get((await cookies()).get('session')?.value ?? '');

    return (
        <>
            <p>{me ? `Welcome, ${me.name}, visiting from ${me.home}.` : `An SGIP platform at ${config.platformId}.`}</p>
            {me?.visit && (
                <form action={leave}>
                    <button type="submit">Leave</button>
                </form>
            )}
            <h2>Players</h2>
            <ul>{[...store.players.values()].map((p) => <li key={p.handle}>{p.name} ({addressOf(p)})</li>)}</ul>
            <h2>Messages</h2>
            <ul>{store.messages.map((m) => <li key={m.id}><b>{m.fromName}</b> to {m.to}: {m.text}</li>)}</ul>
        </>
    );
}

9. Let visitors leave

When a visitor leaves, tell their home game with visit.end@1, sent through the hub, so it can take them back. Then show a goodbye page with a link back to their game. If the visit ran in an iframe, the page tells the home game it can close it.

TypeScript
// app/sgip/visit/actions.ts
'use server';

// Ends a visit: tells the player's home game through the hub, then says goodbye [7.3].
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { callHub, envelope } from '@/lib/sgip.ts';
import { store } from '@/lib/store.ts';

export async function leave() {
    const session = await cookies();
    const guest = store.players.get(session.get('session')?.value ?? '');

    if (!guest?.home || !guest.visit) {
        redirect('/');
    }

    const { visit } = guest;

    await callHub(envelope('visit.end@1', { platform: visit.home, player: guest.home }, {
        visit_id: visit.id,
        player: guest.home,
        reason: 'left',
        duration_s: Math.floor((Date.now() - visit.arrivedAt) / 1000),
    }));

    delete guest.visit;
    session.delete('session');
    redirect(`/sgip/visit/goodbye?home=${encodeURIComponent(visit.home)}`);
}

The goodbye page only links to games the hub lists, so nobody can use it to send people to another site:

TypeScript
// app/sgip/visit/goodbye/page.tsx
// Where a visitor lands after leaving: a link back to their home game [7.3].
import { Suspense } from 'react';
import { directory } from '@/lib/sgip.ts';
import { Exit } from './exit';

type Props = { searchParams: Promise<{ home?: string }> };

export default function Goodbye({ searchParams }: Props) {
    return (
        <main style={{ fontFamily: 'system-ui', padding: 48 }}>
            <h1>Thanks for visiting</h1>
            <Suspense fallback={null}>
                <WayHome searchParams={searchParams} />
            </Suspense>
        </main>
    );
}

async function WayHome({ searchParams }: Props) {
    const { home } = await searchParams;
    // Only games the hub lists, so this page can't be used to send people anywhere else.
    const game = (await directory()).find((platform) => platform.platform_id === home);

    if (!game) {
        return null;
    }

    return (
        <p>
            <Exit origin={game.origin} />
            <a href={game.return_url ?? `https://${game.platform_id}`}>Back to {game.name}</a>
        </p>
    );
}
TypeScript
// app/sgip/visit/goodbye/exit.tsx
'use client';

// In an iframe, tells the home game the visit is over so it can close the frame [7.3].
import { useEffect } from 'react';

export function Exit({ origin }: { origin: string | null }) {
    useEffect(() => {
        if (origin && window.parent !== window) {
            window.parent.postMessage({ type: 'sgip.exit' }, origin);
        }
    }, [origin]);

    return null;
}

10. Say hello

The hub fetches your descriptor from https://<SGIP_PLATFORM_ID>/.well-known/sgip.json, so the app must be reachable there over HTTPS: deploy it, or while you develop, point the domain at your machine with a tunnel. Set APP_URL to that address. Then register with one signed hub.hello@1. The hub fetches your descriptor, checks the key matches, and answers with its own public key.

TypeScript
// scripts/hello.ts
// Registers this game with the hub (hub.hello@1). Run it with the app up, so
// the hub can fetch the descriptor [9]. Usage: npm run hello
import { CAPABILITIES, callHub, config, envelope } from '../lib/sgip.ts';

const reply = await callHub(envelope('hub.hello@1', { platform: 'sgip-hub' }, {
    name: config.name,
    icon: config.icon,
    inbox: `${config.appUrl}/sgip/inbox`,
    visit: `${config.appUrl}/sgip/visit`,
    public_key: config.publicKey,
    capabilities: CAPABILITIES,
}));

console.log(JSON.stringify(reply, null, 2));
process.exit(reply.ok ? 0 : 1);
Shell
npm run dev        # in one terminal
npm run hello      # in another

11. Check your game

Ask the hub to check your game. The request is signed with your key, so only you can ask for your game's check. The hub fetches your descriptor, sends signed, unsigned, stale, replayed and malformed requests, and grades every reply against the schemas (spec section 9.1).

TypeScript
// scripts/check.ts
// Asks the hub to check this game against the contract (hub.check@1). Only
// this game can ask for its own check: the request is signed with its key [9.1].
// Run it with the app up. Usage: npm run check
import { callHub, envelope } from '../lib/sgip.ts';

type Check = { name: string; passed: boolean; required: boolean; detail?: string };

const reply = await callHub(envelope('hub.check@1', { platform: 'sgip-hub' }, {}));

if (!reply.ok) {
    console.error(`${reply.error.code}: ${reply.error.message}`);
    process.exit(1);
}

for (const check of reply.result.checks as Check[]) {
    console.log(`${check.passed ? '✓' : check.required ? '✗' : '!'} ${check.name}${check.detail ? `  ${check.detail}` : ''}`);
}

console.log(reply.result.conforms ? '\nConforms' : '\nDoes not conform yet');
process.exit(reply.result.conforms ? 0 : 1);
Shell
npm run check
# ✓ Descriptor is served … ✓ Visit page does not take the passport in the query string
# Conforms

When it says Conforms, you're on the network.

Before you go live

  • Move the store to your database.
  • Serve over HTTPS on the domain you use as SGIP_PLATFORM_ID.
  • Keep SGIP_SECRET_KEY out of your code and your repository.
  • End visits for visitors who have been idle for 15 minutes too: visit.end@1 with reason: "idle".
  • Add icon_url to your descriptor, a square image of at least 128 px. When your game has money, add currencies; to choose which games' players may visit, add visitors (see the guide).
  • Add more operations as you build them: list each in CAPABILITIES and answer it in the inbox. The examples show what the others look like.

Edit this page on GitHub