witnora 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +54 -9
  2. package/dist/assurance-contract.js +312 -0
  3. package/dist/assurance-loop-demo.js +245 -0
  4. package/dist/authorization-state-machine.js +35 -0
  5. package/dist/authorization-v02.js +60 -0
  6. package/dist/canonical-v02.js +197 -0
  7. package/dist/canonical.js +31 -0
  8. package/dist/cli.js +118 -0
  9. package/dist/command-help.js +76 -0
  10. package/dist/control-plane.js +3 -3
  11. package/dist/credentials.js +1 -1
  12. package/dist/design-partner-pilot.js +402 -0
  13. package/dist/design-partner-v02.js +449 -0
  14. package/dist/evidence-v02.js +118 -0
  15. package/dist/generic-eval.js +160 -0
  16. package/dist/github-design-partner-v02.js +399 -0
  17. package/dist/github-live-v02.js +45 -0
  18. package/dist/guided-setup.js +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/offline-verifier.js +375 -0
  21. package/dist/onboard.js +132 -0
  22. package/dist/onboarding-templates.js +3 -3
  23. package/dist/outcome-evaluator.js +51 -0
  24. package/dist/privacy-manifest.js +273 -0
  25. package/dist/probe-protocol-v02.js +101 -0
  26. package/dist/sandbox.js +1 -1
  27. package/dist/trust-v02.js +111 -0
  28. package/dist/try.js +1 -1
  29. package/dist/vendor/design-partner/failure-exercises.mjs +353 -0
  30. package/dist/vendor/onegent-runtime/browser-enforcement-runtime.d.ts +8 -0
  31. package/dist/vendor/onegent-runtime/browser-enforcement-runtime.d.ts.map +1 -1
  32. package/dist/vendor/onegent-runtime/browser-enforcement-runtime.js +43 -1
  33. package/dist/vendor/witnora-probe/canonical.d.ts +3 -0
  34. package/dist/vendor/witnora-probe/canonical.js +39 -0
  35. package/dist/vendor/witnora-probe/cli.d.ts +2 -0
  36. package/dist/vendor/witnora-probe/cli.js +36 -0
  37. package/dist/vendor/witnora-probe/config.d.ts +2 -0
  38. package/dist/vendor/witnora-probe/config.js +63 -0
  39. package/dist/vendor/witnora-probe/credential-resolver.d.ts +6 -0
  40. package/dist/vendor/witnora-probe/credential-resolver.js +17 -0
  41. package/dist/vendor/witnora-probe/crypto.d.ts +2 -0
  42. package/dist/vendor/witnora-probe/crypto.js +14 -0
  43. package/dist/vendor/witnora-probe/emulator.d.ts +51 -0
  44. package/dist/vendor/witnora-probe/emulator.js +164 -0
  45. package/dist/vendor/witnora-probe/index.d.ts +11 -0
  46. package/dist/vendor/witnora-probe/index.js +11 -0
  47. package/dist/vendor/witnora-probe/logger.d.ts +3 -0
  48. package/dist/vendor/witnora-probe/logger.js +26 -0
  49. package/dist/vendor/witnora-probe/metrics.d.ts +12 -0
  50. package/dist/vendor/witnora-probe/metrics.js +31 -0
  51. package/dist/vendor/witnora-probe/probe.d.ts +22 -0
  52. package/dist/vendor/witnora-probe/probe.js +208 -0
  53. package/dist/vendor/witnora-probe/request-verifier.d.ts +9 -0
  54. package/dist/vendor/witnora-probe/request-verifier.js +84 -0
  55. package/dist/vendor/witnora-probe/server.d.ts +9 -0
  56. package/dist/vendor/witnora-probe/server.js +85 -0
  57. package/dist/vendor/witnora-probe/storage.d.ts +16 -0
  58. package/dist/vendor/witnora-probe/storage.js +114 -0
  59. package/dist/vendor/witnora-probe/types.d.ts +126 -0
  60. package/dist/vendor/witnora-probe/types.js +2 -0
  61. package/dist/vendor/witnora-verifier-python/README.md +43 -0
  62. package/dist/vendor/witnora-verifier-python/pyproject.toml +23 -0
  63. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/__init__.py +33 -0
  64. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/canonical.py +218 -0
  65. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/cli.py +75 -0
  66. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/conformance.py +124 -0
  67. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/crypto.py +139 -0
  68. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/errors.py +15 -0
  69. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/event_chain.py +80 -0
  70. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/evidence.py +336 -0
  71. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/probe.py +188 -0
  72. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/producer.py +127 -0
  73. package/dist/vendor/witnora-verifier-python/src/witnora_verifier/trust.py +337 -0
  74. package/package.json +4 -4
