@atcn/sdk 1.3.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.
@@ -0,0 +1,300 @@
1
+ import { buildResponseStatement, countersignPayload, signStatement, } from "@atcn/subledger";
2
+ import { sha256Digest } from "@atcn/schema";
3
+ import { AtcnApiError, AtcnClient } from "./client.js";
4
+ /** Caller-supplied references address records before their server IDs are known: ext("job-42"). */
5
+ export const ext = (externalRef) => `ext:${externalRef}`;
6
+ /** Idempotency key derived from the caller's stable references; long references are hashed to fit 8-200 chars. */
7
+ export function stableKey(kind, ...parts) {
8
+ const key = `sl:${kind}:${parts.join(":")}`;
9
+ return key.length <= 200 ? key : `sl:${kind}:${sha256Digest(parts.join(":"))}`;
10
+ }
11
+ /**
12
+ * Small, independent subledger operations (PRD §15). Default idempotency keys derive from the caller's own
13
+ * stable references, so a retry from anywhere in the orchestrator never creates a second record.
14
+ */
15
+ export class SubledgerClient {
16
+ http;
17
+ constructor(options) {
18
+ this.http = options instanceof AtcnClient ? options : new AtcnClient(options);
19
+ }
20
+ createTask(input, opts = {}) {
21
+ return this.http.request("POST", "/v1/tasks", input, { idempotencyKey: opts.idempotencyKey ?? stableKey("task", input.external_ref) });
22
+ }
23
+ getTask(taskId) {
24
+ return this.http.request("GET", `/v1/tasks/${encodeURIComponent(taskId)}`);
25
+ }
26
+ listTasks(query = {}) {
27
+ return this.http.request("GET", "/v1/tasks", undefined, { query });
28
+ }
29
+ /** Admin only. Principals are "user:<id>" or "api_key:<id>". */
30
+ getTaskAccess(taskId) {
31
+ return this.http.request("GET", `/v1/tasks/${encodeURIComponent(taskId)}/access`);
32
+ }
33
+ updateTaskAccess(taskId, input, opts = {}) {
34
+ return this.http.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/access`, input, opts);
35
+ }
36
+ createDelegation(taskId, input, opts = {}) {
37
+ const key = opts.idempotencyKey ?? (input.external_ref ? stableKey("delegation", input.external_ref) : undefined);
38
+ return this.http.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/delegations`, input, { idempotencyKey: key });
39
+ }
40
+ assignProvider(delegationId, providerId, opts = {}) {
41
+ return this.http.request("POST", `/v1/delegations/${encodeURIComponent(delegationId)}/provider`, { provider_id: providerId }, opts);
42
+ }
43
+ appendDelegationEvent(delegationId, input, opts = {}) {
44
+ return this.http.request("POST", `/v1/delegations/${encodeURIComponent(delegationId)}/events`, input, opts);
45
+ }
46
+ recordFinancialEvent(input, opts = {}) {
47
+ return this.http.request("POST", "/v1/financial-events", input, { idempotencyKey: opts.idempotencyKey ?? stableKey("financial", input.source, input.source_event_id) });
48
+ }
49
+ importCsv(csv, opts = {}) {
50
+ return this.http.request("POST", "/v1/financial-events/import", undefined, {
51
+ idempotencyKey: opts.idempotencyKey,
52
+ textBody: { contentType: "text/csv", text: csv },
53
+ });
54
+ }
55
+ listFinancialEvents(query = {}) {
56
+ return this.http.request("GET", "/v1/financial-events", undefined, { query });
57
+ }
58
+ allocate(financialEventId, input, opts = {}) {
59
+ return this.http.request("POST", `/v1/financial-events/${financialEventId}/allocations`, input, opts);
60
+ }
61
+ createAllocationRule(input, opts = {}) {
62
+ return this.http.request("POST", "/v1/allocation-rules", input, opts);
63
+ }
64
+ listMatches(status) {
65
+ return this.http.request("GET", "/v1/matches", undefined, { query: { status } });
66
+ }
67
+ confirmMatch(matchId, node, opts = {}) {
68
+ return this.http.request("POST", `/v1/matches/${matchId}/confirm`, node, opts);
69
+ }
70
+ financialSummary(taskId, query = {}) {
71
+ return this.http.request("GET", `/v1/tasks/${encodeURIComponent(taskId)}/financial-summary`, undefined, { query });
72
+ }
73
+ listExceptions(query = {}) {
74
+ return this.http.request("GET", "/v1/subledger-exceptions", undefined, { query });
75
+ }
76
+ resolveException(exceptionId, input, opts = {}) {
77
+ return this.http.request("POST", `/v1/subledger-exceptions/${exceptionId}/resolve`, input, opts);
78
+ }
79
+ reportCaptureGap(taskId, gap, opts = {}) {
80
+ return this.http.request("POST", `/v1/tasks/${encodeURIComponent(taskId)}/capture-gaps`, gap, opts);
81
+ }
82
+ closeTask(taskId, opts = {}) {
83
+ return this.http.request("POST", `/v1/tasks/${taskId}/close`, {}, opts);
84
+ }
85
+ getClosure(taskId, version) {
86
+ return this.http.request("GET", `/v1/tasks/${taskId}/closure`, undefined, { query: { version } });
87
+ }
88
+ createReceipt(taskId, delegationId, opts = {}) {
89
+ return this.http.request("POST", `/v1/tasks/${taskId}/receipts`, { delegation_id: delegationId }, opts);
90
+ }
91
+ getReceipt(receiptId) {
92
+ return this.http.request("GET", `/v1/receipts/${receiptId}`);
93
+ }
94
+ markReceiptDelivered(receiptId, channel, reference = null, opts = {}) {
95
+ return this.http.request("POST", `/v1/receipts/${receiptId}/delivered`, { channel, reference }, opts);
96
+ }
97
+ createReceiptShare(receiptId, allowedActions = ["view"], ttlHours, opts = {}) {
98
+ return this.http.request("POST", "/v1/receipt-shares", { receipt_id: receiptId, allowed_actions: allowedActions, ttl_hours: ttlHours }, opts);
99
+ }
100
+ revokeReceiptShare(shareId, opts = {}) {
101
+ return this.http.request("DELETE", `/v1/receipt-shares/${shareId}`, undefined, opts);
102
+ }
103
+ listResponses(receiptId) {
104
+ return this.http.request("GET", `/v1/receipts/${receiptId}/responses`);
105
+ }
106
+ /** Accepting appends the corrected records. A correction of financial fields needs the corrected events in `financialEvents`. */
107
+ decideCorrection(responseId, status, reason, opts = {}) {
108
+ const body = { status, reason, financial_events: opts.financialEvents ?? [] };
109
+ return this.http.request("POST", `/v1/receipt-responses/${responseId}/decision`, body, { idempotencyKey: opts.idempotencyKey });
110
+ }
111
+ createProvider(input, opts = {}) {
112
+ return this.http.request("POST", "/v1/provider-identities", input, { idempotencyKey: opts.idempotencyKey ?? stableKey("provider", input.name) });
113
+ }
114
+ bindProviderKey(providerId, keyId, publicKey, opts = {}) {
115
+ return this.http.request("POST", `/v1/provider-identities/${providerId}/key-bindings`, { key_id: keyId, public_key: publicKey }, opts);
116
+ }
117
+ /** Admin only. The provider publishes the returned txt_value at txt_name; then call verifyDomainChallenge. */
118
+ startDomainChallenge(providerId, input, opts = {}) {
119
+ return this.http.request("POST", `/v1/provider-identities/${providerId}/domain-challenges`, input, opts);
120
+ }
121
+ verifyDomainChallenge(challengeId, opts = {}) {
122
+ return this.http.request("POST", `/v1/domain-challenges/${challengeId}/verify`, {}, opts);
123
+ }
124
+ /** Admin only. Registers the public half of a key the operator holds; the private key stays with the operator. */
125
+ registerOperatorKey(keyId, publicKey, opts = {}) {
126
+ return this.http.request("POST", "/v1/operator-keys", { key_id: keyId, public_key: publicKey }, opts);
127
+ }
128
+ revokeOperatorKey(keyId, reason, opts = {}) {
129
+ return this.http.request("POST", `/v1/operator-keys/${encodeURIComponent(keyId)}/revoke`, { reason }, opts);
130
+ }
131
+ operatorKeys(operatorId) {
132
+ return this.http.request("GET", `/v1/operators/${operatorId}/keys`);
133
+ }
134
+ /** Signs the document's payload locally with the operator's private key and records the countersignature. */
135
+ countersign(signed, keyId, privateKey, opts = {}) {
136
+ const signature = countersignPayload(signed.payload, privateKey);
137
+ const path = "closure_id" in signed.payload ? `closures/${signed.payload.closure_id}` : `receipts/${signed.payload.receipt_id}`;
138
+ return this.http.request("POST", `/v1/${path}/countersign`, { key_id: keyId, signature }, opts);
139
+ }
140
+ productMetrics() {
141
+ return this.http.request("GET", "/v1/metrics/product");
142
+ }
143
+ serviceKeys() {
144
+ return this.http.request("GET", "/v1/service/keys");
145
+ }
146
+ }
147
+ /** Provider-side access through a receipt link: no account, no API key, only the link's scope. */
148
+ export class ReceiptLinkClient {
149
+ http;
150
+ constructor(options) {
151
+ const shareToken = options.shareToken;
152
+ const baseFetch = options.fetch ?? fetch;
153
+ // The share token replaces the API key: strip the Authorization header the base client adds.
154
+ const linkFetch = (input, init) => {
155
+ const headers = new Headers(init?.headers);
156
+ headers.delete("authorization");
157
+ headers.set("x-atcn-share-token", shareToken);
158
+ return baseFetch(input, { ...init, headers });
159
+ };
160
+ this.http = new AtcnClient({ baseUrl: options.baseUrl, apiKey: "", fetch: linkFetch, retries: options.retries });
161
+ }
162
+ current() {
163
+ return this.http.request("GET", "/v1/receipt-shares/current");
164
+ }
165
+ verification(receiptId) {
166
+ return this.http.request("GET", `/v1/receipts/${receiptId}/verification`);
167
+ }
168
+ respond(receipt, response, signing, opts = {}) {
169
+ let provider_signature = null;
170
+ if (signing) {
171
+ const statement = buildResponseStatement({
172
+ receipt: { receipt_id: receipt.receipt_id, digest: receipt.digest, revision: receipt.revision, issuer_operator_id: signing.issuerOperatorId },
173
+ response_type: response.response_type,
174
+ fields: response.fields ?? [],
175
+ note: response.note ?? null,
176
+ evidence: response.evidence ?? [],
177
+ corrections: response.corrections ?? [],
178
+ });
179
+ provider_signature = { binding_id: signing.bindingId, key_id: signing.keyId, value: signStatement(statement, signing.privateKey) };
180
+ }
181
+ return this.http.request("POST", `/v1/receipts/${receipt.receipt_id}/responses`, { receipt_digest: receipt.digest, receipt_revision: receipt.revision, ...response, provider_signature }, opts);
182
+ }
183
+ }
184
+ /**
185
+ * Bounded local capture queue (PRD §15, acceptance 20-21). enqueue() and flush() never throw, so a capture
186
+ * outage never stops the orchestrator's work. Each operation keeps one idempotency key for its lifetime, so
187
+ * replays and retries create exactly one record. Drops and permanent failures are counted and can be
188
+ * reported as capture gaps, so the chain is never silently shown as complete.
189
+ *
190
+ * Overflow policy: reject the newest operation and increment `dropped`. Older operations keep their order,
191
+ * because later events (completions, charges) depend on earlier ones (tasks, delegations).
192
+ */
193
+ export class CaptureQueue {
194
+ client;
195
+ maxSize;
196
+ maxAttempts;
197
+ pending = [];
198
+ failed = [];
199
+ droppedCount = 0;
200
+ sequence = 0;
201
+ constructor(client, options = {}) {
202
+ this.client = client;
203
+ this.maxSize = options.maxSize ?? 1000;
204
+ this.maxAttempts = options.maxAttempts ?? 5;
205
+ }
206
+ get dropped() {
207
+ return this.droppedCount;
208
+ }
209
+ get size() {
210
+ return this.pending.length;
211
+ }
212
+ /** Queues one mutation. Returns false (and counts a drop) when the queue is full; never throws. */
213
+ enqueue(path, body, idempotencyKey) {
214
+ if (this.pending.length >= this.maxSize) {
215
+ this.droppedCount += 1;
216
+ return false;
217
+ }
218
+ this.sequence += 1;
219
+ this.pending.push({
220
+ id: `op_${Date.now()}_${this.sequence}`,
221
+ method: "POST",
222
+ path,
223
+ body,
224
+ idempotencyKey: idempotencyKey ?? stableKey("capture", crypto.randomUUID()),
225
+ enqueued_at: new Date().toISOString(),
226
+ attempts: 0,
227
+ last_error: null,
228
+ });
229
+ return true;
230
+ }
231
+ task(input) {
232
+ return this.enqueue("/v1/tasks", input, stableKey("task", input.external_ref));
233
+ }
234
+ delegation(taskRef, input) {
235
+ return this.enqueue(`/v1/tasks/${encodeURIComponent(taskRef)}/delegations`, input, input.external_ref ? stableKey("delegation", input.external_ref) : undefined);
236
+ }
237
+ delegationEvent(delegationRef, input, idempotencyKey) {
238
+ return this.enqueue(`/v1/delegations/${encodeURIComponent(delegationRef)}/events`, input, idempotencyKey);
239
+ }
240
+ financialEvent(input) {
241
+ return this.enqueue("/v1/financial-events", input, stableKey("financial", input.source, input.source_event_id));
242
+ }
243
+ /**
244
+ * Sends queued operations in order. A retryable failure (network, 5xx, 429) stops the flush and keeps the
245
+ * operation for the next flush; a non-retryable rejection moves it to the failed list. Never throws.
246
+ */
247
+ async flush() {
248
+ let sent = 0;
249
+ let failed = 0;
250
+ while (this.pending.length > 0) {
251
+ const op = this.pending[0];
252
+ op.attempts += 1;
253
+ try {
254
+ await this.client.http.request(op.method, op.path, op.body, { idempotencyKey: op.idempotencyKey });
255
+ this.pending.shift();
256
+ sent += 1;
257
+ }
258
+ catch (error) {
259
+ op.last_error = error instanceof Error ? error.message : String(error);
260
+ const retryable = !(error instanceof AtcnApiError) || error.retryable;
261
+ if (retryable && op.attempts < this.maxAttempts)
262
+ break;
263
+ this.failed.push(this.pending.shift());
264
+ failed += 1;
265
+ }
266
+ }
267
+ return { sent, failed, remaining: this.pending.length };
268
+ }
269
+ failedOperations() {
270
+ return [...this.failed];
271
+ }
272
+ /** Failed operations as JSON lines, for replay after the cause is fixed. */
273
+ exportFailed() {
274
+ return this.failed.map((op) => JSON.stringify(op)).join("\n");
275
+ }
276
+ /** Puts exported operations back in the queue with their original idempotency keys. */
277
+ replay(jsonLines) {
278
+ const replayed = new Set();
279
+ for (const line of jsonLines.split("\n").filter((l) => l.trim() !== "")) {
280
+ const op = JSON.parse(line);
281
+ if (this.enqueue(op.path, op.body, op.idempotencyKey))
282
+ replayed.add(op.idempotencyKey);
283
+ }
284
+ this.failed = this.failed.filter((f) => !replayed.has(f.idempotencyKey));
285
+ return replayed.size;
286
+ }
287
+ /** Reports drops and permanent failures for a task as capture gaps (marks its lineage incomplete). Never throws. */
288
+ async reportGaps(taskRef) {
289
+ if (this.droppedCount === 0 && this.failed.length === 0)
290
+ return true;
291
+ const detail = `${this.droppedCount} capture operation(s) dropped by queue overflow; ${this.failed.length} failed permanently`;
292
+ try {
293
+ await this.client.reportCaptureGap(taskRef, { kind: this.droppedCount > 0 ? "queue_overflow" : "capture_failed", detail });
294
+ return true;
295
+ }
296
+ catch {
297
+ return false;
298
+ }
299
+ }
300
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@atcn/sdk",
3
+ "version": "1.3.0",
4
+ "description": "TypeScript SDK for ATCN: sign events, build terms and evidence envelopes, call the API, verify webhooks, closures, and closure packages",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/fadnisnikhil/atcn.git",
9
+ "directory": "packages/sdk-ts"
10
+ },
11
+ "homepage": "https://github.com/fadnisnikhil/atcn/tree/main/packages/sdk-ts#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/fadnisnikhil/atcn/issues"
14
+ },
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "bin": {
20
+ "atcn": "bin/atcn.js"
21
+ },
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "atcn-source": "./src/index.ts",
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "dist"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.build.json"
37
+ },
38
+ "dependencies": {
39
+ "@atcn/core": "1.3.0",
40
+ "@atcn/schema": "1.3.0",
41
+ "@atcn/subledger": "1.3.0",
42
+ "@atcn/usage": "1.3.0",
43
+ "zod": "^4.1.0"
44
+ }
45
+ }