@fml-inc/fml 0.7.0 → 0.8.0-rc.2
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/.claude-plugin/plugin.json +2 -4
- package/README.md +323 -79
- package/agent-assets/fml/SKILL.md +180 -0
- package/bin/fml +4 -18
- package/bin/hook-handler +7 -0
- package/bin/mcp-server +4 -18
- package/bin/observer +6 -0
- package/dist/chunk-3KISRT57.js +140 -0
- package/dist/chunk-56TPYMNK.js +4657 -0
- package/dist/chunk-7V7G7NVF.js +142 -0
- package/dist/chunk-A6HAEVJX.js +1606 -0
- package/dist/chunk-DZKYW327.js +1037 -0
- package/dist/chunk-ENW2GJNO.js +109 -0
- package/dist/{chunk-PZ5AY32C.js → chunk-MLKGABMK.js} +0 -1
- package/dist/{chunk-PTUZMKSN.js → chunk-R45CWWZO.js} +34 -23
- package/dist/chunk-WZLLO563.js +953 -0
- package/dist/chunk-YSU5CS2V.js +3331 -0
- package/dist/cli.js +7742 -1072
- package/dist/{esm-2EUDFZWP.js → esm-4YKLKFRX.js} +546 -741
- package/dist/hook-handler.js +440 -0
- package/dist/mcp/server.js +112 -98
- package/dist/observer-server.js +1566 -0
- package/dist/sync-cycle.js +31 -0
- package/dist/{token-store-3IQSLOTQ.js → token-store-BT5THIAJ.js} +10 -3
- package/hooks/hooks.json +290 -0
- package/package.json +24 -27
- package/dist/chunk-PTUZMKSN.js.map +0 -1
- package/dist/chunk-PZ5AY32C.js.map +0 -1
- package/dist/chunk-S4TJNLEN.js +0 -61
- package/dist/chunk-S4TJNLEN.js.map +0 -1
- package/dist/chunk-VV4JHH4H.js +0 -437
- package/dist/chunk-VV4JHH4H.js.map +0 -1
- package/dist/chunk-YH5ROR7G.js +0 -69
- package/dist/chunk-YH5ROR7G.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/daemon-MSLDHLMH.js +0 -21
- package/dist/daemon-MSLDHLMH.js.map +0 -1
- package/dist/daemon-utils-OODBIYB7.js +0 -10
- package/dist/daemon-utils-OODBIYB7.js.map +0 -1
- package/dist/esm-2EUDFZWP.js.map +0 -1
- package/dist/mcp/server.js.map +0 -1
- package/dist/postinstall.js +0 -16
- package/dist/postinstall.js.map +0 -1
- package/dist/token-store-3IQSLOTQ.js.map +0 -1
- package/scripts/postinstall.cjs +0 -25
|
@@ -0,0 +1,1606 @@
|
|
|
1
|
+
// src/auth/token-store.ts
|
|
2
|
+
import { createHash, randomUUID } from "crypto";
|
|
3
|
+
import { execFileSync } from "child_process";
|
|
4
|
+
import fs3 from "fs";
|
|
5
|
+
import path3 from "path";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import fs2 from "fs";
|
|
9
|
+
import path2 from "path";
|
|
10
|
+
|
|
11
|
+
// src/dirs.ts
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import os from "os";
|
|
14
|
+
import path from "path";
|
|
15
|
+
function isPresent(value) {
|
|
16
|
+
return value !== void 0 && value.trim().length > 0;
|
|
17
|
+
}
|
|
18
|
+
function normalizeAbsolutePath(value, variableName, pathApi) {
|
|
19
|
+
if (!pathApi.isAbsolute(value)) {
|
|
20
|
+
throw new Error(`${variableName} must be an absolute path: ${value}`);
|
|
21
|
+
}
|
|
22
|
+
return pathApi.normalize(value);
|
|
23
|
+
}
|
|
24
|
+
function explicitDirectory(value, variableName, pathApi) {
|
|
25
|
+
if (!isPresent(value)) return void 0;
|
|
26
|
+
return normalizeAbsolutePath(value, variableName, pathApi);
|
|
27
|
+
}
|
|
28
|
+
function platformBaseDirectory(value, pathApi) {
|
|
29
|
+
if (!isPresent(value) || !pathApi.isAbsolute(value)) return void 0;
|
|
30
|
+
return pathApi.normalize(value);
|
|
31
|
+
}
|
|
32
|
+
function resolveFmlDirectories(options = {}) {
|
|
33
|
+
const platform = options.platform ?? process.platform;
|
|
34
|
+
const env = options.env ?? process.env;
|
|
35
|
+
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
|
36
|
+
const homeDir = normalizeAbsolutePath(
|
|
37
|
+
options.homeDir ?? os.homedir(),
|
|
38
|
+
"home directory",
|
|
39
|
+
pathApi
|
|
40
|
+
);
|
|
41
|
+
const explicitDataDir = explicitDirectory(
|
|
42
|
+
env.FML_DATA_DIR,
|
|
43
|
+
"FML_DATA_DIR",
|
|
44
|
+
pathApi
|
|
45
|
+
);
|
|
46
|
+
const explicitStateDir = explicitDirectory(
|
|
47
|
+
env.FML_STATE_DIR,
|
|
48
|
+
"FML_STATE_DIR",
|
|
49
|
+
pathApi
|
|
50
|
+
);
|
|
51
|
+
const explicitLogDir = explicitDirectory(
|
|
52
|
+
env.FML_LOG_DIR,
|
|
53
|
+
"FML_LOG_DIR",
|
|
54
|
+
pathApi
|
|
55
|
+
);
|
|
56
|
+
let defaultDataDir;
|
|
57
|
+
let defaultStateDir;
|
|
58
|
+
let nativeLogDir;
|
|
59
|
+
switch (platform) {
|
|
60
|
+
case "darwin": {
|
|
61
|
+
defaultDataDir = pathApi.join(
|
|
62
|
+
homeDir,
|
|
63
|
+
"Library",
|
|
64
|
+
"Application Support",
|
|
65
|
+
"fml"
|
|
66
|
+
);
|
|
67
|
+
defaultStateDir = defaultDataDir;
|
|
68
|
+
nativeLogDir = pathApi.join(homeDir, "Library", "Logs", "fml");
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "win32": {
|
|
72
|
+
const roamingAppData = platformBaseDirectory(env.APPDATA, pathApi) ?? pathApi.join(homeDir, "AppData", "Roaming");
|
|
73
|
+
const localAppData = platformBaseDirectory(env.LOCALAPPDATA, pathApi) ?? pathApi.join(homeDir, "AppData", "Local");
|
|
74
|
+
defaultDataDir = pathApi.join(roamingAppData, "fml");
|
|
75
|
+
defaultStateDir = pathApi.join(localAppData, "fml");
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
default: {
|
|
79
|
+
const xdgDataHome = platformBaseDirectory(env.XDG_DATA_HOME, pathApi) ?? pathApi.join(homeDir, ".local", "share");
|
|
80
|
+
const xdgStateHome = platformBaseDirectory(env.XDG_STATE_HOME, pathApi) ?? pathApi.join(homeDir, ".local", "state");
|
|
81
|
+
defaultDataDir = pathApi.join(xdgDataHome, "fml");
|
|
82
|
+
defaultStateDir = pathApi.join(xdgStateHome, "fml");
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const dataDir = explicitDataDir ?? defaultDataDir;
|
|
87
|
+
const stateDir = explicitStateDir ?? explicitDataDir ?? defaultStateDir;
|
|
88
|
+
const defaultLogDir = nativeLogDir ?? (platform === "win32" ? pathApi.join(defaultDataDir, "logs") : pathApi.join(defaultStateDir, "log"));
|
|
89
|
+
const explicitObserverDir = explicitDirectory(
|
|
90
|
+
env.FML_OBSERVER_DIR,
|
|
91
|
+
"FML_OBSERVER_DIR",
|
|
92
|
+
pathApi
|
|
93
|
+
);
|
|
94
|
+
const legacyPanopticonHome = explicitDirectory(
|
|
95
|
+
env.PANOPTICON_HOME,
|
|
96
|
+
"PANOPTICON_HOME",
|
|
97
|
+
pathApi
|
|
98
|
+
);
|
|
99
|
+
const fmlHome = explicitDirectory(env.FML_HOME, "FML_HOME", pathApi);
|
|
100
|
+
return {
|
|
101
|
+
dataDir,
|
|
102
|
+
stateDir,
|
|
103
|
+
logDir: explicitLogDir ?? defaultLogDir,
|
|
104
|
+
observerDir: explicitObserverDir ?? legacyPanopticonHome ?? (fmlHome ? pathApi.join(fmlHome, "panopticon") : pathApi.join(homeDir, ".fml", "panopticon"))
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function resolveFmlDefaultDirectories(options = {}) {
|
|
108
|
+
const env = options.env ?? process.env;
|
|
109
|
+
return resolveFmlDirectories({
|
|
110
|
+
...options,
|
|
111
|
+
env: {
|
|
112
|
+
APPDATA: env.APPDATA,
|
|
113
|
+
LOCALAPPDATA: env.LOCALAPPDATA,
|
|
114
|
+
XDG_DATA_HOME: env.XDG_DATA_HOME,
|
|
115
|
+
XDG_STATE_HOME: env.XDG_STATE_HOME
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function observerPort() {
|
|
120
|
+
const configured = process.env.FML_OBSERVER_PORT ?? process.env.PANOPTICON_PORT;
|
|
121
|
+
if (configured) {
|
|
122
|
+
const parsed = Number.parseInt(configured, 10);
|
|
123
|
+
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
|
|
124
|
+
return parsed;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return 4318 + (process.getuid?.() ?? 0) % 100;
|
|
128
|
+
}
|
|
129
|
+
var DIRECTORIES = resolveFmlDirectories();
|
|
130
|
+
var FML_DATA_DIR = DIRECTORIES.dataDir;
|
|
131
|
+
var FML_STATE_DIR = DIRECTORIES.stateDir;
|
|
132
|
+
var FML_LOG_DIR = DIRECTORIES.logDir;
|
|
133
|
+
var FML_OBSERVER_DIR = DIRECTORIES.observerDir;
|
|
134
|
+
var FML_OBSERVER_HOST = "127.0.0.1";
|
|
135
|
+
var FML_OBSERVER_PORT = observerPort();
|
|
136
|
+
var FML_OBSERVER_STATE_FILE = path.join(
|
|
137
|
+
FML_OBSERVER_DIR,
|
|
138
|
+
"observer.pid.json"
|
|
139
|
+
);
|
|
140
|
+
var FML_OBSERVER_LOCK_FILE = path.join(
|
|
141
|
+
FML_OBSERVER_DIR,
|
|
142
|
+
"observer.lock"
|
|
143
|
+
);
|
|
144
|
+
var FML_OBSERVER_AUTH_TOKEN_FILE = path.join(
|
|
145
|
+
FML_OBSERVER_DIR,
|
|
146
|
+
"auth-token"
|
|
147
|
+
);
|
|
148
|
+
var FML_OBSERVER_ENDPOINT_FILE = path.join(
|
|
149
|
+
FML_OBSERVER_DIR,
|
|
150
|
+
"endpoint.json"
|
|
151
|
+
);
|
|
152
|
+
var FML_OBSERVER_LOG_FILE = path.join(FML_LOG_DIR, "observer.log");
|
|
153
|
+
var FML_INSTALLATION_ID_FILE = path.join(
|
|
154
|
+
FML_STATE_DIR,
|
|
155
|
+
"installation_id"
|
|
156
|
+
);
|
|
157
|
+
var FML_PANOPTICON_DB_FILE = path.join(FML_STATE_DIR, "panopticon.db");
|
|
158
|
+
var FML_PANOPTICON_DB_OBSERVER_STATE_FILE = `${FML_PANOPTICON_DB_FILE}.observer.pid.json`;
|
|
159
|
+
var FML_PANOPTICON_DB_OBSERVER_LOCK_FILE = `${FML_PANOPTICON_DB_FILE}.observer.lock`;
|
|
160
|
+
var FML_LEGACY_OBSERVER_DB_FILE = path.join(
|
|
161
|
+
FML_OBSERVER_DIR,
|
|
162
|
+
"observer.db"
|
|
163
|
+
);
|
|
164
|
+
var FML_HOOK_INBOX_DIR = path.join(FML_OBSERVER_DIR, "hook-inbox");
|
|
165
|
+
var FML_HOOK_CONTEXT_STATE_DIR = path.join(
|
|
166
|
+
FML_OBSERVER_DIR,
|
|
167
|
+
"hook-context-state"
|
|
168
|
+
);
|
|
169
|
+
var FML_HOOK_CAPTURE_DISABLED_FILE = path.join(
|
|
170
|
+
FML_OBSERVER_DIR,
|
|
171
|
+
"hook-capture.disabled"
|
|
172
|
+
);
|
|
173
|
+
function observerOnlyOverrideWarning(env = process.env, databaseFile = FML_PANOPTICON_DB_FILE) {
|
|
174
|
+
if (isPresent(env.FML_STATE_DIR) || isPresent(env.FML_DATA_DIR)) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const observerAlias = ["FML_OBSERVER_DIR", "PANOPTICON_HOME", "FML_HOME"].find((variableName) => isPresent(env[variableName]));
|
|
178
|
+
if (!observerAlias) return null;
|
|
179
|
+
return `${observerAlias} relocates only observer coordination state; the FML database stays at ${databaseFile}. Set FML_STATE_DIR (or FML_DATA_DIR, including its platform default) to choose the database root explicitly.`;
|
|
180
|
+
}
|
|
181
|
+
function ensureDirs() {
|
|
182
|
+
fs.mkdirSync(FML_DATA_DIR, { recursive: true, mode: 448 });
|
|
183
|
+
fs.mkdirSync(FML_STATE_DIR, { recursive: true, mode: 448 });
|
|
184
|
+
fs.mkdirSync(FML_LOG_DIR, { recursive: true, mode: 448 });
|
|
185
|
+
fs.mkdirSync(FML_OBSERVER_DIR, { recursive: true, mode: 448 });
|
|
186
|
+
fs.mkdirSync(FML_HOOK_INBOX_DIR, { recursive: true, mode: 448 });
|
|
187
|
+
fs.mkdirSync(FML_HOOK_CONTEXT_STATE_DIR, {
|
|
188
|
+
recursive: true,
|
|
189
|
+
mode: 448
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/config.ts
|
|
194
|
+
var DEFAULT_PROD_URL = "https://trustworthy-chihuahua-382.convex.cloud";
|
|
195
|
+
var DEFAULT_SYNC_URL = DEFAULT_PROD_URL.replace(
|
|
196
|
+
".convex.cloud",
|
|
197
|
+
".convex.site"
|
|
198
|
+
);
|
|
199
|
+
var DEFAULT_TARGET_NAME = "fml";
|
|
200
|
+
var ENV_CONFIG_PATH = path2.join(FML_DATA_DIR, "env.json");
|
|
201
|
+
var ENV_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
202
|
+
function isValidEnvName(envName) {
|
|
203
|
+
return ENV_NAME_RE.test(envName);
|
|
204
|
+
}
|
|
205
|
+
function normalizeConvexUrl(value) {
|
|
206
|
+
const url = new URL(value);
|
|
207
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
|
|
208
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username || url.password || url.search || url.hash || url.pathname !== "" && url.pathname !== "/") {
|
|
209
|
+
throw new Error(
|
|
210
|
+
"FML environment URL must be HTTPS (HTTP is allowed only for loopback) with no path, credentials, query, or fragment"
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return value.replace(/\/$/, "").replace(".convex.site", ".convex.cloud");
|
|
214
|
+
}
|
|
215
|
+
function readEnvConfig() {
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(
|
|
218
|
+
fs2.readFileSync(ENV_CONFIG_PATH, "utf8")
|
|
219
|
+
);
|
|
220
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
221
|
+
throw new Error("invalid environment config");
|
|
222
|
+
}
|
|
223
|
+
const config = parsed;
|
|
224
|
+
if (typeof config.active !== "string" || !isValidEnvName(config.active)) {
|
|
225
|
+
throw new Error("invalid active environment");
|
|
226
|
+
}
|
|
227
|
+
const environments = {};
|
|
228
|
+
if (config.environments && typeof config.environments === "object" && !Array.isArray(config.environments)) {
|
|
229
|
+
for (const [name, environment] of Object.entries(
|
|
230
|
+
config.environments
|
|
231
|
+
)) {
|
|
232
|
+
if (!isValidEnvName(name) || !environment || typeof environment !== "object" || Array.isArray(environment)) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const convexUrl = environment.convexUrl;
|
|
236
|
+
if (typeof convexUrl !== "string") continue;
|
|
237
|
+
try {
|
|
238
|
+
environments[name] = { convexUrl: normalizeConvexUrl(convexUrl) };
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
schemaVersion: 1,
|
|
245
|
+
active: config.active,
|
|
246
|
+
environments
|
|
247
|
+
};
|
|
248
|
+
} catch {
|
|
249
|
+
return {
|
|
250
|
+
schemaVersion: 1,
|
|
251
|
+
active: DEFAULT_TARGET_NAME,
|
|
252
|
+
environments: {
|
|
253
|
+
[DEFAULT_TARGET_NAME]: { convexUrl: DEFAULT_PROD_URL }
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function writeEnvConfig(config) {
|
|
259
|
+
const dir = path2.dirname(ENV_CONFIG_PATH);
|
|
260
|
+
fs2.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
261
|
+
fs2.writeFileSync(
|
|
262
|
+
ENV_CONFIG_PATH,
|
|
263
|
+
`${JSON.stringify({ ...config, schemaVersion: 1 }, null, 2)}
|
|
264
|
+
`,
|
|
265
|
+
{ mode: 384 }
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
function envConfigExists() {
|
|
269
|
+
return fs2.existsSync(ENV_CONFIG_PATH);
|
|
270
|
+
}
|
|
271
|
+
function resolveEnvConvexUrl(envName) {
|
|
272
|
+
const configured = readEnvConfig().environments?.[envName]?.convexUrl;
|
|
273
|
+
if (configured) return configured;
|
|
274
|
+
if (envName === DEFAULT_TARGET_NAME) return DEFAULT_PROD_URL;
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
function setActiveEnvironment(name, convexUrl) {
|
|
278
|
+
if (!isValidEnvName(name)) {
|
|
279
|
+
throw new Error(`Invalid FML environment name: ${name}`);
|
|
280
|
+
}
|
|
281
|
+
const current = readEnvConfig();
|
|
282
|
+
const next = {
|
|
283
|
+
schemaVersion: 1,
|
|
284
|
+
active: name,
|
|
285
|
+
environments: {
|
|
286
|
+
...current.environments ?? {},
|
|
287
|
+
[name]: { convexUrl: normalizeConvexUrl(convexUrl) }
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
writeEnvConfig(next);
|
|
291
|
+
return next;
|
|
292
|
+
}
|
|
293
|
+
function getActiveEnv() {
|
|
294
|
+
const name = readEnvConfig().active;
|
|
295
|
+
const override = process.env.FML_CONVEX_URL;
|
|
296
|
+
return {
|
|
297
|
+
name,
|
|
298
|
+
convexUrl: override ? normalizeConvexUrl(override) : resolveEnvConvexUrl(name)
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function authStorePathFor(envName) {
|
|
302
|
+
return path2.join(FML_DATA_DIR, `auth.${envName}.json`);
|
|
303
|
+
}
|
|
304
|
+
var CONVEX_URL = (process.env.FML_CONVEX_URL ?? getActiveEnv().convexUrl ?? DEFAULT_PROD_URL).replace(/\/$/, "");
|
|
305
|
+
var WORKOS_API_URL = "https://api.workos.com";
|
|
306
|
+
var WORKOS_AUTH_URL = "https://auth.fml.inc";
|
|
307
|
+
function getSiteUrl() {
|
|
308
|
+
return getSiteUrlForConvexUrl(getActiveEnv().convexUrl);
|
|
309
|
+
}
|
|
310
|
+
function getSiteUrlForConvexUrl(convexUrl) {
|
|
311
|
+
const explicit = process.env.CONVEX_SITE_URL;
|
|
312
|
+
if (explicit) return explicit.replace(/\/$/, "");
|
|
313
|
+
return (convexUrl ?? DEFAULT_PROD_URL).replace(".convex.cloud", ".convex.site").replace(/\/$/, "");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/sentry.ts
|
|
317
|
+
var initialized = false;
|
|
318
|
+
var sentry = null;
|
|
319
|
+
var client = null;
|
|
320
|
+
async function loadSentry() {
|
|
321
|
+
if (sentry) return sentry;
|
|
322
|
+
try {
|
|
323
|
+
sentry = await import("./esm-4YKLKFRX.js");
|
|
324
|
+
return sentry;
|
|
325
|
+
} catch {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async function initSentry() {
|
|
330
|
+
if (initialized) return;
|
|
331
|
+
initialized = true;
|
|
332
|
+
const mod = await loadSentry();
|
|
333
|
+
if (!mod) return;
|
|
334
|
+
client = new mod.ServerRuntimeClient({
|
|
335
|
+
dsn: "https://4c243521c03917c886d0bf33bc0038c8@o4510167429873664.ingest.us.sentry.io/4511078213156864",
|
|
336
|
+
tracesSampleRate: 0,
|
|
337
|
+
integrations: [],
|
|
338
|
+
stackParser: mod.createStackParser(mod.nodeStackLineParser()),
|
|
339
|
+
transport: (options) => mod.createTransport(options, async (request) => {
|
|
340
|
+
const res = await fetch(options.url, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
headers: { "Content-Type": "application/x-sentry-envelope" },
|
|
343
|
+
body: request.body
|
|
344
|
+
});
|
|
345
|
+
return { statusCode: res.status };
|
|
346
|
+
})
|
|
347
|
+
});
|
|
348
|
+
mod.setCurrentClient(client);
|
|
349
|
+
client.init();
|
|
350
|
+
}
|
|
351
|
+
var Sentry = {
|
|
352
|
+
captureException: (err) => sentry?.captureException(err),
|
|
353
|
+
captureMessage: (msg) => sentry?.captureMessage(msg),
|
|
354
|
+
flush: async (timeoutMs) => {
|
|
355
|
+
try {
|
|
356
|
+
await client?.flush(timeoutMs);
|
|
357
|
+
} catch {
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// src/auth/token-store.ts
|
|
363
|
+
var TOKEN_REFRESH_WINDOW_MS = 6e4;
|
|
364
|
+
var OAUTH_REFRESH_LOCK_POLL_MS = 50;
|
|
365
|
+
var OAUTH_REFRESH_LOCK_STALE_MS = 45e3;
|
|
366
|
+
var OAUTH_REFRESH_RECOVERY_STALE_MS = 45e3;
|
|
367
|
+
var OAUTH_REFRESH_LOCK_WAIT_MS = 6e4;
|
|
368
|
+
var OAUTH_REFRESH_REQUEST_TIMEOUT_MS = 3e4;
|
|
369
|
+
var PROCESS_IDENTITY_STALE_MS = 45e3;
|
|
370
|
+
var PROCESS_IDENTITY_RECHECK_MS = 5e3;
|
|
371
|
+
var PROCESS_INSTANCE_KEY = /* @__PURE__ */ Symbol.for("@fml-inc/fml:auth-process-instance");
|
|
372
|
+
var PROCESS_IDENTITY_KEY = /* @__PURE__ */ Symbol.for("@fml-inc/fml:auth-process-identity");
|
|
373
|
+
var processGlobals = globalThis;
|
|
374
|
+
var existingProcessInstanceId = processGlobals[PROCESS_INSTANCE_KEY];
|
|
375
|
+
var CURRENT_PROCESS_INSTANCE_ID = typeof existingProcessInstanceId === "string" ? existingProcessInstanceId : randomUUID();
|
|
376
|
+
processGlobals[PROCESS_INSTANCE_KEY] = CURRENT_PROCESS_INSTANCE_ID;
|
|
377
|
+
function getCurrentProcessIdentity() {
|
|
378
|
+
let cache = processGlobals[PROCESS_IDENTITY_KEY];
|
|
379
|
+
if (!cache) {
|
|
380
|
+
cache = { resolved: false };
|
|
381
|
+
processGlobals[PROCESS_IDENTITY_KEY] = cache;
|
|
382
|
+
}
|
|
383
|
+
if (!cache.resolved) {
|
|
384
|
+
cache.value = getProcessIdentity(process.pid) ?? void 0;
|
|
385
|
+
cache.resolved = true;
|
|
386
|
+
}
|
|
387
|
+
return cache.value;
|
|
388
|
+
}
|
|
389
|
+
function storePathFor(envName) {
|
|
390
|
+
return authStorePathFor(envName ?? getActiveEnv().name);
|
|
391
|
+
}
|
|
392
|
+
function resolveEnvName(envName) {
|
|
393
|
+
return envName ?? getActiveEnv().name;
|
|
394
|
+
}
|
|
395
|
+
function ensureDir(envName) {
|
|
396
|
+
const dir = path3.dirname(storePathFor(envName));
|
|
397
|
+
fs3.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
398
|
+
try {
|
|
399
|
+
fs3.chmodSync(dir, 448);
|
|
400
|
+
} catch {
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function writeSecretFile(filePath, data) {
|
|
404
|
+
const temporaryFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
405
|
+
let descriptor = null;
|
|
406
|
+
try {
|
|
407
|
+
descriptor = fs3.openSync(temporaryFile, "wx", 384);
|
|
408
|
+
fs3.writeFileSync(descriptor, data, { encoding: "utf8" });
|
|
409
|
+
fs3.fsyncSync(descriptor);
|
|
410
|
+
fs3.closeSync(descriptor);
|
|
411
|
+
descriptor = null;
|
|
412
|
+
fs3.renameSync(temporaryFile, filePath);
|
|
413
|
+
try {
|
|
414
|
+
fs3.chmodSync(filePath, 384);
|
|
415
|
+
} catch {
|
|
416
|
+
}
|
|
417
|
+
} finally {
|
|
418
|
+
if (descriptor !== null) {
|
|
419
|
+
try {
|
|
420
|
+
fs3.closeSync(descriptor);
|
|
421
|
+
} catch {
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
fs3.unlinkSync(temporaryFile);
|
|
426
|
+
} catch {
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function readTokens(envName) {
|
|
431
|
+
let raw;
|
|
432
|
+
try {
|
|
433
|
+
raw = fs3.readFileSync(storePathFor(envName), "utf-8");
|
|
434
|
+
} catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
return parseStoredAuthShape(JSON.parse(raw));
|
|
439
|
+
} catch {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
var CorruptStoredCredentialsError = class extends Error {
|
|
444
|
+
};
|
|
445
|
+
function parseStoredAuthShape(parsed) {
|
|
446
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
const auth = parsed;
|
|
450
|
+
const user = auth.user;
|
|
451
|
+
if (typeof auth.accessToken !== "string" || typeof auth.refreshToken !== "string" || typeof auth.expiresAt !== "number" || !Number.isFinite(auth.expiresAt) || !user || typeof user.id !== "string" || typeof user.email !== "string" || typeof user.name !== "string" || auth.orgId !== void 0 && typeof auth.orgId !== "string" || auth.orgSlug !== void 0 && typeof auth.orgSlug !== "string" || auth.workosClientId !== void 0 && typeof auth.workosClientId !== "string" || auth.tokenType !== void 0 && auth.tokenType !== "oauth" && auth.tokenType !== "service" || auth.refreshInvalidAt !== void 0 && (typeof auth.refreshInvalidAt !== "number" || !Number.isFinite(auth.refreshInvalidAt))) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
return auth;
|
|
455
|
+
}
|
|
456
|
+
function readTokensForReconciliation(envName) {
|
|
457
|
+
let raw;
|
|
458
|
+
try {
|
|
459
|
+
raw = fs3.readFileSync(storePathFor(envName), "utf8");
|
|
460
|
+
} catch (error) {
|
|
461
|
+
if (error.code === "ENOENT") return null;
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
let parsed;
|
|
465
|
+
try {
|
|
466
|
+
parsed = JSON.parse(raw);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
throw new CorruptStoredCredentialsError(
|
|
469
|
+
"Stored credentials are not valid JSON",
|
|
470
|
+
{ cause: error }
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const auth = parseStoredAuthShape(parsed);
|
|
474
|
+
if (!auth) {
|
|
475
|
+
throw new CorruptStoredCredentialsError(
|
|
476
|
+
"Stored credentials have an invalid shape"
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return auth;
|
|
480
|
+
}
|
|
481
|
+
function writeTokensUnlocked(auth, envName) {
|
|
482
|
+
ensureDir(envName);
|
|
483
|
+
writeSecretFile(storePathFor(envName), JSON.stringify(auth, null, 2));
|
|
484
|
+
}
|
|
485
|
+
async function writeTokens(auth, envName) {
|
|
486
|
+
const resolvedEnvName = resolveEnvName(envName);
|
|
487
|
+
await withRefreshLock(
|
|
488
|
+
resolvedEnvName,
|
|
489
|
+
() => writeTokensUnlocked(auth, resolvedEnvName),
|
|
490
|
+
{ unreadableStorePolicy: "discard-staged" }
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
function getSelectedOrg(envName) {
|
|
494
|
+
return readTokens(envName)?.orgSlug ?? null;
|
|
495
|
+
}
|
|
496
|
+
function getSelectedOrgId(envName) {
|
|
497
|
+
return readTokens(envName)?.orgId ?? null;
|
|
498
|
+
}
|
|
499
|
+
async function setSelectedOrg(orgSlug, orgId, envName) {
|
|
500
|
+
const resolvedEnvName = resolveEnvName(envName);
|
|
501
|
+
await withRefreshLock(resolvedEnvName, () => {
|
|
502
|
+
const stored = readTokens(resolvedEnvName);
|
|
503
|
+
if (!stored) return;
|
|
504
|
+
writeTokensUnlocked({ ...stored, orgId, orgSlug }, resolvedEnvName);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
function credentialHash(value) {
|
|
508
|
+
return createHash("sha256").update(value).digest("hex");
|
|
509
|
+
}
|
|
510
|
+
function describeError(error) {
|
|
511
|
+
return error instanceof Error ? error.message : String(error);
|
|
512
|
+
}
|
|
513
|
+
function getSyncDestinationAuthBinding(envName) {
|
|
514
|
+
const resolvedEnvName = resolveEnvName(envName);
|
|
515
|
+
const envToken = process.env.FML_TOKEN;
|
|
516
|
+
if (envToken?.startsWith("fml_srt_")) {
|
|
517
|
+
return {
|
|
518
|
+
destinationIdentity: `service-refresh:${credentialHash(envToken)}`
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
if (envToken?.startsWith("fml_st_")) {
|
|
522
|
+
return {
|
|
523
|
+
destinationIdentity: `service-access:${credentialHash(envToken)}`
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
const stored = readTokens(resolvedEnvName);
|
|
527
|
+
if (!stored) return null;
|
|
528
|
+
if (stored.tokenType === "service") {
|
|
529
|
+
return {
|
|
530
|
+
destinationIdentity: `service-refresh:${credentialHash(stored.refreshToken)}`
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
const destinationIdentity = `oauth-user:${stored.user.id}`;
|
|
534
|
+
if (stored.orgId && stored.orgSlug) {
|
|
535
|
+
return {
|
|
536
|
+
destinationIdentity,
|
|
537
|
+
organizationId: stored.orgId,
|
|
538
|
+
organizationSlug: stored.orgSlug
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
return { destinationIdentity, organizationSelectionRequired: true };
|
|
542
|
+
}
|
|
543
|
+
function getSyncDestinationIdentity(envName) {
|
|
544
|
+
return getSyncDestinationAuthBinding(envName)?.destinationIdentity ?? null;
|
|
545
|
+
}
|
|
546
|
+
var SERVICE_TOKEN_LOGIN_USER_ID = "service-token";
|
|
547
|
+
var serviceTokenCaches = /* @__PURE__ */ new Map();
|
|
548
|
+
var serviceRefreshPromises = /* @__PURE__ */ new Map();
|
|
549
|
+
var oauthRefreshPromises = /* @__PURE__ */ new Map();
|
|
550
|
+
var cacheKey = (envName) => envName;
|
|
551
|
+
function waitForTokenRefresh(promise, signal) {
|
|
552
|
+
if (!signal) return promise;
|
|
553
|
+
if (signal.aborted) return Promise.resolve(null);
|
|
554
|
+
return new Promise((resolve, reject) => {
|
|
555
|
+
const onAbort = () => {
|
|
556
|
+
cleanup();
|
|
557
|
+
resolve(null);
|
|
558
|
+
};
|
|
559
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
560
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
561
|
+
void promise.then(
|
|
562
|
+
(value) => {
|
|
563
|
+
cleanup();
|
|
564
|
+
resolve(value);
|
|
565
|
+
},
|
|
566
|
+
(error) => {
|
|
567
|
+
cleanup();
|
|
568
|
+
reject(error);
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
function getOrStartTokenRefresh(flights, key, forceRefresh, refresh, signal) {
|
|
574
|
+
const existing = flights.get(key);
|
|
575
|
+
if (existing && (!forceRefresh || existing.forceRefresh)) {
|
|
576
|
+
return waitForTokenRefresh(existing.promise, signal);
|
|
577
|
+
}
|
|
578
|
+
const operation = existing ? existing.promise.then(refresh, refresh) : refresh();
|
|
579
|
+
let flight;
|
|
580
|
+
const promise = operation.finally(() => {
|
|
581
|
+
if (flights.get(key) === flight) flights.delete(key);
|
|
582
|
+
});
|
|
583
|
+
flight = { forceRefresh, promise };
|
|
584
|
+
flights.set(key, flight);
|
|
585
|
+
return waitForTokenRefresh(promise, signal);
|
|
586
|
+
}
|
|
587
|
+
function refreshLockPath(envName) {
|
|
588
|
+
return `${storePathFor(envName)}.refresh.lock`;
|
|
589
|
+
}
|
|
590
|
+
function pendingOAuthRefreshPrefix(envName) {
|
|
591
|
+
return `${path3.basename(storePathFor(envName))}.refresh-result.`;
|
|
592
|
+
}
|
|
593
|
+
function parsePendingOAuthRefresh(raw) {
|
|
594
|
+
try {
|
|
595
|
+
const parsed = JSON.parse(raw);
|
|
596
|
+
const refreshed = parsed.refreshed;
|
|
597
|
+
if (parsed.version !== 1 || typeof parsed.previousRefreshTokenHash !== "string" || !/^[a-f0-9]{64}$/.test(parsed.previousRefreshTokenHash) || typeof parsed.createdAtMs !== "number" || !Number.isFinite(parsed.createdAtMs) || !refreshed || typeof refreshed.accessToken !== "string" || typeof refreshed.refreshToken !== "string" || typeof refreshed.expiresAt !== "number" || !Number.isFinite(refreshed.expiresAt) || !refreshed.user || typeof refreshed.user.id !== "string" || typeof refreshed.user.email !== "string" || typeof refreshed.user.name !== "string") {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
return parsed;
|
|
601
|
+
} catch {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
function stagePendingOAuthRefresh(previousRefreshToken, refreshed, envName) {
|
|
606
|
+
ensureDir(envName);
|
|
607
|
+
const resultPath = `${storePathFor(envName)}.refresh-result.${Date.now()}.${randomUUID()}.json`;
|
|
608
|
+
const pending = {
|
|
609
|
+
version: 1,
|
|
610
|
+
previousRefreshTokenHash: credentialHash(previousRefreshToken),
|
|
611
|
+
refreshed,
|
|
612
|
+
createdAtMs: Date.now()
|
|
613
|
+
};
|
|
614
|
+
writeSecretFile(resultPath, `${JSON.stringify(pending)}
|
|
615
|
+
`);
|
|
616
|
+
return resultPath;
|
|
617
|
+
}
|
|
618
|
+
function removePendingOAuthRefresh(resultPath) {
|
|
619
|
+
try {
|
|
620
|
+
fs3.unlinkSync(resultPath);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (error.code !== "ENOENT") {
|
|
623
|
+
console.error(
|
|
624
|
+
`[fml] Auth: unable to remove a committed refresh result: ${error instanceof Error ? error.message : String(error)}`
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function reconcilePendingOAuthRefreshes(envName, unreadableStorePolicy = "preserve-staged") {
|
|
630
|
+
const storePath = storePathFor(envName);
|
|
631
|
+
const directory = path3.dirname(storePath);
|
|
632
|
+
const prefix = pendingOAuthRefreshPrefix(envName);
|
|
633
|
+
let resultPaths;
|
|
634
|
+
try {
|
|
635
|
+
resultPaths = fs3.readdirSync(directory).filter((entry) => entry.startsWith(prefix) && entry.endsWith(".json")).map((entry) => path3.join(directory, entry));
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (error.code === "ENOENT") return;
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
const pendingResults = resultPaths.map((resultPath) => {
|
|
641
|
+
let pending = null;
|
|
642
|
+
try {
|
|
643
|
+
pending = parsePendingOAuthRefresh(fs3.readFileSync(resultPath, "utf8"));
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (error.code !== "ENOENT") throw error;
|
|
646
|
+
}
|
|
647
|
+
return { resultPath, pending };
|
|
648
|
+
}).sort(
|
|
649
|
+
(left, right) => (left.pending?.createdAtMs ?? 0) - (right.pending?.createdAtMs ?? 0)
|
|
650
|
+
);
|
|
651
|
+
for (const { resultPath, pending } of pendingResults) {
|
|
652
|
+
if (!pending) {
|
|
653
|
+
removePendingOAuthRefresh(resultPath);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
let current;
|
|
657
|
+
try {
|
|
658
|
+
current = readTokensForReconciliation(envName);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
if (unreadableStorePolicy === "discard-staged" && error instanceof CorruptStoredCredentialsError) {
|
|
661
|
+
console.error(
|
|
662
|
+
`[fml] Auth: discarding a staged refresh result because the credential store is corrupt and is being replaced: ${error.message}`
|
|
663
|
+
);
|
|
664
|
+
removePendingOAuthRefresh(resultPath);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
if (current?.refreshToken === pending.refreshed.refreshToken || current && credentialHash(current.refreshToken) === pending.previousRefreshTokenHash) {
|
|
670
|
+
if (current.refreshToken !== pending.refreshed.refreshToken) {
|
|
671
|
+
writeTokensUnlocked(
|
|
672
|
+
{
|
|
673
|
+
...pending.refreshed,
|
|
674
|
+
orgId: current.orgId,
|
|
675
|
+
orgSlug: current.orgSlug
|
|
676
|
+
},
|
|
677
|
+
envName
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
removePendingOAuthRefresh(resultPath);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
function parseRefreshLockOwner(raw) {
|
|
685
|
+
try {
|
|
686
|
+
const parsed = JSON.parse(raw);
|
|
687
|
+
if (parsed.version !== 1 || typeof parsed.ownerId !== "string" || parsed.ownerId.length === 0 || typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0 || parsed.processIdentity !== void 0 && typeof parsed.processIdentity !== "string" || parsed.processInstanceId !== void 0 && typeof parsed.processInstanceId !== "string" || typeof parsed.createdAtMs !== "number" || !Number.isFinite(parsed.createdAtMs) || parsed.releasedAtMs !== void 0 && (typeof parsed.releasedAtMs !== "number" || !Number.isFinite(parsed.releasedAtMs))) {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
return parsed;
|
|
691
|
+
} catch {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function getProcessIdentity(pid) {
|
|
696
|
+
try {
|
|
697
|
+
if (process.platform === "linux") {
|
|
698
|
+
const stat = fs3.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
699
|
+
const fieldsAfterCommand = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
700
|
+
const startTimeTicks = fieldsAfterCommand[19];
|
|
701
|
+
return startTimeTicks ? `linux:${startTimeTicks}` : null;
|
|
702
|
+
}
|
|
703
|
+
if (process.platform === "darwin") {
|
|
704
|
+
const startedAt = execFileSync(
|
|
705
|
+
"ps",
|
|
706
|
+
["-p", String(pid), "-o", "lstart="],
|
|
707
|
+
{
|
|
708
|
+
encoding: "utf8",
|
|
709
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
710
|
+
timeout: 2e3
|
|
711
|
+
}
|
|
712
|
+
).trim();
|
|
713
|
+
return startedAt ? `darwin:${startedAt}` : null;
|
|
714
|
+
}
|
|
715
|
+
if (process.platform === "win32") {
|
|
716
|
+
const startedAt = execFileSync(
|
|
717
|
+
"powershell.exe",
|
|
718
|
+
[
|
|
719
|
+
"-NoProfile",
|
|
720
|
+
"-NonInteractive",
|
|
721
|
+
"-Command",
|
|
722
|
+
`(Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks`
|
|
723
|
+
],
|
|
724
|
+
{
|
|
725
|
+
encoding: "utf8",
|
|
726
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
727
|
+
timeout: 2e3
|
|
728
|
+
}
|
|
729
|
+
).trim();
|
|
730
|
+
return startedAt ? `win32:${startedAt}` : null;
|
|
731
|
+
}
|
|
732
|
+
} catch {
|
|
733
|
+
}
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
function parseRecoveryMarkerOwner(raw) {
|
|
737
|
+
try {
|
|
738
|
+
const parsed = JSON.parse(raw);
|
|
739
|
+
if (parsed.version !== 1 || typeof parsed.ownerId !== "string" || parsed.ownerId.length === 0 || typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0 || parsed.processIdentity !== void 0 && typeof parsed.processIdentity !== "string" || parsed.processInstanceId !== void 0 && typeof parsed.processInstanceId !== "string" || typeof parsed.createdAtMs !== "number" || !Number.isFinite(parsed.createdAtMs) || parsed.releasedAtMs !== void 0 && (typeof parsed.releasedAtMs !== "number" || !Number.isFinite(parsed.releasedAtMs))) {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
return parsed;
|
|
743
|
+
} catch {
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function snapshotLeaseFile(filePath) {
|
|
748
|
+
let stat;
|
|
749
|
+
try {
|
|
750
|
+
stat = fs3.lstatSync(filePath);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (error.code === "ENOENT") return null;
|
|
753
|
+
throw error;
|
|
754
|
+
}
|
|
755
|
+
if (stat.isDirectory()) {
|
|
756
|
+
try {
|
|
757
|
+
return {
|
|
758
|
+
stat,
|
|
759
|
+
raw: fs3.readFileSync(path3.join(filePath, "owner.json"), "utf8")
|
|
760
|
+
};
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (error.code === "ENOENT") {
|
|
763
|
+
return { stat, raw: "" };
|
|
764
|
+
}
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
return { stat, raw: fs3.readFileSync(filePath, "utf8") };
|
|
770
|
+
} catch (error) {
|
|
771
|
+
if (error.code === "ENOENT") return null;
|
|
772
|
+
throw error;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
function snapshotsMatch(observed, current) {
|
|
776
|
+
return current.stat.dev === observed.stat.dev && current.stat.ino === observed.stat.ino && current.stat.mtimeMs === observed.stat.mtimeMs && current.raw === observed.raw;
|
|
777
|
+
}
|
|
778
|
+
function restoreMovedLease(movedPath, originalPath) {
|
|
779
|
+
let movedStat;
|
|
780
|
+
try {
|
|
781
|
+
movedStat = fs3.lstatSync(movedPath);
|
|
782
|
+
} catch (error) {
|
|
783
|
+
if (error.code === "ENOENT") return;
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
if (!movedStat.isFile()) return;
|
|
787
|
+
try {
|
|
788
|
+
fs3.linkSync(movedPath, originalPath);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
const code = error.code;
|
|
791
|
+
if (code === "EEXIST" || code === "ENOENT") return;
|
|
792
|
+
throw error;
|
|
793
|
+
}
|
|
794
|
+
try {
|
|
795
|
+
fs3.unlinkSync(movedPath);
|
|
796
|
+
} catch (error) {
|
|
797
|
+
if (error.code !== "ENOENT") throw error;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
function pathMatchesClaim(filePath, claimedStat, ownerId, parseOwner) {
|
|
801
|
+
const current = snapshotLeaseFile(filePath);
|
|
802
|
+
return Boolean(
|
|
803
|
+
current && current.stat.dev === claimedStat.dev && current.stat.ino === claimedStat.ino && parseOwner(current.raw)?.ownerId === ownerId
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
function markClaimReleased(filePath, claimedStat, owner, parseOwner) {
|
|
807
|
+
let descriptor;
|
|
808
|
+
try {
|
|
809
|
+
descriptor = fs3.openSync(filePath, "r+");
|
|
810
|
+
} catch (error) {
|
|
811
|
+
if (error.code === "ENOENT") return false;
|
|
812
|
+
throw error;
|
|
813
|
+
}
|
|
814
|
+
try {
|
|
815
|
+
const currentStat = fs3.fstatSync(descriptor);
|
|
816
|
+
if (currentStat.dev !== claimedStat.dev || currentStat.ino !== claimedStat.ino) {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
const currentOwner = parseOwner(fs3.readFileSync(descriptor, "utf8"));
|
|
820
|
+
if (currentOwner?.ownerId !== owner.ownerId) return false;
|
|
821
|
+
const serialized = Buffer.from(
|
|
822
|
+
`${JSON.stringify({ ...owner, releasedAtMs: Date.now() })}
|
|
823
|
+
`,
|
|
824
|
+
"utf8"
|
|
825
|
+
);
|
|
826
|
+
fs3.ftruncateSync(descriptor, 0);
|
|
827
|
+
let offset = 0;
|
|
828
|
+
while (offset < serialized.length) {
|
|
829
|
+
offset += fs3.writeSync(
|
|
830
|
+
descriptor,
|
|
831
|
+
serialized,
|
|
832
|
+
offset,
|
|
833
|
+
serialized.length - offset,
|
|
834
|
+
offset
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
fs3.fsyncSync(descriptor);
|
|
838
|
+
return true;
|
|
839
|
+
} finally {
|
|
840
|
+
fs3.closeSync(descriptor);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
function releaseOwnedLease(filePath, claimedStat, owner, parseOwner, description) {
|
|
844
|
+
const releasedPath = `${filePath}.released.${randomUUID()}`;
|
|
845
|
+
let moved = false;
|
|
846
|
+
try {
|
|
847
|
+
if (!markClaimReleased(filePath, claimedStat, owner, parseOwner)) return;
|
|
848
|
+
try {
|
|
849
|
+
fs3.renameSync(filePath, releasedPath);
|
|
850
|
+
moved = true;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
if (error.code === "ENOENT") return;
|
|
853
|
+
throw error;
|
|
854
|
+
}
|
|
855
|
+
const released = snapshotLeaseFile(releasedPath);
|
|
856
|
+
const releasedOwner = released ? parseOwner(released.raw) : null;
|
|
857
|
+
if (!released || released.stat.dev !== claimedStat.dev || released.stat.ino !== claimedStat.ino || releasedOwner?.ownerId !== owner.ownerId || releasedOwner.releasedAtMs === void 0) {
|
|
858
|
+
restoreMovedLease(releasedPath, filePath);
|
|
859
|
+
moved = false;
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
fs3.rmSync(releasedPath, {
|
|
863
|
+
recursive: released.stat.isDirectory(),
|
|
864
|
+
force: true
|
|
865
|
+
});
|
|
866
|
+
moved = false;
|
|
867
|
+
} catch (error) {
|
|
868
|
+
let restorationError;
|
|
869
|
+
if (moved) {
|
|
870
|
+
try {
|
|
871
|
+
restoreMovedLease(releasedPath, filePath);
|
|
872
|
+
} catch (restoreError) {
|
|
873
|
+
restorationError = restoreError;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
console.error(
|
|
877
|
+
`[fml] Auth: unable to remove released ${description}: ${error instanceof Error ? error.message : String(error)}${restorationError ? `; unable to restore it: ${restorationError instanceof Error ? restorationError.message : String(restorationError)}` : ""}`
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
function sleep(milliseconds, signal) {
|
|
882
|
+
if (!signal) {
|
|
883
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
884
|
+
}
|
|
885
|
+
if (signal.aborted) {
|
|
886
|
+
return Promise.reject(
|
|
887
|
+
signal.reason ?? new DOMException("The operation was aborted", "AbortError")
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
return new Promise((resolve, reject) => {
|
|
891
|
+
const timeout = setTimeout(() => {
|
|
892
|
+
cleanup();
|
|
893
|
+
resolve();
|
|
894
|
+
}, milliseconds);
|
|
895
|
+
const onAbort = () => {
|
|
896
|
+
clearTimeout(timeout);
|
|
897
|
+
cleanup();
|
|
898
|
+
reject(
|
|
899
|
+
signal.reason ?? new DOMException("The operation was aborted", "AbortError")
|
|
900
|
+
);
|
|
901
|
+
};
|
|
902
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
903
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
function isProcessAlive(pid) {
|
|
907
|
+
try {
|
|
908
|
+
process.kill(pid, 0);
|
|
909
|
+
return true;
|
|
910
|
+
} catch (error) {
|
|
911
|
+
return error.code === "EPERM";
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
var processIdentityChecks = /* @__PURE__ */ new Map();
|
|
915
|
+
function ownerPidWasReused(owner, lockMtimeMs) {
|
|
916
|
+
const now = Date.now();
|
|
917
|
+
if (now - Math.max(owner.createdAtMs, lockMtimeMs) < PROCESS_IDENTITY_STALE_MS)
|
|
918
|
+
return false;
|
|
919
|
+
if (owner.pid === process.pid && owner.processInstanceId !== void 0) {
|
|
920
|
+
return owner.processInstanceId !== CURRENT_PROCESS_INSTANCE_ID;
|
|
921
|
+
}
|
|
922
|
+
if (!owner.processIdentity) return false;
|
|
923
|
+
const cached = processIdentityChecks.get(owner.ownerId);
|
|
924
|
+
let currentIdentity;
|
|
925
|
+
if (cached && now - cached.checkedAtMs < PROCESS_IDENTITY_RECHECK_MS) {
|
|
926
|
+
currentIdentity = cached.identity;
|
|
927
|
+
} else {
|
|
928
|
+
currentIdentity = getProcessIdentity(owner.pid);
|
|
929
|
+
processIdentityChecks.set(owner.ownerId, {
|
|
930
|
+
checkedAtMs: now,
|
|
931
|
+
identity: currentIdentity
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
return currentIdentity !== null && currentIdentity !== owner.processIdentity;
|
|
935
|
+
}
|
|
936
|
+
function writeClaimToDescriptor(descriptor, serializedOwner) {
|
|
937
|
+
const claimedStat = fs3.fstatSync(descriptor);
|
|
938
|
+
fs3.writeFileSync(descriptor, serializedOwner, { encoding: "utf8" });
|
|
939
|
+
fs3.fsyncSync(descriptor);
|
|
940
|
+
return claimedStat;
|
|
941
|
+
}
|
|
942
|
+
function recoverAbandonedRecoveryMarker(recoveryPath) {
|
|
943
|
+
const observed = snapshotLeaseFile(recoveryPath);
|
|
944
|
+
if (!observed) return;
|
|
945
|
+
const owner = parseRecoveryMarkerOwner(observed.raw);
|
|
946
|
+
const ownerAlive = owner ? isProcessAlive(owner.pid) : false;
|
|
947
|
+
const reclaimable = owner ? owner.releasedAtMs !== void 0 || !ownerAlive || ownerAlive && ownerPidWasReused(owner, observed.stat.mtimeMs) : Date.now() - observed.stat.mtimeMs > OAUTH_REFRESH_RECOVERY_STALE_MS;
|
|
948
|
+
if (!reclaimable) return;
|
|
949
|
+
const abandonedPath = `${recoveryPath}.stale.${randomUUID()}`;
|
|
950
|
+
try {
|
|
951
|
+
fs3.renameSync(recoveryPath, abandonedPath);
|
|
952
|
+
const moved = snapshotLeaseFile(abandonedPath);
|
|
953
|
+
if (!moved || !snapshotsMatch(observed, moved)) {
|
|
954
|
+
restoreMovedLease(abandonedPath, recoveryPath);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
fs3.rmSync(abandonedPath, {
|
|
958
|
+
recursive: moved.stat.isDirectory(),
|
|
959
|
+
force: true
|
|
960
|
+
});
|
|
961
|
+
} catch (error) {
|
|
962
|
+
if (error.code !== "ENOENT") throw error;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
function acquireRecoveryMarker(recoveryPath) {
|
|
966
|
+
const ownerId = randomUUID();
|
|
967
|
+
const owner = {
|
|
968
|
+
version: 1,
|
|
969
|
+
ownerId,
|
|
970
|
+
pid: process.pid,
|
|
971
|
+
processIdentity: getCurrentProcessIdentity(),
|
|
972
|
+
processInstanceId: CURRENT_PROCESS_INSTANCE_ID,
|
|
973
|
+
createdAtMs: Date.now()
|
|
974
|
+
};
|
|
975
|
+
let descriptor;
|
|
976
|
+
try {
|
|
977
|
+
descriptor = fs3.openSync(recoveryPath, "wx", 384);
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (error.code === "EEXIST") return null;
|
|
980
|
+
throw error;
|
|
981
|
+
}
|
|
982
|
+
let claimedStat;
|
|
983
|
+
try {
|
|
984
|
+
claimedStat = writeClaimToDescriptor(
|
|
985
|
+
descriptor,
|
|
986
|
+
`${JSON.stringify(owner)}
|
|
987
|
+
`
|
|
988
|
+
);
|
|
989
|
+
} finally {
|
|
990
|
+
fs3.closeSync(descriptor);
|
|
991
|
+
}
|
|
992
|
+
const isOwned = () => pathMatchesClaim(
|
|
993
|
+
recoveryPath,
|
|
994
|
+
claimedStat,
|
|
995
|
+
ownerId,
|
|
996
|
+
parseRecoveryMarkerOwner
|
|
997
|
+
);
|
|
998
|
+
if (!isOwned()) return null;
|
|
999
|
+
let released = false;
|
|
1000
|
+
return {
|
|
1001
|
+
isOwned,
|
|
1002
|
+
release: () => {
|
|
1003
|
+
if (released) return;
|
|
1004
|
+
released = true;
|
|
1005
|
+
if (isOwned()) {
|
|
1006
|
+
releaseOwnedLease(
|
|
1007
|
+
recoveryPath,
|
|
1008
|
+
claimedStat,
|
|
1009
|
+
owner,
|
|
1010
|
+
parseRecoveryMarkerOwner,
|
|
1011
|
+
"credential-lock recovery marker"
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
function recoverStaleRefreshLock(lockPath) {
|
|
1018
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
1019
|
+
const recovery = acquireRecoveryMarker(recoveryPath);
|
|
1020
|
+
if (!recovery) return;
|
|
1021
|
+
try {
|
|
1022
|
+
const observed = snapshotLeaseFile(lockPath);
|
|
1023
|
+
if (!observed) return;
|
|
1024
|
+
let reclaimable;
|
|
1025
|
+
const owner = parseRefreshLockOwner(observed.raw);
|
|
1026
|
+
if (owner) {
|
|
1027
|
+
const alive = isProcessAlive(owner.pid);
|
|
1028
|
+
reclaimable = owner.releasedAtMs !== void 0 || !alive || alive && ownerPidWasReused(owner, observed.stat.mtimeMs);
|
|
1029
|
+
} else {
|
|
1030
|
+
reclaimable = Date.now() - observed.stat.mtimeMs > OAUTH_REFRESH_LOCK_STALE_MS;
|
|
1031
|
+
}
|
|
1032
|
+
if (!reclaimable) return;
|
|
1033
|
+
if (!recovery.isOwned()) return;
|
|
1034
|
+
const abandonedPath = `${lockPath}.stale.${randomUUID()}`;
|
|
1035
|
+
try {
|
|
1036
|
+
fs3.renameSync(lockPath, abandonedPath);
|
|
1037
|
+
const moved = snapshotLeaseFile(abandonedPath);
|
|
1038
|
+
if (!moved || !snapshotsMatch(observed, moved)) {
|
|
1039
|
+
restoreMovedLease(abandonedPath, lockPath);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
fs3.rmSync(abandonedPath, {
|
|
1043
|
+
recursive: moved.stat.isDirectory(),
|
|
1044
|
+
force: true
|
|
1045
|
+
});
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
if (error.code !== "ENOENT") throw error;
|
|
1048
|
+
}
|
|
1049
|
+
} finally {
|
|
1050
|
+
recovery.release();
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async function acquireRefreshLock(envName, signal) {
|
|
1054
|
+
try {
|
|
1055
|
+
ensureDir(envName);
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
console.error(
|
|
1058
|
+
`[fml] Auth: unable to prepare refresh lock: ${error instanceof Error ? error.message : String(error)}`
|
|
1059
|
+
);
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
const lockPath = refreshLockPath(envName);
|
|
1063
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
1064
|
+
const deadline = Date.now() + OAUTH_REFRESH_LOCK_WAIT_MS;
|
|
1065
|
+
while (Date.now() < deadline) {
|
|
1066
|
+
if (signal?.aborted) {
|
|
1067
|
+
throw signal.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
1068
|
+
}
|
|
1069
|
+
if (fs3.existsSync(recoveryPath)) {
|
|
1070
|
+
try {
|
|
1071
|
+
recoverAbandonedRecoveryMarker(recoveryPath);
|
|
1072
|
+
} catch (recoveryError) {
|
|
1073
|
+
console.error(
|
|
1074
|
+
`[fml] Auth: unable to recover refresh recovery marker: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`
|
|
1075
|
+
);
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
1078
|
+
await sleep(OAUTH_REFRESH_LOCK_POLL_MS, signal);
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
let descriptor;
|
|
1082
|
+
try {
|
|
1083
|
+
descriptor = fs3.openSync(lockPath, "wx", 384);
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
if (error.code !== "EEXIST") {
|
|
1086
|
+
console.error(
|
|
1087
|
+
`[fml] Auth: unable to acquire refresh lock: ${error instanceof Error ? error.message : String(error)}`
|
|
1088
|
+
);
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
try {
|
|
1092
|
+
recoverStaleRefreshLock(lockPath);
|
|
1093
|
+
} catch (recoveryError) {
|
|
1094
|
+
console.error(
|
|
1095
|
+
`[fml] Auth: unable to recover refresh lock: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`
|
|
1096
|
+
);
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
await sleep(OAUTH_REFRESH_LOCK_POLL_MS, signal);
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
const ownerId = randomUUID();
|
|
1103
|
+
const owner = {
|
|
1104
|
+
version: 1,
|
|
1105
|
+
ownerId,
|
|
1106
|
+
pid: process.pid,
|
|
1107
|
+
processIdentity: getCurrentProcessIdentity(),
|
|
1108
|
+
processInstanceId: CURRENT_PROCESS_INSTANCE_ID,
|
|
1109
|
+
createdAtMs: Date.now()
|
|
1110
|
+
};
|
|
1111
|
+
let claimedStat;
|
|
1112
|
+
try {
|
|
1113
|
+
claimedStat = writeClaimToDescriptor(
|
|
1114
|
+
descriptor,
|
|
1115
|
+
`${JSON.stringify(owner)}
|
|
1116
|
+
`
|
|
1117
|
+
);
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
fs3.closeSync(descriptor);
|
|
1120
|
+
console.error(
|
|
1121
|
+
`[fml] Auth: unable to initialize refresh lock: ${error instanceof Error ? error.message : String(error)}`
|
|
1122
|
+
);
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
fs3.closeSync(descriptor);
|
|
1126
|
+
const isOwned = () => pathMatchesClaim(lockPath, claimedStat, ownerId, parseRefreshLockOwner);
|
|
1127
|
+
const releaseClaim = () => releaseOwnedLease(
|
|
1128
|
+
lockPath,
|
|
1129
|
+
claimedStat,
|
|
1130
|
+
owner,
|
|
1131
|
+
parseRefreshLockOwner,
|
|
1132
|
+
"credential lock"
|
|
1133
|
+
);
|
|
1134
|
+
if (signal?.aborted) {
|
|
1135
|
+
releaseClaim();
|
|
1136
|
+
throw signal.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
1137
|
+
}
|
|
1138
|
+
let entryBlocked;
|
|
1139
|
+
try {
|
|
1140
|
+
entryBlocked = fs3.existsSync(recoveryPath) || !isOwned();
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
console.error(
|
|
1143
|
+
`[fml] Auth: unable to verify refresh lock ownership: ${describeError(error)}`
|
|
1144
|
+
);
|
|
1145
|
+
try {
|
|
1146
|
+
if (isOwned()) releaseClaim();
|
|
1147
|
+
} catch {
|
|
1148
|
+
}
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
if (entryBlocked) {
|
|
1152
|
+
try {
|
|
1153
|
+
await sleep(OAUTH_REFRESH_LOCK_POLL_MS, signal);
|
|
1154
|
+
} finally {
|
|
1155
|
+
try {
|
|
1156
|
+
if (isOwned()) releaseClaim();
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
console.error(
|
|
1159
|
+
`[fml] Auth: unable to release a blocked refresh lock claim: ${describeError(error)}`
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
let released = false;
|
|
1166
|
+
return {
|
|
1167
|
+
isOwned,
|
|
1168
|
+
release: () => {
|
|
1169
|
+
if (released) return;
|
|
1170
|
+
released = true;
|
|
1171
|
+
let owned = false;
|
|
1172
|
+
try {
|
|
1173
|
+
owned = isOwned();
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
console.error(
|
|
1176
|
+
`[fml] Auth: unable to verify credential lock ownership during release: ${describeError(error)}`
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
if (owned) releaseClaim();
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
console.error(
|
|
1184
|
+
(() => {
|
|
1185
|
+
try {
|
|
1186
|
+
const owner = snapshotLeaseFile(lockPath);
|
|
1187
|
+
const parsedOwner = owner ? parseRefreshLockOwner(owner.raw) : null;
|
|
1188
|
+
return parsedOwner && isProcessAlive(parsedOwner.pid) ? `[fml] Auth: credential lock is still owned by live process ${parsedOwner.pid}; refusing an unsafe takeover` : "[fml] Auth: timed out waiting for another process to refresh credentials";
|
|
1189
|
+
} catch {
|
|
1190
|
+
return "[fml] Auth: timed out waiting for another process to refresh credentials";
|
|
1191
|
+
}
|
|
1192
|
+
})()
|
|
1193
|
+
);
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
function isLockOwnedSafely(lock) {
|
|
1197
|
+
try {
|
|
1198
|
+
return lock.isOwned();
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
console.error(
|
|
1201
|
+
`[fml] Auth: unable to verify credential lock ownership: ${describeError(error)}`
|
|
1202
|
+
);
|
|
1203
|
+
return false;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
async function withRefreshLock(envName, mutation, options) {
|
|
1207
|
+
const lock = await acquireRefreshLock(envName, options?.signal);
|
|
1208
|
+
if (!lock) {
|
|
1209
|
+
throw new Error("Unable to acquire the credential mutation lock");
|
|
1210
|
+
}
|
|
1211
|
+
try {
|
|
1212
|
+
if (!isLockOwnedSafely(lock)) {
|
|
1213
|
+
throw new Error("Credential mutation lock ownership was lost");
|
|
1214
|
+
}
|
|
1215
|
+
reconcilePendingOAuthRefreshes(envName, options?.unreadableStorePolicy);
|
|
1216
|
+
if (!isLockOwnedSafely(lock)) {
|
|
1217
|
+
throw new Error("Credential mutation lock ownership was lost");
|
|
1218
|
+
}
|
|
1219
|
+
const result = await mutation(lock);
|
|
1220
|
+
if (!isLockOwnedSafely(lock)) {
|
|
1221
|
+
throw new Error("Credential mutation lock ownership was lost");
|
|
1222
|
+
}
|
|
1223
|
+
return result;
|
|
1224
|
+
} finally {
|
|
1225
|
+
lock.release();
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
async function clearStoredCredentials(envName) {
|
|
1229
|
+
const resolvedEnvName = resolveEnvName(envName);
|
|
1230
|
+
await withRefreshLock(
|
|
1231
|
+
resolvedEnvName,
|
|
1232
|
+
() => {
|
|
1233
|
+
try {
|
|
1234
|
+
fs3.unlinkSync(storePathFor(resolvedEnvName));
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
if (error.code !== "ENOENT") throw error;
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
{ unreadableStorePolicy: "discard-staged" }
|
|
1240
|
+
);
|
|
1241
|
+
const key = cacheKey(resolvedEnvName);
|
|
1242
|
+
serviceTokenCaches.delete(key);
|
|
1243
|
+
serviceRefreshPromises.delete(key);
|
|
1244
|
+
oauthRefreshPromises.delete(key);
|
|
1245
|
+
}
|
|
1246
|
+
async function refreshServiceToken(refreshToken2, envName, siteUrl, signal) {
|
|
1247
|
+
try {
|
|
1248
|
+
if (signal?.aborted) return null;
|
|
1249
|
+
const convexUrl = (envName ? resolveEnvConvexUrl(envName) : null) ?? CONVEX_URL;
|
|
1250
|
+
const requestTimeout = AbortSignal.timeout(
|
|
1251
|
+
OAUTH_REFRESH_REQUEST_TIMEOUT_MS
|
|
1252
|
+
);
|
|
1253
|
+
const response = await fetch(
|
|
1254
|
+
`${siteUrl ?? getSiteUrlForConvexUrl(convexUrl)}/api/tokens/refresh`,
|
|
1255
|
+
{
|
|
1256
|
+
method: "POST",
|
|
1257
|
+
headers: {
|
|
1258
|
+
"Content-Type": "application/json",
|
|
1259
|
+
Authorization: `Bearer ${refreshToken2}`
|
|
1260
|
+
},
|
|
1261
|
+
signal: signal ? AbortSignal.any([signal, requestTimeout]) : requestTimeout
|
|
1262
|
+
}
|
|
1263
|
+
);
|
|
1264
|
+
if (!response.ok) {
|
|
1265
|
+
console.error(
|
|
1266
|
+
`[fml] Service token refresh failed: HTTP ${response.status}`
|
|
1267
|
+
);
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
const data = await response.json();
|
|
1271
|
+
if (!data.ok || !data.accessToken) {
|
|
1272
|
+
console.error(`[fml] Service token refresh error: ${data.error}`);
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
serviceTokenCaches.set(cacheKey(envName), {
|
|
1276
|
+
accessToken: data.accessToken,
|
|
1277
|
+
expiresAt: data.expiresAt
|
|
1278
|
+
});
|
|
1279
|
+
return data.accessToken;
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
if (signal?.aborted) return null;
|
|
1282
|
+
Sentry.captureException(err);
|
|
1283
|
+
console.error(`[fml] Service token refresh error: ${err}`);
|
|
1284
|
+
return null;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
async function storeServiceRefreshToken(refreshToken2, opts) {
|
|
1288
|
+
const envName = resolveEnvName(opts?.env);
|
|
1289
|
+
const token = refreshToken2.trim();
|
|
1290
|
+
if (!token.startsWith("fml_srt_")) {
|
|
1291
|
+
console.error(
|
|
1292
|
+
"[fml] Auth: service-token login expects a refresh token that starts with fml_srt_."
|
|
1293
|
+
);
|
|
1294
|
+
return false;
|
|
1295
|
+
}
|
|
1296
|
+
const accessToken = await refreshServiceToken(token, envName, opts?.siteUrl);
|
|
1297
|
+
const cached = serviceTokenCaches.get(cacheKey(envName));
|
|
1298
|
+
if (!accessToken || !cached) {
|
|
1299
|
+
return false;
|
|
1300
|
+
}
|
|
1301
|
+
await writeTokens(
|
|
1302
|
+
{
|
|
1303
|
+
accessToken,
|
|
1304
|
+
refreshToken: token,
|
|
1305
|
+
expiresAt: cached.expiresAt,
|
|
1306
|
+
user: {
|
|
1307
|
+
id: SERVICE_TOKEN_LOGIN_USER_ID,
|
|
1308
|
+
email: "service-token",
|
|
1309
|
+
name: "FML service token"
|
|
1310
|
+
},
|
|
1311
|
+
tokenType: "service"
|
|
1312
|
+
},
|
|
1313
|
+
envName
|
|
1314
|
+
);
|
|
1315
|
+
return true;
|
|
1316
|
+
}
|
|
1317
|
+
async function getValidToken(opts) {
|
|
1318
|
+
if (opts?.signal?.aborted) return null;
|
|
1319
|
+
const envName = resolveEnvName(opts?.env);
|
|
1320
|
+
const forceRefresh = opts?.forceRefresh === true;
|
|
1321
|
+
const key = cacheKey(envName);
|
|
1322
|
+
const envToken = process.env.FML_TOKEN;
|
|
1323
|
+
if (envToken?.startsWith("fml_srt_")) {
|
|
1324
|
+
const cached = serviceTokenCaches.get(key);
|
|
1325
|
+
if (!forceRefresh && cached && cached.expiresAt > Date.now() + TOKEN_REFRESH_WINDOW_MS) {
|
|
1326
|
+
return cached.accessToken;
|
|
1327
|
+
}
|
|
1328
|
+
return await getOrStartTokenRefresh(
|
|
1329
|
+
serviceRefreshPromises,
|
|
1330
|
+
key,
|
|
1331
|
+
forceRefresh,
|
|
1332
|
+
() => refreshServiceToken(envToken, envName, void 0, opts?.signal),
|
|
1333
|
+
opts?.signal
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
if (envToken?.startsWith("fml_st_")) return envToken;
|
|
1337
|
+
if (envToken) {
|
|
1338
|
+
console.error(
|
|
1339
|
+
"[fml] Auth: unsupported FML_TOKEN format. Expected fml_srt_* or fml_st_*."
|
|
1340
|
+
);
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
const stored = readTokens(envName);
|
|
1344
|
+
if (!stored) {
|
|
1345
|
+
console.error(
|
|
1346
|
+
envName ? `[fml] Auth: no stored tokens for env "${envName}"` : "[fml] Auth: no stored tokens"
|
|
1347
|
+
);
|
|
1348
|
+
return null;
|
|
1349
|
+
}
|
|
1350
|
+
if (!forceRefresh && stored.expiresAt > Date.now() + TOKEN_REFRESH_WINDOW_MS) {
|
|
1351
|
+
return stored.accessToken;
|
|
1352
|
+
}
|
|
1353
|
+
if (stored.tokenType === "service") {
|
|
1354
|
+
return await getOrStartTokenRefresh(
|
|
1355
|
+
serviceRefreshPromises,
|
|
1356
|
+
key,
|
|
1357
|
+
forceRefresh,
|
|
1358
|
+
() => withRefreshLock(
|
|
1359
|
+
envName,
|
|
1360
|
+
async (lock) => {
|
|
1361
|
+
const current = readTokens(envName);
|
|
1362
|
+
if (!current) return null;
|
|
1363
|
+
if (!forceRefresh && current.expiresAt > Date.now() + TOKEN_REFRESH_WINDOW_MS) {
|
|
1364
|
+
return current.accessToken;
|
|
1365
|
+
}
|
|
1366
|
+
if (current.tokenType !== "service") {
|
|
1367
|
+
return current.accessToken;
|
|
1368
|
+
}
|
|
1369
|
+
if (!lock.isOwned()) return null;
|
|
1370
|
+
const newAccessToken = await refreshServiceToken(
|
|
1371
|
+
current.refreshToken,
|
|
1372
|
+
envName,
|
|
1373
|
+
void 0,
|
|
1374
|
+
opts?.signal
|
|
1375
|
+
);
|
|
1376
|
+
if (!lock.isOwned()) return null;
|
|
1377
|
+
const cached = serviceTokenCaches.get(key);
|
|
1378
|
+
if (newAccessToken && cached) {
|
|
1379
|
+
writeTokensUnlocked(
|
|
1380
|
+
{
|
|
1381
|
+
...current,
|
|
1382
|
+
accessToken: newAccessToken,
|
|
1383
|
+
expiresAt: cached.expiresAt
|
|
1384
|
+
},
|
|
1385
|
+
envName
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
return newAccessToken;
|
|
1389
|
+
},
|
|
1390
|
+
{ signal: opts?.signal }
|
|
1391
|
+
).catch((error) => {
|
|
1392
|
+
if (opts?.signal?.aborted) return null;
|
|
1393
|
+
console.error(
|
|
1394
|
+
`[fml] Auth: unable to refresh stored service credentials: ${error instanceof Error ? error.message : String(error)}`
|
|
1395
|
+
);
|
|
1396
|
+
return null;
|
|
1397
|
+
}),
|
|
1398
|
+
opts?.signal
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
if (stored.refreshInvalidAt) {
|
|
1402
|
+
console.error(
|
|
1403
|
+
"[fml] Auth: refresh token is no longer valid \u2014 run `fml login`"
|
|
1404
|
+
);
|
|
1405
|
+
return null;
|
|
1406
|
+
}
|
|
1407
|
+
return await getOrStartTokenRefresh(
|
|
1408
|
+
oauthRefreshPromises,
|
|
1409
|
+
key,
|
|
1410
|
+
forceRefresh,
|
|
1411
|
+
() => refreshTokenWithLock(envName, forceRefresh, opts?.signal),
|
|
1412
|
+
opts?.signal
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
async function refreshTokenWithLock(envName, forceRefresh = false, signal) {
|
|
1416
|
+
const lock = await acquireRefreshLock(envName, signal);
|
|
1417
|
+
if (!lock) return null;
|
|
1418
|
+
try {
|
|
1419
|
+
if (!isLockOwnedSafely(lock)) return null;
|
|
1420
|
+
try {
|
|
1421
|
+
reconcilePendingOAuthRefreshes(envName);
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
console.error(
|
|
1424
|
+
`[fml] Auth: unable to reconcile staged refresh results: ${describeError(error)}`
|
|
1425
|
+
);
|
|
1426
|
+
return null;
|
|
1427
|
+
}
|
|
1428
|
+
if (!isLockOwnedSafely(lock)) return null;
|
|
1429
|
+
const current = readTokens(envName);
|
|
1430
|
+
if (!current) {
|
|
1431
|
+
console.error("[fml] Auth: credentials disappeared during refresh");
|
|
1432
|
+
return null;
|
|
1433
|
+
}
|
|
1434
|
+
if (!forceRefresh && current.expiresAt > Date.now() + TOKEN_REFRESH_WINDOW_MS) {
|
|
1435
|
+
return current.accessToken;
|
|
1436
|
+
}
|
|
1437
|
+
if (current.refreshInvalidAt) {
|
|
1438
|
+
console.error(
|
|
1439
|
+
"[fml] Auth: refresh token is no longer valid \u2014 run `fml login`"
|
|
1440
|
+
);
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1443
|
+
if (current.tokenType === "service") {
|
|
1444
|
+
return null;
|
|
1445
|
+
}
|
|
1446
|
+
if (!isLockOwnedSafely(lock)) return null;
|
|
1447
|
+
return await refreshToken(current, lock, envName, signal);
|
|
1448
|
+
} finally {
|
|
1449
|
+
lock.release();
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
async function refreshToken(stored, lock, envName, signal) {
|
|
1453
|
+
if (!stored.workosClientId) {
|
|
1454
|
+
console.error(
|
|
1455
|
+
"[fml] Auth: token expired, missing workosClientId \u2014 run `fml login`"
|
|
1456
|
+
);
|
|
1457
|
+
return null;
|
|
1458
|
+
}
|
|
1459
|
+
try {
|
|
1460
|
+
if (signal?.aborted) return null;
|
|
1461
|
+
if (!lock.isOwned()) return null;
|
|
1462
|
+
const requestTimeout = AbortSignal.timeout(
|
|
1463
|
+
OAUTH_REFRESH_REQUEST_TIMEOUT_MS
|
|
1464
|
+
);
|
|
1465
|
+
const response = await fetch(
|
|
1466
|
+
`${WORKOS_API_URL}/user_management/authenticate`,
|
|
1467
|
+
{
|
|
1468
|
+
method: "POST",
|
|
1469
|
+
headers: { "Content-Type": "application/json" },
|
|
1470
|
+
body: JSON.stringify({
|
|
1471
|
+
grant_type: "refresh_token",
|
|
1472
|
+
client_id: stored.workosClientId,
|
|
1473
|
+
refresh_token: stored.refreshToken
|
|
1474
|
+
}),
|
|
1475
|
+
signal: signal ? AbortSignal.any([signal, requestTimeout]) : requestTimeout
|
|
1476
|
+
}
|
|
1477
|
+
);
|
|
1478
|
+
if (!response.ok) {
|
|
1479
|
+
if (!lock.isOwned()) return null;
|
|
1480
|
+
let oauthError = null;
|
|
1481
|
+
try {
|
|
1482
|
+
const errorBody = await response.json();
|
|
1483
|
+
if (errorBody && typeof errorBody === "object" && "error" in errorBody && typeof errorBody.error === "string") {
|
|
1484
|
+
oauthError = errorBody.error;
|
|
1485
|
+
}
|
|
1486
|
+
} catch {
|
|
1487
|
+
}
|
|
1488
|
+
if (oauthError === "invalid_grant") {
|
|
1489
|
+
const current2 = readTokens(envName);
|
|
1490
|
+
if (lock.isOwned() && current2?.refreshToken === stored.refreshToken) {
|
|
1491
|
+
writeTokensUnlocked(
|
|
1492
|
+
{ ...current2, refreshInvalidAt: Date.now() },
|
|
1493
|
+
envName
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
console.error(
|
|
1497
|
+
"[fml] Auth: refresh token is no longer valid \u2014 run `fml login`"
|
|
1498
|
+
);
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
console.error(
|
|
1502
|
+
`[fml] Auth: refresh failed (HTTP ${response.status}) \u2014 token preserved`
|
|
1503
|
+
);
|
|
1504
|
+
return null;
|
|
1505
|
+
}
|
|
1506
|
+
const data = await response.json();
|
|
1507
|
+
const userName = [data.user.first_name, data.user.last_name].filter(Boolean).join(" ");
|
|
1508
|
+
const refreshed = {
|
|
1509
|
+
accessToken: data.access_token,
|
|
1510
|
+
refreshToken: data.refresh_token,
|
|
1511
|
+
expiresAt: Date.now() + (data.expires_in ?? 300) * 1e3,
|
|
1512
|
+
user: {
|
|
1513
|
+
id: data.user.id,
|
|
1514
|
+
email: data.user.email,
|
|
1515
|
+
name: userName || stored.user.name
|
|
1516
|
+
},
|
|
1517
|
+
orgId: stored.orgId,
|
|
1518
|
+
orgSlug: stored.orgSlug,
|
|
1519
|
+
workosClientId: stored.workosClientId
|
|
1520
|
+
};
|
|
1521
|
+
const pendingPath = stagePendingOAuthRefresh(
|
|
1522
|
+
stored.refreshToken,
|
|
1523
|
+
refreshed,
|
|
1524
|
+
envName
|
|
1525
|
+
);
|
|
1526
|
+
const current = readTokens(envName);
|
|
1527
|
+
if (current?.refreshToken === stored.refreshToken && lock.isOwned()) {
|
|
1528
|
+
writeTokensUnlocked(
|
|
1529
|
+
{
|
|
1530
|
+
...refreshed,
|
|
1531
|
+
orgId: current.orgId,
|
|
1532
|
+
orgSlug: current.orgSlug
|
|
1533
|
+
},
|
|
1534
|
+
envName
|
|
1535
|
+
);
|
|
1536
|
+
removePendingOAuthRefresh(pendingPath);
|
|
1537
|
+
return refreshed.accessToken;
|
|
1538
|
+
}
|
|
1539
|
+
if (!current) return null;
|
|
1540
|
+
if (current.refreshToken !== stored.refreshToken) {
|
|
1541
|
+
return current.accessToken;
|
|
1542
|
+
}
|
|
1543
|
+
return refreshed.accessToken;
|
|
1544
|
+
} catch (err) {
|
|
1545
|
+
if (signal?.aborted) return null;
|
|
1546
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1547
|
+
console.error(
|
|
1548
|
+
`[fml] Auth: refresh failed (network error: ${msg}) \u2014 token preserved`
|
|
1549
|
+
);
|
|
1550
|
+
Sentry.captureException(err);
|
|
1551
|
+
return null;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
export {
|
|
1556
|
+
initSentry,
|
|
1557
|
+
Sentry,
|
|
1558
|
+
resolveFmlDefaultDirectories,
|
|
1559
|
+
FML_DATA_DIR,
|
|
1560
|
+
FML_STATE_DIR,
|
|
1561
|
+
FML_LOG_DIR,
|
|
1562
|
+
FML_OBSERVER_DIR,
|
|
1563
|
+
FML_OBSERVER_HOST,
|
|
1564
|
+
FML_OBSERVER_PORT,
|
|
1565
|
+
FML_OBSERVER_STATE_FILE,
|
|
1566
|
+
FML_OBSERVER_LOCK_FILE,
|
|
1567
|
+
FML_OBSERVER_AUTH_TOKEN_FILE,
|
|
1568
|
+
FML_OBSERVER_ENDPOINT_FILE,
|
|
1569
|
+
FML_OBSERVER_LOG_FILE,
|
|
1570
|
+
FML_INSTALLATION_ID_FILE,
|
|
1571
|
+
FML_PANOPTICON_DB_FILE,
|
|
1572
|
+
FML_PANOPTICON_DB_OBSERVER_STATE_FILE,
|
|
1573
|
+
FML_PANOPTICON_DB_OBSERVER_LOCK_FILE,
|
|
1574
|
+
FML_LEGACY_OBSERVER_DB_FILE,
|
|
1575
|
+
FML_HOOK_INBOX_DIR,
|
|
1576
|
+
FML_HOOK_CONTEXT_STATE_DIR,
|
|
1577
|
+
FML_HOOK_CAPTURE_DISABLED_FILE,
|
|
1578
|
+
observerOnlyOverrideWarning,
|
|
1579
|
+
ensureDirs,
|
|
1580
|
+
DEFAULT_PROD_URL,
|
|
1581
|
+
DEFAULT_TARGET_NAME,
|
|
1582
|
+
isValidEnvName,
|
|
1583
|
+
normalizeConvexUrl,
|
|
1584
|
+
writeEnvConfig,
|
|
1585
|
+
envConfigExists,
|
|
1586
|
+
resolveEnvConvexUrl,
|
|
1587
|
+
setActiveEnvironment,
|
|
1588
|
+
getActiveEnv,
|
|
1589
|
+
authStorePathFor,
|
|
1590
|
+
CONVEX_URL,
|
|
1591
|
+
WORKOS_API_URL,
|
|
1592
|
+
WORKOS_AUTH_URL,
|
|
1593
|
+
getSiteUrl,
|
|
1594
|
+
getSiteUrlForConvexUrl,
|
|
1595
|
+
readTokens,
|
|
1596
|
+
writeTokens,
|
|
1597
|
+
getSelectedOrg,
|
|
1598
|
+
getSelectedOrgId,
|
|
1599
|
+
setSelectedOrg,
|
|
1600
|
+
getSyncDestinationAuthBinding,
|
|
1601
|
+
getSyncDestinationIdentity,
|
|
1602
|
+
SERVICE_TOKEN_LOGIN_USER_ID,
|
|
1603
|
+
clearStoredCredentials,
|
|
1604
|
+
storeServiceRefreshToken,
|
|
1605
|
+
getValidToken
|
|
1606
|
+
};
|