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