@@ -0,0 +1,63 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve, dirname } from "node:path";
3
+ import { EnvironmentCredentialResolver } from "./credential-resolver.js";
4
+ import { DeterministicGitHubEmulatorAdapter, GitHubLiveApiAdapter, GitHubSandboxHttpAdapter } from "./emulator.js";
5
+ export async function loadProbeConfig(path, environment = process.env) {
6
+ const absolute = resolve(path);
7
+ const root = dirname(absolute);
8
+ const file = JSON.parse(await readFile(absolute, "utf8"));
9
+ const encryption = environment[file.storageEncryptionKeyEnv];
10
+ if (!encryption)
11
+ throw new Error(`Storage encryption key environment variable is not set: ${file.storageEncryptionKeyEnv}.`);
12
+ const storageEncryptionKey = Buffer.from(encryption, "base64");
13
+ if (storageEncryptionKey.byteLength !== 32)
14
+ throw new Error("Storage encryption key must decode to exactly 32 bytes.");
15
+ const requestVerificationKeys = {};
16
+ for (const key of file.requestVerificationKeys) {
17
+ requestVerificationKeys[key.keyId] = {
18
+ publicKeyPem: await readFile(resolve(root, key.publicKeyPemPath), "utf8"),
19
+ role: key.role,
20
+ validFrom: key.validFrom,
21
+ validUntil: key.validUntil,
22
+ ...(key.revokedAt ? { revokedAt: key.revokedAt } : {}),
23
+ };
24
+ }
25
+ const resolver = new EnvironmentCredentialResolver(environment);
26
+ const adapters = [];
27
+ if (file.githubEmulator) {
28
+ adapters.push(new DeterministicGitHubEmulatorAdapter(file.githubEmulator.pullRequests, {
29
+ requireCredential: Boolean(file.githubEmulator.requiredCredentialRef),
30
+ }));
31
+ }
32
+ if (file.githubTarget)
33
+ adapters.push(new GitHubSandboxHttpAdapter(file.githubTarget.url));
34
+ if (file.githubLive)
35
+ adapters.push(new GitHubLiveApiAdapter(file.githubLive.apiBase));
36
+ if (adapters.length === 0)
37
+ throw new Error("At least one Probe target adapter must be configured.");
38
+ const credentialReference = file.githubLive?.requiredCredentialRef ?? file.githubTarget?.requiredCredentialRef ?? file.githubEmulator?.requiredCredentialRef;
39
+ return {
40
+ host: file.host ?? "0.0.0.0",
41
+ port: file.port ?? 8080,
42
+ stateDirectory: resolve(root, file.stateDirectory),
43
+ requestBodyLimitBytes: file.requestBodyLimitBytes ?? 64 * 1024,
44
+ observationTimeoutMs: file.observationTimeoutMs ?? 5_000,
45
+ shutdownTimeoutMs: file.shutdownTimeoutMs ?? 10_000,
46
+ retry: {
47
+ maxAttempts: file.retry?.maxAttempts ?? 4,
48
+ baseDelayMs: file.retry?.baseDelayMs ?? 500,
49
+ scanIntervalMs: file.retry?.scanIntervalMs ?? 1_000,
50
+ },
51
+ bindings: file.bindings,
52
+ requestVerificationKeys,
53
+ probeSigningKey: {
54
+ keyCertificateId: file.probeSigningKey.keyCertificateId,
55
+ privateKeyPem: await readFile(resolve(root, file.probeSigningKey.privateKeyPemPath), "utf8"),
56
+ },
57
+ probeVersion: file.probeVersion,
58
+ storageEncryptionKey,
59
+ adapters,
60
+ credentialReference,
61
+ credentialResolver: resolver,
62
+ };
63
+ }
@@ -0,0 +1,6 @@
1
+ import type { CredentialResolver } from "./types.js";
2
+ export declare class EnvironmentCredentialResolver implements CredentialResolver {
3
+ private readonly environment;
4
+ constructor(environment?: NodeJS.ProcessEnv);
5
+ resolve(reference: string): Promise<string>;
6
+ }
@@ -0,0 +1,17 @@
1
+ export class EnvironmentCredentialResolver {
2
+ environment;
3
+ constructor(environment = process.env) {
4
+ this.environment = environment;
5
+ }
6
+ async resolve(reference) {
7
+ if (!reference.startsWith("env:"))
8
+ throw new Error("Only env:<VARIABLE_NAME> credential references are supported.");
9
+ const name = reference.slice(4);
10
+ if (!/^[A-Z][A-Z0-9_]{1,127}$/.test(name))
11
+ throw new Error("Credential reference contains an invalid environment variable name.");
12
+ const value = this.environment[name];
13
+ if (!value)
14
+ throw new Error(`Referenced read credential is unavailable: env:${name}.`);
15
+ return value;
16
+ }
17
+ }
@@ -0,0 +1,2 @@
1
+ export declare function signCanonical(value: unknown, privateKeyPem: string): string;
2
+ export declare function verifyCanonical(value: unknown, signature: string, publicKeyPem: string): boolean;
@@ -0,0 +1,14 @@
1
+ import { createPublicKey, sign, verify } from "node:crypto";
2
+ import { canonicalJson } from "./canonical.js";
3
+ export function signCanonical(value, privateKeyPem) {
4
+ const bytes = Buffer.from(canonicalJson(value), "utf8");
5
+ return sign(null, bytes, privateKeyPem).toString("base64");
6
+ }
7
+ export function verifyCanonical(value, signature, publicKeyPem) {
8
+ try {
9
+ return verify(null, Buffer.from(canonicalJson(value), "utf8"), createPublicKey(publicKeyPem), Buffer.from(signature, "base64"));
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
@@ -0,0 +1,51 @@
1
+ import type { AdapterObservation, GitHubPullRequestReadQueryV02, ObservationTargetAdapter } from "./types.js";
2
+ export interface GitHubEmulatorPullRequest {
3
+ repository: string;
4
+ pullRequestNumber: number;
5
+ merged: boolean;
6
+ headSha: string;
7
+ baseBranch: string;
8
+ updatedAt: string;
9
+ mergeCommitSha?: string;
10
+ }
11
+ export declare class DeterministicGitHubEmulatorAdapter implements ObservationTargetAdapter {
12
+ private readonly pullRequests;
13
+ readonly id = "github-emulator-read-api";
14
+ private failuresRemaining;
15
+ constructor(pullRequests: GitHubEmulatorPullRequest[], options?: {
16
+ failuresBeforeSuccess?: number;
17
+ requireCredential?: boolean;
18
+ });
19
+ private readonly requireCredential;
20
+ supports(query: unknown): query is GitHubPullRequestReadQueryV02;
21
+ observe(input: {
22
+ query: unknown;
23
+ credential?: string;
24
+ signal: AbortSignal;
25
+ }): Promise<AdapterObservation>;
26
+ }
27
+ export declare class RetryableObservationError extends Error {
28
+ readonly retryable = true;
29
+ }
30
+ export declare class GitHubSandboxHttpAdapter implements ObservationTargetAdapter {
31
+ private readonly targetUrl;
32
+ readonly id = "github-sandbox-read-api";
33
+ constructor(targetUrl: string);
34
+ supports(query: unknown): query is GitHubPullRequestReadQueryV02;
35
+ observe(input: {
36
+ query: unknown;
37
+ credential?: string;
38
+ signal: AbortSignal;
39
+ }): Promise<AdapterObservation>;
40
+ }
41
+ export declare class GitHubLiveApiAdapter implements ObservationTargetAdapter {
42
+ private readonly apiBase;
43
+ readonly id = "github-live-read-api";
44
+ constructor(apiBase?: string);
45
+ supports(query: unknown): query is GitHubPullRequestReadQueryV02;
46
+ observe(input: {
47
+ query: unknown;
48
+ credential?: string;
49
+ signal: AbortSignal;
50
+ }): Promise<AdapterObservation>;
51
+ }
@@ -0,0 +1,164 @@
1
+ export class DeterministicGitHubEmulatorAdapter {
2
+ pullRequests;
3
+ id = "github-emulator-read-api";
4
+ failuresRemaining;
5
+ constructor(pullRequests, options = {}) {
6
+ this.pullRequests = pullRequests;
7
+ this.failuresRemaining = options.failuresBeforeSuccess ?? 0;
8
+ this.requireCredential = options.requireCredential ?? false;
9
+ }
10
+ requireCredential;
11
+ supports(query) {
12
+ if (!query || typeof query !== "object")
13
+ return false;
14
+ const value = query;
15
+ return value.type === "github.pull_request.read.v0.2"
16
+ && typeof value.repository === "string"
17
+ && Number.isInteger(value.pullRequestNumber)
18
+ && Number(value.pullRequestNumber) > 0;
19
+ }
20
+ async observe(input) {
21
+ if (!this.supports(input.query))
22
+ throw new Error("GitHub emulator query is invalid.");
23
+ const query = input.query;
24
+ if (input.signal.aborted)
25
+ throw new Error("Observation aborted.");
26
+ if (this.requireCredential && !input.credential) {
27
+ throw new Error("Read credential rejected by GitHub emulator.");
28
+ }
29
+ if (this.failuresRemaining > 0) {
30
+ this.failuresRemaining -= 1;
31
+ throw new RetryableObservationError("GitHub emulator injected a transient read failure.");
32
+ }
33
+ const pullRequest = this.pullRequests.find((item) => item.repository === query.repository && item.pullRequestNumber === query.pullRequestNumber);
34
+ if (!pullRequest)
35
+ throw new Error("GitHub emulator pull request does not exist.");
36
+ const normalizedObservation = {
37
+ type: "github.pull_request.outcome.v0.2",
38
+ merged: pullRequest.merged,
39
+ headSha: pullRequest.headSha,
40
+ baseBranch: pullRequest.baseBranch,
41
+ };
42
+ return {
43
+ normalizedObservation,
44
+ rawObservation: structuredClone(pullRequest),
45
+ observationSource: this.id,
46
+ sourceObservedAt: pullRequest.updatedAt,
47
+ targetVersion: pullRequest.mergeCommitSha ?? pullRequest.headSha,
48
+ targetAuditReference: undefined,
49
+ limitations: ["TARGET_STATE_RECONCILED", "TARGET_AUDIT_LOG_NOT_VERIFIED"],
50
+ };
51
+ }
52
+ }
53
+ export class RetryableObservationError extends Error {
54
+ retryable = true;
55
+ }
56
+ export class GitHubSandboxHttpAdapter {
57
+ targetUrl;
58
+ id = "github-sandbox-read-api";
59
+ constructor(targetUrl) {
60
+ this.targetUrl = targetUrl;
61
+ }
62
+ supports(query) {
63
+ if (!query || typeof query !== "object")
64
+ return false;
65
+ const value = query;
66
+ return value.type === "github.pull_request.read.v0.2"
67
+ && typeof value.repository === "string"
68
+ && Number.isInteger(value.pullRequestNumber)
69
+ && Number(value.pullRequestNumber) > 0;
70
+ }
71
+ async observe(input) {
72
+ if (!this.supports(input.query))
73
+ throw new Error("GitHub sandbox query is invalid.");
74
+ if (!input.credential)
75
+ throw new Error("GitHub sandbox read credential is unavailable.");
76
+ const response = await fetch(`${this.targetUrl.replace(/\/$/u, "")}/v0.2/github/pull-request`, {
77
+ headers: { authorization: `Bearer ${input.credential}` },
78
+ signal: input.signal,
79
+ });
80
+ if (!response.ok) {
81
+ if (response.status >= 400 && response.status < 500) {
82
+ throw new Error(`GitHub sandbox read was rejected with status ${response.status}.`);
83
+ }
84
+ throw new RetryableObservationError(`GitHub sandbox read failed with status ${response.status}.`);
85
+ }
86
+ const state = await response.json();
87
+ const repository = `${state.owner}/${state.repository}`;
88
+ if (repository !== input.query.repository || state.pullRequestNumber !== input.query.pullRequestNumber) {
89
+ throw new Error("GitHub sandbox response does not match the bound read query.");
90
+ }
91
+ return {
92
+ normalizedObservation: {
93
+ type: "github.pull_request.outcome.v0.2",
94
+ merged: state.merged,
95
+ headSha: state.headSha,
96
+ baseBranch: state.baseBranch,
97
+ ...((state.consumedGrantIds?.length ?? 0) > 0 ? { consumedGrantId: state.consumedGrantIds?.at(-1) } : {}),
98
+ },
99
+ rawObservation: state,
100
+ observationSource: this.id,
101
+ sourceObservedAt: state.mergedAt ?? new Date().toISOString(),
102
+ targetVersion: state.mergeCommitSha ?? String(state.targetVersion),
103
+ limitations: ["TARGET_STATE_RECONCILED", "TARGET_AUDIT_LOG_NOT_VERIFIED"],
104
+ };
105
+ }
106
+ }
107
+ export class GitHubLiveApiAdapter {
108
+ apiBase;
109
+ id = "github-live-read-api";
110
+ constructor(apiBase = "https://api.github.com") {
111
+ this.apiBase = apiBase;
112
+ }
113
+ supports(query) {
114
+ if (!query || typeof query !== "object")
115
+ return false;
116
+ const value = query;
117
+ return value.type === "github.pull_request.read.v0.2"
118
+ && typeof value.repository === "string"
119
+ && value.repository.includes("/")
120
+ && Number.isInteger(value.pullRequestNumber)
121
+ && Number(value.pullRequestNumber) > 0;
122
+ }
123
+ async observe(input) {
124
+ if (!this.supports(input.query))
125
+ throw new Error("GitHub live query is invalid.");
126
+ if (!input.credential)
127
+ throw new Error("GitHub live read credential is unavailable.");
128
+ const [owner, repository, ...rest] = input.query.repository.split("/");
129
+ if (!owner || !repository || rest.length > 0)
130
+ throw new Error("GitHub live repository must be owner/name.");
131
+ const response = await fetch(`${this.apiBase.replace(/\/$/u, "")}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls/${input.query.pullRequestNumber}`, {
132
+ headers: githubHeaders(input.credential),
133
+ signal: input.signal,
134
+ });
135
+ if (!response.ok) {
136
+ if (response.status >= 500)
137
+ throw new RetryableObservationError(`GitHub live read failed with status ${response.status}.`);
138
+ throw new Error(`GitHub live read was rejected with status ${response.status}.`);
139
+ }
140
+ const pull = await response.json();
141
+ const head = pull.head;
142
+ const base = pull.base;
143
+ return {
144
+ normalizedObservation: {
145
+ type: "github.pull_request.outcome.v0.2",
146
+ merged: pull.merged === true,
147
+ headSha: String(head.sha),
148
+ baseBranch: String(base.ref),
149
+ },
150
+ rawObservation: pull,
151
+ observationSource: this.id,
152
+ sourceObservedAt: String(pull.merged_at ?? pull.updated_at),
153
+ targetVersion: String(pull.merge_commit_sha ?? head.sha),
154
+ limitations: ["TARGET_STATE_RECONCILED", "GITHUB_PULL_API_ONLY", "TARGET_AUDIT_LOG_NOT_VERIFIED"],
155
+ };
156
+ }
157
+ }
158
+ function githubHeaders(credential) {
159
+ return {
160
+ authorization: `Bearer ${credential}`,
161
+ accept: "application/vnd.github+json",
162
+ "x-github-api-version": "2022-11-28",
163
+ };
164
+ }
@@ -0,0 +1,11 @@
1
+ export * from "./canonical.js";
2
+ export * from "./credential-resolver.js";
3
+ export * from "./crypto.js";
4
+ export * from "./emulator.js";
5
+ export * from "./logger.js";
6
+ export * from "./metrics.js";
7
+ export * from "./probe.js";
8
+ export * from "./request-verifier.js";
9
+ export * from "./server.js";
10
+ export * from "./storage.js";
11
+ export * from "./types.js";
@@ -0,0 +1,11 @@
1
+ export * from "./canonical.js";
2
+ export * from "./credential-resolver.js";
3
+ export * from "./crypto.js";
4
+ export * from "./emulator.js";
5
+ export * from "./logger.js";
6
+ export * from "./metrics.js";
7
+ export * from "./probe.js";
8
+ export * from "./request-verifier.js";
9
+ export * from "./server.js";
10
+ export * from "./storage.js";
11
+ export * from "./types.js";
@@ -0,0 +1,3 @@
1
+ import type { StructuredLogger } from "./types.js";
2
+ export declare function redact(value: unknown): unknown;
3
+ export declare function createJsonLogger(write?: (line: string) => void): StructuredLogger;
@@ -0,0 +1,26 @@
1
+ const SENSITIVE_KEY = /(credential|secret|token|password|authorization|private.?key|raw.?observation)/i;
2
+ const SENSITIVE_VALUE = /(bearer\s+[a-z0-9._~-]+|sk[-_][a-z0-9_-]{8,}|rk_(?:test|live)_[a-z0-9]+)/i;
3
+ export function redact(value) {
4
+ if (typeof value === "string")
5
+ return SENSITIVE_VALUE.test(value) ? "[REDACTED]" : value;
6
+ if (Array.isArray(value))
7
+ return value.map(redact);
8
+ if (value && typeof value === "object") {
9
+ const output = {};
10
+ for (const [key, item] of Object.entries(value)) {
11
+ output[key] = SENSITIVE_KEY.test(key) ? "[REDACTED]" : redact(item);
12
+ }
13
+ return output;
14
+ }
15
+ return value;
16
+ }
17
+ export function createJsonLogger(write = console.log) {
18
+ const emit = (level, event, fields = {}) => {
19
+ write(JSON.stringify(redact({ timestamp: new Date().toISOString(), level, event, ...fields })));
20
+ };
21
+ return {
22
+ info: (event, fields) => emit("info", event, fields),
23
+ warn: (event, fields) => emit("warn", event, fields),
24
+ error: (event, fields) => emit("error", event, fields),
25
+ };
26
+ }
@@ -0,0 +1,12 @@
1
+ declare const COUNTER_NAMES: readonly ["probe_requests_total", "probe_attestations_total", "probe_replay_rejections_total", "probe_stale_observations_total", "probe_signature_failures_total", "probe_target_read_failures_total"];
2
+ declare const GAUGE_NAMES: readonly ["probe_queue_depth", "probe_clock_skew_seconds"];
3
+ export type ProbeCounterName = typeof COUNTER_NAMES[number];
4
+ export type ProbeGaugeName = typeof GAUGE_NAMES[number];
5
+ export declare class ProbeMetrics {
6
+ private readonly counters;
7
+ private readonly gauges;
8
+ increment(name: ProbeCounterName, amount?: number): void;
9
+ set(name: ProbeGaugeName, value: number): void;
10
+ render(): string;
11
+ }
12
+ export {};
@@ -0,0 +1,31 @@
1
+ const COUNTER_NAMES = [
2
+ "probe_requests_total",
3
+ "probe_attestations_total",
4
+ "probe_replay_rejections_total",
5
+ "probe_stale_observations_total",
6
+ "probe_signature_failures_total",
7
+ "probe_target_read_failures_total",
8
+ ];
9
+ const GAUGE_NAMES = [
10
+ "probe_queue_depth",
11
+ "probe_clock_skew_seconds",
12
+ ];
13
+ export class ProbeMetrics {
14
+ counters = new Map(COUNTER_NAMES.map((name) => [name, 0]));
15
+ gauges = new Map(GAUGE_NAMES.map((name) => [name, 0]));
16
+ increment(name, amount = 1) {
17
+ this.counters.set(name, (this.counters.get(name) ?? 0) + amount);
18
+ }
19
+ set(name, value) {
20
+ if (!Number.isFinite(value))
21
+ return;
22
+ this.gauges.set(name, value);
23
+ }
24
+ render() {
25
+ return [
26
+ ...COUNTER_NAMES.map((name) => `# TYPE ${name} counter\n${name} ${this.counters.get(name) ?? 0}`),
27
+ ...GAUGE_NAMES.map((name) => `# TYPE ${name} gauge\n${name} ${this.gauges.get(name) ?? 0}`),
28
+ "",
29
+ ].join("\n");
30
+ }
31
+ }
@@ -0,0 +1,22 @@
1
+ import { ProbeMetrics } from "./metrics.js";
2
+ import { type ProbeConfig, type ProbeObservationAttestationV02, type ProbeObserveBodyV02, type StructuredLogger } from "./types.js";
3
+ export declare class WitnoraProbe {
4
+ private readonly config;
5
+ private readonly logger;
6
+ readonly metrics: ProbeMetrics;
7
+ private readonly store;
8
+ private readonly nonces;
9
+ private retryTimer?;
10
+ private accepting;
11
+ private active;
12
+ private draining;
13
+ constructor(config: ProbeConfig, logger: StructuredLogger);
14
+ start(): void;
15
+ isReady(): boolean;
16
+ observe(body: ProbeObserveBodyV02): Promise<ProbeObservationAttestationV02>;
17
+ shutdown(): Promise<void>;
18
+ drainRetryQueue(now?: Date): Promise<void>;
19
+ private executeWithRetries;
20
+ private readTarget;
21
+ private createAttestation;
22
+ }
@@ -0,0 +1,208 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { canonicalJson, sha256Canonical } from "./canonical.js";
4
+ import { signCanonical } from "./crypto.js";
5
+ import { observationRequestDigest, RequestVerificationError, verifyObservationRequest, } from "./request-verifier.js";
6
+ import { EncryptedJsonStore, PersistentNonceCache } from "./storage.js";
7
+ import { ProbeMetrics } from "./metrics.js";
8
+ import { OBSERVATION_ATTESTATION_PROTOCOL, } from "./types.js";
9
+ export class WitnoraProbe {
10
+ config;
11
+ logger;
12
+ metrics = new ProbeMetrics();
13
+ store;
14
+ nonces;
15
+ retryTimer;
16
+ accepting = true;
17
+ active = 0;
18
+ draining = false;
19
+ constructor(config, logger) {
20
+ this.config = config;
21
+ this.logger = logger;
22
+ this.store = new EncryptedJsonStore(config.stateDirectory, config.storageEncryptionKey);
23
+ this.nonces = new PersistentNonceCache(join(config.stateDirectory, "nonces"));
24
+ }
25
+ start() {
26
+ if (this.retryTimer)
27
+ return;
28
+ this.retryTimer = setInterval(() => void this.drainRetryQueue(), this.config.retry.scanIntervalMs);
29
+ this.retryTimer.unref();
30
+ }
31
+ isReady() {
32
+ return this.accepting && Object.keys(this.config.requestVerificationKeys).length > 0 && this.config.adapters.length > 0;
33
+ }
34
+ async observe(body) {
35
+ this.metrics.increment("probe_requests_total");
36
+ if (!this.accepting)
37
+ throw new RequestVerificationError("SHUTTING_DOWN", "Probe is shutting down.", 503);
38
+ const request = verifyObservationRequest(body, this.config.bindings, this.config.requestVerificationKeys);
39
+ if (!(await this.nonces.claim(request.nonce, request.expiresAt))) {
40
+ this.metrics.increment("probe_replay_rejections_total");
41
+ throw new RequestVerificationError("NONCE_REPLAY", "Observation request nonce has already been used.", 409);
42
+ }
43
+ return this.executeWithRetries(body, 1);
44
+ }
45
+ async shutdown() {
46
+ this.accepting = false;
47
+ if (this.retryTimer)
48
+ clearInterval(this.retryTimer);
49
+ this.retryTimer = undefined;
50
+ const deadline = Date.now() + this.config.shutdownTimeoutMs;
51
+ while (this.active > 0 && Date.now() < deadline)
52
+ await new Promise((resolve) => setTimeout(resolve, 10));
53
+ this.logger.info("probe.shutdown", { activeObservations: this.active });
54
+ }
55
+ async drainRetryQueue(now = new Date()) {
56
+ if (this.draining)
57
+ return;
58
+ this.draining = true;
59
+ try {
60
+ for (const id of await this.store.list("retry")) {
61
+ const item = await this.store.get("retry", id);
62
+ if (!item || Date.parse(item.nextAttemptAt) > now.getTime() || Date.parse(item.leaseUntil) > now.getTime())
63
+ continue;
64
+ try {
65
+ verifyObservationRequest(item.body, this.config.bindings, this.config.requestVerificationKeys, now);
66
+ item.leaseUntil = new Date(now.getTime() + this.config.observationTimeoutMs + this.config.retry.baseDelayMs + 1_000).toISOString();
67
+ await this.store.put("retry", id, item);
68
+ await this.executeWithRetries(item.body, item.attempt);
69
+ }
70
+ catch (error) {
71
+ this.logger.warn("probe.retry_rejected", { requestId: item.body.request.requestId, error: errorCode(error) });
72
+ await this.store.delete("retry", id);
73
+ }
74
+ }
75
+ }
76
+ finally {
77
+ this.draining = false;
78
+ }
79
+ }
80
+ async executeWithRetries(body, firstAttempt) {
81
+ const request = body.request;
82
+ let attempt = firstAttempt;
83
+ while (attempt <= this.config.retry.maxAttempts) {
84
+ this.active += 1;
85
+ try {
86
+ const observed = await this.readTarget(body.query);
87
+ const result = await this.createAttestation(body, observed);
88
+ await this.store.put("attestations", request.requestId, result);
89
+ await this.store.delete("retry", request.requestId);
90
+ this.metrics.set("probe_queue_depth", (await this.store.list("retry")).length);
91
+ this.metrics.increment("probe_attestations_total");
92
+ this.logger.info("probe.observation_attested", {
93
+ requestId: request.requestId,
94
+ requestDigest: result.requestDigest,
95
+ outcomeStatus: result.outcomeStatus,
96
+ attempt,
97
+ });
98
+ return result;
99
+ }
100
+ catch (error) {
101
+ const retryable = isRetryable(error) && attempt < this.config.retry.maxAttempts;
102
+ if (!retryable) {
103
+ this.metrics.increment("probe_target_read_failures_total");
104
+ await this.store.delete("retry", request.requestId);
105
+ this.metrics.set("probe_queue_depth", (await this.store.list("retry")).length);
106
+ this.logger.error("probe.observation_failed", { requestId: request.requestId, attempt, error: errorCode(error) });
107
+ throw error;
108
+ }
109
+ const nextAttemptAt = new Date(Date.now() + this.config.retry.baseDelayMs * 2 ** (attempt - 1)).toISOString();
110
+ const leaseUntil = new Date(Date.parse(nextAttemptAt) + this.config.observationTimeoutMs + 1_000).toISOString();
111
+ await this.store.put("retry", request.requestId, {
112
+ body,
113
+ attempt: attempt + 1,
114
+ nextAttemptAt,
115
+ leaseUntil,
116
+ lastErrorCode: errorCode(error),
117
+ });
118
+ this.metrics.set("probe_queue_depth", (await this.store.list("retry")).length);
119
+ this.logger.warn("probe.observation_queued", {
120
+ requestId: request.requestId,
121
+ requestDigest: observationRequestDigest(request),
122
+ attempt,
123
+ error: errorCode(error),
124
+ });
125
+ await sleepUntil(nextAttemptAt);
126
+ attempt += 1;
127
+ }
128
+ finally {
129
+ this.active -= 1;
130
+ }
131
+ }
132
+ throw new Error("Observation retry loop exhausted unexpectedly.");
133
+ }
134
+ async readTarget(query) {
135
+ const adapter = this.config.adapters.find((candidate) => candidate.supports(query));
136
+ if (!adapter)
137
+ throw new RequestVerificationError("QUERY_NOT_SUPPORTED", "No configured read adapter supports this query.", 403);
138
+ const credential = this.config.credentialReference
139
+ ? await this.config.credentialResolver.resolve(this.config.credentialReference)
140
+ : undefined;
141
+ const controller = new AbortController();
142
+ const timeout = setTimeout(() => controller.abort(), this.config.observationTimeoutMs);
143
+ try {
144
+ return await adapter.observe({ query, credential, signal: controller.signal });
145
+ }
146
+ finally {
147
+ clearTimeout(timeout);
148
+ }
149
+ }
150
+ async createAttestation(body, observed) {
151
+ const request = body.request;
152
+ const observedAt = new Date();
153
+ const sourceObservedAt = Date.parse(observed.sourceObservedAt);
154
+ if (!Number.isFinite(sourceObservedAt))
155
+ throw new Error("Adapter returned an invalid sourceObservedAt timestamp.");
156
+ if (sourceObservedAt > observedAt.getTime() + 30_000)
157
+ throw new Error("Adapter returned a future-dated source observation beyond clock skew.");
158
+ this.metrics.set("probe_clock_skew_seconds", (observedAt.getTime() - sourceObservedAt) / 1_000);
159
+ const rawObservationCommitment = sha256Canonical(observed.rawObservation);
160
+ const unsigned = {
161
+ protocolVersion: OBSERVATION_ATTESTATION_PROTOCOL,
162
+ attestationId: randomUUID(),
163
+ requestId: request.requestId,
164
+ requestDigest: observationRequestDigest(request),
165
+ nonce: request.nonce,
166
+ tenantRef: request.tenantRef,
167
+ projectRef: request.projectRef,
168
+ environment: request.environment,
169
+ actionDigest: request.actionDigest,
170
+ contractDigest: request.contractDigest,
171
+ resourceCommitment: request.resourceCommitment,
172
+ predicateDigest: request.predicateDigest,
173
+ probeId: request.probeId,
174
+ probeVersion: this.config.probeVersion,
175
+ observationSource: observed.observationSource,
176
+ observedAt: observedAt.toISOString(),
177
+ dataFreshnessMs: Math.max(0, observedAt.getTime() - sourceObservedAt),
178
+ normalizedObservation: observed.normalizedObservation,
179
+ rawObservationCommitment,
180
+ targetVersion: observed.targetVersion,
181
+ ...(observed.targetAuditReference ? { targetAuditReference: observed.targetAuditReference } : {}),
182
+ outcomeStatus: canonicalJson(observed.normalizedObservation) === canonicalJson(body.expected) ? "VERIFIED" : "NOT_VERIFIED",
183
+ limitations: observed.limitations,
184
+ keyCertificateId: this.config.probeSigningKey.keyCertificateId,
185
+ };
186
+ const result = { ...unsigned, signature: signCanonical(unsigned, this.config.probeSigningKey.privateKeyPem) };
187
+ await this.store.put("raw-observations", request.requestId, {
188
+ observedAt: unsigned.observedAt,
189
+ observationSource: observed.observationSource,
190
+ rawObservation: observed.rawObservation,
191
+ });
192
+ return result;
193
+ }
194
+ }
195
+ function isRetryable(error) {
196
+ return Boolean(error && typeof error === "object" && "retryable" in error && error.retryable === true);
197
+ }
198
+ function errorCode(error) {
199
+ if (error instanceof RequestVerificationError)
200
+ return error.code;
201
+ if (error instanceof Error)
202
+ return error.name || "ERROR";
203
+ return "UNKNOWN_ERROR";
204
+ }
205
+ async function sleepUntil(timestamp) {
206
+ const delay = Math.max(0, Date.parse(timestamp) - Date.now());
207
+ await new Promise((resolve) => setTimeout(resolve, delay));
208
+ }