@tutti-os/auth-bridge 0.0.1

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/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @tutti-os/auth-bridge
2
+
3
+ High-level Tutti auth helpers for browser apps and Node/Electron clients.
4
+
5
+ ## Browser
6
+
7
+ ```ts
8
+ import { createTuttiBrowserAuthClient } from '@tutti-os/auth-bridge/browser';
9
+
10
+ const auth = createTuttiBrowserAuthClient();
11
+
12
+ auth.login();
13
+ const user = await auth.getUserInfo();
14
+ await auth.logout();
15
+ ```
16
+
17
+ ## Node
18
+
19
+ ```ts
20
+ import { createTuttiNodeAuthClient } from '@tutti-os/auth-bridge/node';
21
+
22
+ const auth = createTuttiNodeAuthClient({
23
+ authJsonPath,
24
+ appCallbackUrl,
25
+ });
26
+
27
+ const { session, user } = await auth.login();
28
+ ```
@@ -0,0 +1,20 @@
1
+ import { T as TuttiUserInfo } from './shared-Bm-pnst6.js';
2
+
3
+ interface TuttiBrowserAuthClientOptions {
4
+ openUrl?: (url: string) => void;
5
+ appId?: string;
6
+ accountBaseUrl?: string;
7
+ authLoginUrl?: string;
8
+ }
9
+ interface TuttiBrowserLoginOptions {
10
+ redirectUri?: string;
11
+ locale?: string;
12
+ }
13
+ interface TuttiBrowserAuthClient {
14
+ login: (options?: TuttiBrowserLoginOptions) => void;
15
+ getUserInfo: () => Promise<TuttiUserInfo | null>;
16
+ logout: () => Promise<void>;
17
+ }
18
+ declare function createTuttiBrowserAuthClient(options?: TuttiBrowserAuthClientOptions): TuttiBrowserAuthClient;
19
+
20
+ export { type TuttiBrowserAuthClient, type TuttiBrowserAuthClientOptions, type TuttiBrowserLoginOptions, TuttiUserInfo, createTuttiBrowserAuthClient };
@@ -0,0 +1,72 @@
1
+ import {
2
+ DEFAULT_ACCOUNT_BASE_URL,
3
+ DEFAULT_APP_ID,
4
+ DEFAULT_AUTH_LOGIN_URL,
5
+ buildAccountUrl,
6
+ mapUserInfo,
7
+ readEnvelopeError
8
+ } from "./chunk-B7N5AC4Q.js";
9
+
10
+ // src/browser.ts
11
+ function defaultOpenUrl(url) {
12
+ window.location.assign(url);
13
+ }
14
+ function resolveCurrentRedirectUri() {
15
+ return `${window.location.pathname}${window.location.search}${window.location.hash}` || "/";
16
+ }
17
+ async function readJsonEnvelope(response) {
18
+ return await response.json().catch(() => null);
19
+ }
20
+ function createTuttiBrowserAuthClient(options = {}) {
21
+ const appId = options.appId?.trim() || DEFAULT_APP_ID;
22
+ const accountBaseUrl = options.accountBaseUrl?.trim() || DEFAULT_ACCOUNT_BASE_URL;
23
+ const authLoginUrl = options.authLoginUrl?.trim() || DEFAULT_AUTH_LOGIN_URL;
24
+ const openUrl = options.openUrl ?? defaultOpenUrl;
25
+ return {
26
+ login(loginOptions = {}) {
27
+ const url = new URL(authLoginUrl);
28
+ url.searchParams.set("redirect_uri", loginOptions.redirectUri ?? resolveCurrentRedirectUri());
29
+ if (loginOptions.locale) {
30
+ url.searchParams.set("locale", loginOptions.locale);
31
+ }
32
+ openUrl(url.toString());
33
+ },
34
+ async getUserInfo() {
35
+ const response = await fetch(buildAccountUrl(accountBaseUrl, "/user/v1/user_info"), {
36
+ method: "POST",
37
+ credentials: "include",
38
+ headers: {
39
+ Accept: "application/json",
40
+ "Content-Type": "application/json"
41
+ },
42
+ body: JSON.stringify({})
43
+ });
44
+ const payload = await readJsonEnvelope(response);
45
+ if (response.status === 401 || payload?.code === 401) {
46
+ return null;
47
+ }
48
+ if (!response.ok || payload?.code !== 0) {
49
+ throw readEnvelopeError(response, payload);
50
+ }
51
+ return mapUserInfo(payload.data);
52
+ },
53
+ async logout() {
54
+ const response = await fetch(buildAccountUrl(accountBaseUrl, "/auth/v1/logout-web-session"), {
55
+ method: "POST",
56
+ credentials: "include",
57
+ headers: {
58
+ Accept: "application/json",
59
+ "Content-Type": "application/json"
60
+ },
61
+ body: JSON.stringify({ appId })
62
+ });
63
+ const payload = await readJsonEnvelope(response);
64
+ if (!response.ok || (payload?.code ?? 0) !== 0) {
65
+ throw readEnvelopeError(response, payload);
66
+ }
67
+ }
68
+ };
69
+ }
70
+ export {
71
+ createTuttiBrowserAuthClient
72
+ };
@@ -0,0 +1,55 @@
1
+ // src/shared.ts
2
+ var DEFAULT_APP_ID = "nextop";
3
+ var DEFAULT_ACCOUNT_BASE_URL = "https://tutti.sh/api/account";
4
+ var DEFAULT_AUTH_LOGIN_URL = "https://tutti.sh/auth/login";
5
+ var AUTH_SERVER_HOST = "127.0.0.1";
6
+ var AUTH_SERVER_BASE_PORT = 38473;
7
+ var AUTH_SERVER_MAX_PORT = 38492;
8
+ var DEFAULT_LOGIN_IDLE_TIMEOUT_MS = 9e4;
9
+ var DEFAULT_LOGIN_MAX_TIMEOUT_MS = 5 * 6e4;
10
+ function trimString(value) {
11
+ return typeof value === "string" ? value.trim() : "";
12
+ }
13
+ function buildSessionCookie(sessionId) {
14
+ return `session_id=${sessionId.trim()}`;
15
+ }
16
+ function normalizeBaseUrl(value) {
17
+ return value.replace(/\/+$/u, "");
18
+ }
19
+ function buildAccountUrl(accountBaseUrl, path) {
20
+ return `${normalizeBaseUrl(accountBaseUrl)}/${path.replace(/^\/+/u, "")}`;
21
+ }
22
+ function readEnvelopeError(response, payload) {
23
+ return new Error(payload?.errmsg ?? payload?.message ?? `Request failed with status ${response.status}`);
24
+ }
25
+ function mapUserInfo(data) {
26
+ if (!data) {
27
+ return null;
28
+ }
29
+ const userId = trimString(data.userId) || trimString(data.user_id);
30
+ if (!userId) {
31
+ return null;
32
+ }
33
+ return {
34
+ userId,
35
+ name: trimString(data.name) || void 0,
36
+ email: trimString(data.email) || trimString(data.userEmail) || trimString(data.emailAddress) || void 0,
37
+ avatar: trimString(data.avatar) || trimString(data.picture) || trimString(data.avatarUrl) || trimString(data.headImg) || void 0
38
+ };
39
+ }
40
+
41
+ export {
42
+ DEFAULT_APP_ID,
43
+ DEFAULT_ACCOUNT_BASE_URL,
44
+ DEFAULT_AUTH_LOGIN_URL,
45
+ AUTH_SERVER_HOST,
46
+ AUTH_SERVER_BASE_PORT,
47
+ AUTH_SERVER_MAX_PORT,
48
+ DEFAULT_LOGIN_IDLE_TIMEOUT_MS,
49
+ DEFAULT_LOGIN_MAX_TIMEOUT_MS,
50
+ trimString,
51
+ buildSessionCookie,
52
+ buildAccountUrl,
53
+ readEnvelopeError,
54
+ mapUserInfo
55
+ };
package/dist/node.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { a as TuttiAuthSession, T as TuttiUserInfo } from './shared-Bm-pnst6.js';
2
+
3
+ interface TuttiNodeAuthClientOptions {
4
+ authJsonPath: string;
5
+ appCallbackUrl: string;
6
+ openUrl?: (url: string) => Promise<void> | void;
7
+ appId?: string;
8
+ accountBaseUrl?: string;
9
+ authLoginUrl?: string;
10
+ deviceId?: string;
11
+ deviceName?: string;
12
+ clientVersion?: string;
13
+ hostname?: string;
14
+ loginIdleTimeoutMs?: number;
15
+ loginMaxTimeoutMs?: number;
16
+ }
17
+ interface TuttiNodeAuthClient {
18
+ login: () => Promise<{
19
+ session: TuttiAuthSession;
20
+ user: TuttiUserInfo;
21
+ }>;
22
+ getUserInfo: () => Promise<TuttiUserInfo | null>;
23
+ logout: () => Promise<void>;
24
+ readSession: () => Promise<TuttiAuthSession | null>;
25
+ clearSession: () => Promise<void>;
26
+ }
27
+ declare function createTuttiNodeAuthClient(options: TuttiNodeAuthClientOptions): TuttiNodeAuthClient;
28
+ declare function readAuthJson(authJsonPath: string): Promise<TuttiAuthSession | null>;
29
+ declare function writeAuthJson(authJsonPath: string, session: TuttiAuthSession): Promise<void>;
30
+
31
+ export { TuttiAuthSession, type TuttiNodeAuthClient, type TuttiNodeAuthClientOptions, TuttiUserInfo, createTuttiNodeAuthClient, readAuthJson, writeAuthJson };
package/dist/node.js ADDED
@@ -0,0 +1,420 @@
1
+ import {
2
+ AUTH_SERVER_BASE_PORT,
3
+ AUTH_SERVER_HOST,
4
+ AUTH_SERVER_MAX_PORT,
5
+ DEFAULT_ACCOUNT_BASE_URL,
6
+ DEFAULT_APP_ID,
7
+ DEFAULT_AUTH_LOGIN_URL,
8
+ DEFAULT_LOGIN_IDLE_TIMEOUT_MS,
9
+ DEFAULT_LOGIN_MAX_TIMEOUT_MS,
10
+ buildAccountUrl,
11
+ buildSessionCookie,
12
+ mapUserInfo,
13
+ readEnvelopeError,
14
+ trimString
15
+ } from "./chunk-B7N5AC4Q.js";
16
+
17
+ // src/node.ts
18
+ import { spawn } from "child_process";
19
+ import { randomUUID } from "crypto";
20
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
21
+ import { createServer } from "http";
22
+ import { createServer as createNetServer } from "net";
23
+ import { hostname } from "os";
24
+ import { dirname } from "path";
25
+ function createTuttiNodeAuthClient(options) {
26
+ const authJsonPath = options.authJsonPath.trim();
27
+ const appCallbackUrl = options.appCallbackUrl.trim();
28
+ if (!authJsonPath) {
29
+ throw new Error("authJsonPath is required");
30
+ }
31
+ if (!appCallbackUrl) {
32
+ throw new Error("appCallbackUrl is required");
33
+ }
34
+ new URL(appCallbackUrl);
35
+ const appId = options.appId?.trim() || DEFAULT_APP_ID;
36
+ const accountBaseUrl = options.accountBaseUrl?.trim() || DEFAULT_ACCOUNT_BASE_URL;
37
+ const authLoginUrl = options.authLoginUrl?.trim() || DEFAULT_AUTH_LOGIN_URL;
38
+ const openUrl = options.openUrl ?? openUrlWithDefaultBrowser;
39
+ const idleTimeoutMs = positiveMs(options.loginIdleTimeoutMs, DEFAULT_LOGIN_IDLE_TIMEOUT_MS);
40
+ const maxTimeoutMs = Math.max(idleTimeoutMs, positiveMs(options.loginMaxTimeoutMs, DEFAULT_LOGIN_MAX_TIMEOUT_MS));
41
+ async function readSession() {
42
+ return await readAuthJson(authJsonPath);
43
+ }
44
+ async function clearSession() {
45
+ await rm(authJsonPath, { force: true });
46
+ }
47
+ async function getUserInfo() {
48
+ const session = await readSession();
49
+ if (!session) {
50
+ return null;
51
+ }
52
+ const user = await fetchUserInfo(accountBaseUrl, session.cookie);
53
+ if (!user) {
54
+ return null;
55
+ }
56
+ await writeAuthJson(authJsonPath, sessionFromUser(session.sessionId, user));
57
+ return user;
58
+ }
59
+ return {
60
+ async login() {
61
+ const port = await findAvailablePort();
62
+ const localServerOrigin = `http://${AUTH_SERVER_HOST}:${port}`;
63
+ const attemptId = randomUUID();
64
+ const bridgeToken = randomUUID();
65
+ const deviceId = options.deviceId?.trim() || randomUUID();
66
+ const now = Date.now();
67
+ const state = encodeBridgeState({
68
+ v: 1,
69
+ flow: "desktop_bridge",
70
+ attemptId,
71
+ localServerOrigin,
72
+ bridgeToken,
73
+ appId,
74
+ appCallbackUrl,
75
+ deviceId,
76
+ deviceName: options.deviceName?.trim() || hostname() || "Desktop",
77
+ clientVersion: options.clientVersion?.trim() || void 0,
78
+ hostname: options.hostname?.trim() || hostname()
79
+ });
80
+ const pending = {
81
+ accountBaseUrl,
82
+ appId,
83
+ appCallbackUrl,
84
+ attemptId,
85
+ authOrigin: new URL(authLoginUrl).origin,
86
+ bridgeToken,
87
+ completed: false,
88
+ deviceId,
89
+ expiresAt: now + maxTimeoutMs,
90
+ idleTimeoutMs,
91
+ localServerOrigin,
92
+ maxExpiresAt: now + maxTimeoutMs,
93
+ state
94
+ };
95
+ const bridge = await createLoginBridgeServer(pending, port);
96
+ const loginUrl = buildLoginUrl(authLoginUrl, state);
97
+ const completion = waitForCompletion(pending, bridge.waitForCompletion);
98
+ void completion.catch(() => void 0);
99
+ try {
100
+ await openUrl(loginUrl);
101
+ const transferCode = await completion;
102
+ const sessionId = await redeemDesktopTransferCode(pending, transferCode);
103
+ const user = await fetchUserInfo(accountBaseUrl, buildSessionCookie(sessionId));
104
+ if (!user) {
105
+ throw new Error("Failed to load user info after login");
106
+ }
107
+ const session = sessionFromUser(sessionId, user);
108
+ await writeAuthJson(authJsonPath, session);
109
+ return { session, user };
110
+ } finally {
111
+ await closeServer(bridge.server);
112
+ }
113
+ },
114
+ getUserInfo,
115
+ logout: async () => {
116
+ const session = await readSession();
117
+ if (session) {
118
+ await logoutSession(accountBaseUrl, appId, session.cookie);
119
+ }
120
+ await clearSession();
121
+ },
122
+ readSession,
123
+ clearSession
124
+ };
125
+ }
126
+ async function readAuthJson(authJsonPath) {
127
+ try {
128
+ const parsed = JSON.parse(await readFile(authJsonPath, "utf8"));
129
+ const sessionId = trimString(parsed.sessionId) || trimString(parsed.session_id);
130
+ if (!sessionId) {
131
+ return null;
132
+ }
133
+ return {
134
+ sessionId,
135
+ cookie: trimString(parsed.cookie) || buildSessionCookie(sessionId),
136
+ userId: trimString(parsed.userId) || trimString(parsed.user_id),
137
+ name: trimString(parsed.name),
138
+ avatar: trimString(parsed.avatar),
139
+ email: trimString(parsed.email),
140
+ updatedAt: typeof parsed.updatedAt === "number" && Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : Date.now()
141
+ };
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+ async function writeAuthJson(authJsonPath, session) {
147
+ await mkdir(dirname(authJsonPath), { recursive: true });
148
+ const payload = {
149
+ session_id: session.sessionId,
150
+ cookie: session.cookie,
151
+ user_id: session.userId,
152
+ name: session.name,
153
+ avatar: session.avatar,
154
+ email: session.email,
155
+ updatedAt: session.updatedAt
156
+ };
157
+ const tempPath = `${authJsonPath}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`;
158
+ try {
159
+ await writeFile(tempPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 384 });
160
+ await rename(tempPath, authJsonPath);
161
+ } catch (error) {
162
+ await rm(tempPath, { force: true }).catch(() => void 0);
163
+ throw error;
164
+ }
165
+ }
166
+ function encodeBridgeState(state) {
167
+ return Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
168
+ }
169
+ function decodeBridgeState(rawState) {
170
+ try {
171
+ const parsed = JSON.parse(Buffer.from(rawState, "base64url").toString("utf8"));
172
+ if (parsed.v !== 1 || parsed.flow !== "desktop_bridge" || !trimString(parsed.attemptId) || !trimString(parsed.localServerOrigin) || !trimString(parsed.bridgeToken)) {
173
+ return null;
174
+ }
175
+ return parsed;
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+ function buildLoginUrl(authLoginUrl, state) {
181
+ const url = new URL(authLoginUrl);
182
+ url.pathname = "/auth/login";
183
+ url.search = "";
184
+ url.hash = "";
185
+ url.searchParams.set("state", state);
186
+ return url.toString();
187
+ }
188
+ function stateMatches(input, rawState) {
189
+ const state = decodeBridgeState(rawState);
190
+ return !input.completed && Date.now() <= input.maxExpiresAt && state?.attemptId === input.attemptId && state.bridgeToken === input.bridgeToken && state.localServerOrigin === input.localServerOrigin && (state.appId ?? "") === input.appId;
191
+ }
192
+ async function createLoginBridgeServer(input, port) {
193
+ let resolveCompletion;
194
+ let rejectCompletion;
195
+ let completed = false;
196
+ const waitForCompletion2 = new Promise((resolve, reject) => {
197
+ resolveCompletion = resolve;
198
+ rejectCompletion = reject;
199
+ });
200
+ const complete = (fn) => {
201
+ if (completed) {
202
+ return;
203
+ }
204
+ completed = true;
205
+ fn();
206
+ };
207
+ const server = createServer(async (req, res) => {
208
+ const url = new URL(req.url ?? "/", input.localServerOrigin);
209
+ if (req.method === "OPTIONS") {
210
+ if (!isAllowedBridgeOrigin(req, input) || !isAllowedLoopbackHost(req, port)) {
211
+ res.writeHead(403);
212
+ res.end();
213
+ return;
214
+ }
215
+ sendCors(res, 204);
216
+ return;
217
+ }
218
+ if (req.method === "GET" && url.pathname === "/oauth/health") {
219
+ const matched = !input.completed && Date.now() <= input.expiresAt && Date.now() <= input.maxExpiresAt && url.searchParams.get("attempt_id") === input.attemptId && url.searchParams.get("token") === input.bridgeToken;
220
+ if (!matched) {
221
+ sendJson(res, 401, { ok: false, error: { code: "INVALID_BRIDGE_ATTEMPT", message: "Desktop login attempt is unavailable." } });
222
+ return;
223
+ }
224
+ input.expiresAt = Math.min(input.maxExpiresAt, Date.now() + input.idleTimeoutMs);
225
+ sendJson(res, 200, { ok: true, data: { attemptId: input.attemptId, status: "ready", expiresAt: input.expiresAt } });
226
+ return;
227
+ }
228
+ if (req.method === "POST" && url.pathname === "/oauth/complete") {
229
+ if (!isAllowedBridgeOrigin(req, input) || !isAllowedLoopbackHost(req, port)) {
230
+ sendJson(res, 403, { ok: false, error: { code: "FORBIDDEN_BRIDGE_ORIGIN", message: "Bridge origin is not allowed." } });
231
+ return;
232
+ }
233
+ const payload = await readJsonBody(req).catch(() => ({}));
234
+ const callbackError = trimString(payload.error);
235
+ const callbackState = trimString(payload.state);
236
+ const transferCode = trimString(payload.transfer_code) || trimString(payload.transferCode);
237
+ if (!stateMatches(input, callbackState)) {
238
+ const error = new Error("Invalid state");
239
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_STATE", message: error.message } });
240
+ complete(() => rejectCompletion(error));
241
+ return;
242
+ }
243
+ if (callbackError) {
244
+ const error = new Error(callbackError);
245
+ sendJson(res, 400, { ok: false, error: { code: "PROVIDER_CALLBACK_ERROR", message: error.message } });
246
+ complete(() => rejectCompletion(error));
247
+ return;
248
+ }
249
+ if (!transferCode) {
250
+ const error = new Error("Missing transfer_code");
251
+ sendJson(res, 400, { ok: false, error: { code: "MISSING_TRANSFER_CODE", message: error.message } });
252
+ complete(() => rejectCompletion(error));
253
+ return;
254
+ }
255
+ input.completed = true;
256
+ sendJson(res, 200, { ok: true, data: { status: "completed" } });
257
+ complete(() => resolveCompletion(transferCode));
258
+ return;
259
+ }
260
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: "Not found" } });
261
+ });
262
+ await new Promise((resolve, reject) => {
263
+ server.once("error", reject);
264
+ server.listen(port, AUTH_SERVER_HOST, () => resolve());
265
+ });
266
+ return { server, waitForCompletion: waitForCompletion2 };
267
+ }
268
+ async function waitForCompletion(input, completion) {
269
+ while (true) {
270
+ const remainingMs = Math.min(input.expiresAt, input.maxExpiresAt) - Date.now();
271
+ if (remainingMs <= 0) {
272
+ throw new Error("Login timed out");
273
+ }
274
+ const result = await Promise.race([
275
+ completion.then((transferCode) => ({ transferCode })),
276
+ delay(Math.min(remainingMs, 500)).then(() => null)
277
+ ]);
278
+ if (result) {
279
+ return result.transferCode;
280
+ }
281
+ }
282
+ }
283
+ async function fetchUserInfo(accountBaseUrl, cookie) {
284
+ const response = await fetch(buildAccountUrl(accountBaseUrl, "/user/v1/user_info"), {
285
+ method: "POST",
286
+ headers: {
287
+ Accept: "application/json",
288
+ "Content-Type": "application/json",
289
+ Cookie: cookie
290
+ },
291
+ body: JSON.stringify({})
292
+ });
293
+ const payload = await response.json().catch(() => null);
294
+ if (response.status === 401 || payload?.code === 401) {
295
+ return null;
296
+ }
297
+ if (!response.ok || payload?.code !== 0) {
298
+ throw readEnvelopeError(response, payload);
299
+ }
300
+ return mapUserInfo(payload.data);
301
+ }
302
+ async function redeemDesktopTransferCode(input, transferCode) {
303
+ const response = await fetch(buildAccountUrl(input.accountBaseUrl, "/auth/v1/redeem_desktop_transfer_code"), {
304
+ method: "POST",
305
+ headers: {
306
+ Accept: "application/json",
307
+ "Content-Type": "application/json"
308
+ },
309
+ body: JSON.stringify({
310
+ transfer_code: transferCode,
311
+ attempt_id: input.attemptId,
312
+ bridge_token: input.bridgeToken,
313
+ app_id: input.appId,
314
+ device_id: input.deviceId
315
+ })
316
+ });
317
+ const payload = await response.json().catch(() => null);
318
+ const sessionId = trimString(payload?.data?.sessionId) || trimString(payload?.data?.session_id);
319
+ if (!response.ok || payload?.code !== 0 || !sessionId) {
320
+ throw readEnvelopeError(response, payload);
321
+ }
322
+ return sessionId;
323
+ }
324
+ async function logoutSession(accountBaseUrl, appId, cookie) {
325
+ const response = await fetch(buildAccountUrl(accountBaseUrl, "/auth/v1/logout-web-session"), {
326
+ method: "POST",
327
+ headers: {
328
+ Accept: "application/json",
329
+ "Content-Type": "application/json",
330
+ Cookie: cookie
331
+ },
332
+ body: JSON.stringify({ appId })
333
+ });
334
+ const payload = await response.json().catch(() => null);
335
+ if (!response.ok || (payload?.code ?? 0) !== 0) {
336
+ throw readEnvelopeError(response, payload);
337
+ }
338
+ }
339
+ async function findAvailablePort() {
340
+ for (let port = AUTH_SERVER_BASE_PORT; port <= AUTH_SERVER_MAX_PORT; port += 1) {
341
+ const available = await new Promise((resolve) => {
342
+ const probe = createNetServer();
343
+ probe.once("error", () => resolve(false));
344
+ probe.listen(port, AUTH_SERVER_HOST, () => {
345
+ probe.close(() => resolve(true));
346
+ });
347
+ });
348
+ if (available) {
349
+ return port;
350
+ }
351
+ }
352
+ throw new Error("unable to allocate localhost auth port");
353
+ }
354
+ function sessionFromUser(sessionId, user) {
355
+ return {
356
+ sessionId,
357
+ cookie: buildSessionCookie(sessionId),
358
+ userId: user.userId,
359
+ name: user.name ?? "",
360
+ avatar: user.avatar ?? "",
361
+ email: user.email ?? "",
362
+ updatedAt: Date.now()
363
+ };
364
+ }
365
+ function positiveMs(value, fallback) {
366
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
367
+ }
368
+ function delay(ms) {
369
+ return new Promise((resolve) => setTimeout(resolve, ms));
370
+ }
371
+ async function readJsonBody(req) {
372
+ let raw = "";
373
+ for await (const chunk of req) {
374
+ raw += chunk;
375
+ }
376
+ return raw ? JSON.parse(raw) : {};
377
+ }
378
+ function sendCors(res, status) {
379
+ res.writeHead(status, {
380
+ "Access-Control-Allow-Origin": "*",
381
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
382
+ "Access-Control-Allow-Headers": "Content-Type",
383
+ "Access-Control-Allow-Private-Network": "true"
384
+ });
385
+ res.end();
386
+ }
387
+ function isAllowedBridgeOrigin(req, input) {
388
+ const origin = trimString(req.headers.origin);
389
+ return !origin || origin === input.authOrigin;
390
+ }
391
+ function isAllowedLoopbackHost(req, port) {
392
+ const host = trimString(req.headers.host).toLowerCase();
393
+ return host === `${AUTH_SERVER_HOST}:${port}` || host === `localhost:${port}`;
394
+ }
395
+ function sendJson(res, status, payload) {
396
+ res.writeHead(status, {
397
+ "Access-Control-Allow-Origin": "*",
398
+ "Content-Type": "application/json; charset=utf-8"
399
+ });
400
+ res.end(JSON.stringify(payload));
401
+ }
402
+ function closeServer(server) {
403
+ return new Promise((resolve) => server.close(() => resolve()));
404
+ }
405
+ function openUrlWithDefaultBrowser(url) {
406
+ const command = process.platform === "darwin" ? { file: "open", args: [url] } : process.platform === "win32" ? { file: "cmd", args: ["/c", "start", "", url] } : { file: "xdg-open", args: [url] };
407
+ return new Promise((resolve, reject) => {
408
+ const child = spawn(command.file, command.args, { detached: true, stdio: "ignore", windowsHide: true });
409
+ child.once("error", reject);
410
+ child.once("spawn", () => {
411
+ child.unref();
412
+ resolve();
413
+ });
414
+ });
415
+ }
416
+ export {
417
+ createTuttiNodeAuthClient,
418
+ readAuthJson,
419
+ writeAuthJson
420
+ };
@@ -0,0 +1,17 @@
1
+ interface TuttiUserInfo {
2
+ userId: string;
3
+ name?: string;
4
+ email?: string;
5
+ avatar?: string;
6
+ }
7
+ interface TuttiAuthSession {
8
+ sessionId: string;
9
+ cookie: string;
10
+ userId: string;
11
+ name: string;
12
+ avatar: string;
13
+ email: string;
14
+ updatedAt: number;
15
+ }
16
+
17
+ export type { TuttiUserInfo as T, TuttiAuthSession as a };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@tutti-os/auth-bridge",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "types": "./dist/browser.d.ts",
7
+ "exports": {
8
+ "./browser": {
9
+ "types": "./dist/browser.d.ts",
10
+ "import": "./dist/browser.js"
11
+ },
12
+ "./node": {
13
+ "types": "./dist/node.d.ts",
14
+ "import": "./dist/node.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "devDependencies": {
22
+ "@types/node": "^24.12.2",
23
+ "tsx": "^4.21.0",
24
+ "typescript": "^5.9.3"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup --config tsup.config.ts",
31
+ "type-check": "tsc --noEmit",
32
+ "test": "pnpm exec tsx --test src/*.test.ts"
33
+ }
34
+ }