Suite of AT Protocol TypeScript libraries built on web standards
21
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: atp permissions improvements

+377 -86
+135 -46
common/logger.ts
··· 4 4 getLogger, 5 5 type Logger, 6 6 type LogLevel, 7 + type LogMethod, 7 8 type Sink, 8 9 } from "@logtape/logtape"; 9 10 import { getFileSink } from "@logtape/file"; 10 11 import process from "node:process"; 11 12 12 - // Runtime detection for Deno vs Node.js 13 13 const isDeno = typeof Deno !== "undefined"; 14 + type LoggingConfig = { 15 + allSystemsEnabled: boolean; 16 + enabledSystems: string[]; 17 + enabled: boolean; 18 + level: LogLevel; 19 + logDestination?: string; 20 + }; 21 + 14 22 const getEnv = (key: string): string | undefined => { 15 23 if (isDeno) { 16 - return Deno.env.get(key); 17 - } else { 18 - return process.env[key]; 24 + try { 25 + return Deno.env.get(key); 26 + } catch { 27 + return undefined; 28 + } 19 29 } 20 - }; 21 30 22 - const allSystemsEnabled = !getEnv("LOG_SYSTEMS"); 23 - const enabledSystems = (getEnv("LOG_SYSTEMS") || "") 24 - .replace(",", " ") 25 - .split(" ") 26 - .filter(Boolean); 27 - 28 - const enabledEnv = getEnv("LOG_ENABLED"); 29 - const enabled = enabledEnv === "true" || enabledEnv === "t" || 30 - enabledEnv === "1"; 31 + return process.env[key]; 32 + }; 31 33 32 - const level = (getEnv("LOG_LEVEL") || "info") as LogLevel; 33 - const logDestination = getEnv("LOG_DESTINATION"); 34 - 35 - // Initialize LogTape configuration 36 - let configured = false; 34 + let config: LoggingConfig | undefined; 35 + let configurePromise: Promise<void> | undefined; 37 36 38 37 async function ensureConfigured() { 39 - if (configured || !enabled) return; 38 + const { 39 + enabled, 40 + level, 41 + logDestination, 42 + } = getLoggingConfig(); 43 + if (!enabled) return; 44 + if (configurePromise) { 45 + await configurePromise; 46 + return; 47 + } 40 48 41 49 const sinks: Record<string, Sink> = { 42 50 console: getConsoleSink(), 43 51 }; 44 52 45 - // Add file sink if LOG_DESTINATION is specified 46 53 if (logDestination) { 47 54 sinks.file = getFileSink(logDestination); 48 55 } 49 56 50 - await configure({ 57 + configurePromise = configure({ 51 58 sinks, 52 59 loggers: [ 53 60 { 54 - category: [], // Root logger 61 + category: [], 55 62 lowestLevel: level, 56 63 sinks: logDestination ? ["console", "file"] : ["console"], 57 64 }, 58 65 ], 66 + }).catch((error) => { 67 + configurePromise = undefined; 68 + throw error; 59 69 }); 60 70 61 - configured = true; 71 + await configurePromise; 62 72 } 63 73 64 74 const subsystemLoggers: Record<string, Logger> = {}; ··· 66 76 export const subsystemLogger = (name: string): Logger => { 67 77 if (subsystemLoggers[name]) return subsystemLoggers[name]; 68 78 69 - const subsystemEnabled = enabled && 70 - (allSystemsEnabled || enabledSystems.includes(name)); 79 + subsystemLoggers[name] = wrapLogger(name, getLogger([name])); 80 + return subsystemLoggers[name]; 81 + }; 82 + 83 + export function _resetLoggerStateForTest(): void { 84 + config = undefined; 85 + configurePromise = undefined; 86 + for (const name in subsystemLoggers) { 87 + delete subsystemLoggers[name]; 88 + } 89 + } 90 + 91 + function getLoggingConfig(): LoggingConfig { 92 + if (config) { 93 + return config; 94 + } 95 + 96 + const logSystems = getEnv("LOG_SYSTEMS") || ""; 97 + const enabledSystems = logSystems.replace(",", " ") 98 + .split(" ") 99 + .filter(Boolean); 100 + const enabledEnv = getEnv("LOG_ENABLED"); 101 + 102 + config = { 103 + allSystemsEnabled: !logSystems, 104 + enabledSystems, 105 + enabled: enabledEnv === "true" || enabledEnv === "t" || enabledEnv === "1", 106 + level: (getEnv("LOG_LEVEL") || "info") as LogLevel, 107 + logDestination: getEnv("LOG_DESTINATION"), 108 + }; 109 + return config; 110 + } 111 + 112 + function isSubsystemEnabled(name: string): boolean { 113 + const { allSystemsEnabled, enabled, enabledSystems } = getLoggingConfig(); 114 + return enabled && (allSystemsEnabled || enabledSystems.includes(name)); 115 + } 71 116 72 - // Ensure LogTape is configured before creating loggers 73 - ensureConfigured().catch(console.error); 117 + function wrapLogger(name: string, logger: Logger): Logger { 118 + return new Proxy(logger, { 119 + get(target, property, receiver) { 120 + if (property === "parent") { 121 + return target.parent === null ? null : wrapLogger(name, target.parent); 122 + } 74 123 75 - // Create LogTape logger for this subsystem 76 - const logger = getLogger([name]); 124 + if (property === "getChild") { 125 + return (subcategory: Parameters<Logger["getChild"]>[0]) => 126 + wrapLogger(name, target.getChild(subcategory)); 127 + } 77 128 78 - if (!subsystemEnabled) { 79 - // Create a wrapper that no-ops all logging methods for disabled subsystems 80 - const noOpLogger: Logger = { 81 - ...logger, 82 - debug: () => {}, 83 - info: () => {}, 84 - warn: () => {}, 85 - error: () => {}, 86 - fatal: () => {}, 87 - }; 88 - subsystemLoggers[name] = noOpLogger; 89 - return subsystemLoggers[name]; 90 - } 129 + if (property === "with") { 130 + return (properties: Parameters<Logger["with"]>[0]) => 131 + wrapLogger(name, target.with(properties)); 132 + } 91 133 92 - subsystemLoggers[name] = logger; 93 - return subsystemLoggers[name]; 94 - }; 134 + if ( 135 + property === "trace" || 136 + property === "debug" || 137 + property === "info" || 138 + property === "warn" || 139 + property === "warning" || 140 + property === "error" || 141 + property === "fatal" 142 + ) { 143 + return wrapLogMethod( 144 + name, 145 + Reflect.get(target, property, receiver).bind(target) as LogMethod, 146 + ); 147 + } 148 + 149 + if (property === "emit") { 150 + return wrapEmitMethod( 151 + name, 152 + Reflect.get(target, property, receiver).bind( 153 + target, 154 + ) as Logger["emit"], 155 + ); 156 + } 157 + 158 + return Reflect.get(target, property, receiver); 159 + }, 160 + }); 161 + } 162 + 163 + function wrapLogMethod(name: string, method: LogMethod): LogMethod { 164 + return ((...args: unknown[]) => { 165 + if (!isSubsystemEnabled(name)) { 166 + return; 167 + } 168 + 169 + ensureConfigured().catch(console.error); 170 + Reflect.apply(method as (...args: unknown[]) => void, undefined, args); 171 + }) as LogMethod; 172 + } 173 + 174 + function wrapEmitMethod(name: string, method: Logger["emit"]): Logger["emit"] { 175 + return ((record) => { 176 + if (!isSubsystemEnabled(name)) { 177 + return; 178 + } 179 + 180 + ensureConfigured().catch(console.error); 181 + method(record); 182 + }) as Logger["emit"]; 183 + }
+58
common/tests/logger_test.ts
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { _resetLoggerStateForTest, subsystemLogger } from "../logger.ts"; 3 + 4 + async function withPatchedEnvGet<T>( 5 + get: typeof Deno.env.get, 6 + fn: () => Promise<T> | T, 7 + ): Promise<T> { 8 + const original = Deno.env.get; 9 + Object.defineProperty(Deno.env, "get", { 10 + configurable: true, 11 + writable: true, 12 + value: get, 13 + }); 14 + 15 + try { 16 + return await fn(); 17 + } finally { 18 + Object.defineProperty(Deno.env, "get", { 19 + configurable: true, 20 + writable: true, 21 + value: original, 22 + }); 23 + } 24 + } 25 + 26 + Deno.test("subsystemLogger defers env access until a log method is called", async () => { 27 + const calls: string[] = []; 28 + 29 + await withPatchedEnvGet((name) => { 30 + calls.push(name); 31 + return undefined; 32 + }, async () => { 33 + _resetLoggerStateForTest(); 34 + const logger = subsystemLogger("repo"); 35 + 36 + assertEquals(calls, []); 37 + 38 + logger.info("hello"); 39 + 40 + assertEquals(calls, [ 41 + "LOG_SYSTEMS", 42 + "LOG_ENABLED", 43 + "LOG_LEVEL", 44 + "LOG_DESTINATION", 45 + ]); 46 + }); 47 + }); 48 + 49 + Deno.test("subsystemLogger treats env permission errors as unset", async () => { 50 + await withPatchedEnvGet(() => { 51 + throw new Deno.errors.PermissionDenied("env denied"); 52 + }, async () => { 53 + _resetLoggerStateForTest(); 54 + const logger = subsystemLogger("repo"); 55 + 56 + logger.info("hello"); 57 + }); 58 + });
+12 -1
deno.lock
··· 9 9 "jsr:@david/code-block-writer@13": "13.0.3", 10 10 "jsr:@hono/hono@^4.10.8": "4.10.8", 11 11 "jsr:@logtape/file@^1.2.2": "1.2.2", 12 + "jsr:@logtape/logtape@*": "1.2.2", 12 13 "jsr:@logtape/logtape@^1.2.2": "1.2.2", 13 14 "jsr:@noble/curves@^2.0.1": "2.0.1", 14 15 "jsr:@noble/hashes@2": "2.0.1", ··· 38 39 "npm:@atproto/crypto@*": "0.1.0", 39 40 "npm:@did-plc/lib@^0.0.4": "0.0.4", 40 41 "npm:@did-plc/server@^0.0.1": "0.0.1_express@4.21.2", 42 + "npm:@dprint/formatter@~0.5.1": "0.5.1", 43 + "npm:@dprint/typescript@~0.95.15": "0.95.15", 41 44 "npm:@ipld/dag-cbor@^9.2.5": "9.2.5", 42 45 "npm:@opentelemetry/api@^1.9.0": "1.9.0", 43 46 "npm:@types/node@*": "24.2.0", ··· 97 100 "@logtape/file@1.2.2": { 98 101 "integrity": "a602f49148d0d5553dd3398506e9579e7294bab5706dff91f4c18abde53f1985", 99 102 "dependencies": [ 100 - "jsr:@logtape/logtape" 103 + "jsr:@logtape/logtape@^1.2.2" 101 104 ] 102 105 }, 103 106 "@logtape/logtape@1.2.2": { ··· 253 256 "pino", 254 257 "pino-http" 255 258 ] 259 + }, 260 + "@dprint/formatter@0.5.1": { 261 + "integrity": "sha512-cdZUrm0iv/FnnY3CKE2dEcVhNEzrC551aE2h2mTFwQCRBrqyARLDnb7D+3PlXTUVp3s34ftlnGOVCmhLT9DeKA==" 262 + }, 263 + "@dprint/typescript@0.95.15": { 264 + "integrity": "sha512-PQGYicsjlv7Eq+5BzpI04Ow2N9xeHkjr9O1TG3lDebK2B48IvXFQz0RwO4OhkKj22o7YLcjVIFM6WL7zVuM2ng==" 256 265 }, 257 266 "@ipld/dag-cbor@7.0.3": { 258 267 "integrity": "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA==", ··· 1144 1153 "dependencies": [ 1145 1154 "jsr:@cliffy/command@^1.0.0-rc.8", 1146 1155 "jsr:@ts-morph/ts-morph@26", 1156 + "npm:@dprint/formatter@~0.5.1", 1157 + "npm:@dprint/typescript@~0.95.15", 1147 1158 "npm:cborg@^4.2.15", 1148 1159 "npm:multiformats@^13.4.1" 1149 1160 ]
+68
lex/build/formatter.ts
··· 1 + import { readFile } from "node:fs/promises"; 2 + import { createFromBuffer } from "@dprint/formatter"; 3 + import * as typescriptPlugin from "@dprint/typescript"; 4 + 5 + type DprintFormatter = ReturnType<typeof createFromBuffer>; 6 + 7 + let formatterPromise: Promise<DprintFormatter> | undefined; 8 + 9 + export async function formatGeneratedText( 10 + filePath: string, 11 + text: string, 12 + ): Promise<string> { 13 + const formatter = await getFormatter(); 14 + return formatter.formatText({ 15 + filePath, 16 + fileText: text, 17 + }); 18 + } 19 + 20 + async function getFormatter(): Promise<DprintFormatter> { 21 + if (!formatterPromise) { 22 + formatterPromise = loadFormatter(); 23 + } 24 + 25 + return await formatterPromise; 26 + } 27 + 28 + async function loadFormatter(): Promise<DprintFormatter> { 29 + const formatter = createFromBuffer(await loadTypescriptPluginBuffer()); 30 + formatter.setConfig({ 31 + indentWidth: 2, 32 + lineWidth: 80, 33 + newLineKind: "lf", 34 + useTabs: false, 35 + }, {}); 36 + return formatter; 37 + } 38 + 39 + async function loadTypescriptPluginBuffer(): Promise<BufferSource> { 40 + const plugin = typescriptPlugin as Record<string, unknown>; 41 + const getBuffer = plugin.getBuffer; 42 + if (typeof getBuffer === "function") { 43 + return toBufferSource(getBuffer()); 44 + } 45 + 46 + const getPath = plugin.getPath; 47 + if (typeof getPath !== "function") { 48 + throw new TypeError("Could not load @dprint/typescript plugin"); 49 + } 50 + 51 + return toBufferSource(await readFile(getPath() as string)); 52 + } 53 + 54 + function toBufferSource(value: unknown): BufferSource { 55 + if (value instanceof ArrayBuffer) { 56 + return value; 57 + } 58 + 59 + if (ArrayBuffer.isView(value)) { 60 + const buffer = new Uint8Array(value.byteLength); 61 + buffer.set( 62 + new Uint8Array(value.buffer, value.byteOffset, value.byteLength), 63 + ); 64 + return buffer.buffer; 65 + } 66 + 67 + throw new TypeError("Could not load @dprint/typescript plugin"); 68 + }
+2 -1
lex/build/lex-builder.ts
··· 5 5 import { buildFilter, type BuildFilterOptions } from "./filter.ts"; 6 6 import { FilteredIndexer } from "./filtered-indexer.ts"; 7 7 import { LexDefBuilder, type LexDefBuilderOptions } from "./def-builder.ts"; 8 + import { formatGeneratedText } from "./formatter.ts"; 8 9 import { 9 10 LexiconDirectoryIndexer, 10 11 type LexiconDirectoryIndexerOptions, ··· 80 81 await Promise.all( 81 82 Array.from(files, async (file) => { 82 83 const filePath = resolveOutputFilePath(destination, file.getFilePath()); 83 - const content = file.getFullText(); 84 + const content = await formatGeneratedText(filePath, file.getFullText()); 84 85 await mkdir(dirname(filePath), { recursive: true }); 85 86 await rm(filePath, { recursive: true, force: true }); 86 87 await writeFile(filePath, content, "utf8");
-14
lex/cli/build.ts
··· 82 82 exclude: opts.exclude, 83 83 }); 84 84 85 - await denoFmt(opts.out); 86 85 console.log("Done."); 87 86 }); 88 - 89 - async function denoFmt(dir: string): Promise<void> { 90 - const cmd = new Deno.Command("deno", { 91 - args: ["fmt", dir], 92 - cwd: Deno.cwd(), 93 - stdout: "inherit", 94 - stderr: "inherit", 95 - }); 96 - const { code } = await cmd.output(); 97 - if (code !== 0) { 98 - console.warn(`Warning: deno fmt exited with code ${code}`); 99 - } 100 - } 101 87 102 88 export default command;
+2
lex/deno.json
··· 11 11 }, 12 12 "license": "MIT", 13 13 "imports": { 14 + "@dprint/formatter": "npm:@dprint/formatter@^0.5.1", 15 + "@dprint/typescript": "npm:@dprint/typescript@^0.95.15", 14 16 "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", 15 17 "cborg": "npm:cborg@^4.2.15", 16 18 "multiformats/cid": "npm:multiformats@^13.4.1/cid",
+1 -1
lex/installer/fs.ts
··· 10 10 data: unknown, 11 11 ): Promise<void> { 12 12 await Deno.mkdir(dirname(path), { recursive: true }); 13 - await Deno.writeTextFile(path, JSON.stringify(data, null, 2)); 13 + await Deno.writeTextFile(path, `${JSON.stringify(data, null, 2)}\n`); 14 14 } 15 15 16 16 export function isEnoentError(err: unknown): boolean {
+84 -23
lex/resolver/lex-resolver.ts
··· 20 20 verifyCommitSig, 21 21 } from "@atp/repo"; 22 22 import { AtUri, ensureValidDid, NSID } from "@atp/syntax"; 23 - import { XrpcClient } from "@atp/xrpc"; 24 23 import { LexResolverError } from "./lex-resolver-error.ts"; 25 24 26 25 const LEXICON_COLLECTION = "com.atproto.lexicon.schema"; ··· 214 213 ); 215 214 } 216 215 217 - const client = new XrpcClient({ 218 - service: atprotoData.pds, 219 - fetch: this.options.fetch, 220 - }); 221 216 const didParam = did; 222 217 const rkey = nsid.toString(); 223 218 assertDid(didParam); 224 219 assertRecordKey(rkey); 225 220 226 - const response = await client.call(getRecordQuery, { 227 - params: { 228 - did: didParam as DidString, 229 - collection: LEXICON_COLLECTION, 230 - rkey: rkey as RecordKeyString, 231 - }, 232 - headers: options?.noCache ? { "cache-control": "no-cache" } : undefined, 221 + const response = await fetchGetRecord({ 222 + service: atprotoData.pds, 223 + fetch: this.options.fetch, 224 + did: didParam as DidString, 225 + collection: LEXICON_COLLECTION, 226 + rkey: rkey as RecordKeyString, 227 + noCache: options?.noCache, 233 228 signal: options?.signal, 234 - validateRequest: true, 235 - validateResponse: false, 236 229 }).catch((cause) => { 237 230 throw new LexResolverError(nsid, `Failed to fetch Record ${uri}`, { 238 231 cause, 239 232 }); 240 233 }); 241 234 242 - if (!(response.data instanceof Uint8Array)) { 243 - throw new LexResolverError( 244 - nsid, 245 - `Invalid record response at ${uri}`, 246 - { cause: new TypeError("Expected CAR bytes") }, 247 - ); 248 - } 249 - 250 235 return verifyRecordProof( 251 - response.data, 236 + response, 252 237 did, 253 238 atprotoData.signingKey, 254 239 LEXICON_COLLECTION, ··· 300 285 throw didLines.length > 1 301 286 ? new Error("Multiple DIDs found in DNS TXT records") 302 287 : new Error("No DID found in DNS TXT records"); 288 + } 289 + 290 + type FetchGetRecordOptions = { 291 + service: string; 292 + fetch?: typeof globalThis.fetch; 293 + did: DidString; 294 + collection: string; 295 + rkey: RecordKeyString; 296 + noCache?: boolean; 297 + signal?: AbortSignal; 298 + }; 299 + 300 + async function fetchGetRecord( 301 + options: FetchGetRecordOptions, 302 + ): Promise<Uint8Array> { 303 + const fetchFn = options.fetch ?? globalThis.fetch; 304 + if (typeof fetchFn !== "function") { 305 + throw new TypeError("fetch() is not available in this environment"); 306 + } 307 + 308 + const params = { 309 + did: options.did, 310 + collection: options.collection, 311 + rkey: options.rkey, 312 + }; 313 + const result = getRecordQuery.parameters.safeParse(params); 314 + if (!result.success) { 315 + throw result.error; 316 + } 317 + 318 + const url = new URL( 319 + `/xrpc/${encodeURIComponent(getRecordQuery.nsid)}?${ 320 + getRecordQuery.parameters.toURLSearchParams(result.value).toString() 321 + }`, 322 + options.service, 323 + ); 324 + const headers = new Headers(); 325 + 326 + if (getRecordQuery.output.encoding !== undefined) { 327 + headers.set("accept", getRecordQuery.output.encoding); 328 + } 329 + 330 + if (options.noCache) { 331 + headers.set("cache-control", "no-cache"); 332 + } 333 + 334 + const response = await fetchFn(url, { 335 + method: "get", 336 + headers, 337 + redirect: "follow", 338 + signal: options.signal, 339 + }); 340 + 341 + if (!response.ok) { 342 + throw new Error( 343 + `Unexpected response ${response.status} ${response.statusText}`, 344 + ); 345 + } 346 + 347 + const contentType = response.headers.get("content-type"); 348 + const expected = getRecordQuery.output.encoding; 349 + if (expected !== undefined && !matchesEncoding(expected, contentType)) { 350 + throw new TypeError( 351 + `Unexpected content-type ${contentType ?? "null"} for ${getRecordQuery.nsid}`, 352 + ); 353 + } 354 + 355 + return new Uint8Array(await response.arrayBuffer()); 356 + } 357 + 358 + function matchesEncoding(expected: string, actual: string | null): boolean { 359 + if (actual == null) { 360 + return false; 361 + } 362 + 363 + return actual === expected || actual.startsWith(`${expected};`); 303 364 } 304 365 305 366 function parseLexiconUri(uri: AtUri): { did: string; nsid: NSID } {
+14
lex/tests/formatter_test.ts
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { formatGeneratedText } from "../build/formatter.ts"; 3 + 4 + Deno.test("formatGeneratedText formats TypeScript output programmatically", async () => { 5 + const formatted = await formatGeneratedText( 6 + "/tmp/example.ts", 7 + 'const value={foo:"bar",items:[1,2,3]};', 8 + ); 9 + 10 + assertEquals( 11 + formatted, 12 + 'const value = { foo: "bar", items: [1, 2, 3] };\n', 13 + ); 14 + });
+1
lex/tests/lex-resolver_test.ts
··· 95 95 calls.url = url.toString(); 96 96 assertEquals(init?.method, "get"); 97 97 assertEquals(headers.get("accept"), "application/vnd.ipld.car"); 98 + assertEquals(headers.get("cache-control"), "no-cache"); 98 99 return Promise.resolve( 99 100 new Response(toArrayBuffer(fixture.car), { 100 101 headers: { "content-type": "application/vnd.ipld.car" },