cortad 0.1.9 → 0.1.11

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.
@@ -26,7 +26,7 @@ const SOURCE = /^(?:env:[A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*|gen:(?:username|
26
26
  // before the program saw it. Still no quote, angle bracket, `$`, backtick or semicolon.
27
27
  const WHERE = /^[\w./:@()[\] -]{1,300}$/;
28
28
  const BASE = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost):\d{2,5}$/;
29
- const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase"]);
29
+ const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase"]);
30
30
  const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable", "upstream"]);
31
31
 
32
32
  const slug = (role) => role.replace(/[^a-z0-9-]/g, "-");
@@ -245,6 +245,13 @@ def b64url(raw):
245
245
  return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
246
246
 
247
247
 
248
+ def hs256(secret, claims):
249
+ head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode("utf-8"))
250
+ body = b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
251
+ sig = b64url(hmac.new(secret.encode("utf-8"), ("%s.%s" % (head, body)).encode("ascii"), hashlib.sha256).digest())
252
+ return "%s.%s.%s" % (head, body, sig)
253
+
254
+
248
255
  def jwt(recipe, env):
249
256
  secret = source("env:" + "|".join(recipe["env"]), env, None, None)
250
257
  if not secret:
@@ -257,10 +264,163 @@ def jwt(recipe, env):
257
264
  claim = recipe.get("claim") or "sub"
258
265
  if claim not in claims:
259
266
  claims[claim] = TEST
260
- head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode("utf-8"))
261
- body = b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
262
- sig = b64url(hmac.new(secret.encode("utf-8"), ("%s.%s" % (head, body)).encode("ascii"), hashlib.sha256).digest())
263
- return minted("authorization", "%s.%s.%s" % (head, body, sig))
267
+ return minted("authorization", hs256(secret, claims))
268
+
269
+
270
+ # GoTrue signs its access token HS256 with the project's JWT secret; RLS reads sub as auth.uid()
271
+ # and role as auth.role(). The id is a well-formed uuid that belongs to nobody, never a real row.
272
+ SUPA_UID = "00000000-0000-4000-8000-000000009001"
273
+
274
+
275
+ def supabase(recipe, env):
276
+ secret = source("env:" + "|".join(recipe["env"]), env, None, None)
277
+ if not secret:
278
+ return {"status": "absent", "note": " or ".join(recipe["env"])}
279
+ now = int(time.time())
280
+ claims = {"sub": SUPA_UID, "aud": "authenticated", "role": "authenticated", "iat": now, "exp": now + TTL, "is_anonymous": False}
281
+ return minted("authorization", hs256(secret, claims))
282
+
283
+
284
+ # Auth.js v5 (and next-auth v5) seals its session as a JWE, not a signed JWT: dir key management,
285
+ # A256CBC-HS512 content encryption, the key derived from the secret with HKDF over the cookie name.
286
+ # Verified against the @auth/core jwt.ts encode path. next-auth v4 (A256GCM) is not minted here.
287
+ def hkdf_sha256(secret, salt, info, length):
288
+ salt = salt or bytes(hashlib.sha256().digest_size)
289
+ prk = hmac.new(salt, secret, hashlib.sha256).digest()
290
+ out, block, i = b"", b"", 1
291
+ while len(out) < length:
292
+ block = hmac.new(prk, block + info + bytes([i]), hashlib.sha256).digest()
293
+ out += block
294
+ i += 1
295
+ return out[:length]
296
+
297
+
298
+ AES_SBOX = bytes.fromhex(
299
+ "637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
300
+ "b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
301
+ "09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
302
+ "d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
303
+ "cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
304
+ "e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08"
305
+ "ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e"
306
+ "e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16")
307
+ AES_RCON = (0, 1, 2, 4, 8, 16, 32, 64)
308
+
309
+
310
+ def _aes_mul(a, b):
311
+ r = 0
312
+ for _ in range(8):
313
+ if b & 1:
314
+ r ^= a
315
+ hi = a & 0x80
316
+ a = (a << 1) & 0xFF
317
+ if hi:
318
+ a ^= 0x1B
319
+ b >>= 1
320
+ return r
321
+
322
+
323
+ def _aes_expand(key):
324
+ w = [list(key[i:i + 4]) for i in range(0, 32, 4)]
325
+ for i in range(8, 60):
326
+ t = list(w[i - 1])
327
+ if i % 8 == 0:
328
+ t = [AES_SBOX[b] for b in t[1:] + t[:1]]
329
+ t[0] ^= AES_RCON[i // 8]
330
+ elif i % 8 == 4:
331
+ t = [AES_SBOX[b] for b in t]
332
+ w.append([w[i - 8][j] ^ t[j] for j in range(4)])
333
+ return [b for word in w for b in word]
334
+
335
+
336
+ def _aes_shift_rows(s):
337
+ o = list(s)
338
+ for r in range(1, 4):
339
+ vals = [s[c * 4 + r] for c in range(4)]
340
+ vals = vals[r:] + vals[:r]
341
+ for c in range(4):
342
+ o[c * 4 + r] = vals[c]
343
+ return o
344
+
345
+
346
+ def _aes_encrypt_block(block, rk):
347
+ s = list(block)
348
+
349
+ def add(rnd):
350
+ for i in range(16):
351
+ s[i] ^= rk[rnd * 16 + i]
352
+
353
+ add(0)
354
+ for rnd in range(1, 14):
355
+ s = [AES_SBOX[b] for b in s]
356
+ s = _aes_shift_rows(s)
357
+ mixed = []
358
+ for c in range(4):
359
+ a, b, d, e = s[c * 4:c * 4 + 4]
360
+ mixed += [
361
+ _aes_mul(a, 2) ^ _aes_mul(b, 3) ^ d ^ e,
362
+ a ^ _aes_mul(b, 2) ^ _aes_mul(d, 3) ^ e,
363
+ a ^ b ^ _aes_mul(d, 2) ^ _aes_mul(e, 3),
364
+ _aes_mul(a, 3) ^ b ^ d ^ _aes_mul(e, 2),
365
+ ]
366
+ s = mixed
367
+ add(rnd)
368
+ s = _aes_shift_rows([AES_SBOX[b] for b in s])
369
+ add(14)
370
+ return bytes(s)
371
+
372
+
373
+ def _aes_cbc(key, iv, data):
374
+ rk = _aes_expand(key)
375
+ out, prev = b"", iv
376
+ for i in range(0, len(data), 16):
377
+ block = bytes(x ^ y for x, y in zip(data[i:i + 16], prev))
378
+ prev = _aes_encrypt_block(block, rk)
379
+ out += prev
380
+ return out
381
+
382
+
383
+ def _authjs_cookie(env_path):
384
+ root = os.path.dirname(os.path.abspath(env_path))
385
+ for _ in range(5):
386
+ nm = os.path.join(root, "node_modules")
387
+ if os.path.isdir(os.path.join(nm, "@auth", "core")):
388
+ return "authjs.session-token"
389
+ try:
390
+ with open(os.path.join(nm, "next-auth", "package.json"), encoding="utf-8") as f:
391
+ major = json.load(f).get("version", "").lstrip("^~").split(".")[0]
392
+ return "authjs.session-token" if major.isdigit() and int(major) >= 5 else None
393
+ except OSError:
394
+ pass
395
+ parent = os.path.dirname(root)
396
+ if parent == root:
397
+ break
398
+ root = parent
399
+ # The dependency was detected upstream but the tree is not on disk: v5 is the current default.
400
+ return "authjs.session-token"
401
+
402
+
403
+ def authjs(recipe, env):
404
+ secret = source("env:" + "|".join(recipe["env"]), env, None, None)
405
+ if not secret:
406
+ return {"status": "absent", "note": " or ".join(recipe["env"])}
407
+ cookie = _authjs_cookie(PLAN["envPath"])
408
+ if not cookie:
409
+ return {"status": "upstream", "note": "Auth.js v4"}
410
+ key = hkdf_sha256(secret.encode("utf-8"), cookie.encode("utf-8"), ("Auth.js Generated Encryption Key (%s)" % cookie).encode("utf-8"), 64)
411
+ mac_key, enc_key = key[:32], key[32:]
412
+ now = int(time.time())
413
+ claims = {"sub": TEST, "name": "Cortad test", "email": "cortad@example.invalid", "iat": now, "exp": now + TTL, "jti": secrets.token_hex(16)}
414
+ header = b64url(json.dumps({"alg": "dir", "enc": "A256CBC-HS512"}, separators=(",", ":")).encode("utf-8"))
415
+ plaintext = json.dumps(claims, separators=(",", ":")).encode("utf-8")
416
+ pad = 16 - (len(plaintext) % 16)
417
+ iv = secrets.token_bytes(16)
418
+ ct = _aes_cbc(enc_key, iv, plaintext + bytes([pad]) * pad)
419
+ aad = header.encode("ascii")
420
+ al = (len(aad) * 8).to_bytes(8, "big")
421
+ tag = hmac.new(mac_key, aad + iv + ct + al, hashlib.sha512).digest()[:32]
422
+ jwe = "%s..%s.%s.%s" % (header, b64url(iv), b64url(ct), b64url(tag))
423
+ return minted("cookie", "%s=%s" % (cookie, jwe))
264
424
 
265
425
 
266
426
  # The Auth emulator the boot raised in place of their Firebase project: it signs any address up
@@ -341,6 +501,10 @@ def main():
341
501
  got = sealed(recipe, env)
342
502
  elif recipe["kind"] == "jwt":
343
503
  got = jwt(recipe, env)
504
+ elif recipe["kind"] == "supabase":
505
+ got = supabase(recipe, env)
506
+ elif recipe["kind"] == "authjs":
507
+ got = authjs(recipe, env)
344
508
  elif recipe["kind"] == "login":
345
509
  got = login(recipe, role, role_value, env)
346
510
  elif recipe["kind"] == "firebase":
@@ -0,0 +1,71 @@
1
+ // Some apps keep their sign-in on a different service than the one that serves the AI: databuddy
2
+ // serves chat from apps/api and mounts better-auth in apps/dashboard, and the command starts only
3
+ // apps/api. A recipe that names such a service (src/setup/road-identities.ts) is minted against
4
+ // that service instead, so nobody is asked to send a message by hand. Cookies set on localhost
5
+ // apply to every port, which is what makes the session work on the AI service after.
6
+ import { relative, join } from "node:path";
7
+ import { startPlan } from "./start.mjs";
8
+
9
+ export const serviceBase = (port) => `http://127.0.0.1:${port}`;
10
+
11
+ // The recipes to mint against a service other than the running app, grouped by that service's
12
+ // workspace, each with how it starts itself. A recipe whose service is the app's own dir stays with
13
+ // the app and is not returned here.
14
+ export function servicesFor({ recipes, root, appDir, onPath }) {
15
+ const here = relative(root, appDir) || ".";
16
+ const byDir = new Map();
17
+ for (const recipe of Array.isArray(recipes) ? recipes : []) {
18
+ const dir = typeof recipe?.service === "string" ? recipe.service.replace(/^\.\//, "").replace(/\/+$/, "") : "";
19
+ if (!dir || dir === here) continue;
20
+ if (!byDir.has(dir)) byDir.set(dir, []);
21
+ byDir.get(dir).push(recipe);
22
+ }
23
+ return [...byDir].map(([dir, group]) => ({
24
+ dir,
25
+ cwd: join(root, dir),
26
+ plan: startPlan({ root: join(root, dir), onPath }),
27
+ recipes: group,
28
+ }));
29
+ }
30
+
31
+ // One sign-in per port: recipes whose sign-in is mounted in another workspace are minted against
32
+ // that workspace, the rest against the app, and the rows come back as one list. A repository whose
33
+ // sign-in is where the app is mints once, against the app, as it always did.
34
+ export async function mintAcross({ recipes, root, appDir, onPath, appPort, portFor, mint, originFor = () => undefined }) {
35
+ const groups = servicesFor({ recipes, root, appDir, onPath });
36
+ if (!groups.length) return mint(recipes, appPort);
37
+ const elsewhere = new Set(groups.flatMap((g) => g.recipes));
38
+ const rows = [];
39
+ for (const group of groups) {
40
+ const port = (await portFor(group)) ?? appPort;
41
+ rows.push(...((await mint(group.recipes, port, originFor(port)))?.identities ?? []));
42
+ }
43
+ const rest = (Array.isArray(recipes) ? recipes : []).filter((r) => !elsewhere.has(r));
44
+ if (rest.length) rows.push(...((await mint(rest, appPort))?.identities ?? []));
45
+ return { identities: rows };
46
+ }
47
+
48
+ // Wait for a service to answer on its port, bounded so a service that never comes up cannot hold
49
+ // the whole raise. Any HTTP reply means the port is up; a connection refused means keep waiting.
50
+ export async function waitForPort(port, deadlineMs = 90_000) {
51
+ const until = Date.now() + deadlineMs;
52
+ while (Date.now() < until) {
53
+ try {
54
+ await fetch(serviceBase(port) + "/", { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" });
55
+ return true;
56
+ } catch (e) {
57
+ if (!/aborted|timeout|ECONNREFUSED|ECONNRESET|fetch failed|other side closed/i.test(String(e?.cause?.code ?? e?.message ?? e))) return false;
58
+ await new Promise((r) => setTimeout(r, 500));
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
64
+ // The page a sign-in believes it is being asked from. Nearly every auth library refuses a request
65
+ // whose Origin it does not trust: better-auth answered databuddy's own sign-up with 403
66
+ // INVALID_ORIGIN because the ask went to 127.0.0.1 with no page behind it. Their own settings name
67
+ // the page; where they do not, it is that port on localhost, which is what their own browser sends.
68
+ export function originFor(port, origins = []) {
69
+ const named = origins.find((o) => { try { return new URL(o).port === String(port); } catch { return false; } });
70
+ return named ?? `http://localhost:${port}`;
71
+ }
package/local.mjs CHANGED
@@ -23,6 +23,7 @@ import { lockHolds, makeLock } from "./lib/lock.mjs";
23
23
  import { AS_HEADER, makeIdentities } from "./lib/mint.mjs";
24
24
  import { CAPTURED, makeCapture } from "./lib/replay.mjs";
25
25
  import { sampleHere } from "./lib/sample.mjs";
26
+ import { mintAcross, originFor, waitForPort } from "./lib/service.mjs";
26
27
  import { listingUrl } from "./lib/listing.mjs";
27
28
  import { installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
28
29
 
@@ -395,7 +396,11 @@ async function verb(job) {
395
396
  }
396
397
  case "changes": return door.changes();
397
398
  case "diff": return door.diff(String(b.path ?? ""));
398
- case "mint": return identities ? JSON.parse(mask(JSON.stringify(await identities.mint(b, app?.port)))) : { identities: [] };
399
+ case "mint": return identities ? JSON.parse(mask(JSON.stringify(await mintAcross({
400
+ recipes: b.recipes, root, appDir, onPath, appPort: app?.port, portFor: serviceUp,
401
+ mint: (recipes, port, origin) => identities.mint({ ...b, recipes, headers: { ...(b.headers ?? {}), ...(origin ? { origin, referer: `${origin}/` } : {}) } }, port),
402
+ originFor: (port) => originFor(port, envOrigins(envFiles)),
403
+ })))) : { identities: [] };
399
404
  case "restore": return door.restore(String(b.checkpoint ?? ""));
400
405
  case "keep": return door.keep(typeof b.checkpoint === "string" && b.checkpoint ? b.checkpoint : undefined);
401
406
  case "restart": return restartApp();
@@ -423,6 +428,57 @@ const answers = async (port) => {
423
428
  const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
424
429
  // Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
425
430
  let appDir = root;
431
+
432
+ // ---- a sign-in mounted in another workspace
433
+ // databuddy serves its AI from apps/api and mounts sign-in in apps/dashboard: a test account can
434
+ // only be made where the sign-in is, and posting a sign-up at the AI's own port is a 404.
435
+ const PORT_IN_SCRIPT = /(?:^|\s)(?:PORT=|-p[ =]|--port[ =])(\d{2,5})\b/;
436
+ // The port a framework serves on when nobody names one. sveltekit and astro before vite: both bring
437
+ // vite with them and neither uses its port.
438
+ const FRAMEWORK_PORT = [["next", 3000], ["nuxt", 3000], ["@remix-run/serve", 3000], ["@remix-run/dev", 3000], ["@sveltejs/kit", 5173], ["astro", 4321], ["vite", 5173]];
439
+ // No scan of the usual ports: whatever answers on 3000 or 8080 on this machine is very often not
440
+ // this workspace, and a test account made against somebody else's service is the worst kind of
441
+ // wrong. Only the port this workspace itself names, and if nothing is there, it is started.
442
+
443
+ // What that workspace serves on: its own script says so, or the framework it is written in does.
444
+ function servicePort(cwd) {
445
+ let pkg = null;
446
+ try { pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")); } catch { /* not a Node workspace */ }
447
+ const script = ["dev", "develop", "start:dev", "serve", "start"].map((s) => pkg?.scripts?.[s]).find(Boolean) ?? "";
448
+ const named = Number(PORT_IN_SCRIPT.exec(script)?.[1]);
449
+ if (named) return named;
450
+ const deps = Object.keys({ ...pkg?.dependencies, ...pkg?.devDependencies });
451
+ const framework = FRAMEWORK_PORT.find(([dep]) => deps.includes(dep))?.[1];
452
+ if (framework) return framework;
453
+ if (existsSync(join(cwd, "manage.py"))) return 8000;
454
+ if (existsSync(join(cwd, "Gemfile"))) return 3000;
455
+ const py = ["requirements.txt", "pyproject.toml"].map((f) => { try { return readFileSync(join(cwd, f), "utf8"); } catch { return ""; } }).join("\n");
456
+ return /\bdjango\b/i.test(py) ? 8000 : /\bflask\b/i.test(py) ? 5000 : null;
457
+ }
458
+
459
+ const toldService = new Set();
460
+ const signedInAt = (dir, port) => { if (!toldService.has(dir)) { toldService.add(dir); say(`your sign-in lives in ${dir}, so I signed in there`); } return port; };
461
+ // Everything else this command started, stopped with it.
462
+ const sidecars = [];
463
+ // The port that workspace's sign-in answers on. One it is already serving on comes first: starting
464
+ // a second copy of a dashboard that is already up costs a minute and takes its port.
465
+ async function serviceUp(group) {
466
+ const named = servicePort(group.cwd);
467
+ if (named && named !== app?.port && (await answers(named))) return signedInAt(group.dir, named);
468
+ // Its own port taken by the app leaves nothing to wait on: whatever answers there is the app.
469
+ if (!named || named === app?.port || !group.plan?.cmd) return null;
470
+ const kid = spawn("/bin/sh", ["-c", group.plan.cmd], {
471
+ cwd: group.plan.cwd,
472
+ env: { ...process.env, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) },
473
+ stdio: ["ignore", "pipe", "pipe"], detached: true,
474
+ });
475
+ sidecars.push(kid);
476
+ const keep = (d) => appendFileSync(bootLog, d.toString());
477
+ kid.stdout.on("data", keep);
478
+ kid.stderr.on("data", keep);
479
+ return (await waitForPort(named, 90_000)) ? signedInAt(group.dir, named) : null;
480
+ }
481
+
426
482
  async function ask(question) {
427
483
  if (!process.stdin.isTTY) return null;
428
484
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -719,7 +775,10 @@ if (!files.length) fail("no source files here to read.");
719
775
  // Which project this is, as a hash of where it lives: the same folder coming back resumes the same
720
776
  // connection, and the path itself never leaves this machine.
721
777
  const project = createHash("sha256").update(realpathSync(root)).digest("hex").slice(0, 16);
722
- const attach = await call("POST", "/local/attach", { code, name: basename(root), project });
778
+ // A network that drops while connecting ends here in a sentence, never a stack trace: running the
779
+ // command again starts a clean connection.
780
+ const unreachable = (err) => fail(`could not reach ${origin.host}: ${err?.name === "TimeoutError" ? "it did not answer in time" : "the connection failed"}. Check your connection and run the command again.`);
781
+ const attach = await call("POST", "/local/attach", { code, name: basename(root), project }).catch(unreachable);
723
782
  if (!attach.ok) fail(attach.data?.error ?? `could not sign in (${attach.status})`);
724
783
  box = attach.data.box;
725
784
  key = attach.data.key;
@@ -759,7 +818,7 @@ const parts = Math.max(1, Math.ceil(bytes.length / PART));
759
818
  for (let off = 0; off < bytes.length; off += PART) {
760
819
  const last = off + PART >= bytes.length;
761
820
  step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
762
- const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART), { raw: true, timeoutMs: 120_000 });
821
+ const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART), { raw: true, timeoutMs: 120_000 }).catch(unreachable);
763
822
  if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
764
823
  if (last) resumed = put.data?.resumed === true;
765
824
  }
@@ -868,6 +927,7 @@ async function close(code = 0) {
868
927
  closing = true;
869
928
  await call("DELETE", `/local/${box}`).catch(() => {});
870
929
  if (child?.pid) await stopApp(child.pid);
930
+ for (const kid of sidecars) if (kid.pid) await stopApp(kid.pid);
871
931
  const pending = door?.pending() ?? 0;
872
932
  if (pending) say(`${pending} agent edit${pending === 1 ? " is" : "s are"} waiting for you to keep or undo. Run the command again to review ${pending === 1 ? "it" : "them"} in the browser.`);
873
933
  await exec("rm", ["-rf", work]).catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
@@ -16,6 +16,7 @@
16
16
  "lib/pyhook/sitecustomize.py",
17
17
  "lib/replay.mjs",
18
18
  "lib/sample.mjs",
19
+ "lib/service.mjs",
19
20
  "lib/start.mjs",
20
21
  "lib/trace.cjs",
21
22
  "local.mjs"