infracensus-collector 1.1.2 → 1.1.3

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.
Files changed (43) hide show
  1. package/dist/a10-index.js +2 -2
  2. package/dist/azure-index.js +6 -6
  3. package/dist/{chunk-TEEGT33B.js → chunk-ASU5IADY.js} +8 -2
  4. package/dist/{chunk-TEEGT33B.js.map → chunk-ASU5IADY.js.map} +2 -2
  5. package/dist/{chunk-2PED6PTT.js → chunk-CXCA2TI4.js} +2 -2
  6. package/dist/{chunk-5TRYP3HM.js → chunk-CYDM3P2U.js} +2 -2
  7. package/dist/{chunk-4CH7OHPW.js → chunk-GZEDFM4N.js} +8 -1
  8. package/dist/chunk-GZEDFM4N.js.map +7 -0
  9. package/dist/{chunk-SU6AWS4X.js → chunk-HQDLQHZ5.js} +48 -9
  10. package/dist/chunk-HQDLQHZ5.js.map +7 -0
  11. package/dist/{chunk-5Z2BXF7Z.js → chunk-HXIPFDDB.js} +2 -2
  12. package/dist/{chunk-P2LKSPTC.js → chunk-KGGDNTMB.js} +2 -2
  13. package/dist/{chunk-XSCBI63T.js → chunk-NB7JXKCQ.js} +3 -3
  14. package/dist/{chunk-BW2PKM6Y.js → chunk-O42IVAGK.js} +2 -2
  15. package/dist/{chunk-3LEKWH7O.js → chunk-QGR6XRZZ.js} +2 -2
  16. package/dist/{chunk-L2R6VSJJ.js → chunk-XTZQ4SRI.js} +2 -2
  17. package/dist/{chunk-UW2244GW.js → chunk-YWDNT4T2.js} +2 -2
  18. package/dist/cli.js +2 -2
  19. package/dist/{config-XU3ZWNOH.js → config-3KFRZUFD.js} +3 -3
  20. package/dist/configimport-index.js +4 -4
  21. package/dist/hyperv-index.js +6 -6
  22. package/dist/identity-index.js +6 -6
  23. package/dist/index.js +152 -16
  24. package/dist/index.js.map +2 -2
  25. package/dist/sensitive-data-index.js +5 -5
  26. package/dist/sql-index.js +6 -6
  27. package/dist/{status-JAKICYID.js → status-TOMHWKUW.js} +4 -4
  28. package/dist/vmware-index.js +6 -6
  29. package/dist/voice-index.js +6 -6
  30. package/package.json +1 -1
  31. package/dist/chunk-4CH7OHPW.js.map +0 -7
  32. package/dist/chunk-SU6AWS4X.js.map +0 -7
  33. /package/dist/{chunk-2PED6PTT.js.map → chunk-CXCA2TI4.js.map} +0 -0
  34. /package/dist/{chunk-5TRYP3HM.js.map → chunk-CYDM3P2U.js.map} +0 -0
  35. /package/dist/{chunk-5Z2BXF7Z.js.map → chunk-HXIPFDDB.js.map} +0 -0
  36. /package/dist/{chunk-P2LKSPTC.js.map → chunk-KGGDNTMB.js.map} +0 -0
  37. /package/dist/{chunk-XSCBI63T.js.map → chunk-NB7JXKCQ.js.map} +0 -0
  38. /package/dist/{chunk-BW2PKM6Y.js.map → chunk-O42IVAGK.js.map} +0 -0
  39. /package/dist/{chunk-3LEKWH7O.js.map → chunk-QGR6XRZZ.js.map} +0 -0
  40. /package/dist/{chunk-L2R6VSJJ.js.map → chunk-XTZQ4SRI.js.map} +0 -0
  41. /package/dist/{chunk-UW2244GW.js.map → chunk-YWDNT4T2.js.map} +0 -0
  42. /package/dist/{config-XU3ZWNOH.js.map → config-3KFRZUFD.js.map} +0 -0
  43. /package/dist/{status-JAKICYID.js.map → status-TOMHWKUW.js.map} +0 -0
package/dist/a10-index.js CHANGED
@@ -4,10 +4,10 @@ import {
4
4
  } from "./chunk-7EGPKFND.js";
5
5
  import {
6
6
  requireEntitlementToScan
7
- } from "./chunk-TEEGT33B.js";
7
+ } from "./chunk-ASU5IADY.js";
8
8
  import {
9
9
  ROUTES
10
- } from "./chunk-4CH7OHPW.js";
10
+ } from "./chunk-GZEDFM4N.js";
11
11
  import "./chunk-XSHYWQAH.js";
12
12
 
13
13
  // src/a10-index.ts
@@ -2,18 +2,18 @@ import {
2
2
  ArmClient,
3
3
  buildAzureInventory,
4
4
  listAccessibleSubscriptions
5
- } from "./chunk-2PED6PTT.js";
5
+ } from "./chunk-CXCA2TI4.js";
6
6
  import {
7
7
  pushAzureInventory
8
- } from "./chunk-XSCBI63T.js";
9
- import "./chunk-5TRYP3HM.js";
8
+ } from "./chunk-NB7JXKCQ.js";
9
+ import "./chunk-CYDM3P2U.js";
10
10
  import {
11
11
  loadAzureConfig
12
- } from "./chunk-BW2PKM6Y.js";
12
+ } from "./chunk-O42IVAGK.js";
13
13
  import {
14
14
  requireEntitlementToScan
15
- } from "./chunk-TEEGT33B.js";
16
- import "./chunk-4CH7OHPW.js";
15
+ } from "./chunk-ASU5IADY.js";
16
+ import "./chunk-GZEDFM4N.js";
17
17
  import "./chunk-XSHYWQAH.js";
18
18
 
19
19
  // src/azure-index.ts
@@ -4,7 +4,7 @@ import {
4
4
  CollectorEntitlementResult,
5
5
  ROUTES,
6
6
  collectorBearer
7
- } from "./chunk-4CH7OHPW.js";
7
+ } from "./chunk-GZEDFM4N.js";
8
8
  import {
9
9
  PKG_ROOT
10
10
  } from "./chunk-XSHYWQAH.js";
