diff --git a/src/atproto.ts b/src/atproto.ts index 1553eb9..ac48a22 100644 --- a/src/atproto.ts +++ b/src/atproto.ts @@ -4,8 +4,8 @@ import { fetchJson } from "./http.ts"; export type AtprotoLogin = Readonly<{ - handle: string; - appPassword: string; + atprotoHandle: string; + atprotoAppPassword: string; // Presetting the PDS URL skips handle resolution. pdsUrl?: string | undefined; }>; @@ -17,7 +17,7 @@ export async function getServiceToken( aud: string, lxm: string, ): Promise { - const pdsUrl = login.pdsUrl ?? (await resolvePds(login.handle)); + const pdsUrl = login.pdsUrl ?? (await resolvePds(login.atprotoHandle)); const session = await fetchJson<{ accessJwt: string }>( "session creation", @@ -25,7 +25,10 @@ export async function getServiceToken( { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identifier: login.handle, password: login.appPassword }), + body: JSON.stringify({ + identifier: login.atprotoHandle, + password: login.atprotoAppPassword, + }), }, ); diff --git a/src/config.ts b/src/config.ts index b8898a4..5483c3b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,11 @@ export type Config = Readonly<{ atprotoPdsUrl: string | undefined; }>; +// Narrow views over Config for functions that only need a subset of fields. +// Composition roots keep using the full Config; downstream functions take +// just what they read. +export type OpenMeetConfig = Pick; + const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; export function loadConfig(env: Record = process.env): Config { @@ -52,7 +57,12 @@ export function loadConfig(env: Record = process.env }; } -const DURATION_UNITS: Record = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }; +const DURATION_UNITS: Record = { + ms: 1, + s: 1_000, + m: 60_000, + h: 3_600_000, +}; // Parses Go-style durations like "30s", "5m", "1h30m" into milliseconds. export function parseDuration(input: string): number { diff --git a/src/openmeet.test.ts b/src/openmeet.test.ts index 57f4636..18f6cb9 100644 --- a/src/openmeet.test.ts +++ b/src/openmeet.test.ts @@ -1,21 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { Config } from "./config.ts"; -import { getUpcomingEvents, type OpenMeetEvent } from "./openmeet.ts"; +import type { AtprotoLogin } from "./atproto.ts"; +import type { OpenMeetConfig } from "./config.ts"; +import { authenticate, getUpcomingEvents, type OpenMeetEvent } from "./openmeet.ts"; -const cfg: Config = { +const om: OpenMeetConfig = { openMeetBaseUrl: "http://openmeet.test" }; +const atp: AtprotoLogin = { atprotoHandle: "test.example", atprotoAppPassword: "hunter2", - slackToken: undefined, - slackChannel: undefined, - weeklyNotifyDay: 1, - weeklyNotifyHour: 7, - weeklyNotifyMinute: 30, - checkIntervalMs: 300_000, - enableStdout: true, - openMeetBaseUrl: "http://openmeet.test", - atprotoPdsUrl: "http://pds.test", // skip handle resolution; everything goes through fetch + pdsUrl: "http://pds.test", // skip handle resolution; everything goes through fetch }; const events: OpenMeetEvent[] = [ @@ -49,25 +43,16 @@ const authRoutes = { Response.json({ token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }), }; -test("authenticates via ATProto on first use and fetches events", async (t) => { - const calls = routeFetch(t, { - ...authRoutes, - "/api/groups/devict/events": (init) => { - assert.equal(new Headers(init.headers).get("authorization"), "Bearer access-1"); - return Response.json(events); - }, - }); +test("authenticate runs the full ATProto handshake and returns OpenMeet tokens", async (t) => { + const calls = routeFetch(t, authRoutes); - const today = Temporal.PlainDate.from("2026-06-12"); - const result = await getUpcomingEvents(cfg, null, today); + const tokens = await authenticate(atp, om.openMeetBaseUrl); - assert.deepEqual(result.events, events); - assert.equal(result.tokens.token, "access-1"); + assert.deepEqual(tokens, { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }); assert.deepEqual(calls, [ "/xrpc/com.atproto.server.createSession", "/xrpc/com.atproto.server.getServiceAuth", "/api/v1/auth/atproto/service-auth", - "/api/groups/devict/events", ]); }); @@ -79,7 +64,7 @@ test("sends a 7-day date range", async (t) => { }); const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; - await getUpcomingEvents(cfg, tokens, Temporal.PlainDate.from("2026-06-12")); + await getUpcomingEvents(om, atp, tokens, Temporal.PlainDate.from("2026-06-12")); assert.equal(query?.get("startDate"), "2026-06-12T00:00:00"); assert.equal(query?.get("endDate"), "2026-06-19T00:00:00"); @@ -100,7 +85,7 @@ test("refreshes tokens on 401 and retries", async (t) => { }); const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; - const result = await getUpcomingEvents(cfg, tokens, Temporal.PlainDate.from("2026-06-12")); + const result = await getUpcomingEvents(om, atp, tokens, Temporal.PlainDate.from("2026-06-12")); assert.deepEqual(result.events, events); assert.equal(result.tokens.token, "access-2"); @@ -121,7 +106,7 @@ test("falls back to full re-auth when the refresh token is rejected", async (t) }); const tokens = { token: "stale", refreshToken: "stale", tokenExpires: 0 }; - const result = await getUpcomingEvents(cfg, tokens, Temporal.PlainDate.from("2026-06-12")); + const result = await getUpcomingEvents(om, atp, tokens, Temporal.PlainDate.from("2026-06-12")); assert.deepEqual(result.events, events); assert.equal(result.tokens.token, "access-1"); // came from the full ATProto handshake @@ -136,7 +121,7 @@ test("gives up after exhausting retries on persistent 401s", async (t) => { const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; await assert.rejects( - getUpcomingEvents(cfg, tokens, Temporal.PlainDate.from("2026-06-12")), + getUpcomingEvents(om, atp, tokens, Temporal.PlainDate.from("2026-06-12")), /events fetch failed: 401/, ); }); @@ -148,7 +133,7 @@ test("non-401 errors propagate immediately", async (t) => { const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; await assert.rejects( - getUpcomingEvents(cfg, tokens, Temporal.PlainDate.from("2026-06-12")), + getUpcomingEvents(om, atp, tokens, Temporal.PlainDate.from("2026-06-12")), /events fetch failed: 500: boom/, ); assert.equal(calls.length, 1); diff --git a/src/openmeet.ts b/src/openmeet.ts index c799471..b75f04d 100644 --- a/src/openmeet.ts +++ b/src/openmeet.ts @@ -1,8 +1,8 @@ // OpenMeet API client as plain functions. There is no client object: tokens // are passed in and returned out, so the caller owns all state. -import { getServiceToken } from "./atproto.ts"; -import type { Config } from "./config.ts"; +import { getServiceToken, type AtprotoLogin } from "./atproto.ts"; +import type { OpenMeetConfig } from "./config.ts"; import { fetchJson, HttpError } from "./http.ts"; const TENANT_ID = "lsdfaopkljdfs"; @@ -23,61 +23,57 @@ export type Tokens = Readonly<{ tokenExpires: number; }>; -export const formatEvent = (e: OpenMeetEvent): string => `${e.name} (${e.slug})`; - -const headers = (tokens?: Tokens): Record => ({ - "X-Tenant-Id": TENANT_ID, - "Content-Type": "application/json", - ...(tokens ? { Authorization: `Bearer ${tokens.token}` } : {}), -}); +function headers(tokens?: Tokens): Record { + return { + "X-Tenant-Id": TENANT_ID, + "Content-Type": "application/json", + ...(tokens ? { Authorization: `Bearer ${tokens.token}` } : {}), + }; +} // Full handshake: ATProto service token -> OpenMeet token exchange. -export async function authenticate(cfg: Config): Promise { - const serviceToken = await getServiceToken( - { handle: cfg.atprotoHandle, appPassword: cfg.atprotoAppPassword, pdsUrl: cfg.atprotoPdsUrl }, - SERVICE_DID, - LEXICON_METHOD, - ); - return fetchJson( - "token exchange", - `${cfg.openMeetBaseUrl}/api/v1/auth/atproto/service-auth`, - { - method: "POST", - headers: headers(), - body: JSON.stringify({ token: serviceToken }), - }, - ); +export async function authenticate(atpLogin: AtprotoLogin, omBaseUrl: string): Promise { + const serviceToken = await getServiceToken(atpLogin, SERVICE_DID, LEXICON_METHOD); + return fetchJson("token exchange", `${omBaseUrl}/api/v1/auth/atproto/service-auth`, { + method: "POST", + headers: headers(), + body: JSON.stringify({ token: serviceToken }), + }); } // Mints a new access token from the refresh token; a 401 falls back to a // full re-auth via ATProto. -async function refreshOrReauth(cfg: Config, tokens: Tokens): Promise { +async function refreshOrReauth( + om: OpenMeetConfig, + atp: AtprotoLogin, + tokens: Tokens, +): Promise { try { - return await fetchJson("token refresh", `${cfg.openMeetBaseUrl}/api/v1/auth/refresh`, { + return await fetchJson("token refresh", `${om.openMeetBaseUrl}/api/v1/auth/refresh`, { method: "POST", headers: headers(tokens), body: JSON.stringify(tokens), }); } catch (err) { - if (err instanceof HttpError && err.status === 401) return authenticate(cfg); + if (err instanceof HttpError && err.status === 401) + return authenticate(atp, om.openMeetBaseUrl); throw err; } } -// Fetches all events in [today, today+7d), authenticating on first use -// (tokens == null) and refreshing/re-authing on 401. Returns the events -// along with the current tokens so the caller can thread them into the -// next call. +// Fetches all events in [today, today+7d), refreshing/re-authing on 401. +// Returns the events along with the current tokens so the caller can thread +// them into the next call. export async function getUpcomingEvents( - cfg: Config, - tokens: Tokens | null, + om: OpenMeetConfig, + atp: AtprotoLogin, + tokens: Tokens, today: Temporal.PlainDate = Temporal.Now.plainDateISO(), ): Promise<{ events: OpenMeetEvent[]; tokens: Tokens }> { - const url = new URL(`${cfg.openMeetBaseUrl}/api/groups/${GROUP_SLUG}/events`); + const url = new URL(`${om.openMeetBaseUrl}/api/groups/${GROUP_SLUG}/events`); url.searchParams.set("startDate", today.toPlainDateTime().toString()); url.searchParams.set("endDate", today.add({ days: 7 }).toPlainDateTime().toString()); - tokens ??= await authenticate(cfg); for (let attempt = 1; ; attempt++) { try { const events = await fetchJson("events fetch", url, { @@ -86,7 +82,7 @@ export async function getUpcomingEvents( return { events, tokens }; } catch (err) { if (err instanceof HttpError && err.status === 401 && attempt < 3) { - tokens = await refreshOrReauth(cfg, tokens); + tokens = await refreshOrReauth(om, atp, tokens); continue; } throw err;