verdifax 1.3.0 → 1.4.0

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 CHANGED
@@ -105,6 +105,13 @@ if (v.verified) {
105
105
  | `client.testAccountPolicy({ inputs, document?, documentHash? })` | `POST /account/policies/test` | owner key |
106
106
  | `client.getAccountPolicyHistory()` | `GET /account/policies/history` | owner key |
107
107
  | `client.activateAccountPolicy(documentHash)` | `POST /account/policies/{document_hash}/activate` | owner key |
108
+ | `client.registerApprover(name, approver)` | `POST /account/approvers` | owner key |
109
+ | `client.listApprovers()` | `GET /account/approvers` | owner key |
110
+ | `client.revokeApprover(id)` | `DELETE /account/approvers/{id}` | owner key |
111
+ | `client.getSignOffHistory()` | `GET /account/signoffs/history` | owner key |
112
+ | `client.getNewestSignOff({ asOwner? })` | `GET /signoffs/newest`, or `GET /account/signoffs/newest` with `asOwner` | required, or owner key |
113
+ | `client.signOff(target, decision, approverKey, { note?, asOwner? })` | `POST /signoffs`, or `POST /account/signoffs` with `asOwner` | required, or owner key |
114
+ | `client.getSignOffs(runId, { asOwner? })` | `GET /runs/{id}/signoffs`, or `GET /account/runs/{id}/signoffs` with `asOwner` | required, or owner key |
108
115
  | `client.adminCreateKey(name, { accountId? })` | `POST /admin/keys` | admin |
109
116
  | `client.adminListKeys()` | `GET /admin/keys` | admin |
110
117
  | `client.adminRevokeKey(id)` | `DELETE /admin/keys/{id}` | admin |
@@ -115,7 +122,7 @@ if (v.verified) {
115
122
 
116
123
  `client.job(jobId)` returns a [job helper](#jobs) and makes no request until a step runs.
117
124
 
118
- These functions make no request: `captureEnvironment`, `formatProviderPayload`, `signRequest`, `signRequestV2`, `requestKeyRegistrationBody`, `requestKeyChangeApproval`, `requestKeyChangePreimage`, `policyActivationApproval`, `policyActivationPreimage`, `requestSignaturePreimage`, `requestSignaturePreimageV2`, `generateRequestKeyPair`, `validateHex64`, `validateJobName`, `validateRouteId`, `dseDispatchHash`, `dcaeClosureHash` and `cresReceiptHash`. `RUN_STATUSES` lists every run status the server uses, and `EXECUTE_FIELDS` lists every top-level key a run request may hold.
125
+ These functions make no request: `captureEnvironment`, `formatProviderPayload`, `signRequest`, `signRequestV2`, `requestKeyRegistrationBody`, `requestKeyChangeApproval`, `requestKeyChangePreimage`, `policyActivationApproval`, `policyActivationPreimage`, `signOffPreimage`, `approverKeyProof`, `requestSignaturePreimage`, `requestSignaturePreimageV2`, `generateRequestKeyPair`, `validateHex64`, `validateJobName`, `validateRouteId`, `dseDispatchHash`, `dcaeClosureHash` and `cresReceiptHash`. `RUN_STATUSES` lists every run status the server uses, and `EXECUTE_FIELDS` lists every top-level key a run request may hold.
119
126
 
120
127
  `getRunArtifacts`, `getPublicBundle` and `getPublicBundleByToken` return the exact JSON text the server sent, so you can save it and check it with `verdifax-verify`.
121
128
 
@@ -293,6 +300,56 @@ The account's view covers the runs its keys made through Verdifax. It does not c
293
300
 
294
301
  `exportAccountRuns` and `exportAccountRunsCSV` take the same period and `job_id` as `exportRuns` and check the CSV counts the same way. The JSON form also carries `account_id`, and `account_policy_documents` and `account_policy_history` once the account has loaded a policy. `listAccountRuns` pages like `listRuns`. `revokeAccountKey` revokes a member key, and a key in another account, or an owner key, throws `NotFoundError`. `adminCreateKey(name, { accountId })` puts a new customer key in an existing account, by its number.
295
302
 
303
+ ## Sign-off
304
+
305
+ A sign-off is an approver's decision, approve or reject, on one exact result: a completed run's record, a refused or halted run's receipt, or a whole job by its final step. The approver signs it on their own machine with an approver key, and Verdifax links it to the account's previous sign-off and logs it on the public log.
306
+
307
+ ```typescript
308
+ import { writeFileSync } from "node:fs";
309
+ import { VerdifaxClient, approverKeyProof, generateRequestKeyPair } from "verdifax";
310
+
311
+ // The approver creates a key on their own machine and keeps the private key.
312
+ const approverKey = generateRequestKeyPair().privateKey;
313
+ const proof = approverKeyProof(approverKey); // { public_key, proof }, safe to hand to the owner
314
+
315
+ // The account's owner registers the approver key.
316
+ const owner = new VerdifaxClient({ apiKey: process.env.VERDIFAX_OWNER_KEY! });
317
+ await owner.registerApprover("credit review", proof);
318
+
319
+ // Any key in the account submits the approver's signed decision on one exact result.
320
+ const member = new VerdifaxClient({ apiKey: process.env.VERDIFAX_API_KEY! });
321
+ const bundle = JSON.parse(await member.getRunArtifacts(1042));
322
+ const signOff = await member.signOff(
323
+ { kind: "record", run_id: 1042, digest: bundle.bundle_anchor.anchored_digest },
324
+ "approve",
325
+ approverKey,
326
+ { note: "Checked against the loan file." }, // only its SHA-256 is sent
327
+ );
328
+ console.log(signOff.sequence, signOff.signoff_hash);
329
+
330
+ // Save the run's sign-offs and check them with verdifax-verify --signoff signoffs.json.
331
+ writeFileSync("signoffs.json", JSON.stringify(await member.getSignOffs(1042)));
332
+
333
+ // The owner saves the account's whole chain for verdifax-verify --signoff-history.
334
+ writeFileSync("signoff-history.json", JSON.stringify(await owner.getSignOffHistory()));
335
+ ```
336
+
337
+ An approver key is registered by the account's owner, and it is never a request key in the same account. The server refuses to register a key that is, so the key that requested a run cannot sign it off. `registerApprover` takes the approver's signing key, from which it makes the proof on this machine, or the `{ public_key, proof }` that `approverKeyProof` made on the approver's own machine, so the owner need not hold the approver's private key. It refuses a proof that does not verify before sending. `revokeApprover` revokes a key, and its earlier sign-offs stay on the record. `listApprovers` lists revoked keys too.
338
+
339
+ `signOff` reads `getNewestSignOff` first for the account's chain, `account-N`, and its newest sign-off, then signs over them, the target, the decision, the note's hash, the time and a fresh nonce. The approver's private key is never sent. The target's `digest` is what the result's public-log entry covers: for a record, the `bundle_anchor.anchored_digest` of the bundle `getRunArtifacts` returns; for a receipt, its hash; for a job, the final step's record digest, read the same way as a record's, with `job_id`. The note is hashed on your machine and only its SHA-256 is sent and signed: the note's text never reaches Verdifax, so keep it yourself. `signOff` refuses a bad target, decision, key or note before sending anything. It also checks that the record the server returns carries the signature, `approver_key`, `decision`, `previous_signoff_hash` and `target` that were sent, and throws `VerdifaxError` if not. An HTTP 409 means another sign-off came first or the target is not this account's result: it throws the usual `APIError` and is not retried.
340
+
341
+ With `asOwner: true`, `signOff`, `getNewestSignOff` and `getSignOffs` use the owner key's `/account` routes. `getSignOffs` returns a run's sign-offs oldest first, in the form `verdifax-verify --signoff FILE` reads: for a member key, a run it made; with `asOwner`, any run of the account. `getSignOffHistory` returns the account's whole chain, oldest first, in the form `verdifax-verify --signoff-history FILE` reads, and `exportAccountRuns` carries the same chain as `signoffs`. The SDK returns `signoffs` as the server sent it, without checking it: check it with `verdifax-verify --signoff-history`.
342
+
343
+ A sign-off shows that the holder of a registered approver key signed that decision on that exact result, and that Verdifax logged it on the public log. It does not show that the review was careful, that the result is right, or who the person is. A key is not an identity.
344
+
345
+ A sign-off gates nothing on its own.
346
+
347
+ A later sign-off on the same result supersedes the earlier one, and nothing is deleted.
348
+
349
+ Verdifax did not review the result.
350
+
351
+ Since v0.22.0, the verifier reads sign-offs with `--signoff` and `--signoff-history`.
352
+
296
353
  ## Evidence export
297
354
 
298
355
  `exportRuns` lists the runs the server holds for your key in a period, failures included, with up to 10,000 rows per export. `exportRunsCSV` returns the same rows as raw CSV text.
package/dist/index.d.mts CHANGED
@@ -569,6 +569,11 @@ interface RunsExport {
569
569
  * key in the account, with the account's policy documents and changes. */
570
570
  interface AccountRunsExport extends RunsExport {
571
571
  account_id: string;
572
+ /** The account's whole chain of sign-offs, oldest first, as
573
+ * {@link VerdifaxClient.getSignOffHistory} returns it. Only the owner
574
+ * key's export carries it, and the SDK returns it as the server sent
575
+ * it, without checking it: check it with `verdifax-verify`. */
576
+ signoffs?: SignOffRecord[];
572
577
  }
573
578
  /** Returned by {@link VerdifaxClient.exportRunsCSV}. */
574
579
  interface RunsExportCSV {
@@ -755,6 +760,127 @@ interface AdminAPIKey {
755
760
  run_count: number;
756
761
  revoked: boolean;
757
762
  }
763
+ /** The kind of result a sign-off names: a completed run's record, a
764
+ * refused or halted run's receipt, or a whole job by its final step. */
765
+ type SignOffTargetKind = "record" | "receipt" | "job";
766
+ /** An approver's decision on one result. */
767
+ type SignOffDecision = "approve" | "reject";
768
+ /** The exact result a sign-off names, by what its public-log entry
769
+ * covers. */
770
+ interface SignOffTarget {
771
+ kind: SignOffTargetKind;
772
+ /** The run: for a job, the run of its final step. */
773
+ run_id: number;
774
+ /** What the result's public-log entry covers. For a record, its
775
+ * `bundle_anchor.anchored_digest` in the bundle
776
+ * {@link VerdifaxClient.getRunArtifacts} returns. For a receipt, its
777
+ * hash. For a job, the final step's record digest, read the same way
778
+ * as a record's. 64 lowercase hex characters. */
779
+ digest: Hex64;
780
+ /** The job, for a job target only. */
781
+ job_id?: string;
782
+ }
783
+ /** One sign-off, verdifax.signoff.v1, as the server records it and
784
+ * `verdifax-verify --signoff` and `--signoff-history` read it. */
785
+ interface SignOffRecord {
786
+ format: string;
787
+ /** The account's chain, `account-N`. */
788
+ chain_id: string;
789
+ sequence: number;
790
+ /** The sign-off this one follows, or 64 zeros for the account's first. */
791
+ previous_signoff_hash: Hex64;
792
+ target: SignOffTarget;
793
+ decision: SignOffDecision;
794
+ /** SHA-256 of the approver's note, when there was one. The note's text
795
+ * never reaches Verdifax. */
796
+ note_hash?: string;
797
+ /** The base64 Ed25519 public key of the approver who signed. */
798
+ approver_key: string;
799
+ /** Base64 Ed25519 signature by the approver key. */
800
+ signature: string;
801
+ signed_at: string;
802
+ nonce: string;
803
+ recorded_at: string;
804
+ signoff_hash: Hex64;
805
+ /** Where the sign-off was logged on the public log. */
806
+ anchor?: Record<string, unknown>;
807
+ }
808
+ /** What an approver signs against: the account's chain and its newest
809
+ * sign-off. Returned by {@link VerdifaxClient.getNewestSignOff}. */
810
+ interface NewestSignOff {
811
+ ok: boolean;
812
+ /** The account's chain, `account-N`. */
813
+ chain_id: string;
814
+ /** The newest sign-off's hash, or 64 zeros when there is none yet. */
815
+ newest_signoff_hash: Hex64;
816
+ /** The newest sign-off's place in the chain, 0 when there is none. */
817
+ sequence: number;
818
+ }
819
+ /** The approver key's part of a sign-off. */
820
+ interface SignOffSignature {
821
+ /** The base64 Ed25519 public key of the approver. */
822
+ approver_key: string;
823
+ /** Base64 Ed25519 signature by the approver key. */
824
+ signature: string;
825
+ /** RFC 3339 UTC with whole seconds. */
826
+ signed_at: string;
827
+ /** 16 random bytes as 32 lowercase hex characters, fresh per sign-off. */
828
+ nonce: string;
829
+ }
830
+ /** Options for {@link VerdifaxClient.signOff}. */
831
+ interface SignOffOptions {
832
+ /** The approver's note. It is hashed on this machine and only its
833
+ * SHA-256 is sent and signed: the text never reaches Verdifax. Keep the
834
+ * text yourself to show it matches the hash. */
835
+ note?: string;
836
+ /** Use the owner key's routes, /account/signoffs/newest and
837
+ * /account/signoffs, instead of a member key's. */
838
+ asOwner?: boolean;
839
+ }
840
+ /** For a method that serves both a member key's route and the owner
841
+ * key's /account route. */
842
+ interface AsOwnerOptions {
843
+ /** Use the owner key's /account route. */
844
+ asOwner?: boolean;
845
+ }
846
+ /** An approver key's public key and the proof that its holder has the
847
+ * private key, from {@link approverKeyProof}. */
848
+ interface ApproverKeyProof {
849
+ /** Base64 of the raw 32-byte public key. */
850
+ public_key: string;
851
+ /** Base64 Ed25519 signature over verdifax.approver-key.v1, a zero byte
852
+ * and the base64 public key. */
853
+ proof: string;
854
+ }
855
+ /** An approver key registered to an account. A key is not an identity:
856
+ * `name` is the label the owner gave it. */
857
+ interface Approver {
858
+ id: number;
859
+ public_key: string;
860
+ name: string;
861
+ /** The account, `account-N`. */
862
+ account_id: string;
863
+ registered_at: string;
864
+ revoked: boolean;
865
+ revoked_at?: string;
866
+ }
867
+ /** Returned by {@link VerdifaxClient.registerApprover}. */
868
+ interface ApproverRegistration extends Approver {
869
+ ok: boolean;
870
+ }
871
+ /** Returned by {@link VerdifaxClient.listApprovers}: revoked keys
872
+ * included. */
873
+ interface ApproverList {
874
+ ok: boolean;
875
+ account_id: string;
876
+ approvers: Approver[];
877
+ }
878
+ /** Returned by {@link VerdifaxClient.revokeApprover}. */
879
+ interface ApproverRevocation {
880
+ ok: boolean;
881
+ id: number;
882
+ revoked: boolean;
883
+ }
758
884
 
759
885
  /**
760
886
  * The job helper: runs the steps of one job and keeps the map of step ids
@@ -869,6 +995,13 @@ declare const MAX_BATCH_ROWS = 1000;
869
995
  * | `testAccountPolicy(req)` | POST /account/policies/test | owner key |
870
996
  * | `getAccountPolicyHistory()` | GET /account/policies/history | owner key |
871
997
  * | `activateAccountPolicy(documentHash)` | POST /account/policies/{document_hash}/activate | owner key |
998
+ * | `registerApprover(name, approver)` | POST /account/approvers | owner key |
999
+ * | `listApprovers()` | GET /account/approvers | owner key |
1000
+ * | `revokeApprover(id)` | DELETE /account/approvers/{id} | owner key |
1001
+ * | `signOff(target, decision, approverKey, options)` | POST /signoffs, or POST /account/signoffs with asOwner | required |
1002
+ * | `getNewestSignOff(options)` | GET /signoffs/newest, or GET /account/signoffs/newest with asOwner | required |
1003
+ * | `getSignOffs(runId, options)` | GET /runs/{id}/signoffs, or GET /account/runs/{id}/signoffs with asOwner | required |
1004
+ * | `getSignOffHistory()` | GET /account/signoffs/history | owner key |
872
1005
  * | `job(jobId)` | none, returns a {@link VerdifaxJob} | none |
873
1006
  *
874
1007
  * With the `signingKey` option set, every path to POST /execute carries
@@ -1037,6 +1170,56 @@ declare class VerdifaxClient {
1037
1170
  * `account-N`. The server refuses an unsigned account change, so this
1038
1171
  * needs the `signingKey` option and refuses to send without it. */
1039
1172
  activateAccountPolicy(documentHash: Hex64): Promise<PolicyActivation>;
1173
+ /** Registers an approver key to the owner's account. `approver` is the
1174
+ * approver's signing key, from which the proof is made on this machine,
1175
+ * or the `{ public_key, proof }` that {@link approverKeyProof} made on
1176
+ * the approver's own machine, so the owner need not hold the private
1177
+ * key. A proof that does not verify is refused before sending. The
1178
+ * server refuses a key that is a request key in the same account.
1179
+ * `name` is a label, not an identity. */
1180
+ registerApprover(name: string, approver: Uint8Array | KeyObject | ApproverKeyProof): Promise<ApproverRegistration>;
1181
+ /** The account's approver keys, revoked ones included. */
1182
+ listApprovers(): Promise<ApproverList>;
1183
+ /** Revokes one of the account's approver keys. Its earlier sign-offs
1184
+ * stay on the record. A key that is not an active approver in the
1185
+ * account throws {@link NotFoundError}. */
1186
+ revokeApprover(approverId: number): Promise<ApproverRevocation>;
1187
+ /** What an approver signs against: the account's chain `account-N` and
1188
+ * its newest sign-off (64 zeros when there is none yet). With
1189
+ * `asOwner` it reads the owner key's route. */
1190
+ getNewestSignOff(options?: AsOwnerOptions): Promise<NewestSignOff>;
1191
+ /** Signs an approver's decision, approve or reject, on one exact result
1192
+ * and submits it. It first reads {@link getNewestSignOff} for the
1193
+ * account's chain and its newest sign-off, then signs over them with
1194
+ * `approverKey` on this machine. The approver's private key is never
1195
+ * sent.
1196
+ *
1197
+ * `target.digest` is what the result's public-log entry covers: for a
1198
+ * record, the `bundle_anchor.anchored_digest` of the bundle
1199
+ * {@link getRunArtifacts} returns; for a receipt, its hash; for a job,
1200
+ * the final step's record digest, read as a record's, with `job_id`.
1201
+ *
1202
+ * `note` is hashed here and only its SHA-256 is sent and signed: the
1203
+ * text never reaches Verdifax. With `asOwner` it uses the owner key's
1204
+ * routes. Bad input is refused before anything is sent.
1205
+ *
1206
+ * The record the server returns must carry the signature,
1207
+ * approver_key, decision, previous_signoff_hash and target that were
1208
+ * sent, or this throws {@link VerdifaxError}.
1209
+ *
1210
+ * A later sign-off on the same result supersedes this one, and neither
1211
+ * is deleted. An HTTP 409 means the chain moved or the target is not
1212
+ * this account's result: it throws the usual {@link APIError} and is
1213
+ * not retried. */
1214
+ signOff(target: SignOffTarget, decision: SignOffDecision, approverKey: Uint8Array | KeyObject, options?: SignOffOptions): Promise<SignOffRecord>;
1215
+ /** The sign-offs on one run, oldest first, in the form
1216
+ * `verdifax-verify --signoff FILE` reads: for a member key, a run it
1217
+ * made; with `asOwner`, any run of the account. The latest is the
1218
+ * decision in force. */
1219
+ getSignOffs(runId: number, options?: AsOwnerOptions): Promise<SignOffRecord[]>;
1220
+ /** The account's whole chain of sign-offs, oldest first, in the form
1221
+ * `verdifax-verify --signoff-history FILE` reads. Needs the owner key. */
1222
+ getSignOffHistory(): Promise<SignOffRecord[]>;
1040
1223
  getRun(runId: number): Promise<Run>;
1041
1224
  verifyRun(runId: number): Promise<VerifyResponse>;
1042
1225
  listRuns(params?: ListRunsParams): Promise<{
@@ -1349,6 +1532,29 @@ declare function policyActivationApproval(signingKey: Uint8Array | KeyObject, fi
1349
1532
  signedAt?: string;
1350
1533
  nonce?: string;
1351
1534
  }): PolicyActivationSignature;
1535
+ /** The bytes an approver key signs for one sign-off, matching the
1536
+ * orchestrator's SignOffPreimage: the fields joined with single zero
1537
+ * bytes. `chain_id` and `previous_signoff_hash` come from GET
1538
+ * /signoffs/newest (or /account/signoffs/newest for the owner key), so a
1539
+ * signature cannot be moved to another account, another result or
1540
+ * another point in the chain. `target.job_id` is empty unless the target
1541
+ * is a job, and `note_hash` is empty when there is no note. */
1542
+ declare function signOffPreimage(fields: {
1543
+ chain_id: string;
1544
+ previous_signoff_hash: string;
1545
+ target: SignOffTarget;
1546
+ decision: SignOffDecision;
1547
+ note_hash: string;
1548
+ signed_at: string;
1549
+ nonce: string;
1550
+ }): Buffer;
1551
+ /** The approver key's base64 public key and its proof: its signature over
1552
+ * verdifax.approver-key.v1, a zero byte and the base64 public key, which
1553
+ * shows its holder has the private key. The approver can run this on
1554
+ * their own machine and give the result to the account's owner, who
1555
+ * registers it with {@link VerdifaxClient.registerApprover}, so the owner
1556
+ * never holds the approver's private key. */
1557
+ declare function approverKeyProof(approverKey: Uint8Array | KeyObject): ApproverKeyProof;
1352
1558
 
1353
1559
  /**
1354
1560
  * Input-validation helpers, mirrors the Python SDK's
@@ -1434,4 +1640,4 @@ declare class APIKeyRequiredError extends VerdifaxError {
1434
1640
  constructor();
1435
1641
  }
1436
1642
 
1437
- export { APIError, APIKeyRequiredError, type Account, type AccountInfo, type AccountKey, type AccountKeyList, type AccountKeyRevocation, type AccountRunsExport, type AdminAPIKey, type AdminCreateAccountOptions, type AdminCreateAccountResponse, type AdminCreateKeyOptions, type AdminCreateKeyResponse, type AdminEraseRequest, type AttestRequest, type AttestationList, AuthError, CRES_ENGINE_VERSION, CRES_PREIMAGE_VERSION, type ClientConfig, type CreateAccountKeyOptions, type CreatedAccountKey, type DCAEProofBundle, type DCAEVerdict, DCAE_PREIMAGE_VERSION, DSE_FORMULA_VERSION, type DeletionReceipt, type DeterminismDiff, type DeterminismRun, EXECUTE_FIELDS, type ExecuteBatchResponse, type ExecuteBatchResult, type ExecuteRequest, type ExecuteResponse, type ExecutionManifest, type ExportRow, type ExportRunsParams, type FormulaEra, type Health, type Hex64, type JobLink, type JobStepOptions, type KeyTier, type ListAttestationsParams, type ListRunsParams, MAX_BATCH_ROWS, NotFoundError, type PDFFormat, type Policy, type PolicyActivation, type PolicyActivationSignature, type PolicyActorType, type PolicyClassification, type PolicyContext, type PolicyList, type PolicyObjectType, type PolicyTestRequest, type PolicyTestResult, type PolicyWithDocument, RUN_STATUSES, type ReproducibilityContext, type RequestKeyChange, type RequestKeyPair, type RequestKeyRegistration, type RequestKeyRegistrationBody, type RequestSignature, type RequestSignatureV2, type Run, type RunStats, type RunStatus, type RunSummary, type RunsExport, type RunsExportCSV, type SearchRunsParams, type SignedRequestBody, VerdifaxClient, VerdifaxError, VerdifaxJob, type VerdifaxVersion, type VerifyDeterminismResponse, type VerifyResponse, captureEnvironment, cresReceiptHash, dcaeClosureHash, dseDispatchHash, formatProviderPayload, fromEnv, generateRequestKeyPair, policyActivationApproval, policyActivationPreimage, requestKeyChangeApproval, requestKeyChangePreimage, requestKeyRegistrationBody, requestSignaturePreimage, requestSignaturePreimageV2, signRequest, signRequestV2, validateHex64, validateJobName, validateRouteId };
1643
+ export { APIError, APIKeyRequiredError, type Account, type AccountInfo, type AccountKey, type AccountKeyList, type AccountKeyRevocation, type AccountRunsExport, type AdminAPIKey, type AdminCreateAccountOptions, type AdminCreateAccountResponse, type AdminCreateKeyOptions, type AdminCreateKeyResponse, type AdminEraseRequest, type Approver, type ApproverKeyProof, type ApproverList, type ApproverRegistration, type ApproverRevocation, type AsOwnerOptions, type AttestRequest, type AttestationList, AuthError, CRES_ENGINE_VERSION, CRES_PREIMAGE_VERSION, type ClientConfig, type CreateAccountKeyOptions, type CreatedAccountKey, type DCAEProofBundle, type DCAEVerdict, DCAE_PREIMAGE_VERSION, DSE_FORMULA_VERSION, type DeletionReceipt, type DeterminismDiff, type DeterminismRun, EXECUTE_FIELDS, type ExecuteBatchResponse, type ExecuteBatchResult, type ExecuteRequest, type ExecuteResponse, type ExecutionManifest, type ExportRow, type ExportRunsParams, type FormulaEra, type Health, type Hex64, type JobLink, type JobStepOptions, type KeyTier, type ListAttestationsParams, type ListRunsParams, MAX_BATCH_ROWS, type NewestSignOff, NotFoundError, type PDFFormat, type Policy, type PolicyActivation, type PolicyActivationSignature, type PolicyActorType, type PolicyClassification, type PolicyContext, type PolicyList, type PolicyObjectType, type PolicyTestRequest, type PolicyTestResult, type PolicyWithDocument, RUN_STATUSES, type ReproducibilityContext, type RequestKeyChange, type RequestKeyPair, type RequestKeyRegistration, type RequestKeyRegistrationBody, type RequestSignature, type RequestSignatureV2, type Run, type RunStats, type RunStatus, type RunSummary, type RunsExport, type RunsExportCSV, type SearchRunsParams, type SignOffDecision, type SignOffOptions, type SignOffRecord, type SignOffSignature, type SignOffTarget, type SignOffTargetKind, type SignedRequestBody, VerdifaxClient, VerdifaxError, VerdifaxJob, type VerdifaxVersion, type VerifyDeterminismResponse, type VerifyResponse, approverKeyProof, captureEnvironment, cresReceiptHash, dcaeClosureHash, dseDispatchHash, formatProviderPayload, fromEnv, generateRequestKeyPair, policyActivationApproval, policyActivationPreimage, requestKeyChangeApproval, requestKeyChangePreimage, requestKeyRegistrationBody, requestSignaturePreimage, requestSignaturePreimageV2, signOffPreimage, signRequest, signRequestV2, validateHex64, validateJobName, validateRouteId };
package/dist/index.d.ts CHANGED
@@ -569,6 +569,11 @@ interface RunsExport {
569
569
  * key in the account, with the account's policy documents and changes. */
570
570
  interface AccountRunsExport extends RunsExport {
571
571
  account_id: string;
572
+ /** The account's whole chain of sign-offs, oldest first, as
573
+ * {@link VerdifaxClient.getSignOffHistory} returns it. Only the owner
574
+ * key's export carries it, and the SDK returns it as the server sent
575
+ * it, without checking it: check it with `verdifax-verify`. */
576
+ signoffs?: SignOffRecord[];
572
577
  }
573
578
  /** Returned by {@link VerdifaxClient.exportRunsCSV}. */
574
579
  interface RunsExportCSV {
@@ -755,6 +760,127 @@ interface AdminAPIKey {
755
760
  run_count: number;
756
761
  revoked: boolean;
757
762
  }
763
+ /** The kind of result a sign-off names: a completed run's record, a
764
+ * refused or halted run's receipt, or a whole job by its final step. */
765
+ type SignOffTargetKind = "record" | "receipt" | "job";
766
+ /** An approver's decision on one result. */
767
+ type SignOffDecision = "approve" | "reject";
768
+ /** The exact result a sign-off names, by what its public-log entry
769
+ * covers. */
770
+ interface SignOffTarget {
771
+ kind: SignOffTargetKind;
772
+ /** The run: for a job, the run of its final step. */
773
+ run_id: number;
774
+ /** What the result's public-log entry covers. For a record, its
775
+ * `bundle_anchor.anchored_digest` in the bundle
776
+ * {@link VerdifaxClient.getRunArtifacts} returns. For a receipt, its
777
+ * hash. For a job, the final step's record digest, read the same way
778
+ * as a record's. 64 lowercase hex characters. */
779
+ digest: Hex64;
780
+ /** The job, for a job target only. */
781
+ job_id?: string;
782
+ }
783
+ /** One sign-off, verdifax.signoff.v1, as the server records it and
784
+ * `verdifax-verify --signoff` and `--signoff-history` read it. */
785
+ interface SignOffRecord {
786
+ format: string;
787
+ /** The account's chain, `account-N`. */
788
+ chain_id: string;
789
+ sequence: number;
790
+ /** The sign-off this one follows, or 64 zeros for the account's first. */
791
+ previous_signoff_hash: Hex64;
792
+ target: SignOffTarget;
793
+ decision: SignOffDecision;
794
+ /** SHA-256 of the approver's note, when there was one. The note's text
795
+ * never reaches Verdifax. */
796
+ note_hash?: string;
797
+ /** The base64 Ed25519 public key of the approver who signed. */
798
+ approver_key: string;
799
+ /** Base64 Ed25519 signature by the approver key. */
800
+ signature: string;
801
+ signed_at: string;
802
+ nonce: string;
803
+ recorded_at: string;
804
+ signoff_hash: Hex64;
805
+ /** Where the sign-off was logged on the public log. */
806
+ anchor?: Record<string, unknown>;
807
+ }
808
+ /** What an approver signs against: the account's chain and its newest
809
+ * sign-off. Returned by {@link VerdifaxClient.getNewestSignOff}. */
810
+ interface NewestSignOff {
811
+ ok: boolean;
812
+ /** The account's chain, `account-N`. */
813
+ chain_id: string;
814
+ /** The newest sign-off's hash, or 64 zeros when there is none yet. */
815
+ newest_signoff_hash: Hex64;
816
+ /** The newest sign-off's place in the chain, 0 when there is none. */
817
+ sequence: number;
818
+ }
819
+ /** The approver key's part of a sign-off. */
820
+ interface SignOffSignature {
821
+ /** The base64 Ed25519 public key of the approver. */
822
+ approver_key: string;
823
+ /** Base64 Ed25519 signature by the approver key. */
824
+ signature: string;
825
+ /** RFC 3339 UTC with whole seconds. */
826
+ signed_at: string;
827
+ /** 16 random bytes as 32 lowercase hex characters, fresh per sign-off. */
828
+ nonce: string;
829
+ }
830
+ /** Options for {@link VerdifaxClient.signOff}. */
831
+ interface SignOffOptions {
832
+ /** The approver's note. It is hashed on this machine and only its
833
+ * SHA-256 is sent and signed: the text never reaches Verdifax. Keep the
834
+ * text yourself to show it matches the hash. */
835
+ note?: string;
836
+ /** Use the owner key's routes, /account/signoffs/newest and
837
+ * /account/signoffs, instead of a member key's. */
838
+ asOwner?: boolean;
839
+ }
840
+ /** For a method that serves both a member key's route and the owner
841
+ * key's /account route. */
842
+ interface AsOwnerOptions {
843
+ /** Use the owner key's /account route. */
844
+ asOwner?: boolean;
845
+ }
846
+ /** An approver key's public key and the proof that its holder has the
847
+ * private key, from {@link approverKeyProof}. */
848
+ interface ApproverKeyProof {
849
+ /** Base64 of the raw 32-byte public key. */
850
+ public_key: string;
851
+ /** Base64 Ed25519 signature over verdifax.approver-key.v1, a zero byte
852
+ * and the base64 public key. */
853
+ proof: string;
854
+ }
855
+ /** An approver key registered to an account. A key is not an identity:
856
+ * `name` is the label the owner gave it. */
857
+ interface Approver {
858
+ id: number;
859
+ public_key: string;
860
+ name: string;
861
+ /** The account, `account-N`. */
862
+ account_id: string;
863
+ registered_at: string;
864
+ revoked: boolean;
865
+ revoked_at?: string;
866
+ }
867
+ /** Returned by {@link VerdifaxClient.registerApprover}. */
868
+ interface ApproverRegistration extends Approver {
869
+ ok: boolean;
870
+ }
871
+ /** Returned by {@link VerdifaxClient.listApprovers}: revoked keys
872
+ * included. */
873
+ interface ApproverList {
874
+ ok: boolean;
875
+ account_id: string;
876
+ approvers: Approver[];
877
+ }
878
+ /** Returned by {@link VerdifaxClient.revokeApprover}. */
879
+ interface ApproverRevocation {
880
+ ok: boolean;
881
+ id: number;
882
+ revoked: boolean;
883
+ }
758
884
 
759
885
  /**
760
886
  * The job helper: runs the steps of one job and keeps the map of step ids
@@ -869,6 +995,13 @@ declare const MAX_BATCH_ROWS = 1000;
869
995
  * | `testAccountPolicy(req)` | POST /account/policies/test | owner key |
870
996
  * | `getAccountPolicyHistory()` | GET /account/policies/history | owner key |
871
997
  * | `activateAccountPolicy(documentHash)` | POST /account/policies/{document_hash}/activate | owner key |
998
+ * | `registerApprover(name, approver)` | POST /account/approvers | owner key |
999
+ * | `listApprovers()` | GET /account/approvers | owner key |
1000
+ * | `revokeApprover(id)` | DELETE /account/approvers/{id} | owner key |
1001
+ * | `signOff(target, decision, approverKey, options)` | POST /signoffs, or POST /account/signoffs with asOwner | required |
1002
+ * | `getNewestSignOff(options)` | GET /signoffs/newest, or GET /account/signoffs/newest with asOwner | required |
1003
+ * | `getSignOffs(runId, options)` | GET /runs/{id}/signoffs, or GET /account/runs/{id}/signoffs with asOwner | required |
1004
+ * | `getSignOffHistory()` | GET /account/signoffs/history | owner key |
872
1005
  * | `job(jobId)` | none, returns a {@link VerdifaxJob} | none |
873
1006
  *
874
1007
  * With the `signingKey` option set, every path to POST /execute carries
@@ -1037,6 +1170,56 @@ declare class VerdifaxClient {
1037
1170
  * `account-N`. The server refuses an unsigned account change, so this
1038
1171
  * needs the `signingKey` option and refuses to send without it. */
1039
1172
  activateAccountPolicy(documentHash: Hex64): Promise<PolicyActivation>;
1173
+ /** Registers an approver key to the owner's account. `approver` is the
1174
+ * approver's signing key, from which the proof is made on this machine,
1175
+ * or the `{ public_key, proof }` that {@link approverKeyProof} made on
1176
+ * the approver's own machine, so the owner need not hold the private
1177
+ * key. A proof that does not verify is refused before sending. The
1178
+ * server refuses a key that is a request key in the same account.
1179
+ * `name` is a label, not an identity. */
1180
+ registerApprover(name: string, approver: Uint8Array | KeyObject | ApproverKeyProof): Promise<ApproverRegistration>;
1181
+ /** The account's approver keys, revoked ones included. */
1182
+ listApprovers(): Promise<ApproverList>;
1183
+ /** Revokes one of the account's approver keys. Its earlier sign-offs
1184
+ * stay on the record. A key that is not an active approver in the
1185
+ * account throws {@link NotFoundError}. */
1186
+ revokeApprover(approverId: number): Promise<ApproverRevocation>;
1187
+ /** What an approver signs against: the account's chain `account-N` and
1188
+ * its newest sign-off (64 zeros when there is none yet). With
1189
+ * `asOwner` it reads the owner key's route. */
1190
+ getNewestSignOff(options?: AsOwnerOptions): Promise<NewestSignOff>;
1191
+ /** Signs an approver's decision, approve or reject, on one exact result
1192
+ * and submits it. It first reads {@link getNewestSignOff} for the
1193
+ * account's chain and its newest sign-off, then signs over them with
1194
+ * `approverKey` on this machine. The approver's private key is never
1195
+ * sent.
1196
+ *
1197
+ * `target.digest` is what the result's public-log entry covers: for a
1198
+ * record, the `bundle_anchor.anchored_digest` of the bundle
1199
+ * {@link getRunArtifacts} returns; for a receipt, its hash; for a job,
1200
+ * the final step's record digest, read as a record's, with `job_id`.
1201
+ *
1202
+ * `note` is hashed here and only its SHA-256 is sent and signed: the
1203
+ * text never reaches Verdifax. With `asOwner` it uses the owner key's
1204
+ * routes. Bad input is refused before anything is sent.
1205
+ *
1206
+ * The record the server returns must carry the signature,
1207
+ * approver_key, decision, previous_signoff_hash and target that were
1208
+ * sent, or this throws {@link VerdifaxError}.
1209
+ *
1210
+ * A later sign-off on the same result supersedes this one, and neither
1211
+ * is deleted. An HTTP 409 means the chain moved or the target is not
1212
+ * this account's result: it throws the usual {@link APIError} and is
1213
+ * not retried. */
1214
+ signOff(target: SignOffTarget, decision: SignOffDecision, approverKey: Uint8Array | KeyObject, options?: SignOffOptions): Promise<SignOffRecord>;
1215
+ /** The sign-offs on one run, oldest first, in the form
1216
+ * `verdifax-verify --signoff FILE` reads: for a member key, a run it
1217
+ * made; with `asOwner`, any run of the account. The latest is the
1218
+ * decision in force. */
1219
+ getSignOffs(runId: number, options?: AsOwnerOptions): Promise<SignOffRecord[]>;
1220
+ /** The account's whole chain of sign-offs, oldest first, in the form
1221
+ * `verdifax-verify --signoff-history FILE` reads. Needs the owner key. */
1222
+ getSignOffHistory(): Promise<SignOffRecord[]>;
1040
1223
  getRun(runId: number): Promise<Run>;
1041
1224
  verifyRun(runId: number): Promise<VerifyResponse>;
1042
1225
  listRuns(params?: ListRunsParams): Promise<{
@@ -1349,6 +1532,29 @@ declare function policyActivationApproval(signingKey: Uint8Array | KeyObject, fi
1349
1532
  signedAt?: string;
1350
1533
  nonce?: string;
1351
1534
  }): PolicyActivationSignature;
1535
+ /** The bytes an approver key signs for one sign-off, matching the
1536
+ * orchestrator's SignOffPreimage: the fields joined with single zero
1537
+ * bytes. `chain_id` and `previous_signoff_hash` come from GET
1538
+ * /signoffs/newest (or /account/signoffs/newest for the owner key), so a
1539
+ * signature cannot be moved to another account, another result or
1540
+ * another point in the chain. `target.job_id` is empty unless the target
1541
+ * is a job, and `note_hash` is empty when there is no note. */
1542
+ declare function signOffPreimage(fields: {
1543
+ chain_id: string;
1544
+ previous_signoff_hash: string;
1545
+ target: SignOffTarget;
1546
+ decision: SignOffDecision;
1547
+ note_hash: string;
1548
+ signed_at: string;
1549
+ nonce: string;
1550
+ }): Buffer;
1551
+ /** The approver key's base64 public key and its proof: its signature over
1552
+ * verdifax.approver-key.v1, a zero byte and the base64 public key, which
1553
+ * shows its holder has the private key. The approver can run this on
1554
+ * their own machine and give the result to the account's owner, who
1555
+ * registers it with {@link VerdifaxClient.registerApprover}, so the owner
1556
+ * never holds the approver's private key. */
1557
+ declare function approverKeyProof(approverKey: Uint8Array | KeyObject): ApproverKeyProof;
1352
1558
 
1353
1559
  /**
1354
1560
  * Input-validation helpers, mirrors the Python SDK's
@@ -1434,4 +1640,4 @@ declare class APIKeyRequiredError extends VerdifaxError {
1434
1640
  constructor();
1435
1641
  }
1436
1642
 
1437
- export { APIError, APIKeyRequiredError, type Account, type AccountInfo, type AccountKey, type AccountKeyList, type AccountKeyRevocation, type AccountRunsExport, type AdminAPIKey, type AdminCreateAccountOptions, type AdminCreateAccountResponse, type AdminCreateKeyOptions, type AdminCreateKeyResponse, type AdminEraseRequest, type AttestRequest, type AttestationList, AuthError, CRES_ENGINE_VERSION, CRES_PREIMAGE_VERSION, type ClientConfig, type CreateAccountKeyOptions, type CreatedAccountKey, type DCAEProofBundle, type DCAEVerdict, DCAE_PREIMAGE_VERSION, DSE_FORMULA_VERSION, type DeletionReceipt, type DeterminismDiff, type DeterminismRun, EXECUTE_FIELDS, type ExecuteBatchResponse, type ExecuteBatchResult, type ExecuteRequest, type ExecuteResponse, type ExecutionManifest, type ExportRow, type ExportRunsParams, type FormulaEra, type Health, type Hex64, type JobLink, type JobStepOptions, type KeyTier, type ListAttestationsParams, type ListRunsParams, MAX_BATCH_ROWS, NotFoundError, type PDFFormat, type Policy, type PolicyActivation, type PolicyActivationSignature, type PolicyActorType, type PolicyClassification, type PolicyContext, type PolicyList, type PolicyObjectType, type PolicyTestRequest, type PolicyTestResult, type PolicyWithDocument, RUN_STATUSES, type ReproducibilityContext, type RequestKeyChange, type RequestKeyPair, type RequestKeyRegistration, type RequestKeyRegistrationBody, type RequestSignature, type RequestSignatureV2, type Run, type RunStats, type RunStatus, type RunSummary, type RunsExport, type RunsExportCSV, type SearchRunsParams, type SignedRequestBody, VerdifaxClient, VerdifaxError, VerdifaxJob, type VerdifaxVersion, type VerifyDeterminismResponse, type VerifyResponse, captureEnvironment, cresReceiptHash, dcaeClosureHash, dseDispatchHash, formatProviderPayload, fromEnv, generateRequestKeyPair, policyActivationApproval, policyActivationPreimage, requestKeyChangeApproval, requestKeyChangePreimage, requestKeyRegistrationBody, requestSignaturePreimage, requestSignaturePreimageV2, signRequest, signRequestV2, validateHex64, validateJobName, validateRouteId };
1643
+ export { APIError, APIKeyRequiredError, type Account, type AccountInfo, type AccountKey, type AccountKeyList, type AccountKeyRevocation, type AccountRunsExport, type AdminAPIKey, type AdminCreateAccountOptions, type AdminCreateAccountResponse, type AdminCreateKeyOptions, type AdminCreateKeyResponse, type AdminEraseRequest, type Approver, type ApproverKeyProof, type ApproverList, type ApproverRegistration, type ApproverRevocation, type AsOwnerOptions, type AttestRequest, type AttestationList, AuthError, CRES_ENGINE_VERSION, CRES_PREIMAGE_VERSION, type ClientConfig, type CreateAccountKeyOptions, type CreatedAccountKey, type DCAEProofBundle, type DCAEVerdict, DCAE_PREIMAGE_VERSION, DSE_FORMULA_VERSION, type DeletionReceipt, type DeterminismDiff, type DeterminismRun, EXECUTE_FIELDS, type ExecuteBatchResponse, type ExecuteBatchResult, type ExecuteRequest, type ExecuteResponse, type ExecutionManifest, type ExportRow, type ExportRunsParams, type FormulaEra, type Health, type Hex64, type JobLink, type JobStepOptions, type KeyTier, type ListAttestationsParams, type ListRunsParams, MAX_BATCH_ROWS, type NewestSignOff, NotFoundError, type PDFFormat, type Policy, type PolicyActivation, type PolicyActivationSignature, type PolicyActorType, type PolicyClassification, type PolicyContext, type PolicyList, type PolicyObjectType, type PolicyTestRequest, type PolicyTestResult, type PolicyWithDocument, RUN_STATUSES, type ReproducibilityContext, type RequestKeyChange, type RequestKeyPair, type RequestKeyRegistration, type RequestKeyRegistrationBody, type RequestSignature, type RequestSignatureV2, type Run, type RunStats, type RunStatus, type RunSummary, type RunsExport, type RunsExportCSV, type SearchRunsParams, type SignOffDecision, type SignOffOptions, type SignOffRecord, type SignOffSignature, type SignOffTarget, type SignOffTargetKind, type SignedRequestBody, VerdifaxClient, VerdifaxError, VerdifaxJob, type VerdifaxVersion, type VerifyDeterminismResponse, type VerifyResponse, approverKeyProof, captureEnvironment, cresReceiptHash, dcaeClosureHash, dseDispatchHash, formatProviderPayload, fromEnv, generateRequestKeyPair, policyActivationApproval, policyActivationPreimage, requestKeyChangeApproval, requestKeyChangePreimage, requestKeyRegistrationBody, requestSignaturePreimage, requestSignaturePreimageV2, signOffPreimage, signRequest, signRequestV2, validateHex64, validateJobName, validateRouteId };
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ __export(index_exports, {
34
34
  VerdifaxClient: () => VerdifaxClient,
35
35
  VerdifaxError: () => VerdifaxError,
36
36
  VerdifaxJob: () => VerdifaxJob,
37
+ approverKeyProof: () => approverKeyProof,
37
38
  captureEnvironment: () => captureEnvironment,
38
39
  cresReceiptHash: () => cresReceiptHash,
39
40
  dcaeClosureHash: () => dcaeClosureHash,
@@ -48,6 +49,7 @@ __export(index_exports, {
48
49
  requestKeyRegistrationBody: () => requestKeyRegistrationBody,
49
50
  requestSignaturePreimage: () => requestSignaturePreimage,
50
51
  requestSignaturePreimageV2: () => requestSignaturePreimageV2,
52
+ signOffPreimage: () => signOffPreimage,
51
53
  signRequest: () => signRequest,
52
54
  signRequestV2: () => signRequestV2,
53
55
  validateHex64: () => validateHex64,
@@ -389,7 +391,10 @@ var JOB_FORM = "verdifax.job.v1";
389
391
  var REQUEST_KEY_PROOF_FORM = "verdifax.request-key.v1";
390
392
  var REQUEST_KEY_CHANGE_FORM = "verdifax.request-key-change.v1";
391
393
  var POLICY_ACTIVATION_FORM = "verdifax.policy-activation.v1";
394
+ var SIGNOFF_FORM = "verdifax.signoff.v1";
395
+ var APPROVER_KEY_PROOF_FORM = "verdifax.approver-key.v1";
392
396
  var SIGNING_KEY_BYTES = 32;
397
+ var SPKI_ED25519_HEADER = Buffer.from("302a300506032b6570032100", "hex");
393
398
  var PKCS8_ED25519_HEADER = Buffer.from(
394
399
  "302e020100300506032b657004220420",
395
400
  "hex"
@@ -573,6 +578,71 @@ function policyActivationApproval(signingKey, fields, options = {}) {
573
578
  nonce
574
579
  };
575
580
  }
581
+ function signOffPreimage(fields) {
582
+ const { target } = fields;
583
+ return Buffer.from(
584
+ [
585
+ SIGNOFF_FORM,
586
+ fields.chain_id,
587
+ fields.previous_signoff_hash,
588
+ target.kind,
589
+ String(target.run_id),
590
+ target.digest,
591
+ target.job_id ?? "",
592
+ fields.decision,
593
+ fields.note_hash,
594
+ fields.signed_at,
595
+ fields.nonce
596
+ ].join("\0"),
597
+ "utf8"
598
+ );
599
+ }
600
+ function signOffSignature(approverKey, fields, options = {}) {
601
+ const key = loadSigningKey(approverKey);
602
+ const signed_at = options.signedAt ?? signedAtNow();
603
+ const nonce = options.nonce ?? (0, import_node_crypto.randomBytes)(16).toString("hex");
604
+ const preimage = signOffPreimage({ ...fields, signed_at, nonce });
605
+ return {
606
+ approver_key: rawPublicKey((0, import_node_crypto.createPublicKey)(key)).toString("base64"),
607
+ signature: (0, import_node_crypto.sign)(null, preimage, key).toString("base64"),
608
+ signed_at,
609
+ nonce
610
+ };
611
+ }
612
+ function approverKeyProof(approverKey) {
613
+ const key = loadSigningKey(approverKey);
614
+ const public_key = rawPublicKey((0, import_node_crypto.createPublicKey)(key)).toString("base64");
615
+ const proof = (0, import_node_crypto.sign)(
616
+ null,
617
+ Buffer.from(`${APPROVER_KEY_PROOF_FORM}\0${public_key}`, "utf8"),
618
+ key
619
+ ).toString("base64");
620
+ return { public_key, proof };
621
+ }
622
+ function approverKeyProofVerifies(proof) {
623
+ if (typeof proof?.public_key !== "string" || typeof proof?.proof !== "string") {
624
+ return false;
625
+ }
626
+ const raw = Buffer.from(proof.public_key, "base64");
627
+ const signature = Buffer.from(proof.proof, "base64");
628
+ if (raw.length !== 32 || raw.toString("base64") !== proof.public_key || signature.length !== 64 || signature.toString("base64") !== proof.proof) {
629
+ return false;
630
+ }
631
+ const publicKey = (0, import_node_crypto.createPublicKey)({
632
+ key: Buffer.concat([SPKI_ED25519_HEADER, raw]),
633
+ format: "der",
634
+ type: "spki"
635
+ });
636
+ return (0, import_node_crypto.verify)(
637
+ null,
638
+ Buffer.from(`${APPROVER_KEY_PROOF_FORM}\0${proof.public_key}`, "utf8"),
639
+ publicKey,
640
+ signature
641
+ );
642
+ }
643
+ function publicKeyOf(key) {
644
+ return rawPublicKey((0, import_node_crypto.createPublicKey)(key)).toString("base64");
645
+ }
576
646
 
577
647
  // src/job.ts
578
648
  var STEP_OPTIONS = {
@@ -674,9 +744,10 @@ var VerdifaxJob = class {
674
744
  };
675
745
 
676
746
  // src/version.ts
677
- var SDK_VERSION = "1.3.0";
747
+ var SDK_VERSION = "1.4.0";
678
748
 
679
749
  // src/client.ts
750
+ var import_node_crypto2 = require("crypto");
680
751
  var DEFAULT_BASE_URL = "https://api.verdifax.com";
681
752
  var DEFAULT_TIMEOUT_MS = 9e4;
682
753
  var DEFAULT_MAX_RETRIES = 3;
@@ -689,6 +760,12 @@ var ACCOUNT_PATH = "/account";
689
760
  var ACCOUNT_REQUEST_KEY_PATH = "/account/request-key";
690
761
  var ACCOUNT_POLICIES_PATH = "/account/policies";
691
762
  var ACCOUNT_CHAIN_ID = /^account-[1-9][0-9]*$/;
763
+ var SIGNOFFS_PATH = "/signoffs";
764
+ var ACCOUNT_SIGNOFFS_PATH = "/account/signoffs";
765
+ var APPROVERS_PATH = "/account/approvers";
766
+ var SIGNOFF_TARGET_KINDS = /* @__PURE__ */ new Set(["record", "receipt", "job"]);
767
+ var SIGNOFF_DECISIONS = /* @__PURE__ */ new Set(["approve", "reject"]);
768
+ var MAX_APPROVER_NAME_BYTES = 200;
692
769
  var HEADER_EXPORT_TOTAL = "X-Verdifax-Total";
693
770
  var HEADER_EXPORT_TRUNCATED = "X-Verdifax-Truncated";
694
771
  var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
@@ -1195,6 +1272,174 @@ var VerdifaxClient = class {
1195
1272
  }
1196
1273
  return this.activatePolicyAt(ACCOUNT_POLICIES_PATH, documentHash, chain);
1197
1274
  }
1275
+ // ── Sign-off ─────────────────────────────────────────────────
1276
+ //
1277
+ // An account's owner registers approver keys. An approver signs a
1278
+ // decision, approve or reject, on one exact result on their own machine,
1279
+ // and any key in the account, or the owner key with asOwner, submits it.
1280
+ // A sign-off shows that the holder of a registered approver key signed
1281
+ // that decision on that exact result and that Verdifax logged it on the
1282
+ // public log. It does not show the review was careful, the result right,
1283
+ // or who the person is, and it gates nothing on its own.
1284
+ /** Registers an approver key to the owner's account. `approver` is the
1285
+ * approver's signing key, from which the proof is made on this machine,
1286
+ * or the `{ public_key, proof }` that {@link approverKeyProof} made on
1287
+ * the approver's own machine, so the owner need not hold the private
1288
+ * key. A proof that does not verify is refused before sending. The
1289
+ * server refuses a key that is a request key in the same account.
1290
+ * `name` is a label, not an identity. */
1291
+ async registerApprover(name, approver) {
1292
+ this.requireAPIKey();
1293
+ if (typeof name !== "string" || name.trim() === "" || Buffer.byteLength(name.trim(), "utf8") > MAX_APPROVER_NAME_BYTES) {
1294
+ throw new VerdifaxError(
1295
+ "verdifax: registerApprover needs a name of at most 200 bytes"
1296
+ );
1297
+ }
1298
+ const body = approver instanceof Uint8Array || approver instanceof import_node_crypto2.KeyObject ? approverKeyProof(approver) : { public_key: approver?.public_key, proof: approver?.proof };
1299
+ if (!approverKeyProofVerifies(body)) {
1300
+ throw new VerdifaxError(
1301
+ "verdifax: registerApprover needs the approver's signing key, or a public_key and proof from approverKeyProof that verify"
1302
+ );
1303
+ }
1304
+ if (this.signingKey && publicKeyOf(this.signingKey) === body.public_key) {
1305
+ throw new VerdifaxError(
1306
+ "verdifax: an approver key must not be a request key in the same account, and this is the client's signingKey"
1307
+ );
1308
+ }
1309
+ return this.request("POST", APPROVERS_PATH, {
1310
+ public_key: body.public_key,
1311
+ name,
1312
+ proof: body.proof
1313
+ });
1314
+ }
1315
+ /** The account's approver keys, revoked ones included. */
1316
+ async listApprovers() {
1317
+ this.requireAPIKey();
1318
+ const out = await this.request("GET", APPROVERS_PATH);
1319
+ return { ...out, approvers: out?.approvers ?? [] };
1320
+ }
1321
+ /** Revokes one of the account's approver keys. Its earlier sign-offs
1322
+ * stay on the record. A key that is not an active approver in the
1323
+ * account throws {@link NotFoundError}. */
1324
+ async revokeApprover(approverId) {
1325
+ this.requireAPIKey();
1326
+ requirePositiveId(approverId, "approverId");
1327
+ return this.request(
1328
+ "DELETE",
1329
+ `${APPROVERS_PATH}/${approverId}`
1330
+ );
1331
+ }
1332
+ /** What an approver signs against: the account's chain `account-N` and
1333
+ * its newest sign-off (64 zeros when there is none yet). With
1334
+ * `asOwner` it reads the owner key's route. */
1335
+ async getNewestSignOff(options = {}) {
1336
+ this.requireAPIKey();
1337
+ return this.request(
1338
+ "GET",
1339
+ `${signOffsBase(options, "getNewestSignOff")}/newest`
1340
+ );
1341
+ }
1342
+ /** Signs an approver's decision, approve or reject, on one exact result
1343
+ * and submits it. It first reads {@link getNewestSignOff} for the
1344
+ * account's chain and its newest sign-off, then signs over them with
1345
+ * `approverKey` on this machine. The approver's private key is never
1346
+ * sent.
1347
+ *
1348
+ * `target.digest` is what the result's public-log entry covers: for a
1349
+ * record, the `bundle_anchor.anchored_digest` of the bundle
1350
+ * {@link getRunArtifacts} returns; for a receipt, its hash; for a job,
1351
+ * the final step's record digest, read as a record's, with `job_id`.
1352
+ *
1353
+ * `note` is hashed here and only its SHA-256 is sent and signed: the
1354
+ * text never reaches Verdifax. With `asOwner` it uses the owner key's
1355
+ * routes. Bad input is refused before anything is sent.
1356
+ *
1357
+ * The record the server returns must carry the signature,
1358
+ * approver_key, decision, previous_signoff_hash and target that were
1359
+ * sent, or this throws {@link VerdifaxError}.
1360
+ *
1361
+ * A later sign-off on the same result supersedes this one, and neither
1362
+ * is deleted. An HTTP 409 means the chain moved or the target is not
1363
+ * this account's result: it throws the usual {@link APIError} and is
1364
+ * not retried. */
1365
+ async signOff(target, decision, approverKey, options = {}) {
1366
+ this.requireAPIKey();
1367
+ const base = signOffsBase(options, "signOff");
1368
+ const exact = signOffTargetOf(target);
1369
+ if (!SIGNOFF_DECISIONS.has(decision)) {
1370
+ throw new VerdifaxError(
1371
+ "verdifax: signOff decision must be approve or reject"
1372
+ );
1373
+ }
1374
+ const note = options?.note;
1375
+ if (note !== void 0 && (typeof note !== "string" || note === "")) {
1376
+ throw new VerdifaxError(
1377
+ "verdifax: signOff note must be non-empty text when given"
1378
+ );
1379
+ }
1380
+ const key = loadSigningKey(approverKey);
1381
+ const note_hash = note === void 0 ? "" : sha256Hex(note);
1382
+ const newest = await this.getNewestSignOff(options);
1383
+ if (typeof newest?.chain_id !== "string" || !ACCOUNT_CHAIN_ID.test(newest.chain_id)) {
1384
+ throw new VerdifaxError(
1385
+ "verdifax: the server did not report an account chain_id (account-N), so the sign-off cannot be signed"
1386
+ );
1387
+ }
1388
+ const previous = newest.newest_signoff_hash;
1389
+ if (typeof previous !== "string" || !/^[0-9a-f]{64}$/.test(previous)) {
1390
+ throw new VerdifaxError(
1391
+ "verdifax: the server did not report newest_signoff_hash, so the sign-off cannot name the one it follows"
1392
+ );
1393
+ }
1394
+ const signed = signOffSignature(key, {
1395
+ chain_id: newest.chain_id,
1396
+ previous_signoff_hash: previous,
1397
+ target: exact,
1398
+ decision,
1399
+ note_hash
1400
+ });
1401
+ const body = {
1402
+ previous_signoff_hash: previous,
1403
+ target: exact,
1404
+ decision,
1405
+ ...note_hash ? { note_hash } : {},
1406
+ ...signed
1407
+ };
1408
+ const out = await this.request(
1409
+ "POST",
1410
+ base,
1411
+ body
1412
+ );
1413
+ const record = out?.signoff;
1414
+ if (!recordMatchesSent(record, exact, decision, previous, signed)) {
1415
+ throw new VerdifaxError(
1416
+ "verdifax: the sign-off the server returned does not carry the signature, approver_key, decision, previous_signoff_hash and target that were sent"
1417
+ );
1418
+ }
1419
+ return record;
1420
+ }
1421
+ /** The sign-offs on one run, oldest first, in the form
1422
+ * `verdifax-verify --signoff FILE` reads: for a member key, a run it
1423
+ * made; with `asOwner`, any run of the account. The latest is the
1424
+ * decision in force. */
1425
+ async getSignOffs(runId, options = {}) {
1426
+ this.requireAPIKey();
1427
+ requirePositiveId(runId, "runId");
1428
+ const base = asOwnerOf(options, "getSignOffs") ? `${ACCOUNT_PATH}/runs` : "/runs";
1429
+ return await this.request(
1430
+ "GET",
1431
+ `${base}/${runId}/signoffs`
1432
+ ) ?? [];
1433
+ }
1434
+ /** The account's whole chain of sign-offs, oldest first, in the form
1435
+ * `verdifax-verify --signoff-history FILE` reads. Needs the owner key. */
1436
+ async getSignOffHistory() {
1437
+ this.requireAPIKey();
1438
+ return await this.request(
1439
+ "GET",
1440
+ `${ACCOUNT_SIGNOFFS_PATH}/history`
1441
+ ) ?? [];
1442
+ }
1198
1443
  // ── Runs ──────────────────────────────────────────────────────
1199
1444
  async getRun(runId) {
1200
1445
  this.requireAPIKey();
@@ -1648,6 +1893,52 @@ var VerdifaxClient = class {
1648
1893
  return this.signingKey ? signRequestV2(this.signingKey, req) : withExecuteFlags(req);
1649
1894
  }
1650
1895
  };
1896
+ function signOffsBase(options, method) {
1897
+ return asOwnerOf(options, method) ? ACCOUNT_SIGNOFFS_PATH : SIGNOFFS_PATH;
1898
+ }
1899
+ function asOwnerOf(options, method) {
1900
+ const asOwner = options?.asOwner;
1901
+ if (asOwner !== void 0 && typeof asOwner !== "boolean") {
1902
+ throw new VerdifaxError(`verdifax: ${method} asOwner must be true or false`);
1903
+ }
1904
+ return asOwner === true;
1905
+ }
1906
+ function signOffTargetOf(target) {
1907
+ if (typeof target !== "object" || target === null || Array.isArray(target)) {
1908
+ throw new VerdifaxError("verdifax: signOff needs a target object");
1909
+ }
1910
+ for (const name of Object.keys(target)) {
1911
+ if (!["kind", "run_id", "digest", "job_id"].includes(name)) {
1912
+ throw new VerdifaxError(`verdifax: signOff target has an unknown field: ${name}`);
1913
+ }
1914
+ }
1915
+ if (!SIGNOFF_TARGET_KINDS.has(target.kind)) {
1916
+ throw new VerdifaxError(
1917
+ "verdifax: signOff target kind must be record, receipt or job"
1918
+ );
1919
+ }
1920
+ requirePositiveId(target.run_id, "target.run_id");
1921
+ validateHex64(target.digest, "target.digest");
1922
+ if (target.kind === "job") {
1923
+ validateJobName(target.job_id, "target.job_id");
1924
+ return {
1925
+ kind: target.kind,
1926
+ run_id: target.run_id,
1927
+ digest: target.digest,
1928
+ job_id: target.job_id
1929
+ };
1930
+ }
1931
+ if (target.job_id !== void 0) {
1932
+ throw new VerdifaxError(
1933
+ "verdifax: signOff target job_id is for a job target only"
1934
+ );
1935
+ }
1936
+ return { kind: target.kind, run_id: target.run_id, digest: target.digest };
1937
+ }
1938
+ function recordMatchesSent(record, target, decision, previous, signed) {
1939
+ const got = record?.target;
1940
+ return typeof record === "object" && record !== null && record.signature === signed.signature && record.approver_key === signed.approver_key && record.decision === decision && record.previous_signoff_hash === previous && typeof got === "object" && got !== null && got.kind === target.kind && got.run_id === target.run_id && got.digest === target.digest && (got.job_id ?? "") === (target.job_id ?? "");
1941
+ }
1651
1942
  function requirePositiveId(id, name) {
1652
1943
  if (!Number.isSafeInteger(id) || id <= 0) {
1653
1944
  throw new VerdifaxError(`verdifax: ${name} must be a whole number > 0`);
@@ -1778,13 +2069,13 @@ var RUN_STATUSES = [
1778
2069
  ];
1779
2070
 
1780
2071
  // src/section0.ts
1781
- var import_node_crypto2 = require("crypto");
2072
+ var import_node_crypto3 = require("crypto");
1782
2073
  var DSE_FORMULA_VERSION = "dse.tcu.dispatch.v1";
1783
2074
  var DCAE_PREIMAGE_VERSION = "verdifax.dcae.v1";
1784
2075
  var CRES_PREIMAGE_VERSION = "verdifax.cres.v1";
1785
2076
  var CRES_ENGINE_VERSION = "verdifax-cres/1.0.0";
1786
2077
  function sha256Hex2(preimage) {
1787
- return (0, import_node_crypto2.createHash)("sha256").update(Buffer.from(preimage, "utf8")).digest("hex");
2078
+ return (0, import_node_crypto3.createHash)("sha256").update(Buffer.from(preimage, "utf8")).digest("hex");
1788
2079
  }
1789
2080
  function requireHex64(value, name) {
1790
2081
  try {
@@ -1854,6 +2145,7 @@ function cresReceiptHash(fields) {
1854
2145
  VerdifaxClient,
1855
2146
  VerdifaxError,
1856
2147
  VerdifaxJob,
2148
+ approverKeyProof,
1857
2149
  captureEnvironment,
1858
2150
  cresReceiptHash,
1859
2151
  dcaeClosureHash,
@@ -1868,6 +2160,7 @@ function cresReceiptHash(fields) {
1868
2160
  requestKeyRegistrationBody,
1869
2161
  requestSignaturePreimage,
1870
2162
  requestSignaturePreimageV2,
2163
+ signOffPreimage,
1871
2164
  signRequest,
1872
2165
  signRequestV2,
1873
2166
  validateHex64,
package/dist/index.mjs CHANGED
@@ -57,7 +57,8 @@ import {
57
57
  generateKeyPairSync,
58
58
  KeyObject,
59
59
  randomBytes,
60
- sign
60
+ sign,
61
+ verify
61
62
  } from "crypto";
62
63
 
63
64
  // src/validation.ts
@@ -339,7 +340,10 @@ var JOB_FORM = "verdifax.job.v1";
339
340
  var REQUEST_KEY_PROOF_FORM = "verdifax.request-key.v1";
340
341
  var REQUEST_KEY_CHANGE_FORM = "verdifax.request-key-change.v1";
341
342
  var POLICY_ACTIVATION_FORM = "verdifax.policy-activation.v1";
343
+ var SIGNOFF_FORM = "verdifax.signoff.v1";
344
+ var APPROVER_KEY_PROOF_FORM = "verdifax.approver-key.v1";
342
345
  var SIGNING_KEY_BYTES = 32;
346
+ var SPKI_ED25519_HEADER = Buffer.from("302a300506032b6570032100", "hex");
343
347
  var PKCS8_ED25519_HEADER = Buffer.from(
344
348
  "302e020100300506032b657004220420",
345
349
  "hex"
@@ -523,6 +527,71 @@ function policyActivationApproval(signingKey, fields, options = {}) {
523
527
  nonce
524
528
  };
525
529
  }
530
+ function signOffPreimage(fields) {
531
+ const { target } = fields;
532
+ return Buffer.from(
533
+ [
534
+ SIGNOFF_FORM,
535
+ fields.chain_id,
536
+ fields.previous_signoff_hash,
537
+ target.kind,
538
+ String(target.run_id),
539
+ target.digest,
540
+ target.job_id ?? "",
541
+ fields.decision,
542
+ fields.note_hash,
543
+ fields.signed_at,
544
+ fields.nonce
545
+ ].join("\0"),
546
+ "utf8"
547
+ );
548
+ }
549
+ function signOffSignature(approverKey, fields, options = {}) {
550
+ const key = loadSigningKey(approverKey);
551
+ const signed_at = options.signedAt ?? signedAtNow();
552
+ const nonce = options.nonce ?? randomBytes(16).toString("hex");
553
+ const preimage = signOffPreimage({ ...fields, signed_at, nonce });
554
+ return {
555
+ approver_key: rawPublicKey(createPublicKey(key)).toString("base64"),
556
+ signature: sign(null, preimage, key).toString("base64"),
557
+ signed_at,
558
+ nonce
559
+ };
560
+ }
561
+ function approverKeyProof(approverKey) {
562
+ const key = loadSigningKey(approverKey);
563
+ const public_key = rawPublicKey(createPublicKey(key)).toString("base64");
564
+ const proof = sign(
565
+ null,
566
+ Buffer.from(`${APPROVER_KEY_PROOF_FORM}\0${public_key}`, "utf8"),
567
+ key
568
+ ).toString("base64");
569
+ return { public_key, proof };
570
+ }
571
+ function approverKeyProofVerifies(proof) {
572
+ if (typeof proof?.public_key !== "string" || typeof proof?.proof !== "string") {
573
+ return false;
574
+ }
575
+ const raw = Buffer.from(proof.public_key, "base64");
576
+ const signature = Buffer.from(proof.proof, "base64");
577
+ if (raw.length !== 32 || raw.toString("base64") !== proof.public_key || signature.length !== 64 || signature.toString("base64") !== proof.proof) {
578
+ return false;
579
+ }
580
+ const publicKey = createPublicKey({
581
+ key: Buffer.concat([SPKI_ED25519_HEADER, raw]),
582
+ format: "der",
583
+ type: "spki"
584
+ });
585
+ return verify(
586
+ null,
587
+ Buffer.from(`${APPROVER_KEY_PROOF_FORM}\0${proof.public_key}`, "utf8"),
588
+ publicKey,
589
+ signature
590
+ );
591
+ }
592
+ function publicKeyOf(key) {
593
+ return rawPublicKey(createPublicKey(key)).toString("base64");
594
+ }
526
595
 
527
596
  // src/job.ts
528
597
  var STEP_OPTIONS = {
@@ -624,9 +693,10 @@ var VerdifaxJob = class {
624
693
  };
625
694
 
626
695
  // src/version.ts
627
- var SDK_VERSION = "1.3.0";
696
+ var SDK_VERSION = "1.4.0";
628
697
 
629
698
  // src/client.ts
699
+ import { KeyObject as KeyObject2 } from "crypto";
630
700
  var DEFAULT_BASE_URL = "https://api.verdifax.com";
631
701
  var DEFAULT_TIMEOUT_MS = 9e4;
632
702
  var DEFAULT_MAX_RETRIES = 3;
@@ -639,6 +709,12 @@ var ACCOUNT_PATH = "/account";
639
709
  var ACCOUNT_REQUEST_KEY_PATH = "/account/request-key";
640
710
  var ACCOUNT_POLICIES_PATH = "/account/policies";
641
711
  var ACCOUNT_CHAIN_ID = /^account-[1-9][0-9]*$/;
712
+ var SIGNOFFS_PATH = "/signoffs";
713
+ var ACCOUNT_SIGNOFFS_PATH = "/account/signoffs";
714
+ var APPROVERS_PATH = "/account/approvers";
715
+ var SIGNOFF_TARGET_KINDS = /* @__PURE__ */ new Set(["record", "receipt", "job"]);
716
+ var SIGNOFF_DECISIONS = /* @__PURE__ */ new Set(["approve", "reject"]);
717
+ var MAX_APPROVER_NAME_BYTES = 200;
642
718
  var HEADER_EXPORT_TOTAL = "X-Verdifax-Total";
643
719
  var HEADER_EXPORT_TRUNCATED = "X-Verdifax-Truncated";
644
720
  var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
@@ -1145,6 +1221,174 @@ var VerdifaxClient = class {
1145
1221
  }
1146
1222
  return this.activatePolicyAt(ACCOUNT_POLICIES_PATH, documentHash, chain);
1147
1223
  }
1224
+ // ── Sign-off ─────────────────────────────────────────────────
1225
+ //
1226
+ // An account's owner registers approver keys. An approver signs a
1227
+ // decision, approve or reject, on one exact result on their own machine,
1228
+ // and any key in the account, or the owner key with asOwner, submits it.
1229
+ // A sign-off shows that the holder of a registered approver key signed
1230
+ // that decision on that exact result and that Verdifax logged it on the
1231
+ // public log. It does not show the review was careful, the result right,
1232
+ // or who the person is, and it gates nothing on its own.
1233
+ /** Registers an approver key to the owner's account. `approver` is the
1234
+ * approver's signing key, from which the proof is made on this machine,
1235
+ * or the `{ public_key, proof }` that {@link approverKeyProof} made on
1236
+ * the approver's own machine, so the owner need not hold the private
1237
+ * key. A proof that does not verify is refused before sending. The
1238
+ * server refuses a key that is a request key in the same account.
1239
+ * `name` is a label, not an identity. */
1240
+ async registerApprover(name, approver) {
1241
+ this.requireAPIKey();
1242
+ if (typeof name !== "string" || name.trim() === "" || Buffer.byteLength(name.trim(), "utf8") > MAX_APPROVER_NAME_BYTES) {
1243
+ throw new VerdifaxError(
1244
+ "verdifax: registerApprover needs a name of at most 200 bytes"
1245
+ );
1246
+ }
1247
+ const body = approver instanceof Uint8Array || approver instanceof KeyObject2 ? approverKeyProof(approver) : { public_key: approver?.public_key, proof: approver?.proof };
1248
+ if (!approverKeyProofVerifies(body)) {
1249
+ throw new VerdifaxError(
1250
+ "verdifax: registerApprover needs the approver's signing key, or a public_key and proof from approverKeyProof that verify"
1251
+ );
1252
+ }
1253
+ if (this.signingKey && publicKeyOf(this.signingKey) === body.public_key) {
1254
+ throw new VerdifaxError(
1255
+ "verdifax: an approver key must not be a request key in the same account, and this is the client's signingKey"
1256
+ );
1257
+ }
1258
+ return this.request("POST", APPROVERS_PATH, {
1259
+ public_key: body.public_key,
1260
+ name,
1261
+ proof: body.proof
1262
+ });
1263
+ }
1264
+ /** The account's approver keys, revoked ones included. */
1265
+ async listApprovers() {
1266
+ this.requireAPIKey();
1267
+ const out = await this.request("GET", APPROVERS_PATH);
1268
+ return { ...out, approvers: out?.approvers ?? [] };
1269
+ }
1270
+ /** Revokes one of the account's approver keys. Its earlier sign-offs
1271
+ * stay on the record. A key that is not an active approver in the
1272
+ * account throws {@link NotFoundError}. */
1273
+ async revokeApprover(approverId) {
1274
+ this.requireAPIKey();
1275
+ requirePositiveId(approverId, "approverId");
1276
+ return this.request(
1277
+ "DELETE",
1278
+ `${APPROVERS_PATH}/${approverId}`
1279
+ );
1280
+ }
1281
+ /** What an approver signs against: the account's chain `account-N` and
1282
+ * its newest sign-off (64 zeros when there is none yet). With
1283
+ * `asOwner` it reads the owner key's route. */
1284
+ async getNewestSignOff(options = {}) {
1285
+ this.requireAPIKey();
1286
+ return this.request(
1287
+ "GET",
1288
+ `${signOffsBase(options, "getNewestSignOff")}/newest`
1289
+ );
1290
+ }
1291
+ /** Signs an approver's decision, approve or reject, on one exact result
1292
+ * and submits it. It first reads {@link getNewestSignOff} for the
1293
+ * account's chain and its newest sign-off, then signs over them with
1294
+ * `approverKey` on this machine. The approver's private key is never
1295
+ * sent.
1296
+ *
1297
+ * `target.digest` is what the result's public-log entry covers: for a
1298
+ * record, the `bundle_anchor.anchored_digest` of the bundle
1299
+ * {@link getRunArtifacts} returns; for a receipt, its hash; for a job,
1300
+ * the final step's record digest, read as a record's, with `job_id`.
1301
+ *
1302
+ * `note` is hashed here and only its SHA-256 is sent and signed: the
1303
+ * text never reaches Verdifax. With `asOwner` it uses the owner key's
1304
+ * routes. Bad input is refused before anything is sent.
1305
+ *
1306
+ * The record the server returns must carry the signature,
1307
+ * approver_key, decision, previous_signoff_hash and target that were
1308
+ * sent, or this throws {@link VerdifaxError}.
1309
+ *
1310
+ * A later sign-off on the same result supersedes this one, and neither
1311
+ * is deleted. An HTTP 409 means the chain moved or the target is not
1312
+ * this account's result: it throws the usual {@link APIError} and is
1313
+ * not retried. */
1314
+ async signOff(target, decision, approverKey, options = {}) {
1315
+ this.requireAPIKey();
1316
+ const base = signOffsBase(options, "signOff");
1317
+ const exact = signOffTargetOf(target);
1318
+ if (!SIGNOFF_DECISIONS.has(decision)) {
1319
+ throw new VerdifaxError(
1320
+ "verdifax: signOff decision must be approve or reject"
1321
+ );
1322
+ }
1323
+ const note = options?.note;
1324
+ if (note !== void 0 && (typeof note !== "string" || note === "")) {
1325
+ throw new VerdifaxError(
1326
+ "verdifax: signOff note must be non-empty text when given"
1327
+ );
1328
+ }
1329
+ const key = loadSigningKey(approverKey);
1330
+ const note_hash = note === void 0 ? "" : sha256Hex(note);
1331
+ const newest = await this.getNewestSignOff(options);
1332
+ if (typeof newest?.chain_id !== "string" || !ACCOUNT_CHAIN_ID.test(newest.chain_id)) {
1333
+ throw new VerdifaxError(
1334
+ "verdifax: the server did not report an account chain_id (account-N), so the sign-off cannot be signed"
1335
+ );
1336
+ }
1337
+ const previous = newest.newest_signoff_hash;
1338
+ if (typeof previous !== "string" || !/^[0-9a-f]{64}$/.test(previous)) {
1339
+ throw new VerdifaxError(
1340
+ "verdifax: the server did not report newest_signoff_hash, so the sign-off cannot name the one it follows"
1341
+ );
1342
+ }
1343
+ const signed = signOffSignature(key, {
1344
+ chain_id: newest.chain_id,
1345
+ previous_signoff_hash: previous,
1346
+ target: exact,
1347
+ decision,
1348
+ note_hash
1349
+ });
1350
+ const body = {
1351
+ previous_signoff_hash: previous,
1352
+ target: exact,
1353
+ decision,
1354
+ ...note_hash ? { note_hash } : {},
1355
+ ...signed
1356
+ };
1357
+ const out = await this.request(
1358
+ "POST",
1359
+ base,
1360
+ body
1361
+ );
1362
+ const record = out?.signoff;
1363
+ if (!recordMatchesSent(record, exact, decision, previous, signed)) {
1364
+ throw new VerdifaxError(
1365
+ "verdifax: the sign-off the server returned does not carry the signature, approver_key, decision, previous_signoff_hash and target that were sent"
1366
+ );
1367
+ }
1368
+ return record;
1369
+ }
1370
+ /** The sign-offs on one run, oldest first, in the form
1371
+ * `verdifax-verify --signoff FILE` reads: for a member key, a run it
1372
+ * made; with `asOwner`, any run of the account. The latest is the
1373
+ * decision in force. */
1374
+ async getSignOffs(runId, options = {}) {
1375
+ this.requireAPIKey();
1376
+ requirePositiveId(runId, "runId");
1377
+ const base = asOwnerOf(options, "getSignOffs") ? `${ACCOUNT_PATH}/runs` : "/runs";
1378
+ return await this.request(
1379
+ "GET",
1380
+ `${base}/${runId}/signoffs`
1381
+ ) ?? [];
1382
+ }
1383
+ /** The account's whole chain of sign-offs, oldest first, in the form
1384
+ * `verdifax-verify --signoff-history FILE` reads. Needs the owner key. */
1385
+ async getSignOffHistory() {
1386
+ this.requireAPIKey();
1387
+ return await this.request(
1388
+ "GET",
1389
+ `${ACCOUNT_SIGNOFFS_PATH}/history`
1390
+ ) ?? [];
1391
+ }
1148
1392
  // ── Runs ──────────────────────────────────────────────────────
1149
1393
  async getRun(runId) {
1150
1394
  this.requireAPIKey();
@@ -1598,6 +1842,52 @@ var VerdifaxClient = class {
1598
1842
  return this.signingKey ? signRequestV2(this.signingKey, req) : withExecuteFlags(req);
1599
1843
  }
1600
1844
  };
1845
+ function signOffsBase(options, method) {
1846
+ return asOwnerOf(options, method) ? ACCOUNT_SIGNOFFS_PATH : SIGNOFFS_PATH;
1847
+ }
1848
+ function asOwnerOf(options, method) {
1849
+ const asOwner = options?.asOwner;
1850
+ if (asOwner !== void 0 && typeof asOwner !== "boolean") {
1851
+ throw new VerdifaxError(`verdifax: ${method} asOwner must be true or false`);
1852
+ }
1853
+ return asOwner === true;
1854
+ }
1855
+ function signOffTargetOf(target) {
1856
+ if (typeof target !== "object" || target === null || Array.isArray(target)) {
1857
+ throw new VerdifaxError("verdifax: signOff needs a target object");
1858
+ }
1859
+ for (const name of Object.keys(target)) {
1860
+ if (!["kind", "run_id", "digest", "job_id"].includes(name)) {
1861
+ throw new VerdifaxError(`verdifax: signOff target has an unknown field: ${name}`);
1862
+ }
1863
+ }
1864
+ if (!SIGNOFF_TARGET_KINDS.has(target.kind)) {
1865
+ throw new VerdifaxError(
1866
+ "verdifax: signOff target kind must be record, receipt or job"
1867
+ );
1868
+ }
1869
+ requirePositiveId(target.run_id, "target.run_id");
1870
+ validateHex64(target.digest, "target.digest");
1871
+ if (target.kind === "job") {
1872
+ validateJobName(target.job_id, "target.job_id");
1873
+ return {
1874
+ kind: target.kind,
1875
+ run_id: target.run_id,
1876
+ digest: target.digest,
1877
+ job_id: target.job_id
1878
+ };
1879
+ }
1880
+ if (target.job_id !== void 0) {
1881
+ throw new VerdifaxError(
1882
+ "verdifax: signOff target job_id is for a job target only"
1883
+ );
1884
+ }
1885
+ return { kind: target.kind, run_id: target.run_id, digest: target.digest };
1886
+ }
1887
+ function recordMatchesSent(record, target, decision, previous, signed) {
1888
+ const got = record?.target;
1889
+ return typeof record === "object" && record !== null && record.signature === signed.signature && record.approver_key === signed.approver_key && record.decision === decision && record.previous_signoff_hash === previous && typeof got === "object" && got !== null && got.kind === target.kind && got.run_id === target.run_id && got.digest === target.digest && (got.job_id ?? "") === (target.job_id ?? "");
1890
+ }
1601
1891
  function requirePositiveId(id, name) {
1602
1892
  if (!Number.isSafeInteger(id) || id <= 0) {
1603
1893
  throw new VerdifaxError(`verdifax: ${name} must be a whole number > 0`);
@@ -1803,6 +2093,7 @@ export {
1803
2093
  VerdifaxClient,
1804
2094
  VerdifaxError,
1805
2095
  VerdifaxJob,
2096
+ approverKeyProof,
1806
2097
  captureEnvironment,
1807
2098
  cresReceiptHash,
1808
2099
  dcaeClosureHash,
@@ -1817,6 +2108,7 @@ export {
1817
2108
  requestKeyRegistrationBody,
1818
2109
  requestSignaturePreimage,
1819
2110
  requestSignaturePreimageV2,
2111
+ signOffPreimage,
1820
2112
  signRequest,
1821
2113
  signRequestV2,
1822
2114
  validateHex64,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verdifax",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Official Node.js / TypeScript SDK for the Verdifax orchestrator REST API.",
5
5
  "license": "MIT",
6
6
  "author": "Verdifax <team@verdifax.com>",