@@ -406,6 +406,11 @@ function noteEntitlementRefusalFromPlane(input) {
406
406
  function collectorOwnsModule(moduleKey) {
407
407
  return processGate?.ownsModule(moduleKey) ?? false;
408
408
  }
409
+ async function refreshCollectorEntitlement() {
410
+ if (processGate === null) return false;
411
+ await processGate.check();
412
+ return true;
413
+ }
409
414
  function collectorEntitlementStatus(cachePath) {
410
415
  if (processGate !== null) return processGate.status();
411
416
  const cached = cachePath === void 0 ? readCachedVerdict() : readCachedVerdict(cachePath);
@@ -468,8 +473,9 @@ export {
468
473
  setProcessEntitlementGate,
469
474
  noteEntitlementRefusalFromPlane,
470
475
  collectorOwnsModule,
476
+ refreshCollectorEntitlement,
471
477
  collectorEntitlementStatus,
472
478
  requireEntitlementToScan,
473
479
  assertEntitlementConfirmed
474
480
  };
475
- //# sourceMappingURL=chunk-TEEGT33B.js.map
481
+ //# sourceMappingURL=chunk-ASU5IADY.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/entitlement/verdict.ts", "../src/entitlement/client.ts", "../src/entitlement/cache.ts", "../src/entitlement/gate.ts", "../src/entitlement/require.ts"],
4
- "sourcesContent": ["/**\n * THE VERDICT \u2014 what the plane said about this tenant's right to run a\n * collector, and what an operator is supposed to do about it.\n *\n * \u2500\u2500 WHY THIS EXISTS AT ALL, GIVEN core/planRefusal.ts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * `planRefusal.ts` closes with a paragraph headed \"THE GAP THIS DOES NOT\n * CLOSE\", and this file is the code that closes it. Quoted, because the\n * argument is still exactly right and this must not be read as overturning it:\n *\n * \"A refusal is learned at PUSH \u2014 which for a one-shot `netdoc-collector\n * scan vmware` is after the collector has already logged into the\n * customer's vCenter and read their whole inventory. That connection is\n * precisely what the module gate exists to prevent, and no amount of error\n * handling here prevents it: the collector has no way to ask what this\n * tenant is allowed to scan.\"\n *\n * The collector now has a way to ask (`ROUTES.collectorEntitlement`), so the\n * refusal can be learned BEFORE the login instead of after it.\n *\n * \u2500\u2500 THE ONE RULE FROM planRefusal.ts THAT SURVIVES UNCHANGED \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * That file also says, under \"THE COLLECTOR NEVER DECIDES IT IS ENTITLED\":\n *\n * \"If this file ever grows a function that returns 'yes, you may scan', the\n * server has stopped being the authority.\"\n *\n * It has not grown one, and neither has this one. Nothing here evaluates a\n * licence, reads `limits.collector`, or compares a date to decide access. The\n * only thing that can produce an ALLOWED verdict is a 200 from a route the\n * control plane refuses to serve unless the tenant is entitled \u2014 the grant is\n * still entirely the server's, and this file only classifies the answer. What\n * changed is WHEN we ask, not who decides.\n *\n * \u2500\u2500 THREE REFUSALS, BECAUSE THREE DIFFERENT PEOPLE FIX THEM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * All three stop work identically. They are kept apart because the operator's\n * next move is different in each case, and a collector that printed one message\n * for all three would send somebody to the billing page over a firewall rule:\n *\n * not_paid the plan does not include a collector. Somebody with a\n * purchasing decision has to UPGRADE. Nothing on this host is\n * broken and nothing here will fix it.\n * deactivated the authority this host had was withdrawn at the plane \u2014 the\n * licence lapsed, the account was suspended, or this\n * collector's key was revoked. Somebody with billing or admin\n * access has to RENEW or re-enrol.\n * unconfirmed we could not get an answer. The plane was unreachable, or\n * answered with something we could not read. Somebody with\n * network access has to FIX THE PATH \u2014 and until they do,\n * nothing runs.\n *\n * \u2500\u2500 WHY `unconfirmed` STOPS WORK, WHEN A GRACE PERIOD WOULD BE KINDER \u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * It was put to the operator directly, with the cost stated: fail closed\n * immediately, and a control-plane outage pauses a paying customer's scheduled\n * scans. They chose it over a grace window anyway, on 2026-09-01. Do not add\n * one back because it looks kinder \u2014 the decision is on the record, and the\n * reason it is defensible is that the alternative has no natural end. A grace\n * period is a period during which an unentitled collector scans a customer's\n * network with stored credentials, and every value for its length is arbitrary:\n * whatever number is picked, the tenant who lapsed the day before gets exactly\n * that long of free, unauthorised access to their own estate on our software.\n *\n * The three refusals are also why `deactivated` is not folded into\n * `unconfirmed`. A 401 is an ANSWER \u2014 a definite, authoritative \"your authority\n * is not valid\". Calling that \"we could not confirm\" would send an operator to\n * check their firewall over an expired invoice.\n */\n\nimport { ApiError, CollectorEntitlementResult } from \"@netdoc/collector-contract\";\n\n/** Why work is refused. Never collapsed into one \u2014 see the header. */\nexport type EntitlementRefusalReason = \"not_paid\" | \"deactivated\" | \"unconfirmed\";\n\n/** Every reason, for exhaustive iteration in tests and in the UI. */\nexport const ENTITLEMENT_REFUSAL_REASONS = [\n \"not_paid\",\n \"deactivated\",\n \"unconfirmed\",\n] as const satisfies readonly EntitlementRefusalReason[];\n\n/**\n * The plane said yes.\n *\n * `confirmedAtLocal` is stamped from THIS host's clock and is the only field\n * staleness is measured against. `confirmedAt` is the plane's clock, carried\n * for display: comparing one machine's clock to another's is where skew stops\n * being a curiosity and starts being a wrong answer, so the two are separate\n * fields and only one of them is ever subtracted from `Date.now()`.\n */\nexport interface EntitlementAllowed {\n allowed: true;\n /** This host's clock at the moment the answer arrived. Staleness uses THIS. */\n confirmedAtLocal: string;\n /** The plane's clock, verbatim, for the operator to read. */\n confirmedAt: string;\n /** Display only. Never re-evaluated here \u2014 see `CollectorEntitlementResult`. */\n licenceExpiresAt: string;\n /** Display and audit only, exactly as the licence payload says. */\n tier: string | null;\n tenantId: string;\n collectorId: string;\n /**\n * The collector/scan modules this tenant OWNS, exactly as the PLANE computed\n * them (`CollectorEntitlementResult.modules`). Authoritative and read\n * fail-closed by `gate.ownsModule`: a module absent here is not owned, and the\n * collector never derives this itself. Carried on the verdict so the ONE\n * per-process gate answers \"may I run THIS scan\" from the same confirmed\n * answer it uses for \"may I scan at all\".\n */\n modules: readonly string[];\n}\n\n/** The plane said no, or could not be asked. */\nexport interface EntitlementRefused {\n allowed: false;\n reason: EntitlementRefusalReason;\n /** This host's clock when we last tried. */\n checkedAtLocal: string;\n /**\n * The sentence an operator can act on. Ours, because it knows the local\n * context (\"nothing on this host will scan\"); the plane's own words appended\n * in quotes, because they know the tenant's. The same convention\n * `planRefusal.ts` uses, and for the same reason: a refusal carrying only one\n * of the two leaves whoever reads it guessing at the other.\n */\n message: string;\n /** The plane's own words, verbatim, or null when it never answered. */\n planeMessage: string | null;\n /** The HTTP status, for diagnosis. Null when the request did not complete. */\n httpStatus: number | null;\n}\n\nexport type EntitlementVerdict = EntitlementAllowed | EntitlementRefused;\n\n/**\n * How long a confirmation may back the SYNCHRONOUS backstop before it reads as\n * unconfirmed.\n *\n * \u2500\u2500 THIS IS NOT THE GRACE PERIOD THAT WAS DECLINED \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * Nothing starts work on a cached verdict. Every path that begins a scan awaits\n * a fresh `require()` that goes to the plane, and refuses if it cannot. What\n * this bound governs is the second, cheap, synchronous assertion made further\n * down the call stack \u2014 inside `runCollector`, inside each domain scan \u2014 whose\n * job is to catch a caller that skipped the async check entirely, not to grant\n * time to one that failed it.\n *\n * Ten minutes is chosen to be comfortably longer than the gap between the async\n * check and the scan it authorised (milliseconds), and comfortably shorter than\n * any interval on which an entitlement realistically changes. If it were zero,\n * a scan that took a second to start would trip its own backstop; if it were a\n * day, the backstop would stop being one.\n */\nexport const CONFIRMATION_MAX_AGE_MS = 10 * 60_000;\n\n/**\n * Is this verdict fresh enough for the synchronous backstop?\n *\n * NEGATIVE AGE IS STALE, and that is the clock-skew rule. A confirmation\n * stamped in the future means the host's clock moved backwards between the\n * check and now (an NTP correction, a VM resuming from a snapshot, a\n * hand-edited clock). We cannot say how much time really passed, so we say we\n * do not know \u2014 which, here, means refuse and re-ask. Treating it as fresh\n * because `now - then` happens to be a small negative number is the exact\n * shape of a fail-open bug: it is most wrong precisely when the clock is most\n * wrong.\n */\nexport function confirmationIsFresh(\n verdict: EntitlementVerdict,\n nowMs: number = Date.now(),\n maxAgeMs: number = CONFIRMATION_MAX_AGE_MS,\n): boolean {\n if (!verdict.allowed) return false;\n const stampedMs = Date.parse(verdict.confirmedAtLocal);\n if (!Number.isFinite(stampedMs)) return false;\n const ageMs = nowMs - stampedMs;\n return ageMs >= 0 && ageMs <= maxAgeMs;\n}\n\n/** Trim and cap the plane's prose before it goes anywhere near a log line. */\nfunction tidy(planeMessage: string | null): string | null {\n if (planeMessage === null) return null;\n const t = planeMessage.trim();\n if (t.length === 0) return null;\n return t.length > 500 ? `${t.slice(0, 500)}\u2026` : t;\n}\n\n/** ` (the control plane said: \"\u2026\")`, or nothing when it said nothing. */\nfunction said(planeMessage: string | null): string {\n const t = tidy(planeMessage);\n return t === null ? \"\" : ` (the control plane said: \"${t}\")`;\n}\n\n/**\n * The sentence, written for somebody standing at a terminal on hardware they\n * administer, who has just been told \"no\" by a machine they do not. Every\n * branch ends with what they \u2014 or a colleague with a different login \u2014 can\n * actually do about it, and none of them suggests reinstalling anything,\n * because reinstalling has never been the answer to any of the three.\n */\nexport function refusalSentence(\n reason: EntitlementRefusalReason,\n planeMessage: string | null,\n): string {\n switch (reason) {\n case \"not_paid\":\n return (\n \"this tenant's plan does not include an on-premises collector, so nothing on this \" +\n \"host will scan. No credential, schedule or enrolment here needs changing \u2014 the \" +\n \"plan does. Someone with purchasing access can upgrade it in the portal.\" +\n said(planeMessage)\n );\n case \"deactivated\":\n return (\n \"the control plane has withdrawn this collector's authority to run \u2014 the tenant's \" +\n \"license has lapsed, the account is suspended, or this collector's key was revoked. \" +\n \"Scanning is PAUSED, not broken. Read the plane's own words below: they say whether \" +\n \"someone with billing access should renew, or whether this host needs re-enrolling.\" +\n said(planeMessage)\n );\n case \"unconfirmed\":\n return (\n \"this collector could not confirm with the control plane that its tenant is on a \" +\n \"paid, active plan, so it will not start any scan. This is deliberate: an \" +\n \"unconfirmed license stops work immediately rather than running on trust. Restore \" +\n \"the collector's network path to the control plane and it resumes by itself, with \" +\n \"no restart and nothing to re-enrol.\" +\n said(planeMessage)\n );\n }\n}\n\n/**\n * Build a refusal, with the sentence already composed.\n *\n * `localDetail` is for a fact only THIS host knows \u2014 the transport error behind\n * an unreachable plane, the reason a cache file could not be read. It is kept\n * apart from `planeMessage` rather than merged into it because the difference\n * between \"the plane said no\" and \"the plane never spoke\" is the difference\n * between `deactivated` and `unconfirmed`, and a reader (or a support engineer\n * reading a log) must be able to tell which words came from where. `planeMessage`\n * staying null is the machine-readable form of \"it did not answer\".\n */\nexport function refuse(init: {\n reason: EntitlementRefusalReason;\n planeMessage?: string | null;\n localDetail?: string | null;\n httpStatus?: number | null;\n nowIso?: string;\n}): EntitlementRefused {\n const planeMessage = tidy(init.planeMessage ?? null);\n const detail = tidy(init.localDetail ?? null);\n return {\n allowed: false,\n reason: init.reason,\n checkedAtLocal: init.nowIso ?? new Date().toISOString(),\n message: refusalSentence(init.reason, planeMessage) + (detail === null ? \"\" : ` [${detail}]`),\n planeMessage,\n httpStatus: init.httpStatus ?? null,\n };\n}\n\n/**\n * Classify one HTTP answer from `ROUTES.collectorEntitlement`.\n *\n * PURE, and separated from the fetch on purpose. Fail-closed code is the code\n * most likely to be wrong in the safe-looking direction, so every refusal here\n * \u2014 expired licence, `collector: false`, a plane returning HTML, a 500, a body\n * that parses as JSON but is not the result \u2014 has to be reachable in a test\n * without a network, a server, or a clock. A classifier tangled up with\n * `fetch` gets tested on its happy path and nowhere else.\n *\n * \u2500\u2500 THE MAPPING, AND THE ONE JUDGEMENT CALL IN IT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * 2xx + a body that parses ALLOWED. The middleware verified the signature,\n * the expiry and `limits.collector` before the\n * handler ran; the body cannot say \"no\".\n * 2xx + a body that does not unconfirmed. A 200 carrying something we cannot\n * read is not a grant. This is the case a\n * captive portal, a proxy error page or a\n * half-deployed plane produces, and it is exactly\n * where a lazier parse would fail open.\n * 403 / 402 not_paid. The plane's own tier refusal.\n * 401 deactivated. See below.\n * 404 / 405 unconfirmed. A plane that does not know the\n * question has confirmed nothing. This is what a\n * collector newer than its control plane sees,\n * and it fails closed \u2014 which is the cost the\n * operator accepted when they declined a grace\n * window.\n * anything else (5xx, 429\u2026) unconfirmed.\n *\n * The judgement call is 401. It arrives for an expired licence, a suspended\n * account, a tenant mismatch AND a revoked collector key \u2014 and the fix for the\n * last of those is re-enrolment, not renewal. It is still `deactivated` rather\n * than a fourth reason, because all four are the same fact from the operator's\n * side (the plane has withdrawn this host's authority) and the plane's own\n * sentence, carried through verbatim, is what distinguishes them: \"this\n * collector has been revoked \u2014 re-enrol it from the portal\" and \"license\n * invalid or expired\" are unmistakable, and neither is improved by our\n * inventing a category name for it. What matters is that a 401 is never read as\n * `unconfirmed`, which would blame the network for a billing fact.\n */\nexport function classifyEntitlementAnswer(input: {\n status: number;\n /** The raw body. Parsed here rather than by the caller, so an unparseable\n * body is classified by the same function that classifies a status. */\n body: string;\n nowIso?: string;\n}): EntitlementVerdict {\n const nowIso = input.nowIso ?? new Date().toISOString();\n const planeMessage = apiErrorMessage(input.body);\n\n if (input.status >= 200 && input.status < 300) {\n const parsed = parseResult(input.body);\n if (parsed === null) {\n return refuse({\n reason: \"unconfirmed\",\n httpStatus: input.status,\n nowIso,\n planeMessage:\n \"the control plane answered the entitlement check with a body this collector \" +\n \"could not read, so no answer was received\",\n });\n }\n return {\n allowed: true,\n confirmedAtLocal: nowIso,\n confirmedAt: parsed.confirmedAt,\n licenceExpiresAt: parsed.licenceExpiresAt,\n tier: parsed.tier,\n tenantId: parsed.tenantId,\n collectorId: parsed.collectorId,\n modules: parsed.modules,\n };\n }\n\n // 402 is not a code this plane sends today. It is mapped anyway because it is\n // the one status a payment intermediary in front of us WOULD send, and it\n // means precisely \"not paid\" \u2014 a default of `unconfirmed` for it would tell an\n // operator to check their network over an unpaid invoice.\n if (input.status === 403 || input.status === 402) {\n return refuse({ reason: \"not_paid\", httpStatus: input.status, nowIso, planeMessage });\n }\n if (input.status === 401) {\n return refuse({ reason: \"deactivated\", httpStatus: input.status, nowIso, planeMessage });\n }\n return refuse({ reason: \"unconfirmed\", httpStatus: input.status, nowIso, planeMessage });\n}\n\n/**\n * Parse the 200 body. Returns null for anything that is not exactly the result\n * \u2014 including a JSON object with the right field names but `entitled: false`,\n * which `z.literal(true)` refuses in the schema and which must never be read as\n * a grant here either.\n *\n * Imported lazily-shaped: the schema lives in the contract so the plane and this\n * file cannot disagree about the wire.\n */\nfunction parseResult(body: string): {\n tenantId: string;\n collectorId: string;\n tier: string | null;\n licenceExpiresAt: string;\n confirmedAt: string;\n modules: readonly string[];\n} | null {\n let json: unknown;\n try {\n json = JSON.parse(body);\n } catch {\n return null;\n }\n const parsed = CollectorEntitlementResult.safeParse(json);\n if (!parsed.success) return null;\n return {\n tenantId: parsed.data.tenantId,\n collectorId: parsed.data.collectorId,\n tier: parsed.data.tier,\n licenceExpiresAt: parsed.data.licenceExpiresAt,\n confirmedAt: parsed.data.confirmedAt,\n // The AUTHORITATIVE owned-module list, straight off the wire. `.strict()` on\n // `CollectorEntitlementResult` guarantees it is present (an old plane that\n // omits it fails the parse above, which becomes `unconfirmed` \u2014 fail closed).\n modules: parsed.data.modules,\n };\n}\n\n/** The plane's `error` string out of an ApiError body, or null if it sent none. */\nfunction apiErrorMessage(body: string): string | null {\n let json: unknown;\n try {\n json = JSON.parse(body);\n } catch {\n // Not JSON at all \u2014 a proxy's HTML error page, an empty body, a truncated\n // response. Carrying a snippet of HTML into an operator's log helps nobody;\n // the status is already recorded on the refusal.\n return null;\n }\n const parsed = ApiError.safeParse(json);\n return parsed.success ? parsed.data.error : null;\n}\n", "import { ROUTES, COLLECTOR_AUTH_HEADER, collectorBearer } from \"@netdoc/collector-contract\";\nimport type { AppConfig } from \"../config.js\";\nimport { classifyEntitlementAnswer, refuse, type EntitlementVerdict } from \"./verdict.js\";\n\n/**\n * Asking the plane. One request, one classification, no decisions.\n *\n * \u2500\u2500 THE SUBSET OF CONFIG THIS NEEDS, AND WHY IT IS A SUBSET \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * `Pick`ed rather than taking `AppConfig`, exactly as `PollConfig` in\n * `core/poll.ts` is, because the callers are not all daemons. The one-shot\n * module entry points (`vmware-index.ts`, `sql-index.ts`, \u2026) load their own\n * narrower config shapes, and every one of them carries these two fields \u2014\n * making the parameter the full `AppConfig` would have meant either widening\n * nine config loaders or leaving nine scan paths ungated, and the second is how\n * `pnpm vmware-scan` stays wide open.\n */\nexport type EntitlementCheckConfig = Pick<AppConfig, \"controlPlaneUrl\" | \"apiKey\">;\n\n/**\n * How long to wait for the answer.\n *\n * SHORTER than `core/poll.ts`'s 30s, deliberately. A poll that hangs delays the\n * next poll; an entitlement check that hangs delays the START OF WORK on every\n * path in the process, including a one-shot scan somebody is watching at a\n * terminal. Ten seconds is long enough for a slow link and short enough that a\n * black-holed control plane produces a refusal an operator can read rather than\n * a command that appears to have frozen.\n *\n * The timeout produces `unconfirmed`, not a crash, and that is the fail-closed\n * outcome: nothing runs.\n */\nconst ENTITLEMENT_TIMEOUT_MS = 10_000;\n\n/**\n * Ask the control plane whether this collector's tenant may run one.\n *\n * NEVER THROWS. Every failure \u2014 DNS, TLS, refused connection, timeout, a proxy's\n * HTML error page, a 500, an abort \u2014 becomes an `unconfirmed` refusal, because a\n * gate whose check can throw is a gate that a missing `try` upstream turns into\n * an unhandled rejection, and an unhandled rejection in a daemon loop is exactly\n * the shape of \"the scheduler silently stopped\". A total function here means the\n * only two things a caller can receive are \"yes\" and \"no, because\", and both are\n * values it must handle.\n *\n * The abort signal is honoured so a shutting-down daemon does not sit in a\n * ten-second wait; an abort classifies as `unconfirmed` like any other\n * non-answer, which is correct \u2014 we are shutting down, and nothing should start.\n */\nexport async function fetchEntitlement(\n cfg: EntitlementCheckConfig,\n signal?: AbortSignal,\n): Promise<EntitlementVerdict> {\n const url = cfg.controlPlaneUrl.replace(/\\/+$/, \"\") + ROUTES.collectorEntitlement;\n const timeout = AbortSignal.timeout(ENTITLEMENT_TIMEOUT_MS);\n const reqSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;\n try {\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [COLLECTOR_AUTH_HEADER]: collectorBearer(cfg.apiKey),\n },\n // The route reads no body. An empty JSON object is sent rather than\n // nothing so that a proxy or WAF that dislikes a POST with no body does\n // not turn a licence check into a 411 \u2014 which would classify as\n // `unconfirmed` and pause a paying customer over a header.\n body: \"{}\",\n signal: reqSignal,\n });\n return classifyEntitlementAnswer({ status: res.status, body: await res.text() });\n } catch (err) {\n // The transport error goes in OUR sentence, never passed off as the plane's\n // words, because the plane never spoke. `planeMessage` staying null is what\n // lets a reader tell \"it did not answer\" from \"it said no\".\n return refuse({\n reason: \"unconfirmed\",\n planeMessage: null,\n localDetail: describeTransportFailure(err),\n httpStatus: null,\n });\n }\n}\n\n/**\n * The transport failure, appended to the operator's sentence.\n *\n * Separate from `fetchEntitlement` only so the message composition stays in one\n * place; `refuse()` already writes the standing advice, and this adds the one\n * detail that changes per failure \u2014 \"getaddrinfo ENOTFOUND\", \"connect\n * ECONNREFUSED\", \"The operation was aborted due to timeout\". Without it an\n * operator reading the log knows their collector is paused and not why.\n */\nexport function describeTransportFailure(err: unknown): string {\n if (!(err instanceof Error)) return String(err);\n let msg = err.message;\n // Node's global fetch throws a bare \"fetch failed\" and hides the real reason\n // in `.cause` \u2014 the same unwrap `core/domainScan.ts` does, for the same\n // reason: \"fetch failed\" alone is useless for diagnosis.\n const cause = (err as { cause?: unknown }).cause;\n if (cause) {\n const code = (cause as { code?: string }).code;\n const cmsg = cause instanceof Error ? cause.message : String(cause);\n const extra = cmsg || code;\n if (extra && !msg.includes(extra)) msg += `: ${extra}`;\n }\n return msg;\n}\n", "/**\n * THE LAST VERDICT, ON DISK \u2014 so a restart refuses before it has asked.\n *\n * \u2500\u2500 WHAT THIS FILE IS FOR, AND WHAT IT IS EMPHATICALLY NOT FOR \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * It is NOT an offline licence. Nothing in this process ever starts a scan on\n * the strength of what is in this file: every path that begins work awaits a\n * live answer from the control plane first (see `gate.ts`). A cached ALLOWED is\n * worth exactly one thing \u2014 the localhost UI and the local scheduler can render\n * and reason about a plausible state in the seconds between process start and\n * the first live check completing, instead of showing a blank or, worse,\n * defaulting to something permissive.\n *\n * What it is for is the moment immediately after a restart. Without it, a\n * collector that comes back up has no verdict at all, and \"no verdict\" has to\n * mean \"refuse\" \u2014 which is right, and which would also mean the status pane says\n * \"unconfirmed\" on every restart of a perfectly healthy paying customer, for as\n * long as it takes the first check to land. With it, the pane says what was last\n * true and the gate still refuses to start work until the live check agrees.\n *\n * \u2500\u2500 FAIL CLOSED ON A MISSING OR UNREADABLE FILE, WHICH IS THE EASY HALF \u2500\u2500\u2500\u2500\u2500\n *\n * `readCachedVerdict` returns an `unconfirmed` refusal for a file that is\n * absent, unreadable, not JSON, JSON of the wrong shape, or of a version this\n * build does not know. It never returns null and never throws, so there is no\n * \"no answer\" case for a caller to get wrong \u2014 the only two things it can hand\n * back are a verdict and a refusal, and a caller that ignores the difference\n * still refuses.\n *\n * The harder half is that a cache is a file an attacker with local write access\n * can author. They could write `{allowed: true}` into it. That gains them\n * nothing, because of the rule at the top: work needs a LIVE 200 from the plane,\n * and this file cannot manufacture one. It is written 0600 all the same, and for\n * the same reason `schedule/store.ts` gives \u2014 integrity, not secrecy. It carries\n * no secret: a tier name, two timestamps and two ids that are already in the\n * config file beside it.\n *\n * \u2500\u2500 WHY THIS DOES NOT REUSE schedule/store.ts's writeJsonAtomic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * It is the same eight lines, and copying them is still right: `schedule/`\n * imports the entitlement gate (the scheduler must not hand out a run to an\n * unentitled tenant), so importing back the other way would be a cycle between\n * two modules that have no business knowing about each other. Entitlement is\n * lower in the stack than scheduling and must not depend on it.\n */\nimport { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { z } from \"zod\";\n// Found, not hop-counted: the bundled build flattens src/ into dist/, so a\n// dirname() count that was right in src/ would resolve one directory out. The\n// same reasoning `schedule/store.ts` and `ui/status.ts` record.\nimport { PKG_ROOT } from \"../pkgRoot.js\";\nimport { refuse, type EntitlementVerdict } from \"./verdict.js\";\n\n/** Where the last verdict is kept. Overridable so tests never touch a real one. */\nexport function entitlementCachePath(): string {\n return (\n process.env.COLLECTOR_ENTITLEMENT_CACHE_FILE ??\n join(PKG_ROOT, \"collector-entitlement.json\")\n );\n}\n\n/**\n * The persisted shape.\n *\n * MIRRORED as a zod schema rather than trusted as a cast, for the reason\n * `schedule/store.ts` gives about its own state file: this is a file on a disk\n * somebody else administers, and one truncated by a full volume or edited by a\n * curious operator must fail to parse rather than feed `undefined` into a gate.\n *\n * `version` is a literal. A file written by a future build with a different\n * shape reads as unparseable here \u2014 which fails closed, and is the safe\n * direction for a downgrade.\n */\nconst CachedVerdictFile = z.object({\n version: z.literal(1),\n verdict: z.union([\n z.object({\n allowed: z.literal(true),\n confirmedAtLocal: z.string(),\n confirmedAt: z.string(),\n licenceExpiresAt: z.string(),\n tier: z.string().nullable(),\n tenantId: z.string(),\n collectorId: z.string(),\n // The owned-module list, so a restart can SHOW which scans were last\n // permitted. Required for the same reason the rest of the shape is: a file\n // written by an older build that lacks it fails to parse and reads as an\n // `unconfirmed` refusal \u2014 the fail-closed direction \u2014 and the first live\n // check rewrites it. Nothing starts work on a cached list either way: the\n // synchronous per-module backstop, like the paid-collector one, refuses\n // until THIS process has re-confirmed.\n modules: z.array(z.string()),\n }),\n z.object({\n allowed: z.literal(false),\n reason: z.enum([\"not_paid\", \"deactivated\", \"unconfirmed\"]),\n checkedAtLocal: z.string(),\n message: z.string(),\n planeMessage: z.string().nullable(),\n httpStatus: z.number().nullable(),\n }),\n ]),\n});\n\n/**\n * Read the last verdict, or an `unconfirmed` refusal.\n *\n * Every failure mode collapses to the same safe answer, and the `localDetail`\n * says which one it was \u2014 because \"you have never run this collector\" and \"your\n * cache file is corrupt\" are different problems and only the second needs\n * anybody's attention.\n */\nexport function readCachedVerdict(path = entitlementCachePath()): EntitlementVerdict {\n let raw: string;\n try {\n raw = readFileSync(path, \"utf8\");\n } catch {\n return refuse({\n reason: \"unconfirmed\",\n localDetail: `no entitlement has been confirmed on this host yet (${path} is absent or unreadable)`,\n });\n }\n let json: unknown;\n try {\n json = JSON.parse(raw);\n } catch {\n return refuse({\n reason: \"unconfirmed\",\n localDetail: `${path} is not valid JSON; the last entitlement answer has been discarded`,\n });\n }\n const parsed = CachedVerdictFile.safeParse(json);\n if (!parsed.success) {\n return refuse({\n reason: \"unconfirmed\",\n localDetail: `${path} does not match the format this collector writes; the last entitlement answer has been discarded`,\n });\n }\n return parsed.data.verdict;\n}\n\n/**\n * Write the verdict, atomically.\n *\n * NEVER THROWS. A collector that cannot write this file still works perfectly \u2014\n * it asks the plane before every piece of work, which is the authority anyway \u2014\n * and all it loses is the state the UI shows in the first seconds after a\n * restart. Letting an EACCES or a full disk propagate out of here would turn a\n * cosmetic loss into a failed scan, so the caller is handed the problem as a log\n * line and nothing else.\n *\n * Written to a sibling temp name and renamed over the target, the same way\n * `schedule/store.ts` writes its state and for the same reason: `renameSync` is\n * atomic within a filesystem on every platform this ships to, so a reader sees\n * either the whole old file or the whole new one. A half-written file would\n * parse as garbage, and \u2014 thanks to the rule above \u2014 would then read as\n * `unconfirmed` and pause a paying customer over a power cut.\n */\nexport function writeCachedVerdict(\n verdict: EntitlementVerdict,\n path = entitlementCachePath(),\n): { ok: true } | { ok: false; problem: string } {\n try {\n const dir = dirname(path);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n const tmp = `${path}.tmp`;\n // 0600 for integrity, not secrecy \u2014 see the header.\n writeFileSync(tmp, `${JSON.stringify({ version: 1, verdict }, null, 2)}\\n`, {\n encoding: \"utf8\",\n mode: 0o600,\n });\n renameSync(tmp, path);\n return { ok: true };\n } catch (err) {\n return { ok: false, problem: (err as Error).message };\n }\n}\n", "/**\n * THE GATE \u2014 nothing on this host starts work without a confirmed paid licence.\n *\n * Operator instruction, verbatim, 2026-09-01: *\"Tenant must have a paid Tier to\n * access collector. Nothing runs if free or deactivated tier.\"* Asked what\n * should happen when the licence cannot be CONFIRMED \u2014 the plane is down, the\n * link is cut \u2014 they were told the cost (a control-plane outage pauses a paying\n * customer's scheduled scans) and chose to FAIL CLOSED IMMEDIATELY over a grace\n * window. That decision is on the record. Do not reintroduce a grace period here\n * because it looks kinder; `verdict.ts` sets out why the kinder version has no\n * defensible length.\n *\n * \u2500\u2500 WHY A CLIENT-SIDE GATE IS NOT A BYPASS, WHICH IS THE OBVIOUS OBJECTION \u2500\u2500\n *\n * `core/planRefusal.ts` states the rule this has to live under: \"a client-side\n * gate is advisory at best, and a client-side grant is a bypass.\" It is right,\n * and this file does not break it. Nothing here can produce a grant. The only\n * thing that can is a 200 from `ROUTES.collectorEntitlement`, a route the\n * control plane mounts in CAPTURE mode and therefore refuses to serve at all\n * unless it has already verified the licence signature, checked the expiry and\n * asked `collectorAllowed`. The server is still the sole authority; this gate is\n * the client half that ASKS FIRST instead of finding out at the push.\n *\n * That distinction is what makes the gate necessary rather than decorative. Up\n * to 2026-09-01 the server-side refusal was sufficient on its own, because the\n * server held the credentials and the schedule \u2014 a refused collector had nothing\n * to scan with and no reason to start. Both moved onto this host in the same\n * change. A collector now holds its own vault and its own scheduler, so it can\n * sweep a customer's entire estate \u2014 vCenter, domain controllers, SQL instances,\n * every switch \u2014 end to end with no server involved, and merely fail to upload.\n * The gate had to move to where the work starts.\n *\n * \u2500\u2500 WHERE THE WORK STARTS, WHICH IS MORE PLACES THAN THE DAEMON \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * A gate on the daemon alone leaves `pnpm scan` wide open. The paths are:\n *\n * 1. `core/daemon.ts` `runJob` \u2014 every job the daemon executes, whether it was\n * polled from the portal (\"Run scan now\") or produced by a local schedule.\n * Async `require`, so this is the authoritative check.\n * 2. `schedule/engine.ts` `tick` \u2014 refuses to HAND OUT a due run at all, using\n * the synchronous snapshot. Cheap (it runs every few seconds) and it means\n * a refused tenant's schedules are never marked running and never counted\n * as missed.\n * 3. `core/runner.ts` `runCollector` \u2014 the network scan itself, so the one-shot\n * `pnpm scan` / `netdoc-collector scan` is gated without depending on its\n * entry point remembering to.\n * 4. `core/domainScan.ts` \u2014 a synchronous backstop at the top of every\n * `run*Scan`, so a future caller that skips 1\u20133 still cannot log in to a\n * customer's appliance.\n * 5. the per-module CLI entry points (`vmware-index.ts` and its eight\n * siblings), which run their own scan code and reach none of the above.\n *\n * \u2500\u2500 TWO CHECKS, AND WHY THE SECOND IS NOT A WEAKER COPY OF THE FIRST \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * `require()` is asynchronous and always asks the plane. `assertConfirmed()` is\n * synchronous and reads the last answer. They are not two grades of the same\n * check; they answer different questions:\n *\n * require() \"Is this tenant entitled, right now?\" \u2014 the decision.\n * assertConfirmed() \"Did anybody actually ask?\" \u2014 the backstop.\n *\n * The backstop exists because the failure this whole change is about is a scan\n * path that nobody remembered to gate. It costs no network, so it can sit at the\n * bottom of the stack where the credentials are actually used, and its answer\n * comes from a confirmation a few milliseconds old. It is bounded by\n * `CONFIRMATION_MAX_AGE_MS` so that it cannot quietly become the grace period\n * that was declined: a snapshot older than that reads as unconfirmed and\n * refuses.\n *\n * \u2500\u2500 NO TIME-BASED CACHE ON THE DECISION ITSELF \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * `require()` deliberately has no TTL. Draining twenty queued jobs makes twenty\n * small HTTPS requests, which is nothing beside twenty estate sweeps, and the\n * alternative \u2014 \"one confirmation authorises the next N minutes of work\" \u2014 is a\n * grace period wearing a cache's clothes. Concurrent callers DO share one\n * in-flight request, because that is deduplication rather than a time window:\n * two questions asked at the same instant have the same answer.\n */\nimport { fetchEntitlement, type EntitlementCheckConfig } from \"./client.js\";\nimport { readCachedVerdict, writeCachedVerdict } from \"./cache.js\";\nimport {\n confirmationIsFresh,\n refuse,\n type EntitlementRefusalReason,\n type EntitlementRefused,\n type EntitlementVerdict,\n} from \"./verdict.js\";\n\n/** The collector's logger shape, duplicated as a type so `entitlement/` does\n * not import `core/` \u2014 `core/` imports THIS, and a cycle helps nobody. */\nexport type GateLogger = (\n level: \"debug\" | \"info\" | \"warn\" | \"error\",\n message: string,\n meta?: unknown,\n) => void;\n\n/**\n * Work was refused because the tenant is not on a confirmed paid licence.\n *\n * A TYPE and not a string, for the reason `PlanRefusal` is one: every caller\n * that already has a `catch` gets to ask \"was this an entitlement refusal?\" and\n * answer the operator in words, and the machine-readable `reason` stays\n * available for code that has to branch on which of the three it is.\n *\n * Deliberately NOT a subclass of `PlanRefusal`, and deliberately not reusing its\n * codes. That class means \"the control plane rejected something we sent it\", and\n * every one of its three codes is a value the plane returned in an ApiError.\n * This one means \"we refused to send anything at all\", and one of its three\n * reasons (`unconfirmed`) is a fact about THIS host's network that no plane ever\n * asserted. Folding them together would put a refusal the plane never issued\n * behind a class whose whole documented invariant is that every value in it came\n * from the plane.\n */\nexport class CollectorNotEntitled extends Error {\n readonly reason: EntitlementRefusalReason;\n /** The plane's verbatim words, or null when it never answered. */\n readonly planeMessage: string | null;\n readonly httpStatus: number | null;\n /** The work that was refused, so the log line says what did not happen. */\n readonly attempted: string;\n readonly verdict: EntitlementRefused;\n\n constructor(verdict: EntitlementRefused, attempted: string) {\n super(`${attempted} was refused: ${verdict.message}`);\n this.name = \"CollectorNotEntitled\";\n this.reason = verdict.reason;\n this.planeMessage = verdict.planeMessage;\n this.httpStatus = verdict.httpStatus;\n this.attempted = attempted;\n this.verdict = verdict;\n }\n}\n\n/** Is this thrown value an entitlement refusal? */\nexport function isNotEntitled(err: unknown): err is CollectorNotEntitled {\n return err instanceof CollectorNotEntitled;\n}\n\n/** The refusal, or `undefined` \u2014 the shape a `catch` block wants. */\nexport function asNotEntitled(err: unknown): CollectorNotEntitled | undefined {\n return err instanceof CollectorNotEntitled ? err : undefined;\n}\n\n/**\n * What the localhost UI renders, and the ONLY shape a UI should read.\n *\n * Flat and already-decided on purpose: a renderer must not have to work out\n * whether a verdict is fresh, whether a cached one counts, or which of two\n * timestamps to show. Every one of those is a judgement this module owns, and a\n * second implementation of it in a template is a second answer.\n */\nexport interface CollectorEntitlementStatus {\n /** May this collector start work, as of the last answer? */\n allowed: boolean;\n /** Which refusal, or null when allowed. Never collapsed \u2014 see verdict.ts. */\n reason: EntitlementRefusalReason | null;\n /** The sentence to show an operator, or null when allowed. */\n message: string | null;\n /** The control plane's own words, when it spoke. Null when it did not. */\n planeMessage: string | null;\n /** Host-clock ISO of the last time entitlement was CONFIRMED, or null. */\n lastConfirmedAt: string | null;\n /** Host-clock ISO of the last time this collector ASKED, or null if never. */\n lastCheckedAt: string | null;\n /** The plane's clock at that confirmation, for display beside the local one. */\n confirmedAtPlane: string | null;\n /** Display only \u2014 the collector never re-judges expiry. Null when unknown. */\n licenceExpiresAt: string | null;\n /** Display and audit only, exactly as the licence says. */\n tier: string | null;\n /**\n * True when the only answer we have came off disk and this process has not\n * yet completed a live check. Distinct from `allowed` because a UI should say\n * \"last known good, not yet re-confirmed\" rather than either lying or blanking.\n */\n fromCache: boolean;\n /** The HTTP status behind a refusal, for a support engineer. Null otherwise. */\n httpStatus: number | null;\n}\n\nexport interface EntitlementGate {\n /**\n * Record a plan refusal the plane volunteered on some OTHER call \u2014 a job\n * poll, an ingest \u2014 as an entitlement refusal, without asking again.\n *\n * \u2500\u2500 WHY REFUSALS PROPAGATE THIS WAY AND CONFIRMATIONS DO NOT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * A successful poll of `ROUTES.collectorJobsNext` is, today, exactly as strong\n * a confirmation as the entitlement route: it is mounted in CAPTURE mode too,\n * so a 200 means the middleware verified the signature, the expiry and\n * `collectorAllowed`. It is TEMPTING to treat it as one and halve the request\n * count. It is refused here, on purpose.\n *\n * The reason is what happens when somebody later moves that route to\n * `collector-winddown` \u2014 a plausible edit with a reasonable-sounding argument\n * behind it, exactly like the one `routes/collectorEntitlement.ts` guards\n * against. If a 200 from it granted entitlement, that edit would silently\n * authorise every lapsed collector in the fleet, and nothing would fail. One\n * route confirms, and it is the one whose only job is to confirm.\n *\n * Refusals are the other direction and the asymmetry is safe by construction:\n * this can only ever move the gate from open to shut, so a caller that never\n * calls it is merely slower to notice, never wrong. The same one-directional\n * argument the contract makes for `LicensePayload.trials`.\n */\n noteRefusal(input: { reason: EntitlementRefusalReason; planeMessage: string; httpStatus: number }): void;\n /**\n * Ask the plane, record the answer, and REFUSE by throwing\n * `CollectorNotEntitled` if it is not a yes. `attempted` names the work in\n * the words an operator would use (\"the nightly VMware scan of site-3\").\n */\n require(attempted: string, signal?: AbortSignal): Promise<void>;\n /**\n * Ask the plane and hand back the verdict without throwing. For the daemon,\n * which has a job to mark failed and a status to report rather than an\n * exception to propagate.\n */\n check(signal?: AbortSignal): Promise<EntitlementVerdict>;\n /**\n * The synchronous backstop: throw unless a FRESH confirmation is on record.\n * See the header for why this is not a weaker copy of `require`.\n */\n assertConfirmed(attempted: string): void;\n /** The same question, as a boolean \u2014 for `schedule/engine.ts`'s tick. */\n mayStartWork(): boolean;\n /**\n * Does this tenant OWN the given collector/scan module, as of the last\n * confirmed answer? An ADDITIONAL gate beside `mayStartWork` \u2014 that one asks\n * \"may I scan at all\", this asks \"may I run THIS scan\" \u2014 and read the same\n * fail-closed way: FALSE unless a FRESH confirmation is on record AND the\n * module is in the plane's authoritative list. An absent, stale or refused\n * verdict owns nothing, and the collector never derives the answer itself (see\n * `CollectorEntitlementResult.modules`).\n */\n ownsModule(moduleKey: string): boolean;\n /** What the UI renders. */\n status(): CollectorEntitlementStatus;\n}\n\nexport interface EntitlementGateOptions {\n /** Where to ask, and what to authenticate with. */\n config: EntitlementCheckConfig;\n log: GateLogger;\n /** Injectable clock, so staleness and skew are testable without waiting. */\n now?: () => number;\n /**\n * The check itself, injectable so every refusal \u2014 expired licence,\n * `collector: false`, an unreachable plane, a plane returning garbage \u2014 is\n * reachable in a test without a network or a server. Fail-closed code is most\n * often wrong in the safe-looking direction, so the refusals are the paths\n * that most need to be easy to exercise.\n */\n fetch?: (\n cfg: EntitlementCheckConfig,\n signal?: AbortSignal,\n ) => Promise<EntitlementVerdict>;\n /** Cache file location; a test points it at a temp directory. */\n cachePath?: string | undefined;\n /**\n * Seed the gate from the on-disk cache at construction. On by default \u2014 that\n * is the whole reason the cache exists (see cache.ts). Off in tests that want\n * a gate with no history.\n */\n seedFromCache?: boolean;\n}\n\nexport function createEntitlementGate(options: EntitlementGateOptions): EntitlementGate {\n const now = options.now ?? Date.now;\n const ask = options.fetch ?? fetchEntitlement;\n const log = options.log;\n const cachePath = options.cachePath;\n\n /**\n * The last answer. Seeded from disk so a restart has something honest to show\n * \u2014 and seeded to a REFUSAL when the disk has nothing, which is what makes\n * \"fail closed on a missing or unreadable cache\" a property of construction\n * rather than of a branch somebody has to reach.\n */\n let verdict: EntitlementVerdict =\n options.seedFromCache === false\n ? refuse({\n reason: \"unconfirmed\",\n localDetail: \"this collector has not yet asked the control plane\",\n })\n : cachePath === undefined\n ? readCachedVerdict()\n : readCachedVerdict(cachePath);\n let fromCache = true;\n /** Host-clock ISO of the last time entitlement was CONFIRMED, ever. */\n let lastConfirmedAt: string | null = verdict.allowed ? verdict.confirmedAtLocal : null;\n /** One in-flight request shared by concurrent askers. See the header. */\n let inFlight: Promise<EntitlementVerdict> | null = null;\n /**\n * The refusal already reported, so a loop that keeps running says it ONCE.\n *\n * The same argument `createRefusalLatch` makes in `core/planRefusal.ts`: the\n * scheduler evaluates every few seconds and will go on doing so through a\n * lapsed subscription \u2014 correctly, because somebody else can lift a billing\n * hold without touching this host \u2014 but \"keep checking\" must not mean \"keep\n * saying it\". An unattended weekend at a few seconds an evaluation is tens of\n * thousands of identical lines on a disk we do not own.\n *\n * Reported again when the reason CHANGES or CLEARS, because \"it started\n * working again\" is the other fact an operator reading that file wants.\n */\n let latched: EntitlementRefusalReason | null = null;\n\n const record = (next: EntitlementVerdict): void => {\n verdict = next;\n fromCache = false;\n if (next.allowed) lastConfirmedAt = next.confirmedAtLocal;\n const written = cachePath === undefined ? writeCachedVerdict(next) : writeCachedVerdict(next, cachePath);\n if (!written.ok) {\n // Not fatal, and deliberately at debug: the plane is asked before every\n // piece of work regardless, so an unwritable cache costs only what the UI\n // shows in the first seconds after a restart. A warn here would be a\n // recurring line about a cosmetic problem.\n log(\"debug\", `could not cache the entitlement answer: ${written.problem}`);\n }\n if (next.allowed) {\n if (latched !== null) {\n latched = null;\n log(\"info\", \"the control plane has confirmed this tenant's paid license again \u2014 scanning resumes\");\n }\n return;\n }\n if (latched === next.reason) {\n log(\"debug\", `entitlement still refused (${next.reason})`);\n return;\n }\n latched = next.reason;\n log(\"error\", `this collector is not permitted to scan: ${next.message}`);\n };\n\n const check = async (signal?: AbortSignal): Promise<EntitlementVerdict> => {\n // Concurrent askers share one request. Deduplication, not a time window \u2014\n // the promise is cleared the instant it settles, so the NEXT caller asks\n // again rather than reading a stale answer.\n if (inFlight !== null) return inFlight;\n const pending = (async () => {\n try {\n const answer = await ask(options.config, signal);\n record(answer);\n return answer;\n } catch (err) {\n // `fetchEntitlement` is documented as total, but an injected fetcher (or\n // a future one) might not be, and an entitlement check that throws would\n // become an unhandled rejection inside a daemon loop \u2014 which is the\n // shape of \"the scheduler silently stopped\". Fail closed instead.\n const answer = refuse({\n reason: \"unconfirmed\",\n localDetail: `the entitlement check itself failed: ${(err as Error).message}`,\n });\n record(answer);\n return answer;\n } finally {\n inFlight = null;\n }\n })();\n inFlight = pending;\n return pending;\n };\n\n const require_ = async (attempted: string, signal?: AbortSignal): Promise<void> => {\n const answer = await check(signal);\n if (answer.allowed) return;\n throw new CollectorNotEntitled(answer, attempted);\n };\n\n const staleRefusal = (): EntitlementRefused =>\n refuse({\n reason: \"unconfirmed\",\n nowIso: new Date(now()).toISOString(),\n localDetail:\n \"no entitlement confirmation from the control plane is current on this host, so no \" +\n \"scan may start\",\n });\n\n const assertConfirmed = (attempted: string): void => {\n if (confirmationIsFresh(verdict, now())) return;\n // A refusal already on record is reported as itself \u2014 an operator must not\n // be told \"unconfirmed\" when the plane actually said \"not paid\". Only a\n // verdict that is allowed-but-STALE, or absent, becomes `unconfirmed` here.\n throw new CollectorNotEntitled(verdict.allowed ? staleRefusal() : verdict, attempted);\n };\n\n const status = (): CollectorEntitlementStatus => {\n const fresh = confirmationIsFresh(verdict, now());\n if (verdict.allowed) {\n return {\n allowed: fresh,\n // An ALLOWED verdict that has gone stale is not a refusal the plane\n // issued, so it is reported for what it is: nobody has confirmed\n // recently. Saying `not_paid` here would accuse a paying customer.\n reason: fresh ? null : \"unconfirmed\",\n message: fresh ? null : staleRefusal().message,\n planeMessage: null,\n lastConfirmedAt,\n lastCheckedAt: verdict.confirmedAtLocal,\n confirmedAtPlane: verdict.confirmedAt,\n licenceExpiresAt: verdict.licenceExpiresAt,\n tier: verdict.tier,\n fromCache,\n httpStatus: null,\n };\n }\n return {\n allowed: false,\n reason: verdict.reason,\n message: verdict.message,\n planeMessage: verdict.planeMessage,\n lastConfirmedAt,\n lastCheckedAt: verdict.checkedAtLocal,\n confirmedAtPlane: null,\n licenceExpiresAt: null,\n tier: null,\n fromCache,\n httpStatus: verdict.httpStatus,\n };\n };\n\n const noteRefusal = (input: {\n reason: EntitlementRefusalReason;\n planeMessage: string;\n httpStatus: number;\n }): void => {\n record(\n refuse({\n reason: input.reason,\n planeMessage: input.planeMessage,\n httpStatus: input.httpStatus,\n nowIso: new Date(now()).toISOString(),\n }),\n );\n };\n\n const ownsModule = (moduleKey: string): boolean => {\n // Fail-closed, and layered on top of freshness rather than beside it: a stale\n // ALLOWED verdict grants no module, exactly as it starts no work. Once fresh\n // and allowed, the answer is a membership test against the plane's list \u2014\n // never a computation of our own.\n if (!confirmationIsFresh(verdict, now())) return false;\n if (!verdict.allowed) return false;\n return verdict.modules.includes(moduleKey);\n };\n\n return {\n noteRefusal,\n require: require_,\n check,\n assertConfirmed,\n mayStartWork: () => confirmationIsFresh(verdict, now()),\n ownsModule,\n status,\n };\n}\n\n// ---------------------------------------------------------------------------\n// The process's gate.\n// ---------------------------------------------------------------------------\n\n/**\n * \u2500\u2500 WHY THERE IS A PROCESS-WIDE ONE AT ALL \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * This codebase prefers injection, for a reason it has already paid to learn:\n * two vault handles over one file each hold their own decrypted copy, so a\n * credential added in the UI is invisible to the next scan. Entitlement has the\n * same shape of problem for a different reason \u2014 the UI's status pane, the\n * scheduler's tick and the backstop inside a domain scan must all read ONE\n * answer, or the pane says \"paused\" while a scan is running on a verdict only\n * the daemon saw.\n *\n * But entitlement cannot be injected everywhere the way the vault is. The\n * vault reaches two call sites; the gate has to reach the daemon, the\n * scheduler, the network runner, eleven domain-scan functions and nine\n * standalone CLI entry points, several of which construct nothing and load their\n * own narrow config. Threading a handle through all of them would guarantee that\n * one of them is missed \u2014 and a missed one is a scan path with no gate, which is\n * the exact defect this change exists to remove.\n *\n * So: a memoised per-process gate, built from whatever config the FIRST caller\n * has, plus `setProcessEntitlementGate` for the daemon (which builds its own\n * with its own logger) and for tests. The memo is keyed on nothing \u2014 one\n * process serves one collector identity, which is the same assumption\n * `config.ts` already makes.\n */\nlet processGate: EntitlementGate | null = null;\n\n/**\n * The gate this process uses, built on first use from the caller's config.\n *\n * Every call site that starts work already holds `controlPlaneUrl` and `apiKey`\n * \u2014 the daemon from `AppConfig`, a domain scan from `DomainScanParams`, a CLI\n * entry point from its own module config \u2014 so nothing has to be threaded and\n * nothing has to be remembered at startup. A gate that only exists when an entry\n * point remembers to build one is the same absence that left `MetricsCollector`\n * constructed by nothing for weeks (see `core/daemonLoops.ts`), and it fails the\n * same way: silently, on the customer's machine.\n */\nexport function processEntitlementGate(\n config: EntitlementCheckConfig,\n log: GateLogger,\n): EntitlementGate {\n if (processGate === null) {\n processGate = createEntitlementGate({ config, log });\n }\n return processGate;\n}\n\n/**\n * Install the gate for this process \u2014 used by the daemon entry path (so the\n * gate logs through the daemon's redacting logger) and by tests.\n *\n * `null` clears it, which is what a test's `afterEach` wants; the next\n * `processEntitlementGate` call then builds a fresh one. Note that clearing does\n * NOT open the gate: the freshly built one starts from the on-disk cache, and a\n * missing cache is an `unconfirmed` refusal.\n */\nexport function setProcessEntitlementGate(gate: EntitlementGate | null): void {\n processGate = gate;\n}\n\n/**\n * Tell the process gate about a refusal the plane volunteered elsewhere.\n *\n * A NO-OP when no gate has been built yet, and that is right rather than a gap:\n * with no gate there is nothing to shut, and the first thing that tries to start\n * work builds one that begins from the on-disk cache \u2014 which, having never been\n * confirmed in this process, refuses anyway. Nothing is missed except a status\n * pane update on a process that has not yet done anything.\n *\n * Never builds a gate of its own, deliberately. It has no config to build one\n * from, and inventing one from the refusal would put the wrong `controlPlaneUrl`\n * behind every later check.\n */\nexport function noteEntitlementRefusalFromPlane(input: {\n reason: EntitlementRefusalReason;\n planeMessage: string;\n httpStatus: number;\n}): void {\n processGate?.noteRefusal(input);\n}\n\n/**\n * Does this process's collector OWN the given scan module right now?\n *\n * The per-module companion to `collectorEntitlementStatus`, for the UI (which\n * enables a \"Run scan\" button only for owned modules) and for the metrics\n * side-loop (which must not SNMP-poll switches for a module nobody bought).\n *\n * FAIL-CLOSED BY CONSTRUCTION. With no process gate built yet \u2014 a fresh process\n * that has not asked the plane \u2014 the honest answer is that nothing is confirmed,\n * so nothing is owned: FALSE. That matches what `collectorEntitlementStatus`\n * reports in the same state (`allowed: false`), so a UI reading both never shows\n * an enabled button beside a \"not scanning\" banner. Once a gate exists, the\n * gate's own fail-closed `ownsModule` decides, against a FRESH confirmation.\n *\n * No cache fallback, deliberately: a cached ALLOWED does not authorise this\n * process (see `collectorEntitlementStatus`, which reports `allowed: false` when\n * the only answer is from disk), so it must not light a module either.\n */\nexport function collectorOwnsModule(moduleKey: string): boolean {\n return processGate?.ownsModule(moduleKey) ?? false;\n}\n\n/**\n * The state a renderer reads. THE export for the UI.\n *\n * Answers without a config, and without building a gate, because a status pane\n * must be able to ask before anything has started work. When no gate exists yet\n * the honest answer is the one from disk \u2014 which, on a host that has never\n * confirmed anything, is an `unconfirmed` refusal.\n */\nexport function collectorEntitlementStatus(cachePath?: string): CollectorEntitlementStatus {\n if (processGate !== null) return processGate.status();\n const cached = cachePath === undefined ? readCachedVerdict() : readCachedVerdict(cachePath);\n if (cached.allowed) {\n return {\n allowed: false, // nothing in THIS process has confirmed anything yet\n reason: \"unconfirmed\",\n message:\n \"this collector has a previously confirmed paid license on record but has not yet \" +\n \"re-confirmed it with the control plane since starting; no scan will run until it does\",\n planeMessage: null,\n lastConfirmedAt: cached.confirmedAtLocal,\n lastCheckedAt: cached.confirmedAtLocal,\n confirmedAtPlane: cached.confirmedAt,\n licenceExpiresAt: cached.licenceExpiresAt,\n tier: cached.tier,\n fromCache: true,\n httpStatus: null,\n };\n }\n return {\n allowed: false,\n reason: cached.reason,\n message: cached.message,\n planeMessage: cached.planeMessage,\n lastConfirmedAt: null,\n lastCheckedAt: cached.checkedAtLocal,\n confirmedAtPlane: null,\n licenceExpiresAt: null,\n tier: null,\n fromCache: true,\n httpStatus: cached.httpStatus,\n };\n}\n", "/**\n * The two lines a scan path writes.\n *\n * Every gated call site in the collector uses one of these rather than reaching\n * for `processEntitlementGate` itself, and the reason is that a call site should\n * not have to decide anything. Deciding whether a cached verdict counts, whether\n * a stale one is a refusal or a fresh question, which of the three reasons to\n * report \u2014 those are judgements `gate.ts` owns, and a second copy of any of them\n * in a scan path is a second answer that will eventually differ from the first.\n *\n * Two functions, because there are two questions (see `gate.ts`'s header):\n *\n * requireEntitlementToScan asks the plane. THE decision. Use this wherever\n * work actually begins and an `await` is available.\n * assertEntitlementConfirmed does not ask anybody. The backstop, for the\n * synchronous bottom of the stack \u2014 it proves that\n * somebody upstream DID ask, and refuses if the\n * answer on record is missing, stale or a no.\n */\nimport { CollectorNotEntitled, processEntitlementGate, type GateLogger } from \"./gate.js\";\nimport { refuse } from \"./verdict.js\";\nimport type { EntitlementCheckConfig } from \"./client.js\";\n\n/**\n * What a call site actually has in its hand.\n *\n * WIDER than `EntitlementCheckConfig` on purpose: one of the nine one-shot entry\n * points \u2014 `sensitive-data-index.ts` \u2014 loads a config whose `controlPlaneUrl`\n * and `apiKey` are OPTIONAL, and every other caller's are required. Accepting\n * the wider shape here means the missing-endpoint case is handled once, in the\n * gate, instead of nine times at the call sites (eight of which would be dead\n * code, and the ninth of which somebody would get wrong).\n */\nexport interface EntitlementTarget {\n controlPlaneUrl?: string | undefined;\n apiKey?: string | undefined;\n}\n\n/**\n * Narrow a target, or refuse.\n *\n * \u2500\u2500 THIS OVERTURNS `SensitiveDataConfig`'s LOCAL-ONLY MODE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * `config.ts` says of that config's two optional fields, verbatim:\n *\n * \"Both present = results are pushed; either missing = local-only scan.\"\n *\n * That affordance is withdrawn for the SCAN, though not for the push. It lost\n * because it is the one path in the collector that could read a customer's file\n * shares \u2014 the module whose entire subject is where their sensitive data lives \u2014\n * with no way for anybody to ask whether the tenant is entitled to run it, and\n * \"cannot ask\" now means \"does not run\". Leaving it would have made the gate\n * bypassable by deleting two lines from a config file, which is not a gate.\n *\n * What is NOT claimed here: that this is costless. An operator who ran\n * sensitive-data discovery purely locally, on purpose, must now configure the\n * collector's endpoint and key even though nothing will be uploaded. That is a\n * real regression, and it is the same trade the operator made knowingly when\n * they chose fail-closed over a grace window \u2014 an unconfirmable licence stops\n * work, whatever the reason it cannot be confirmed.\n */\nfunction targetOrRefuse(target: EntitlementTarget, attempted: string): EntitlementCheckConfig {\n const controlPlaneUrl = target.controlPlaneUrl?.trim() ?? \"\";\n const apiKey = target.apiKey?.trim() ?? \"\";\n if (controlPlaneUrl.length === 0 || apiKey.length === 0) {\n throw new CollectorNotEntitled(\n refuse({\n reason: \"unconfirmed\",\n localDetail:\n \"this collector has no control-plane URL and API key configured, so there is \" +\n \"nobody to confirm the tenant's license with\",\n }),\n attempted,\n );\n }\n return { controlPlaneUrl, apiKey };\n}\n\n/**\n * Confirm with the control plane that this tenant may run a collector, and\n * throw `CollectorNotEntitled` if it will not say so.\n *\n * `attempted` is the work in the words an operator would use \u2014 \"the nightly\n * VMware scan of site-3\", \"the network sweep\" \u2014 because it is what ends up in\n * the message they read and in the failed job's reason in the portal. \"job\" or\n * \"scan\" tells them nothing they did not know.\n *\n * `config` is any object carrying `controlPlaneUrl` and `apiKey`. Every caller\n * already has one: the daemon's `AppConfig`, a domain scan's\n * `DomainScanParams`, a CLI entry point's own module config. Nothing has to be\n * threaded through a constructor and nothing has to be remembered at startup \u2014\n * which is the property that lets nine standalone entry points be gated without\n * any of them growing a wiring step somebody can forget.\n */\nexport async function requireEntitlementToScan(\n config: EntitlementTarget,\n log: GateLogger,\n attempted: string,\n signal?: AbortSignal,\n): Promise<void> {\n await processEntitlementGate(targetOrRefuse(config, attempted), log).require(attempted, signal);\n}\n\n/**\n * Throw unless a FRESH confirmation is already on record for this process.\n *\n * No network, no config, no `await` \u2014 so it can sit at the top of a synchronous\n * scan function where the credentials are about to be used. It does not decide\n * entitlement; it detects a caller that never asked.\n *\n * A gate that has never been built refuses, because the gate it would build\n * starts from the on-disk cache and an absent cache is an `unconfirmed`\n * refusal. That is the fail-closed default arriving by construction rather than\n * through a branch somebody has to reach.\n */\nexport function assertEntitlementConfirmed(\n config: EntitlementTarget,\n log: GateLogger,\n attempted: string,\n): void {\n processEntitlementGate(targetOrRefuse(config, attempted), log).assertConfirmed(attempted);\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * THE VERDICT \u2014 what the plane said about this tenant's right to run a\n * collector, and what an operator is supposed to do about it.\n *\n * \u2500\u2500 WHY THIS EXISTS AT ALL, GIVEN core/planRefusal.ts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * `planRefusal.ts` closes with a paragraph headed \"THE GAP THIS DOES NOT\n * CLOSE\", and this file is the code that closes it. Quoted, because the\n * argument is still exactly right and this must not be read as overturning it:\n *\n * \"A refusal is learned at PUSH \u2014 which for a one-shot `netdoc-collector\n * scan vmware` is after the collector has already logged into the\n * customer's vCenter and read their whole inventory. That connection is\n * precisely what the module gate exists to prevent, and no amount of error\n * handling here prevents it: the collector has no way to ask what this\n * tenant is allowed to scan.\"\n *\n * The collector now has a way to ask (`ROUTES.collectorEntitlement`), so the\n * refusal can be learned BEFORE the login instead of after it.\n *\n * \u2500\u2500 THE ONE RULE FROM planRefusal.ts THAT SURVIVES UNCHANGED \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * That file also says, under \"THE COLLECTOR NEVER DECIDES IT IS ENTITLED\":\n *\n * \"If this file ever grows a function that returns 'yes, you may scan', the\n * server has stopped being the authority.\"\n *\n * It has not grown one, and neither has this one. Nothing here evaluates a\n * licence, reads `limits.collector`, or compares a date to decide access. The\n * only thing that can produce an ALLOWED verdict is a 200 from a route the\n * control plane refuses to serve unless the tenant is entitled \u2014 the grant is\n * still entirely the server's, and this file only classifies the answer. What\n * changed is WHEN we ask, not who decides.\n *\n * \u2500\u2500 THREE REFUSALS, BECAUSE THREE DIFFERENT PEOPLE FIX THEM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * All three stop work identically. They are kept apart because the operator's\n * next move is different in each case, and a collector that printed one message\n * for all three would send somebody to the billing page over a firewall rule:\n *\n * not_paid the plan does not include a collector. Somebody with a\n * purchasing decision has to UPGRADE. Nothing on this host is\n * broken and nothing here will fix it.\n * deactivated the authority this host had was withdrawn at the plane \u2014 the\n * licence lapsed, the account was suspended, or this\n * collector's key was revoked. Somebody with billing or admin\n * access has to RENEW or re-enrol.\n * unconfirmed we could not get an answer. The plane was unreachable, or\n * answered with something we could not read. Somebody with\n * network access has to FIX THE PATH \u2014 and until they do,\n * nothing runs.\n *\n * \u2500\u2500 WHY `unconfirmed` STOPS WORK, WHEN A GRACE PERIOD WOULD BE KINDER \u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * It was put to the operator directly, with the cost stated: fail closed\n * immediately, and a control-plane outage pauses a paying customer's scheduled\n * scans. They chose it over a grace window anyway, on 2026-09-01. Do not add\n * one back because it looks kinder \u2014 the decision is on the record, and the\n * reason it is defensible is that the alternative has no natural end. A grace\n * period is a period during which an unentitled collector scans a customer's\n * network with stored credentials, and every value for its length is arbitrary:\n * whatever number is picked, the tenant who lapsed the day before gets exactly\n * that long of free, unauthorised access to their own estate on our software.\n *\n * The three refusals are also why `deactivated` is not folded into\n * `unconfirmed`. A 401 is an ANSWER \u2014 a definite, authoritative \"your authority\n * is not valid\". Calling that \"we could not confirm\" would send an operator to\n * check their firewall over an expired invoice.\n */\n\nimport { ApiError, CollectorEntitlementResult } from \"@netdoc/collector-contract\";\n\n/** Why work is refused. Never collapsed into one \u2014 see the header. */\nexport type EntitlementRefusalReason = \"not_paid\" | \"deactivated\" | \"unconfirmed\";\n\n/** Every reason, for exhaustive iteration in tests and in the UI. */\nexport const ENTITLEMENT_REFUSAL_REASONS = [\n \"not_paid\",\n \"deactivated\",\n \"unconfirmed\",\n] as const satisfies readonly EntitlementRefusalReason[];\n\n/**\n * The plane said yes.\n *\n * `confirmedAtLocal` is stamped from THIS host's clock and is the only field\n * staleness is measured against. `confirmedAt` is the plane's clock, carried\n * for display: comparing one machine's clock to another's is where skew stops\n * being a curiosity and starts being a wrong answer, so the two are separate\n * fields and only one of them is ever subtracted from `Date.now()`.\n */\nexport interface EntitlementAllowed {\n allowed: true;\n /** This host's clock at the moment the answer arrived. Staleness uses THIS. */\n confirmedAtLocal: string;\n /** The plane's clock, verbatim, for the operator to read. */\n confirmedAt: string;\n /** Display only. Never re-evaluated here \u2014 see `CollectorEntitlementResult`. */\n licenceExpiresAt: string;\n /** Display and audit only, exactly as the licence payload says. */\n tier: string | null;\n tenantId: string;\n collectorId: string;\n /**\n * The collector/scan modules this tenant OWNS, exactly as the PLANE computed\n * them (`CollectorEntitlementResult.modules`). Authoritative and read\n * fail-closed by `gate.ownsModule`: a module absent here is not owned, and the\n * collector never derives this itself. Carried on the verdict so the ONE\n * per-process gate answers \"may I run THIS scan\" from the same confirmed\n * answer it uses for \"may I scan at all\".\n */\n modules: readonly string[];\n}\n\n/** The plane said no, or could not be asked. */\nexport interface EntitlementRefused {\n allowed: false;\n reason: EntitlementRefusalReason;\n /** This host's clock when we last tried. */\n checkedAtLocal: string;\n /**\n * The sentence an operator can act on. Ours, because it knows the local\n * context (\"nothing on this host will scan\"); the plane's own words appended\n * in quotes, because they know the tenant's. The same convention\n * `planRefusal.ts` uses, and for the same reason: a refusal carrying only one\n * of the two leaves whoever reads it guessing at the other.\n */\n message: string;\n /** The plane's own words, verbatim, or null when it never answered. */\n planeMessage: string | null;\n /** The HTTP status, for diagnosis. Null when the request did not complete. */\n httpStatus: number | null;\n}\n\nexport type EntitlementVerdict = EntitlementAllowed | EntitlementRefused;\n\n/**\n * How long a confirmation may back the SYNCHRONOUS backstop before it reads as\n * unconfirmed.\n *\n * \u2500\u2500 THIS IS NOT THE GRACE PERIOD THAT WAS DECLINED \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * Nothing starts work on a cached verdict. Every path that begins a scan awaits\n * a fresh `require()` that goes to the plane, and refuses if it cannot. What\n * this bound governs is the second, cheap, synchronous assertion made further\n * down the call stack \u2014 inside `runCollector`, inside each domain scan \u2014 whose\n * job is to catch a caller that skipped the async check entirely, not to grant\n * time to one that failed it.\n *\n * Ten minutes is chosen to be comfortably longer than the gap between the async\n * check and the scan it authorised (milliseconds), and comfortably shorter than\n * any interval on which an entitlement realistically changes. If it were zero,\n * a scan that took a second to start would trip its own backstop; if it were a\n * day, the backstop would stop being one.\n */\nexport const CONFIRMATION_MAX_AGE_MS = 10 * 60_000;\n\n/**\n * Is this verdict fresh enough for the synchronous backstop?\n *\n * NEGATIVE AGE IS STALE, and that is the clock-skew rule. A confirmation\n * stamped in the future means the host's clock moved backwards between the\n * check and now (an NTP correction, a VM resuming from a snapshot, a\n * hand-edited clock). We cannot say how much time really passed, so we say we\n * do not know \u2014 which, here, means refuse and re-ask. Treating it as fresh\n * because `now - then` happens to be a small negative number is the exact\n * shape of a fail-open bug: it is most wrong precisely when the clock is most\n * wrong.\n */\nexport function confirmationIsFresh(\n verdict: EntitlementVerdict,\n nowMs: number = Date.now(),\n maxAgeMs: number = CONFIRMATION_MAX_AGE_MS,\n): boolean {\n if (!verdict.allowed) return false;\n const stampedMs = Date.parse(verdict.confirmedAtLocal);\n if (!Number.isFinite(stampedMs)) return false;\n const ageMs = nowMs - stampedMs;\n return ageMs >= 0 && ageMs <= maxAgeMs;\n}\n\n/** Trim and cap the plane's prose before it goes anywhere near a log line. */\nfunction tidy(planeMessage: string | null): string | null {\n if (planeMessage === null) return null;\n const t = planeMessage.trim();\n if (t.length === 0) return null;\n return t.length > 500 ? `${t.slice(0, 500)}\u2026` : t;\n}\n\n/** ` (the control plane said: \"\u2026\")`, or nothing when it said nothing. */\nfunction said(planeMessage: string | null): string {\n const t = tidy(planeMessage);\n return t === null ? \"\" : ` (the control plane said: \"${t}\")`;\n}\n\n/**\n * The sentence, written for somebody standing at a terminal on hardware they\n * administer, who has just been told \"no\" by a machine they do not. Every\n * branch ends with what they \u2014 or a colleague with a different login \u2014 can\n * actually do about it, and none of them suggests reinstalling anything,\n * because reinstalling has never been the answer to any of the three.\n */\nexport function refusalSentence(\n reason: EntitlementRefusalReason,\n planeMessage: string | null,\n): string {\n switch (reason) {\n case \"not_paid\":\n return (\n \"this tenant's plan does not include an on-premises collector, so nothing on this \" +\n \"host will scan. No credential, schedule or enrolment here needs changing \u2014 the \" +\n \"plan does. Someone with purchasing access can upgrade it in the portal.\" +\n said(planeMessage)\n );\n case \"deactivated\":\n return (\n \"the control plane has withdrawn this collector's authority to run \u2014 the tenant's \" +\n \"license has lapsed, the account is suspended, or this collector's key was revoked. \" +\n \"Scanning is PAUSED, not broken. Read the plane's own words below: they say whether \" +\n \"someone with billing access should renew, or whether this host needs re-enrolling.\" +\n said(planeMessage)\n );\n case \"unconfirmed\":\n return (\n \"this collector could not confirm with the control plane that its tenant is on a \" +\n \"paid, active plan, so it will not start any scan. This is deliberate: an \" +\n \"unconfirmed license stops work immediately rather than running on trust. Restore \" +\n \"the collector's network path to the control plane and it resumes by itself, with \" +\n \"no restart and nothing to re-enrol.\" +\n said(planeMessage)\n );\n }\n}\n\n/**\n * Build a refusal, with the sentence already composed.\n *\n * `localDetail` is for a fact only THIS host knows \u2014 the transport error behind\n * an unreachable plane, the reason a cache file could not be read. It is kept\n * apart from `planeMessage` rather than merged into it because the difference\n * between \"the plane said no\" and \"the plane never spoke\" is the difference\n * between `deactivated` and `unconfirmed`, and a reader (or a support engineer\n * reading a log) must be able to tell which words came from where. `planeMessage`\n * staying null is the machine-readable form of \"it did not answer\".\n */\nexport function refuse(init: {\n reason: EntitlementRefusalReason;\n planeMessage?: string | null;\n localDetail?: string | null;\n httpStatus?: number | null;\n nowIso?: string;\n}): EntitlementRefused {\n const planeMessage = tidy(init.planeMessage ?? null);\n const detail = tidy(init.localDetail ?? null);\n return {\n allowed: false,\n reason: init.reason,\n checkedAtLocal: init.nowIso ?? new Date().toISOString(),\n message: refusalSentence(init.reason, planeMessage) + (detail === null ? \"\" : ` [${detail}]`),\n planeMessage,\n httpStatus: init.httpStatus ?? null,\n };\n}\n\n/**\n * Classify one HTTP answer from `ROUTES.collectorEntitlement`.\n *\n * PURE, and separated from the fetch on purpose. Fail-closed code is the code\n * most likely to be wrong in the safe-looking direction, so every refusal here\n * \u2014 expired licence, `collector: false`, a plane returning HTML, a 500, a body\n * that parses as JSON but is not the result \u2014 has to be reachable in a test\n * without a network, a server, or a clock. A classifier tangled up with\n * `fetch` gets tested on its happy path and nowhere else.\n *\n * \u2500\u2500 THE MAPPING, AND THE ONE JUDGEMENT CALL IN IT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * 2xx + a body that parses ALLOWED. The middleware verified the signature,\n * the expiry and `limits.collector` before the\n * handler ran; the body cannot say \"no\".\n * 2xx + a body that does not unconfirmed. A 200 carrying something we cannot\n * read is not a grant. This is the case a\n * captive portal, a proxy error page or a\n * half-deployed plane produces, and it is exactly\n * where a lazier parse would fail open.\n * 403 / 402 not_paid. The plane's own tier refusal.\n * 401 deactivated. See below.\n * 404 / 405 unconfirmed. A plane that does not know the\n * question has confirmed nothing. This is what a\n * collector newer than its control plane sees,\n * and it fails closed \u2014 which is the cost the\n * operator accepted when they declined a grace\n * window.\n * anything else (5xx, 429\u2026) unconfirmed.\n *\n * The judgement call is 401. It arrives for an expired licence, a suspended\n * account, a tenant mismatch AND a revoked collector key \u2014 and the fix for the\n * last of those is re-enrolment, not renewal. It is still `deactivated` rather\n * than a fourth reason, because all four are the same fact from the operator's\n * side (the plane has withdrawn this host's authority) and the plane's own\n * sentence, carried through verbatim, is what distinguishes them: \"this\n * collector has been revoked \u2014 re-enrol it from the portal\" and \"license\n * invalid or expired\" are unmistakable, and neither is improved by our\n * inventing a category name for it. What matters is that a 401 is never read as\n * `unconfirmed`, which would blame the network for a billing fact.\n */\nexport function classifyEntitlementAnswer(input: {\n status: number;\n /** The raw body. Parsed here rather than by the caller, so an unparseable\n * body is classified by the same function that classifies a status. */\n body: string;\n nowIso?: string;\n}): EntitlementVerdict {\n const nowIso = input.nowIso ?? new Date().toISOString();\n const planeMessage = apiErrorMessage(input.body);\n\n if (input.status >= 200 && input.status < 300) {\n const parsed = parseResult(input.body);\n if (parsed === null) {\n return refuse({\n reason: \"unconfirmed\",\n httpStatus: input.status,\n nowIso,\n planeMessage:\n \"the control plane answered the entitlement check with a body this collector \" +\n \"could not read, so no answer was received\",\n });\n }\n return {\n allowed: true,\n confirmedAtLocal: nowIso,\n confirmedAt: parsed.confirmedAt,\n licenceExpiresAt: parsed.licenceExpiresAt,\n tier: parsed.tier,\n tenantId: parsed.tenantId,\n collectorId: parsed.collectorId,\n modules: parsed.modules,\n };\n }\n\n // 402 is not a code this plane sends today. It is mapped anyway because it is\n // the one status a payment intermediary in front of us WOULD send, and it\n // means precisely \"not paid\" \u2014 a default of `unconfirmed` for it would tell an\n // operator to check their network over an unpaid invoice.\n if (input.status === 403 || input.status === 402) {\n return refuse({ reason: \"not_paid\", httpStatus: input.status, nowIso, planeMessage });\n }\n if (input.status === 401) {\n return refuse({ reason: \"deactivated\", httpStatus: input.status, nowIso, planeMessage });\n }\n return refuse({ reason: \"unconfirmed\", httpStatus: input.status, nowIso, planeMessage });\n}\n\n/**\n * Parse the 200 body. Returns null for anything that is not exactly the result\n * \u2014 including a JSON object with the right field names but `entitled: false`,\n * which `z.literal(true)` refuses in the schema and which must never be read as\n * a grant here either.\n *\n * Imported lazily-shaped: the schema lives in the contract so the plane and this\n * file cannot disagree about the wire.\n */\nfunction parseResult(body: string): {\n tenantId: string;\n collectorId: string;\n tier: string | null;\n licenceExpiresAt: string;\n confirmedAt: string;\n modules: readonly string[];\n} | null {\n let json: unknown;\n try {\n json = JSON.parse(body);\n } catch {\n return null;\n }\n const parsed = CollectorEntitlementResult.safeParse(json);\n if (!parsed.success) return null;\n return {\n tenantId: parsed.data.tenantId,\n collectorId: parsed.data.collectorId,\n tier: parsed.data.tier,\n licenceExpiresAt: parsed.data.licenceExpiresAt,\n confirmedAt: parsed.data.confirmedAt,\n // The AUTHORITATIVE owned-module list, straight off the wire. `.strict()` on\n // `CollectorEntitlementResult` guarantees it is present (an old plane that\n // omits it fails the parse above, which becomes `unconfirmed` \u2014 fail closed).\n modules: parsed.data.modules,\n };\n}\n\n/** The plane's `error` string out of an ApiError body, or null if it sent none. */\nfunction apiErrorMessage(body: string): string | null {\n let json: unknown;\n try {\n json = JSON.parse(body);\n } catch {\n // Not JSON at all \u2014 a proxy's HTML error page, an empty body, a truncated\n // response. Carrying a snippet of HTML into an operator's log helps nobody;\n // the status is already recorded on the refusal.\n return null;\n }\n const parsed = ApiError.safeParse(json);\n return parsed.success ? parsed.data.error : null;\n}\n", "import { ROUTES, COLLECTOR_AUTH_HEADER, collectorBearer } from \"@netdoc/collector-contract\";\nimport type { AppConfig } from \"../config.js\";\nimport { classifyEntitlementAnswer, refuse, type EntitlementVerdict } from \"./verdict.js\";\n\n/**\n * Asking the plane. One request, one classification, no decisions.\n *\n * \u2500\u2500 THE SUBSET OF CONFIG THIS NEEDS, AND WHY IT IS A SUBSET \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * `Pick`ed rather than taking `AppConfig`, exactly as `PollConfig` in\n * `core/poll.ts` is, because the callers are not all daemons. The one-shot\n * module entry points (`vmware-index.ts`, `sql-index.ts`, \u2026) load their own\n * narrower config shapes, and every one of them carries these two fields \u2014\n * making the parameter the full `AppConfig` would have meant either widening\n * nine config loaders or leaving nine scan paths ungated, and the second is how\n * `pnpm vmware-scan` stays wide open.\n */\nexport type EntitlementCheckConfig = Pick<AppConfig, \"controlPlaneUrl\" | \"apiKey\">;\n\n/**\n * How long to wait for the answer.\n *\n * SHORTER than `core/poll.ts`'s 30s, deliberately. A poll that hangs delays the\n * next poll; an entitlement check that hangs delays the START OF WORK on every\n * path in the process, including a one-shot scan somebody is watching at a\n * terminal. Ten seconds is long enough for a slow link and short enough that a\n * black-holed control plane produces a refusal an operator can read rather than\n * a command that appears to have frozen.\n *\n * The timeout produces `unconfirmed`, not a crash, and that is the fail-closed\n * outcome: nothing runs.\n */\nconst ENTITLEMENT_TIMEOUT_MS = 10_000;\n\n/**\n * Ask the control plane whether this collector's tenant may run one.\n *\n * NEVER THROWS. Every failure \u2014 DNS, TLS, refused connection, timeout, a proxy's\n * HTML error page, a 500, an abort \u2014 becomes an `unconfirmed` refusal, because a\n * gate whose check can throw is a gate that a missing `try` upstream turns into\n * an unhandled rejection, and an unhandled rejection in a daemon loop is exactly\n * the shape of \"the scheduler silently stopped\". A total function here means the\n * only two things a caller can receive are \"yes\" and \"no, because\", and both are\n * values it must handle.\n *\n * The abort signal is honoured so a shutting-down daemon does not sit in a\n * ten-second wait; an abort classifies as `unconfirmed` like any other\n * non-answer, which is correct \u2014 we are shutting down, and nothing should start.\n */\nexport async function fetchEntitlement(\n cfg: EntitlementCheckConfig,\n signal?: AbortSignal,\n): Promise<EntitlementVerdict> {\n const url = cfg.controlPlaneUrl.replace(/\\/+$/, \"\") + ROUTES.collectorEntitlement;\n const timeout = AbortSignal.timeout(ENTITLEMENT_TIMEOUT_MS);\n const reqSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;\n try {\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [COLLECTOR_AUTH_HEADER]: collectorBearer(cfg.apiKey),\n },\n // The route reads no body. An empty JSON object is sent rather than\n // nothing so that a proxy or WAF that dislikes a POST with no body does\n // not turn a licence check into a 411 \u2014 which would classify as\n // `unconfirmed` and pause a paying customer over a header.\n body: \"{}\",\n signal: reqSignal,\n });\n return classifyEntitlementAnswer({ status: res.status, body: await res.text() });\n } catch (err) {\n // The transport error goes in OUR sentence, never passed off as the plane's\n // words, because the plane never spoke. `planeMessage` staying null is what\n // lets a reader tell \"it did not answer\" from \"it said no\".\n return refuse({\n reason: \"unconfirmed\",\n planeMessage: null,\n localDetail: describeTransportFailure(err),\n httpStatus: null,\n });\n }\n}\n\n/**\n * The transport failure, appended to the operator's sentence.\n *\n * Separate from `fetchEntitlement` only so the message composition stays in one\n * place; `refuse()` already writes the standing advice, and this adds the one\n * detail that changes per failure \u2014 \"getaddrinfo ENOTFOUND\", \"connect\n * ECONNREFUSED\", \"The operation was aborted due to timeout\". Without it an\n * operator reading the log knows their collector is paused and not why.\n */\nexport function describeTransportFailure(err: unknown): string {\n if (!(err instanceof Error)) return String(err);\n let msg = err.message;\n // Node's global fetch throws a bare \"fetch failed\" and hides the real reason\n // in `.cause` \u2014 the same unwrap `core/domainScan.ts` does, for the same\n // reason: \"fetch failed\" alone is useless for diagnosis.\n const cause = (err as { cause?: unknown }).cause;\n if (cause) {\n const code = (cause as { code?: string }).code;\n const cmsg = cause instanceof Error ? cause.message : String(cause);\n const extra = cmsg || code;\n if (extra && !msg.includes(extra)) msg += `: ${extra}`;\n }\n return msg;\n}\n", "/**\n * THE LAST VERDICT, ON DISK \u2014 so a restart refuses before it has asked.\n *\n * \u2500\u2500 WHAT THIS FILE IS FOR, AND WHAT IT IS EMPHATICALLY NOT FOR \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * It is NOT an offline licence. Nothing in this process ever starts a scan on\n * the strength of what is in this file: every path that begins work awaits a\n * live answer from the control plane first (see `gate.ts`). A cached ALLOWED is\n * worth exactly one thing \u2014 the localhost UI and the local scheduler can render\n * and reason about a plausible state in the seconds between process start and\n * the first live check completing, instead of showing a blank or, worse,\n * defaulting to something permissive.\n *\n * What it is for is the moment immediately after a restart. Without it, a\n * collector that comes back up has no verdict at all, and \"no verdict\" has to\n * mean \"refuse\" \u2014 which is right, and which would also mean the status pane says\n * \"unconfirmed\" on every restart of a perfectly healthy paying customer, for as\n * long as it takes the first check to land. With it, the pane says what was last\n * true and the gate still refuses to start work until the live check agrees.\n *\n * \u2500\u2500 FAIL CLOSED ON A MISSING OR UNREADABLE FILE, WHICH IS THE EASY HALF \u2500\u2500\u2500\u2500\u2500\n *\n * `readCachedVerdict` returns an `unconfirmed` refusal for a file that is\n * absent, unreadable, not JSON, JSON of the wrong shape, or of a version this\n * build does not know. It never returns null and never throws, so there is no\n * \"no answer\" case for a caller to get wrong \u2014 the only two things it can hand\n * back are a verdict and a refusal, and a caller that ignores the difference\n * still refuses.\n *\n * The harder half is that a cache is a file an attacker with local write access\n * can author. They could write `{allowed: true}` into it. That gains them\n * nothing, because of the rule at the top: work needs a LIVE 200 from the plane,\n * and this file cannot manufacture one. It is written 0600 all the same, and for\n * the same reason `schedule/store.ts` gives \u2014 integrity, not secrecy. It carries\n * no secret: a tier name, two timestamps and two ids that are already in the\n * config file beside it.\n *\n * \u2500\u2500 WHY THIS DOES NOT REUSE schedule/store.ts's writeJsonAtomic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * It is the same eight lines, and copying them is still right: `schedule/`\n * imports the entitlement gate (the scheduler must not hand out a run to an\n * unentitled tenant), so importing back the other way would be a cycle between\n * two modules that have no business knowing about each other. Entitlement is\n * lower in the stack than scheduling and must not depend on it.\n */\nimport { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { z } from \"zod\";\n// Found, not hop-counted: the bundled build flattens src/ into dist/, so a\n// dirname() count that was right in src/ would resolve one directory out. The\n// same reasoning `schedule/store.ts` and `ui/status.ts` record.\nimport { PKG_ROOT } from \"../pkgRoot.js\";\nimport { refuse, type EntitlementVerdict } from \"./verdict.js\";\n\n/** Where the last verdict is kept. Overridable so tests never touch a real one. */\nexport function entitlementCachePath(): string {\n return (\n process.env.COLLECTOR_ENTITLEMENT_CACHE_FILE ??\n join(PKG_ROOT, \"collector-entitlement.json\")\n );\n}\n\n/**\n * The persisted shape.\n *\n * MIRRORED as a zod schema rather than trusted as a cast, for the reason\n * `schedule/store.ts` gives about its own state file: this is a file on a disk\n * somebody else administers, and one truncated by a full volume or edited by a\n * curious operator must fail to parse rather than feed `undefined` into a gate.\n *\n * `version` is a literal. A file written by a future build with a different\n * shape reads as unparseable here \u2014 which fails closed, and is the safe\n * direction for a downgrade.\n */\nconst CachedVerdictFile = z.object({\n version: z.literal(1),\n verdict: z.union([\n z.object({\n allowed: z.literal(true),\n confirmedAtLocal: z.string(),\n confirmedAt: z.string(),\n licenceExpiresAt: z.string(),\n tier: z.string().nullable(),\n tenantId: z.string(),\n collectorId: z.string(),\n // The owned-module list, so a restart can SHOW which scans were last\n // permitted. Required for the same reason the rest of the shape is: a file\n // written by an older build that lacks it fails to parse and reads as an\n // `unconfirmed` refusal \u2014 the fail-closed direction \u2014 and the first live\n // check rewrites it. Nothing starts work on a cached list either way: the\n // synchronous per-module backstop, like the paid-collector one, refuses\n // until THIS process has re-confirmed.\n modules: z.array(z.string()),\n }),\n z.object({\n allowed: z.literal(false),\n reason: z.enum([\"not_paid\", \"deactivated\", \"unconfirmed\"]),\n checkedAtLocal: z.string(),\n message: z.string(),\n planeMessage: z.string().nullable(),\n httpStatus: z.number().nullable(),\n }),\n ]),\n});\n\n/**\n * Read the last verdict, or an `unconfirmed` refusal.\n *\n * Every failure mode collapses to the same safe answer, and the `localDetail`\n * says which one it was \u2014 because \"you have never run this collector\" and \"your\n * cache file is corrupt\" are different problems and only the second needs\n * anybody's attention.\n */\nexport function readCachedVerdict(path = entitlementCachePath()): EntitlementVerdict {\n let raw: string;\n try {\n raw = readFileSync(path, \"utf8\");\n } catch {\n return refuse({\n reason: \"unconfirmed\",\n localDetail: `no entitlement has been confirmed on this host yet (${path} is absent or unreadable)`,\n });\n }\n let json: unknown;\n try {\n json = JSON.parse(raw);\n } catch {\n return refuse({\n reason: \"unconfirmed\",\n localDetail: `${path} is not valid JSON; the last entitlement answer has been discarded`,\n });\n }\n const parsed = CachedVerdictFile.safeParse(json);\n if (!parsed.success) {\n return refuse({\n reason: \"unconfirmed\",\n localDetail: `${path} does not match the format this collector writes; the last entitlement answer has been discarded`,\n });\n }\n return parsed.data.verdict;\n}\n\n/**\n * Write the verdict, atomically.\n *\n * NEVER THROWS. A collector that cannot write this file still works perfectly \u2014\n * it asks the plane before every piece of work, which is the authority anyway \u2014\n * and all it loses is the state the UI shows in the first seconds after a\n * restart. Letting an EACCES or a full disk propagate out of here would turn a\n * cosmetic loss into a failed scan, so the caller is handed the problem as a log\n * line and nothing else.\n *\n * Written to a sibling temp name and renamed over the target, the same way\n * `schedule/store.ts` writes its state and for the same reason: `renameSync` is\n * atomic within a filesystem on every platform this ships to, so a reader sees\n * either the whole old file or the whole new one. A half-written file would\n * parse as garbage, and \u2014 thanks to the rule above \u2014 would then read as\n * `unconfirmed` and pause a paying customer over a power cut.\n */\nexport function writeCachedVerdict(\n verdict: EntitlementVerdict,\n path = entitlementCachePath(),\n): { ok: true } | { ok: false; problem: string } {\n try {\n const dir = dirname(path);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n const tmp = `${path}.tmp`;\n // 0600 for integrity, not secrecy \u2014 see the header.\n writeFileSync(tmp, `${JSON.stringify({ version: 1, verdict }, null, 2)}\\n`, {\n encoding: \"utf8\",\n mode: 0o600,\n });\n renameSync(tmp, path);\n return { ok: true };\n } catch (err) {\n return { ok: false, problem: (err as Error).message };\n }\n}\n", "/**\n * THE GATE \u2014 nothing on this host starts work without a confirmed paid licence.\n *\n * Operator instruction, verbatim, 2026-09-01: *\"Tenant must have a paid Tier to\n * access collector. Nothing runs if free or deactivated tier.\"* Asked what\n * should happen when the licence cannot be CONFIRMED \u2014 the plane is down, the\n * link is cut \u2014 they were told the cost (a control-plane outage pauses a paying\n * customer's scheduled scans) and chose to FAIL CLOSED IMMEDIATELY over a grace\n * window. That decision is on the record. Do not reintroduce a grace period here\n * because it looks kinder; `verdict.ts` sets out why the kinder version has no\n * defensible length.\n *\n * \u2500\u2500 WHY A CLIENT-SIDE GATE IS NOT A BYPASS, WHICH IS THE OBVIOUS OBJECTION \u2500\u2500\n *\n * `core/planRefusal.ts` states the rule this has to live under: \"a client-side\n * gate is advisory at best, and a client-side grant is a bypass.\" It is right,\n * and this file does not break it. Nothing here can produce a grant. The only\n * thing that can is a 200 from `ROUTES.collectorEntitlement`, a route the\n * control plane mounts in CAPTURE mode and therefore refuses to serve at all\n * unless it has already verified the licence signature, checked the expiry and\n * asked `collectorAllowed`. The server is still the sole authority; this gate is\n * the client half that ASKS FIRST instead of finding out at the push.\n *\n * That distinction is what makes the gate necessary rather than decorative. Up\n * to 2026-09-01 the server-side refusal was sufficient on its own, because the\n * server held the credentials and the schedule \u2014 a refused collector had nothing\n * to scan with and no reason to start. Both moved onto this host in the same\n * change. A collector now holds its own vault and its own scheduler, so it can\n * sweep a customer's entire estate \u2014 vCenter, domain controllers, SQL instances,\n * every switch \u2014 end to end with no server involved, and merely fail to upload.\n * The gate had to move to where the work starts.\n *\n * \u2500\u2500 WHERE THE WORK STARTS, WHICH IS MORE PLACES THAN THE DAEMON \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * A gate on the daemon alone leaves `pnpm scan` wide open. The paths are:\n *\n * 1. `core/daemon.ts` `runJob` \u2014 every job the daemon executes, whether it was\n * polled from the portal (\"Run scan now\") or produced by a local schedule.\n * Async `require`, so this is the authoritative check.\n * 2. `schedule/engine.ts` `tick` \u2014 refuses to HAND OUT a due run at all, using\n * the synchronous snapshot. Cheap (it runs every few seconds) and it means\n * a refused tenant's schedules are never marked running and never counted\n * as missed.\n * 3. `core/runner.ts` `runCollector` \u2014 the network scan itself, so the one-shot\n * `pnpm scan` / `netdoc-collector scan` is gated without depending on its\n * entry point remembering to.\n * 4. `core/domainScan.ts` \u2014 a synchronous backstop at the top of every\n * `run*Scan`, so a future caller that skips 1\u20133 still cannot log in to a\n * customer's appliance.\n * 5. the per-module CLI entry points (`vmware-index.ts` and its eight\n * siblings), which run their own scan code and reach none of the above.\n *\n * \u2500\u2500 TWO CHECKS, AND WHY THE SECOND IS NOT A WEAKER COPY OF THE FIRST \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * `require()` is asynchronous and always asks the plane. `assertConfirmed()` is\n * synchronous and reads the last answer. They are not two grades of the same\n * check; they answer different questions:\n *\n * require() \"Is this tenant entitled, right now?\" \u2014 the decision.\n * assertConfirmed() \"Did anybody actually ask?\" \u2014 the backstop.\n *\n * The backstop exists because the failure this whole change is about is a scan\n * path that nobody remembered to gate. It costs no network, so it can sit at the\n * bottom of the stack where the credentials are actually used, and its answer\n * comes from a confirmation a few milliseconds old. It is bounded by\n * `CONFIRMATION_MAX_AGE_MS` so that it cannot quietly become the grace period\n * that was declined: a snapshot older than that reads as unconfirmed and\n * refuses.\n *\n * \u2500\u2500 NO TIME-BASED CACHE ON THE DECISION ITSELF \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * `require()` deliberately has no TTL. Draining twenty queued jobs makes twenty\n * small HTTPS requests, which is nothing beside twenty estate sweeps, and the\n * alternative \u2014 \"one confirmation authorises the next N minutes of work\" \u2014 is a\n * grace period wearing a cache's clothes. Concurrent callers DO share one\n * in-flight request, because that is deduplication rather than a time window:\n * two questions asked at the same instant have the same answer.\n */\nimport { fetchEntitlement, type EntitlementCheckConfig } from \"./client.js\";\nimport { readCachedVerdict, writeCachedVerdict } from \"./cache.js\";\nimport {\n confirmationIsFresh,\n refuse,\n type EntitlementRefusalReason,\n type EntitlementRefused,\n type EntitlementVerdict,\n} from \"./verdict.js\";\n\n/** The collector's logger shape, duplicated as a type so `entitlement/` does\n * not import `core/` \u2014 `core/` imports THIS, and a cycle helps nobody. */\nexport type GateLogger = (\n level: \"debug\" | \"info\" | \"warn\" | \"error\",\n message: string,\n meta?: unknown,\n) => void;\n\n/**\n * Work was refused because the tenant is not on a confirmed paid licence.\n *\n * A TYPE and not a string, for the reason `PlanRefusal` is one: every caller\n * that already has a `catch` gets to ask \"was this an entitlement refusal?\" and\n * answer the operator in words, and the machine-readable `reason` stays\n * available for code that has to branch on which of the three it is.\n *\n * Deliberately NOT a subclass of `PlanRefusal`, and deliberately not reusing its\n * codes. That class means \"the control plane rejected something we sent it\", and\n * every one of its three codes is a value the plane returned in an ApiError.\n * This one means \"we refused to send anything at all\", and one of its three\n * reasons (`unconfirmed`) is a fact about THIS host's network that no plane ever\n * asserted. Folding them together would put a refusal the plane never issued\n * behind a class whose whole documented invariant is that every value in it came\n * from the plane.\n */\nexport class CollectorNotEntitled extends Error {\n readonly reason: EntitlementRefusalReason;\n /** The plane's verbatim words, or null when it never answered. */\n readonly planeMessage: string | null;\n readonly httpStatus: number | null;\n /** The work that was refused, so the log line says what did not happen. */\n readonly attempted: string;\n readonly verdict: EntitlementRefused;\n\n constructor(verdict: EntitlementRefused, attempted: string) {\n super(`${attempted} was refused: ${verdict.message}`);\n this.name = \"CollectorNotEntitled\";\n this.reason = verdict.reason;\n this.planeMessage = verdict.planeMessage;\n this.httpStatus = verdict.httpStatus;\n this.attempted = attempted;\n this.verdict = verdict;\n }\n}\n\n/** Is this thrown value an entitlement refusal? */\nexport function isNotEntitled(err: unknown): err is CollectorNotEntitled {\n return err instanceof CollectorNotEntitled;\n}\n\n/** The refusal, or `undefined` \u2014 the shape a `catch` block wants. */\nexport function asNotEntitled(err: unknown): CollectorNotEntitled | undefined {\n return err instanceof CollectorNotEntitled ? err : undefined;\n}\n\n/**\n * What the localhost UI renders, and the ONLY shape a UI should read.\n *\n * Flat and already-decided on purpose: a renderer must not have to work out\n * whether a verdict is fresh, whether a cached one counts, or which of two\n * timestamps to show. Every one of those is a judgement this module owns, and a\n * second implementation of it in a template is a second answer.\n */\nexport interface CollectorEntitlementStatus {\n /** May this collector start work, as of the last answer? */\n allowed: boolean;\n /** Which refusal, or null when allowed. Never collapsed \u2014 see verdict.ts. */\n reason: EntitlementRefusalReason | null;\n /** The sentence to show an operator, or null when allowed. */\n message: string | null;\n /** The control plane's own words, when it spoke. Null when it did not. */\n planeMessage: string | null;\n /** Host-clock ISO of the last time entitlement was CONFIRMED, or null. */\n lastConfirmedAt: string | null;\n /** Host-clock ISO of the last time this collector ASKED, or null if never. */\n lastCheckedAt: string | null;\n /** The plane's clock at that confirmation, for display beside the local one. */\n confirmedAtPlane: string | null;\n /** Display only \u2014 the collector never re-judges expiry. Null when unknown. */\n licenceExpiresAt: string | null;\n /** Display and audit only, exactly as the licence says. */\n tier: string | null;\n /**\n * True when the only answer we have came off disk and this process has not\n * yet completed a live check. Distinct from `allowed` because a UI should say\n * \"last known good, not yet re-confirmed\" rather than either lying or blanking.\n */\n fromCache: boolean;\n /** The HTTP status behind a refusal, for a support engineer. Null otherwise. */\n httpStatus: number | null;\n}\n\nexport interface EntitlementGate {\n /**\n * Record a plan refusal the plane volunteered on some OTHER call \u2014 a job\n * poll, an ingest \u2014 as an entitlement refusal, without asking again.\n *\n * \u2500\u2500 WHY REFUSALS PROPAGATE THIS WAY AND CONFIRMATIONS DO NOT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * A successful poll of `ROUTES.collectorJobsNext` is, today, exactly as strong\n * a confirmation as the entitlement route: it is mounted in CAPTURE mode too,\n * so a 200 means the middleware verified the signature, the expiry and\n * `collectorAllowed`. It is TEMPTING to treat it as one and halve the request\n * count. It is refused here, on purpose.\n *\n * The reason is what happens when somebody later moves that route to\n * `collector-winddown` \u2014 a plausible edit with a reasonable-sounding argument\n * behind it, exactly like the one `routes/collectorEntitlement.ts` guards\n * against. If a 200 from it granted entitlement, that edit would silently\n * authorise every lapsed collector in the fleet, and nothing would fail. One\n * route confirms, and it is the one whose only job is to confirm.\n *\n * Refusals are the other direction and the asymmetry is safe by construction:\n * this can only ever move the gate from open to shut, so a caller that never\n * calls it is merely slower to notice, never wrong. The same one-directional\n * argument the contract makes for `LicensePayload.trials`.\n */\n noteRefusal(input: { reason: EntitlementRefusalReason; planeMessage: string; httpStatus: number }): void;\n /**\n * Ask the plane, record the answer, and REFUSE by throwing\n * `CollectorNotEntitled` if it is not a yes. `attempted` names the work in\n * the words an operator would use (\"the nightly VMware scan of site-3\").\n */\n require(attempted: string, signal?: AbortSignal): Promise<void>;\n /**\n * Ask the plane and hand back the verdict without throwing. For the daemon,\n * which has a job to mark failed and a status to report rather than an\n * exception to propagate.\n */\n check(signal?: AbortSignal): Promise<EntitlementVerdict>;\n /**\n * The synchronous backstop: throw unless a FRESH confirmation is on record.\n * See the header for why this is not a weaker copy of `require`.\n */\n assertConfirmed(attempted: string): void;\n /** The same question, as a boolean \u2014 for `schedule/engine.ts`'s tick. */\n mayStartWork(): boolean;\n /**\n * Does this tenant OWN the given collector/scan module, as of the last\n * confirmed answer? An ADDITIONAL gate beside `mayStartWork` \u2014 that one asks\n * \"may I scan at all\", this asks \"may I run THIS scan\" \u2014 and read the same\n * fail-closed way: FALSE unless a FRESH confirmation is on record AND the\n * module is in the plane's authoritative list. An absent, stale or refused\n * verdict owns nothing, and the collector never derives the answer itself (see\n * `CollectorEntitlementResult.modules`).\n */\n ownsModule(moduleKey: string): boolean;\n /** What the UI renders. */\n status(): CollectorEntitlementStatus;\n}\n\nexport interface EntitlementGateOptions {\n /** Where to ask, and what to authenticate with. */\n config: EntitlementCheckConfig;\n log: GateLogger;\n /** Injectable clock, so staleness and skew are testable without waiting. */\n now?: () => number;\n /**\n * The check itself, injectable so every refusal \u2014 expired licence,\n * `collector: false`, an unreachable plane, a plane returning garbage \u2014 is\n * reachable in a test without a network or a server. Fail-closed code is most\n * often wrong in the safe-looking direction, so the refusals are the paths\n * that most need to be easy to exercise.\n */\n fetch?: (\n cfg: EntitlementCheckConfig,\n signal?: AbortSignal,\n ) => Promise<EntitlementVerdict>;\n /** Cache file location; a test points it at a temp directory. */\n cachePath?: string | undefined;\n /**\n * Seed the gate from the on-disk cache at construction. On by default \u2014 that\n * is the whole reason the cache exists (see cache.ts). Off in tests that want\n * a gate with no history.\n */\n seedFromCache?: boolean;\n}\n\nexport function createEntitlementGate(options: EntitlementGateOptions): EntitlementGate {\n const now = options.now ?? Date.now;\n const ask = options.fetch ?? fetchEntitlement;\n const log = options.log;\n const cachePath = options.cachePath;\n\n /**\n * The last answer. Seeded from disk so a restart has something honest to show\n * \u2014 and seeded to a REFUSAL when the disk has nothing, which is what makes\n * \"fail closed on a missing or unreadable cache\" a property of construction\n * rather than of a branch somebody has to reach.\n */\n let verdict: EntitlementVerdict =\n options.seedFromCache === false\n ? refuse({\n reason: \"unconfirmed\",\n localDetail: \"this collector has not yet asked the control plane\",\n })\n : cachePath === undefined\n ? readCachedVerdict()\n : readCachedVerdict(cachePath);\n let fromCache = true;\n /** Host-clock ISO of the last time entitlement was CONFIRMED, ever. */\n let lastConfirmedAt: string | null = verdict.allowed ? verdict.confirmedAtLocal : null;\n /** One in-flight request shared by concurrent askers. See the header. */\n let inFlight: Promise<EntitlementVerdict> | null = null;\n /**\n * The refusal already reported, so a loop that keeps running says it ONCE.\n *\n * The same argument `createRefusalLatch` makes in `core/planRefusal.ts`: the\n * scheduler evaluates every few seconds and will go on doing so through a\n * lapsed subscription \u2014 correctly, because somebody else can lift a billing\n * hold without touching this host \u2014 but \"keep checking\" must not mean \"keep\n * saying it\". An unattended weekend at a few seconds an evaluation is tens of\n * thousands of identical lines on a disk we do not own.\n *\n * Reported again when the reason CHANGES or CLEARS, because \"it started\n * working again\" is the other fact an operator reading that file wants.\n */\n let latched: EntitlementRefusalReason | null = null;\n\n const record = (next: EntitlementVerdict): void => {\n verdict = next;\n fromCache = false;\n if (next.allowed) lastConfirmedAt = next.confirmedAtLocal;\n const written = cachePath === undefined ? writeCachedVerdict(next) : writeCachedVerdict(next, cachePath);\n if (!written.ok) {\n // Not fatal, and deliberately at debug: the plane is asked before every\n // piece of work regardless, so an unwritable cache costs only what the UI\n // shows in the first seconds after a restart. A warn here would be a\n // recurring line about a cosmetic problem.\n log(\"debug\", `could not cache the entitlement answer: ${written.problem}`);\n }\n if (next.allowed) {\n if (latched !== null) {\n latched = null;\n log(\"info\", \"the control plane has confirmed this tenant's paid license again \u2014 scanning resumes\");\n }\n return;\n }\n if (latched === next.reason) {\n log(\"debug\", `entitlement still refused (${next.reason})`);\n return;\n }\n latched = next.reason;\n log(\"error\", `this collector is not permitted to scan: ${next.message}`);\n };\n\n const check = async (signal?: AbortSignal): Promise<EntitlementVerdict> => {\n // Concurrent askers share one request. Deduplication, not a time window \u2014\n // the promise is cleared the instant it settles, so the NEXT caller asks\n // again rather than reading a stale answer.\n if (inFlight !== null) return inFlight;\n const pending = (async () => {\n try {\n const answer = await ask(options.config, signal);\n record(answer);\n return answer;\n } catch (err) {\n // `fetchEntitlement` is documented as total, but an injected fetcher (or\n // a future one) might not be, and an entitlement check that throws would\n // become an unhandled rejection inside a daemon loop \u2014 which is the\n // shape of \"the scheduler silently stopped\". Fail closed instead.\n const answer = refuse({\n reason: \"unconfirmed\",\n localDetail: `the entitlement check itself failed: ${(err as Error).message}`,\n });\n record(answer);\n return answer;\n } finally {\n inFlight = null;\n }\n })();\n inFlight = pending;\n return pending;\n };\n\n const require_ = async (attempted: string, signal?: AbortSignal): Promise<void> => {\n const answer = await check(signal);\n if (answer.allowed) return;\n throw new CollectorNotEntitled(answer, attempted);\n };\n\n const staleRefusal = (): EntitlementRefused =>\n refuse({\n reason: \"unconfirmed\",\n nowIso: new Date(now()).toISOString(),\n localDetail:\n \"no entitlement confirmation from the control plane is current on this host, so no \" +\n \"scan may start\",\n });\n\n const assertConfirmed = (attempted: string): void => {\n if (confirmationIsFresh(verdict, now())) return;\n // A refusal already on record is reported as itself \u2014 an operator must not\n // be told \"unconfirmed\" when the plane actually said \"not paid\". Only a\n // verdict that is allowed-but-STALE, or absent, becomes `unconfirmed` here.\n throw new CollectorNotEntitled(verdict.allowed ? staleRefusal() : verdict, attempted);\n };\n\n const status = (): CollectorEntitlementStatus => {\n const fresh = confirmationIsFresh(verdict, now());\n if (verdict.allowed) {\n return {\n allowed: fresh,\n // An ALLOWED verdict that has gone stale is not a refusal the plane\n // issued, so it is reported for what it is: nobody has confirmed\n // recently. Saying `not_paid` here would accuse a paying customer.\n reason: fresh ? null : \"unconfirmed\",\n message: fresh ? null : staleRefusal().message,\n planeMessage: null,\n lastConfirmedAt,\n lastCheckedAt: verdict.confirmedAtLocal,\n confirmedAtPlane: verdict.confirmedAt,\n licenceExpiresAt: verdict.licenceExpiresAt,\n tier: verdict.tier,\n fromCache,\n httpStatus: null,\n };\n }\n return {\n allowed: false,\n reason: verdict.reason,\n message: verdict.message,\n planeMessage: verdict.planeMessage,\n lastConfirmedAt,\n lastCheckedAt: verdict.checkedAtLocal,\n confirmedAtPlane: null,\n licenceExpiresAt: null,\n tier: null,\n fromCache,\n httpStatus: verdict.httpStatus,\n };\n };\n\n const noteRefusal = (input: {\n reason: EntitlementRefusalReason;\n planeMessage: string;\n httpStatus: number;\n }): void => {\n record(\n refuse({\n reason: input.reason,\n planeMessage: input.planeMessage,\n httpStatus: input.httpStatus,\n nowIso: new Date(now()).toISOString(),\n }),\n );\n };\n\n const ownsModule = (moduleKey: string): boolean => {\n // Fail-closed, and layered on top of freshness rather than beside it: a stale\n // ALLOWED verdict grants no module, exactly as it starts no work. Once fresh\n // and allowed, the answer is a membership test against the plane's list \u2014\n // never a computation of our own.\n if (!confirmationIsFresh(verdict, now())) return false;\n if (!verdict.allowed) return false;\n return verdict.modules.includes(moduleKey);\n };\n\n return {\n noteRefusal,\n require: require_,\n check,\n assertConfirmed,\n mayStartWork: () => confirmationIsFresh(verdict, now()),\n ownsModule,\n status,\n };\n}\n\n// ---------------------------------------------------------------------------\n// The process's gate.\n// ---------------------------------------------------------------------------\n\n/**\n * \u2500\u2500 WHY THERE IS A PROCESS-WIDE ONE AT ALL \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * This codebase prefers injection, for a reason it has already paid to learn:\n * two vault handles over one file each hold their own decrypted copy, so a\n * credential added in the UI is invisible to the next scan. Entitlement has the\n * same shape of problem for a different reason \u2014 the UI's status pane, the\n * scheduler's tick and the backstop inside a domain scan must all read ONE\n * answer, or the pane says \"paused\" while a scan is running on a verdict only\n * the daemon saw.\n *\n * But entitlement cannot be injected everywhere the way the vault is. The\n * vault reaches two call sites; the gate has to reach the daemon, the\n * scheduler, the network runner, eleven domain-scan functions and nine\n * standalone CLI entry points, several of which construct nothing and load their\n * own narrow config. Threading a handle through all of them would guarantee that\n * one of them is missed \u2014 and a missed one is a scan path with no gate, which is\n * the exact defect this change exists to remove.\n *\n * So: a memoised per-process gate, built from whatever config the FIRST caller\n * has, plus `setProcessEntitlementGate` for the daemon (which builds its own\n * with its own logger) and for tests. The memo is keyed on nothing \u2014 one\n * process serves one collector identity, which is the same assumption\n * `config.ts` already makes.\n */\nlet processGate: EntitlementGate | null = null;\n\n/**\n * The gate this process uses, built on first use from the caller's config.\n *\n * Every call site that starts work already holds `controlPlaneUrl` and `apiKey`\n * \u2014 the daemon from `AppConfig`, a domain scan from `DomainScanParams`, a CLI\n * entry point from its own module config \u2014 so nothing has to be threaded and\n * nothing has to be remembered at startup. A gate that only exists when an entry\n * point remembers to build one is the same absence that left `MetricsCollector`\n * constructed by nothing for weeks (see `core/daemonLoops.ts`), and it fails the\n * same way: silently, on the customer's machine.\n */\nexport function processEntitlementGate(\n config: EntitlementCheckConfig,\n log: GateLogger,\n): EntitlementGate {\n if (processGate === null) {\n processGate = createEntitlementGate({ config, log });\n }\n return processGate;\n}\n\n/**\n * Install the gate for this process \u2014 used by the daemon entry path (so the\n * gate logs through the daemon's redacting logger) and by tests.\n *\n * `null` clears it, which is what a test's `afterEach` wants; the next\n * `processEntitlementGate` call then builds a fresh one. Note that clearing does\n * NOT open the gate: the freshly built one starts from the on-disk cache, and a\n * missing cache is an `unconfirmed` refusal.\n */\nexport function setProcessEntitlementGate(gate: EntitlementGate | null): void {\n processGate = gate;\n}\n\n/**\n * Tell the process gate about a refusal the plane volunteered elsewhere.\n *\n * A NO-OP when no gate has been built yet, and that is right rather than a gap:\n * with no gate there is nothing to shut, and the first thing that tries to start\n * work builds one that begins from the on-disk cache \u2014 which, having never been\n * confirmed in this process, refuses anyway. Nothing is missed except a status\n * pane update on a process that has not yet done anything.\n *\n * Never builds a gate of its own, deliberately. It has no config to build one\n * from, and inventing one from the refusal would put the wrong `controlPlaneUrl`\n * behind every later check.\n */\nexport function noteEntitlementRefusalFromPlane(input: {\n reason: EntitlementRefusalReason;\n planeMessage: string;\n httpStatus: number;\n}): void {\n processGate?.noteRefusal(input);\n}\n\n/**\n * Does this process's collector OWN the given scan module right now?\n *\n * The per-module companion to `collectorEntitlementStatus`, for the UI (which\n * enables a \"Run scan\" button only for owned modules) and for the metrics\n * side-loop (which must not SNMP-poll switches for a module nobody bought).\n *\n * FAIL-CLOSED BY CONSTRUCTION. With no process gate built yet \u2014 a fresh process\n * that has not asked the plane \u2014 the honest answer is that nothing is confirmed,\n * so nothing is owned: FALSE. That matches what `collectorEntitlementStatus`\n * reports in the same state (`allowed: false`), so a UI reading both never shows\n * an enabled button beside a \"not scanning\" banner. Once a gate exists, the\n * gate's own fail-closed `ownsModule` decides, against a FRESH confirmation.\n *\n * No cache fallback, deliberately: a cached ALLOWED does not authorise this\n * process (see `collectorEntitlementStatus`, which reports `allowed: false` when\n * the only answer is from disk), so it must not light a module either.\n */\nexport function collectorOwnsModule(moduleKey: string): boolean {\n return processGate?.ownsModule(moduleKey) ?? false;\n}\n\n/**\n * Re-ask the control plane NOW, for the local UI's \"Refresh\" control.\n *\n * The same accessor shape as `collectorOwnsModule`: it acts on the INSTALLED\n * process gate and never creates one, because a gate built here would hold a\n * second confirmation and could disagree with the daemon's about the same tenant\n * in the same second.\n *\n * It cannot grant anything. All it does is refresh the confirmation the gate\n * already fails closed on \u2014 which is what makes it safe to expose on a local\n * page: the worst an operator can do by pressing it is learn sooner.\n *\n * `false` when no gate is installed, which is the one-shot CLI and the tests.\n */\nexport async function refreshCollectorEntitlement(): Promise<boolean> {\n if (processGate === null) return false;\n await processGate.check();\n return true;\n}\n\n/**\n * The state a renderer reads. THE export for the UI.\n *\n * Answers without a config, and without building a gate, because a status pane\n * must be able to ask before anything has started work. When no gate exists yet\n * the honest answer is the one from disk \u2014 which, on a host that has never\n * confirmed anything, is an `unconfirmed` refusal.\n */\nexport function collectorEntitlementStatus(cachePath?: string): CollectorEntitlementStatus {\n if (processGate !== null) return processGate.status();\n const cached = cachePath === undefined ? readCachedVerdict() : readCachedVerdict(cachePath);\n if (cached.allowed) {\n return {\n allowed: false, // nothing in THIS process has confirmed anything yet\n reason: \"unconfirmed\",\n message:\n \"this collector has a previously confirmed paid license on record but has not yet \" +\n \"re-confirmed it with the control plane since starting; no scan will run until it does\",\n planeMessage: null,\n lastConfirmedAt: cached.confirmedAtLocal,\n lastCheckedAt: cached.confirmedAtLocal,\n confirmedAtPlane: cached.confirmedAt,\n licenceExpiresAt: cached.licenceExpiresAt,\n tier: cached.tier,\n fromCache: true,\n httpStatus: null,\n };\n }\n return {\n allowed: false,\n reason: cached.reason,\n message: cached.message,\n planeMessage: cached.planeMessage,\n lastConfirmedAt: null,\n lastCheckedAt: cached.checkedAtLocal,\n confirmedAtPlane: null,\n licenceExpiresAt: null,\n tier: null,\n fromCache: true,\n httpStatus: cached.httpStatus,\n };\n}\n", "/**\n * The two lines a scan path writes.\n *\n * Every gated call site in the collector uses one of these rather than reaching\n * for `processEntitlementGate` itself, and the reason is that a call site should\n * not have to decide anything. Deciding whether a cached verdict counts, whether\n * a stale one is a refusal or a fresh question, which of the three reasons to\n * report \u2014 those are judgements `gate.ts` owns, and a second copy of any of them\n * in a scan path is a second answer that will eventually differ from the first.\n *\n * Two functions, because there are two questions (see `gate.ts`'s header):\n *\n * requireEntitlementToScan asks the plane. THE decision. Use this wherever\n * work actually begins and an `await` is available.\n * assertEntitlementConfirmed does not ask anybody. The backstop, for the\n * synchronous bottom of the stack \u2014 it proves that\n * somebody upstream DID ask, and refuses if the\n * answer on record is missing, stale or a no.\n */\nimport { CollectorNotEntitled, processEntitlementGate, type GateLogger } from \"./gate.js\";\nimport { refuse } from \"./verdict.js\";\nimport type { EntitlementCheckConfig } from \"./client.js\";\n\n/**\n * What a call site actually has in its hand.\n *\n * WIDER than `EntitlementCheckConfig` on purpose: one of the nine one-shot entry\n * points \u2014 `sensitive-data-index.ts` \u2014 loads a config whose `controlPlaneUrl`\n * and `apiKey` are OPTIONAL, and every other caller's are required. Accepting\n * the wider shape here means the missing-endpoint case is handled once, in the\n * gate, instead of nine times at the call sites (eight of which would be dead\n * code, and the ninth of which somebody would get wrong).\n */\nexport interface EntitlementTarget {\n controlPlaneUrl?: string | undefined;\n apiKey?: string | undefined;\n}\n\n/**\n * Narrow a target, or refuse.\n *\n * \u2500\u2500 THIS OVERTURNS `SensitiveDataConfig`'s LOCAL-ONLY MODE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * `config.ts` says of that config's two optional fields, verbatim:\n *\n * \"Both present = results are pushed; either missing = local-only scan.\"\n *\n * That affordance is withdrawn for the SCAN, though not for the push. It lost\n * because it is the one path in the collector that could read a customer's file\n * shares \u2014 the module whose entire subject is where their sensitive data lives \u2014\n * with no way for anybody to ask whether the tenant is entitled to run it, and\n * \"cannot ask\" now means \"does not run\". Leaving it would have made the gate\n * bypassable by deleting two lines from a config file, which is not a gate.\n *\n * What is NOT claimed here: that this is costless. An operator who ran\n * sensitive-data discovery purely locally, on purpose, must now configure the\n * collector's endpoint and key even though nothing will be uploaded. That is a\n * real regression, and it is the same trade the operator made knowingly when\n * they chose fail-closed over a grace window \u2014 an unconfirmable licence stops\n * work, whatever the reason it cannot be confirmed.\n */\nfunction targetOrRefuse(target: EntitlementTarget, attempted: string): EntitlementCheckConfig {\n const controlPlaneUrl = target.controlPlaneUrl?.trim() ?? \"\";\n const apiKey = target.apiKey?.trim() ?? \"\";\n if (controlPlaneUrl.length === 0 || apiKey.length === 0) {\n throw new CollectorNotEntitled(\n refuse({\n reason: \"unconfirmed\",\n localDetail:\n \"this collector has no control-plane URL and API key configured, so there is \" +\n \"nobody to confirm the tenant's license with\",\n }),\n attempted,\n );\n }\n return { controlPlaneUrl, apiKey };\n}\n\n/**\n * Confirm with the control plane that this tenant may run a collector, and\n * throw `CollectorNotEntitled` if it will not say so.\n *\n * `attempted` is the work in the words an operator would use \u2014 \"the nightly\n * VMware scan of site-3\", \"the network sweep\" \u2014 because it is what ends up in\n * the message they read and in the failed job's reason in the portal. \"job\" or\n * \"scan\" tells them nothing they did not know.\n *\n * `config` is any object carrying `controlPlaneUrl` and `apiKey`. Every caller\n * already has one: the daemon's `AppConfig`, a domain scan's\n * `DomainScanParams`, a CLI entry point's own module config. Nothing has to be\n * threaded through a constructor and nothing has to be remembered at startup \u2014\n * which is the property that lets nine standalone entry points be gated without\n * any of them growing a wiring step somebody can forget.\n */\nexport async function requireEntitlementToScan(\n config: EntitlementTarget,\n log: GateLogger,\n attempted: string,\n signal?: AbortSignal,\n): Promise<void> {\n await processEntitlementGate(targetOrRefuse(config, attempted), log).require(attempted, signal);\n}\n\n/**\n * Throw unless a FRESH confirmation is already on record for this process.\n *\n * No network, no config, no `await` \u2014 so it can sit at the top of a synchronous\n * scan function where the credentials are about to be used. It does not decide\n * entitlement; it detects a caller that never asked.\n *\n * A gate that has never been built refuses, because the gate it would build\n * starts from the on-disk cache and an absent cache is an `unconfirmed`\n * refusal. That is the fail-closed default arriving by construction rather than\n * through a branch somebody has to reach.\n */\nexport function assertEntitlementConfirmed(\n config: EntitlementTarget,\n log: GateLogger,\n attempted: string,\n): void {\n processEntitlementGate(targetOrRefuse(config, attempted), log).assertConfirmed(attempted);\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -8,7 +8,7 @@ import {
8
8
  AzureRbacAssignment,
9
9
  AzureResource,
10
10
  AzureSubscription
11
- } from "./chunk-4CH7OHPW.js";
11
+ } from "./chunk-GZEDFM4N.js";
12
12
 
13
13
  // src/azure/arm.ts
14
14
  var noopLog = () => {
@@ -774,4 +774,4 @@ export {
774
774
  listAccessibleSubscriptions,
775
775
  buildAzureInventory
776
776
  };
777
- //# sourceMappingURL=chunk-2PED6PTT.js.map
777
+ //# sourceMappingURL=chunk-CXCA2TI4.js.map
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ApiError,
3
3
  moduleForScanDomain
4
- } from "./chunk-4CH7OHPW.js";
4
+ } from "./chunk-GZEDFM4N.js";
5
5
 
