atproto user agency toolkit for individuals and groups
1import { readFileSync, existsSync } from "node:fs";
2import { randomBytes } from "node:crypto";
3import { resolve } from "node:path";
4import type { PolicySet } from "./policy/types.js";
5
6export interface Config {
7 /** Social account DID (optional — omit for replication-only node). */
8 DID?: string;
9 /** Social account handle (optional). */
10 HANDLE?: string;
11 PDS_HOSTNAME?: string;
12 AUTH_TOKEN: string;
13 /** Social account signing key hex (optional). */
14 SIGNING_KEY?: string;
15 /** Social account signing key public multibase (optional). */
16 SIGNING_KEY_PUBLIC?: string;
17 JWT_SECRET?: string;
18 PASSWORD_HASH?: string;
19 EMAIL?: string;
20 DATA_DIR: string;
21 PORT: number;
22 IPFS_ENABLED: boolean;
23 IPFS_NETWORKING: boolean;
24 REPLICATE_DIDS: string[];
25 POLICY_FILE?: string;
26 FIREHOSE_URL: string;
27 FIREHOSE_ENABLED: boolean;
28 /** Whether rate limiting is enabled (default true). */
29 RATE_LIMIT_ENABLED: boolean;
30 /** Per-pool rate limit overrides (requests per minute). */
31 RATE_LIMIT_READ_PER_MIN: number;
32 RATE_LIMIT_SYNC_PER_MIN: number;
33 RATE_LIMIT_SESSION_PER_MIN: number;
34 RATE_LIMIT_WRITE_PER_MIN: number;
35 RATE_LIMIT_CHALLENGE_PER_MIN: number;
36 RATE_LIMIT_MAX_CONNECTIONS: number;
37 RATE_LIMIT_FIREHOSE_PER_IP: number;
38 /** Whether OAuth login is enabled for remote PDS publishing (default true). */
39 OAUTH_ENABLED: boolean;
40 /** Public URL of this p2pds instance, used for push notifications between nodes. */
41 PUBLIC_URL: string;
42}
43
44/** Required when OAuth is disabled (legacy mode). With OAuth, identity comes from login. */
45const LEGACY_REQUIRED_KEYS = [
46 "PDS_HOSTNAME",
47 "AUTH_TOKEN",
48 "JWT_SECRET",
49 "PASSWORD_HASH",
50] as const;
51
52/**
53 * Load a .env file into process.env (simple key=value parser).
54 * Skips comments and empty lines.
55 */
56function loadDotEnv(path: string): void {
57 let content: string;
58 try {
59 content = readFileSync(path, "utf-8");
60 } catch {
61 return; // .env file is optional
62 }
63
64 for (const line of content.split("\n")) {
65 const trimmed = line.trim();
66 if (!trimmed || trimmed.startsWith("#")) continue;
67 const eqIdx = trimmed.indexOf("=");
68 if (eqIdx === -1) continue;
69 const key = trimmed.slice(0, eqIdx).trim();
70 let value = trimmed.slice(eqIdx + 1).trim();
71 // Strip surrounding quotes
72 if (
73 (value.startsWith('"') && value.endsWith('"')) ||
74 (value.startsWith("'") && value.endsWith("'"))
75 ) {
76 value = value.slice(1, -1);
77 }
78 if (!process.env[key]) {
79 process.env[key] = value;
80 }
81 }
82}
83
84/**
85 * Load and validate configuration from environment variables.
86 * Optionally loads a .env file first.
87 *
88 * Social account fields (DID, HANDLE, SIGNING_KEY, SIGNING_KEY_PUBLIC) are optional.
89 * When omitted, the node runs as replication-only.
90 */
91export function loadConfig(envPath?: string): Config {
92 // Load .env file if it exists
93 const dotenvPath = envPath ?? process.env.DOTENV_PATH ?? resolve(process.cwd(), ".env");
94 loadDotEnv(dotenvPath);
95
96 // Validate required variables (legacy auth fields only required without OAuth)
97 const oauthEnabled = process.env.OAUTH_ENABLED !== "false";
98 if (!oauthEnabled) {
99 const missing: string[] = [];
100 for (const key of LEGACY_REQUIRED_KEYS) {
101 if (!process.env[key]) {
102 missing.push(key);
103 }
104 }
105 if (missing.length > 0) {
106 throw new Error(
107 `Missing required environment variables: ${missing.join(", ")}`,
108 );
109 }
110 }
111
112 const pdsHostname = process.env.PDS_HOSTNAME || undefined;
113
114 return {
115 DID: process.env.DID || undefined,
116 HANDLE: process.env.HANDLE || undefined,
117 PDS_HOSTNAME: pdsHostname,
118 AUTH_TOKEN: process.env.AUTH_TOKEN || randomBytes(32).toString("hex"),
119 SIGNING_KEY: process.env.SIGNING_KEY || undefined,
120 SIGNING_KEY_PUBLIC: process.env.SIGNING_KEY_PUBLIC || undefined,
121 JWT_SECRET: process.env.JWT_SECRET || undefined,
122 PASSWORD_HASH: process.env.PASSWORD_HASH || undefined,
123 EMAIL: process.env.EMAIL,
124 DATA_DIR: process.env.DATA_DIR ?? "./data",
125 PORT: parseInt(process.env.PORT ?? "3000", 10),
126 IPFS_ENABLED: process.env.IPFS_ENABLED !== "false",
127 IPFS_NETWORKING: process.env.IPFS_NETWORKING !== "false",
128 REPLICATE_DIDS: (process.env.REPLICATE_DIDS ?? "").split(",").map(s => s.trim()).filter(Boolean),
129 POLICY_FILE: process.env.POLICY_FILE || undefined,
130 FIREHOSE_URL: process.env.FIREHOSE_URL ?? "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos",
131 FIREHOSE_ENABLED: process.env.FIREHOSE_ENABLED !== "false",
132 RATE_LIMIT_ENABLED: process.env.RATE_LIMIT_ENABLED !== "false",
133 RATE_LIMIT_READ_PER_MIN: parseInt(process.env.RATE_LIMIT_READ_PER_MIN ?? "300", 10),
134 RATE_LIMIT_SYNC_PER_MIN: parseInt(process.env.RATE_LIMIT_SYNC_PER_MIN ?? "30", 10),
135 RATE_LIMIT_SESSION_PER_MIN: parseInt(process.env.RATE_LIMIT_SESSION_PER_MIN ?? "10", 10),
136 RATE_LIMIT_WRITE_PER_MIN: parseInt(process.env.RATE_LIMIT_WRITE_PER_MIN ?? "200", 10),
137 RATE_LIMIT_CHALLENGE_PER_MIN: parseInt(process.env.RATE_LIMIT_CHALLENGE_PER_MIN ?? "20", 10),
138 RATE_LIMIT_MAX_CONNECTIONS: parseInt(process.env.RATE_LIMIT_MAX_CONNECTIONS ?? "100", 10),
139 RATE_LIMIT_FIREHOSE_PER_IP: parseInt(process.env.RATE_LIMIT_FIREHOSE_PER_IP ?? "3", 10),
140 OAUTH_ENABLED: process.env.OAUTH_ENABLED !== "false",
141 PUBLIC_URL: process.env.PUBLIC_URL || `http://localhost:${parseInt(process.env.PORT ?? "3000", 10)}`,
142 };
143}
144
145/**
146 * Load policies from a JSON file. Returns null if no file is configured or found.
147 * The file should contain a PolicySet JSON object.
148 */
149export function loadPolicies(config: Config): PolicySet | null {
150 const policyPath = config.POLICY_FILE;
151 if (!policyPath) return null;
152
153 const resolved = resolve(policyPath);
154 if (!existsSync(resolved)) {
155 console.warn(`Policy file not found: ${resolved}`);
156 return null;
157 }
158
159 try {
160 const content = readFileSync(resolved, "utf-8");
161 const parsed = JSON.parse(content) as PolicySet;
162 if (parsed.version !== 1) {
163 throw new Error(`Unsupported policy version: ${parsed.version}`);
164 }
165 if (!Array.isArray(parsed.policies)) {
166 throw new Error("Policy file must contain a 'policies' array");
167 }
168 return parsed;
169 } catch (err) {
170 const message = err instanceof Error ? err.message : String(err);
171 throw new Error(`Failed to load policy file ${resolved}: ${message}`);
172 }
173}