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"