···2525# Vite
2626vite.config.js.timestamp-*
2727vite.config.ts.timestamp-*
2828+2929+# Tunnel-generated (only meaningful while tunnel is running)
3030+static/.well-known/did.json
···11export * as AppBskyActorProfile from "./types/app/bsky/actor/profile.js";
22export * as CommunityLexiconCalendarEvent from "./types/community/lexicon/calendar/event.js";
33-export * as CommunityLexiconCalendarEventGetRecord from "./types/community/lexicon/calendar/event/getRecord.js";
44-export * as CommunityLexiconCalendarEventListRecords from "./types/community/lexicon/calendar/event/listRecords.js";
53export * as CommunityLexiconCalendarRsvp from "./types/community/lexicon/calendar/rsvp.js";
66-export * as CommunityLexiconCalendarRsvpGetRecord from "./types/community/lexicon/calendar/rsvp/getRecord.js";
77-export * as CommunityLexiconCalendarRsvpListRecords from "./types/community/lexicon/calendar/rsvp/listRecords.js";
84export * as CommunityLexiconLocationAddress from "./types/community/lexicon/location/address.js";
95export * as CommunityLexiconLocationFsq from "./types/community/lexicon/location/fsq.js";
106export * as CommunityLexiconLocationGeo from "./types/community/lexicon/location/geo.js";
117export * as CommunityLexiconLocationHthree from "./types/community/lexicon/location/hthree.js";
88+export * as RsvpAtmoEventGetRecord from "./types/rsvp/atmo/event/getRecord.js";
99+export * as RsvpAtmoEventListRecords from "./types/rsvp/atmo/event/listRecords.js";
1210export * as RsvpAtmoGetCursor from "./types/rsvp/atmo/getCursor.js";
1311export * as RsvpAtmoGetOverview from "./types/rsvp/atmo/getOverview.js";
1412export * as RsvpAtmoGetProfile from "./types/rsvp/atmo/getProfile.js";
1513export * as RsvpAtmoNotifyOfUpdate from "./types/rsvp/atmo/notifyOfUpdate.js";
1414+export * as RsvpAtmoRsvpGetRecord from "./types/rsvp/atmo/rsvp/getRecord.js";
1515+export * as RsvpAtmoRsvpListRecords from "./types/rsvp/atmo/rsvp/listRecords.js";
1616+export * as RsvpAtmoSpaceAddMember from "./types/rsvp/atmo/space/addMember.js";
1717+export * as RsvpAtmoSpaceCreateSpace from "./types/rsvp/atmo/space/createSpace.js";
1818+export * as RsvpAtmoSpaceDefs from "./types/rsvp/atmo/space/defs.js";
1919+export * as RsvpAtmoSpaceDeleteRecord from "./types/rsvp/atmo/space/deleteRecord.js";
2020+export * as RsvpAtmoSpaceGetRecord from "./types/rsvp/atmo/space/getRecord.js";
2121+export * as RsvpAtmoSpaceGetSpace from "./types/rsvp/atmo/space/getSpace.js";
2222+export * as RsvpAtmoSpaceInviteCreate from "./types/rsvp/atmo/space/invite/create.js";
2323+export * as RsvpAtmoSpaceInviteList from "./types/rsvp/atmo/space/invite/list.js";
2424+export * as RsvpAtmoSpaceInviteRedeem from "./types/rsvp/atmo/space/invite/redeem.js";
2525+export * as RsvpAtmoSpaceInviteRevoke from "./types/rsvp/atmo/space/invite/revoke.js";
2626+export * as RsvpAtmoSpaceLeaveSpace from "./types/rsvp/atmo/space/leaveSpace.js";
2727+export * as RsvpAtmoSpaceListMembers from "./types/rsvp/atmo/space/listMembers.js";
2828+export * as RsvpAtmoSpaceListRecords from "./types/rsvp/atmo/space/listRecords.js";
2929+export * as RsvpAtmoSpaceListSpaces from "./types/rsvp/atmo/space/listSpaces.js";
3030+export * as RsvpAtmoSpacePutRecord from "./types/rsvp/atmo/space/putRecord.js";
3131+export * as RsvpAtmoSpaceRemoveMember from "./types/rsvp/atmo/space/removeMember.js";
3232+export * as RsvpAtmoSpaceWhoami from "./types/rsvp/atmo/space/whoami.js";
···11+import type {} from "@atcute/lexicons";
22+import * as v from "@atcute/lexicons/validations";
33+import type {} from "@atcute/lexicons/ambient";
44+55+const _mainSchema = /*#__PURE__*/ v.query("rsvp.atmo.space.whoami", {
66+ params: /*#__PURE__*/ v.object({
77+ spaceUri: /*#__PURE__*/ v.resourceUriString(),
88+ }),
99+ output: {
1010+ type: "lex",
1111+ schema: /*#__PURE__*/ v.object({
1212+ isMember: /*#__PURE__*/ v.boolean(),
1313+ isOwner: /*#__PURE__*/ v.boolean(),
1414+ /**
1515+ * Present only when the caller is a member or the owner.
1616+ */
1717+ perms: /*#__PURE__*/ v.optional(
1818+ /*#__PURE__*/ v.string<"read" | "write" | (string & {})>(),
1919+ ),
2020+ }),
2121+ },
2222+});
2323+2424+type main$schematype = typeof _mainSchema;
2525+2626+export interface mainSchema extends main$schematype {}
2727+2828+export const mainSchema = _mainSchema as mainSchema;
2929+3030+export interface $params extends v.InferInput<mainSchema["params"]> {}
3131+export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {}
3232+3333+declare module "@atcute/lexicons/ambient" {
3434+ interface XRPCQueries {
3535+ "rsvp.atmo.space.whoami": mainSchema;
3636+ }
3737+}
+59-2
src/lib/atproto/scripts/tunnel.ts
···11-import { readFileSync, writeFileSync } from 'node:fs';
22-import { resolve } from 'node:path';
11+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
22+import { resolve, dirname } from 'node:path';
33import { spawn } from 'node:child_process';
44import { DEV_PORT } from '../port';
5566const cwd = process.cwd();
77const envPath = resolve(cwd, '.env');
88const vitePath = resolve(cwd, 'vite.config.ts');
99+const didDocPath = resolve(cwd, 'static/.well-known/did.json');
1010+const generatedServicePath = resolve(cwd, 'src/lib/spaces/tunnel-service.generated.ts');
1111+1212+/** Service fragment identifier used by our DID doc and service DID. */
1313+const SERVICE_FRAGMENT = 'event_space';
1414+const SERVICE_TYPE = 'AtmoSpaceService';
9151016let tunnelUrl: string | null = null;
1117let statusBarActive = false;
···133139 writeFileSync(vitePath, vite);
134140}
135141142142+// ── did doc + generated service file ─────────────────────────────
143143+144144+function writeDidDoc(hostname: string, tunnelUrl: string): void {
145145+ const did = `did:web:${hostname}`;
146146+ const doc = {
147147+ '@context': ['https://www.w3.org/ns/did/v1'],
148148+ id: did,
149149+ service: [
150150+ {
151151+ id: `#${SERVICE_FRAGMENT}`,
152152+ type: SERVICE_TYPE,
153153+ serviceEndpoint: tunnelUrl
154154+ }
155155+ ]
156156+ };
157157+ mkdirSync(dirname(didDocPath), { recursive: true });
158158+ writeFileSync(didDocPath, JSON.stringify(doc, null, 2) + '\n');
159159+}
160160+161161+function writeGeneratedService(hostname: string, tunnelUrl: string): void {
162162+ // NOTE: plain DID, no fragment. PDSes reject fragments in getServiceAuth's `aud` param,
163163+ // and the library's middleware does strict string equality. The fragment is only used
164164+ // by PDSes to look up service entries in the DID doc for Atproto-Proxy routing — that
165165+ // concern is separate from JWT audience validation.
166166+ const did = `did:web:${hostname}`;
167167+ const body =
168168+ `/** Auto-generated by \`pnpm tunnel\`. Do not edit by hand.\n` +
169169+ ` * When the tunnel is running, this file is rewritten with the tunnel's\n` +
170170+ ` * service DID + URL; when the tunnel stops, it is reset to null values. */\n\n` +
171171+ `export const SERVICE_DID: string | null = ${JSON.stringify(did)};\n` +
172172+ `export const SERVICE_URL: string | null = ${JSON.stringify(tunnelUrl)};\n`;
173173+ mkdirSync(dirname(generatedServicePath), { recursive: true });
174174+ writeFileSync(generatedServicePath, body);
175175+}
176176+177177+function resetGeneratedService(): void {
178178+ const body =
179179+ `/** Auto-generated by \`pnpm tunnel\`. Do not edit by hand.\n` +
180180+ ` * When the tunnel is running, this file is rewritten with the tunnel's\n` +
181181+ ` * service DID + URL; when the tunnel stops, it is reset to null values. */\n\n` +
182182+ `export const SERVICE_DID: string | null = null;\n` +
183183+ `export const SERVICE_URL: string | null = null;\n`;
184184+ writeFileSync(generatedServicePath, body);
185185+}
186186+136187// ── cleanup ──────────────────────────────────────────────────────
137188138189function cleanup(): void {
···143194 console.log(' Cleared OAUTH_PUBLIC_URL from .env');
144195 clearViteAllowedHosts();
145196 console.log(' Cleared allowedHosts from vite.config.ts');
197197+ resetGeneratedService();
198198+ console.log(' Reset src/lib/spaces/tunnel-service.generated.ts');
146199 }
147200}
148201···163216164217 setEnvVar('OAUTH_PUBLIC_URL', tunnelUrl);
165218 setViteAllowedHosts(hostname);
219219+ writeDidDoc(hostname, tunnelUrl);
220220+ writeGeneratedService(hostname, tunnelUrl);
166221167222 writeLog(`\n Set OAUTH_PUBLIC_URL=${tunnelUrl}\n`);
168223 writeLog(` Set vite allowedHosts to [${hostname}]\n`);
224224+ writeLog(` Wrote static/.well-known/did.json (did:web:${hostname}#${SERVICE_FRAGMENT})\n`);
225225+ writeLog(` Wrote src/lib/spaces/tunnel-service.generated.ts\n`);
169226 writeLog(` Tunnel is ready! Restart your dev server to pick up the new URL.\n\n`);
170227171228 setupScrollRegion();
+11-3
src/lib/atproto/settings.ts
···11import { dev } from '$app/environment';
22import { scope } from '@atcute/oauth-node-client';
3344-// writable collections
44+// writable collections — declared as a standalone scope because their NSIDs
55+// (`community.lexicon.*`) sit outside our namespace, so they can't go in
66+// `rsvp.atmo.permissionSet` (permission sets can only reference NSIDs in their
77+// own namespace).
58export const collections = [
69 'community.lexicon.calendar.event',
710 'community.lexicon.calendar.rsvp'
···9121013export type AllowedCollection = (typeof collections)[number];
11141212-// OAuth scope — add scope.blob(), scope.rpc(), etc. as needed
1515+// OAuth scopes. `include:rsvp.atmo.permissionSet?aud=*` bundles every rpc method
1616+// the deployment exposes; `aud=*` lets the same consent cover dev (tunnel DID)
1717+// and prod (published DID) without re-consenting. Repo writes and blob uploads
1818+// live as standalone scopes since they reference NSIDs (or resource kinds)
1919+// outside the `rsvp.atmo` namespace.
1320export const scopes = [
1421 'atproto',
1522 scope.repo({ collection: [...collections] }),
1616- scope.blob({ accept: ['image/*'] })
2323+ scope.blob({ accept: ['image/*'] }),
2424+ 'include:rsvp.atmo.permissionSet'
1725];
18261927// set to false to disable signup
···22import type { EventData } from '$lib/event-types';
33import type {
44 RsvpAtmoGetProfile,
55- CommunityLexiconCalendarEventGetRecord,
66- CommunityLexiconCalendarEventListRecords,
77- CommunityLexiconCalendarRsvpListRecords
55+ RsvpAtmoEventGetRecord,
66+ RsvpAtmoEventListRecords,
77+ RsvpAtmoRsvpListRecords
88} from '../lexicon-types';
99import type { Client } from '@atcute/client';
1010import type { ActorIdentifier } from '@atcute/lexicons';
···1616export const RSVP_INTERESTED = 'community.lexicon.calendar.rsvp#interested';
17171818type ProfileOutput = RsvpAtmoGetProfile.$output;
1919-type EventListOutput = CommunityLexiconCalendarEventListRecords.$output;
2020-type EventListRecord = CommunityLexiconCalendarEventListRecords.Record;
2121-type EventProfileEntry = CommunityLexiconCalendarEventListRecords.ProfileEntry;
2222-type EventGetOutput = CommunityLexiconCalendarEventGetRecord.$output;
2323-type EventGetProfileEntry = CommunityLexiconCalendarEventGetRecord.ProfileEntry;
2424-type RsvpListRecord = CommunityLexiconCalendarRsvpListRecords.Record;
2525-type RsvpProfileEntry = CommunityLexiconCalendarRsvpListRecords.ProfileEntry;
2626-type HydratedEventRecord = CommunityLexiconCalendarRsvpListRecords.RefEventRecord;
1919+type EventListOutput = RsvpAtmoEventListRecords.$output;
2020+type EventListRecord = RsvpAtmoEventListRecords.Record;
2121+type EventProfileEntry = RsvpAtmoEventListRecords.ProfileEntry;
2222+type EventGetOutput = RsvpAtmoEventGetRecord.$output;
2323+type EventGetProfileEntry = RsvpAtmoEventGetRecord.ProfileEntry;
2424+type RsvpListRecord = RsvpAtmoRsvpListRecords.Record;
2525+type RsvpProfileEntry = RsvpAtmoRsvpListRecords.ProfileEntry;
2626+type HydratedEventRecord = RsvpAtmoRsvpListRecords.RefEventRecord;
2727type FlattenableEventRecord = EventListRecord | EventGetOutput | HydratedEventRecord;
2828type EventProfiles = EventProfileEntry[] | EventGetProfileEntry[] | undefined;
2929type EventRsvps = EventListRecord['rsvps'] | EventGetOutput['rsvps'];
···3333 did: string;
3434 rkey: string;
3535 uri: string;
3636+ /** Populated when the event was read from a permissioned space. */
3737+ space?: string;
3638 rsvps?: EventRsvps;
3739 rsvpsCount?: number;
3840 rsvpsGoingCount?: number;
···7375 rsvpsGoingCountMin?: number;
7476 hydrateRsvps?: number;
7577 profiles?: boolean;
7878+ preferencesShowInDiscovery?: string;
7679 sort?: string;
7780 order?: 'asc' | 'desc';
7881 limit?: number;
···113116 did: record.did,
114117 rkey: record.rkey,
115118 uri: record.uri,
119119+ ...('space' in record && typeof record.space === 'string' ? { space: record.space } : {}),
116120 ...('rsvps' in record ? { rsvps: record.rsvps } : {}),
117121 ...('rsvpsCount' in record ? { rsvpsCount: record.rsvpsCount } : {}),
118122 ...('rsvpsGoingCount' in record ? { rsvpsGoingCount: record.rsvpsGoingCount } : {}),
···129133 .filter((record): record is FlatEventRecord => record !== null);
130134}
131135136136+/** Build the canonical path for an event. Private events (those with a `space`
137137+ * field from contrail's union) live under `/p/<actor>/e/<rkey>/s/<skey>` so
138138+ * the page knows both which event to show and which space to look in. Public
139139+ * events use `/p/<actor>/e/<rkey>`. */
140140+export function eventUrl(event: FlatEventRecord, actor?: string): string {
141141+ const who = actor || event.did;
142142+ if (event.space) {
143143+ const m = event.space.match(/^at:\/\/[^/]+\/[^/]+\/([^/]+)$/);
144144+ const skey = m?.[1];
145145+ if (skey) return `/p/${who}/e/${event.rkey}/s/${skey}`;
146146+ }
147147+ return `/p/${who}/e/${event.rkey}`;
148148+}
149149+132150export function getHostProfile(did: string, profiles?: EventProfiles): HostProfile | null {
133151 const profile = profiles?.find((entry) => entry.did === did);
134152 if (!profile) return null;
···215233export async function getProfileFromContrail(
216234 client: Client,
217235 actor: ActorIdentifier
218218-): Promise<ProfileOutput | null> {
236236+): Promise<ProfileOutput['profiles'][number] | null> {
219237 const response = await client.get('rsvp.atmo.getProfile', {
220238 params: { actor }
221239 });
222240223241 if (!response.ok) return null;
224224- return response.data;
242242+ return response.data.profiles?.[0] ?? null;
225243}
226244227245export async function listEventRecordsFromContrail(
228246 client: Client,
229247 params: ListEventsParams
230248): Promise<EventListOutput | null> {
231231- const response = await client.get('community.lexicon.calendar.event.listRecords', {
249249+ const response = await client.get('rsvp.atmo.event.listRecords', {
232250 params
233251 });
234252···236254 return response.data;
237255}
238256257257+/**
258258+ * Hits the `listDiscoverable` pipelineQuery, which reuses the listRecords
259259+ * pipeline but adds a WHERE condition excluding events where
260260+ * `preferences.showInDiscovery === false`. Missing field is treated as true.
261261+ * Response shape is identical to listRecords.
262262+ */
263263+export async function listDiscoverableEventsFromContrail(
264264+ client: Client,
265265+ params: Omit<ListEventsParams, 'preferencesShowInDiscovery'>
266266+): Promise<EventListOutput | null> {
267267+ const response = await client.get(
268268+ 'rsvp.atmo.event.listDiscoverable' as 'rsvp.atmo.event.listRecords',
269269+ { params }
270270+ );
271271+272272+ if (!response.ok) return null;
273273+ return response.data;
274274+}
275275+239276export async function getEventRecordFromContrail(
240277 client: Client,
241278 {
···250287 profiles?: boolean;
251288 }
252289): Promise<EventGetOutput | null> {
253253- const response = await client.get('community.lexicon.calendar.event.getRecord', {
290290+ const response = await client.get('rsvp.atmo.event.getRecord', {
254291 params: {
255292 uri: `at://${did}/community.lexicon.calendar.event/${rkey}`,
256293 ...(hydrateRsvps ? { hydrateRsvps } : {}),
···272309 actor: ActorIdentifier;
273310 }
274311): Promise<RsvpListRecord | null> {
275275- const response = await client.get('community.lexicon.calendar.rsvp.listRecords', {
312312+ const response = await client.get('rsvp.atmo.rsvp.listRecords', {
276313 params: {
277314 actor,
278315 subjectUri: eventUri,
···289326 eventUri: string
290327): Promise<EventAttendeesResult> {
291328 const [goingResponse, interestedResponse] = await Promise.all([
292292- client.get('community.lexicon.calendar.rsvp.listRecords', {
329329+ client.get('rsvp.atmo.rsvp.listRecords', {
293330 params: {
294331 subjectUri: eventUri,
295332 status: RSVP_GOING,
···297334 limit: 200
298335 }
299336 }),
300300- client.get('community.lexicon.calendar.rsvp.listRecords', {
337337+ client.get('rsvp.atmo.rsvp.listRecords', {
301338 params: {
302339 subjectUri: eventUri,
303340 status: RSVP_INTERESTED,
···337374}
338375339376export async function listAttendingEventsFromContrail(client: Client, actor: ActorIdentifier) {
340340- const response = await client.get('community.lexicon.calendar.rsvp.listRecords', {
377377+ const response = await client.get('rsvp.atmo.rsvp.listRecords', {
341378 params: {
342379 actor,
343380 hydrateEvent: true,
+36-4
src/lib/contrail/config.ts
···11import type { ContrailConfig } from '@atmo-dev/contrail';
22+import { SPACE_TYPE } from '../spaces/config';
2334export const config: ContrailConfig = {
45 namespace: 'rsvp.atmo',
66+ // Enable the rsvp.atmo.notifyOfUpdate endpoint. The client calls it after
77+ // writing records to the PDS so contrail re-fetches and indexes them
88+ // immediately instead of waiting for the jetstream.
99+ notify: true,
1010+ // `spaces` is declared statically so `pnpm generate` emits the `rsvp.atmo.space.*`
1111+ // lexicons. The real serviceDid is injected at runtime in `$lib/contrail/index.ts`
1212+ // via `getSpacesConfig()` — generate doesn't serialize it.
1313+ spaces: { type: SPACE_TYPE, serviceDid: 'did:web:placeholder' },
1414+ permissionSet: {
1515+ title: 'Atmo Events',
1616+ description: 'Manage your private events and rsvps.'
1717+ // NOTE: permission-set lexicons can only reference NSIDs under their own
1818+ // namespace (`rsvp.atmo.*`). Repo writes for `community.lexicon.*` and
1919+ // blob uploads are declared as standalone `scope.repo(...)` /
2020+ // `scope.blob(...)` entries in `atproto/settings.ts`, not here.
2121+ },
522 collections: {
66- 'community.lexicon.calendar.event': {
2323+ event: {
2424+ collection: 'community.lexicon.calendar.event',
725 queryable: {
826 mode: {},
927 name: {},
1028 status: {},
1129 description: {},
3030+ 'preferences.showInDiscovery': {},
1231 startsAt: { type: 'range' },
1332 endsAt: { type: 'range' },
1433 createdAt: { type: 'range' }
···1635 searchable: ['mode', 'name', 'status', 'description'],
1736 relations: {
1837 rsvps: {
1919- collection: 'community.lexicon.calendar.rsvp',
3838+ collection: 'rsvp',
2039 groupBy: 'status',
2140 groups: {
2241 going: 'community.lexicon.calendar.rsvp#going',
···2443 notgoing: 'community.lexicon.calendar.rsvp#notgoing'
2544 }
2645 }
4646+ },
4747+ pipelineQueries: {
4848+ // Endpoint: rsvp.atmo.event.listDiscoverable
4949+ // Same shape as listRecords, but filters out unlisted events
5050+ // (preferences.showInDiscovery === false). Missing field defaults
5151+ // to true, so pre-existing records without `preferences` are included.
5252+ listDiscoverable: async () => ({
5353+ conditions: [
5454+ `(json_extract(r.record, '$.preferences.showInDiscovery') IS NULL
5555+ OR json_extract(r.record, '$.preferences.showInDiscovery') != 0)`
5656+ ]
5757+ })
2758 }
2859 },
2929- 'community.lexicon.calendar.rsvp': {
6060+ rsvp: {
6161+ collection: 'community.lexicon.calendar.rsvp',
3062 queryable: {
3163 status: {},
3264 'subject.uri': {}
3365 },
3466 references: {
3567 event: {
3636- collection: 'community.lexicon.calendar.event',
6868+ collection: 'event',
3769 field: 'subject.uri'
3870 }
3971 }
+9-1
src/lib/contrail/index.ts
···22import { createHandler } from '@atmo-dev/contrail/server';
33import { Client } from '@atcute/client';
44import { config } from './config';
55+import { getSpacesConfig, spacesAvailable } from '../spaces/config';
5666-export const contrail = new Contrail(config);
77+const spaces = getSpacesConfig();
88+if (!spacesAvailable()) {
99+ console.warn(
1010+ '[contrail/spaces] No service DID configured — spaces features will be inactive. Run `pnpm tunnel` in dev to enable.'
1111+ );
1212+}
1313+1414+export const contrail = new Contrail({ ...config, ...(spaces ? { spaces } : {}) });
715816let initialized = false;
917
+24
src/lib/spaces/config.ts
···11+import type { SpacesConfig } from '@atmo-dev/contrail';
22+import { SERVICE_DID, SERVICE_URL } from './tunnel-service.generated';
33+44+/** The NSID identifying our kind of permissioned space. */
55+export const SPACE_TYPE = 'tools.atmo.event.space';
66+77+/** Build the spaces config for contrail, or null if we can't run spaces
88+ * (no service DID => dev without tunnel, prod before service is published). */
99+export function getSpacesConfig(): SpacesConfig | null {
1010+ if (!SERVICE_DID) {
1111+ return null;
1212+ }
1313+ return {
1414+ type: SPACE_TYPE,
1515+ serviceDid: SERVICE_DID
1616+ };
1717+}
1818+1919+/** True if spaces are available in this environment. */
2020+export function spacesAvailable(): boolean {
2121+ return SERVICE_DID != null;
2222+}
2323+2424+export { SERVICE_DID, SERVICE_URL };
+66
src/lib/spaces/server/client.ts
···11+import type { Client } from '@atcute/client';
22+import { Client as AtcuteClient } from '@atcute/client';
33+import { createHandler } from '@atmo-dev/contrail/server';
44+import { contrail, ensureInit } from '$lib/contrail/index';
55+import { SERVICE_DID } from '../config';
66+77+// Register lexicon ambient types (atmo-events/generated)
88+import '../../../lexicon-types/index.js';
99+1010+const handle = createHandler(contrail);
1111+1212+/** Cache-key per (did→lxm). Keyed per-request client to avoid bleed across users. */
1313+function makeJwtCache() {
1414+ return new Map<string, { token: string; expiresAt: number }>();
1515+}
1616+1717+async function mintServiceJwt(
1818+ oauthClient: Client,
1919+ aud: string,
2020+ lxm: string
2121+): Promise<string> {
2222+ const response = await oauthClient.get('com.atproto.server.getServiceAuth', {
2323+ params: {
2424+ aud: aud as `did:${string}:${string}`,
2525+ lxm: lxm as `${string}.${string}.${string}`,
2626+ exp: Math.floor(Date.now() / 1000) + 300
2727+ }
2828+ });
2929+ if (!response.ok) {
3030+ throw new Error(
3131+ `getServiceAuth failed: ${response.status} ${JSON.stringify(response.data)}`
3232+ );
3333+ }
3434+ return response.data.token;
3535+}
3636+3737+/** Build a typed @atcute/client that routes rsvp.atmo.* calls through
3838+ * contrail's handler in-process, attaching a real service-auth JWT per request.
3939+ * Each JWT is cached for ~4 minutes to avoid hammering the user's PDS. */
4040+export function getSpacesClient(oauthClient: Client, db: D1Database): Client {
4141+ if (!SERVICE_DID) {
4242+ throw new Error('Spaces not configured (no SERVICE_DID). Run `pnpm tunnel` in dev.');
4343+ }
4444+ const aud = SERVICE_DID;
4545+ const jwtCache = makeJwtCache();
4646+4747+ async function jwtFor(lxm: string): Promise<string> {
4848+ const cached = jwtCache.get(lxm);
4949+ if (cached && cached.expiresAt > Date.now() + 10_000) return cached.token;
5050+ const token = await mintServiceJwt(oauthClient, aud, lxm);
5151+ jwtCache.set(lxm, { token, expiresAt: Date.now() + 250_000 });
5252+ return token;
5353+ }
5454+5555+ return new AtcuteClient({
5656+ handler: async (pathname, init) => {
5757+ await ensureInit(db);
5858+ const url = new URL(pathname, 'http://localhost');
5959+ const nsid = url.pathname.replace(/^\/xrpc\//, '');
6060+ const token = await jwtFor(nsid);
6161+ const headers = new Headers(init?.headers as HeadersInit);
6262+ headers.set('Authorization', `Bearer ${token}`);
6363+ return handle(new Request(url, { ...init, headers }), db) as Promise<Response>;
6464+ }
6565+ });
6666+}
···11+/** Auto-generated by `pnpm tunnel`. Do not edit by hand.
22+ * When the tunnel is running, this file is rewritten with the tunnel's
33+ * service DID + URL; when the tunnel stops, it is reset to null values. */
44+55+export const SERVICE_DID: string | null = "did:web:described-yamaha-fame-social.trycloudflare.com";
66+export const SERVICE_URL: string | null = "https://described-yamaha-fame-social.trycloudflare.com";