6
6
  // src/core/planRefusal.ts
7
7
  var PLAN_REFUSAL_CODES = [
@@ -112,4 +112,4 @@ export {
112
112
  asPlanRefusal,
113
113
  createRefusalLatch
114
114
  };
115
- //# sourceMappingURL=chunk-5TRYP3HM.js.map
115
+ //# sourceMappingURL=chunk-CYDM3P2U.js.map
@@ -3310,6 +3310,12 @@ var ROUTES = {
3310
3310
  // Session-gated on purpose: `publicTiers` rules grants out because it is
3311
3311
  // unauthenticated and CORS-open, and both halves of that reason are about a
3312
3312
  // PUBLIC feed. See routes/planTiers.ts.
3313
+ // A tenant admin ending their own paid plan, effective when the period they
3314
+ // have already bought runs out. GET reads the pending request, DELETE
3315
+ // withdraws it. See routes/planDowngrade.ts for why this does not breach the
3316
+ // "no Stripe signal drives a downward re-mint" rule.
3317
+ planDowngrade: `${API_BASE}/plan/downgrade`,
3318
+ // GET | POST | DELETE (session, ADMIN)
3313
3319
  planTierModules: `${API_BASE}/plan/tier-modules`,
3314
3320
  // GET (session, READ)
3315
3321
  threatIntelAdvisories: `${API_BASE}/threat-intel/advisories`,
@@ -14749,6 +14755,7 @@ export {
14749
14755
  ScheduleTiming,
14750
14756
  ROUTES,
14751
14757
  ScanDomain,
14758
+ MODULE_FOR_SCAN_DOMAIN,
14752
14759
  moduleForScanDomain,
14753
14760
  ClaimedJobResponse,
14754
14761
  ScanJobStatusResult,
@@ -14826,4 +14833,4 @@ export {
14826
14833
  earliestNextRun,
14827
14834
  jitterFor
14828
14835
  };
14829
- //# sourceMappingURL=chunk-4CH7OHPW.js.map
14836
+ //# sourceMappingURL=chunk-GZEDFM4N.js.map