@secondlayer/shared 11.4.3 → 11.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/src/archive/derived-stage-report.d.ts +2 -2
- package/dist/src/archive/derived-stage-report.js.map +1 -1
- package/dist/src/archive/instance-diagnosis.js +2 -2
- package/dist/src/archive/instance-diagnosis.js.map +2 -2
- package/dist/src/archive/range-digest.d.ts +25 -25
- package/dist/src/archive/semantic-digest-builder.d.ts +25 -25
- package/dist/src/coverage/index.d.ts +25 -25
- package/dist/src/coverage/index.js.map +1 -1
- package/dist/src/crypto/secondlayer-webhook.d.ts +2 -2
- package/dist/src/crypto/secondlayer-webhook.js.map +1 -1
- package/dist/src/crypto/secrets.js.map +1 -1
- package/dist/src/db/index.d.ts +37 -37
- package/dist/src/db/index.js +4 -4
- package/dist/src/db/index.js.map +3 -3
- package/dist/src/db/queries/chain-reorgs.d.ts +25 -25
- package/dist/src/db/queries/chain-reorgs.js +4 -4
- package/dist/src/db/queries/chain-reorgs.js.map +3 -3
- package/dist/src/db/queries/contracts.d.ts +25 -25
- package/dist/src/db/queries/instance.d.ts +25 -25
- package/dist/src/db/queries/integrity.d.ts +25 -25
- package/dist/src/db/queries/subgraph-gaps.d.ts +25 -25
- package/dist/src/db/queries/subgraph-operations.d.ts +25 -25
- package/dist/src/db/queries/subgraphs.d.ts +25 -25
- package/dist/src/db/queries/subgraphs.js +4 -4
- package/dist/src/db/queries/subgraphs.js.map +3 -3
- package/dist/src/db/queries/sync-scope.d.ts +25 -25
- package/dist/src/db/queries/{subscriptions.d.ts → webhooks.d.ts} +58 -58
- package/dist/src/db/queries/{subscriptions.js → webhooks.js} +38 -38
- package/dist/src/db/queries/webhooks.js.map +13 -0
- package/dist/src/db/schema.d.ts +34 -34
- package/dist/src/index.d.ts +107 -107
- package/dist/src/index.js +51 -54
- package/dist/src/index.js.map +6 -6
- package/dist/src/leader.d.ts +4 -4
- package/dist/src/leader.js +3 -3
- package/dist/src/leader.js.map +3 -3
- package/dist/src/node/local-client.d.ts +25 -25
- package/dist/src/queue/listener.d.ts +2 -2
- package/dist/src/queue/listener.js.map +1 -1
- package/dist/src/runtime/index.js +2 -6
- package/dist/src/runtime/index.js.map +4 -4
- package/dist/src/schemas/index.d.ts +61 -61
- package/dist/src/schemas/index.js +48 -51
- package/dist/src/schemas/index.js.map +5 -5
- package/dist/src/schemas/subgraphs.d.ts +2 -2
- package/dist/src/schemas/subgraphs.js.map +1 -1
- package/dist/src/schemas/{subscriptions.d.ts → webhooks.d.ts} +63 -63
- package/dist/src/schemas/{subscriptions.js → webhooks.js} +49 -52
- package/dist/src/schemas/webhooks.js.map +10 -0
- package/migrations/0126_rename_subscriptions_to_webhooks.ts +96 -0
- package/migrations/0127_rename_webhook_catalog_names.ts +55 -0
- package/package.json +8 -8
- package/dist/src/db/queries/subscriptions.js.map +0 -13
- package/dist/src/schemas/subscriptions.js.map +0 -10
package/README.md
CHANGED
|
@@ -22,13 +22,13 @@ DATABASE_URL=... bun run migrate
|
|
|
22
22
|
|
|
23
23
|
| Path | Description |
|
|
24
24
|
|------|-------------|
|
|
25
|
-
| `@secondlayer/shared` | Core utilities (errors, logger, env, db layer, subgraph/
|
|
25
|
+
| `@secondlayer/shared` | Core utilities (errors, logger, env, db layer, subgraph/webhook schemas) |
|
|
26
26
|
| `@secondlayer/shared/db` | Kysely database layer |
|
|
27
27
|
| `@secondlayer/shared/db/schema` | Database schema + row types |
|
|
28
|
-
| `@secondlayer/shared/db/queries/*` | Query helpers (integrity, chain-reorgs, subgraphs, subgraph-gaps, subgraph-operations,
|
|
29
|
-
| `@secondlayer/shared/schemas` | Zod schemas (subgraphs,
|
|
28
|
+
| `@secondlayer/shared/db/queries/*` | Query helpers (integrity, chain-reorgs, subgraphs, subgraph-gaps, subgraph-operations, webhooks) |
|
|
29
|
+
| `@secondlayer/shared/schemas` | Zod schemas (subgraphs, webhooks) |
|
|
30
30
|
| `@secondlayer/shared/schemas/subgraphs` | Subgraph schemas |
|
|
31
|
-
| `@secondlayer/shared/schemas/
|
|
31
|
+
| `@secondlayer/shared/schemas/webhooks` | Webhook schemas |
|
|
32
32
|
| `@secondlayer/shared/subgraphs/spec` | Subgraph spec generation |
|
|
33
33
|
| `@secondlayer/shared/queue/listener` | Postgres LISTEN/NOTIFY helper (used for block notifications) |
|
|
34
34
|
| `@secondlayer/shared/logger` | Logger |
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The canonical archive rebuilds `blocks`, `transactions`, and `events`
|
|
6
6
|
* deterministically from R2 alone. Every stage downstream of those tables
|
|
7
|
-
* (decoders, subgraphs,
|
|
7
|
+
* (decoders, subgraphs, webhook delivery) has its own inputs, outputs,
|
|
8
8
|
* and rebuild requirements — and an operator restoring from the archive
|
|
9
9
|
* needs to know exactly which of those it also needs to source, replay, or
|
|
10
10
|
* re-derive.
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* `assertAllServicesDeclared()` fails a build when a known service is missing.
|
|
29
29
|
*/
|
|
30
30
|
declare const DERIVED_STAGE_REPORT_SCHEMA_VERSION: 1;
|
|
31
|
-
type ServiceKind = "decoder" | "subgraph-runtime" | "
|
|
31
|
+
type ServiceKind = "decoder" | "subgraph-runtime" | "webhook-runtime" | "protocol-producer";
|
|
32
32
|
type CanonicalInput = "blocks" | "transactions" | "events";
|
|
33
33
|
interface ExternalInput {
|
|
34
34
|
/** Short name for the dependency: `stacks-node-rpc`, `hiro-extended-api`,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/archive/derived-stage-report.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * Derived-stage acceptance — what each service reads, writes, and needs\n * beyond the canonical archive.\n *\n * The canonical archive rebuilds `blocks`, `transactions`, and `events`\n * deterministically from R2 alone. Every stage downstream of those tables\n * (decoders, subgraphs,
|
|
5
|
+
"/**\n * Derived-stage acceptance — what each service reads, writes, and needs\n * beyond the canonical archive.\n *\n * The canonical archive rebuilds `blocks`, `transactions`, and `events`\n * deterministically from R2 alone. Every stage downstream of those tables\n * (decoders, subgraphs, webhook delivery) has its own inputs, outputs,\n * and rebuild requirements — and an operator restoring from the archive\n * needs to know exactly which of those it also needs to source, replay, or\n * re-derive.\n *\n * This module defines the reporting shape. Each producer/service declares\n * itself once (a `ServiceManifest`) and the aggregator emits one report per\n * service to `reports/derived/<service>.json`. Stateful verification —\n * actually re-running the service and comparing outputs — is Phase 4 work\n * (see P4.10 effect manifests, P4.13 stateful deep verify); this shape is\n * what Phase 4 will populate.\n *\n * The report answers three operator questions:\n * 1. What canonical inputs does this service consume? (archive suffices)\n * 2. What external inputs does it need? (RPC endpoints, envelope journal,\n * contract source, ORACLE feeds — anything not in the archive)\n * 3. If I restore from R2 alone, can this service resume? If not, what\n * is missing?\n *\n * Silence is the failure mode we most want to prevent: a service that ships\n * without a manifest is a service whose rebuild story is undocumented.\n * `assertAllServicesDeclared()` fails a build when a known service is missing.\n */\n\nexport const DERIVED_STAGE_REPORT_SCHEMA_VERSION = 1 as const;\n\nexport type ServiceKind =\n\t| \"decoder\"\n\t| \"subgraph-runtime\"\n\t| \"webhook-runtime\"\n\t| \"protocol-producer\";\n\nexport type CanonicalInput = \"blocks\" | \"transactions\" | \"events\";\n\nexport interface ExternalInput {\n\t/** Short name for the dependency: `stacks-node-rpc`, `hiro-extended-api`,\n\t * `observer-journal`, `contract-source-registry`, etc. */\n\tname: string;\n\t/** Why the archive can't provide this. One sentence. */\n\treason: string;\n\t/** Where an operator can source it. `bundled-stacks`, `own-node`,\n\t * `hiro-public-api`, `re-derive-from-events`, `manual-import`. */\n\tsource: string;\n\t/** True if the archive supplies enough to re-derive this dependency\n\t * without a live external source. Rare but happens (e.g. contract ABIs\n\t * derivable from `transactions.raw_tx`). */\n\trebuildable_from_archive: boolean;\n}\n\nexport interface Output {\n\t/** Table/topic/queue name in the operator's runtime. */\n\ttarget: string;\n\t/** How the operator verifies this output — e.g. `row-count`,\n\t * `semantic-digest`, `effect-manifest`, `dlq-empty`. */\n\tverification: string;\n}\n\nexport interface ServiceManifest {\n\tname: string;\n\tkind: ServiceKind;\n\tdescription: string;\n\tcanonical_inputs: CanonicalInput[];\n\texternal_inputs: ExternalInput[];\n\toutputs: Output[];\n\t/**\n\t * Explicit judgment on whether R2 alone can rebuild this service.\n\t * `false` REQUIRES `external_inputs` to name what else is needed.\n\t */\n\tr2_alone_can_rebuild: boolean;\n\t/** When true, this service can only replay forward from a starting point\n\t * the operator supplies (e.g. a webhook consumer's last-delivered id).\n\t * Publish it, don't hide it. */\n\trequires_operator_state: boolean;\n\t/** Repo path where the service lives, for cross-reference. */\n\tsource_path: string;\n}\n\nexport interface DerivedStageReport {\n\tschema_version: typeof DERIVED_STAGE_REPORT_SCHEMA_VERSION;\n\tgenerated_at: string;\n\tservices: ServiceManifest[];\n}\n\n/**\n * Compose a report from a set of registered manifests. Sorts services by name\n * so the JSON is stable across runs — a re-emit with unchanged manifests\n * produces byte-identical output, so an operator can diff two archive\n * publish cycles cleanly.\n */\nexport function buildDerivedStageReport(\n\tmanifests: ReadonlyArray<ServiceManifest>,\n\tgeneratedAt: string,\n): DerivedStageReport {\n\tconst sorted = [...manifests].sort((a, b) => a.name.localeCompare(b.name));\n\treturn {\n\t\tschema_version: DERIVED_STAGE_REPORT_SCHEMA_VERSION,\n\t\tgenerated_at: generatedAt,\n\t\tservices: sorted,\n\t};\n}\n\nexport type ManifestValidationIssue = {\n\tname: string;\n\tproblem: string;\n};\n\n/**\n * Enforce the invariants the report shape leaves ambiguous:\n * - `r2_alone_can_rebuild: false` REQUIRES at least one external input.\n * Otherwise the report claims a rebuild gap it can't explain, which is\n * worse than declaring nothing.\n * - Names are unique — a duplicate is almost always a copy-paste that\n * silently shadows the earlier declaration.\n * - Every canonical_input is a real dataset name.\n */\nexport function validateServiceManifests(\n\tmanifests: ReadonlyArray<ServiceManifest>,\n): ManifestValidationIssue[] {\n\tconst issues: ManifestValidationIssue[] = [];\n\tconst seen = new Set<string>();\n\tconst canonicalDatasets: ReadonlySet<CanonicalInput> = new Set([\n\t\t\"blocks\",\n\t\t\"transactions\",\n\t\t\"events\",\n\t]);\n\tfor (const m of manifests) {\n\t\tif (seen.has(m.name)) {\n\t\t\tissues.push({ name: m.name, problem: \"duplicate service name\" });\n\t\t}\n\t\tseen.add(m.name);\n\t\tif (!m.r2_alone_can_rebuild && m.external_inputs.length === 0) {\n\t\t\tissues.push({\n\t\t\t\tname: m.name,\n\t\t\t\tproblem:\n\t\t\t\t\t\"declares r2_alone_can_rebuild=false but lists no external_inputs — the gap is undocumented\",\n\t\t\t});\n\t\t}\n\t\tfor (const input of m.canonical_inputs) {\n\t\t\tif (!canonicalDatasets.has(input)) {\n\t\t\t\tissues.push({\n\t\t\t\t\tname: m.name,\n\t\t\t\t\tproblem: `canonical_inputs contains unknown dataset: ${String(input)}`,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\treturn issues;\n}\n"
|
|
6
6
|
],
|
|
7
7
|
"mappings": ";;;;;;;;;;;;;;;;;AA8BO,IAAM,sCAAsC;AAiE5C,SAAS,uBAAuB,CACtC,WACA,aACqB;AAAA,EACrB,MAAM,SAAS,CAAC,GAAG,SAAS,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,IAAI,CAAC;AAAA,EACzE,OAAO;AAAA,IACN,gBAAgB;AAAA,IAChB,cAAc;AAAA,IACd,UAAU;AAAA,EACX;AAAA;AAiBM,SAAS,wBAAwB,CACvC,WAC4B;AAAA,EAC5B,MAAM,SAAoC,CAAC;AAAA,EAC3C,MAAM,OAAO,IAAI;AAAA,EACjB,MAAM,oBAAiD,IAAI,IAAI;AAAA,IAC9D;AAAA,IACA;AAAA,IACA;AAAA,EACD,CAAC;AAAA,EACD,WAAW,KAAK,WAAW;AAAA,IAC1B,IAAI,KAAK,IAAI,EAAE,IAAI,GAAG;AAAA,MACrB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,yBAAyB,CAAC;AAAA,IAChE;AAAA,IACA,KAAK,IAAI,EAAE,IAAI;AAAA,IACf,IAAI,CAAC,EAAE,wBAAwB,EAAE,gBAAgB,WAAW,GAAG;AAAA,MAC9D,OAAO,KAAK;AAAA,QACX,MAAM,EAAE;AAAA,QACR,SACC;AAAA,MACF,CAAC;AAAA,IACF;AAAA,IACA,WAAW,SAAS,EAAE,kBAAkB;AAAA,MACvC,IAAI,CAAC,kBAAkB,IAAI,KAAK,GAAG;AAAA,QAClC,OAAO,KAAK;AAAA,UACX,MAAM,EAAE;AAAA,UACR,SAAS,8CAA8C,OAAO,KAAK;AAAA,QACpE,CAAC;AAAA,MACF;AAAA,IACD;AAAA,EACD;AAAA,EACA,OAAO;AAAA;",
|
|
8
8
|
"debugId": "C79406F0A3036B1164756E2164756E21",
|
|
@@ -83,7 +83,7 @@ var CONTEXT_FIELDS = [
|
|
|
83
83
|
"streamsTip",
|
|
84
84
|
"indexTip",
|
|
85
85
|
"subgraphs",
|
|
86
|
-
"
|
|
86
|
+
"webhooks",
|
|
87
87
|
"activeOperations"
|
|
88
88
|
];
|
|
89
89
|
function explainContextNulls(snapshot, probe) {
|
|
@@ -130,5 +130,5 @@ export {
|
|
|
130
130
|
BOOTSTRAP_STEP
|
|
131
131
|
};
|
|
132
132
|
|
|
133
|
-
//# debugId=
|
|
133
|
+
//# debugId=66007563793D52F964756E2164756E21
|
|
134
134
|
//# sourceMappingURL=instance-diagnosis.js.map
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/archive/instance-diagnosis.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * One reading of `/status` (or `/public/status`), shared by the CLI\n * (`status`, `doctor`, `context`) and the SDK (`sl.instance.diagnose`).\n *\n * These surfaces used to each interpret the same payload on their own and\n * disagreed in the most common first-run state: on a freshly self-hosted\n * instance with no chain data, `status` printed DEGRADED while `doctor` printed\n * \"All checks passed\", and neither named the cause or a next command. An\n * operator (or an agent) reading both had no way to tell whether it was safe to\n * proceed. The verdict now lives here so the surfaces cannot drift apart, and\n * every issue carries the exact command that resolves it.\n */\n\nexport interface PublicStatusDecoder {\n\tdecoder: string;\n\tstatus: string;\n}\n\nexport interface PublicStatusService {\n\tname: string;\n\tstatus: string;\n}\n\n/** The subset of `/status` a diagnosis reads. Everything is optional:\n * an older instance may not send a field, and a missing field must never be\n * read as a failure. */\nexport interface PublicStatus {\n\tstatus?: string;\n\tchainTip?: number | null;\n\tchainIntegrity?: {\n\t\tok?: boolean;\n\t\tmaxHeight?: number;\n\t\treason?: string | null;\n\t};\n\tstreams?: {\n\t\tstatus?: string;\n\t\ttip?: { lag_seconds?: number; block_height?: number } | null;\n\t};\n\tindex?: { status?: string; decoders?: PublicStatusDecoder[] };\n\tservices?: PublicStatusService[];\n\ttimestamp?: string;\n\t[key: string]: unknown;\n}\n\nexport interface InstanceIssue {\n\t/** What is wrong, in one line. */\n\ttitle: string;\n\t/** Why — assembled only from what the payload actually reports. */\n\tdetail?: string;\n\t/** What to run next, most useful first. */\n\tnextSteps: string[];\n}\n\nexport type InstanceState = \"healthy\" | \"empty-index\" | \"degraded\";\n\nexport interface InstanceDiagnosis {\n\tstate: InstanceState;\n\t/** The API's own top-level verdict, passed through unmodified. */\n\toverall: string;\n\tissues: InstanceIssue[];\n}\n\nexport const BOOTSTRAP_STEP =\n\t\"Restore verified history: secondlayer bootstrap --against <manifest>\";\nexport const OBSERVER_STEP =\n\t\"Confirm your node streams into this instance — secondlayer observer --mode indexer --endpoint secondlayer:3700 prints the stanza its config needs\";\nexport const SELF_HOST_DOCS_STEP =\n\t\"Docs: https://www.secondlayer.tools/docs/self-host\";\n\n/** `true` when the instance is reachable but has never indexed a block. */\nexport function hasNoChainData(status: PublicStatus): boolean {\n\tconst integrity = status.chainIntegrity;\n\t// `check_failed` is the API's placeholder when the integrity query itself\n\t// errored — its maxHeight of 0 means \"unknown\", not \"empty\".\n\tif (!integrity || integrity.reason === \"check_failed\") return false;\n\tif (integrity.maxHeight !== 0) return false;\n\treturn (status.chainTip ?? null) === null;\n}\n\nfunction emptyIndexIssue(status: PublicStatus): InstanceIssue {\n\tconst decoders = status.index?.decoders ?? [];\n\tconst stalled = decoders.filter((d) => d.status !== \"ok\").length;\n\tconst decoderClause =\n\t\tstalled > 0\n\t\t\t? ` and ${stalled} decoder${stalled === 1 ? \"\" : \"s\"} report unavailable because there is nothing to decode`\n\t\t\t: \"\";\n\treturn {\n\t\ttitle: \"No blocks indexed yet — this instance holds 0 blocks.\",\n\t\tdetail: `Streams has no tip${decoderClause}, which is what makes the overall status DEGRADED. This is the expected state for a fresh instance: nothing is broken, but no read returns data until history lands.`,\n\t\tnextSteps: [BOOTSTRAP_STEP, OBSERVER_STEP, SELF_HOST_DOCS_STEP],\n\t};\n}\n\nconst SERVICE_STEPS = [\n\t\"See the raw per-service report: secondlayer status --json\",\n\t\"Check the containers from docker/oss: docker compose ps, then docker compose logs --tail=50 secondlayer\",\n];\n\n/**\n * Turn a `/status` payload into a verdict plus zero or more issues.\n * Only facts present in the payload become issues — when the API says degraded\n * and names no failing surface, the issue says exactly that rather than\n * guessing a cause.\n */\nexport function diagnoseInstanceStatus(\n\tstatus: PublicStatus,\n): InstanceDiagnosis {\n\tconst overall = String(status.status ?? \"unknown\");\n\n\tif (hasNoChainData(status)) {\n\t\treturn { state: \"empty-index\", overall, issues: [emptyIndexIssue(status)] };\n\t}\n\n\tconst issues: InstanceIssue[] = [];\n\tfor (const service of status.services ?? []) {\n\t\tif (service.status === \"ok\") continue;\n\t\tissues.push({\n\t\t\ttitle: `Service ${service.name} reports ${service.status}.`,\n\t\t\tnextSteps: SERVICE_STEPS,\n\t\t});\n\t}\n\n\tconst integrity = status.chainIntegrity;\n\tif (integrity && integrity.ok === false) {\n\t\tissues.push({\n\t\t\ttitle: \"Chain-data integrity check failed.\",\n\t\t\tdetail:\n\t\t\t\tintegrity.reason ??\n\t\t\t\t\"The tip the instance serves is not backed by the history it implies.\",\n\t\t\tnextSteps: [\n\t\t\t\t\"Verify against the archive: secondlayer verify all --against <manifest>\",\n\t\t\t\t\"Repair what diverged: secondlayer repair --against <manifest> --apply\",\n\t\t\t],\n\t\t});\n\t}\n\n\tif (issues.length === 0 && overall !== \"healthy\") {\n\t\t// Do not invent a cause: report the contradiction and where to look.\n\t\tissues.push({\n\t\t\ttitle: `The API reports status=${overall} but names no failing service.`,\n\t\t\tdetail:\n\t\t\t\t\"Every service in the report is ok, so the degradation is in a surface this command does not summarize.\",\n\t\t\tnextSteps: SERVICE_STEPS,\n\t\t});\n\t}\n\n\treturn {\n\t\tstate: issues.length > 0 ? \"degraded\" : \"healthy\",\n\t\toverall,\n\t\tissues,\n\t};\n}\n\n// ── context ────────────────────────────────────────────────────────────\n\nexport interface ContextProbe {\n\t/** The endpoint the snapshot was read from. */\n\tapiUrl: string;\n\t/** `false` only for the metered archive deployment, which has accounts. */\n\tselfHosted: boolean;\n\t/** `true` when INSTANCE_TOKEN / SL_API_KEY resolved to a value. */\n\thasCredential: boolean;\n\t/** `/public/status` when it answered, `null` when the probe failed. */\n\tstatus: PublicStatus | null;\n\t/** Why the probe failed, when it did. */\n\tstatusError?: string;\n}\n\nexport interface ContextExplanation {\n\t/** One line naming the likeliest cause of the nulls below. */\n\tsummary: string;\n\t/** Per-null-field reason. Fields that carry a value are absent. */\n\tnulls: Record<string, string>;\n}\n\nconst CONTEXT_FIELDS = [\n\t\"account\",\n\t\"streamsTip\",\n\t\"indexTip\",\n\t\"subgraphs\",\n\t\"subscriptions\",\n\t\"activeOperations\",\n] as const;\n\n/**\n * Explain every null in a `context` snapshot.\n *\n * The SDK degrades each read to `null` on failure, so an all-null snapshot is\n * indistinguishable from \"not bootstrapped\", \"unauthorized\", and \"broken\" —\n * which is exactly what a caller needs to tell apart. Each reason here is\n * derived from a live probe of the same instance, never assumed.\n */\nexport function explainContextNulls(\n\tsnapshot: Record<string, unknown>,\n\tprobe: ContextProbe,\n): ContextExplanation {\n\tconst nullFields = CONTEXT_FIELDS.filter(\n\t\t(field) => (snapshot[field] ?? null) === null,\n\t);\n\n\tif (nullFields.length === 0) {\n\t\treturn { summary: \"Every field resolved.\", nulls: {} };\n\t}\n\n\tif (!probe.status) {\n\t\tconst reason = `No instance answered at ${probe.apiUrl} (${probe.statusError ?? \"unreachable\"}), so this read never reached an API.`;\n\t\treturn {\n\t\t\tsummary: `${reason} Start it from docker/oss with: docker compose up -d — or point SL_API_URL at the instance you meant.`,\n\t\t\tnulls: Object.fromEntries(nullFields.map((field) => [field, reason])),\n\t\t};\n\t}\n\n\tconst emptyIndex = hasNoChainData(probe.status);\n\tconst blocks = probe.status.chainIntegrity?.maxHeight ?? null;\n\tconst accountReason = probe.selfHosted\n\t\t? \"Expected: a self-hosted instance has no account system. Accounts exist only on the metered archive deployment at api.secondlayer.tools, so null here is normal and not a failure.\"\n\t\t: \"Not authenticated — run `secondlayer login`, or export INSTANCE_TOKEN.\";\n\n\t// An empty index explains the chain tips and nothing else: the subgraph and\n\t// subscription lists are control-plane reads that answer on a bare instance.\n\tconst listReason = unresolvedReadReason(probe, blocks);\n\tconst chainReason = emptyIndex\n\t\t? `No blocks indexed yet — this instance holds 0 blocks, so there is no tip to report. ${BOOTSTRAP_STEP}`\n\t\t: listReason;\n\n\tconst nulls: Record<string, string> = {};\n\tfor (const field of nullFields) {\n\t\tif (field === \"account\") nulls[field] = accountReason;\n\t\telse if (field === \"streamsTip\" || field === \"indexTip\")\n\t\t\tnulls[field] = chainReason;\n\t\telse if (field === \"activeOperations\")\n\t\t\tnulls[field] =\n\t\t\t\t(snapshot.subgraphs ?? null) === null\n\t\t\t\t\t? \"Not probed: in-flight operations are read per subgraph, and the subgraph list did not resolve.\"\n\t\t\t\t\t: listReason;\n\t\telse nulls[field] = listReason;\n\t}\n\n\tconst summary = emptyIndex\n\t\t? `Instance at ${probe.apiUrl} is reachable but holds 0 blocks — the chain fields are null because nothing has been indexed yet. ${BOOTSTRAP_STEP}`\n\t\t: nullFields.length === 1 && nullFields[0] === \"account\"\n\t\t\t? `Instance at ${probe.apiUrl} is reachable and indexed to block ${blocks ?? \"unknown\"}; only \\`account\\` is null, which is normal when self-hosting.`\n\t\t\t: `Instance at ${probe.apiUrl} is reachable, but ${nullFields.length} field${nullFields.length === 1 ? \"\" : \"s\"} did not resolve — see \\`nulls\\` below for each one.`;\n\n\treturn { summary, nulls };\n}\n\nfunction unresolvedReadReason(\n\tprobe: ContextProbe,\n\tblocks: number | null,\n): string {\n\tconst where = `The instance at ${probe.apiUrl} is reachable${blocks !== null ? ` and indexed to block ${blocks}` : \"\"}, but this read returned nothing.`;\n\treturn probe.hasCredential\n\t\t? `${where} Check the surface directly: secondlayer status --json`\n\t\t: `${where} No credential resolved: export INSTANCE_TOKEN — an instance published past loopback requires it on every /v1 read.`;\n}\n"
|
|
5
|
+
"/**\n * One reading of `/status` (or `/public/status`), shared by the CLI\n * (`status`, `doctor`, `context`) and the SDK (`sl.instance.diagnose`).\n *\n * These surfaces used to each interpret the same payload on their own and\n * disagreed in the most common first-run state: on a freshly self-hosted\n * instance with no chain data, `status` printed DEGRADED while `doctor` printed\n * \"All checks passed\", and neither named the cause or a next command. An\n * operator (or an agent) reading both had no way to tell whether it was safe to\n * proceed. The verdict now lives here so the surfaces cannot drift apart, and\n * every issue carries the exact command that resolves it.\n */\n\nexport interface PublicStatusDecoder {\n\tdecoder: string;\n\tstatus: string;\n}\n\nexport interface PublicStatusService {\n\tname: string;\n\tstatus: string;\n}\n\n/** The subset of `/status` a diagnosis reads. Everything is optional:\n * an older instance may not send a field, and a missing field must never be\n * read as a failure. */\nexport interface PublicStatus {\n\tstatus?: string;\n\tchainTip?: number | null;\n\tchainIntegrity?: {\n\t\tok?: boolean;\n\t\tmaxHeight?: number;\n\t\treason?: string | null;\n\t};\n\tstreams?: {\n\t\tstatus?: string;\n\t\ttip?: { lag_seconds?: number; block_height?: number } | null;\n\t};\n\tindex?: { status?: string; decoders?: PublicStatusDecoder[] };\n\tservices?: PublicStatusService[];\n\ttimestamp?: string;\n\t[key: string]: unknown;\n}\n\nexport interface InstanceIssue {\n\t/** What is wrong, in one line. */\n\ttitle: string;\n\t/** Why — assembled only from what the payload actually reports. */\n\tdetail?: string;\n\t/** What to run next, most useful first. */\n\tnextSteps: string[];\n}\n\nexport type InstanceState = \"healthy\" | \"empty-index\" | \"degraded\";\n\nexport interface InstanceDiagnosis {\n\tstate: InstanceState;\n\t/** The API's own top-level verdict, passed through unmodified. */\n\toverall: string;\n\tissues: InstanceIssue[];\n}\n\nexport const BOOTSTRAP_STEP =\n\t\"Restore verified history: secondlayer bootstrap --against <manifest>\";\nexport const OBSERVER_STEP =\n\t\"Confirm your node streams into this instance — secondlayer observer --mode indexer --endpoint secondlayer:3700 prints the stanza its config needs\";\nexport const SELF_HOST_DOCS_STEP =\n\t\"Docs: https://www.secondlayer.tools/docs/self-host\";\n\n/** `true` when the instance is reachable but has never indexed a block. */\nexport function hasNoChainData(status: PublicStatus): boolean {\n\tconst integrity = status.chainIntegrity;\n\t// `check_failed` is the API's placeholder when the integrity query itself\n\t// errored — its maxHeight of 0 means \"unknown\", not \"empty\".\n\tif (!integrity || integrity.reason === \"check_failed\") return false;\n\tif (integrity.maxHeight !== 0) return false;\n\treturn (status.chainTip ?? null) === null;\n}\n\nfunction emptyIndexIssue(status: PublicStatus): InstanceIssue {\n\tconst decoders = status.index?.decoders ?? [];\n\tconst stalled = decoders.filter((d) => d.status !== \"ok\").length;\n\tconst decoderClause =\n\t\tstalled > 0\n\t\t\t? ` and ${stalled} decoder${stalled === 1 ? \"\" : \"s\"} report unavailable because there is nothing to decode`\n\t\t\t: \"\";\n\treturn {\n\t\ttitle: \"No blocks indexed yet — this instance holds 0 blocks.\",\n\t\tdetail: `Streams has no tip${decoderClause}, which is what makes the overall status DEGRADED. This is the expected state for a fresh instance: nothing is broken, but no read returns data until history lands.`,\n\t\tnextSteps: [BOOTSTRAP_STEP, OBSERVER_STEP, SELF_HOST_DOCS_STEP],\n\t};\n}\n\nconst SERVICE_STEPS = [\n\t\"See the raw per-service report: secondlayer status --json\",\n\t\"Check the containers from docker/oss: docker compose ps, then docker compose logs --tail=50 secondlayer\",\n];\n\n/**\n * Turn a `/status` payload into a verdict plus zero or more issues.\n * Only facts present in the payload become issues — when the API says degraded\n * and names no failing surface, the issue says exactly that rather than\n * guessing a cause.\n */\nexport function diagnoseInstanceStatus(\n\tstatus: PublicStatus,\n): InstanceDiagnosis {\n\tconst overall = String(status.status ?? \"unknown\");\n\n\tif (hasNoChainData(status)) {\n\t\treturn { state: \"empty-index\", overall, issues: [emptyIndexIssue(status)] };\n\t}\n\n\tconst issues: InstanceIssue[] = [];\n\tfor (const service of status.services ?? []) {\n\t\tif (service.status === \"ok\") continue;\n\t\tissues.push({\n\t\t\ttitle: `Service ${service.name} reports ${service.status}.`,\n\t\t\tnextSteps: SERVICE_STEPS,\n\t\t});\n\t}\n\n\tconst integrity = status.chainIntegrity;\n\tif (integrity && integrity.ok === false) {\n\t\tissues.push({\n\t\t\ttitle: \"Chain-data integrity check failed.\",\n\t\t\tdetail:\n\t\t\t\tintegrity.reason ??\n\t\t\t\t\"The tip the instance serves is not backed by the history it implies.\",\n\t\t\tnextSteps: [\n\t\t\t\t\"Verify against the archive: secondlayer verify all --against <manifest>\",\n\t\t\t\t\"Repair what diverged: secondlayer repair --against <manifest> --apply\",\n\t\t\t],\n\t\t});\n\t}\n\n\tif (issues.length === 0 && overall !== \"healthy\") {\n\t\t// Do not invent a cause: report the contradiction and where to look.\n\t\tissues.push({\n\t\t\ttitle: `The API reports status=${overall} but names no failing service.`,\n\t\t\tdetail:\n\t\t\t\t\"Every service in the report is ok, so the degradation is in a surface this command does not summarize.\",\n\t\t\tnextSteps: SERVICE_STEPS,\n\t\t});\n\t}\n\n\treturn {\n\t\tstate: issues.length > 0 ? \"degraded\" : \"healthy\",\n\t\toverall,\n\t\tissues,\n\t};\n}\n\n// ── context ────────────────────────────────────────────────────────────\n\nexport interface ContextProbe {\n\t/** The endpoint the snapshot was read from. */\n\tapiUrl: string;\n\t/** `false` only for the metered archive deployment, which has accounts. */\n\tselfHosted: boolean;\n\t/** `true` when INSTANCE_TOKEN / SL_API_KEY resolved to a value. */\n\thasCredential: boolean;\n\t/** `/public/status` when it answered, `null` when the probe failed. */\n\tstatus: PublicStatus | null;\n\t/** Why the probe failed, when it did. */\n\tstatusError?: string;\n}\n\nexport interface ContextExplanation {\n\t/** One line naming the likeliest cause of the nulls below. */\n\tsummary: string;\n\t/** Per-null-field reason. Fields that carry a value are absent. */\n\tnulls: Record<string, string>;\n}\n\nconst CONTEXT_FIELDS = [\n\t\"account\",\n\t\"streamsTip\",\n\t\"indexTip\",\n\t\"subgraphs\",\n\t\"webhooks\",\n\t\"activeOperations\",\n] as const;\n\n/**\n * Explain every null in a `context` snapshot.\n *\n * The SDK degrades each read to `null` on failure, so an all-null snapshot is\n * indistinguishable from \"not bootstrapped\", \"unauthorized\", and \"broken\" —\n * which is exactly what a caller needs to tell apart. Each reason here is\n * derived from a live probe of the same instance, never assumed.\n */\nexport function explainContextNulls(\n\tsnapshot: Record<string, unknown>,\n\tprobe: ContextProbe,\n): ContextExplanation {\n\tconst nullFields = CONTEXT_FIELDS.filter(\n\t\t(field) => (snapshot[field] ?? null) === null,\n\t);\n\n\tif (nullFields.length === 0) {\n\t\treturn { summary: \"Every field resolved.\", nulls: {} };\n\t}\n\n\tif (!probe.status) {\n\t\tconst reason = `No instance answered at ${probe.apiUrl} (${probe.statusError ?? \"unreachable\"}), so this read never reached an API.`;\n\t\treturn {\n\t\t\tsummary: `${reason} Start it from docker/oss with: docker compose up -d — or point SL_API_URL at the instance you meant.`,\n\t\t\tnulls: Object.fromEntries(nullFields.map((field) => [field, reason])),\n\t\t};\n\t}\n\n\tconst emptyIndex = hasNoChainData(probe.status);\n\tconst blocks = probe.status.chainIntegrity?.maxHeight ?? null;\n\tconst accountReason = probe.selfHosted\n\t\t? \"Expected: a self-hosted instance has no account system. Accounts exist only on the metered archive deployment at api.secondlayer.tools, so null here is normal and not a failure.\"\n\t\t: \"Not authenticated — run `secondlayer login`, or export INSTANCE_TOKEN.\";\n\n\t// An empty index explains the chain tips and nothing else: the subgraph and\n\t// webhook lists are control-plane reads that answer on a bare instance.\n\tconst listReason = unresolvedReadReason(probe, blocks);\n\tconst chainReason = emptyIndex\n\t\t? `No blocks indexed yet — this instance holds 0 blocks, so there is no tip to report. ${BOOTSTRAP_STEP}`\n\t\t: listReason;\n\n\tconst nulls: Record<string, string> = {};\n\tfor (const field of nullFields) {\n\t\tif (field === \"account\") nulls[field] = accountReason;\n\t\telse if (field === \"streamsTip\" || field === \"indexTip\")\n\t\t\tnulls[field] = chainReason;\n\t\telse if (field === \"activeOperations\")\n\t\t\tnulls[field] =\n\t\t\t\t(snapshot.subgraphs ?? null) === null\n\t\t\t\t\t? \"Not probed: in-flight operations are read per subgraph, and the subgraph list did not resolve.\"\n\t\t\t\t\t: listReason;\n\t\telse nulls[field] = listReason;\n\t}\n\n\tconst summary = emptyIndex\n\t\t? `Instance at ${probe.apiUrl} is reachable but holds 0 blocks — the chain fields are null because nothing has been indexed yet. ${BOOTSTRAP_STEP}`\n\t\t: nullFields.length === 1 && nullFields[0] === \"account\"\n\t\t\t? `Instance at ${probe.apiUrl} is reachable and indexed to block ${blocks ?? \"unknown\"}; only \\`account\\` is null, which is normal when self-hosting.`\n\t\t\t: `Instance at ${probe.apiUrl} is reachable, but ${nullFields.length} field${nullFields.length === 1 ? \"\" : \"s\"} did not resolve — see \\`nulls\\` below for each one.`;\n\n\treturn { summary, nulls };\n}\n\nfunction unresolvedReadReason(\n\tprobe: ContextProbe,\n\tblocks: number | null,\n): string {\n\tconst where = `The instance at ${probe.apiUrl} is reachable${blocks !== null ? ` and indexed to block ${blocks}` : \"\"}, but this read returned nothing.`;\n\treturn probe.hasCredential\n\t\t? `${where} Check the surface directly: secondlayer status --json`\n\t\t: `${where} No credential resolved: export INSTANCE_TOKEN — an instance published past loopback requires it on every /v1 read.`;\n}\n"
|
|
6
6
|
],
|
|
7
7
|
"mappings": "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",
|
|
8
|
-
"debugId": "
|
|
8
|
+
"debugId": "66007563793D52F964756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
|
@@ -788,9 +788,9 @@ interface Database {
|
|
|
788
788
|
account_spend_caps: AccountSpendCapsTable;
|
|
789
789
|
account_credits: AccountCreditsTable;
|
|
790
790
|
hosted_meter_days: HostedMeterDaysTable;
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
791
|
+
webhooks: WebhooksTable;
|
|
792
|
+
webhook_outbox: WebhookOutboxTable;
|
|
793
|
+
webhook_deliveries: WebhookDeliveriesTable;
|
|
794
794
|
trigger_evaluator_state: TriggerEvaluatorStateTable;
|
|
795
795
|
decoded_events: DecodedEventsTable;
|
|
796
796
|
decoder_checkpoints: DecoderCheckpointsTable;
|
|
@@ -910,31 +910,31 @@ interface ArchiveFetchesTable {
|
|
|
910
910
|
via_allowance: Generated<boolean>;
|
|
911
911
|
charged_at: Generated<Date>;
|
|
912
912
|
}
|
|
913
|
-
type
|
|
914
|
-
/** Polymorphic
|
|
913
|
+
type WebhookStatus = "active" | "paused" | "error";
|
|
914
|
+
/** Polymorphic webhook mode: `subgraph` reacts to processed table rows;
|
|
915
915
|
* `chain` reacts to raw chain events matched directly off the Index/Streams
|
|
916
916
|
* clock (no subgraph). See migration 0088. */
|
|
917
|
-
type
|
|
918
|
-
type
|
|
919
|
-
type
|
|
920
|
-
interface
|
|
917
|
+
type WebhookKind = "subgraph" | "chain";
|
|
918
|
+
type WebhookFormat = "standard-webhooks" | "inngest" | "trigger" | "cloudflare" | "cloudevents" | "raw";
|
|
919
|
+
type WebhookRuntime = "inngest" | "trigger" | "cloudflare" | "node";
|
|
920
|
+
interface WebhooksTable {
|
|
921
921
|
id: Generated<string>;
|
|
922
922
|
account_id: string;
|
|
923
923
|
project_id: string | null;
|
|
924
924
|
name: string;
|
|
925
|
-
status: ColumnType<
|
|
926
|
-
kind: ColumnType<
|
|
927
|
-
/** Null for chain
|
|
925
|
+
status: ColumnType<WebhookStatus, WebhookStatus | undefined, WebhookStatus>;
|
|
926
|
+
kind: ColumnType<WebhookKind, WebhookKind | undefined, WebhookKind>;
|
|
927
|
+
/** Null for chain webhooks (CHECK webhooks_kind_shape). */
|
|
928
928
|
subgraph_name: string | null;
|
|
929
|
-
/** Null for chain
|
|
929
|
+
/** Null for chain webhooks (CHECK webhooks_kind_shape). */
|
|
930
930
|
table_name: string | null;
|
|
931
931
|
/** Chain-trigger filter array (the `SubgraphFilter` shape, JSON). Null for
|
|
932
|
-
* subgraph
|
|
933
|
-
* import cycle; the Zod schema in schemas/
|
|
932
|
+
* subgraph webhooks. Typed loosely here to avoid a shared→subgraphs
|
|
933
|
+
* import cycle; the Zod schema in schemas/webhooks.ts owns the shape. */
|
|
934
934
|
triggers: unknown | null;
|
|
935
935
|
filter: Generated<unknown>;
|
|
936
|
-
format: ColumnType<
|
|
937
|
-
runtime:
|
|
936
|
+
format: ColumnType<WebhookFormat, WebhookFormat | undefined, WebhookFormat>;
|
|
937
|
+
runtime: WebhookRuntime | null;
|
|
938
938
|
url: string;
|
|
939
939
|
signing_secret_enc: Buffer;
|
|
940
940
|
auth_config: Generated<unknown>;
|
|
@@ -950,13 +950,13 @@ interface SubscriptionsTable {
|
|
|
950
950
|
updated_at: Generated<Date>;
|
|
951
951
|
}
|
|
952
952
|
type OutboxStatus = "pending" | "delivered" | "dead";
|
|
953
|
-
interface
|
|
953
|
+
interface WebhookOutboxTable {
|
|
954
954
|
id: Generated<string>;
|
|
955
|
-
|
|
956
|
-
kind: ColumnType<
|
|
957
|
-
/** Null for chain-
|
|
955
|
+
webhook_id: string;
|
|
956
|
+
kind: ColumnType<WebhookKind, WebhookKind | undefined, WebhookKind>;
|
|
957
|
+
/** Null for chain-webhook rows. */
|
|
958
958
|
subgraph_name: string | null;
|
|
959
|
-
/** Null for chain-
|
|
959
|
+
/** Null for chain-webhook rows. */
|
|
960
960
|
table_name: string | null;
|
|
961
961
|
block_height: number | bigint;
|
|
962
962
|
tx_id: string | null;
|
|
@@ -974,12 +974,12 @@ interface SubscriptionOutboxTable {
|
|
|
974
974
|
locked_until: Date | null;
|
|
975
975
|
created_at: Generated<Date>;
|
|
976
976
|
}
|
|
977
|
-
interface
|
|
977
|
+
interface WebhookDeliveriesTable {
|
|
978
978
|
id: Generated<string>;
|
|
979
979
|
/** Nullable after migration 0077 — outbox row may be cleaned up while
|
|
980
980
|
* delivery telemetry is retained. */
|
|
981
981
|
outbox_id: string | null;
|
|
982
|
-
|
|
982
|
+
webhook_id: string;
|
|
983
983
|
attempt: number;
|
|
984
984
|
status_code: number | null;
|
|
985
985
|
response_headers: unknown | null;
|
|
@@ -989,7 +989,7 @@ interface SubscriptionDeliveriesTable {
|
|
|
989
989
|
dispatched_at: Generated<Date>;
|
|
990
990
|
}
|
|
991
991
|
/** Single-row (id always TRUE) high-water mark for the chain-trigger evaluator.
|
|
992
|
-
* One loop serves all chain
|
|
992
|
+
* One loop serves all chain webhooks, so the cursor is global. */
|
|
993
993
|
interface TriggerEvaluatorStateTable {
|
|
994
994
|
id: Generated<boolean>;
|
|
995
995
|
last_processed_block: ColumnType<bigint, bigint | number | undefined, bigint | number>;
|
|
@@ -788,9 +788,9 @@ interface Database {
|
|
|
788
788
|
account_spend_caps: AccountSpendCapsTable;
|
|
789
789
|
account_credits: AccountCreditsTable;
|
|
790
790
|
hosted_meter_days: HostedMeterDaysTable;
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
791
|
+
webhooks: WebhooksTable;
|
|
792
|
+
webhook_outbox: WebhookOutboxTable;
|
|
793
|
+
webhook_deliveries: WebhookDeliveriesTable;
|
|
794
794
|
trigger_evaluator_state: TriggerEvaluatorStateTable;
|
|
795
795
|
decoded_events: DecodedEventsTable;
|
|
796
796
|
decoder_checkpoints: DecoderCheckpointsTable;
|
|
@@ -910,31 +910,31 @@ interface ArchiveFetchesTable {
|
|
|
910
910
|
via_allowance: Generated<boolean>;
|
|
911
911
|
charged_at: Generated<Date>;
|
|
912
912
|
}
|
|
913
|
-
type
|
|
914
|
-
/** Polymorphic
|
|
913
|
+
type WebhookStatus = "active" | "paused" | "error";
|
|
914
|
+
/** Polymorphic webhook mode: `subgraph` reacts to processed table rows;
|
|
915
915
|
* `chain` reacts to raw chain events matched directly off the Index/Streams
|
|
916
916
|
* clock (no subgraph). See migration 0088. */
|
|
917
|
-
type
|
|
918
|
-
type
|
|
919
|
-
type
|
|
920
|
-
interface
|
|
917
|
+
type WebhookKind = "subgraph" | "chain";
|
|
918
|
+
type WebhookFormat = "standard-webhooks" | "inngest" | "trigger" | "cloudflare" | "cloudevents" | "raw";
|
|
919
|
+
type WebhookRuntime = "inngest" | "trigger" | "cloudflare" | "node";
|
|
920
|
+
interface WebhooksTable {
|
|
921
921
|
id: Generated<string>;
|
|
922
922
|
account_id: string;
|
|
923
923
|
project_id: string | null;
|
|
924
924
|
name: string;
|
|
925
|
-
status: ColumnType<
|
|
926
|
-
kind: ColumnType<
|
|
927
|
-
/** Null for chain
|
|
925
|
+
status: ColumnType<WebhookStatus, WebhookStatus | undefined, WebhookStatus>;
|
|
926
|
+
kind: ColumnType<WebhookKind, WebhookKind | undefined, WebhookKind>;
|
|
927
|
+
/** Null for chain webhooks (CHECK webhooks_kind_shape). */
|
|
928
928
|
subgraph_name: string | null;
|
|
929
|
-
/** Null for chain
|
|
929
|
+
/** Null for chain webhooks (CHECK webhooks_kind_shape). */
|
|
930
930
|
table_name: string | null;
|
|
931
931
|
/** Chain-trigger filter array (the `SubgraphFilter` shape, JSON). Null for
|
|
932
|
-
* subgraph
|
|
933
|
-
* import cycle; the Zod schema in schemas/
|
|
932
|
+
* subgraph webhooks. Typed loosely here to avoid a shared→subgraphs
|
|
933
|
+
* import cycle; the Zod schema in schemas/webhooks.ts owns the shape. */
|
|
934
934
|
triggers: unknown | null;
|
|
935
935
|
filter: Generated<unknown>;
|
|
936
|
-
format: ColumnType<
|
|
937
|
-
runtime:
|
|
936
|
+
format: ColumnType<WebhookFormat, WebhookFormat | undefined, WebhookFormat>;
|
|
937
|
+
runtime: WebhookRuntime | null;
|
|
938
938
|
url: string;
|
|
939
939
|
signing_secret_enc: Buffer;
|
|
940
940
|
auth_config: Generated<unknown>;
|
|
@@ -950,13 +950,13 @@ interface SubscriptionsTable {
|
|
|
950
950
|
updated_at: Generated<Date>;
|
|
951
951
|
}
|
|
952
952
|
type OutboxStatus = "pending" | "delivered" | "dead";
|
|
953
|
-
interface
|
|
953
|
+
interface WebhookOutboxTable {
|
|
954
954
|
id: Generated<string>;
|
|
955
|
-
|
|
956
|
-
kind: ColumnType<
|
|
957
|
-
/** Null for chain-
|
|
955
|
+
webhook_id: string;
|
|
956
|
+
kind: ColumnType<WebhookKind, WebhookKind | undefined, WebhookKind>;
|
|
957
|
+
/** Null for chain-webhook rows. */
|
|
958
958
|
subgraph_name: string | null;
|
|
959
|
-
/** Null for chain-
|
|
959
|
+
/** Null for chain-webhook rows. */
|
|
960
960
|
table_name: string | null;
|
|
961
961
|
block_height: number | bigint;
|
|
962
962
|
tx_id: string | null;
|
|
@@ -974,12 +974,12 @@ interface SubscriptionOutboxTable {
|
|
|
974
974
|
locked_until: Date | null;
|
|
975
975
|
created_at: Generated<Date>;
|
|
976
976
|
}
|
|
977
|
-
interface
|
|
977
|
+
interface WebhookDeliveriesTable {
|
|
978
978
|
id: Generated<string>;
|
|
979
979
|
/** Nullable after migration 0077 — outbox row may be cleaned up while
|
|
980
980
|
* delivery telemetry is retained. */
|
|
981
981
|
outbox_id: string | null;
|
|
982
|
-
|
|
982
|
+
webhook_id: string;
|
|
983
983
|
attempt: number;
|
|
984
984
|
status_code: number | null;
|
|
985
985
|
response_headers: unknown | null;
|
|
@@ -989,7 +989,7 @@ interface SubscriptionDeliveriesTable {
|
|
|
989
989
|
dispatched_at: Generated<Date>;
|
|
990
990
|
}
|
|
991
991
|
/** Single-row (id always TRUE) high-water mark for the chain-trigger evaluator.
|
|
992
|
-
* One loop serves all chain
|
|
992
|
+
* One loop serves all chain webhooks, so the cursor is global. */
|
|
993
993
|
interface TriggerEvaluatorStateTable {
|
|
994
994
|
id: Generated<boolean>;
|
|
995
995
|
last_processed_block: ColumnType<bigint, bigint | number | undefined, bigint | number>;
|
|
@@ -1106,9 +1106,9 @@ interface Database {
|
|
|
1106
1106
|
account_spend_caps: AccountSpendCapsTable;
|
|
1107
1107
|
account_credits: AccountCreditsTable;
|
|
1108
1108
|
hosted_meter_days: HostedMeterDaysTable;
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1109
|
+
webhooks: WebhooksTable;
|
|
1110
|
+
webhook_outbox: WebhookOutboxTable;
|
|
1111
|
+
webhook_deliveries: WebhookDeliveriesTable;
|
|
1112
1112
|
trigger_evaluator_state: TriggerEvaluatorStateTable;
|
|
1113
1113
|
decoded_events: DecodedEventsTable;
|
|
1114
1114
|
decoder_checkpoints: DecoderCheckpointsTable;
|
|
@@ -1228,31 +1228,31 @@ interface ArchiveFetchesTable {
|
|
|
1228
1228
|
via_allowance: Generated<boolean>;
|
|
1229
1229
|
charged_at: Generated<Date>;
|
|
1230
1230
|
}
|
|
1231
|
-
type
|
|
1232
|
-
/** Polymorphic
|
|
1231
|
+
type WebhookStatus = "active" | "paused" | "error";
|
|
1232
|
+
/** Polymorphic webhook mode: `subgraph` reacts to processed table rows;
|
|
1233
1233
|
* `chain` reacts to raw chain events matched directly off the Index/Streams
|
|
1234
1234
|
* clock (no subgraph). See migration 0088. */
|
|
1235
|
-
type
|
|
1236
|
-
type
|
|
1237
|
-
type
|
|
1238
|
-
interface
|
|
1235
|
+
type WebhookKind = "subgraph" | "chain";
|
|
1236
|
+
type WebhookFormat = "standard-webhooks" | "inngest" | "trigger" | "cloudflare" | "cloudevents" | "raw";
|
|
1237
|
+
type WebhookRuntime = "inngest" | "trigger" | "cloudflare" | "node";
|
|
1238
|
+
interface WebhooksTable {
|
|
1239
1239
|
id: Generated<string>;
|
|
1240
1240
|
account_id: string;
|
|
1241
1241
|
project_id: string | null;
|
|
1242
1242
|
name: string;
|
|
1243
|
-
status: ColumnType<
|
|
1244
|
-
kind: ColumnType<
|
|
1245
|
-
/** Null for chain
|
|
1243
|
+
status: ColumnType<WebhookStatus, WebhookStatus | undefined, WebhookStatus>;
|
|
1244
|
+
kind: ColumnType<WebhookKind, WebhookKind | undefined, WebhookKind>;
|
|
1245
|
+
/** Null for chain webhooks (CHECK webhooks_kind_shape). */
|
|
1246
1246
|
subgraph_name: string | null;
|
|
1247
|
-
/** Null for chain
|
|
1247
|
+
/** Null for chain webhooks (CHECK webhooks_kind_shape). */
|
|
1248
1248
|
table_name: string | null;
|
|
1249
1249
|
/** Chain-trigger filter array (the `SubgraphFilter` shape, JSON). Null for
|
|
1250
|
-
* subgraph
|
|
1251
|
-
* import cycle; the Zod schema in schemas/
|
|
1250
|
+
* subgraph webhooks. Typed loosely here to avoid a shared→subgraphs
|
|
1251
|
+
* import cycle; the Zod schema in schemas/webhooks.ts owns the shape. */
|
|
1252
1252
|
triggers: unknown | null;
|
|
1253
1253
|
filter: Generated<unknown>;
|
|
1254
|
-
format: ColumnType<
|
|
1255
|
-
runtime:
|
|
1254
|
+
format: ColumnType<WebhookFormat, WebhookFormat | undefined, WebhookFormat>;
|
|
1255
|
+
runtime: WebhookRuntime | null;
|
|
1256
1256
|
url: string;
|
|
1257
1257
|
signing_secret_enc: Buffer;
|
|
1258
1258
|
auth_config: Generated<unknown>;
|
|
@@ -1268,13 +1268,13 @@ interface SubscriptionsTable {
|
|
|
1268
1268
|
updated_at: Generated<Date>;
|
|
1269
1269
|
}
|
|
1270
1270
|
type OutboxStatus = "pending" | "delivered" | "dead";
|
|
1271
|
-
interface
|
|
1271
|
+
interface WebhookOutboxTable {
|
|
1272
1272
|
id: Generated<string>;
|
|
1273
|
-
|
|
1274
|
-
kind: ColumnType<
|
|
1275
|
-
/** Null for chain-
|
|
1273
|
+
webhook_id: string;
|
|
1274
|
+
kind: ColumnType<WebhookKind, WebhookKind | undefined, WebhookKind>;
|
|
1275
|
+
/** Null for chain-webhook rows. */
|
|
1276
1276
|
subgraph_name: string | null;
|
|
1277
|
-
/** Null for chain-
|
|
1277
|
+
/** Null for chain-webhook rows. */
|
|
1278
1278
|
table_name: string | null;
|
|
1279
1279
|
block_height: number | bigint;
|
|
1280
1280
|
tx_id: string | null;
|
|
@@ -1292,12 +1292,12 @@ interface SubscriptionOutboxTable {
|
|
|
1292
1292
|
locked_until: Date | null;
|
|
1293
1293
|
created_at: Generated<Date>;
|
|
1294
1294
|
}
|
|
1295
|
-
interface
|
|
1295
|
+
interface WebhookDeliveriesTable {
|
|
1296
1296
|
id: Generated<string>;
|
|
1297
1297
|
/** Nullable after migration 0077 — outbox row may be cleaned up while
|
|
1298
1298
|
* delivery telemetry is retained. */
|
|
1299
1299
|
outbox_id: string | null;
|
|
1300
|
-
|
|
1300
|
+
webhook_id: string;
|
|
1301
1301
|
attempt: number;
|
|
1302
1302
|
status_code: number | null;
|
|
1303
1303
|
response_headers: unknown | null;
|
|
@@ -1307,7 +1307,7 @@ interface SubscriptionDeliveriesTable {
|
|
|
1307
1307
|
dispatched_at: Generated<Date>;
|
|
1308
1308
|
}
|
|
1309
1309
|
/** Single-row (id always TRUE) high-water mark for the chain-trigger evaluator.
|
|
1310
|
-
* One loop serves all chain
|
|
1310
|
+
* One loop serves all chain webhooks, so the cursor is global. */
|
|
1311
1311
|
interface TriggerEvaluatorStateTable {
|
|
1312
1312
|
id: Generated<boolean>;
|
|
1313
1313
|
last_processed_block: ColumnType<bigint, bigint | number | undefined, bigint | number>;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"/**\n * Atomic subgraph adapter — commit effects, rows, journal, cursor,\n * receipt, and failure in one transaction.\n */\n\nimport type { DecoderAdapterFailure } from \"./adapter.ts\";\nimport type { DecoderClockReceipt } from \"./decoder-clock.ts\";\nimport type { EffectMutation } from \"./effect-manifest.ts\";\n\nexport const SUBGRAPH_COMMIT_STEPS = [\n\t\"effects\",\n\t\"rows\",\n\t\"journal\",\n\t\"cursor\",\n\t\"receipt\",\n\t\"failure\",\n] as const;\nexport type SubgraphCommitStep = (typeof SUBGRAPH_COMMIT_STEPS)[number];\n\nexport class SubgraphAdapterCrash extends Error {\n\treadonly name = \"SubgraphAdapterCrash\";\n\tconstructor(readonly step: SubgraphCommitStep) {\n\t\tsuper(`subgraph adapter crash after ${step}`);\n\t}\n}\n\nexport type SubgraphCommitInput = {\n\tstage_id: string;\n\teffects: readonly EffectMutation[];\n\treceipts: readonly (DecoderClockReceipt & { effect_digest: string })[];\n\tfailure?: DecoderAdapterFailure | null;\n\twriteEffects: () => Promise<void>;\n\twriteRows: () => Promise<void>;\n\twriteJournal: () => Promise<void>;\n\twriteCursor: () => Promise<void>;\n\twriteReceipts: () => Promise<void>;\n\twriteFailure: () => Promise<void>;\n\tcrashAfter?: SubgraphCommitStep;\n};\n\nexport async function runSubgraphCommitSteps(\n\tsteps: Record<SubgraphCommitStep, () => Promise<void>>,\n\tcrashAfter?: SubgraphCommitStep,\n): Promise<void> {\n\tfor (const step of SUBGRAPH_COMMIT_STEPS) {\n\t\tawait steps[step]();\n\t\tif (crashAfter === step) throw new SubgraphAdapterCrash(step);\n\t}\n}\n\nexport async function commitSubgraphAdapter(\n\tinput: SubgraphCommitInput,\n\twrap: (fn: () => Promise<void>) => Promise<void> = (fn) => fn(),\n): Promise<void> {\n\tawait wrap(() =>\n\t\trunSubgraphCommitSteps(\n\t\t\t{\n\t\t\t\teffects: input.writeEffects,\n\t\t\t\trows: input.writeRows,\n\t\t\t\tjournal: input.writeJournal,\n\t\t\t\tcursor: input.writeCursor,\n\t\t\t\treceipt: input.writeReceipts,\n\t\t\t\tfailure: input.writeFailure,\n\t\t\t},\n\t\t\tinput.crashAfter,\n\t\t),\n\t);\n}\n",
|
|
14
14
|
"/**\n * Sparse proofs — acknowledge skipped canonical ranges without handler\n * execution. Sparse and sequential paths must agree on chain / effect /\n * final-row digests.\n */\n\nimport { createHash } from \"node:crypto\";\nimport type { CoverageRange } from \"./constraints.ts\";\nimport { type EffectMutation, hashEffectManifest } from \"./effect-manifest.ts\";\nimport { rangeDigestOf } from \"./sealer.ts\";\n\nexport type SparseSkip = CoverageRange & { reason: \"no-match\" | \"filtered\" };\n\nexport type HeightDigest = {\n\theight: number;\n\thash: string;\n\teffect_digest: string;\n\trow_digest: string;\n};\n\nexport function skipReceipt(skip: SparseSkip, chainHash: string): HeightDigest {\n\tconst empty = hashEffectManifest([]);\n\treturn {\n\t\theight: skip.from_height,\n\t\thash: chainHash,\n\t\teffect_digest: empty,\n\t\trow_digest: empty,\n\t};\n}\n\nexport function sequentialDigests(heights: readonly HeightDigest[]): {\n\tchain: string;\n\teffect: string;\n\trow: string;\n} {\n\tconst ordered = [...heights].sort((a, b) => a.height - b.height);\n\treturn {\n\t\tchain: rangeDigestOf(ordered.map((h) => `${h.height}:${h.hash}`)),\n\t\teffect: rangeDigestOf(ordered.map((h) => h.effect_digest)),\n\t\trow: rangeDigestOf(ordered.map((h) => h.row_digest)),\n\t};\n}\n\nexport function sparseDigests(\n\texecuted: readonly HeightDigest[],\n\tskips: readonly { skip: SparseSkip; hash: string }[],\n): { chain: string; effect: string; row: string } {\n\tconst skipped = skips.flatMap(({ skip, hash }) => {\n\t\tconst out: HeightDigest[] = [];\n\t\tfor (let h = skip.from_height; h <= skip.to_height; h++) {\n\t\t\tout.push(skipReceipt({ ...skip, from_height: h, to_height: h }, hash));\n\t\t}\n\t\treturn out;\n\t});\n\treturn sequentialDigests([...executed, ...skipped]);\n}\n\nexport function proofsAgree(\n\tsequential: { chain: string; effect: string; row: string },\n\tsparse: { chain: string; effect: string; row: string },\n): boolean {\n\treturn (\n\t\tsequential.chain === sparse.chain &&\n\t\tsequential.effect === sparse.effect &&\n\t\tsequential.row === sparse.row\n\t);\n}\n\nexport function emptyRowDigest(): string {\n\treturn createHash(\"sha256\").update(\"empty-row\\n\").digest(\"hex\");\n}\n\nexport type { EffectMutation };\n",
|
|
15
15
|
"/**\n * Stateful deep verify — replay into a scratch schema and compare the\n * canonical final-row digest. A seeded historical mutation must be found.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { type EffectMutation, hashEffectManifest } from \"./effect-manifest.ts\";\n\nexport type RowSnapshot = {\n\ttable: string;\n\tkey: string;\n\tvalue: string;\n};\n\nexport function finalRowDigest(rows: readonly RowSnapshot[]): string {\n\tconst ordered = [...rows].sort(\n\t\t(a, b) => a.table.localeCompare(b.table) || a.key.localeCompare(b.key),\n\t);\n\tconst hash = createHash(\"sha256\");\n\tfor (const row of ordered) {\n\t\thash.update(`${row.table}\\t${row.key}\\t${row.value}\\n`);\n\t}\n\treturn hash.digest(\"hex\");\n}\n\nexport function applyMutations(\n\trows: readonly RowSnapshot[],\n\tops: readonly EffectMutation[],\n): RowSnapshot[] {\n\tconst map = new Map(rows.map((r) => [`${r.table}:${r.key}`, { ...r }]));\n\tfor (const op of ops) {\n\t\tconst id = String(op.key.id ?? JSON.stringify(op.key));\n\t\tconst k = `${op.table}:${id}`;\n\t\tif (op.op === \"delete\") {\n\t\t\tmap.delete(k);\n\t\t\tcontinue;\n\t\t}\n\t\tmap.set(k, {\n\t\t\ttable: op.table,\n\t\t\tkey: id,\n\t\t\tvalue: JSON.stringify(op.value ?? {}),\n\t\t});\n\t}\n\treturn [...map.values()];\n}\n\nexport type DeepVerifyResult = {\n\tok: boolean;\n\tlive_digest: string;\n\tscratch_digest: string;\n\tfound_mutation: boolean;\n};\n\nexport function deepVerify(opts: {\n\tlive: readonly RowSnapshot[];\n\treplay: readonly EffectMutation[];\n\tseed?: EffectMutation;\n}): DeepVerifyResult {\n\tconst scratch = applyMutations([], opts.replay);\n\tconst liveDigest = finalRowDigest(opts.live);\n\tconst scratchDigest = finalRowDigest(scratch);\n\tlet found = false;\n\tif (opts.seed) {\n\t\tconst tainted = applyMutations(opts.live, [opts.seed]);\n\t\tfound = finalRowDigest(tainted) !== liveDigest;\n\t}\n\treturn {\n\t\tok: liveDigest === scratchDigest,\n\t\tlive_digest: liveDigest,\n\t\tscratch_digest: scratchDigest,\n\t\tfound_mutation: found,\n\t};\n}\n\nexport function emptyManifestHash(): string {\n\treturn hashEffectManifest([]);\n}\n",
|
|
16
|
-
"/**\n * Queue coverage —
|
|
16
|
+
"/**\n * Queue coverage — webhook decision, outbox, delivery, rollback,\n * DLQ, and replay with cursor fences.\n */\n\nimport type { QueueCounters } from \"./evaluate.ts\";\n\nexport const QUEUE_STAGES = [\n\t\"decision\",\n\t\"outbox\",\n\t\"delivery\",\n\t\"rollback\",\n\t\"dlq\",\n\t\"replay\",\n] as const;\nexport type QueueStage = (typeof QUEUE_STAGES)[number];\n\nexport type QueueEvent =\n\t| { type: \"accept\" }\n\t| { type: \"decide\"; matched: boolean }\n\t| { type: \"enqueue\" }\n\t| { type: \"deliver\" }\n\t| { type: \"fail\" }\n\t| { type: \"dead\" }\n\t| { type: \"requeue\" }\n\t| { type: \"rollback\" }\n\t| { type: \"replay\"; cursor: string };\n\nexport type QueueState = QueueCounters & {\n\tstage: QueueStage;\n\tlast_cursor: string | null;\n\tdedupe: Set<string>;\n};\n\nexport function emptyQueueState(): QueueState {\n\treturn {\n\t\tstage: \"decision\",\n\t\taccepted: 0,\n\t\tdecided: 0,\n\t\tenqueued: 0,\n\t\tdelivered: 0,\n\t\tdead: 0,\n\t\tfence_cursor: null,\n\t\tlast_cursor: null,\n\t\tdedupe: new Set(),\n\t};\n}\n\nexport type QueueApply =\n\t| { ok: true; state: QueueState }\n\t| { ok: false; reason: string; state: QueueState };\n\nexport function applyQueueEvent(\n\tstate: QueueState,\n\tevent: QueueEvent,\n\tdedupeKey?: string,\n): QueueApply {\n\tif (dedupeKey && state.dedupe.has(dedupeKey)) {\n\t\treturn { ok: true, state };\n\t}\n\tconst next: QueueState = {\n\t\t...state,\n\t\tdedupe: new Set(state.dedupe),\n\t};\n\tif (dedupeKey) next.dedupe.add(dedupeKey);\n\n\tswitch (event.type) {\n\t\tcase \"accept\":\n\t\t\tnext.accepted += 1;\n\t\t\tnext.stage = \"decision\";\n\t\t\treturn { ok: true, state: next };\n\t\tcase \"decide\":\n\t\t\tif (next.decided >= next.accepted) {\n\t\t\t\treturn { ok: false, reason: \"decide without accept\", state };\n\t\t\t}\n\t\t\tnext.decided += 1;\n\t\t\tnext.stage = event.matched ? \"outbox\" : \"decision\";\n\t\t\treturn { ok: true, state: next };\n\t\tcase \"enqueue\":\n\t\t\tif (next.enqueued >= next.decided) {\n\t\t\t\treturn { ok: false, reason: \"enqueue without decide\", state };\n\t\t\t}\n\t\t\tnext.enqueued += 1;\n\t\t\tnext.stage = \"outbox\";\n\t\t\treturn { ok: true, state: next };\n\t\tcase \"deliver\":\n\t\t\tif (next.delivered + next.dead >= next.enqueued) {\n\t\t\t\treturn { ok: false, reason: \"deliver without enqueue\", state };\n\t\t\t}\n\t\t\tnext.delivered += 1;\n\t\t\tnext.stage = \"delivery\";\n\t\t\treturn { ok: true, state: next };\n\t\tcase \"fail\":\n\t\t\tnext.stage = \"delivery\";\n\t\t\treturn { ok: true, state: next };\n\t\tcase \"dead\":\n\t\t\tif (next.delivered + next.dead >= next.enqueued) {\n\t\t\t\treturn { ok: false, reason: \"dead without enqueue\", state };\n\t\t\t}\n\t\t\tnext.dead += 1;\n\t\t\tnext.stage = \"dlq\";\n\t\t\treturn { ok: true, state: next };\n\t\tcase \"requeue\":\n\t\t\tif (next.dead < 1)\n\t\t\t\treturn { ok: false, reason: \"requeue empty dlq\", state };\n\t\t\tnext.dead -= 1;\n\t\t\tnext.enqueued += 1;\n\t\t\tnext.stage = \"outbox\";\n\t\t\treturn { ok: true, state: next };\n\t\tcase \"rollback\":\n\t\t\tnext.stage = \"rollback\";\n\t\t\treturn { ok: true, state: next };\n\t\tcase \"replay\":\n\t\t\tnext.stage = \"replay\";\n\t\t\tnext.last_cursor = event.cursor;\n\t\t\tnext.fence_cursor = event.cursor;\n\t\t\treturn { ok: true, state: next };\n\t}\n}\n\nexport function queueCaughtUp(state: QueueState): boolean {\n\treturn (\n\t\tstate.accepted === state.decided &&\n\t\tstate.delivered + state.dead === state.enqueued\n\t);\n}\n",
|
|
17
17
|
"/**\n * Repair SAFETY GATE — decides whether a proposed repair is allowed to run.\n * Only registered safe modes pass; unsafe ranges and mode/defect mismatches are\n * refused.\n *\n * This file plans and refuses. It does not repair. The executor that actually\n * writes is `secondlayer repair --apply` (packages/cli/src/commands/repair.ts),\n * which rewrites blocks from a signed archive inside a transaction.\n *\n * There used to be an `applyRepair` here that returned `{applied: true}`\n * without touching anything. It was removed in 2026-08 rather than\n * implemented: a second executor would have been a fake one, and drills that\n * \"recovered\" through it would have proven nothing.\n */\n\nimport type { RepairMode } from \"./constraints.ts\";\nimport type { CoverageRange } from \"./constraints.ts\";\n\nexport const SAFE_REPAIR_MODES: readonly RepairMode[] = [\n\t\"range_safe\",\n\t\"full_reindex\",\n\t\"archive_replay\",\n];\n\nexport type RepairDefect =\n\t| \"gap\"\n\t| \"digest_mismatch\"\n\t| \"omission\"\n\t| \"version\"\n\t| \"reorg\"\n\t| \"source_gap\";\n\nexport type RepairPlan = {\n\tstage_id: string;\n\trange: CoverageRange;\n\tmode: RepairMode;\n\tdefect: RepairDefect;\n\tsafe: boolean;\n\treason: string;\n};\n\nexport function planRepair(input: {\n\tstage_id: string;\n\trange: CoverageRange;\n\tmode: RepairMode;\n\tdefect: RepairDefect;\n\trange_safe_proven?: boolean;\n}): RepairPlan {\n\tif (input.range.from_height > input.range.to_height) {\n\t\treturn {\n\t\t\t...input,\n\t\t\tsafe: false,\n\t\t\treason: \"inverted range\",\n\t\t};\n\t}\n\tif (!(SAFE_REPAIR_MODES as readonly string[]).includes(input.mode)) {\n\t\treturn {\n\t\t\t...input,\n\t\t\tsafe: false,\n\t\t\treason: `mode ${input.mode} is not registered`,\n\t\t};\n\t}\n\tif (input.mode === \"range_safe\" && !input.range_safe_proven) {\n\t\treturn {\n\t\t\t...input,\n\t\t\tsafe: false,\n\t\t\treason: \"range_safe requires a proven range\",\n\t\t};\n\t}\n\tif (input.defect === \"source_gap\" && input.mode !== \"archive_replay\") {\n\t\treturn {\n\t\t\t...input,\n\t\t\tsafe: false,\n\t\t\treason: \"raw/source gaps require archive_replay\",\n\t\t};\n\t}\n\treturn {\n\t\t...input,\n\t\tsafe: true,\n\t\treason: `dry-run ${input.mode} for ${input.defect}`,\n\t};\n}\n",
|
|
18
18
|
"/**\n * Unified verify targets — library used by CLI (and REST later).\n * Exit codes match `sl verify`: 0 clean, 1 diverged, 2 unanchored.\n */\n\nexport const VERIFY_EXIT = {\n\tCLEAN: 0,\n\tDIVERGED: 1,\n\tUNANCHORED: 2,\n} as const;\nexport type VerifyExit = (typeof VERIFY_EXIT)[keyof typeof VERIFY_EXIT];\n\nexport type VerifyTarget =\n\t| { kind: \"all\" }\n\t| { kind: \"raw\" }\n\t| { kind: \"decode\"; name: string }\n\t| { kind: \"subgraph\"; name: string };\n\nexport type VerifyMode = \"quick\" | \"deep\" | \"anchor\";\n\nexport function parseVerifyTarget(value: string | undefined): VerifyTarget {\n\tconst raw = (value ?? \"raw\").trim();\n\tif (raw === \"all\") return { kind: \"all\" };\n\tif (raw === \"raw\" || raw === \"\") return { kind: \"raw\" };\n\tif (raw.startsWith(\"decode:\")) {\n\t\tconst name = raw.slice(\"decode:\".length).trim();\n\t\tif (!name) throw new Error(\"decode: target needs a decoder name\");\n\t\treturn { kind: \"decode\", name };\n\t}\n\tif (raw.startsWith(\"subgraph:\")) {\n\t\tconst name = raw.slice(\"subgraph:\".length).trim();\n\t\tif (!name) throw new Error(\"subgraph: target needs a subgraph name\");\n\t\treturn { kind: \"subgraph\", name };\n\t}\n\tthrow new Error(\n\t\t`verify target must be all | raw | decode:<name> | subgraph:<name> (got ${value})`,\n\t);\n}\n\nconst RAW_DATASETS = new Set([\"blocks\", \"transactions\", \"events\"]);\n\n/** Whether an archive/coverage dataset belongs to this verify target. */\nexport function datasetMatchesTarget(\n\tdataset: string,\n\ttarget: VerifyTarget,\n): boolean {\n\tif (target.kind === \"all\") return true;\n\tconst name = dataset.toLowerCase();\n\tif (target.kind === \"raw\") return RAW_DATASETS.has(name);\n\tif (target.kind === \"decode\") {\n\t\treturn (\n\t\t\tname === target.name.toLowerCase() ||\n\t\t\tname === `decode:${target.name.toLowerCase()}` ||\n\t\t\tname.endsWith(`_${target.name.toLowerCase()}`)\n\t\t);\n\t}\n\treturn (\n\t\tname === target.name.toLowerCase() ||\n\t\tname === `subgraph:${target.name.toLowerCase()}` ||\n\t\tname.startsWith(`${target.name.toLowerCase()}_`)\n\t);\n}\n\nexport type VerifyReport = {\n\ttarget: VerifyTarget;\n\tmode: VerifyMode;\n\texit: VerifyExit;\n\tstatus: \"clean\" | \"diverged\" | \"unanchored\";\n\tdetail: string;\n};\n\nexport function reportVerify(opts: {\n\ttarget: VerifyTarget;\n\tmode?: VerifyMode;\n\tanchored?: boolean;\n\tdiverged?: boolean;\n\tdetail?: string;\n}): VerifyReport {\n\tconst mode = opts.mode ?? \"quick\";\n\tif (opts.anchored === false) {\n\t\treturn {\n\t\t\ttarget: opts.target,\n\t\t\tmode,\n\t\t\texit: VERIFY_EXIT.UNANCHORED,\n\t\t\tstatus: \"unanchored\",\n\t\t\tdetail: opts.detail ?? \"reference unavailable or unverifiable\",\n\t\t};\n\t}\n\tif (opts.diverged) {\n\t\treturn {\n\t\t\ttarget: opts.target,\n\t\t\tmode,\n\t\t\texit: VERIFY_EXIT.DIVERGED,\n\t\t\tstatus: \"diverged\",\n\t\t\tdetail: opts.detail ?? \"coverage or archive diverged\",\n\t\t};\n\t}\n\tconst label =\n\t\topts.target.kind === \"decode\"\n\t\t\t? `decode:${opts.target.name}`\n\t\t\t: opts.target.kind === \"subgraph\"\n\t\t\t\t? `subgraph:${opts.target.name}`\n\t\t\t\t: opts.target.kind;\n\treturn {\n\t\ttarget: opts.target,\n\t\tmode,\n\t\texit: VERIFY_EXIT.CLEAN,\n\t\tstatus: \"clean\",\n\t\tdetail: opts.detail ?? `${label} ${mode} is clean`,\n\t};\n}\n"
|
|
19
19
|
],
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Only the `standard-webhooks` format carries an HMAC; `raw`/`cloudevents`/etc.
|
|
6
6
|
* carried no Secondlayer proof, so a receiver had no way to know a payload came
|
|
7
7
|
* from us. This signs each delivery with a single platform ed25519 key so any
|
|
8
|
-
* receiver verifies with the published public key — no per-
|
|
8
|
+
* receiver verifies with the published public key — no per-webhook secret,
|
|
9
9
|
* and the body shape stays format-specific.
|
|
10
10
|
*
|
|
11
11
|
* Header names are lowercase to match the format builders' header maps (HTTP
|
|
@@ -28,7 +28,7 @@ declare function getSecondlayerWebhookSigner(): SecondlayerWebhookSigner | null;
|
|
|
28
28
|
declare function resetSecondlayerWebhookSignerForTest(): void;
|
|
29
29
|
/**
|
|
30
30
|
* Boot-time guard for any service whose job is to DELIVER signed webhooks (the
|
|
31
|
-
*
|
|
31
|
+
* webhook-processor). We market every delivery as carrying a verifiable
|
|
32
32
|
* ed25519 signature; if no signing key is configured the signer is a silent
|
|
33
33
|
* no-op and deliveries ship UNSIGNED. That's worse than a missing feature — a
|
|
34
34
|
* partner's verify step fails on data we told them was signed.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"sourcesContent": [
|
|
5
5
|
"import { z } from \"zod\";\n\n// Parse comma-separated networks\nconst networksSchema = z.string().transform((val) => {\n\tconst networks = val\n\t\t.split(\",\")\n\t\t.map((n) => n.trim())\n\t\t.filter(Boolean);\n\tconst valid = [\"mainnet\", \"testnet\"];\n\tfor (const n of networks) {\n\t\tif (!valid.includes(n)) {\n\t\t\tthrow new Error(\n\t\t\t\t`Invalid network: ${n}. Must be one of: ${valid.join(\", \")}`,\n\t\t\t);\n\t\t}\n\t}\n\treturn networks as (\"mainnet\" | \"testnet\")[];\n});\n\ninterface EnvSchemaOutput {\n\tDATABASE_URL?: string;\n\t/**\n\t * Shared indexer DB (blocks/txs/events). Falls back to DATABASE_URL.\n\t * Set this alongside TARGET_DATABASE_URL to enable dual-DB mode.\n\t */\n\tSOURCE_DATABASE_URL?: string;\n\t/**\n\t * Tenant DB (subgraph schemas + subgraphs table). Falls back to DATABASE_URL.\n\t * Set this alongside SOURCE_DATABASE_URL to enable dual-DB mode.\n\t */\n\tTARGET_DATABASE_URL?: string;\n\tNETWORK?: \"mainnet\" | \"testnet\";\n\tNETWORKS?: (\"mainnet\" | \"testnet\")[];\n\tLOG_LEVEL: \"debug\" | \"info\" | \"warn\" | \"error\";\n\tNODE_ENV: \"development\" | \"production\" | \"test\";\n}\n\n// Cast needed: z.preprocess / z.default create different _input vs _output types\n// that z.ZodType<T> can't represent without explicit input type param\nconst envSchema: z.ZodType<EnvSchemaOutput> = z.object({\n\tDATABASE_URL: z.preprocess(\n\t\t(val) => (typeof val === \"string\" && val.length === 0 ? undefined : val),\n\t\tz.string().url().optional(),\n\t),\n\tSOURCE_DATABASE_URL: z.preprocess(\n\t\t(val) => (typeof val === \"string\" && val.length === 0 ? undefined : val),\n\t\tz.string().url().optional(),\n\t),\n\tTARGET_DATABASE_URL: z.preprocess(\n\t\t(val) => (typeof val === \"string\" && val.length === 0 ? undefined : val),\n\t\tz.string().url().optional(),\n\t),\n\tNETWORK: z.enum([\"mainnet\", \"testnet\"]).optional(),\n\tNETWORKS: networksSchema.optional(),\n\tLOG_LEVEL: z.enum([\"debug\", \"info\", \"warn\", \"error\"]).default(\"info\"),\n\tNODE_ENV: z\n\t\t.enum([\"development\", \"production\", \"test\"])\n\t\t.default(\"development\"),\n}) as unknown as z.ZodType<EnvSchemaOutput>;\n\nexport type Env = EnvSchemaOutput & {\n\tenabledNetworks: (\"mainnet\" | \"testnet\")[];\n};\n\nlet cachedEnv: Env | null = null;\n\nexport function getEnv(): Env {\n\tif (cachedEnv) {\n\t\treturn cachedEnv;\n\t}\n\n\tconst result = envSchema.safeParse(process.env);\n\n\tif (!result.success) {\n\t\tconsole.error(\"❌ Invalid environment configuration:\");\n\t\tconsole.error(z.treeifyError(result.error));\n\t\tthrow new Error(\"Invalid environment configuration\");\n\t}\n\n\t// Compute enabled networks from NETWORKS or NETWORK\n\tlet enabledNetworks: (\"mainnet\" | \"testnet\")[];\n\tif (result.data.NETWORKS && result.data.NETWORKS.length > 0) {\n\t\tenabledNetworks = result.data.NETWORKS;\n\t} else if (result.data.NETWORK) {\n\t\tenabledNetworks = [result.data.NETWORK];\n\t} else {\n\t\tenabledNetworks = [\"mainnet\"]; // Default\n\t}\n\n\tcachedEnv = { ...result.data, enabledNetworks };\n\treturn cachedEnv;\n}\n\n/**\n * True when `NODE_ENV=production`, read at RUNTIME — the single source of truth\n * for prod-vs-dev branching in this package.\n *\n * Why a helper instead of inlining `process.env.NODE_ENV === \"production\"`:\n * bunup/esbuild constant-folds the dot-access `process.env.NODE_ENV` to its\n * BUILD-time value. `@secondlayer/shared` is consumed by other services as its\n * built `dist`, so an inlined check freezes to a literal in the shipped bundle\n * (e.g. `const isProd = false`) and silently ignores the container's real\n * NODE_ENV. The bracket access below is NOT folded, so it stays a runtime read.\n * Route every prod check through here so the footgun lives in exactly one place.\n */\nexport function isProductionEnv(): boolean {\n\t// biome-ignore lint/complexity/useLiteralKeys: bracket access is deliberate — dot-access gets constant-folded by the bundler, freezing this to a build-time literal in the shipped dist.\n\treturn process.env[\"NODE_ENV\"] === \"production\";\n}\n\ntype EnvMap = NodeJS.ProcessEnv;\n\n/**\n * PoX-4 stacking decoder is ON by default — `/v1/index/stacking` is part of the\n * public surface, so the decoder that fills `pox4_calls` runs unless explicitly\n * opted out with `POX4_DECODER_ENABLED=false` (mirrors the sBTC decoder policy).\n */\nexport function isPox4DecoderEnabled(env: EnvMap = process.env): boolean {\n\treturn env.POX4_DECODER_ENABLED !== \"false\";\n}\n\n/**\n * PoX-5 print-event decoder is ON by default (mirrors pox-4/sBTC policy).\n * No activation gating needed: pox-5 events cannot exist before the Epoch 4.0\n * hard fork (Bitcoin block 960,230), so pre-activation the decoder idles at\n * tip. Opt out with `POX5_DECODER_ENABLED=false`.\n */\nexport function isPox5DecoderEnabled(env: EnvMap = process.env): boolean {\n\treturn env.POX5_DECODER_ENABLED !== \"false\";\n}\n\n/** Default ON. Opt out with `SBTC_DECODER_ENABLED=false`. */\nexport function isSbtcDecoderEnabled(env: EnvMap = process.env): boolean {\n\treturn env.SBTC_DECODER_ENABLED !== \"false\";\n}\n\n/** Default OFF. Opt in with `BNS_DECODER_ENABLED=true`. */\nexport function isBnsDecoderEnabled(env: EnvMap = process.env): boolean {\n\treturn env.BNS_DECODER_ENABLED === \"true\";\n}\n\n// Export for testing\nexport { envSchema };\n",
|
|
6
6
|
"import {\n\ttype KeyObject,\n\tcreateHash,\n\tcreatePrivateKey,\n\tcreatePublicKey,\n\tgenerateKeyPairSync,\n\tsign as nodeSign,\n\tverify as nodeVerify,\n} from \"node:crypto\";\n\n/**\n * Asymmetric ed25519 signing for Streams response proofs.\n *\n * Asymmetric (not HMAC) so the proof is real: only the server holds the private\n * key, and any consumer verifies with the published public key — no shared\n * secret to leak. ed25519 uses node's `sign`/`verify` with a `null` algorithm.\n * Keys are PEM (PKCS8 private / SPKI public) for env transport; load once and\n * reuse the KeyObject on hot paths.\n */\n\nexport function generateEd25519KeyPair(): {\n\tprivateKeyPem: string;\n\tpublicKeyPem: string;\n} {\n\tconst { privateKey, publicKey } = generateKeyPairSync(\"ed25519\");\n\treturn {\n\t\tprivateKeyPem: privateKey\n\t\t\t.export({ format: \"pem\", type: \"pkcs8\" })\n\t\t\t.toString(),\n\t\tpublicKeyPem: publicKey.export({ format: \"pem\", type: \"spki\" }).toString(),\n\t};\n}\n\nexport function loadEd25519PrivateKey(pem: string): KeyObject {\n\treturn createPrivateKey(pem);\n}\n\nexport function loadEd25519PublicKey(pem: string): KeyObject {\n\treturn createPublicKey(pem);\n}\n\nexport function publicKeyPemFromPrivate(privateKeyPem: string): string {\n\treturn createPublicKey(createPrivateKey(privateKeyPem))\n\t\t.export({ format: \"pem\", type: \"spki\" })\n\t\t.toString();\n}\n\n/** Stable short id for a public key (rotation hint via X-Signature-KeyId). */\nexport function ed25519KeyId(publicKeyPem: string): string {\n\tconst der = createPublicKey(publicKeyPem).export({\n\t\tformat: \"der\",\n\t\ttype: \"spki\",\n\t});\n\treturn createHash(\"sha256\").update(der).digest(\"base64url\").slice(0, 16);\n}\n\nexport function signEd25519(payload: string, privateKey: KeyObject): string {\n\treturn nodeSign(null, Buffer.from(payload, \"utf8\"), privateKey).toString(\n\t\t\"base64\",\n\t);\n}\n\nexport function verifyEd25519(\n\tpayload: string,\n\tsignatureBase64: string,\n\tpublicKey: KeyObject,\n): boolean {\n\ttry {\n\t\treturn nodeVerify(\n\t\t\tnull,\n\t\t\tBuffer.from(payload, \"utf8\"),\n\t\t\tpublicKey,\n\t\t\tBuffer.from(signatureBase64, \"base64\"),\n\t\t);\n\t} catch {\n\t\treturn false;\n\t}\n}\n",
|
|
7
|
-
"import { isProductionEnv } from \"../env.ts\";\nimport {\n\ted25519KeyId,\n\tloadEd25519PrivateKey,\n\tloadEd25519PublicKey,\n\tpublicKeyPemFromPrivate,\n\tsignEd25519,\n\tverifyEd25519,\n} from \"./ed25519.ts\";\n\n/**\n * Universal Secondlayer webhook authenticity — an ed25519 signature attached to\n * EVERY delivery regardless of body format.\n *\n * Only the `standard-webhooks` format carries an HMAC; `raw`/`cloudevents`/etc.\n * carried no Secondlayer proof, so a receiver had no way to know a payload came\n * from us. This signs each delivery with a single platform ed25519 key so any\n * receiver verifies with the published public key — no per-
|
|
7
|
+
"import { isProductionEnv } from \"../env.ts\";\nimport {\n\ted25519KeyId,\n\tloadEd25519PrivateKey,\n\tloadEd25519PublicKey,\n\tpublicKeyPemFromPrivate,\n\tsignEd25519,\n\tverifyEd25519,\n} from \"./ed25519.ts\";\n\n/**\n * Universal Secondlayer webhook authenticity — an ed25519 signature attached to\n * EVERY delivery regardless of body format.\n *\n * Only the `standard-webhooks` format carries an HMAC; `raw`/`cloudevents`/etc.\n * carried no Secondlayer proof, so a receiver had no way to know a payload came\n * from us. This signs each delivery with a single platform ed25519 key so any\n * receiver verifies with the published public key — no per-webhook secret,\n * and the body shape stays format-specific.\n *\n * Header names are lowercase to match the format builders' header maps (HTTP\n * header names are case-insensitive on the wire).\n */\nexport const WEBHOOK_ID_HEADER = \"webhook-id\";\nexport const SECONDLAYER_SIGNATURE_HEADER = \"x-secondlayer-signature\";\nexport const SECONDLAYER_KEY_ID_HEADER = \"x-secondlayer-signature-keyid\";\n\nexport type SecondlayerWebhookSigner = {\n\tkeyId: string;\n\tpublicKeyPem: string;\n\tsign(payload: string): string;\n};\n\nlet cached: SecondlayerWebhookSigner | null | undefined;\n\nfunction signingKeyFromEnv(): string | undefined {\n\t// A dedicated webhook key if provided, else the platform Streams key — both\n\t// are the same ed25519 \"single platform identity\", so reusing it keeps key\n\t// distribution to one published public key.\n\treturn (\n\t\tprocess.env.SECONDLAYER_WEBHOOK_SIGNING_PRIVATE_KEY ||\n\t\tprocess.env.STREAMS_SIGNING_PRIVATE_KEY ||\n\t\tundefined\n\t);\n}\n\n/**\n * Memoized webhook signer, or null when no key is configured (signing is then a\n * no-op, so the universal header is safe to ship before a key is provisioned).\n */\nexport function getSecondlayerWebhookSigner(): SecondlayerWebhookSigner | null {\n\tif (cached !== undefined) return cached;\n\tconst raw = signingKeyFromEnv();\n\tif (!raw) {\n\t\tcached = null;\n\t\treturn null;\n\t}\n\t// Env transport often escapes newlines; restore real PEM line breaks.\n\tconst pem = raw.includes(\"\\\\n\") ? raw.replace(/\\\\n/g, \"\\n\") : raw;\n\tconst privateKey = loadEd25519PrivateKey(pem);\n\tconst publicKeyPem = publicKeyPemFromPrivate(pem);\n\tcached = {\n\t\tkeyId: ed25519KeyId(publicKeyPem),\n\t\tpublicKeyPem,\n\t\tsign: (payload) => signEd25519(payload, privateKey),\n\t};\n\treturn cached;\n}\n\n/** Reset the memoized signer. Tests only. */\nexport function resetSecondlayerWebhookSignerForTest(): void {\n\tcached = undefined;\n}\n\n/**\n * Boot-time guard for any service whose job is to DELIVER signed webhooks (the\n * webhook-processor). We market every delivery as carrying a verifiable\n * ed25519 signature; if no signing key is configured the signer is a silent\n * no-op and deliveries ship UNSIGNED. That's worse than a missing feature — a\n * partner's verify step fails on data we told them was signed.\n *\n * Unlike `assertDbSplit` (fail-soft: only ever logs), this is fail-HARD in prod:\n * refuse to boot so the misconfig surfaces as a crash-loop instead of unsigned\n * traffic. Keep the throw — don't \"soften\" it back to a warning. Set\n * `ALLOW_UNSIGNED_WEBHOOKS=true` to opt out (self-host / intentional unsigned),\n * which logs the posture loudly in every env so it's never silent.\n */\nexport function assertWebhookSigningConfigured(): void {\n\tif (getSecondlayerWebhookSigner()) return;\n\n\tconst optedOut = process.env.ALLOW_UNSIGNED_WEBHOOKS === \"true\";\n\t// isProductionEnv() reads NODE_ENV at runtime; do NOT inline a\n\t// `process.env.NODE_ENV` check — the bundler constant-folds it and the prod\n\t// throw would silently die in the shipped dist. See env.ts for the why.\n\tconst isProd = isProductionEnv();\n\tconst msg =\n\t\t\"No webhook signing key (SECONDLAYER_WEBHOOK_SIGNING_PRIVATE_KEY / STREAMS_SIGNING_PRIVATE_KEY) — deliveries would ship UNSIGNED while we market signed webhooks\";\n\n\tif (optedOut) {\n\t\tconsole.warn(`⚠️ ${msg}; continuing because ALLOW_UNSIGNED_WEBHOOKS=true`);\n\t\treturn;\n\t}\n\tif (isProd) {\n\t\tthrow new Error(\n\t\t\t`${msg}. Refusing to boot. Set the signing key, or ALLOW_UNSIGNED_WEBHOOKS=true to override.`,\n\t\t);\n\t}\n\tconsole.warn(`⚠️ ${msg} (non-prod: continuing)`);\n}\n\n/**\n * The exact bytes the signature covers: `${webhookId}.${body}`. Binding the id\n * into the signed content stops a captured body from being replayed under a\n * different delivery id.\n */\nfunction signedContent(webhookId: string, body: string): string {\n\treturn `${webhookId}.${body}`;\n}\n\n/**\n * Build the universal authenticity headers for a delivery. Returns null when no\n * signing key is configured (caller leaves the delivery unsigned).\n */\nexport function signSecondlayerWebhook(\n\twebhookId: string,\n\tbody: string,\n): Record<string, string> | null {\n\tconst signer = getSecondlayerWebhookSigner();\n\tif (!signer) return null;\n\treturn {\n\t\t[WEBHOOK_ID_HEADER]: webhookId,\n\t\t[SECONDLAYER_SIGNATURE_HEADER]: signer.sign(signedContent(webhookId, body)),\n\t\t[SECONDLAYER_KEY_ID_HEADER]: signer.keyId,\n\t};\n}\n\n/**\n * Verify a delivery's ed25519 signature over `${webhookId}.${rawBody}` against\n * the published public key. Low-level (raw values); receivers use the SDK's\n * `verifySecondlayerSignature`, which extracts the headers ergonomically.\n */\nexport function verifySecondlayerSignatureValues(\n\trawBody: string,\n\twebhookId: string | undefined,\n\tsignatureBase64: string | undefined,\n\tpublicKeyPem: string,\n): boolean {\n\tif (!webhookId || !signatureBase64) return false;\n\tconst publicKey = loadEd25519PublicKey(publicKeyPem);\n\treturn verifyEd25519(\n\t\tsignedContent(webhookId, rawBody),\n\t\tsignatureBase64,\n\t\tpublicKey,\n\t);\n}\n"
|
|
8
8
|
],
|
|
9
9
|
"mappings": "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",
|
|
10
10
|
"debugId": "4EF56E0B6A85BEF064756E2164756E21",
|