open source is social v-it.org
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 sol pbc
3
4import { Agent, AtpAgent } from '@atproto/api';
5import { NodeOAuthClient } from '@atproto/oauth-client-node';
6import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7import { join } from 'node:path';
8import { configDir, configPath } from './paths.js';
9import { errorMessage } from './error-format.js';
10
11const requestLock = async (_name, fn) => await fn();
12
13const noopStore = {
14 set: async () => {},
15 get: async () => undefined,
16 del: async () => {},
17};
18
19const clientMetadata = {
20 client_id: 'https://v-it.org/client-metadata.json',
21 client_name: 'vit CLI',
22 application_type: 'native',
23 grant_types: ['authorization_code', 'refresh_token'],
24 response_types: ['code'],
25 scope: 'atproto transition:generic',
26 token_endpoint_auth_method: 'none',
27 dpop_bound_access_tokens: true,
28 client_uri: 'https://v-it.org',
29};
30
31export function createStore() {
32 const map = new Map();
33
34 return {
35 set: async (key, value) => {
36 map.set(key, value);
37 },
38 get: async (key) => map.get(key),
39 del: async (key) => {
40 map.delete(key);
41 },
42 };
43}
44
45export function createSessionStore() {
46 const sessionFile = configPath('session.json');
47 let data = {};
48 if (existsSync(sessionFile)) {
49 try {
50 data = JSON.parse(readFileSync(sessionFile, 'utf-8'));
51 } catch (err) {
52 console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`);
53 }
54 }
55 return {
56 set: async (key, value) => {
57 data[key] = value;
58 mkdirSync(configDir, { recursive: true });
59 writeFileSync(sessionFile, JSON.stringify(data, null, 2) + '\n');
60 },
61 get: async (key) => data[key],
62 del: async (key) => {
63 delete data[key];
64 mkdirSync(configDir, { recursive: true });
65 writeFileSync(sessionFile, JSON.stringify(data, null, 2) + '\n');
66 },
67 };
68}
69
70export function checkSession(did) {
71 // Check project-local app-password session
72 const localPath = join(process.cwd(), '.vit', 'login.json');
73 try {
74 if (existsSync(localPath)) {
75 const local = JSON.parse(readFileSync(localPath, 'utf-8'));
76 if (local.did === did && local.type === 'app-password' && local.session?.accessJwt) {
77 return did;
78 }
79 }
80 } catch (err) {
81 console.warn(`warning: failed to read ${localPath}: ${errorMessage(err)}`);
82 }
83
84 const sessionFile = configPath('session.json');
85 if (!existsSync(sessionFile)) return null;
86 try {
87 const raw = readFileSync(sessionFile, 'utf-8');
88 const data = JSON.parse(raw);
89 const entry = data[did];
90 if (!entry) return null;
91 // App-password session in global store
92 if (entry.type === 'app-password') {
93 return entry.session?.accessJwt ? did : null;
94 }
95 // OAuth session
96 const tokenSet = entry?.tokenSet;
97 if (!tokenSet) return null;
98 const accessValid = tokenSet.expires_at && new Date(tokenSet.expires_at) > new Date();
99 if (accessValid || tokenSet.refresh_token) return did;
100 return null;
101 } catch (err) {
102 console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`);
103 return null;
104 }
105}
106
107export function createOAuthClient({ stateStore, sessionStore, redirectUri }) {
108 return new NodeOAuthClient({
109 requestLock,
110 clientMetadata: {
111 ...clientMetadata,
112 redirect_uris: [redirectUri],
113 },
114 stateStore,
115 sessionStore,
116 });
117}
118
119export async function restoreAgent(did) {
120 // Check project-local app-password session
121 const localPath = join(process.cwd(), '.vit', 'login.json');
122 try {
123 if (existsSync(localPath)) {
124 const local = JSON.parse(readFileSync(localPath, 'utf-8'));
125 if (local.did === did && local.type === 'app-password' && local.session) {
126 const agent = new AtpAgent({ service: local.service || 'https://bsky.social' });
127 await agent.resumeSession(local.session);
128 return { agent, session: { did: local.did, handle: local.handle } };
129 }
130 }
131 } catch (err) {
132 console.warn(`warning: failed to read ${localPath}: ${errorMessage(err)}`);
133 }
134
135 // Check global app-password session
136 const sessionFile = configPath('session.json');
137 try {
138 if (existsSync(sessionFile)) {
139 const raw = readFileSync(sessionFile, 'utf-8');
140 const data = JSON.parse(raw);
141 const entry = data[did];
142 if (entry?.type === 'app-password' && entry.session) {
143 const agent = new AtpAgent({ service: entry.service || 'https://bsky.social' });
144 await agent.resumeSession(entry.session);
145 return { agent, session: { did, handle: entry.session.handle } };
146 }
147 }
148 } catch (err) {
149 console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`);
150 }
151
152 // Existing OAuth restore path
153 const sessionStore = createSessionStore();
154 const client = new NodeOAuthClient({
155 handleResolver: { resolve() { throw new Error('handle resolution not needed for restore'); } },
156 requestLock,
157 clientMetadata: {
158 ...clientMetadata,
159 redirect_uris: ['http://127.0.0.1'],
160 },
161 stateStore: noopStore,
162 sessionStore,
163 });
164 const session = await client.restore(did);
165 return { agent: new Agent(session), session };
166}