@docsxai/engine 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 +202 -0
- package/README.md +130 -0
- package/dist/auth/api-login.d.ts +69 -0
- package/dist/auth/api-login.js +95 -0
- package/dist/auth/browser-session.d.ts +28 -0
- package/dist/auth/browser-session.js +43 -0
- package/dist/auth/cookie-jar.d.ts +58 -0
- package/dist/auth/cookie-jar.js +212 -0
- package/dist/auth/email-otp.d.ts +210 -0
- package/dist/auth/email-otp.js +166 -0
- package/dist/auth/http-basic.d.ts +5 -0
- package/dist/auth/http-basic.js +17 -0
- package/dist/auth/index.d.ts +47 -0
- package/dist/auth/index.js +137 -0
- package/dist/auth/jwt-injection.d.ts +153 -0
- package/dist/auth/jwt-injection.js +136 -0
- package/dist/auth/manual-capture.d.ts +35 -0
- package/dist/auth/manual-capture.js +30 -0
- package/dist/auth/mtls.d.ts +15 -0
- package/dist/auth/mtls.js +53 -0
- package/dist/auth/pat-header.d.ts +19 -0
- package/dist/auth/pat-header.js +34 -0
- package/dist/auth/storage-state-cache.d.ts +38 -0
- package/dist/auth/storage-state-cache.js +143 -0
- package/dist/auth/test-backdoor.d.ts +25 -0
- package/dist/auth/test-backdoor.js +51 -0
- package/dist/auth/totp.d.ts +39 -0
- package/dist/auth/totp.js +108 -0
- package/dist/auth/types.d.ts +86 -0
- package/dist/auth/types.js +57 -0
- package/dist/auth/ui-form.d.ts +204 -0
- package/dist/auth/ui-form.js +153 -0
- package/dist/auth/webauthn.d.ts +88 -0
- package/dist/auth/webauthn.js +67 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +3 -0
- package/dist/backend-client-contracts.d.ts +88 -0
- package/dist/backend-client-contracts.js +19 -0
- package/dist/backend-client-oauth-login.d.ts +7 -0
- package/dist/backend-client-oauth-login.js +90 -0
- package/dist/backend-client-state-cache.d.ts +73 -0
- package/dist/backend-client-state-cache.js +185 -0
- package/dist/backend-client-token.d.ts +18 -0
- package/dist/backend-client-token.js +94 -0
- package/dist/backend-client-transport.d.ts +66 -0
- package/dist/backend-client-transport.js +181 -0
- package/dist/backend-client.d.ts +5 -0
- package/dist/backend-client.js +18 -0
- package/dist/calibrate.d.ts +31 -0
- package/dist/calibrate.js +68 -0
- package/dist/cli-commands-authoring.d.ts +5 -0
- package/dist/cli-commands-authoring.js +403 -0
- package/dist/cli-commands-backend.d.ts +5 -0
- package/dist/cli-commands-backend.js +211 -0
- package/dist/cli-commands-docpack.d.ts +5 -0
- package/dist/cli-commands-docpack.js +280 -0
- package/dist/cli-commands-session.d.ts +4 -0
- package/dist/cli-commands-session.js +398 -0
- package/dist/cli-shared.d.ts +5 -0
- package/dist/cli-shared.js +45 -0
- package/dist/cli-usage.d.ts +1 -0
- package/dist/cli-usage.js +137 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +77 -0
- package/dist/diagnose.d.ts +50 -0
- package/dist/diagnose.js +168 -0
- package/dist/diff-compute.d.ts +13 -0
- package/dist/diff-compute.js +378 -0
- package/dist/diff-report.d.ts +7 -0
- package/dist/diff-report.js +125 -0
- package/dist/diff-types.d.ts +125 -0
- package/dist/diff-types.js +15 -0
- package/dist/diff.d.ts +3 -0
- package/dist/diff.js +16 -0
- package/dist/doc-pack-io.d.ts +30 -0
- package/dist/doc-pack-io.js +182 -0
- package/dist/doc-pack.d.ts +1814 -0
- package/dist/doc-pack.js +328 -0
- package/dist/doctor-checks-plugins.d.ts +2 -0
- package/dist/doctor-checks-plugins.js +136 -0
- package/dist/doctor-checks.d.ts +56 -0
- package/dist/doctor-checks.js +367 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +62 -0
- package/dist/export/adf.d.ts +57 -0
- package/dist/export/adf.js +323 -0
- package/dist/export/playwright-test.d.ts +26 -0
- package/dist/export/playwright-test.js +221 -0
- package/dist/flow-file.d.ts +21 -0
- package/dist/flow-file.js +180 -0
- package/dist/flow-lint.d.ts +24 -0
- package/dist/flow-lint.js +203 -0
- package/dist/flow-runtime.d.ts +113 -0
- package/dist/flow-runtime.js +273 -0
- package/dist/flow-tree.d.ts +19 -0
- package/dist/flow-tree.js +104 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +31 -0
- package/dist/playwright-driver.d.ts +105 -0
- package/dist/playwright-driver.js +363 -0
- package/dist/playwright-instrumented-browser.d.ts +51 -0
- package/dist/playwright-instrumented-browser.js +189 -0
- package/dist/plugins/load.d.ts +22 -0
- package/dist/plugins/load.js +99 -0
- package/dist/plugins/lock.d.ts +40 -0
- package/dist/plugins/lock.js +122 -0
- package/dist/plugins/manifest.d.ts +70 -0
- package/dist/plugins/manifest.js +115 -0
- package/dist/plugins/plan.d.ts +51 -0
- package/dist/plugins/plan.js +279 -0
- package/dist/plugins/registry.d.ts +59 -0
- package/dist/plugins/registry.js +71 -0
- package/dist/plugins/runtime.d.ts +7 -0
- package/dist/plugins/runtime.js +27 -0
- package/dist/plugins/types.d.ts +58 -0
- package/dist/plugins/types.js +4 -0
- package/dist/plugins-cli.d.ts +1 -0
- package/dist/plugins-cli.js +191 -0
- package/dist/redact.d.ts +16 -0
- package/dist/redact.js +72 -0
- package/dist/style.d.ts +46 -0
- package/dist/style.js +151 -0
- package/dist/viewer-bin.d.ts +20 -0
- package/dist/viewer-bin.js +97 -0
- package/dist/workspace.d.ts +60 -0
- package/dist/workspace.js +172 -0
- package/dist/zip.d.ts +17 -0
- package/dist/zip.js +113 -0
- package/package.json +64 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// OAuth 2.1 + PKCE login flow (`docsxai login --oauth`): drives the authorization-code handshake
|
|
2
|
+
// against the backend's minimal authorization server. Re-exported from `./backend-client.js`.
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { BackendClientError, } from "./backend-client-contracts.js";
|
|
6
|
+
/**
|
|
7
|
+
* Drive the authorization-code + PKCE handshake against the backend's minimal authorization
|
|
8
|
+
* server: start a loopback listener for the redirect, hand the authorize URL to the caller,
|
|
9
|
+
* await the code, exchange it (S256 verifier) for tokens.
|
|
10
|
+
*/
|
|
11
|
+
export async function oauthLogin(opts) {
|
|
12
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
13
|
+
const base = opts.backendUrl.replace(/\/+$/, "");
|
|
14
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
15
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
16
|
+
const state = randomBytes(16).toString("base64url");
|
|
17
|
+
let resolveCode;
|
|
18
|
+
let rejectCode;
|
|
19
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
20
|
+
resolveCode = resolve;
|
|
21
|
+
rejectCode = reject;
|
|
22
|
+
});
|
|
23
|
+
const server = createServer((req, res) => {
|
|
24
|
+
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
25
|
+
if (u.pathname !== "/callback") {
|
|
26
|
+
res.writeHead(404).end();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const gotCode = u.searchParams.get("code");
|
|
30
|
+
if (u.searchParams.get("state") !== state || !gotCode) {
|
|
31
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
32
|
+
res.end("docsxai login: state mismatch or missing code\n");
|
|
33
|
+
rejectCode(new BackendClientError("OAuth redirect carried a bad state or no code"));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
37
|
+
res.end("docsxai login complete — you can close this tab.\n");
|
|
38
|
+
resolveCode(gotCode);
|
|
39
|
+
});
|
|
40
|
+
const port = await new Promise((resolve, reject) => {
|
|
41
|
+
server.once("error", reject);
|
|
42
|
+
server.listen(0, "127.0.0.1", () => {
|
|
43
|
+
const addr = server.address();
|
|
44
|
+
if (addr && typeof addr === "object")
|
|
45
|
+
resolve(addr.port);
|
|
46
|
+
else
|
|
47
|
+
reject(new BackendClientError("failed to bind the OAuth callback listener"));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
51
|
+
const authorizeUrl = new URL(`${base}/v1/oauth/authorize`);
|
|
52
|
+
authorizeUrl.searchParams.set("client_id", "docsxai-cli");
|
|
53
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
54
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
55
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
56
|
+
authorizeUrl.searchParams.set("state", state);
|
|
57
|
+
opts.onAuthorizeUrl(authorizeUrl.toString());
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
rejectCode(new BackendClientError("OAuth login timed out waiting for the browser redirect"));
|
|
60
|
+
}, opts.timeoutMs ?? 300_000);
|
|
61
|
+
timer.unref();
|
|
62
|
+
let code;
|
|
63
|
+
try {
|
|
64
|
+
code = await codePromise;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
server.close();
|
|
69
|
+
}
|
|
70
|
+
const res = await doFetch(`${base}/v1/oauth/token`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
73
|
+
body: new URLSearchParams({
|
|
74
|
+
grant_type: "authorization_code",
|
|
75
|
+
code,
|
|
76
|
+
code_verifier: verifier,
|
|
77
|
+
redirect_uri: redirectUri,
|
|
78
|
+
}).toString(),
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
const text = await res.text().catch(() => "");
|
|
82
|
+
throw new BackendClientError(`token exchange → ${res.status}: ${text.slice(0, 200)}`, res.status);
|
|
83
|
+
}
|
|
84
|
+
const tokens = (await res.json());
|
|
85
|
+
return {
|
|
86
|
+
access_token: tokens.access_token,
|
|
87
|
+
refresh_token: tokens.refresh_token,
|
|
88
|
+
expires_at: Date.now() + tokens.expires_in * 1000,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export interface CachedStorageState {
|
|
2
|
+
cookies: Array<{
|
|
3
|
+
name: string;
|
|
4
|
+
value: string;
|
|
5
|
+
domain: string;
|
|
6
|
+
path: string;
|
|
7
|
+
expires: number;
|
|
8
|
+
httpOnly: boolean;
|
|
9
|
+
secure: boolean;
|
|
10
|
+
sameSite: "Strict" | "Lax" | "None";
|
|
11
|
+
}>;
|
|
12
|
+
origins: Array<{
|
|
13
|
+
origin: string;
|
|
14
|
+
localStorage: Array<{
|
|
15
|
+
name: string;
|
|
16
|
+
value: string;
|
|
17
|
+
}>;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
export interface CachedAuthResult {
|
|
21
|
+
storageState: CachedStorageState;
|
|
22
|
+
/** Epoch ms when the session is known to expire (e.g. from a cookie's `expires`). Optional. */
|
|
23
|
+
expiresAt?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface CacheRoleConfig {
|
|
26
|
+
cache: {
|
|
27
|
+
/** `"session"`, a duration string (`30m`, `1h`, `500ms`), or ms as a number. */
|
|
28
|
+
ttl: string | number;
|
|
29
|
+
auth_cookie?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export declare class BackendStateCacheError extends Error {
|
|
33
|
+
constructor(message: string);
|
|
34
|
+
}
|
|
35
|
+
export interface BackendStateCacheOptions {
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
token: string;
|
|
38
|
+
workspaceId: string;
|
|
39
|
+
/** Base64-encoded 32-byte AES key — the resolved value of `DOCSX_CACHE_KEY`. */
|
|
40
|
+
cacheKey: string;
|
|
41
|
+
fetch?: typeof globalThis.fetch;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Backend-relayed storage-state cache. Same `load` / `save` / `clear` contract as the local
|
|
45
|
+
* `.auth/<role>.json` cache (incl. the expiry-priority rules), but the entry lives on the backend
|
|
46
|
+
* as a client-side-encrypted envelope — usable by a team sharing one backend, opaque to the server.
|
|
47
|
+
*/
|
|
48
|
+
export declare class BackendStateCache {
|
|
49
|
+
private readonly baseUrl;
|
|
50
|
+
private readonly token;
|
|
51
|
+
private readonly workspaceId;
|
|
52
|
+
private readonly key;
|
|
53
|
+
private readonly doFetch;
|
|
54
|
+
constructor(opts: BackendStateCacheOptions);
|
|
55
|
+
/** Return the cached state for a role if present and not past its expiry; otherwise `null`. */
|
|
56
|
+
load(role: string, now?: number): Promise<CachedStorageState | null>;
|
|
57
|
+
/**
|
|
58
|
+
* Encrypt + store a captured session, computing its expiry exactly like the local cache:
|
|
59
|
+
* auth-cookie expiry when available, else `ttl`, else the strategy-reported `expiresAt`
|
|
60
|
+
* (if plausibly in the future), else +1h. Refuses to cache an already-dead session.
|
|
61
|
+
*/
|
|
62
|
+
save(role: string, result: CachedAuthResult, roleAuth: CacheRoleConfig, now?: number, opts?: {
|
|
63
|
+
authCookie?: string;
|
|
64
|
+
}): Promise<{
|
|
65
|
+
expiresAt: number;
|
|
66
|
+
source: string;
|
|
67
|
+
}>;
|
|
68
|
+
clear(role: string): Promise<void>;
|
|
69
|
+
private url;
|
|
70
|
+
private headers;
|
|
71
|
+
private encrypt;
|
|
72
|
+
private decrypt;
|
|
73
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Encrypted storage-state cache relay (`BackendStateCache`). Re-exported from `./backend-client.js`.
|
|
2
|
+
//
|
|
3
|
+
// Client-side encryption: the backend stores an opaque AES-256-GCM envelope and never sees the
|
|
4
|
+
// plaintext session. The shapes below are structural mirrors of the engine's auth layer
|
|
5
|
+
// (`StorageState` / `AuthResult` / `RoleAuth.cache`) — deliberately not imported from auth.ts so
|
|
6
|
+
// the modules stay decoupled; the auth layer satisfies them structurally.
|
|
7
|
+
//
|
|
8
|
+
// NOTE: the ttl-to-ms / cookie-expiry math here is the same shape that also lives in
|
|
9
|
+
// `auth/storage-state-cache.ts` and `auth/types.ts`. The two helpers below are file-private (a
|
|
10
|
+
// single copy for the backend-client surface); the auth/* copies are left untouched here, so the
|
|
11
|
+
// shape exists in three places but each is small and within the jscpd budget.
|
|
12
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
13
|
+
import { API_VERSION, API_VERSION_HEADER, BackendClientError } from "./backend-client-contracts.js";
|
|
14
|
+
export class BackendStateCacheError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "BackendStateCacheError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Parse a `ttl` (number ms, duration string `30m`/`1h`/`500ms`, or anything else) to ms or `"session"`. */
|
|
21
|
+
function ttlToMs(ttl) {
|
|
22
|
+
if (typeof ttl === "number")
|
|
23
|
+
return ttl;
|
|
24
|
+
const m = /^(\d+)(ms|s|m|h)$/.exec(ttl);
|
|
25
|
+
if (!m)
|
|
26
|
+
return "session";
|
|
27
|
+
const n = Number(m[1]);
|
|
28
|
+
switch (m[2]) {
|
|
29
|
+
case "ms":
|
|
30
|
+
return n;
|
|
31
|
+
case "s":
|
|
32
|
+
return n * 1000;
|
|
33
|
+
case "m":
|
|
34
|
+
return n * 60_000;
|
|
35
|
+
default:
|
|
36
|
+
return n * 3_600_000;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Expiry (epoch ms) of the cookie named `name` — the latest if several; `undefined` if absent or session-only. */
|
|
40
|
+
function cookieExpiryByName(state, name) {
|
|
41
|
+
const expiries = state.cookies
|
|
42
|
+
.filter((c) => c.name === name && typeof c.expires === "number" && c.expires > 0)
|
|
43
|
+
.map((c) => c.expires * 1000);
|
|
44
|
+
return expiries.length ? Math.max(...expiries) : undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Backend-relayed storage-state cache. Same `load` / `save` / `clear` contract as the local
|
|
48
|
+
* `.auth/<role>.json` cache (incl. the expiry-priority rules), but the entry lives on the backend
|
|
49
|
+
* as a client-side-encrypted envelope — usable by a team sharing one backend, opaque to the server.
|
|
50
|
+
*/
|
|
51
|
+
export class BackendStateCache {
|
|
52
|
+
baseUrl;
|
|
53
|
+
token;
|
|
54
|
+
workspaceId;
|
|
55
|
+
key;
|
|
56
|
+
doFetch;
|
|
57
|
+
constructor(opts) {
|
|
58
|
+
if (!opts.cacheKey) {
|
|
59
|
+
throw new BackendStateCacheError("missing cache key — set DOCSX_CACHE_KEY to a base64-encoded 32-byte key");
|
|
60
|
+
}
|
|
61
|
+
const key = Buffer.from(opts.cacheKey, "base64");
|
|
62
|
+
if (key.length !== 32) {
|
|
63
|
+
throw new BackendStateCacheError(`malformed cache key — DOCSX_CACHE_KEY must decode to exactly 32 bytes (got ${key.length})`);
|
|
64
|
+
}
|
|
65
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
66
|
+
this.token = opts.token;
|
|
67
|
+
this.workspaceId = opts.workspaceId;
|
|
68
|
+
this.key = key;
|
|
69
|
+
this.doFetch = opts.fetch ?? globalThis.fetch;
|
|
70
|
+
}
|
|
71
|
+
/** Return the cached state for a role if present and not past its expiry; otherwise `null`. */
|
|
72
|
+
async load(role, now = Date.now()) {
|
|
73
|
+
const res = await this.doFetch(this.url(role), { headers: this.headers() });
|
|
74
|
+
if (res.status === 404)
|
|
75
|
+
return null;
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
throw new BackendClientError(`GET auth-cache(${role}) → ${res.status}`, res.status);
|
|
78
|
+
}
|
|
79
|
+
const envelope = (await res.json());
|
|
80
|
+
const plain = this.decrypt(envelope);
|
|
81
|
+
let entry;
|
|
82
|
+
try {
|
|
83
|
+
entry = JSON.parse(plain);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null; // corrupt entry → treat as miss (mirrors the local cache)
|
|
87
|
+
}
|
|
88
|
+
if (!entry.storageState || typeof entry.expiresAt !== "number" || entry.expiresAt <= now) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return entry.storageState;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Encrypt + store a captured session, computing its expiry exactly like the local cache:
|
|
95
|
+
* auth-cookie expiry when available, else `ttl`, else the strategy-reported `expiresAt`
|
|
96
|
+
* (if plausibly in the future), else +1h. Refuses to cache an already-dead session.
|
|
97
|
+
*/
|
|
98
|
+
async save(role, result, roleAuth, now = Date.now(), opts = {}) {
|
|
99
|
+
const authCookieName = opts.authCookie ?? roleAuth.cache.auth_cookie;
|
|
100
|
+
const fromCookie = authCookieName
|
|
101
|
+
? cookieExpiryByName(result.storageState, authCookieName)
|
|
102
|
+
: undefined;
|
|
103
|
+
const ttlMs = ttlToMs(roleAuth.cache.ttl);
|
|
104
|
+
let expiresAt;
|
|
105
|
+
let source;
|
|
106
|
+
if (fromCookie !== undefined) {
|
|
107
|
+
expiresAt = fromCookie;
|
|
108
|
+
source = `auth-cookie "${authCookieName}"`;
|
|
109
|
+
}
|
|
110
|
+
else if (authCookieName) {
|
|
111
|
+
expiresAt = typeof ttlMs === "number" ? now + ttlMs : now + 3_600_000;
|
|
112
|
+
source = `ttl (fallback — auth-cookie "${authCookieName}" not in the jar or has no expiry)`;
|
|
113
|
+
}
|
|
114
|
+
else if (typeof ttlMs === "number") {
|
|
115
|
+
expiresAt = now + ttlMs;
|
|
116
|
+
source = "ttl";
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const reported = result.expiresAt && result.expiresAt > now + 60_000 ? result.expiresAt : undefined;
|
|
120
|
+
expiresAt = reported ?? now + 3_600_000;
|
|
121
|
+
source = reported ? "strategy-reported expiresAt" : "1h default";
|
|
122
|
+
}
|
|
123
|
+
if (expiresAt <= now) {
|
|
124
|
+
throw new BackendStateCacheError(`computed cache expiry (${new Date(expiresAt).toISOString()}, from ${source}) is not in the future — refusing to cache a dead session`);
|
|
125
|
+
}
|
|
126
|
+
const entry = { storageState: result.storageState, writtenAt: now, expiresAt };
|
|
127
|
+
const envelope = {
|
|
128
|
+
...this.encrypt(JSON.stringify(entry)),
|
|
129
|
+
expires_at: expiresAt,
|
|
130
|
+
};
|
|
131
|
+
const res = await this.doFetch(this.url(role), {
|
|
132
|
+
method: "PUT",
|
|
133
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
134
|
+
body: JSON.stringify(envelope),
|
|
135
|
+
});
|
|
136
|
+
if (!res.ok) {
|
|
137
|
+
throw new BackendClientError(`PUT auth-cache(${role}) → ${res.status}`, res.status);
|
|
138
|
+
}
|
|
139
|
+
return { expiresAt, source };
|
|
140
|
+
}
|
|
141
|
+
async clear(role) {
|
|
142
|
+
const res = await this.doFetch(this.url(role), {
|
|
143
|
+
method: "DELETE",
|
|
144
|
+
headers: this.headers(),
|
|
145
|
+
});
|
|
146
|
+
if (!res.ok && res.status !== 404) {
|
|
147
|
+
throw new BackendClientError(`DELETE auth-cache(${role}) → ${res.status}`, res.status);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
url(role) {
|
|
151
|
+
return `${this.baseUrl}/v1/workspaces/${encodeURIComponent(this.workspaceId)}/auth-cache/${encodeURIComponent(role)}`;
|
|
152
|
+
}
|
|
153
|
+
headers(extra) {
|
|
154
|
+
return {
|
|
155
|
+
authorization: `Bearer ${this.token}`,
|
|
156
|
+
[API_VERSION_HEADER]: API_VERSION,
|
|
157
|
+
...(extra ?? {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
encrypt(plain) {
|
|
161
|
+
const iv = randomBytes(12);
|
|
162
|
+
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
|
163
|
+
const ciphertext = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
164
|
+
return {
|
|
165
|
+
schema: "docsxai/auth-cache@1",
|
|
166
|
+
alg: "aes-256-gcm",
|
|
167
|
+
iv: iv.toString("base64"),
|
|
168
|
+
ciphertext: ciphertext.toString("base64"),
|
|
169
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
decrypt(envelope) {
|
|
173
|
+
try {
|
|
174
|
+
const decipher = createDecipheriv("aes-256-gcm", this.key, Buffer.from(envelope.iv, "base64"));
|
|
175
|
+
decipher.setAuthTag(Buffer.from(envelope.tag, "base64"));
|
|
176
|
+
return Buffer.concat([
|
|
177
|
+
decipher.update(Buffer.from(envelope.ciphertext, "base64")),
|
|
178
|
+
decipher.final(),
|
|
179
|
+
]).toString("utf8");
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
throw new BackendStateCacheError("auth-cache decryption failed — wrong DOCSX_CACHE_KEY or tampered ciphertext");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type BackendTokenFile } from "./backend-client-contracts.js";
|
|
2
|
+
export declare function loadBackendTokenFile(workspaceDir: string): Promise<BackendTokenFile | null>;
|
|
3
|
+
/** Persist OAuth tokens under the workspace's `.auth/` (operator-local; gitignored), mode 0600. */
|
|
4
|
+
export declare function saveBackendTokenFile(workspaceDir: string, tokens: BackendTokenFile): Promise<string>;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the bearer token for a backend call, in priority order:
|
|
7
|
+
* 1. the explicit `token` option,
|
|
8
|
+
* 2. the `DOCSX_TOKEN` env var (the CI path),
|
|
9
|
+
* 3. the workspace's stored OAuth tokens (`.auth/backend-token.json`), refreshing them against
|
|
10
|
+
* the backend when expired (rotated tokens are written back to the file).
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveBackendToken(opts: {
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
token?: string;
|
|
15
|
+
workspaceDir?: string;
|
|
16
|
+
fetch?: typeof globalThis.fetch;
|
|
17
|
+
now?: number;
|
|
18
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Bearer-token resolution for backend calls: the stored OAuth token file (`.auth/backend-token.json`),
|
|
2
|
+
// the resolution precedence (explicit → `DOCSX_TOKEN` → stored, with refresh-rotation), and the
|
|
3
|
+
// load/save helpers. Re-exported from `./backend-client.js`.
|
|
4
|
+
import { promises as fs } from "node:fs";
|
|
5
|
+
import { resolveWorkspacePath, resolveWorkspacePathReal } from "./workspace.js";
|
|
6
|
+
import { BackendClientError } from "./backend-client-contracts.js";
|
|
7
|
+
const BACKEND_TOKEN_FILE = "backend-token.json";
|
|
8
|
+
export async function loadBackendTokenFile(workspaceDir) {
|
|
9
|
+
let text;
|
|
10
|
+
try {
|
|
11
|
+
text = await fs.readFile(resolveWorkspacePath(workspaceDir, ".auth", BACKEND_TOKEN_FILE), "utf8");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(text);
|
|
18
|
+
if (typeof parsed.access_token === "string" &&
|
|
19
|
+
typeof parsed.refresh_token === "string" &&
|
|
20
|
+
typeof parsed.expires_at === "number") {
|
|
21
|
+
return {
|
|
22
|
+
access_token: parsed.access_token,
|
|
23
|
+
refresh_token: parsed.refresh_token,
|
|
24
|
+
expires_at: parsed.expires_at,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Persist OAuth tokens under the workspace's `.auth/` (operator-local; gitignored), mode 0600. */
|
|
34
|
+
export async function saveBackendTokenFile(workspaceDir, tokens) {
|
|
35
|
+
await fs.mkdir(resolveWorkspacePath(workspaceDir, ".auth"), { recursive: true });
|
|
36
|
+
const target = await resolveWorkspacePathReal(workspaceDir, ".auth", BACKEND_TOKEN_FILE);
|
|
37
|
+
await fs.writeFile(target, JSON.stringify(tokens, null, 2) + "\n", {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
mode: 0o600,
|
|
40
|
+
});
|
|
41
|
+
await fs.chmod(target, 0o600);
|
|
42
|
+
return target;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the bearer token for a backend call, in priority order:
|
|
46
|
+
* 1. the explicit `token` option,
|
|
47
|
+
* 2. the `DOCSX_TOKEN` env var (the CI path),
|
|
48
|
+
* 3. the workspace's stored OAuth tokens (`.auth/backend-token.json`), refreshing them against
|
|
49
|
+
* the backend when expired (rotated tokens are written back to the file).
|
|
50
|
+
*/
|
|
51
|
+
export async function resolveBackendToken(opts) {
|
|
52
|
+
if (opts.token)
|
|
53
|
+
return opts.token;
|
|
54
|
+
if (process.env.DOCSX_TOKEN)
|
|
55
|
+
return process.env.DOCSX_TOKEN;
|
|
56
|
+
const reloginHint = `set DOCSX_TOKEN or run \`docsxai login --backend-url ${opts.baseUrl} --oauth <workspace-dir>\``;
|
|
57
|
+
if (!opts.workspaceDir) {
|
|
58
|
+
throw new BackendClientError(`no bearer token — ${reloginHint}`);
|
|
59
|
+
}
|
|
60
|
+
const stored = await loadBackendTokenFile(opts.workspaceDir);
|
|
61
|
+
if (!stored) {
|
|
62
|
+
throw new BackendClientError(`no bearer token — ${reloginHint}`);
|
|
63
|
+
}
|
|
64
|
+
const now = opts.now ?? Date.now();
|
|
65
|
+
if (stored.expires_at > now + 30_000)
|
|
66
|
+
return stored.access_token;
|
|
67
|
+
// Expired (or about to) — attempt a refresh-token rotation.
|
|
68
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
69
|
+
const base = opts.baseUrl.replace(/\/+$/, "");
|
|
70
|
+
let refreshed;
|
|
71
|
+
try {
|
|
72
|
+
const res = await doFetch(`${base}/v1/oauth/token`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
75
|
+
body: new URLSearchParams({
|
|
76
|
+
grant_type: "refresh_token",
|
|
77
|
+
refresh_token: stored.refresh_token,
|
|
78
|
+
}).toString(),
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok)
|
|
81
|
+
throw new BackendClientError(`token refresh → ${res.status}`, res.status);
|
|
82
|
+
refreshed = (await res.json());
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
throw new BackendClientError(`stored backend token expired and the refresh failed (${e.message}) — ${reloginHint}`);
|
|
86
|
+
}
|
|
87
|
+
const tokens = {
|
|
88
|
+
access_token: refreshed.access_token,
|
|
89
|
+
refresh_token: refreshed.refresh_token,
|
|
90
|
+
expires_at: now + refreshed.expires_in * 1000,
|
|
91
|
+
};
|
|
92
|
+
await saveBackendTokenFile(opts.workspaceDir, tokens);
|
|
93
|
+
return tokens.access_token;
|
|
94
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type RevisionKind } from "./doc-pack.js";
|
|
2
|
+
import { type BackendClientOptions, type BlobRef, type Project, type Revision, type RevisionArtifact, type RunRecord, type Workspace } from "./backend-client-contracts.js";
|
|
3
|
+
export declare class BackendClient {
|
|
4
|
+
private readonly baseUrl;
|
|
5
|
+
private readonly token;
|
|
6
|
+
private readonly doFetch;
|
|
7
|
+
constructor(opts: BackendClientOptions);
|
|
8
|
+
private headers;
|
|
9
|
+
private req;
|
|
10
|
+
health(): Promise<{
|
|
11
|
+
ok: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
listWorkspaces(): Promise<Workspace[]>;
|
|
14
|
+
createWorkspace(name: string): Promise<Workspace>;
|
|
15
|
+
getWorkspace(id: string): Promise<Workspace>;
|
|
16
|
+
listProjects(wsId: string): Promise<Project[]>;
|
|
17
|
+
createProject(wsId: string, name: string): Promise<Project>;
|
|
18
|
+
getProject(wsId: string, projectId: string): Promise<Project>;
|
|
19
|
+
listRevisions(wsId: string, projectId: string): Promise<Revision[]>;
|
|
20
|
+
createRevision(wsId: string, projectId: string, body: {
|
|
21
|
+
kind: RevisionKind;
|
|
22
|
+
author: string;
|
|
23
|
+
}): Promise<Revision>;
|
|
24
|
+
getRevision(wsId: string, projectId: string, rev: string): Promise<Revision>;
|
|
25
|
+
/** Finalize a revision (idempotent). Artifact PUTs afterwards are rejected with 409. */
|
|
26
|
+
finalizeRevision(wsId: string, projectId: string, rev: string): Promise<Revision>;
|
|
27
|
+
/** PUT an artifact's payload on a revision. The backend treats the payload as opaque JSON. */
|
|
28
|
+
putArtifact(wsId: string, projectId: string, rev: string, artifact: RevisionArtifact, payload: unknown): Promise<void>;
|
|
29
|
+
getArtifact<T = unknown>(wsId: string, projectId: string, rev: string, artifact: RevisionArtifact): Promise<T>;
|
|
30
|
+
appendRun(wsId: string, projectId: string, rec: {
|
|
31
|
+
rev: string;
|
|
32
|
+
ok: boolean;
|
|
33
|
+
duration_ms: number;
|
|
34
|
+
summary: string;
|
|
35
|
+
}): Promise<RunRecord>;
|
|
36
|
+
listRuns(wsId: string, projectId: string): Promise<RunRecord[]>;
|
|
37
|
+
/** Upload raw bytes; the backend stores them under their sha256. Idempotent. */
|
|
38
|
+
putBlob(data: Uint8Array): Promise<BlobRef>;
|
|
39
|
+
/** HEAD-probe a blob — true when the backend already has these bytes. */
|
|
40
|
+
hasBlob(sha256: string): Promise<boolean>;
|
|
41
|
+
getBlob(sha256: string): Promise<Uint8Array>;
|
|
42
|
+
}
|
|
43
|
+
/** Build a {@link BackendClient} with the token resolved via {@link resolveBackendToken}. */
|
|
44
|
+
export declare function createBackendClient(opts: BackendClientOptions & {
|
|
45
|
+
workspaceDir?: string;
|
|
46
|
+
}): Promise<BackendClient>;
|
|
47
|
+
/**
|
|
48
|
+
* Append an execution-run record for a backend-bound workspace. A no-op when the workspace config
|
|
49
|
+
* lacks the backend binding; never throws — `docsxai run` must stay offline-tolerant, so failures
|
|
50
|
+
* come back as a warning string for the caller to surface.
|
|
51
|
+
*/
|
|
52
|
+
export declare function recordRunHistory(opts: {
|
|
53
|
+
workspaceDir: string;
|
|
54
|
+
config: {
|
|
55
|
+
backend_url?: string;
|
|
56
|
+
backend_workspace_id?: string;
|
|
57
|
+
backend_project_id?: string;
|
|
58
|
+
};
|
|
59
|
+
ok: boolean;
|
|
60
|
+
durationMs: number;
|
|
61
|
+
summary: string;
|
|
62
|
+
fetch?: typeof globalThis.fetch;
|
|
63
|
+
}): Promise<{
|
|
64
|
+
recorded: boolean;
|
|
65
|
+
warning?: string;
|
|
66
|
+
}>;
|