From d900cbb054f02c490a941cd0bc83510de5294ee9 Mon Sep 17 00:00:00 2001 From: Robin Berjon Date: Thu, 6 Aug 2026 16:46:10 +0200 Subject: [PATCH] app that runs --- .gitignore | 6 + broker/Dockerfile | 3 + broker/src/app.ts | 77 ++- broker/src/html.ts | 6 +- broker/test/oidc.test.ts | 46 ++ client/src/index.ts | 2 +- client/src/login.ts | 27 +- counter/src/ceremony.ts | 12 +- counter/src/config.ts | 6 + counter/src/main.ts | 707 ++++++++++++++++---- counter/src/matrix.ts | 106 ++- counter/src/profiles.ts | 73 ++ counter/src/storage.ts | 42 ++ counter/src/style.css | 18 + deploy/sm/README.md | 83 +++ deploy/sm/matey-auth/.env.example | 10 + deploy/sm/matey-auth/Dockerfile | 13 + deploy/sm/matey-auth/compose.yaml | 18 + deploy/sm/matey-auth/service.json | 10 + deploy/sm/matey-counter/Caddyfile | 8 + deploy/sm/matey-counter/Dockerfile | 3 + deploy/sm/matey-counter/compose.yaml | 12 + deploy/sm/matey-counter/service.json | 8 + deploy/sm/matey-matrix/compose.yaml | 18 + deploy/sm/matey-matrix/service.json | 11 + deploy/sm/matey-matrix/tuwunel.toml.example | 36 + deploy/sm/prepare.mjs | 83 +++ package.json | 3 +- 28 files changed, 1303 insertions(+), 144 deletions(-) create mode 100644 counter/src/profiles.ts create mode 100644 deploy/sm/README.md create mode 100644 deploy/sm/matey-auth/.env.example create mode 100644 deploy/sm/matey-auth/Dockerfile create mode 100644 deploy/sm/matey-auth/compose.yaml create mode 100644 deploy/sm/matey-auth/service.json create mode 100644 deploy/sm/matey-counter/Caddyfile create mode 100644 deploy/sm/matey-counter/Dockerfile create mode 100644 deploy/sm/matey-counter/compose.yaml create mode 100644 deploy/sm/matey-counter/service.json create mode 100644 deploy/sm/matey-matrix/compose.yaml create mode 100644 deploy/sm/matey-matrix/service.json create mode 100644 deploy/sm/matey-matrix/tuwunel.toml.example create mode 100644 deploy/sm/prepare.mjs diff --git a/.gitignore b/.gitignore index c757713..ae9d0ce 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,12 @@ dist/ !.env.example deploy/.env deploy/data/ +deploy/sm/matey-matrix/tuwunel.toml + +# sm-staged build artifacts (regenerate with `pnpm sm:prepare`) +deploy/sm/matey-counter/site/ +deploy/sm/matey-auth/dist/ +deploy/sm/matey-auth/package.json # broker runtime keys *.keys.json diff --git a/broker/Dockerfile b/broker/Dockerfile index 6c5fade..bfdc273 100644 --- a/broker/Dockerfile +++ b/broker/Dockerfile @@ -19,6 +19,9 @@ COPY --from=build /app/pnpm-workspace.yaml /app/package.json /app/pnpm-lock.yaml COPY --from=build /app/broker/package.json broker/ RUN pnpm install --frozen-lockfile --filter matey-broker --prod COPY --from=build /app/broker/dist broker/dist +# Pre-create /data owned by the unprivileged user so named volumes inherit the +# right ownership. Bind mounts still need `chown 1000:1000` on the host dir. +RUN mkdir -p /data && chown node:node /data VOLUME /data ENV MATEY_KEYS_FILE=/data/broker.keys.json EXPOSE 8080 diff --git a/broker/src/app.ts b/broker/src/app.ts index 1446d02..71a8455 100644 --- a/broker/src/app.ts +++ b/broker/src/app.ts @@ -8,6 +8,10 @@ import { TtlMap, type AtClaims, type AuthRequest, type IssuedCode } from "./stor const ID_TOKEN_TTL_S = 300; const ACCESS_TOKEN_TTL_S = 300; +/** Login-hint relay cookie (spec Appendix B.1): set by GET /hint, consumed by + * /oidc/authorize when the auth stack (Tuwunel) does not forward login_hint. */ +const HINT_COOKIE = "matey_hint"; +const HINT_TTL_S = 600; export interface BrokerDeps { config: BrokerConfig; @@ -32,6 +36,23 @@ function constantTimeEqual(a: string, b: string): boolean { return ba.length === bb.length && timingSafeEqual(ba, bb); } +function getCookie(req: Request, name: string): string | null { + const header = req.headers.cookie; + if (header === undefined) return null; + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === name) { + try { + return decodeURIComponent(part.slice(eq + 1).trim()); + } catch { + return null; + } + } + } + return null; +} + /** Build the broker's express app. The OIDC surface is deliberately minimal: * one confidential relying party (the homeserver auth stack), code flow only. * See docs/adr/0002 for why this is hand-rolled rather than node-oidc-provider. */ @@ -92,6 +113,46 @@ export function buildApp(deps: BrokerDeps): Express { }); } + // ---- Login-hint relay ------------------------------------------------------ + + const cookieSecure = config.dev ? "" : "; Secure"; + const setHintCookie = (res: Response, value: string): void => { + res.append( + "set-cookie", + `${HINT_COOKIE}=${encodeURIComponent(value)}; Max-Age=${HINT_TTL_S}; Path=/; HttpOnly; SameSite=Lax${cookieSecure}`, + ); + }; + const clearHintCookie = (res: Response): void => { + res.append("set-cookie", `${HINT_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecure}`); + }; + + // The Matey client bounces through here on its way to the homeserver's + // authorize endpoint, parking the user's identifier in a cookie so the + // broker can skip its handle form when the homeserver drops login_hint. + app.get("/hint", (req: Request, res: Response) => { + const identifier = qparam(req, "identifier"); + const then = qparam(req, "then"); + if (identifier === null || identifier.length > 512 || then === null) { + res.status(400).send(errorPage("Bad hint request.")); + return; + } + let thenUrl: URL; + try { + thenUrl = new URL(then); + } catch { + res.status(400).send(errorPage("Bad hint request.")); + return; + } + // Only relay onward to the configured homeserver (no open redirect). + const allowedOrigins = config.rp.redirectUris.map((u) => new URL(u).origin); + if (!allowedOrigins.includes(thenUrl.origin) || (thenUrl.protocol !== "https:" && !config.dev)) { + res.status(400).send(errorPage("Bad hint request.")); + return; + } + setHintCookie(res, identifier); + res.redirect(thenUrl.toString()); + }); + // ---- Authorization --------------------------------------------------------- app.get("/oidc/authorize", (req: Request, res: Response) => { @@ -136,17 +197,21 @@ export function buildApp(deps: BrokerDeps): Express { }; authRequests.set(request.id, request); - // login_hint (the user's DID/handle) skips the form entirely — the path - // MAS follows; Tuwunel currently always lands on the form. - const loginHint = qparam(req, "login_hint"); + // login_hint (the user's DID/handle) skips the form entirely. MAS + // forwards it; Tuwunel does not, so the hint-relay cookie (GET /hint) + // fills in. The form is the last resort, prefilled when we can. + const hintFromCookie = getCookie(req, HINT_COOKIE); + if (hintFromCookie !== null) clearHintCookie(res); + const loginHint = qparam(req, "login_hint") ?? hintFromCookie; if (loginHint !== null) { try { const url = await deps.authenticator.start(request.id, loginHint); res.redirect(url.toString()); return; } catch { - // fall through to the form with an error slot - res.status(200).send(handleForm(request.id, `Could not start sign-in for “${loginHint}”.`)); + res + .status(200) + .send(handleForm(request.id, `Could not start sign-in for “${loginHint}”.`, loginHint)); return; } } @@ -169,7 +234,7 @@ export function buildApp(deps: BrokerDeps): Express { res.redirect(url.toString()); } catch (err) { const msg = err instanceof Error ? err.message : "could not resolve that identifier"; - res.status(200).send(handleForm(request.id, `Sign-in failed: ${msg}`)); + res.status(200).send(handleForm(request.id, `Sign-in failed: ${msg}`, identifier)); } })(); }); diff --git a/broker/src/html.ts b/broker/src/html.ts index c369e0b..a1435dd 100644 --- a/broker/src/html.ts +++ b/broker/src/html.ts @@ -2,8 +2,9 @@ const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); /** Minimal handle-entry form, shown when no login_hint reached the broker - * (Tuwunel does not forward login hints upstream — spec §7 item 11). */ -export function handleForm(requestId: string, error?: string): string { + * (Tuwunel does not forward login hints upstream — spec §7 item 11) and the + * hint-relay cookie is absent or failed. */ +export function handleForm(requestId: string, error?: string, prefill?: string): string { return ` @@ -32,6 +33,7 @@ export function handleForm(requestId: string, error?: string): string { diff --git a/broker/test/oidc.test.ts b/broker/test/oidc.test.ts index fbacb40..83eb36d 100644 --- a/broker/test/oidc.test.ts +++ b/broker/test/oidc.test.ts @@ -211,3 +211,49 @@ describe("authorization code flow", () => { expect(html).toContain('name="request"'); }); }); + +describe("login-hint relay", () => { + it("parks the identifier in a cookie and redirects to the homeserver", async () => { + const then = "https://matrix.example.test/_matrix/client/v3/login/sso/redirect/tuwunel"; + const res = await fetch( + `${baseUrl}/hint?identifier=${encodeURIComponent("robin.berjon.com")}&then=${encodeURIComponent(then)}`, + { redirect: "manual" }, + ); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe(then); + const cookie = res.headers.get("set-cookie") ?? ""; + expect(cookie).toContain("matey_hint=robin.berjon.com"); + expect(cookie).toContain("SameSite=Lax"); + expect(cookie).toContain("HttpOnly"); + }); + + it("rejects relay targets on foreign origins (no open redirect)", async () => { + const res = await fetch( + `${baseUrl}/hint?identifier=x.example.com&then=${encodeURIComponent("https://evil.example/authorize")}`, + { redirect: "manual" }, + ); + expect(res.status).toBe(400); + }); + + it("consumes the cookie in place of login_hint and clears it", async () => { + const res = await fetch(authorizeUrl(), { + redirect: "manual", + headers: { cookie: "matey_hint=robin.berjon.com" }, + }); + // Auto-started the atproto flow instead of serving the form. + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/atproto/callback"); + expect(res.headers.get("set-cookie")).toContain("matey_hint=; Max-Age=0"); + }); + + it("prefills the form when the hinted identifier cannot start a flow", async () => { + const res = await fetch(authorizeUrl(), { + redirect: "manual", + headers: { cookie: "matey_hint=notahandle" }, + }); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('value="notahandle"'); + expect(res.headers.get("set-cookie")).toContain("matey_hint=; Max-Age=0"); + }); +}); diff --git a/client/src/index.ts b/client/src/index.ts index 20c7846..5317581 100644 --- a/client/src/index.ts +++ b/client/src/index.ts @@ -31,7 +31,7 @@ export { refreshSession, } from "./matrixAuth.js"; export type { ClientIdentity, StartAuthorizationInput, CompletedAuthorization } from "./matrixAuth.js"; -export { beginAtLogin, beginHomeserverLogin, completeAtLogin } from "./login.js"; +export { beginAtLogin, beginHomeserverLogin, completeAtLogin, brokerHintUrl } from "./login.js"; export type { LoginContext, LoginStart, HomeserverLoginInput, CompletedLogin } from "./login.js"; export { fetchPlcState, diff --git a/client/src/login.ts b/client/src/login.ts index 78462a6..adc67dc 100644 --- a/client/src/login.ts +++ b/client/src/login.ts @@ -111,6 +111,22 @@ export interface CompletedLogin { mxidMismatch: boolean; /** The AT side lacks the pointer (or points elsewhere): run the ceremony (§5.5). */ needsCeremony: boolean; + /** The DID this login started from (carried through the flow), when known. */ + did?: string; +} + +/** + * Wrap a homeserver authorization URL in the broker's login-hint relay + * (GET /hint on the Matey broker). Used when the homeserver's auth stack does + * not forward login_hint to its upstream provider (Tuwunel today): the broker + * parks the identifier in a short-lived cookie and sends the user on, so its + * handle form is skipped when the user arrives via the homeserver. + */ +export function brokerHintUrl(brokerIssuer: string, identifier: string, thenUrl: string): string { + const url = new URL("/hint", brokerIssuer); + url.searchParams.set("identifier", identifier); + url.searchParams.set("then", thenUrl); + return url.toString(); } /** Shared callback handler for all flows. */ @@ -118,9 +134,16 @@ export async function completeAtLogin( params: URLSearchParams, ctx: LoginContext, ): Promise { - const { session, expectedMxid } = await completeAuthorization(params, ctx.storage, ctx.resolver); + const { session, expectedMxid, did } = await completeAuthorization(params, ctx.storage, ctx.resolver); const mooring = await verifyMooringFromMxid(session.mxid, session.homeserverBaseUrl, ctx.resolver); const mxidMismatch = expectedMxid !== undefined && expectedMxid !== session.mxid; const needsCeremony = mooring.status !== "verified"; - return { session, mooring, mxidMismatch, needsCeremony }; + const knownDid = did ?? mooring.did; + return { + session, + mooring, + mxidMismatch, + needsCeremony, + ...(knownDid !== undefined ? { did: knownDid } : {}), + }; } diff --git a/counter/src/ceremony.ts b/counter/src/ceremony.ts index 58cc123..c57b018 100644 --- a/counter/src/ceremony.ts +++ b/counter/src/ceremony.ts @@ -20,14 +20,12 @@ import { saveMateyKey } from "./storage.js"; * The linking ceremony (spec §5.5) needs an atproto OAuth session with the * `atproto identity:*` scopes, obtained by the app itself — deliberately not * by the homeserver's broker. In dev this uses the atproto loopback client - * (works on http://localhost / 127.0.0.1 origins only); a deployed app must - * serve a client metadata document and pass its URL as clientId. + * (works on http://localhost / 127.0.0.1 origins only); a deployed app serves + * a client metadata document and passes its URL as VITE_ATP_CLIENT_ID. */ -export function makeAtpClient(handleResolver: string, clientId?: string): BrowserOAuthClient { - return new BrowserOAuthClient({ - handleResolver, - ...(clientId !== undefined ? { clientMetadata: undefined, clientId } : {}), - } as ConstructorParameters[0]); +export function makeAtpClient(handleResolver: string, clientId?: string): Promise { + if (clientId !== undefined) return BrowserOAuthClient.load({ clientId, handleResolver }); + return Promise.resolve(new BrowserOAuthClient({ handleResolver })); } export const CEREMONY_SCOPE = "atproto identity:*"; diff --git a/counter/src/config.ts b/counter/src/config.ts index 6db20da..bb4a787 100644 --- a/counter/src/config.ts +++ b/counter/src/config.ts @@ -6,6 +6,10 @@ export interface AppConfig { /** Suggested homeserver for the "create an account" path (spec §5.4.1). */ defaultHomeserver: string | null; handleResolver: string; + /** URL of a hosted atproto client-metadata document; loopback client when absent. */ + atpClientId: string | null; + /** Matey broker issuer, for the login-hint relay (Tuwunel drops login_hint). */ + brokerIssuer: string | null; } export function loadAppConfig(): AppConfig { @@ -22,5 +26,7 @@ export function loadAppConfig(): AppConfig { resolver, defaultHomeserver: env.VITE_DEFAULT_HOMESERVER ?? null, handleResolver, + atpClientId: env.VITE_ATP_CLIENT_ID ?? null, + brokerIssuer: env.VITE_MATEY_BROKER ?? null, }; } diff --git a/counter/src/main.ts b/counter/src/main.ts index ae2e7cf..c704657 100644 --- a/counter/src/main.ts +++ b/counter/src/main.ts @@ -1,15 +1,14 @@ import { beginAtLogin, beginHomeserverLogin, + brokerHintUrl, completeAtLogin, verifyMooringFromDid, - verifyMooringFromMxid, type AtIdentity, type LoginContext, type MatrixSession, - type MooringVerification, } from "@matey/client"; -import { ClientEvent, type MatrixClient, type Room } from "matrix-js-sdk"; +import type { MatrixClient, Room } from "matrix-js-sdk"; import { finishCeremony, initAtpSession, @@ -20,55 +19,126 @@ import { } from "./ceremony.js"; import { loadAppConfig } from "./config.js"; import { + acceptInvite, + awaitRoom, createCounterRoom, createMatrixClient, + declineInvite, foldRoom, + inviterOf, listCounterRooms, + listInvites, loadFullHistory, - onCounterOp, + onRoomChanged, + onRoomsChanged, + roomMembers, sendCounterOp, startAndSync, } from "./matrix.js"; +import { atDisplay, formatDisplay } from "./profiles.js"; import { + addPendingInvite, clearSession, + getCeremonySkipped, getNeedsCeremony, + listPendingInvites, + loadDid, loadSession, + peekReturnPath, + removePendingInvite, + saveDid, saveSession, + setCeremonySkipped, setNeedsCeremony, + setReturnPath, storage, + takeReturnPath, } from "./storage.js"; import { button, clear, el, show, statusLine, textInput } from "./ui.js"; import "./style.css"; const config = loadAppConfig(); const ctx: LoginContext = { storage, client: config.client, resolver: config.resolver }; -const atpClient = makeAtpClient(config.handleResolver); -const mooringCache = new Map>(); +let atpClientPromise: ReturnType | null = null; +const getAtpClient = (): ReturnType => + (atpClientPromise ??= makeAtpClient(config.handleResolver, config.atpClientId ?? undefined)); +// Deep-linking: the open counter lives in the URL as /room/{roomId}, so +// reloads and back/forward land where you were (the static server and the +// vite dev server both SPA-fallback unknown paths to index.html). +const ROOM_PATH_PREFIX = "/room/"; +const INVITE_PATH_PREFIX = "/invite/"; +const roomPath = (roomId: string): string => `${ROOM_PATH_PREFIX}${encodeURIComponent(roomId)}`; +const roomIdFromPath = (path: string): string | null => { + if (!path.startsWith(ROOM_PATH_PREFIX)) return null; + try { + return decodeURIComponent(path.slice(ROOM_PATH_PREFIX.length)); + } catch { + return null; + } +}; +interface InviteLink { + roomId: string; + name: string | null; +} +const inviteLinkFor = (roomId: string, name: string): string => + `${window.location.origin}${INVITE_PATH_PREFIX}${encodeURIComponent(roomId)}?name=${encodeURIComponent(name)}`; +const inviteFromLocation = (): InviteLink | null => { + const path = window.location.pathname; + if (!path.startsWith(INVITE_PATH_PREFIX)) return null; + try { + return { + roomId: decodeURIComponent(path.slice(INVITE_PATH_PREFIX.length)), + name: new URLSearchParams(window.location.search).get("name"), + }; + } catch { + return null; + } +}; +let popHandler: (() => void) | null = null; +let inviteWatcherStarted = false; + +/** Navigate to a homeserver authorization URL, relaying the user's identifier + * through the broker so they never retype it (Tuwunel drops login_hint). */ +function navigateToAuth(authorizationUrl: string, identity: AtIdentity | null): void { + const identifier = identity?.handle ?? identity?.did ?? null; + window.location.assign( + config.brokerIssuer !== null && identifier !== null + ? brokerHintUrl(config.brokerIssuer, identifier, authorizationUrl) + : authorizationUrl, + ); +} // --------------------------------------------------------------------------- // Views // --------------------------------------------------------------------------- +/** Kick off login for an identifier: Flow A redirect, or the choose-a-server + * view when there is no mooring yet. Shared by the login and invite views. */ +async function startLoginFor( + identifier: string, + status: ReturnType, +): Promise { + status.set("Looking up your identity…"); + try { + const start = await beginAtLogin(identifier, ctx); + if (start.kind === "redirect") { + status.set("Found your Matrix home — taking you there to sign in…"); + navigateToAuth(start.authorizationUrl, start.identity); + } else { + chooseView(start.identity); + } + } catch (err) { + status.set(err instanceof Error ? err.message : String(err), true); + } +} + function loginView(message?: string): void { const status = statusLine(); if (message !== undefined) status.set(message, true); const input = textInput("alice.example.com", "identifier"); - const go = async (): Promise => { - status.set("Resolving your identity…"); - try { - const start = await beginAtLogin(input.value, ctx); - if (start.kind === "redirect") { - status.set(`Mooring found: ${start.pointer?.mxid ?? ""} — redirecting…`); - window.location.assign(start.authorizationUrl); - } else { - chooseView(start.identity); - } - } catch (err) { - status.set(err instanceof Error ? err.message : String(err), true); - } - }; + const go = (): void => void startLoginFor(input.value, status); input.addEventListener("keydown", (e) => { - if (e.key === "Enter") void go(); + if (e.key === "Enter") go(); }); show( el("main", { class: "card" }, [ @@ -76,7 +146,49 @@ function loginView(message?: string): void { el("p", {}, ["Shared counters over Matrix rooms, with AT Protocol identity."]), el("label", { for: "identifier" }, ["Your handle or DID"]), input, - button("Continue with AT", () => void go()), + button("Continue with AT", go), + status.node, + ]), + ); +} + +/** Landing page for an invite link when not logged in: explains the whole + * onboarding journey, including creating an AT identity from scratch. */ +function inviteWelcomeView(invite: InviteLink): void { + const status = statusLine(); + const input = textInput("alice.example.com", "identifier"); + const dest = invite.name !== null ? `“${invite.name}”` : "a shared counter"; + const go = (): void => { + if (input.value.trim().length === 0) { + status.set("Enter your handle first — or create one via the link below.", true); + return; + } + // Come back to this invitation after the sign-in round trips. + setReturnPath(window.location.pathname + window.location.search); + void startLoginFor(input.value, status); + }; + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") go(); + }); + const bskyLink = el("a", { href: "https://bsky.app", target: "_blank", rel: "noopener" }, ["bsky.app"]); + show( + el("main", { class: "card" }, [ + el("h1", {}, ["You're invited 🎉"]), + el("p", {}, [`Someone wants you to join ${dest}. Here's the whole journey — most of it is automatic:`]), + el("ol", { class: "steps" }, [ + el("li", { class: "current" }, ["Sign in with your AT (Bluesky) handle"]), + el("li", {}, ["Get a Matrix account — created for you if you don't have one"]), + el("li", {}, ["Link the two — your host emails you a code to confirm"]), + el("li", {}, [`Land in ${dest}`]), + ]), + el("label", { for: "identifier" }, ["Your handle"]), + input, + button("Let's go", go), + el("p", { class: "hint" }, [ + "New to the AT network? Create your identity at ", + bskyLink, + " first (it takes a minute), then come back to this link.", + ]), status.node, ]), ); @@ -98,7 +210,7 @@ function chooseView(identity: AtIdentity): void { { server: chosen, identity, ...(prompt !== undefined ? { prompt } : {}) }, ctx, ); - window.location.assign(authorizationUrl); + navigateToAuth(authorizationUrl, identity); } catch (err) { status.set(err instanceof Error ? err.message : String(err), true); } @@ -127,92 +239,188 @@ function chooseView(identity: AtIdentity): void { show(el("main", { class: "card" }, children)); } -function ceremonyBanner(session: MatrixSession, onDone: () => void): HTMLElement { - const container = el("section", { class: "banner" }); +/** Best-known atproto identifier for this session: carried through the login + * flow, else recovered from a DID-derived localpart (never the raw MXID). */ +function didHint(session: MatrixSession): string | null { + const saved = loadDid(); + if (saved !== null) return saved; + const localpart = session.mxid.slice(1, session.mxid.indexOf(":")); + if (localpart.startsWith("did.plc.")) return `did:plc:${localpart.slice("did.plc.".length)}`; + if (localpart.startsWith("did.web.")) return `did:web:${localpart.slice("did.web.".length)}`; + return null; +} + +/** + * Step 2 of the login flow (spec §5.5): a full-screen ceremony that points the + * DID document at the new Matrix account. Reached straight from the OAuth + * callback when the mooring is incomplete, and resumed automatically after the + * PDS consent redirect. + */ +const CEREMONY_STEPS = [ + "Connect your AT account", + "Confirm with the emailed code", + "Publish the link", +] as const; + +function ceremonySteps(current: number): HTMLElement { + return el( + "ol", + { class: "steps" }, + CEREMONY_STEPS.map((label, i) => + el("li", { class: i < current ? "done" : i === current ? "current" : "" }, [ + i < current ? `${label} ✓` : label, + ]), + ), + ); +} + +function ceremonyView(session: MatrixSession, client: MatrixClient): void { + setCeremonySkipped(false); + const enterApp = (): void => appView(session, client); const status = statusLine(); - let draft: CeremonyDraft | null = null; - - const renderStart = (): void => { - clear(container); - container.append( - el("strong", {}, ["Mooring incomplete: "]), - "your DID document does not point at this Matrix account yet. ", - button("Link it now", () => { + const waitingInvite = peekReturnPath()?.startsWith(INVITE_PATH_PREFIX) === true; + const afterText = waitingInvite ? "your invitation is waiting on the other side" : "then you're all set"; + + const card = (step: number, children: Array): void => { + show( + el("main", { class: "card" }, [ + el("h1", {}, ["Last step: link your identities"]), + el("p", {}, [ + `Your Matrix account is ready. Publishing the link in your AT identity lets anyone find ` + + `and verify you by your handle — ${afterText}.`, + ]), + ceremonySteps(step), + ...children, + status.node, + ]), + ); + }; + + const skipButton = (): HTMLButtonElement => + button("Skip for now (you can finish later from the app)", () => { + setCeremonySkipped(true); + enterApp(); + }, "link"); + + const renderIntro = (): void => { + const known = didHint(session); + const idInput = textInput("your handle or DID", "atp-id"); + const children: Array = [ + el("p", {}, [ + `Next, your AT host will ask you to approve “identity changes” for this link, ` + + `and will email you a confirmation code.`, + ]), + ]; + if (known === null) children.push(el("label", { for: "atp-id" }, ["Your handle or DID"]), idInput); + children.push( + button("Connect my AT account", () => { void (async () => { + const identifier = known ?? idInput.value.trim(); + if (identifier.length === 0) { + status.set("Enter your handle first.", true); + return; + } try { - const atp = await initAtpSession(atpClient); - if (atp === null) { - status.set("Redirecting to your PDS to authorize identity changes…"); - const did = (await verifyMooringFromMxid(session.mxid, session.homeserverBaseUrl, config.resolver)).did; - await startAtpSignIn(atpClient, did ?? session.mxid); - return; - } - status.set("Requesting a signing code — check your email…"); - draft = await prepareCeremony(atp, session, config.resolver ?? {}, true); - renderToken(); + status.set("Taking you to your AT host to approve…"); + await startAtpSignIn(await getAtpClient(), identifier); } catch (err) { status.set(err instanceof Error ? err.message : String(err), true); } })(); }), - status.node, + skipButton(), ); + card(0, children); }; - const renderToken = (): void => { - clear(container); + const renderToken = (draft: CeremonyDraft): void => { const token = textInput("code from the email", "plc-token"); - container.append( - el("strong", {}, ["Enter the code your PDS emailed you: "]), + const submit = (): void => { + void (async () => { + if (token.value.trim().length === 0) { + status.set("Paste the code from the email first.", true); + return; + } + try { + status.set("Publishing the link to your identity…"); + await finishCeremony(draft, token.value.trim(), session, config.resolver ?? {}); + setNeedsCeremony(false); + card(3, [ + el("p", {}, ["You're moored ⚓ — your handle and this Matrix account now vouch for each other."]), + button(waitingInvite ? "Take me to my invitation" : "Enter the app", enterApp), + ]); + } catch (err) { + status.set(err instanceof Error ? err.message : String(err), true); + } + })(); + }; + token.addEventListener("keydown", (e) => { + if (e.key === "Enter") submit(); + }); + card(1, [ + el("p", {}, [ + `Check your email: your AT host just sent a confirmation code to the address on your account. ` + + `Nothing is published until you enter it here.`, + ]), + el("label", { for: "plc-token" }, ["Confirmation code"]), token, - button("Complete mooring", () => { - void (async () => { - if (draft === null) return; - try { - status.set("Submitting the PLC operation…"); - await finishCeremony(draft, token.value.trim(), session, config.resolver ?? {}); - status.set("Moored ✓"); - setNeedsCeremony(false); - onDone(); - } catch (err) { - status.set(err instanceof Error ? err.message : String(err), true); - } - })(); - }), - status.node, - ); + button("Confirm and publish", submit), + skipButton(), + ]); }; - renderStart(); - return container; + // Resume after the PDS redirect (or restore a live atproto session); fall + // back to the intro screen otherwise. + void (async () => { + card(0, [el("p", {}, ["Checking your identity session…"])]); + try { + const atp = await initAtpSession(await getAtpClient()); + if (atp !== null) { + status.set("Asking your AT host to email you a code…"); + const draft = await prepareCeremony(atp, session, config.resolver ?? {}, true); + status.set(""); + renderToken(draft); + return; + } + } catch (err) { + status.set(err instanceof Error ? err.message : String(err), true); + } + renderIntro(); + })(); } function appView(session: MatrixSession, client: MatrixClient): void { const roomList = el("nav", { class: "rooms" }); const mainPane = el("section", { class: "main" }); + const whoami = el("span", { class: "whoami", title: session.mxid }, [session.mxid]); + void atDisplay(session.mxid, config.resolver).then((d) => { + if (d.verified) whoami.textContent = formatDisplay(d); + }); const header = el("header", {}, [ el("strong", {}, ["Matey Counter"]), - el("span", { class: "whoami" }, [session.mxid]), + whoami, + ...(getNeedsCeremony() + ? [button("Finish linking ⚓", () => ceremonyView(session, client), "secondary")] + : []), button("Log out", () => { clearSession(); window.location.assign("/"); }, "link"), ]); const layout = el("div", { class: "layout" }, [header]); - if (getNeedsCeremony()) { - layout.append( - ceremonyBanner(session, () => { - appView(session, client); - }), - ); - } layout.append(el("div", { class: "columns" }, [roomList, mainPane])); show(layout); let unsubscribe: (() => void) | null = null; + let currentRoomId: string | null = null; - const openRoom = async (room: Room): Promise => { + const openRoom = async (room: Room, push = true): Promise => { unsubscribe?.(); + currentRoomId = room.roomId; + if (push && roomIdFromPath(window.location.pathname) !== room.roomId) { + window.history.pushState(null, "", roomPath(room.roomId)); + } + renderRooms(); clear(mainPane); const countEl = el("div", { class: "count" }, ["…"]); const opsEl = el("p", { class: "ops" }); @@ -220,27 +428,65 @@ function appView(session: MatrixSession, client: MatrixClient): void { const members = el("ul", { class: "members" }); const inviteInput = textInput("invite by handle or DID", "invite"); + inviteInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") void invite(); + }); const invite = async (): Promise => { - status.set(`Verifying ${inviteInput.value}…`); + const identifier = inviteInput.value.trim(); + status.set(`Verifying ${identifier}…`); try { - const mooring = await verifyMooringFromDid(inviteInput.value, config.resolver); - if (mooring.status !== "verified" || mooring.mxid === undefined) { + const mooring = await verifyMooringFromDid(identifier, config.resolver); + if (mooring.status === "no-mooring") { + addPendingInvite({ identifier, roomId: room.roomId, roomName: room.name || "a counter" }); status.set( - mooring.status === "no-mooring" - ? `${inviteInput.value} has no verified mooring — they need to link a Matrix account first.` - : `Verification failed: ${mooring.detail ?? mooring.status}`, - true, + `${identifier} isn't set up yet. Send them this link — it walks them through everything, ` + + `and they'll be invited automatically as soon as they finish (keep this app open).`, ); + showLink(inviteLinkFor(room.roomId, room.name || "a counter")); + inviteInput.value = ""; + return; + } + if (mooring.status !== "verified" || mooring.mxid === undefined) { + status.set(`Verification failed: ${mooring.detail ?? mooring.status}`, true); + return; + } + const membership = room.getMember(mooring.mxid)?.membership; + if (membership === "join") { + status.set(`${identifier} is already in this room.`, true); + return; + } + if (membership === "invite") { + status.set(`${identifier} already has a pending invitation.`, true); + return; + } + if (membership === "ban") { + status.set(`${identifier} is banned from this room.`, true); return; } await client.invite(room.roomId, mooring.mxid); - status.set(`Invited ${mooring.mxid}${mooring.signed ? " (signed mooring ✓)" : " ✓"}`); + status.set(`Invited ${identifier}${mooring.signed ? " (signed mooring ⚓)" : " ⚓"} — you can also send them the room link.`); + showLink(inviteLinkFor(room.roomId, room.name || "a counter")); inviteInput.value = ""; } catch (err) { status.set(err instanceof Error ? err.message : String(err), true); } }; + const linkBox = el("div", { class: "linkbox hidden" }); + const showLink = (link: string): void => { + clear(linkBox); + linkBox.classList.remove("hidden"); + linkBox.append( + el("code", {}, [link]), + button("Copy", () => { + void navigator.clipboard + .writeText(link) + .then(() => status.set("Link copied ✓")) + .catch(() => status.set("Could not copy — select the link text instead.", true)); + }, "secondary"), + ); + }; + mainPane.append( el("h2", {}, [room.name || room.roomId]), countEl, @@ -249,38 +495,68 @@ function appView(session: MatrixSession, client: MatrixClient): void { button("+1", () => void sendCounterOp(client, room.roomId, 1), "big"), ]), opsEl, - el("div", { class: "row" }, [inviteInput, button("Invite", () => void invite())]), + el("div", { class: "row" }, [ + inviteInput, + button("Invite", () => void invite()), + button("Room link", () => showLink(inviteLinkFor(room.roomId, room.name || "a counter")), "secondary"), + ]), + linkBox, status.node, el("h3", {}, ["Members"]), members, ); - await loadFullHistory(client, room); - const state = foldRoom(room); - const renderCount = (): void => { - countEl.textContent = String(state.count); - opsEl.textContent = `${state.opCount} operation${state.opCount === 1 ? "" : "s"} folded`; + const renderMembers = (): void => { + clear(members); + for (const member of roomMembers(room)) { + const nameSpan = el("span", { title: member.userId }, [member.userId]); + const badge = el("span", { class: "badge" }, ["checking…"]); + const invited = member.membership === "invite"; + members.append( + el("li", {}, [ + nameSpan, + " ", + ...(invited ? [el("em", { class: "badge" }, ["invited — not joined yet "])] : []), + badge, + ]), + ); + void atDisplay(member.userId, config.resolver).then((d) => { + nameSpan.textContent = formatDisplay(d); + badge.textContent = d.verified ? (d.signed ? "⚓ signed" : "⚓") : "no mooring"; + badge.classList.toggle("verified", d.verified); + }); + } }; - renderCount(); - unsubscribe = onCounterOp(client, room.roomId, (event) => { - if (state.apply(event.getId(), event.getContent())) renderCount(); - }); - clear(members); - for (const member of room.getJoinedMembers()) { - const badge = el("span", { class: "badge" }, ["checking…"]); - members.append(el("li", {}, [`${member.name} (${member.userId}) `, badge])); - let cached = mooringCache.get(member.userId); - if (cached === undefined) { - cached = verifyMooringFromMxid(member.userId, undefined, config.resolver); - mooringCache.set(member.userId, cached); + // Self-healing refresh: backfill history and re-fold the whole op set on + // every timeline/membership change. Right after accepting an invite the + // room has no back-pagination token until the join sync lands, so a + // one-shot fold would freeze at 0 — recomputing on change converges + // instead (and folding is idempotent by construction, ADR 0003). + let busy = false; + let dirty = false; + const refresh = async (): Promise => { + if (busy) { + dirty = true; + return; } - void cached.then((m) => { - badge.textContent = - m.status === "verified" ? (m.signed ? `⚓ ${m.did} (signed)` : `⚓ ${m.did}`) : "no mooring"; - badge.classList.toggle("verified", m.status === "verified"); - }); - } + busy = true; + try { + await loadFullHistory(client, room); + const state = foldRoom(room); + countEl.textContent = String(state.count); + opsEl.textContent = `${state.opCount} operation${state.opCount === 1 ? "" : "s"} folded`; + renderMembers(); + } finally { + busy = false; + if (dirty) { + dirty = false; + void refresh(); + } + } + }; + await refresh(); + unsubscribe = onRoomChanged(client, room.roomId, () => void refresh()); }; const renderRooms = (): void => { @@ -288,22 +564,211 @@ function appView(session: MatrixSession, client: MatrixClient): void { roomList.append( button("+ New counter", () => { void (async () => { - const name = window.prompt("Counter name?"); - if (name === null || name.length === 0) return; + const name = window.prompt("Counter name?")?.trim(); + if (name === undefined || name.length === 0) return; + const norm = name.toLowerCase(); + const clash = + listCounterRooms(client).find((r) => r.name.trim().toLowerCase() === norm) ?? + listInvites(client).find((r) => r.name.trim().toLowerCase() === norm); + if (clash !== undefined) { + window.alert( + `You already have a counter called “${name}”` + + `${clash.getMyMembership() === "invite" ? " (a pending invitation)" : ""}. Pick another name.`, + ); + return; + } const roomId = await createCounterRoom(client, name); + const room = await awaitRoom(client, roomId); renderRooms(); - const room = client.getRoom(roomId); - if (room !== null) void openRoom(room); + void openRoom(room); })(); }), ); + + const invites = listInvites(client); + if (invites.length > 0) { + roomList.append(el("h3", { class: "section" }, ["Invitations"])); + for (const room of invites) { + const from = inviterOf(room, session.mxid); + const fromEl = el("div", { class: "badge" }); + if (from !== null) { + fromEl.textContent = "from …"; + void atDisplay(from, config.resolver).then((d) => { + fromEl.textContent = `from ${formatDisplay(d)}${d.verified ? " ⚓" : ""}`; + }); + } + roomList.append( + el("div", { class: "invite" }, [ + el("div", {}, [room.name || room.roomId]), + ...(from !== null ? [fromEl] : []), + el("div", { class: "row" }, [ + button("Accept", () => { + void (async () => { + await acceptInvite(client, room.roomId); + const joined = await awaitRoom(client, room.roomId); + renderRooms(); + void openRoom(joined); + })(); + }), + button("Decline", () => { + void declineInvite(client, room.roomId).then(renderRooms); + }, "secondary"), + ]), + ]), + ); + } + roomList.append(el("h3", { class: "section" }, ["Counters"])); + } + for (const room of listCounterRooms(client)) { const b = button(room.name || room.roomId, () => void openRoom(room), "room"); + b.classList.toggle("active", room.roomId === currentRoomId); roomList.append(b); } }; + /** The invite-accept pane, shown for /invite/{roomId} links. Re-renders on + * sync changes, so the invitation appearing (e.g. via the inviter's + * auto-invite watcher) flips it live from "waiting" to Accept. */ + let rerenderMain: (() => void) | null = null; + const renderInviteAccept = (link: InviteLink): void => { + unsubscribe?.(); + unsubscribe = null; + currentRoomId = null; + renderRooms(); + rerenderMain = () => renderInviteAccept(link); + clear(mainPane); + + const room = client.getRoom(link.roomId); + const name = (room?.name ?? "") || link.name || "a shared counter"; + + if (room !== null && room.getMyMembership() === "join") { + rerenderMain = null; + window.history.replaceState(null, "", roomPath(room.roomId)); + void openRoom(room, false); + return; + } + + if (room !== null && room.getMyMembership() === "invite") { + const from = inviterOf(room, session.mxid); + const fromEl = el("p", {}); + if (from !== null) { + fromEl.textContent = "Invited by …"; + void atDisplay(from, config.resolver).then((d) => { + fromEl.textContent = `Invited by ${formatDisplay(d)}${d.verified ? " ⚓" : ""}`; + }); + } + mainPane.append( + el("h2", {}, [`Join “${name}”?`]), + fromEl, + el("div", { class: "row" }, [ + button("Accept and enter", () => { + void (async () => { + await acceptInvite(client, room.roomId); + const joined = await awaitRoom(client, room.roomId); + rerenderMain = null; + renderRooms(); + window.history.replaceState(null, "", roomPath(joined.roomId)); + void openRoom(joined, false); + })(); + }), + button("Decline", () => { + void declineInvite(client, room.roomId).then(() => { + rerenderMain = null; + window.history.replaceState(null, "", "/"); + clear(mainPane); + renderRooms(); + }); + }, "secondary"), + ]), + ); + return; + } + + // No invitation (yet): the inviter's app auto-invites once this account's + // mooring is visible, and sync will re-render this pane the moment the + // invitation lands. + const who = el("span", {}, [session.mxid]); + void atDisplay(session.mxid, config.resolver).then((d) => { + who.textContent = formatDisplay(d); + }); + mainPane.append( + el("h2", {}, [`Your invitation to “${name}” is on its way`]), + el("p", {}, [ + "You're signed in as ", + who, + ". The person who invited you will send the invitation as soon as your account is visible " + + "(their app does it automatically if it's open). This page updates by itself — no need to refresh.", + ]), + ); + }; + + const renderAll = (): void => { + renderRooms(); + rerenderMain?.(); + }; renderRooms(); - client.on(ClientEvent.Room, renderRooms); + onRoomsChanged(client, renderAll); + + // Auto-invite watcher: handles invited before they existed get their + // invitation the moment their mooring verifies (spec §4). + if (!inviteWatcherStarted) { + inviteWatcherStarted = true; + setInterval(() => { + void (async () => { + for (const pending of listPendingInvites()) { + const room = client.getRoom(pending.roomId); + if (room === null || room.getMyMembership() !== "join") continue; + try { + const mooring = await verifyMooringFromDid(pending.identifier, config.resolver); + if (mooring.status !== "verified" || mooring.mxid === undefined) continue; + const membership = room.getMember(mooring.mxid)?.membership; + if (membership !== "join" && membership !== "invite") { + await client.invite(pending.roomId, mooring.mxid); + } + removePendingInvite(pending.identifier, pending.roomId); + } catch { + // stay pending; retried next tick + } + } + })(); + }, 20_000); + } + + // Restore the deep link stashed across the OAuth round trips, then route. + const returnPath = takeReturnPath(); + if (returnPath !== null && window.location.pathname === "/") { + window.history.replaceState(null, "", returnPath); + } + + // Route from the URL (reload / deep link), and follow back/forward. + const openFromLocation = (push: boolean): void => { + const id = roomIdFromPath(window.location.pathname); + if (id !== null) { + const room = client.getRoom(id); + if (room !== null && room.getMyMembership() === "join") { + rerenderMain = null; + void openRoom(room, push); + } else { + window.history.replaceState(null, "", "/"); + } + return; + } + const invite = inviteFromLocation(); + if (invite !== null) { + renderInviteAccept(invite); + return; + } + unsubscribe?.(); + unsubscribe = null; + currentRoomId = null; + rerenderMain = null; + clear(mainPane); + renderRooms(); + }; + if (popHandler !== null) window.removeEventListener("popstate", popHandler); + popHandler = () => openFromLocation(false); + window.addEventListener("popstate", popHandler); + openFromLocation(false); } // --------------------------------------------------------------------------- @@ -318,6 +783,8 @@ async function boot(): Promise { const done = await completeAtLogin(params, ctx); saveSession(done.session); setNeedsCeremony(done.needsCeremony); + setCeremonySkipped(false); + if (done.did !== undefined) saveDid(done.did); if (done.mxidMismatch) { window.alert( "Heads-up: the account you logged into differs from the one your DID document points at. Re-run the linking ceremony to repair the mooring.", @@ -334,7 +801,14 @@ async function boot(): Promise { const session = loadSession(); if (session === null) { // Also lets the atproto client swallow its own callback params, if any. - await initAtpSession(atpClient).catch(() => null); + await getAtpClient() + .then(initAtpSession) + .catch(() => null); + const invite = inviteFromLocation(); + if (invite !== null) { + inviteWelcomeView(invite); + return; + } loginView(); return; } @@ -346,6 +820,13 @@ async function boot(): Promise { loginView("Your session expired — log in again."); return; } + // The linking ceremony is part of the login flow: it comes before the app + // (and resumes here after the PDS consent redirect), unless deliberately + // skipped — then the app header carries a "finish linking" affordance. + if (getNeedsCeremony() && !getCeremonySkipped()) { + ceremonyView(session, client); + return; + } appView(session, client); } diff --git a/counter/src/matrix.ts b/counter/src/matrix.ts index 6f45b6d..c9c6d87 100644 --- a/counter/src/matrix.ts +++ b/counter/src/matrix.ts @@ -3,9 +3,12 @@ import { createClient, Preset, RoomEvent, + RoomStateEvent, type MatrixClient, type MatrixEvent, type Room, + type RoomMember, + type RoomState, } from "matrix-js-sdk"; import { COUNTER_OP_EVENT, COUNTER_STATE_EVENT, type MatrixSession } from "@matey/client"; import { CounterState } from "./counter.js"; @@ -42,15 +45,64 @@ export function isCounterRoom(room: Room): boolean { export function listCounterRooms(client: MatrixClient): Room[] { return client .getRooms() - .filter(isCounterRoom) + .filter((room) => room.getMyMembership() === "join" && isCounterRoom(room)) .sort((a, b) => a.name.localeCompare(b.name)); } +/** + * Pending invitations. Note: invited rooms carry only *stripped* state (name, + * join rules, membership — no custom events), so the counter marker is not + * visible until after joining; all invites are listed. + */ +export function listInvites(client: MatrixClient): Room[] { + return client.getRooms().filter((room) => room.getMyMembership() === "invite"); +} + +/** Who sent a pending invite, if the stripped state says. */ +export function inviterOf(room: Room, myMxid: string): string | null { + return room.getMember(myMxid)?.events.member?.getSender() ?? null; +} + +export async function acceptInvite(client: MatrixClient, roomId: string): Promise { + await client.joinRoom(roomId); +} + +export async function declineInvite(client: MatrixClient, roomId: string): Promise { + await client.leave(roomId); +} + +/** Joined + invited members, for the room member list. */ +export function roomMembers(room: Room): RoomMember[] { + return room + .getMembers() + .filter((m) => m.membership === "join" || m.membership === "invite") + .sort((a, b) => (a.membership === b.membership ? a.name.localeCompare(b.name) : a.membership === "join" ? -1 : 1)); +} + +/** Re-render hook for anything membership-shaped: new rooms, my own membership + * transitions (invite→join), and other people joining/leaving rooms. */ +export function onRoomsChanged(client: MatrixClient, handler: () => void): () => void { + client.on(ClientEvent.Room, handler); + client.on(RoomEvent.MyMembership, handler); + const onMembers = (): void => handler(); + client.on(RoomStateEvent.Members, onMembers); + return () => { + client.removeListener(ClientEvent.Room, handler); + client.removeListener(RoomEvent.MyMembership, handler); + client.removeListener(RoomStateEvent.Members, onMembers); + }; +} + export async function createCounterRoom(client: MatrixClient, name: string): Promise { const { room_id } = await client.createRoom({ name, preset: Preset.PrivateChat, - initial_state: [{ type: COUNTER_STATE_EVENT, state_key: "", content: { version: 1 } }], + initial_state: [ + { type: COUNTER_STATE_EVENT, state_key: "", content: { version: 1 } }, + // Pin explicitly: newcomers MUST be able to fold ops from before their + // join, or counters would diverge per member. + { type: "m.room.history_visibility", state_key: "", content: { history_visibility: "shared" } }, + ], power_level_content_override: { // every member may send counter ops events: { [COUNTER_OP_EVENT]: 0 }, @@ -59,6 +111,26 @@ export async function createCounterRoom(client: MatrixClient, name: string): Pro return room_id; } +/** createRoom resolves before the room reaches the local store; wait for it. */ +export async function awaitRoom(client: MatrixClient, roomId: string, timeoutMs = 10_000): Promise { + const existing = client.getRoom(roomId); + if (existing !== null) return existing; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + client.removeListener(ClientEvent.Room, onRoom); + reject(new Error("the new room did not sync in time — reload and it should be there")); + }, timeoutMs); + const onRoom = (room: Room): void => { + if (room.roomId === roomId) { + clearTimeout(timer); + client.removeListener(ClientEvent.Room, onRoom); + resolve(room); + } + }; + client.on(ClientEvent.Room, onRoom); + }); +} + export async function sendCounterOp(client: MatrixClient, roomId: string, delta: 1 | -1): Promise { // COUNTER_OP_EVENT is a custom event type; matrix-js-sdk's sendEvent is typed // against the spec'd TimelineEvents map, so the type goes through a cast. @@ -83,17 +155,27 @@ export function foldRoom(room: Room): CounterState { return state; } -/** Subscribe to live ops for a room; returns an unsubscribe function. */ -export function onCounterOp( - client: MatrixClient, - roomId: string, - handler: (event: MatrixEvent) => void, -): () => void { - const listener = (event: MatrixEvent, room: Room | undefined): void => { - if (room?.roomId === roomId && event.getType() === COUNTER_OP_EVENT) handler(event); +/** + * Anything that should refresh an open room: timeline events (ops, history + * pagination after a fresh join), membership of others, and my own membership + * transitions. Returns an unsubscribe function. + */ +export function onRoomChanged(client: MatrixClient, roomId: string, handler: () => void): () => void { + const onTimeline = (_event: MatrixEvent, room: Room | undefined): void => { + if (room?.roomId === roomId) handler(); + }; + const onMembers = (_event: MatrixEvent, state: RoomState): void => { + if (state.roomId === roomId) handler(); + }; + const onMyMembership = (room: Room): void => { + if (room.roomId === roomId) handler(); }; - client.on(RoomEvent.Timeline, listener); + client.on(RoomEvent.Timeline, onTimeline); + client.on(RoomStateEvent.Members, onMembers); + client.on(RoomEvent.MyMembership, onMyMembership); return () => { - client.removeListener(RoomEvent.Timeline, listener); + client.removeListener(RoomEvent.Timeline, onTimeline); + client.removeListener(RoomStateEvent.Members, onMembers); + client.removeListener(RoomEvent.MyMembership, onMyMembership); }; } diff --git a/counter/src/profiles.ts b/counter/src/profiles.ts new file mode 100644 index 0000000..c6eb6d1 --- /dev/null +++ b/counter/src/profiles.ts @@ -0,0 +1,73 @@ +import { + handleFromDocument, + pdsFromDocument, + verifyMooringFromMxid, + type ResolverOptions, +} from "@matey/client"; + +/** + * Display resolution: MXIDs in this app are opaque (DID-derived localparts), + * so anything user-facing resolves through the mooring to the AT profile — + * displayName and @handle — and only falls back to the MXID when unmoored. + */ +export interface AtDisplay { + /** Best human label: profile displayName, else @handle, else the MXID. */ + label: string; + handle: string | null; + did: string | null; + verified: boolean; + signed: boolean; +} + +const cache = new Map>(); + +export function atDisplay(mxid: string, resolver: ResolverOptions = {}): Promise { + let p = cache.get(mxid); + if (p === undefined) { + p = compute(mxid, resolver); + cache.set(mxid, p); + } + return p; +} + +async function compute(mxid: string, resolver: ResolverOptions): Promise { + const fallback: AtDisplay = { label: mxid, handle: null, did: null, verified: false, signed: false }; + try { + const mooring = await verifyMooringFromMxid(mxid, null, resolver); + if (mooring.status !== "verified" || mooring.document === undefined || mooring.did === undefined) { + return fallback; + } + const handle = handleFromDocument(mooring.document); + let name: string | null = null; + const pds = pdsFromDocument(mooring.document); + if (pds !== null) { + try { + const res = await fetch( + `${pds.replace(/\/+$/, "")}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(mooring.did)}&collection=app.bsky.actor.profile&rkey=self`, + ); + if (res.ok) { + const record = (await res.json()) as { value?: { displayName?: string } }; + const displayName = record.value?.displayName; + if (typeof displayName === "string" && displayName.length > 0) name = displayName; + } + } catch { + // profile record is best-effort; identity is already established + } + } + return { + label: name ?? (handle !== null ? `@${handle}` : mooring.did), + handle, + did: mooring.did, + verified: true, + signed: mooring.signed, + }; + } catch { + return fallback; + } +} + +/** "Robin (@robin.berjon.com)" — or just the handle/MXID when that's all there is. */ +export function formatDisplay(d: AtDisplay): string { + if (!d.verified || d.handle === null) return d.label; + return d.label === `@${d.handle}` ? d.label : `${d.label} (@${d.handle})`; +} diff --git a/counter/src/storage.ts b/counter/src/storage.ts index 757308f..6f8265a 100644 --- a/counter/src/storage.ts +++ b/counter/src/storage.ts @@ -12,6 +12,8 @@ export const storage: KeyValueStorage = { const SESSION_KEY = "matey:counter:session"; const CEREMONY_KEY = "matey:counter:needs-ceremony"; +const SKIP_KEY = "matey:counter:ceremony-skipped"; +const DID_KEY = "matey:counter:did"; const MATEY_KEY = "matey:counter:matey-key"; export const saveSession = (s: MatrixSession): void => @@ -23,11 +25,51 @@ export const loadSession = (): MatrixSession | null => { export const clearSession = (): void => { window.localStorage.removeItem(SESSION_KEY); window.localStorage.removeItem(CEREMONY_KEY); + window.localStorage.removeItem(SKIP_KEY); + window.localStorage.removeItem(DID_KEY); }; export const setNeedsCeremony = (v: boolean): void => window.localStorage.setItem(CEREMONY_KEY, v ? "1" : "0"); export const getNeedsCeremony = (): boolean => window.localStorage.getItem(CEREMONY_KEY) === "1"; +export const setCeremonySkipped = (v: boolean): void => + window.localStorage.setItem(SKIP_KEY, v ? "1" : "0"); +export const getCeremonySkipped = (): boolean => window.localStorage.getItem(SKIP_KEY) === "1"; + +export const saveDid = (did: string): void => window.localStorage.setItem(DID_KEY, did); +export const loadDid = (): string | null => window.localStorage.getItem(DID_KEY); + +/** Deep link to restore after the OAuth round trips (login + ceremony). */ +const RETURN_KEY = "matey:counter:return"; +export const setReturnPath = (p: string): void => window.localStorage.setItem(RETURN_KEY, p); +export const peekReturnPath = (): string | null => window.localStorage.getItem(RETURN_KEY); +export const takeReturnPath = (): string | null => { + const v = window.localStorage.getItem(RETURN_KEY); + window.localStorage.removeItem(RETURN_KEY); + return v; +}; + +/** Handles we tried to invite before they had a mooring: watched and + * auto-invited by the poller the moment their mooring verifies. */ +export interface PendingInvite { + identifier: string; + roomId: string; + roomName: string; +} +const PENDING_KEY = "matey:counter:pending-invites"; +export const listPendingInvites = (): PendingInvite[] => + JSON.parse(window.localStorage.getItem(PENDING_KEY) ?? "[]") as PendingInvite[]; +export const addPendingInvite = (p: PendingInvite): void => { + const rest = listPendingInvites().filter((x) => !(x.identifier === p.identifier && x.roomId === p.roomId)); + window.localStorage.setItem(PENDING_KEY, JSON.stringify([...rest, p])); +}; +export const removePendingInvite = (identifier: string, roomId: string): void => { + window.localStorage.setItem( + PENDING_KEY, + JSON.stringify(listPendingInvites().filter((x) => !(x.identifier === identifier && x.roomId === roomId))), + ); +}; + export const saveMateyKey = (exported: string): void => window.localStorage.setItem(MATEY_KEY, exported); export const loadMateyKey = (): string | null => window.localStorage.getItem(MATEY_KEY); diff --git a/counter/src/style.css b/counter/src/style.css index 4f42389..15572f0 100644 --- a/counter/src/style.css +++ b/counter/src/style.css @@ -19,6 +19,7 @@ button.link { background: none; color: #1083fe; padding: .2rem; text-align: left button.big { font-size: 2rem; padding: .4rem 1.6rem; } button.room { background: none; text-align: left; padding: .4rem .6rem; border-radius: .3rem; } button.room:hover { background: #8882; } +button.room.active { background: #1083fe22; font-weight: 600; } .status { min-height: 1.2em; margin: .3rem 0; font-size: .9rem; opacity: .85; } .status.error { color: #c62828; opacity: 1; } @@ -37,6 +38,23 @@ header .whoami { opacity: .7; font-size: .85rem; flex: 1; } .ops { opacity: .6; font-size: .85rem; } .row { display: flex; gap: .6rem; align-items: center; margin: .6rem 0; } +.steps { padding-left: 1.4rem; margin: .8rem 0; } +.steps li { padding: .15rem 0; opacity: .55; } +.steps li.current { opacity: 1; font-weight: 600; } +.steps li.current::marker { color: #1083fe; } +.steps li.done { opacity: .8; color: #2e7d32; } + +.linkbox { display: flex; gap: .6rem; align-items: center; margin: .5rem 0; padding: .5rem .7rem; + border: 1px dashed #8886; border-radius: .4rem; } +.linkbox code { flex: 1; overflow-wrap: anywhere; font-size: .85rem; } +.hidden { display: none; } +.hint { font-size: .85rem; opacity: .75; } + +.rooms .section { margin: .6rem 0 .2rem; font-size: .8rem; text-transform: uppercase; opacity: .6; } +.invite { padding: .4rem .6rem; border: 1px solid #8884; border-radius: .4rem; margin-bottom: .4rem; } +.invite .row { margin: .4rem 0 0; } +.invite button { padding: .3rem .7rem; font-size: .85rem; } + .members { list-style: none; padding: 0; } .members li { padding: .25rem 0; } .badge { font-size: .8rem; opacity: .7; } diff --git a/deploy/sm/README.md b/deploy/sm/README.md new file mode 100644 index 0000000..92266cb --- /dev/null +++ b/deploy/sm/README.md @@ -0,0 +1,83 @@ +# Deploying the Matey demo with `sm` (mycopunk.it) + +Deploys the demo behind the supramundane Caddy front +(`~/Code/darobin/caddy-front`) as three services: + +| Service | Domain | What | +|---|---|---| +| `matey-auth` | `matey-auth.mycopunk.it` | the Matey broker (AT auth adapter) | +| `matey-matrix` | `matey-matrix.mycopunk.it` | Tuwunel homeserver (native next-gen auth; broker as its identity provider) | +| `matey-counter` | `matey-counter.mycopunk.it` | the counter demo app (static SPA) | + +## One-time setup + +1. **DNS**: point the three subdomains above at the supramundane server (the + front's automatic ACME needs them resolving before first deploy). +2. **Secrets**: one shared secret ties Tuwunel to the broker: + + ```sh + cd deploy/sm + cp matey-auth/.env.example matey-auth/.env + cp matey-matrix/tuwunel.toml.example matey-matrix/tuwunel.toml + openssl rand -base64 32 # paste into BOTH files (client_secret / MATEY_BROKER_CLIENT_SECRET) + ``` + + Both files are git-ignored and travel to the server via `sm deploy`'s rsync. + +3. **Data-dir ownership**: the broker container runs unprivileged (uid 1000) + but `sm` creates the data dir as root, so once, after the first + `matey-auth` deploy: + + ```sh + ssh $SM_REMOTE_USER@$SUPRAMUNDANE 'chown 1000:1000 /srv/supramundane/data/matey-auth && docker restart matey-auth' + ``` + + (Symptom if skipped: `matey-auth` crash-loops on + `EACCES /data/broker.keys.json` and Tuwunel reports a 502 for the broker's + openid-configuration at login.) + +## Every deploy + +```sh +pnpm sm:prepare # builds counter → matey-counter/site, broker → matey-auth/dist + +cd deploy/sm/matey-auth && sm deploy +cd ../matey-matrix && sm deploy +cd ../matey-counter && sm deploy +``` + +Order matters on first deploy only (Tuwunel validates its identity provider by +talking to the broker; the app needs both). `sm deploy` wires each service into +the front and reloads it. + +Smoke checks: + +```sh +curl https://matey-auth.mycopunk.it/.well-known/openid-configuration +curl https://matey-auth.mycopunk.it/atproto/client-metadata.json +curl https://matey-matrix.mycopunk.it/_matrix/client/v1/auth_metadata +curl https://matey-counter.mycopunk.it/oauth-client-metadata.json +``` + +Then open , enter your handle, and walk the +flow (no mooring → "create an account on matey-matrix.mycopunk.it" → PDS +consent → provisioned account → run the linking ceremony from the banner). + +## Notes + +- **Federation** is delegated to `:443` via Tuwunel-served + `/.well-known/matrix/server` (`well_known.server` in `tuwunel.toml`), since + the front only exposes 80/443 — no `:8448` needed. +- **Server name is permanent**: `matey-matrix.mycopunk.it` is baked into the + Tuwunel database (the sm data dir `…/data/matey-matrix`). To rename, wipe it. +- **Broker keys** persist in `…/data/matey-auth` (id_token signing + atproto + client keys). Wiping them rotates the broker's identity; in-flight logins die, + nothing else. +- The counter app is built with `VITE_DEFAULT_HOMESERVER=matey-matrix.mycopunk.it` + and serves its own atproto client-metadata document (for the ceremony's + `identity:*` session). Different hosts: override `MATEY_COUNTER_HOST` / + `MATEY_MATRIX_HOST` when running `pnpm sm:prepare`, and edit the two + service.json `domains` + the two secret files to match. +- The other deployment route (plain docker-compose with its own Caddy) remains + in `deploy/` for non-supramundane hosts; known Tuwunel-topology limitations + are listed in `deploy/README.md` and spec Appendix B.1. diff --git a/deploy/sm/matey-auth/.env.example b/deploy/sm/matey-auth/.env.example new file mode 100644 index 0000000..9460db5 --- /dev/null +++ b/deploy/sm/matey-auth/.env.example @@ -0,0 +1,10 @@ +# Copy to .env (synced to the server by `sm deploy`; git-ignored). +MATEY_BROKER_ISSUER=https://matey-auth.mycopunk.it +MATEY_BROKER_PORT=8080 +MATEY_BROKER_CLIENT_ID=tuwunel +# Must match identity_provider client_secret in matey-matrix/tuwunel.toml. +# Generate with: openssl rand -base64 32 +MATEY_BROKER_CLIENT_SECRET=CHANGE-ME +MATEY_BROKER_REDIRECT_URIS=https://matey-matrix.mycopunk.it/_matrix/client/unstable/login/sso/callback/tuwunel +# "did" (stable did.plc.… localparts, spec-recommended) or "handle" +MATEY_LOCALPART_STYLE=did diff --git a/deploy/sm/matey-auth/Dockerfile b/deploy/sm/matey-auth/Dockerfile new file mode 100644 index 0000000..d9b81ac --- /dev/null +++ b/deploy/sm/matey-auth/Dockerfile @@ -0,0 +1,13 @@ +# Runs the pre-built broker (deploy/sm/prepare.mjs copies dist/ + package.json here). +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY package.json ./ +RUN npm install --omit=dev +COPY dist/ dist/ +# The container runs unprivileged; the sm data dir bind-mounted at /data must be +# owned by uid 1000 on the host (one-time: chown 1000:1000 …/data/matey-auth). +RUN mkdir -p /data && chown node:node /data +EXPOSE 8080 +USER node +CMD ["node", "dist/server.js"] diff --git a/deploy/sm/matey-auth/compose.yaml b/deploy/sm/matey-auth/compose.yaml new file mode 100644 index 0000000..a3abe28 --- /dev/null +++ b/deploy/sm/matey-auth/compose.yaml @@ -0,0 +1,18 @@ +services: + matey-auth: + build: . + restart: unless-stopped + container_name: matey-auth + env_file: .env + environment: + MATEY_KEYS_FILE: /data/broker.keys.json + volumes: + # Persistent broker signing keys (id_token + atproto client keys). + - ${SM_DATA_ROOT:-/srv/supramundane/data}/matey-auth:/data + networks: + - default + +networks: + default: + name: supramundane + external: true diff --git a/deploy/sm/matey-auth/service.json b/deploy/sm/matey-auth/service.json new file mode 100644 index 0000000..34eb01b --- /dev/null +++ b/deploy/sm/matey-auth/service.json @@ -0,0 +1,10 @@ +{ + "name": "matey-auth", + "type": "node", + "upstreamPort": 8080, + "data": true, + "domains": { + "prod": ["matey-auth.mycopunk.it"], + "local": ["matey-auth.bast"] + } +} diff --git a/deploy/sm/matey-counter/Caddyfile b/deploy/sm/matey-counter/Caddyfile new file mode 100644 index 0000000..65377ad --- /dev/null +++ b/deploy/sm/matey-counter/Caddyfile @@ -0,0 +1,8 @@ +# Internal vhost — served only behind the supramundane front proxy. +# Public domain, TLS, and privacy headers are handled by the front +# (see sites/matey-counter.caddy on the front, generated by `sm deploy`). +:80 { + root * /srv + try_files {path} /index.html + file_server +} diff --git a/deploy/sm/matey-counter/Dockerfile b/deploy/sm/matey-counter/Dockerfile new file mode 100644 index 0000000..82b3f47 --- /dev/null +++ b/deploy/sm/matey-counter/Dockerfile @@ -0,0 +1,3 @@ +FROM caddy:2-alpine +COPY site/ /srv/ +COPY Caddyfile /etc/caddy/Caddyfile diff --git a/deploy/sm/matey-counter/compose.yaml b/deploy/sm/matey-counter/compose.yaml new file mode 100644 index 0000000..f9102fb --- /dev/null +++ b/deploy/sm/matey-counter/compose.yaml @@ -0,0 +1,12 @@ +services: + matey-counter: + build: . + restart: unless-stopped + container_name: matey-counter + networks: + - default + +networks: + default: + name: supramundane + external: true diff --git a/deploy/sm/matey-counter/service.json b/deploy/sm/matey-counter/service.json new file mode 100644 index 0000000..4bac9bd --- /dev/null +++ b/deploy/sm/matey-counter/service.json @@ -0,0 +1,8 @@ +{ + "name": "matey-counter", + "type": "static", + "domains": { + "prod": ["matey-counter.mycopunk.it"], + "local": ["matey-counter.bast"] + } +} diff --git a/deploy/sm/matey-matrix/compose.yaml b/deploy/sm/matey-matrix/compose.yaml new file mode 100644 index 0000000..d723f7e --- /dev/null +++ b/deploy/sm/matey-matrix/compose.yaml @@ -0,0 +1,18 @@ +services: + matey-matrix: + image: ghcr.io/matrix-construct/tuwunel:latest + restart: unless-stopped + container_name: matey-matrix + environment: + TUWUNEL_CONFIG: /etc/tuwunel/tuwunel.toml + volumes: + - ./tuwunel.toml:/etc/tuwunel/tuwunel.toml:ro + # RocksDB database — server name is baked into it, never change it. + - ${SM_DATA_ROOT:-/srv/supramundane/data}/matey-matrix:/var/lib/tuwunel + networks: + - default + +networks: + default: + name: supramundane + external: true diff --git a/deploy/sm/matey-matrix/service.json b/deploy/sm/matey-matrix/service.json new file mode 100644 index 0000000..71dd1fe --- /dev/null +++ b/deploy/sm/matey-matrix/service.json @@ -0,0 +1,11 @@ +{ + "name": "matey-matrix", + "type": "docker", + "image": "ghcr.io/matrix-construct/tuwunel:latest", + "upstreamPort": 8008, + "data": true, + "domains": { + "prod": ["matey-matrix.mycopunk.it"], + "local": ["matey-matrix.bast"] + } +} diff --git a/deploy/sm/matey-matrix/tuwunel.toml.example b/deploy/sm/matey-matrix/tuwunel.toml.example new file mode 100644 index 0000000..6a3eea0 --- /dev/null +++ b/deploy/sm/matey-matrix/tuwunel.toml.example @@ -0,0 +1,36 @@ +# Copy to tuwunel.toml (synced to the server by `sm deploy`; git-ignored +# because it carries the identity-provider client secret). +# +# THE SERVER NAME CANNOT BE CHANGED LATER without wiping the database +# (the sm data dir for this service). + +[global] +server_name = "matey-matrix.mycopunk.it" +address = "0.0.0.0" +port = 8008 + +# Accounts are created through the AT identity provider, not open registration. +allow_registration = false + +# Advertise OIDC as the preferred login method (MSC3824). +oidc_aware_preferred = true + +[global.well_known] +client = "https://matey-matrix.mycopunk.it" +# TLS terminates at the supramundane front on 443, so point federation there +# instead of the default :8448 (which the front does not expose). +server = "matey-matrix.mycopunk.it:443" + +# --- The Matey broker as the AT Protocol identity provider ------------------ +# (spec §5.2 Strategy A / Appendix B.1) +[[global.identity_provider]] +brand = "matey" +name = "AT Protocol" +# Doubles as the provider id and appears in the callback URL — never change it. +client_id = "tuwunel" +# Must match MATEY_BROKER_CLIENT_SECRET in matey-auth/.env. +client_secret = "CHANGE-ME" +issuer_url = "https://matey-auth.mycopunk.it" +callback_url = "https://matey-matrix.mycopunk.it/_matrix/client/unstable/login/sso/callback/tuwunel" +# The broker supports OIDC prompt=create, so forward registration intent. +forward_action_prompt = true diff --git a/deploy/sm/prepare.mjs b/deploy/sm/prepare.mjs new file mode 100644 index 0000000..954314a --- /dev/null +++ b/deploy/sm/prepare.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// Build the deployable artifacts into the sm service directories. +// Run from anywhere: `node deploy/sm/prepare.mjs` (or `pnpm sm:prepare`). +// +// - matey-counter: vite-builds the counter app against the mycopunk.it hosts +// and stages it (plus its atproto client-metadata document) into site/. +// - matey-auth: tsc-builds the broker and stages dist/ + package.json. +// - matey-matrix: nothing to build (existing image + config file). +import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { execSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SM_DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(SM_DIR, "..", ".."); + +// Deployment hosts — override via env for a different domain layout. +const COUNTER_HOST = process.env.MATEY_COUNTER_HOST ?? "matey-counter.mycopunk.it"; +const MATRIX_HOST = process.env.MATEY_MATRIX_HOST ?? "matey-matrix.mycopunk.it"; +const AUTH_HOST = process.env.MATEY_AUTH_HOST ?? "matey-auth.mycopunk.it"; + +const run = (cmd, env = {}) => { + console.log(`\n$ ${cmd}`); + execSync(cmd, { cwd: ROOT, stdio: "inherit", env: { ...process.env, ...env } }); +}; + +// --- matey-counter ----------------------------------------------------------- + +const counterClientMetadataUrl = `https://${COUNTER_HOST}/oauth-client-metadata.json`; +run("pnpm --filter matey-example-counter build", { + VITE_DEFAULT_HOMESERVER: MATRIX_HOST, + VITE_ATP_CLIENT_ID: counterClientMetadataUrl, + VITE_MATEY_BROKER: `https://${AUTH_HOST}`, +}); +const site = path.join(SM_DIR, "matey-counter", "site"); +rmSync(site, { recursive: true, force: true }); +cpSync(path.join(ROOT, "counter", "dist"), site, { recursive: true }); + +// The app's own atproto client identity, used for the linking ceremony +// (public browser client; spec §5.5 step 1). +writeFileSync( + path.join(site, "oauth-client-metadata.json"), + JSON.stringify( + { + client_id: counterClientMetadataUrl, + client_name: "Matey Counter", + client_uri: `https://${COUNTER_HOST}`, + redirect_uris: [`https://${COUNTER_HOST}/`], + scope: "atproto identity:*", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + application_type: "web", + token_endpoint_auth_method: "none", + dpop_bound_access_tokens: true, + }, + null, + 2, + ) + "\n", +); +console.log(`staged deploy/sm/matey-counter/site (homeserver: ${MATRIX_HOST})`); + +// --- matey-auth -------------------------------------------------------------- + +run("pnpm --filter matey-broker build"); +const authDir = path.join(SM_DIR, "matey-auth"); +rmSync(path.join(authDir, "dist"), { recursive: true, force: true }); +cpSync(path.join(ROOT, "broker", "dist"), path.join(authDir, "dist"), { recursive: true }); +cpSync(path.join(ROOT, "broker", "package.json"), path.join(authDir, "package.json")); +console.log("staged deploy/sm/matey-auth/{dist,package.json}"); + +// --- reminders --------------------------------------------------------------- + +const missing = []; +if (!existsSync(path.join(authDir, ".env"))) missing.push("deploy/sm/matey-auth/.env (from .env.example)"); +if (!existsSync(path.join(SM_DIR, "matey-matrix", "tuwunel.toml"))) + missing.push("deploy/sm/matey-matrix/tuwunel.toml (from tuwunel.toml.example)"); +mkdirSync(site, { recursive: true }); +console.log("\nAll artifacts staged."); +if (missing.length > 0) { + console.log("Before deploying, create (with matching secrets):"); + for (const m of missing) console.log(` - ${m}`); +} +console.log("\nDeploy order: matey-auth, matey-matrix, matey-counter — `sm deploy` in each dir."); diff --git a/package.json b/package.json index 3d6e6e2..8474f7d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "build": "pnpm -r build", "test": "pnpm -r test", - "typecheck": "pnpm -r typecheck" + "typecheck": "pnpm -r typecheck", + "sm:prepare": "node deploy/sm/prepare.mjs" }, "devDependencies": { "typescript": "^5.6.0" -- 2.51.2