@paybond/kit 0.1.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 +86 -0
- package/dist/index.d.ts +235 -0
- package/dist/index.js +570 -0
- package/dist/json-digest.d.ts +7 -0
- package/dist/json-digest.js +29 -0
- package/dist/payee-evidence.d.ts +16 -0
- package/dist/payee-evidence.js +89 -0
- package/dist/principal-intent.d.ts +37 -0
- package/dist/principal-intent.js +128 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# `@paybond/kit`
|
|
2
|
+
|
|
3
|
+
Paybond Kit for TypeScript provides a tenant-bound Harbor client, gateway-backed service-account sessions, capability verification, canonical signing helpers for intent creation and evidence submission, and tenant-scoped ledger provenance reads.
|
|
4
|
+
|
|
5
|
+
It does **not** currently expose a first-class Signal client or Signal analytics/reputation API surface. Signal remains a separate platform surface today.
|
|
6
|
+
|
|
7
|
+
Install the public package with:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @paybond/kit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Node.js 20+
|
|
16
|
+
- A `paybond_sk_...` service-account API key
|
|
17
|
+
- Reachable Gateway and Harbor base URLs
|
|
18
|
+
|
|
19
|
+
## Tenant isolation
|
|
20
|
+
|
|
21
|
+
Every session is bound to the tenant realm echoed by the gateway `POST /v1/auth/harbor-access` exchange.
|
|
22
|
+
|
|
23
|
+
- Do not pass tenant ids by hand for normal SDK usage.
|
|
24
|
+
- Construct one `Paybond` session per tenant/service account.
|
|
25
|
+
- Treat any tenant or intent echo mismatch from Harbor as a severity-zero defect.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { Paybond } from "@paybond/kit";
|
|
31
|
+
|
|
32
|
+
const paybond = await Paybond.open({
|
|
33
|
+
gatewayBaseUrl: "https://gateway.example.com",
|
|
34
|
+
apiKey: process.env.PAYBOND_API_KEY!,
|
|
35
|
+
harborBaseUrl: "https://harbor.example.com",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const verified = await paybond.harbor.verifyCapability({
|
|
40
|
+
intentId: process.env.PAYBOND_INTENT_ID!,
|
|
41
|
+
token: process.env.PAYBOND_CAPABILITY!,
|
|
42
|
+
operation: "payments.capture",
|
|
43
|
+
requestedSpendCents: 18_700,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!verified.allow) {
|
|
47
|
+
throw new Error(`verify denied: ${verified.code ?? "deny"} ${verified.message ?? ""}`);
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
await paybond.aclose();
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## What the package includes
|
|
55
|
+
|
|
56
|
+
- `Paybond.open(...)` for gateway-authenticated, tenant-derived Harbor sessions
|
|
57
|
+
- `HarborClient` for capability verification, intent creation, evidence submission, and ledger reads
|
|
58
|
+
- `PaybondIntents` helpers for principal-signed intent creation and payee-signed evidence submission
|
|
59
|
+
- Low-level signing helpers exported for advanced callers
|
|
60
|
+
|
|
61
|
+
`allowedTools` values are your own tool or operation names, not a Paybond-owned catalog. Harbor enforces string matching against whatever names you chose when creating the intent.
|
|
62
|
+
|
|
63
|
+
## What it does not include
|
|
64
|
+
|
|
65
|
+
- No first-class `SignalClient`
|
|
66
|
+
- No Signal reputation or analytics fetch API
|
|
67
|
+
- No operator-tier settlement or console workflows
|
|
68
|
+
|
|
69
|
+
## Docs
|
|
70
|
+
|
|
71
|
+
- Long-form docs: `docs/kit/`
|
|
72
|
+
- Agents SDK tutorial: `docs/kit/openai-agents.md`
|
|
73
|
+
- TypeScript quickstart: `docs/kit/quickstart-typescript.md`
|
|
74
|
+
- TypeScript SDK reference: `docs/kit/sdk-reference-typescript.md`
|
|
75
|
+
- Example app: `examples/paybond-kit-typescript/`
|
|
76
|
+
- OpenAI Agents example: `examples/paybond-kit-openai-agents-typescript/`
|
|
77
|
+
|
|
78
|
+
## Release verification
|
|
79
|
+
|
|
80
|
+
From `kit/ts`:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm run verify:release
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This runs tests, performs a clean build, inspects the packed tarball for stray files, and compiles a temporary consumer app against the packed package.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paybond Kit — TypeScript Harbor client with tenant binding, retries, optional upstream JWT,
|
|
3
|
+
* and gateway service-account token exchange (`POST /v1/auth/harbor-access`).
|
|
4
|
+
*/
|
|
5
|
+
import { type BuildSignedCreateIntentParams } from "./principal-intent.js";
|
|
6
|
+
import { type SignPayeeEvidenceParams } from "./payee-evidence.js";
|
|
7
|
+
export type VerifyCapabilityResult = {
|
|
8
|
+
allow: boolean;
|
|
9
|
+
auditId: string;
|
|
10
|
+
tenant: string;
|
|
11
|
+
intentId: string;
|
|
12
|
+
code?: string;
|
|
13
|
+
message?: string;
|
|
14
|
+
};
|
|
15
|
+
export type SubmitEvidenceResult = {
|
|
16
|
+
intentId: string;
|
|
17
|
+
tenant: string;
|
|
18
|
+
state: string;
|
|
19
|
+
predicatePassed?: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** Async supplier for short-lived Harbor JWTs minted by the Paybond gateway. */
|
|
22
|
+
export type HarborBearerSupplier = () => Promise<string | null | undefined>;
|
|
23
|
+
/**
|
|
24
|
+
* Structured HTTP failure from Harbor with operator-facing diagnostics.
|
|
25
|
+
*/
|
|
26
|
+
export declare class HarborHttpError extends Error {
|
|
27
|
+
readonly statusCode: number;
|
|
28
|
+
readonly url: string;
|
|
29
|
+
readonly bodyText: string;
|
|
30
|
+
constructor(message: string, init: {
|
|
31
|
+
statusCode: number;
|
|
32
|
+
url: string;
|
|
33
|
+
bodyText: string;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Gateway rejected the service-account exchange or returned an unusable harbor-access payload.
|
|
38
|
+
*/
|
|
39
|
+
export declare class GatewayAuthError extends Error {
|
|
40
|
+
readonly statusCode: number | undefined;
|
|
41
|
+
readonly bodyText: string | undefined;
|
|
42
|
+
constructor(message: string, init?: {
|
|
43
|
+
statusCode?: number;
|
|
44
|
+
bodyText?: string;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Exchanges a `paybond_sk_` API key for short-lived Harbor JWTs and caches tenant realm from the
|
|
49
|
+
* gateway response (no separate tenant env var for the default path).
|
|
50
|
+
*/
|
|
51
|
+
export declare class GatewayHarborTokenProvider {
|
|
52
|
+
private readonly gatewayBase;
|
|
53
|
+
private readonly apiKey;
|
|
54
|
+
private readonly path;
|
|
55
|
+
private readonly skewMs;
|
|
56
|
+
private readonly clock;
|
|
57
|
+
private token;
|
|
58
|
+
private tenantIdValue;
|
|
59
|
+
private notAfterMonotonic;
|
|
60
|
+
private refreshTail;
|
|
61
|
+
constructor(init: {
|
|
62
|
+
gatewayBaseUrl: string;
|
|
63
|
+
apiKey: string;
|
|
64
|
+
harborAccessPath?: string;
|
|
65
|
+
clockSkewSeconds?: number;
|
|
66
|
+
/** Injectable monotonic clock (milliseconds) for tests. */
|
|
67
|
+
clock?: () => number;
|
|
68
|
+
});
|
|
69
|
+
get tenantId(): string | null;
|
|
70
|
+
/**
|
|
71
|
+
* First exchange; returns tenant realm echoed by the gateway.
|
|
72
|
+
*/
|
|
73
|
+
ensureInitial(): Promise<string>;
|
|
74
|
+
/** Return a valid Harbor JWT, refreshing when near expiry. */
|
|
75
|
+
bearer(): Promise<string>;
|
|
76
|
+
/** Force rotation (credential rotation drills). */
|
|
77
|
+
forceRotate(): Promise<void>;
|
|
78
|
+
private refresh;
|
|
79
|
+
private refreshInner;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Tenant-scoped Harbor binding for one funded intent and one Biscuit capability token.
|
|
83
|
+
*/
|
|
84
|
+
export declare class PaybondCapabilityBinding {
|
|
85
|
+
readonly harbor: HarborClient;
|
|
86
|
+
readonly intentId: string;
|
|
87
|
+
readonly capabilityToken: string;
|
|
88
|
+
constructor(harbor: HarborClient, intentId: string, capabilityToken: string);
|
|
89
|
+
}
|
|
90
|
+
export type ServiceAccountHarborSessionInit = {
|
|
91
|
+
gatewayBaseUrl: string;
|
|
92
|
+
apiKey: string;
|
|
93
|
+
harborBaseUrl: string;
|
|
94
|
+
harborAccessPath?: string;
|
|
95
|
+
clockSkewSeconds?: number;
|
|
96
|
+
maxRetries?: number;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Harbor client plus gateway token lifecycle for one service account.
|
|
100
|
+
*/
|
|
101
|
+
export declare class ServiceAccountHarborSession {
|
|
102
|
+
readonly harbor: HarborClient;
|
|
103
|
+
private readonly tokens;
|
|
104
|
+
private constructor();
|
|
105
|
+
/**
|
|
106
|
+
* Build a tenant-bound {@link HarborClient} using gateway-derived tenant id and JWT supplier.
|
|
107
|
+
*/
|
|
108
|
+
static open(init: ServiceAccountHarborSessionInit): Promise<ServiceAccountHarborSession>;
|
|
109
|
+
rotateHarborToken(): Promise<void>;
|
|
110
|
+
/** Reserved for future HTTP client cleanup; safe to call after work completes. */
|
|
111
|
+
aclose(): Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
type HarborClientOptions = {
|
|
114
|
+
harborBearerSupplier?: HarborBearerSupplier;
|
|
115
|
+
staticHarborBearerToken?: string;
|
|
116
|
+
maxRetries?: number;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* HTTP client for Harbor: capability verify, intents, evidence, and tenant-scoped ledger reads
|
|
120
|
+
* (`GET /ledger/v1/*`, PAYBOND-007).
|
|
121
|
+
*/
|
|
122
|
+
export declare class HarborClient {
|
|
123
|
+
private readonly base;
|
|
124
|
+
/** Tenant realm from the gateway exchange; sent as `x-tenant-id` on every Harbor request. */
|
|
125
|
+
readonly tenantId: string;
|
|
126
|
+
private readonly bearerSupplier?;
|
|
127
|
+
private readonly staticBearer?;
|
|
128
|
+
private readonly maxRetries;
|
|
129
|
+
/**
|
|
130
|
+
* @param harborBase - Harbor origin, e.g. `https://harbor.example.com` (trailing slash optional)
|
|
131
|
+
* @param tenantId - Tenant realm; sent as `x-tenant-id` on every request
|
|
132
|
+
*/
|
|
133
|
+
constructor(harborBase: string, tenantId: string, options?: HarborClientOptions);
|
|
134
|
+
private authHeader;
|
|
135
|
+
/**
|
|
136
|
+
* Ledger JSON responses include `tenant_id`; reject if it drifts from the bound client tenant.
|
|
137
|
+
*/
|
|
138
|
+
private assertLedgerTenant;
|
|
139
|
+
/** GET with the same 429/5xx retry behavior as {@link HarborClient.fetchWithRetries}. */
|
|
140
|
+
private fetchGetWithRetries;
|
|
141
|
+
private fetchWithRetries;
|
|
142
|
+
/**
|
|
143
|
+
* POST `/verify` with a Biscuit capability token (PAYBOND-006).
|
|
144
|
+
*
|
|
145
|
+
* @throws HarborHttpError when HTTP fails
|
|
146
|
+
* @throws Error when Harbor echoes a different tenant / intent than requested
|
|
147
|
+
*/
|
|
148
|
+
verifyCapability(input: {
|
|
149
|
+
intentId: string;
|
|
150
|
+
token: string;
|
|
151
|
+
operation: string;
|
|
152
|
+
requestedSpendCents?: number;
|
|
153
|
+
}): Promise<VerifyCapabilityResult>;
|
|
154
|
+
/**
|
|
155
|
+
* POST `/intents` with a principal-signed `CreateIntentRequest` JSON body.
|
|
156
|
+
*
|
|
157
|
+
* @throws HarborHttpError when HTTP fails
|
|
158
|
+
*/
|
|
159
|
+
createIntent(body: Record<string, unknown>, options?: {
|
|
160
|
+
idempotencyKey?: string;
|
|
161
|
+
}): Promise<Record<string, unknown>>;
|
|
162
|
+
/**
|
|
163
|
+
* POST `/intents/{intentId}/evidence` with a signed evidence JSON body.
|
|
164
|
+
*
|
|
165
|
+
* @param idempotencyKey - Optional Harbor idempotency header for safe retries
|
|
166
|
+
*/
|
|
167
|
+
submitEvidence(intentId: string, evidenceBody: Record<string, unknown>, options?: {
|
|
168
|
+
idempotencyKey?: string;
|
|
169
|
+
}): Promise<SubmitEvidenceResult>;
|
|
170
|
+
/**
|
|
171
|
+
* `GET /ledger/v1/tip` — latest sequence and entry commitment for the authenticated tenant.
|
|
172
|
+
*
|
|
173
|
+
* @throws HarborHttpError on HTTP failure
|
|
174
|
+
* @throws Error when JSON `tenant_id` does not match the bound client tenant
|
|
175
|
+
*/
|
|
176
|
+
getLedgerTip(): Promise<Record<string, unknown>>;
|
|
177
|
+
/**
|
|
178
|
+
* `GET /ledger/v1/authority` — hex-encoded Ed25519 verifying key for this Harbor deployment.
|
|
179
|
+
*/
|
|
180
|
+
getLedgerAuthority(): Promise<Record<string, unknown>>;
|
|
181
|
+
/**
|
|
182
|
+
* `GET /ledger/v1/events` — paginated append-only history; `afterSeq` is an exclusive cursor.
|
|
183
|
+
* `limit` is clamped to 1…256 to match Harbor.
|
|
184
|
+
*/
|
|
185
|
+
getLedgerEvents(options?: {
|
|
186
|
+
afterSeq?: number;
|
|
187
|
+
limit?: number;
|
|
188
|
+
}): Promise<Record<string, unknown>>;
|
|
189
|
+
/**
|
|
190
|
+
* `GET /ledger/v1/merkle/latest` — last Merkle checkpoint envelope for the tenant (checkpoint may be null).
|
|
191
|
+
*/
|
|
192
|
+
getLedgerMerkleLatest(): Promise<Record<string, unknown>>;
|
|
193
|
+
}
|
|
194
|
+
/** Parameters for {@link PaybondIntents.create} (tenant is taken from the bound Harbor client). */
|
|
195
|
+
export type PaybondCreateIntentParams = Omit<BuildSignedCreateIntentParams, "tenantId" | "intentId"> & {
|
|
196
|
+
intentId?: string;
|
|
197
|
+
};
|
|
198
|
+
/** Parameters for {@link PaybondIntents.submitEvidence} (tenant is taken from the bound Harbor client). */
|
|
199
|
+
export type PaybondSubmitEvidenceParams = Omit<SignPayeeEvidenceParams, "tenantId">;
|
|
200
|
+
/**
|
|
201
|
+
* Ergonomic intent helpers: principal-signed intent create and payee-signed evidence.
|
|
202
|
+
*/
|
|
203
|
+
export declare class PaybondIntents {
|
|
204
|
+
private readonly harbor;
|
|
205
|
+
constructor(harbor: HarborClient);
|
|
206
|
+
/**
|
|
207
|
+
* Build a principal-signed `POST /intents` body and submit it. `principalSigningSeed` must be 32 bytes.
|
|
208
|
+
*/
|
|
209
|
+
create(params: PaybondCreateIntentParams & {
|
|
210
|
+
idempotencyKey?: string;
|
|
211
|
+
}): Promise<Record<string, unknown>>;
|
|
212
|
+
/**
|
|
213
|
+
* Sign payee evidence and POST it. `payeeSigningSeed` must be 32 bytes.
|
|
214
|
+
*/
|
|
215
|
+
submitEvidence(params: PaybondSubmitEvidenceParams & {
|
|
216
|
+
idempotencyKey?: string;
|
|
217
|
+
}): Promise<SubmitEvidenceResult>;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* High-level Kit entrypoint: same session lifecycle as {@link ServiceAccountHarborSession}, plus {@link PaybondIntents}.
|
|
221
|
+
*/
|
|
222
|
+
export declare class Paybond {
|
|
223
|
+
readonly harbor: HarborClient;
|
|
224
|
+
readonly intents: PaybondIntents;
|
|
225
|
+
private readonly session;
|
|
226
|
+
private constructor();
|
|
227
|
+
/** Open a tenant-bound session via gateway `harbor-access` exchange. */
|
|
228
|
+
static open(init: ServiceAccountHarborSessionInit): Promise<Paybond>;
|
|
229
|
+
rotateHarborToken(): Promise<void>;
|
|
230
|
+
/** Release HTTP resources (Harbor client + gateway token provider). */
|
|
231
|
+
aclose(): Promise<void>;
|
|
232
|
+
}
|
|
233
|
+
export { normalizeJson, jsonValueDigest } from "./json-digest.js";
|
|
234
|
+
export { buildSignedCreateIntentBody, intentCreationSignBytesRaw, type BuildSignedCreateIntentParams, } from "./principal-intent.js";
|
|
235
|
+
export { artifactsDigest, signPayeeEvidenceBinding, type SignPayeeEvidenceParams } from "./payee-evidence.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paybond Kit — TypeScript Harbor client with tenant binding, retries, optional upstream JWT,
|
|
3
|
+
* and gateway service-account token exchange (`POST /v1/auth/harbor-access`).
|
|
4
|
+
*/
|
|
5
|
+
import { buildSignedCreateIntentBody } from "./principal-intent.js";
|
|
6
|
+
import { signPayeeEvidenceBinding } from "./payee-evidence.js";
|
|
7
|
+
/**
|
|
8
|
+
* Structured HTTP failure from Harbor with operator-facing diagnostics.
|
|
9
|
+
*/
|
|
10
|
+
export class HarborHttpError extends Error {
|
|
11
|
+
statusCode;
|
|
12
|
+
url;
|
|
13
|
+
bodyText;
|
|
14
|
+
constructor(message, init) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "HarborHttpError";
|
|
17
|
+
this.statusCode = init.statusCode;
|
|
18
|
+
this.url = init.url;
|
|
19
|
+
this.bodyText = init.bodyText;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Gateway rejected the service-account exchange or returned an unusable harbor-access payload.
|
|
24
|
+
*/
|
|
25
|
+
export class GatewayAuthError extends Error {
|
|
26
|
+
statusCode;
|
|
27
|
+
bodyText;
|
|
28
|
+
constructor(message, init) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "GatewayAuthError";
|
|
31
|
+
this.statusCode = init?.statusCode;
|
|
32
|
+
this.bodyText = init?.bodyText;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function normalizeBase(url) {
|
|
36
|
+
return url.trim().replace(/\/+$/, "");
|
|
37
|
+
}
|
|
38
|
+
function backoffMs(attempt) {
|
|
39
|
+
const base = 200 * 2 ** attempt;
|
|
40
|
+
const jitter = Math.random() * 100;
|
|
41
|
+
return Math.min(base + jitter, 5000);
|
|
42
|
+
}
|
|
43
|
+
function parseRetryAfterSeconds(v) {
|
|
44
|
+
if (!v)
|
|
45
|
+
return null;
|
|
46
|
+
const n = Number.parseFloat(v.trim());
|
|
47
|
+
if (!Number.isFinite(n))
|
|
48
|
+
return null;
|
|
49
|
+
return Math.min(n, 30);
|
|
50
|
+
}
|
|
51
|
+
const DEFAULT_HARBOR_ACCESS_PATH = "/v1/auth/harbor-access";
|
|
52
|
+
/**
|
|
53
|
+
* Exchanges a `paybond_sk_` API key for short-lived Harbor JWTs and caches tenant realm from the
|
|
54
|
+
* gateway response (no separate tenant env var for the default path).
|
|
55
|
+
*/
|
|
56
|
+
export class GatewayHarborTokenProvider {
|
|
57
|
+
gatewayBase;
|
|
58
|
+
apiKey;
|
|
59
|
+
path;
|
|
60
|
+
skewMs;
|
|
61
|
+
clock;
|
|
62
|
+
token = null;
|
|
63
|
+
tenantIdValue = null;
|
|
64
|
+
notAfterMonotonic = 0;
|
|
65
|
+
refreshTail = Promise.resolve();
|
|
66
|
+
constructor(init) {
|
|
67
|
+
this.gatewayBase = normalizeBase(init.gatewayBaseUrl);
|
|
68
|
+
this.apiKey = init.apiKey.trim();
|
|
69
|
+
const rawPath = (init.harborAccessPath ?? DEFAULT_HARBOR_ACCESS_PATH).trim();
|
|
70
|
+
this.path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
|
71
|
+
this.skewMs = Math.max(0, (init.clockSkewSeconds ?? 90) * 1000);
|
|
72
|
+
this.clock = init.clock ?? (() => performance.now());
|
|
73
|
+
}
|
|
74
|
+
get tenantId() {
|
|
75
|
+
return this.tenantIdValue;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* First exchange; returns tenant realm echoed by the gateway.
|
|
79
|
+
*/
|
|
80
|
+
async ensureInitial() {
|
|
81
|
+
await this.refresh(true);
|
|
82
|
+
if (!this.tenantIdValue) {
|
|
83
|
+
throw new GatewayAuthError("harbor-access response missing tenant_id; upgrade gateway (PAYBOND-V1-008)");
|
|
84
|
+
}
|
|
85
|
+
return this.tenantIdValue;
|
|
86
|
+
}
|
|
87
|
+
/** Return a valid Harbor JWT, refreshing when near expiry. */
|
|
88
|
+
async bearer() {
|
|
89
|
+
await this.refresh(false);
|
|
90
|
+
if (!this.token) {
|
|
91
|
+
throw new GatewayAuthError("harbor-access did not return access_token");
|
|
92
|
+
}
|
|
93
|
+
return this.token;
|
|
94
|
+
}
|
|
95
|
+
/** Force rotation (credential rotation drills). */
|
|
96
|
+
async forceRotate() {
|
|
97
|
+
await this.refresh(true);
|
|
98
|
+
}
|
|
99
|
+
async refresh(force) {
|
|
100
|
+
const job = this.refreshTail.then(() => this.refreshInner(force));
|
|
101
|
+
this.refreshTail = job.then(() => undefined, () => undefined);
|
|
102
|
+
await job;
|
|
103
|
+
}
|
|
104
|
+
async refreshInner(force) {
|
|
105
|
+
const now = this.clock();
|
|
106
|
+
if (!force && this.token && now < this.notAfterMonotonic) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const url = `${this.gatewayBase}${this.path}`;
|
|
110
|
+
const res = await fetch(url, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: {
|
|
113
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
114
|
+
accept: "application/json",
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
const text = await res.text();
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
throw new GatewayAuthError(`harbor-access HTTP ${res.status}`, {
|
|
120
|
+
statusCode: res.status,
|
|
121
|
+
bodyText: text,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
let body;
|
|
125
|
+
try {
|
|
126
|
+
body = JSON.parse(text);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new GatewayAuthError("harbor-access response was not JSON", { bodyText: text });
|
|
130
|
+
}
|
|
131
|
+
const access = String(body.access_token ?? "").trim();
|
|
132
|
+
if (!access) {
|
|
133
|
+
throw new GatewayAuthError("harbor-access JSON missing access_token", { bodyText: text });
|
|
134
|
+
}
|
|
135
|
+
const expIn = Number(body.expires_in ?? 0);
|
|
136
|
+
if (!Number.isFinite(expIn) || expIn <= 0) {
|
|
137
|
+
throw new GatewayAuthError("harbor-access JSON missing expires_in", { bodyText: text });
|
|
138
|
+
}
|
|
139
|
+
const tidRaw = body.tenant_id;
|
|
140
|
+
if (typeof tidRaw === "string" && tidRaw.trim()) {
|
|
141
|
+
this.tenantIdValue = tidRaw.trim();
|
|
142
|
+
}
|
|
143
|
+
if (!this.tenantIdValue) {
|
|
144
|
+
throw new GatewayAuthError("harbor-access response missing tenant_id; upgrade gateway (PAYBOND-V1-008)", { bodyText: text });
|
|
145
|
+
}
|
|
146
|
+
this.token = access;
|
|
147
|
+
this.notAfterMonotonic = now + Math.max(1000, expIn * 1000 - this.skewMs);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Tenant-scoped Harbor binding for one funded intent and one Biscuit capability token.
|
|
152
|
+
*/
|
|
153
|
+
export class PaybondCapabilityBinding {
|
|
154
|
+
harbor;
|
|
155
|
+
intentId;
|
|
156
|
+
capabilityToken;
|
|
157
|
+
constructor(harbor, intentId, capabilityToken) {
|
|
158
|
+
this.harbor = harbor;
|
|
159
|
+
this.intentId = intentId;
|
|
160
|
+
this.capabilityToken = capabilityToken;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Harbor client plus gateway token lifecycle for one service account.
|
|
165
|
+
*/
|
|
166
|
+
export class ServiceAccountHarborSession {
|
|
167
|
+
harbor;
|
|
168
|
+
tokens;
|
|
169
|
+
constructor(harbor, tokens) {
|
|
170
|
+
this.harbor = harbor;
|
|
171
|
+
this.tokens = tokens;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Build a tenant-bound {@link HarborClient} using gateway-derived tenant id and JWT supplier.
|
|
175
|
+
*/
|
|
176
|
+
static async open(init) {
|
|
177
|
+
const tokens = new GatewayHarborTokenProvider({
|
|
178
|
+
gatewayBaseUrl: init.gatewayBaseUrl,
|
|
179
|
+
apiKey: init.apiKey,
|
|
180
|
+
harborAccessPath: init.harborAccessPath,
|
|
181
|
+
clockSkewSeconds: init.clockSkewSeconds,
|
|
182
|
+
});
|
|
183
|
+
const tenant = await tokens.ensureInitial();
|
|
184
|
+
const harbor = new HarborClient(init.harborBaseUrl, tenant, {
|
|
185
|
+
harborBearerSupplier: () => tokens.bearer(),
|
|
186
|
+
maxRetries: init.maxRetries ?? 3,
|
|
187
|
+
});
|
|
188
|
+
return new ServiceAccountHarborSession(harbor, tokens);
|
|
189
|
+
}
|
|
190
|
+
async rotateHarborToken() {
|
|
191
|
+
await this.tokens.forceRotate();
|
|
192
|
+
}
|
|
193
|
+
/** Reserved for future HTTP client cleanup; safe to call after work completes. */
|
|
194
|
+
async aclose() {
|
|
195
|
+
await Promise.resolve();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* HTTP client for Harbor: capability verify, intents, evidence, and tenant-scoped ledger reads
|
|
200
|
+
* (`GET /ledger/v1/*`, PAYBOND-007).
|
|
201
|
+
*/
|
|
202
|
+
export class HarborClient {
|
|
203
|
+
base;
|
|
204
|
+
/** Tenant realm from the gateway exchange; sent as `x-tenant-id` on every Harbor request. */
|
|
205
|
+
tenantId;
|
|
206
|
+
bearerSupplier;
|
|
207
|
+
staticBearer;
|
|
208
|
+
maxRetries;
|
|
209
|
+
/**
|
|
210
|
+
* @param harborBase - Harbor origin, e.g. `https://harbor.example.com` (trailing slash optional)
|
|
211
|
+
* @param tenantId - Tenant realm; sent as `x-tenant-id` on every request
|
|
212
|
+
*/
|
|
213
|
+
constructor(harborBase, tenantId, options) {
|
|
214
|
+
if (options?.harborBearerSupplier && options?.staticHarborBearerToken) {
|
|
215
|
+
throw new Error("pass at most one of harborBearerSupplier or staticHarborBearerToken");
|
|
216
|
+
}
|
|
217
|
+
this.base = normalizeBase(harborBase) + "/";
|
|
218
|
+
this.tenantId = tenantId.trim();
|
|
219
|
+
this.bearerSupplier = options?.harborBearerSupplier;
|
|
220
|
+
this.staticBearer = options?.staticHarborBearerToken?.trim();
|
|
221
|
+
this.maxRetries = Math.max(1, options?.maxRetries ?? 3);
|
|
222
|
+
}
|
|
223
|
+
async authHeader() {
|
|
224
|
+
if (this.staticBearer) {
|
|
225
|
+
return { authorization: `Bearer ${this.staticBearer}` };
|
|
226
|
+
}
|
|
227
|
+
if (this.bearerSupplier) {
|
|
228
|
+
const tok = await this.bearerSupplier();
|
|
229
|
+
if (tok && String(tok).trim()) {
|
|
230
|
+
return { authorization: `Bearer ${String(tok).trim()}` };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return {};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Ledger JSON responses include `tenant_id`; reject if it drifts from the bound client tenant.
|
|
237
|
+
*/
|
|
238
|
+
assertLedgerTenant(body, url) {
|
|
239
|
+
const tid = String(body.tenant_id ?? "");
|
|
240
|
+
if (tid !== this.tenantId) {
|
|
241
|
+
throw new Error(`ledger tenant mismatch: client=${this.tenantId} harbor=${tid} url=${url}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** GET with the same 429/5xx retry behavior as {@link HarborClient.fetchWithRetries}. */
|
|
245
|
+
async fetchGetWithRetries(url) {
|
|
246
|
+
let lastErr;
|
|
247
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
248
|
+
let res;
|
|
249
|
+
try {
|
|
250
|
+
const headers = new Headers({
|
|
251
|
+
accept: "application/json",
|
|
252
|
+
"x-tenant-id": this.tenantId,
|
|
253
|
+
});
|
|
254
|
+
const auth = await this.authHeader();
|
|
255
|
+
for (const [k, v] of Object.entries(auth)) {
|
|
256
|
+
headers.set(k, v);
|
|
257
|
+
}
|
|
258
|
+
res = await fetch(url, { method: "GET", headers });
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
lastErr = e;
|
|
262
|
+
if (attempt + 1 >= this.maxRetries)
|
|
263
|
+
throw e;
|
|
264
|
+
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if ([429, 500, 502, 503, 504].includes(res.status)) {
|
|
268
|
+
if (attempt + 1 >= this.maxRetries) {
|
|
269
|
+
return res;
|
|
270
|
+
}
|
|
271
|
+
const raSec = parseRetryAfterSeconds(res.headers.get("retry-after"));
|
|
272
|
+
const delayMs = raSec != null ? raSec * 1000 : backoffMs(attempt);
|
|
273
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
return res;
|
|
277
|
+
}
|
|
278
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
279
|
+
}
|
|
280
|
+
async fetchWithRetries(url, init, { retryBody }) {
|
|
281
|
+
let lastErr;
|
|
282
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
283
|
+
let res;
|
|
284
|
+
try {
|
|
285
|
+
const headers = new Headers(init.headers);
|
|
286
|
+
const auth = await this.authHeader();
|
|
287
|
+
for (const [k, v] of Object.entries(auth)) {
|
|
288
|
+
headers.set(k, v);
|
|
289
|
+
}
|
|
290
|
+
res = await fetch(url, { ...init, headers });
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
lastErr = e;
|
|
294
|
+
if (attempt + 1 >= this.maxRetries)
|
|
295
|
+
throw e;
|
|
296
|
+
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if ([429, 500, 502, 503, 504].includes(res.status)) {
|
|
300
|
+
if (attempt + 1 >= this.maxRetries) {
|
|
301
|
+
return res;
|
|
302
|
+
}
|
|
303
|
+
const raSec = parseRetryAfterSeconds(res.headers.get("retry-after"));
|
|
304
|
+
const delayMs = raSec != null ? raSec * 1000 : backoffMs(attempt);
|
|
305
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
306
|
+
init = {
|
|
307
|
+
...init,
|
|
308
|
+
body: JSON.stringify(retryBody),
|
|
309
|
+
};
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
return res;
|
|
313
|
+
}
|
|
314
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* POST `/verify` with a Biscuit capability token (PAYBOND-006).
|
|
318
|
+
*
|
|
319
|
+
* @throws HarborHttpError when HTTP fails
|
|
320
|
+
* @throws Error when Harbor echoes a different tenant / intent than requested
|
|
321
|
+
*/
|
|
322
|
+
async verifyCapability(input) {
|
|
323
|
+
const url = `${this.base}verify`;
|
|
324
|
+
const payload = {
|
|
325
|
+
intent_id: input.intentId,
|
|
326
|
+
token: input.token,
|
|
327
|
+
operation: input.operation,
|
|
328
|
+
requested_spend_cents: input.requestedSpendCents ?? 0,
|
|
329
|
+
};
|
|
330
|
+
const res = await this.fetchWithRetries(url, {
|
|
331
|
+
method: "POST",
|
|
332
|
+
headers: {
|
|
333
|
+
"content-type": "application/json",
|
|
334
|
+
"x-tenant-id": this.tenantId,
|
|
335
|
+
},
|
|
336
|
+
body: JSON.stringify(payload),
|
|
337
|
+
}, { retryBody: payload });
|
|
338
|
+
const text = await res.text();
|
|
339
|
+
if (!res.ok) {
|
|
340
|
+
throw new HarborHttpError(`Harbor verify HTTP ${res.status}: ${text}`, {
|
|
341
|
+
statusCode: res.status,
|
|
342
|
+
url,
|
|
343
|
+
bodyText: text,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const body = JSON.parse(text);
|
|
347
|
+
if (body.tenant !== this.tenantId) {
|
|
348
|
+
throw new Error(`verify tenant mismatch: client=${this.tenantId} harbor=${body.tenant}`);
|
|
349
|
+
}
|
|
350
|
+
if (body.intent_id !== input.intentId) {
|
|
351
|
+
throw new Error(`verify intent mismatch: requested=${input.intentId} harbor=${body.intent_id}`);
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
allow: body.allow,
|
|
355
|
+
auditId: body.audit_id,
|
|
356
|
+
tenant: body.tenant,
|
|
357
|
+
intentId: body.intent_id,
|
|
358
|
+
code: body.code,
|
|
359
|
+
message: body.message,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* POST `/intents` with a principal-signed `CreateIntentRequest` JSON body.
|
|
364
|
+
*
|
|
365
|
+
* @throws HarborHttpError when HTTP fails
|
|
366
|
+
*/
|
|
367
|
+
async createIntent(body, options) {
|
|
368
|
+
const url = `${this.base}intents`;
|
|
369
|
+
const headers = {
|
|
370
|
+
"content-type": "application/json",
|
|
371
|
+
"x-tenant-id": this.tenantId,
|
|
372
|
+
};
|
|
373
|
+
if (options?.idempotencyKey?.trim()) {
|
|
374
|
+
headers["idempotency-key"] = options.idempotencyKey.trim();
|
|
375
|
+
}
|
|
376
|
+
const res = await this.fetchWithRetries(url, {
|
|
377
|
+
method: "POST",
|
|
378
|
+
headers,
|
|
379
|
+
body: JSON.stringify(body),
|
|
380
|
+
}, { retryBody: body });
|
|
381
|
+
const text = await res.text();
|
|
382
|
+
if (!res.ok) {
|
|
383
|
+
throw new HarborHttpError(`Harbor create intent HTTP ${res.status}: ${text}`, {
|
|
384
|
+
statusCode: res.status,
|
|
385
|
+
url,
|
|
386
|
+
bodyText: text,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return JSON.parse(text);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* POST `/intents/{intentId}/evidence` with a signed evidence JSON body.
|
|
393
|
+
*
|
|
394
|
+
* @param idempotencyKey - Optional Harbor idempotency header for safe retries
|
|
395
|
+
*/
|
|
396
|
+
async submitEvidence(intentId, evidenceBody, options) {
|
|
397
|
+
const url = `${this.base}intents/${intentId}/evidence`;
|
|
398
|
+
const headers = {
|
|
399
|
+
"content-type": "application/json",
|
|
400
|
+
"x-tenant-id": this.tenantId,
|
|
401
|
+
};
|
|
402
|
+
if (options?.idempotencyKey?.trim()) {
|
|
403
|
+
headers["idempotency-key"] = options.idempotencyKey.trim();
|
|
404
|
+
}
|
|
405
|
+
const res = await this.fetchWithRetries(url, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers,
|
|
408
|
+
body: JSON.stringify(evidenceBody),
|
|
409
|
+
}, { retryBody: evidenceBody });
|
|
410
|
+
const text = await res.text();
|
|
411
|
+
if (!res.ok) {
|
|
412
|
+
throw new HarborHttpError(`Harbor evidence HTTP ${res.status}: ${text}`, {
|
|
413
|
+
statusCode: res.status,
|
|
414
|
+
url,
|
|
415
|
+
bodyText: text,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const body = JSON.parse(text);
|
|
419
|
+
return {
|
|
420
|
+
intentId: body.intent_id,
|
|
421
|
+
tenant: body.tenant,
|
|
422
|
+
state: body.state,
|
|
423
|
+
predicatePassed: body.predicate_passed,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* `GET /ledger/v1/tip` — latest sequence and entry commitment for the authenticated tenant.
|
|
428
|
+
*
|
|
429
|
+
* @throws HarborHttpError on HTTP failure
|
|
430
|
+
* @throws Error when JSON `tenant_id` does not match the bound client tenant
|
|
431
|
+
*/
|
|
432
|
+
async getLedgerTip() {
|
|
433
|
+
const url = `${this.base}ledger/v1/tip`;
|
|
434
|
+
const res = await this.fetchGetWithRetries(url);
|
|
435
|
+
const text = await res.text();
|
|
436
|
+
if (!res.ok) {
|
|
437
|
+
throw new HarborHttpError(`Harbor ledger tip HTTP ${res.status}: ${text}`, {
|
|
438
|
+
statusCode: res.status,
|
|
439
|
+
url,
|
|
440
|
+
bodyText: text,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const body = JSON.parse(text);
|
|
444
|
+
this.assertLedgerTenant(body, url);
|
|
445
|
+
return body;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* `GET /ledger/v1/authority` — hex-encoded Ed25519 verifying key for this Harbor deployment.
|
|
449
|
+
*/
|
|
450
|
+
async getLedgerAuthority() {
|
|
451
|
+
const url = `${this.base}ledger/v1/authority`;
|
|
452
|
+
const res = await this.fetchGetWithRetries(url);
|
|
453
|
+
const text = await res.text();
|
|
454
|
+
if (!res.ok) {
|
|
455
|
+
throw new HarborHttpError(`Harbor ledger authority HTTP ${res.status}: ${text}`, {
|
|
456
|
+
statusCode: res.status,
|
|
457
|
+
url,
|
|
458
|
+
bodyText: text,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
const body = JSON.parse(text);
|
|
462
|
+
this.assertLedgerTenant(body, url);
|
|
463
|
+
return body;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* `GET /ledger/v1/events` — paginated append-only history; `afterSeq` is an exclusive cursor.
|
|
467
|
+
* `limit` is clamped to 1…256 to match Harbor.
|
|
468
|
+
*/
|
|
469
|
+
async getLedgerEvents(options) {
|
|
470
|
+
const afterSeq = Math.max(0, Math.floor(options?.afterSeq ?? 0));
|
|
471
|
+
const rawLimit = options?.limit ?? 64;
|
|
472
|
+
const limit = Math.max(1, Math.min(Math.floor(rawLimit), 256));
|
|
473
|
+
const qs = new URLSearchParams({
|
|
474
|
+
after_seq: String(afterSeq),
|
|
475
|
+
limit: String(limit),
|
|
476
|
+
});
|
|
477
|
+
const url = `${this.base}ledger/v1/events?${qs.toString()}`;
|
|
478
|
+
const res = await this.fetchGetWithRetries(url);
|
|
479
|
+
const text = await res.text();
|
|
480
|
+
if (!res.ok) {
|
|
481
|
+
throw new HarborHttpError(`Harbor ledger events HTTP ${res.status}: ${text}`, {
|
|
482
|
+
statusCode: res.status,
|
|
483
|
+
url,
|
|
484
|
+
bodyText: text,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
const body = JSON.parse(text);
|
|
488
|
+
this.assertLedgerTenant(body, url);
|
|
489
|
+
return body;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* `GET /ledger/v1/merkle/latest` — last Merkle checkpoint envelope for the tenant (checkpoint may be null).
|
|
493
|
+
*/
|
|
494
|
+
async getLedgerMerkleLatest() {
|
|
495
|
+
const url = `${this.base}ledger/v1/merkle/latest`;
|
|
496
|
+
const res = await this.fetchGetWithRetries(url);
|
|
497
|
+
const text = await res.text();
|
|
498
|
+
if (!res.ok) {
|
|
499
|
+
throw new HarborHttpError(`Harbor ledger merkle HTTP ${res.status}: ${text}`, {
|
|
500
|
+
statusCode: res.status,
|
|
501
|
+
url,
|
|
502
|
+
bodyText: text,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
const body = JSON.parse(text);
|
|
506
|
+
this.assertLedgerTenant(body, url);
|
|
507
|
+
return body;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Ergonomic intent helpers: principal-signed intent create and payee-signed evidence.
|
|
512
|
+
*/
|
|
513
|
+
export class PaybondIntents {
|
|
514
|
+
harbor;
|
|
515
|
+
constructor(harbor) {
|
|
516
|
+
this.harbor = harbor;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Build a principal-signed `POST /intents` body and submit it. `principalSigningSeed` must be 32 bytes.
|
|
520
|
+
*/
|
|
521
|
+
async create(params) {
|
|
522
|
+
const { idempotencyKey, intentId: maybeIntentId, ...fields } = params;
|
|
523
|
+
const intentId = maybeIntentId ?? globalThis.crypto.randomUUID();
|
|
524
|
+
const body = buildSignedCreateIntentBody({
|
|
525
|
+
tenantId: this.harbor.tenantId,
|
|
526
|
+
intentId,
|
|
527
|
+
...fields,
|
|
528
|
+
});
|
|
529
|
+
return this.harbor.createIntent(body, { idempotencyKey });
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Sign payee evidence and POST it. `payeeSigningSeed` must be 32 bytes.
|
|
533
|
+
*/
|
|
534
|
+
async submitEvidence(params) {
|
|
535
|
+
const { idempotencyKey, ...rest } = params;
|
|
536
|
+
const wire = signPayeeEvidenceBinding({
|
|
537
|
+
tenantId: this.harbor.tenantId,
|
|
538
|
+
...rest,
|
|
539
|
+
});
|
|
540
|
+
return this.harbor.submitEvidence(rest.intentId, wire, { idempotencyKey });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* High-level Kit entrypoint: same session lifecycle as {@link ServiceAccountHarborSession}, plus {@link PaybondIntents}.
|
|
545
|
+
*/
|
|
546
|
+
export class Paybond {
|
|
547
|
+
harbor;
|
|
548
|
+
intents;
|
|
549
|
+
session;
|
|
550
|
+
constructor(session) {
|
|
551
|
+
this.session = session;
|
|
552
|
+
this.harbor = session.harbor;
|
|
553
|
+
this.intents = new PaybondIntents(session.harbor);
|
|
554
|
+
}
|
|
555
|
+
/** Open a tenant-bound session via gateway `harbor-access` exchange. */
|
|
556
|
+
static async open(init) {
|
|
557
|
+
const session = await ServiceAccountHarborSession.open(init);
|
|
558
|
+
return new Paybond(session);
|
|
559
|
+
}
|
|
560
|
+
async rotateHarborToken() {
|
|
561
|
+
await this.session.rotateHarborToken();
|
|
562
|
+
}
|
|
563
|
+
/** Release HTTP resources (Harbor client + gateway token provider). */
|
|
564
|
+
async aclose() {
|
|
565
|
+
await this.session.aclose();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
export { normalizeJson, jsonValueDigest } from "./json-digest.js";
|
|
569
|
+
export { buildSignedCreateIntentBody, intentCreationSignBytesRaw, } from "./principal-intent.js";
|
|
570
|
+
export { artifactsDigest, signPayeeEvidenceBinding } from "./payee-evidence.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON normalization and BLAKE3 digest (matches `paybond-evidence` / Harbor signing).
|
|
3
|
+
*/
|
|
4
|
+
/** Recursively sort object keys for stable JSON serialization. */
|
|
5
|
+
export declare function normalizeJson(value: unknown): unknown;
|
|
6
|
+
/** BLAKE3 digest (32 bytes) over compact JSON of {@link normalizeJson}. */
|
|
7
|
+
export declare function jsonValueDigest(value: unknown): Uint8Array;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON normalization and BLAKE3 digest (matches `paybond-evidence` / Harbor signing).
|
|
3
|
+
*/
|
|
4
|
+
import { hash } from "blake3";
|
|
5
|
+
/** Recursively sort object keys for stable JSON serialization. */
|
|
6
|
+
export function normalizeJson(value) {
|
|
7
|
+
if (value === null || typeof value !== "object") {
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
return value.map((item) => normalizeJson(item));
|
|
12
|
+
}
|
|
13
|
+
const obj = value;
|
|
14
|
+
const keys = Object.keys(obj).sort();
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const k of keys) {
|
|
17
|
+
if (Object.prototype.hasOwnProperty.call(obj, k)) {
|
|
18
|
+
out[k] = normalizeJson(obj[k]);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** BLAKE3 digest (32 bytes) over compact JSON of {@link normalizeJson}. */
|
|
24
|
+
export function jsonValueDigest(value) {
|
|
25
|
+
const normalized = normalizeJson(value);
|
|
26
|
+
const text = JSON.stringify(normalized);
|
|
27
|
+
const buf = hash(text, { length: 32 });
|
|
28
|
+
return new Uint8Array(buf);
|
|
29
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payee evidence signing bytes (matches `paybond-evidence` `EvidenceSignV1` + `encode_evidence_sign_v1`).
|
|
3
|
+
*/
|
|
4
|
+
/** BLAKE3 digest of concatenated artifact hashes (empty list matches Harbor / `paybond-evidence`). */
|
|
5
|
+
export declare function artifactsDigest(artifactHashes32: Uint8Array[]): Uint8Array;
|
|
6
|
+
export type SignPayeeEvidenceParams = {
|
|
7
|
+
tenantId: string;
|
|
8
|
+
intentId: string;
|
|
9
|
+
payeeDid: string;
|
|
10
|
+
payload: Record<string, unknown>;
|
|
11
|
+
artifactsBlake3Hex: string[];
|
|
12
|
+
submittedAtRfc3339: string;
|
|
13
|
+
payeeSigningSeed: Uint8Array;
|
|
14
|
+
};
|
|
15
|
+
/** Build Harbor `POST /intents/{id}/evidence` JSON with detached Ed25519 payee signature. */
|
|
16
|
+
export declare function signPayeeEvidenceBinding(params: SignPayeeEvidenceParams): Record<string, unknown>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payee evidence signing bytes (matches `paybond-evidence` `EvidenceSignV1` + `encode_evidence_sign_v1`).
|
|
3
|
+
*/
|
|
4
|
+
import { sign, getPublicKey } from "@noble/ed25519";
|
|
5
|
+
import { createHash } from "blake3";
|
|
6
|
+
import { parse as parseUuid } from "uuid";
|
|
7
|
+
import { jsonValueDigest } from "./json-digest.js";
|
|
8
|
+
function concatBytes(...parts) {
|
|
9
|
+
const n = parts.reduce((a, p) => a + p.length, 0);
|
|
10
|
+
const out = new Uint8Array(n);
|
|
11
|
+
let o = 0;
|
|
12
|
+
for (const p of parts) {
|
|
13
|
+
out.set(p, o);
|
|
14
|
+
o += p.length;
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
function encodeU64(n) {
|
|
19
|
+
const b = new Uint8Array(8);
|
|
20
|
+
new DataView(b.buffer).setBigUint64(0, BigInt(n), true);
|
|
21
|
+
return b;
|
|
22
|
+
}
|
|
23
|
+
function encodeBincodeString(s) {
|
|
24
|
+
const utf8 = new TextEncoder().encode(s);
|
|
25
|
+
return concatBytes(encodeU64(utf8.length), utf8);
|
|
26
|
+
}
|
|
27
|
+
/** BLAKE3 digest of concatenated artifact hashes (empty list matches Harbor / `paybond-evidence`). */
|
|
28
|
+
export function artifactsDigest(artifactHashes32) {
|
|
29
|
+
const h = createHash();
|
|
30
|
+
for (const a of artifactHashes32) {
|
|
31
|
+
h.update(a);
|
|
32
|
+
}
|
|
33
|
+
const buf = h.digest({ length: 32 });
|
|
34
|
+
return new Uint8Array(buf);
|
|
35
|
+
}
|
|
36
|
+
function encodeEvidenceSignV1(input) {
|
|
37
|
+
const version = new Uint8Array([1]);
|
|
38
|
+
const intentBytes = parseUuid(input.intentId);
|
|
39
|
+
if (intentBytes.length !== 16) {
|
|
40
|
+
throw new Error("intentId must be a UUID string");
|
|
41
|
+
}
|
|
42
|
+
if (input.payloadDigest.length !== 32 || input.artifactsDigest.length !== 32) {
|
|
43
|
+
throw new Error("digest must be 32 bytes");
|
|
44
|
+
}
|
|
45
|
+
return concatBytes(version, encodeBincodeString(input.tenantId), encodeU64(16), intentBytes, encodeBincodeString(input.payeeDid), input.payloadDigest, input.artifactsDigest, encodeBincodeString(input.submittedAtRfc3339));
|
|
46
|
+
}
|
|
47
|
+
function bytesToBase64(bytes) {
|
|
48
|
+
let binary = "";
|
|
49
|
+
for (let i = 0; i < bytes.length; i++)
|
|
50
|
+
binary += String.fromCharCode(bytes[i]);
|
|
51
|
+
return btoa(binary);
|
|
52
|
+
}
|
|
53
|
+
function hexToBytes(hex) {
|
|
54
|
+
const s = hex.trim();
|
|
55
|
+
if (s.length % 2 !== 0)
|
|
56
|
+
throw new Error("bad hex");
|
|
57
|
+
const out = new Uint8Array(s.length / 2);
|
|
58
|
+
for (let i = 0; i < out.length; i++) {
|
|
59
|
+
out[i] = Number.parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/** Build Harbor `POST /intents/{id}/evidence` JSON with detached Ed25519 payee signature. */
|
|
64
|
+
export function signPayeeEvidenceBinding(params) {
|
|
65
|
+
if (params.payeeSigningSeed.length !== 32) {
|
|
66
|
+
throw new Error("payeeSigningSeed must be 32 bytes");
|
|
67
|
+
}
|
|
68
|
+
const artifactBin = params.artifactsBlake3Hex.map((h) => hexToBytes(h));
|
|
69
|
+
const payloadDigest = jsonValueDigest(params.payload);
|
|
70
|
+
const artDigest = artifactsDigest(artifactBin);
|
|
71
|
+
const msg = encodeEvidenceSignV1({
|
|
72
|
+
tenantId: params.tenantId,
|
|
73
|
+
intentId: params.intentId,
|
|
74
|
+
payeeDid: params.payeeDid,
|
|
75
|
+
payloadDigest,
|
|
76
|
+
artifactsDigest: artDigest,
|
|
77
|
+
submittedAtRfc3339: params.submittedAtRfc3339,
|
|
78
|
+
});
|
|
79
|
+
const sig = sign(msg, params.payeeSigningSeed);
|
|
80
|
+
const pub = getPublicKey(params.payeeSigningSeed);
|
|
81
|
+
return {
|
|
82
|
+
payload: params.payload,
|
|
83
|
+
artifacts: params.artifactsBlake3Hex,
|
|
84
|
+
payee_did: params.payeeDid,
|
|
85
|
+
payee_pubkey: bytesToBase64(pub),
|
|
86
|
+
payee_signature: bytesToBase64(sig),
|
|
87
|
+
submitted_at: params.submittedAtRfc3339,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Principal intent creation signing for raw `predicate_dsl` (no managed-policy binding).
|
|
3
|
+
* Matches `crates/harbor-intent-escrow/src/signing.rs` (`intent_creation_sign_bytes_raw`).
|
|
4
|
+
*/
|
|
5
|
+
export declare function intentCreationSignBytesRaw(input: {
|
|
6
|
+
tenantId: string;
|
|
7
|
+
intentId: string;
|
|
8
|
+
principalDid: string;
|
|
9
|
+
payeeDid: string;
|
|
10
|
+
amountCents: number;
|
|
11
|
+
currency: string;
|
|
12
|
+
deadlineRfc3339: string;
|
|
13
|
+
budget: Record<string, unknown>;
|
|
14
|
+
evidenceSchema: Record<string, unknown>;
|
|
15
|
+
predicate: Record<string, unknown>;
|
|
16
|
+
predicateRef: string;
|
|
17
|
+
allowedTools: string[];
|
|
18
|
+
}): Uint8Array;
|
|
19
|
+
export type BuildSignedCreateIntentParams = {
|
|
20
|
+
tenantId: string;
|
|
21
|
+
intentId: string;
|
|
22
|
+
principalDid: string;
|
|
23
|
+
principalSigningSeed: Uint8Array;
|
|
24
|
+
payeeDid: string;
|
|
25
|
+
budget: Record<string, unknown>;
|
|
26
|
+
predicate: Record<string, unknown>;
|
|
27
|
+
currency: string;
|
|
28
|
+
amountCents: number;
|
|
29
|
+
evidenceSchema: Record<string, unknown>;
|
|
30
|
+
deadlineRfc3339: string;
|
|
31
|
+
allowedTools: string[];
|
|
32
|
+
predicateRef?: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Build a Harbor `POST /intents` JSON body with principal Ed25519 detached signature.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildSignedCreateIntentBody(params: BuildSignedCreateIntentParams): Record<string, unknown>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Principal intent creation signing for raw `predicate_dsl` (no managed-policy binding).
|
|
3
|
+
* Matches `crates/harbor-intent-escrow/src/signing.rs` (`intent_creation_sign_bytes_raw`).
|
|
4
|
+
*/
|
|
5
|
+
import { sign, getPublicKey } from "@noble/ed25519";
|
|
6
|
+
import { parse as parseUuid } from "uuid";
|
|
7
|
+
import { jsonValueDigest } from "./json-digest.js";
|
|
8
|
+
function concatBytes(...parts) {
|
|
9
|
+
const n = parts.reduce((a, p) => a + p.length, 0);
|
|
10
|
+
const out = new Uint8Array(n);
|
|
11
|
+
let o = 0;
|
|
12
|
+
for (const p of parts) {
|
|
13
|
+
out.set(p, o);
|
|
14
|
+
o += p.length;
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
function encodeU64(n) {
|
|
19
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
20
|
+
throw new Error("encodeU64: expected non-negative integer");
|
|
21
|
+
}
|
|
22
|
+
const b = new Uint8Array(8);
|
|
23
|
+
new DataView(b.buffer).setBigUint64(0, BigInt(n), true);
|
|
24
|
+
return b;
|
|
25
|
+
}
|
|
26
|
+
function encodeI64(n) {
|
|
27
|
+
const b = new Uint8Array(8);
|
|
28
|
+
new DataView(b.buffer).setBigInt64(0, BigInt(n), true);
|
|
29
|
+
return b;
|
|
30
|
+
}
|
|
31
|
+
function encodeBincodeString(s) {
|
|
32
|
+
const utf8 = new TextEncoder().encode(s);
|
|
33
|
+
return concatBytes(encodeU64(utf8.length), utf8);
|
|
34
|
+
}
|
|
35
|
+
function dslDigest(predicate) {
|
|
36
|
+
return jsonValueDigest(predicate);
|
|
37
|
+
}
|
|
38
|
+
function allowedToolsDigest(tools) {
|
|
39
|
+
const sorted = [...tools]
|
|
40
|
+
.map((s) => s.trim().toLowerCase())
|
|
41
|
+
.sort()
|
|
42
|
+
.filter((v, i, a) => a.indexOf(v) === i);
|
|
43
|
+
return jsonValueDigest(sorted);
|
|
44
|
+
}
|
|
45
|
+
/** Bincode payload for principal intent creation (wire format revision byte `2`). */
|
|
46
|
+
function encodeIntentCreationSign(input) {
|
|
47
|
+
const version = new Uint8Array([2]);
|
|
48
|
+
const intentBytes = parseUuid(input.intentId);
|
|
49
|
+
if (intentBytes.length !== 16) {
|
|
50
|
+
throw new Error("intentId must be a UUID string");
|
|
51
|
+
}
|
|
52
|
+
// Serde `Uuid` + bincode uses a u64 length prefix (16) before the raw 16 bytes (matches Rust).
|
|
53
|
+
return concatBytes(version, encodeBincodeString(input.tenantId), encodeU64(16), intentBytes, encodeBincodeString(input.principalDid), encodeBincodeString(input.payeeDid), encodeI64(input.amountCents), encodeBincodeString(input.currency), encodeBincodeString(input.deadlineRfc3339), input.budgetDigest, input.evidenceSchemaDigest, input.predicateDslDigest, encodeBincodeString(input.predicateRef), input.allowedToolsDigest);
|
|
54
|
+
}
|
|
55
|
+
export function intentCreationSignBytesRaw(input) {
|
|
56
|
+
const budgetDigest = jsonValueDigest(input.budget);
|
|
57
|
+
const evidenceSchemaDigest = jsonValueDigest(input.evidenceSchema);
|
|
58
|
+
const predicateDslDigest = dslDigest(input.predicate);
|
|
59
|
+
const allowedDigest = allowedToolsDigest(input.allowedTools);
|
|
60
|
+
return encodeIntentCreationSign({
|
|
61
|
+
tenantId: input.tenantId,
|
|
62
|
+
intentId: input.intentId,
|
|
63
|
+
principalDid: input.principalDid,
|
|
64
|
+
payeeDid: input.payeeDid,
|
|
65
|
+
amountCents: input.amountCents,
|
|
66
|
+
currency: input.currency,
|
|
67
|
+
deadlineRfc3339: input.deadlineRfc3339,
|
|
68
|
+
budgetDigest,
|
|
69
|
+
evidenceSchemaDigest,
|
|
70
|
+
predicateDslDigest,
|
|
71
|
+
predicateRef: input.predicateRef,
|
|
72
|
+
allowedToolsDigest: allowedDigest,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function bytesToBase64(bytes) {
|
|
76
|
+
let binary = "";
|
|
77
|
+
for (let i = 0; i < bytes.length; i++)
|
|
78
|
+
binary += String.fromCharCode(bytes[i]);
|
|
79
|
+
return btoa(binary);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build a Harbor `POST /intents` JSON body with principal Ed25519 detached signature.
|
|
83
|
+
*/
|
|
84
|
+
export function buildSignedCreateIntentBody(params) {
|
|
85
|
+
if (params.principalSigningSeed.length !== 32) {
|
|
86
|
+
throw new Error("principalSigningSeed must be 32 bytes");
|
|
87
|
+
}
|
|
88
|
+
if (params.allowedTools.length === 0) {
|
|
89
|
+
throw new Error("allowedTools must be non-empty");
|
|
90
|
+
}
|
|
91
|
+
const predicateRef = params.predicateRef ?? "";
|
|
92
|
+
const msg = intentCreationSignBytesRaw({
|
|
93
|
+
tenantId: params.tenantId,
|
|
94
|
+
intentId: params.intentId,
|
|
95
|
+
principalDid: params.principalDid,
|
|
96
|
+
payeeDid: params.payeeDid,
|
|
97
|
+
amountCents: params.amountCents,
|
|
98
|
+
currency: params.currency,
|
|
99
|
+
deadlineRfc3339: params.deadlineRfc3339,
|
|
100
|
+
budget: params.budget,
|
|
101
|
+
evidenceSchema: params.evidenceSchema,
|
|
102
|
+
predicate: params.predicate,
|
|
103
|
+
predicateRef,
|
|
104
|
+
allowedTools: params.allowedTools,
|
|
105
|
+
});
|
|
106
|
+
const sig = sign(msg, params.principalSigningSeed);
|
|
107
|
+
const pub = getPublicKey(params.principalSigningSeed);
|
|
108
|
+
const body = {
|
|
109
|
+
intent_id: params.intentId,
|
|
110
|
+
principal_did: params.principalDid,
|
|
111
|
+
principal_pubkey: bytesToBase64(pub),
|
|
112
|
+
principal_signature: bytesToBase64(sig),
|
|
113
|
+
payee_did: params.payeeDid,
|
|
114
|
+
budget: params.budget,
|
|
115
|
+
currency: params.currency,
|
|
116
|
+
amount_cents: params.amountCents,
|
|
117
|
+
evidence_schema: params.evidenceSchema,
|
|
118
|
+
deadline: params.deadlineRfc3339,
|
|
119
|
+
predicate_dsl: params.predicate,
|
|
120
|
+
signing_version: 2,
|
|
121
|
+
policy_binding: null,
|
|
122
|
+
allowed_tools: params.allowedTools,
|
|
123
|
+
};
|
|
124
|
+
if (predicateRef.trim() !== "") {
|
|
125
|
+
body.predicate_ref = predicateRef;
|
|
126
|
+
}
|
|
127
|
+
return body;
|
|
128
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@paybond/kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Paybond Kit for TypeScript: tenant-bound Harbor sessions, capability verification, and signed intent/evidence flows.",
|
|
5
|
+
"license": "(MIT OR Apache-2.0)",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/nonameuserd/pacta.git",
|
|
25
|
+
"directory": "kit/ts"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/nonameuserd/pacta/tree/main/kit/ts",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/nonameuserd/pacta/issues"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"paybond",
|
|
33
|
+
"harbor",
|
|
34
|
+
"agents",
|
|
35
|
+
"typescript",
|
|
36
|
+
"escrow",
|
|
37
|
+
"capabilities"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"tag": "latest"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"clean": "node ./scripts/clean-dist.mjs",
|
|
45
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
48
|
+
"verify:release": "node ./scripts/verify-release.mjs",
|
|
49
|
+
"prepack": "npm run build"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@noble/ed25519": "^2.2.1",
|
|
53
|
+
"blake3": "^2.1.7",
|
|
54
|
+
"uuid": "^11.0.3"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"typescript": "^5.7.2",
|
|
58
|
+
"vitest": "^3.0.5"
|
|
59
|
+
}
|
|
60
|
+
}
|