···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
···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+ const did = `did:web:${hostname}#${SERVICE_FRAGMENT}`;
163163+ const body =
164164+ `/** Auto-generated by \`pnpm tunnel\`. Do not edit by hand.\n` +
165165+ ` * When the tunnel is running, this file is rewritten with the tunnel's\n` +
166166+ ` * service DID + URL; when the tunnel stops, it is reset to null values. */\n\n` +
167167+ `export const SERVICE_DID: string | null = ${JSON.stringify(did)};\n` +
168168+ `export const SERVICE_URL: string | null = ${JSON.stringify(tunnelUrl)};\n`;
169169+ mkdirSync(dirname(generatedServicePath), { recursive: true });
170170+ writeFileSync(generatedServicePath, body);
171171+}
172172+173173+function resetGeneratedService(): void {
174174+ const body =
175175+ `/** Auto-generated by \`pnpm tunnel\`. Do not edit by hand.\n` +
176176+ ` * When the tunnel is running, this file is rewritten with the tunnel's\n` +
177177+ ` * service DID + URL; when the tunnel stops, it is reset to null values. */\n\n` +
178178+ `export const SERVICE_DID: string | null = null;\n` +
179179+ `export const SERVICE_URL: string | null = null;\n`;
180180+ writeFileSync(generatedServicePath, body);
181181+}
182182+136183// ── cleanup ──────────────────────────────────────────────────────
137184138185function cleanup(): void {
···143190 console.log(' Cleared OAUTH_PUBLIC_URL from .env');
144191 clearViteAllowedHosts();
145192 console.log(' Cleared allowedHosts from vite.config.ts');
193193+ resetGeneratedService();
194194+ console.log(' Reset src/lib/spaces/tunnel-service.generated.ts');
146195 }
147196}
148197···163212164213 setEnvVar('OAUTH_PUBLIC_URL', tunnelUrl);
165214 setViteAllowedHosts(hostname);
215215+ writeDidDoc(hostname, tunnelUrl);
216216+ writeGeneratedService(hostname, tunnelUrl);
166217167218 writeLog(`\n Set OAUTH_PUBLIC_URL=${tunnelUrl}\n`);
168219 writeLog(` Set vite allowedHosts to [${hostname}]\n`);
220220+ writeLog(` Wrote static/.well-known/did.json (did:web:${hostname}#${SERVICE_FRAGMENT})\n`);
221221+ writeLog(` Wrote src/lib/spaces/tunnel-service.generated.ts\n`);
169222 writeLog(` Tunnel is ready! Restart your dev server to pick up the new URL.\n\n`);
170223171224 setupScrollRegion();
+17-1
src/lib/atproto/settings.ts
···991010export type AllowedCollection = (typeof collections)[number];
11111212+/** Permissioned-spaces XRPC methods the app needs to call on the user's behalf.
1313+ * `aud: '*'` lets one consent cover dev (tunnel DID) and prod (published DID) without re-consenting. */
1414+const spaceMethods = [
1515+ 'tools.atmo.space.admin.createSpace',
1616+ 'tools.atmo.space.admin.addMember',
1717+ 'tools.atmo.space.putRecord',
1818+ 'tools.atmo.space.listRecords',
1919+ 'tools.atmo.space.getRecord',
2020+ 'tools.atmo.space.getSpace',
2121+ 'tools.atmo.space.invite.create',
2222+ 'tools.atmo.space.invite.redeem',
2323+ 'tools.atmo.space.invite.list',
2424+ 'tools.atmo.space.invite.revoke'
2525+] as const;
2626+1227// OAuth scope — add scope.blob(), scope.rpc(), etc. as needed
1328export const scopes = [
1429 'atproto',
1530 scope.repo({ collection: [...collections] }),
1616- scope.blob({ accept: ['image/*'] })
3131+ scope.blob({ accept: ['image/*'] }),
3232+ ...spaceMethods.map((lxm) => scope.rpc({ lxm: [lxm], aud: '*' }))
1733];
18341935// set to false to disable signup
+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
+43
src/lib/spaces/config.ts
···11+import type { SpacesConfig } from '@atmo-dev/contrail';
22+import {
33+ CompositeDidDocumentResolver,
44+ PlcDidDocumentResolver,
55+ WebDidDocumentResolver
66+} from '@atcute/identity-resolver';
77+import { SERVICE_DID, SERVICE_URL } from './tunnel-service.generated';
88+99+/** The NSID identifying our kind of permissioned space. */
1010+export const SPACE_TYPE = 'tools.atmo.event.space';
1111+1212+/** Per-collection policy for event-space records. */
1313+const DEFAULT_POLICIES = {
1414+ 'community.lexicon.calendar.event': { read: 'member' as const, write: 'owner' as const },
1515+ 'community.lexicon.calendar.rsvp': { read: 'member' as const, write: 'member' as const },
1616+ 'app.event.message': { read: 'member' as const, write: 'member' as const }
1717+};
1818+1919+/** Build the spaces config for contrail, or null if we can't run spaces
2020+ * (no service DID => dev without tunnel, prod before service is published). */
2121+export function getSpacesConfig(): SpacesConfig | null {
2222+ if (!SERVICE_DID) {
2323+ return null;
2424+ }
2525+ return {
2626+ type: SPACE_TYPE,
2727+ serviceDid: SERVICE_DID,
2828+ resolver: new CompositeDidDocumentResolver({
2929+ methods: {
3030+ plc: new PlcDidDocumentResolver(),
3131+ web: new WebDidDocumentResolver()
3232+ }
3333+ }),
3434+ defaultPolicies: DEFAULT_POLICIES
3535+ };
3636+}
3737+3838+/** True if spaces are available in this environment. */
3939+export function spacesAvailable(): boolean {
4040+ return SERVICE_DID != null;
4141+}
4242+4343+export { SERVICE_DID, SERVICE_URL };
+6
src/lib/spaces/tunnel-service.generated.ts
···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:scroll-heat-flowers-software.trycloudflare.com#event_space";
66+export const SERVICE_URL: string | null = "https://scroll-heat-flowers-software.trycloudflare.com";