import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileExists } from "./http-utils.mjs";
const defaultAuthFilePath = path.join(os.homedir(), ".codex", "nbtanginone-auth.json");
const pendingRequestTtlMs = 10 * 60 * 1000;
const approvedClientTtlMs = 7 * 24 * 60 * 60 * 1000;
const approvedClientTtlDays = 7;
const bootstrapApprovedClientTtlDays = 365;
function daysToMs(days) {
return days * 24 * 60 * 60 * 1000;
}
function normalizeApprovedDays(value) {
const days = Number(value);
return Number.isFinite(days) && days > 0 ? Math.trunc(days) : approvedClientTtlDays;
}
function normalizeRequest(item) {
if (!item || typeof item !== "object" || typeof item.id !== "string") return null;
return {
id: item.id,
createdAt: typeof item.createdAt === "string" ? item.createdAt : "",
status: typeof item.status === "string" ? item.status : "pending",
fingerprint: typeof item.fingerprint === "string" ? item.fingerprint : "",
clientId: typeof item.clientId === "string" ? item.clientId : "",
clientIp: typeof item.clientIp === "string" ? item.clientIp : "",
publicIp: typeof item.publicIp === "string" ? item.publicIp : "",
publicLocation: typeof item.publicLocation === "string" ? item.publicLocation : "",
userAgent: typeof item.userAgent === "string" ? item.userAgent : "",
acceptLanguage: typeof item.acceptLanguage === "string" ? item.acceptLanguage : "",
secChUa: typeof item.secChUa === "string" ? item.secChUa : "",
secChUaPlatform: typeof item.secChUaPlatform === "string" ? item.secChUaPlatform : "",
screen: typeof item.screen === "string" ? item.screen : "",
timezone: typeof item.timezone === "string" ? item.timezone : "",
platform: typeof item.platform === "string" ? item.platform : "",
touchPoints: Number.isFinite(item.touchPoints) ? item.touchPoints : 0,
cookieId: typeof item.cookieId === "string" ? item.cookieId : "",
note: typeof item.note === "string" ? item.note : "",
approvedDays: normalizeApprovedDays(item.approvedDays),
decisionAt: typeof item.decisionAt === "string" ? item.decisionAt : "",
decisionBy: typeof item.decisionBy === "string" ? item.decisionBy : "",
};
}
function normalizeApprovedClient(item) {
if (!item || typeof item !== "object" || typeof item.clientId !== "string") return null;
return {
clientId: item.clientId,
fingerprint: typeof item.fingerprint === "string" ? item.fingerprint : "",
approvedAt: typeof item.approvedAt === "string" ? item.approvedAt : "",
expiresAt: typeof item.expiresAt === "string" ? item.expiresAt : "",
label: typeof item.label === "string" ? item.label : "",
sessionToken: typeof item.sessionToken === "string" ? item.sessionToken : "",
requestId: typeof item.requestId === "string" ? item.requestId : "",
lastSeenAt: typeof item.lastSeenAt === "string" ? item.lastSeenAt : "",
isBootstrapApprover: item.isBootstrapApprover === true,
};
}
function ensureBootstrapApprover(list) {
if (!Array.isArray(list) || list.length === 0) return [];
const normalized = list.map((item) => normalizeApprovedClient(item)).filter(Boolean);
if (normalized.some((item) => item.isBootstrapApprover)) {
return normalized;
}
let selectedIndex = 0;
let selectedTime = Number.POSITIVE_INFINITY;
for (let index = 0; index < normalized.length; index += 1) {
const value = Date.parse(normalized[index].approvedAt || 0);
if (Number.isFinite(value) && value < selectedTime) {
selectedTime = value;
selectedIndex = index;
}
}
normalized[selectedIndex].isBootstrapApprover = true;
return normalized;
}
function dedupeBy(list, key) {
return Array.from(new Map(list.map((item) => [item[key], item])).values());
}
function isNotExpired(isoString, now) {
if (!isoString) return false;
const value = Date.parse(isoString);
return Number.isFinite(value) && value > now;
}
export class AuthStore {
constructor(options = {}) {
this.filePath = options.filePath || process.env.NBTANGINONE_AUTH_FILE || defaultAuthFilePath;
}
async loadState() {
if (!(await fileExists(fs, this.filePath))) {
return { pendingRequests: [], approvedClients: [] };
}
try {
const parsed = JSON.parse(await fs.readFile(this.filePath, "utf8"));
return {
pendingRequests: Array.isArray(parsed.pendingRequests)
? parsed.pendingRequests.map(normalizeRequest).filter(Boolean)
: [],
approvedClients: Array.isArray(parsed.approvedClients)
? parsed.approvedClients.map(normalizeApprovedClient).filter(Boolean)
: [],
};
} catch {
return { pendingRequests: [], approvedClients: [] };
}
}
async saveState(state) {
const approvedClients = ensureBootstrapApprover(
Array.isArray(state.approvedClients) ? state.approvedClients.map(normalizeApprovedClient).filter(Boolean) : []
);
const content =
JSON.stringify(
{
pendingRequests: dedupeBy(
(Array.isArray(state.pendingRequests) ? state.pendingRequests.map(normalizeRequest).filter(Boolean) : []),
"id"
).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))),
approvedClients: dedupeBy(approvedClients, "clientId").sort((a, b) =>
String(b.approvedAt).localeCompare(String(a.approvedAt))
),
},
null,
2
) + "\n";
const tmpPath = this.filePath + ".tmp." + randomUUID();
await fs.writeFile(tmpPath, content, "utf8");
await fs.rename(tmpPath, this.filePath);
}
async cleanupState() {
const state = await this.loadState();
const now = Date.now();
const cleanedState = {
pendingRequests: state.pendingRequests.filter((item) => {
if (item.status === "pending") {
return isNotExpired(new Date(Date.parse(item.createdAt || 0) + pendingRequestTtlMs).toISOString(), now);
}
return isNotExpired(
new Date(Date.parse(item.createdAt || 0) + daysToMs(normalizeApprovedDays(item.approvedDays))).toISOString(),
now
);
}),
approvedClients: state.approvedClients.filter((item) => isNotExpired(item.expiresAt, now)),
};
cleanedState.approvedClients = ensureBootstrapApprover(cleanedState.approvedClients);
await this.saveState(cleanedState);
return cleanedState;
}
getClientIp(req) {
const forwardedFor = req.headers["x-forwarded-for"];
if (typeof forwardedFor === "string" && forwardedFor.trim()) {
return forwardedFor.split(",")[0].trim();
}
return req.socket?.remoteAddress || "";
}
createFingerprintHash(payload) {
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
}
async createOrReuseRequest(req, payload) {
const state = await this.cleanupState();
const nowIso = new Date().toISOString();
const nowMs = Date.now();
const requestPayload = {
clientId: typeof payload.clientId === "string" ? payload.clientId : "",
fingerprint: typeof payload.fingerprint === "string" ? payload.fingerprint : "",
userAgent: typeof payload.userAgent === "string" ? payload.userAgent : "",
acceptLanguage: typeof payload.acceptLanguage === "string" ? payload.acceptLanguage : "",
secChUa: typeof payload.secChUa === "string" ? payload.secChUa : "",
secChUaPlatform: typeof payload.secChUaPlatform === "string" ? payload.secChUaPlatform : "",
screen: typeof payload.screen === "string" ? payload.screen : "",
timezone: typeof payload.timezone === "string" ? payload.timezone : "",
platform: typeof payload.platform === "string" ? payload.platform : "",
touchPoints: Number.isFinite(payload.touchPoints) ? payload.touchPoints : 0,
cookieId: typeof payload.cookieId === "string" ? payload.cookieId : "",
clientIp: this.getClientIp(req),
publicIp: typeof payload.publicIp === "string" ? payload.publicIp : "",
publicLocation: typeof payload.publicLocation === "string" ? payload.publicLocation : "",
};
if (state.pendingRequests.length === 0 && state.approvedClients.length === 0) {
const approvedClient = {
clientId: requestPayload.clientId,
fingerprint: requestPayload.fingerprint,
approvedAt: nowIso,
expiresAt: new Date(Date.now() + daysToMs(bootstrapApprovedClientTtlDays)).toISOString(),
label: `${requestPayload.platform || "browser"} ${requestPayload.screen || ""}`.trim(),
sessionToken: randomUUID(),
requestId: "",
lastSeenAt: nowIso,
isBootstrapApprover: true,
};
state.approvedClients.unshift(approvedClient);
await this.saveState(state);
return {
request: null,
status: "approved",
approvedClient,
};
}
const approvedClient = state.approvedClients.find(
(item) =>
item.clientId === requestPayload.clientId &&
isNotExpired(item.expiresAt, nowMs)
);
if (approvedClient) {
approvedClient.fingerprint = requestPayload.fingerprint;
approvedClient.lastSeenAt = nowIso;
approvedClient.expiresAt = new Date(Date.now() + daysToMs(normalizeApprovedDays(approvedClientTtlDays))).toISOString();
if (!approvedClient.sessionToken) {
approvedClient.sessionToken = randomUUID();
}
await this.saveState(state);
return {
request: null,
status: "approved",
approvedClient,
};
}
const existingPending = state.pendingRequests.find(
(item) =>
item.status === "pending" &&
item.clientId === requestPayload.clientId &&
nowMs - Date.parse(item.createdAt || 0) < pendingRequestTtlMs
);
if (existingPending) {
if (requestPayload.publicIp && !existingPending.publicIp) {
existingPending.publicIp = requestPayload.publicIp;
}
if (requestPayload.publicLocation && !existingPending.publicLocation) {
existingPending.publicLocation = requestPayload.publicLocation;
}
if (requestPayload.clientIp && !existingPending.clientIp) {
existingPending.clientIp = requestPayload.clientIp;
}
await this.saveState(state);
return {
request: existingPending,
status: existingPending.status,
approvedClient: null,
};
}
const request = {
id: "req_" + randomUUID(),
createdAt: nowIso,
status: "pending",
...requestPayload,
note: "",
approvedDays: approvedClientTtlDays,
decisionAt: "",
decisionBy: "",
};
state.pendingRequests.unshift(request);
await this.saveState(state);
return {
request,
status: request.status,
approvedClient: null,
};
}
async getRequestStatus(requestId) {
const state = await this.cleanupState();
const request = state.pendingRequests.find((item) => item.id === requestId);
if (!request) return { request: null, approvedClient: null };
let approvedClient = null;
if (request.status === "approved") {
const nowIso = new Date().toISOString();
const approvedDays = normalizeApprovedDays(request.approvedDays);
const expiresAt = new Date(Date.now() + daysToMs(approvedDays)).toISOString();
approvedClient = state.approvedClients.find((item) => item.clientId === request.clientId);
if (!approvedClient) {
approvedClient = {
clientId: request.clientId,
fingerprint: request.fingerprint,
approvedAt: request.decisionAt || nowIso,
expiresAt,
label: `${request.platform || "browser"} ${request.screen || ""}`.trim(),
sessionToken: randomUUID(),
requestId: request.id,
lastSeenAt: nowIso,
isBootstrapApprover: false,
};
state.approvedClients.unshift(approvedClient);
} else {
approvedClient.fingerprint = request.fingerprint;
approvedClient.requestId = request.id;
approvedClient.lastSeenAt = nowIso;
approvedClient.expiresAt = expiresAt;
if (!approvedClient.sessionToken) {
approvedClient.sessionToken = randomUUID();
}
}
await this.saveState(state);
}
return { request, approvedClient };
}
async verifySessionToken(token) {
if (!token) return null;
const state = await this.cleanupState();
const nowMs = Date.now();
const approvedClient = state.approvedClients.find(
(item) => item.sessionToken === token && isNotExpired(item.expiresAt, nowMs)
);
if (!approvedClient) return null;
approvedClient.lastSeenAt = new Date().toISOString();
approvedClient.expiresAt = new Date(Date.now() + daysToMs(normalizeApprovedDays(approvedClientTtlDays))).toISOString();
await this.saveState(state);
return approvedClient;
}
async listPendingRequests() {
const state = await this.cleanupState();
return state.pendingRequests.filter((item) => item.status === "pending");
}
async listApprovedClients() {
const state = await this.cleanupState();
return state.approvedClients;
}
async decideRequest(requestId, decision, decidedBy = "local-script", approvedDays = approvedClientTtlDays) {
const state = await this.cleanupState();
const request = state.pendingRequests.find((item) => item.id === requestId);
if (!request) {
const error = new Error("Request not found");
error.statusCode = 404;
throw error;
}
if (!["approved", "rejected"].includes(decision)) {
const error = new Error("Invalid decision");
error.statusCode = 400;
throw error;
}
request.status = decision;
request.approvedDays = normalizeApprovedDays(approvedDays);
request.decisionAt = new Date().toISOString();
request.decisionBy = decidedBy;
await this.saveState(state);
return request;
}
async revokeApprovedClient(clientId) {
const state = await this.cleanupState();
const approvedClient = state.approvedClients.find((item) => item.clientId === clientId);
if (!approvedClient) {
const error = new Error("Approved client not found");
error.statusCode = 404;
throw error;
}
if (approvedClient.isBootstrapApprover) {
const error = new Error("Bootstrap approver cannot be revoked");
error.statusCode = 400;
throw error;
}
state.approvedClients = state.approvedClients.filter((item) => item.clientId !== clientId);
await this.saveState(state);
return { clientId };
}
}