bibliocanvas 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/dist/api.d.ts +123 -0
- package/dist/api.js +124 -0
- package/dist/api.js.map +1 -0
- package/dist/auth.d.ts +42 -0
- package/dist/auth.js +227 -0
- package/dist/auth.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +514 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/skills/bibliocanvas/SKILL.md +138 -0
- package/src/api.ts +315 -0
- package/src/auth.ts +270 -0
- package/src/index.ts +545 -0
- package/tsconfig.json +17 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication module for BiblioCanvas CLI
|
|
3
|
+
*
|
|
4
|
+
* All authentication is handled server-side via the BiblioCanvas API.
|
|
5
|
+
* No Firebase config or API keys are stored in the CLI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import * as http from 'node:http';
|
|
11
|
+
import * as crypto from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
const CREDENTIALS_DIR = path.join(
|
|
14
|
+
process.env.HOME || process.env.USERPROFILE || '.',
|
|
15
|
+
'.bibliocanvas'
|
|
16
|
+
);
|
|
17
|
+
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
|
|
18
|
+
|
|
19
|
+
const API_URLS = {
|
|
20
|
+
production: 'https://bibliocanvas.web.app/api',
|
|
21
|
+
development: 'https://bibliocanvas-dev.web.app/api',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface StoredCredentials {
|
|
25
|
+
refreshToken: string;
|
|
26
|
+
uid: string;
|
|
27
|
+
email: string;
|
|
28
|
+
displayName: string;
|
|
29
|
+
env: 'production' | 'development';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get API base URL based on environment
|
|
34
|
+
*/
|
|
35
|
+
export function getApiBaseUrl(env: 'production' | 'development'): string {
|
|
36
|
+
return API_URLS[env];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load stored credentials
|
|
41
|
+
*/
|
|
42
|
+
function loadCredentials(): StoredCredentials | null {
|
|
43
|
+
try {
|
|
44
|
+
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
45
|
+
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
|
|
46
|
+
return JSON.parse(data);
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Save credentials to disk
|
|
56
|
+
*/
|
|
57
|
+
function saveCredentials(creds: StoredCredentials): void {
|
|
58
|
+
if (!fs.existsSync(CREDENTIALS_DIR)) {
|
|
59
|
+
fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
|
|
60
|
+
}
|
|
61
|
+
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), {
|
|
62
|
+
mode: 0o600,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Delete stored credentials
|
|
68
|
+
*/
|
|
69
|
+
export function deleteCredentials(): boolean {
|
|
70
|
+
try {
|
|
71
|
+
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
72
|
+
fs.unlinkSync(CREDENTIALS_FILE);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get a valid ID token (refresh via server-side API)
|
|
83
|
+
*/
|
|
84
|
+
export async function getIdToken(env: 'production' | 'development' = 'production'): Promise<string> {
|
|
85
|
+
const creds = loadCredentials();
|
|
86
|
+
if (!creds) {
|
|
87
|
+
throw new Error('Not logged in. Run `bibliocanvas login` first.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (creds.env !== env) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Logged in to ${creds.env} but trying to use ${env}. Run \`bibliocanvas login --dev\` or \`bibliocanvas login\`.`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Refresh token via server-side API (no API key needed on client)
|
|
97
|
+
const baseUrl = getApiBaseUrl(env);
|
|
98
|
+
const response = await fetch(`${baseUrl}/auth/refresh`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
body: JSON.stringify({ refreshToken: creds.refreshToken }),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw new Error('Token refresh failed. Run `bibliocanvas login` again.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const data = await response.json();
|
|
109
|
+
|
|
110
|
+
// Update stored refresh token if it changed
|
|
111
|
+
if (data.refreshToken && data.refreshToken !== creds.refreshToken) {
|
|
112
|
+
creds.refreshToken = data.refreshToken;
|
|
113
|
+
saveCredentials(creds);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return data.idToken;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get current user info from stored credentials
|
|
121
|
+
*/
|
|
122
|
+
export function getCurrentUser(): { uid: string; email: string; displayName: string; env: string } | null {
|
|
123
|
+
const creds = loadCredentials();
|
|
124
|
+
if (!creds) return null;
|
|
125
|
+
return {
|
|
126
|
+
uid: creds.uid,
|
|
127
|
+
email: creds.email,
|
|
128
|
+
displayName: creds.displayName,
|
|
129
|
+
env: creds.env,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Login via browser OAuth flow (server-side token exchange)
|
|
135
|
+
*
|
|
136
|
+
* 1. Fetch OAuth client ID from server
|
|
137
|
+
* 2. Start local HTTP server
|
|
138
|
+
* 3. Open browser to Google OAuth consent screen
|
|
139
|
+
* 4. Receive authorization code via redirect
|
|
140
|
+
* 5. Send code to server for token exchange
|
|
141
|
+
* 6. Server returns Firebase tokens
|
|
142
|
+
* 7. Save refresh token to disk
|
|
143
|
+
*/
|
|
144
|
+
export async function login(env: 'production' | 'development' = 'production'): Promise<{
|
|
145
|
+
email: string;
|
|
146
|
+
displayName: string;
|
|
147
|
+
}> {
|
|
148
|
+
const baseUrl = getApiBaseUrl(env);
|
|
149
|
+
|
|
150
|
+
// Fetch OAuth client ID from server (no secrets in CLI)
|
|
151
|
+
const configResponse = await fetch(`${baseUrl}/auth/config`);
|
|
152
|
+
if (!configResponse.ok) {
|
|
153
|
+
throw new Error('Failed to fetch OAuth config from server');
|
|
154
|
+
}
|
|
155
|
+
const config = await configResponse.json();
|
|
156
|
+
const clientId = config.clientId;
|
|
157
|
+
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
const server = http.createServer();
|
|
160
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
161
|
+
const address = server.address();
|
|
162
|
+
if (!address || typeof address === 'string') {
|
|
163
|
+
reject(new Error('Failed to start local server'));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const port = address.port;
|
|
167
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
168
|
+
|
|
169
|
+
// Generate state for CSRF protection
|
|
170
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
171
|
+
|
|
172
|
+
// Build Google OAuth URL
|
|
173
|
+
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
174
|
+
authUrl.searchParams.set('client_id', clientId);
|
|
175
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
176
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
177
|
+
authUrl.searchParams.set('scope', 'openid email profile');
|
|
178
|
+
authUrl.searchParams.set('state', state);
|
|
179
|
+
authUrl.searchParams.set('access_type', 'offline');
|
|
180
|
+
authUrl.searchParams.set('prompt', 'consent');
|
|
181
|
+
|
|
182
|
+
console.log(`\nOpening browser for Google login...`);
|
|
183
|
+
console.log(`If the browser doesn't open, visit:\n${authUrl.toString()}\n`);
|
|
184
|
+
|
|
185
|
+
// Open browser
|
|
186
|
+
const open = (await import('open')).default;
|
|
187
|
+
open(authUrl.toString()).catch(() => {
|
|
188
|
+
// Browser open failed, user can use the URL manually
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Handle the OAuth callback
|
|
192
|
+
server.on('request', async (req, res) => {
|
|
193
|
+
if (!req.url?.startsWith('/callback')) return;
|
|
194
|
+
|
|
195
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
196
|
+
const code = url.searchParams.get('code');
|
|
197
|
+
const returnedState = url.searchParams.get('state');
|
|
198
|
+
|
|
199
|
+
if (returnedState !== state) {
|
|
200
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
201
|
+
res.end('<h1>Error: Invalid state</h1>');
|
|
202
|
+
server.close();
|
|
203
|
+
reject(new Error('Invalid OAuth state'));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!code) {
|
|
208
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
209
|
+
res.end('<h1>Error: No authorization code</h1>');
|
|
210
|
+
server.close();
|
|
211
|
+
reject(new Error('No authorization code received'));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
// Send code to server for token exchange (no secrets on client)
|
|
217
|
+
const loginResponse = await fetch(`${baseUrl}/auth/login`, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: { 'Content-Type': 'application/json' },
|
|
220
|
+
body: JSON.stringify({ code, redirectUri }),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!loginResponse.ok) {
|
|
224
|
+
const errorData = await loginResponse.json();
|
|
225
|
+
throw new Error(errorData.error || 'Authentication failed');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const loginData = await loginResponse.json();
|
|
229
|
+
|
|
230
|
+
// Save credentials (only refresh token, no API keys)
|
|
231
|
+
saveCredentials({
|
|
232
|
+
refreshToken: loginData.refreshToken,
|
|
233
|
+
uid: loginData.uid,
|
|
234
|
+
email: loginData.email || '',
|
|
235
|
+
displayName: loginData.displayName || '',
|
|
236
|
+
env,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
240
|
+
res.end(`
|
|
241
|
+
<html>
|
|
242
|
+
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
|
|
243
|
+
<h1>✓ ログイン成功!</h1>
|
|
244
|
+
<p>${loginData.displayName || loginData.email} としてログインしました。</p>
|
|
245
|
+
<p>このウィンドウを閉じてください。</p>
|
|
246
|
+
</body>
|
|
247
|
+
</html>
|
|
248
|
+
`);
|
|
249
|
+
|
|
250
|
+
server.close();
|
|
251
|
+
resolve({
|
|
252
|
+
email: loginData.email || '',
|
|
253
|
+
displayName: loginData.displayName || '',
|
|
254
|
+
});
|
|
255
|
+
} catch (err) {
|
|
256
|
+
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
257
|
+
res.end(`<h1>Error: ${(err as Error).message}</h1>`);
|
|
258
|
+
server.close();
|
|
259
|
+
reject(err);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Timeout after 2 minutes
|
|
264
|
+
setTimeout(() => {
|
|
265
|
+
server.close();
|
|
266
|
+
reject(new Error('Login timed out. Please try again.'));
|
|
267
|
+
}, 120000);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|