@prampta/sdk 0.3.2 → 0.7.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/LICENSE +202 -0
- package/README.md +286 -204
- package/dist/index.d.mts +615 -0
- package/dist/index.d.ts +615 -3
- package/dist/index.js +1322 -2
- package/dist/index.mjs +1266 -0
- package/package.json +35 -46
- package/dist/client.d.ts +0 -75
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -221
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mcp.d.ts +0 -3
- package/dist/mcp.d.ts.map +0 -1
- package/dist/mcp.js +0 -324
- package/dist/mcp.js.map +0 -1
- package/dist/types.d.ts +0 -60
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1266 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import * as ed3 from "@noble/ed25519";
|
|
3
|
+
|
|
4
|
+
// src/canonical.ts
|
|
5
|
+
function canonicalJson(obj) {
|
|
6
|
+
if (obj === null) return "null";
|
|
7
|
+
if (typeof obj === "number") {
|
|
8
|
+
if (!Number.isSafeInteger(obj)) throw new TypeError("Signed JSON numbers must be safe integers");
|
|
9
|
+
return JSON.stringify(obj);
|
|
10
|
+
}
|
|
11
|
+
if (typeof obj === "boolean") return JSON.stringify(obj);
|
|
12
|
+
if (typeof obj === "string") {
|
|
13
|
+
if (Array.from(obj).some((c) => c.length === 1 && c.charCodeAt(0) >= 55296 && c.charCodeAt(0) <= 57343)) {
|
|
14
|
+
throw new TypeError("Signed JSON strings must contain valid Unicode scalar values");
|
|
15
|
+
}
|
|
16
|
+
return JSON.stringify(obj);
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(obj)) return "[" + Array.from(obj, canonicalJson).join(",") + "]";
|
|
19
|
+
if (typeof obj !== "object" || Object.getPrototypeOf(obj) !== Object.prototype && Object.getPrototypeOf(obj) !== null) {
|
|
20
|
+
throw new TypeError("Signed JSON must contain only JSON values");
|
|
21
|
+
}
|
|
22
|
+
const keys = Object.keys(obj).sort((a, b) => {
|
|
23
|
+
const ac = Array.from(a, (c) => c.codePointAt(0));
|
|
24
|
+
const bc = Array.from(b, (c) => c.codePointAt(0));
|
|
25
|
+
for (let i = 0; i < Math.min(ac.length, bc.length); i++) {
|
|
26
|
+
if (ac[i] !== bc[i]) return ac[i] - bc[i];
|
|
27
|
+
}
|
|
28
|
+
return ac.length - bc.length;
|
|
29
|
+
});
|
|
30
|
+
return "{" + keys.map((k) => canonicalJson(k) + ":" + canonicalJson(obj[k])).join(",") + "}";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/index.ts
|
|
34
|
+
import { checkDecision as pregenCheckDecision, issuerOf as pregenIssuerOf } from "@pregen/verify";
|
|
35
|
+
import { loadDirectory } from "@pregen/verify";
|
|
36
|
+
|
|
37
|
+
// src/directory.ts
|
|
38
|
+
import * as ed from "@noble/ed25519";
|
|
39
|
+
var RegistryDirectoryError = class extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "RegistryDirectoryError";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var verifiedConstruction = /* @__PURE__ */ Symbol("verified-directory");
|
|
46
|
+
function fail(message) {
|
|
47
|
+
throw new RegistryDirectoryError(message);
|
|
48
|
+
}
|
|
49
|
+
function hexBytes(value, length) {
|
|
50
|
+
if (typeof value !== "string" || value.length !== length * 2 || !/^[0-9a-fA-F]+$/.test(value)) {
|
|
51
|
+
fail("Invalid Ed25519 key or signature encoding");
|
|
52
|
+
}
|
|
53
|
+
return Uint8Array.from(value.match(/../g), (s) => parseInt(s, 16));
|
|
54
|
+
}
|
|
55
|
+
function apiUrl(value) {
|
|
56
|
+
if (typeof value !== "string") fail("Registry URL must be a string");
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(value);
|
|
60
|
+
} catch {
|
|
61
|
+
return fail("Invalid registry URL");
|
|
62
|
+
}
|
|
63
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
|
|
64
|
+
fail("Registry URL must use HTTPS without credentials, query or fragment");
|
|
65
|
+
}
|
|
66
|
+
return url.href.replace(/\/+$/, "");
|
|
67
|
+
}
|
|
68
|
+
async function sha256(bytes) {
|
|
69
|
+
if (globalThis.crypto?.subtle) {
|
|
70
|
+
const digest2 = await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array(bytes).buffer);
|
|
71
|
+
return Array.from(new Uint8Array(digest2), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
72
|
+
}
|
|
73
|
+
const { createHash } = await import("crypto");
|
|
74
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
75
|
+
}
|
|
76
|
+
function registryNamespace(code) {
|
|
77
|
+
const normalized = code.trim().toUpperCase();
|
|
78
|
+
if (!normalized.startsWith("PG-")) return null;
|
|
79
|
+
const match = /^PG-([A-Z]{4})-/.exec(normalized);
|
|
80
|
+
if (match) {
|
|
81
|
+
if (!/^[ABCDEFGHJKMNPQRSTVWXYZ]{4}$/.test(match[1])) fail("Invalid issuer prefix");
|
|
82
|
+
return match[1];
|
|
83
|
+
}
|
|
84
|
+
if (!/^PG-(?:[A-Z]{3}-)?\d/.test(normalized)) fail("Unrecognized PG namespace");
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
|
|
88
|
+
constructor(registries, sequence, snapshotHash, seal) {
|
|
89
|
+
this.sequence = sequence;
|
|
90
|
+
this.snapshotHash = snapshotHash;
|
|
91
|
+
if (seal !== verifiedConstruction) fail("Construct directories through signature verification");
|
|
92
|
+
this.#registries = registries;
|
|
93
|
+
Object.freeze(this);
|
|
94
|
+
}
|
|
95
|
+
sequence;
|
|
96
|
+
snapshotHash;
|
|
97
|
+
#registries;
|
|
98
|
+
static async verify(input, options) {
|
|
99
|
+
const min = options.minSequence ?? 0;
|
|
100
|
+
if (!Number.isSafeInteger(min) || min < 0) fail("minSequence must be a nonnegative safe integer");
|
|
101
|
+
const previous = options.previousSnapshot ? { ...options.previousSnapshot } : void 0;
|
|
102
|
+
if (previous && (!Number.isSafeInteger(previous.sequence) || previous.sequence < 1 || typeof previous.hash !== "string" || !/^[0-9a-f]{64}$/.test(previous.hash))) {
|
|
103
|
+
fail("Invalid persisted directory snapshot");
|
|
104
|
+
}
|
|
105
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) fail("Directory must be an object");
|
|
106
|
+
let d;
|
|
107
|
+
try {
|
|
108
|
+
d = JSON.parse(canonicalJson(input));
|
|
109
|
+
} catch {
|
|
110
|
+
return fail("Directory contains noncanonical JSON values");
|
|
111
|
+
}
|
|
112
|
+
if (d.schema !== "pregen.registries.v2" || !Number.isSafeInteger(d.sequence) || d.sequence < 1) {
|
|
113
|
+
fail("Unsupported directory schema or sequence");
|
|
114
|
+
}
|
|
115
|
+
if (d.sequence < Math.max(min, previous?.sequence ?? 0)) fail("Directory rollback detected");
|
|
116
|
+
if (!Array.isArray(d.registries) || !d.registries.length) fail("Empty registry directory");
|
|
117
|
+
const prefixes = /* @__PURE__ */ new Set();
|
|
118
|
+
const endpoints = /* @__PURE__ */ new Set();
|
|
119
|
+
const fps = /* @__PURE__ */ new Set();
|
|
120
|
+
for (const r of d.registries) {
|
|
121
|
+
if (!r || typeof r !== "object" || Array.isArray(r) || typeof r.name !== "string" || !r.name.trim()) fail("Malformed registry entry");
|
|
122
|
+
if (r.prefix !== "" && (typeof r.prefix !== "string" || !/^[ABCDEFGHJKMNPQRSTVWXYZ]{4}$/.test(r.prefix))) fail("Invalid registry prefix");
|
|
123
|
+
if (prefixes.has(r.prefix)) fail("Duplicate registry namespace");
|
|
124
|
+
prefixes.add(r.prefix);
|
|
125
|
+
const endpoint = apiUrl(r.api);
|
|
126
|
+
if (endpoints.has(endpoint)) fail("Ambiguous registry endpoint");
|
|
127
|
+
endpoints.add(endpoint);
|
|
128
|
+
apiUrl(r.keys);
|
|
129
|
+
if (!Array.isArray(r.key_fingerprints) || !r.key_fingerprints.length) fail("Empty registry key list");
|
|
130
|
+
for (const fp2 of r.key_fingerprints) {
|
|
131
|
+
if (typeof fp2 !== "string" || !/^pg-ed25519:[0-9a-f]{32}$/.test(fp2)) fail("Invalid registry key fingerprint");
|
|
132
|
+
if (fps.has(fp2)) fail("Duplicate signing key across registry entries");
|
|
133
|
+
fps.add(fp2);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!prefixes.has("")) fail("Missing origin namespace");
|
|
137
|
+
const publicKey = hexBytes(options.stewardPublicKeyHex, 32);
|
|
138
|
+
const hash = await sha256(publicKey);
|
|
139
|
+
const fp = "pg-ed25519:" + hash.slice(0, 32);
|
|
140
|
+
if (d.steward_key_id !== fp) fail("Unexpected steward key");
|
|
141
|
+
const sig = hexBytes(d.signature, 64);
|
|
142
|
+
const body = { ...d };
|
|
143
|
+
delete body.signature;
|
|
144
|
+
const message = new TextEncoder().encode(canonicalJson(body));
|
|
145
|
+
let valid = false;
|
|
146
|
+
try {
|
|
147
|
+
valid = await ed.verifyAsync(sig, message, publicKey);
|
|
148
|
+
} catch {
|
|
149
|
+
fail("Invalid directory signature");
|
|
150
|
+
}
|
|
151
|
+
if (!valid) fail("Invalid directory signature");
|
|
152
|
+
const snapshotHash = await sha256(message);
|
|
153
|
+
if (previous && previous.sequence === d.sequence && previous.hash !== snapshotHash) fail("Directory equivocation detected");
|
|
154
|
+
return new _TrustedRegistryDirectory(d.registries, d.sequence, snapshotHash, verifiedConstruction);
|
|
155
|
+
}
|
|
156
|
+
registryFor(code) {
|
|
157
|
+
const prefix = registryNamespace(code);
|
|
158
|
+
if (prefix === null) return fail("A PG code is required for namespace verification");
|
|
159
|
+
return this.#registries.find((r) => r.prefix === prefix) ?? fail("Unknown registry namespace");
|
|
160
|
+
}
|
|
161
|
+
/** V-11: checked before forwarding a provider's credential to an endpoint. */
|
|
162
|
+
assertEndpoint(subject, baseUrl) {
|
|
163
|
+
const endpoint = apiUrl(baseUrl);
|
|
164
|
+
const prefix = registryNamespace(subject);
|
|
165
|
+
const owner = prefix === null ? this.#registries.find((r) => apiUrl(r.api) === endpoint) : this.registryFor(subject);
|
|
166
|
+
if (!owner || apiUrl(owner.api) !== endpoint) fail("Subject belongs to a different registry endpoint");
|
|
167
|
+
}
|
|
168
|
+
/** V-10: use only AFTER independently verifying the object's signature. */
|
|
169
|
+
assertSigner(code, keyId) {
|
|
170
|
+
if (!this.registryFor(code).key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
|
|
171
|
+
}
|
|
172
|
+
assertDecision(subject, license, keyId, baseUrl) {
|
|
173
|
+
this.assertEndpoint(subject, baseUrl);
|
|
174
|
+
if (registryNamespace(subject) !== null) this.assertSigner(subject, keyId);
|
|
175
|
+
else {
|
|
176
|
+
const owner = this.#registries.find((r) => apiUrl(r.api) === apiUrl(baseUrl));
|
|
177
|
+
if (!owner?.key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
|
|
178
|
+
}
|
|
179
|
+
if (license) {
|
|
180
|
+
this.assertEndpoint(license, baseUrl);
|
|
181
|
+
this.assertSigner(license, keyId);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// src/license.ts
|
|
187
|
+
import * as ed2 from "@noble/ed25519";
|
|
188
|
+
async function verifyLicenseSignatures(input) {
|
|
189
|
+
const bytes = (hex, size) => {
|
|
190
|
+
if (typeof hex !== "string" || hex.length !== size * 2 || !/^[0-9a-fA-F]+$/.test(hex)) throw new TypeError("Invalid hex");
|
|
191
|
+
return Uint8Array.from(hex.match(/../g), (s) => parseInt(s, 16));
|
|
192
|
+
};
|
|
193
|
+
let body;
|
|
194
|
+
let subjectSig;
|
|
195
|
+
try {
|
|
196
|
+
if (!/^PG-[\x21-\x7e]+$/.test(input.licenseId)) throw new TypeError("Invalid license id");
|
|
197
|
+
body = new TextEncoder().encode(canonicalJson(input.body));
|
|
198
|
+
subjectSig = bytes(input.subjectSignatureHex, 64);
|
|
199
|
+
} catch {
|
|
200
|
+
return { subjectValid: false, operatorValid: false };
|
|
201
|
+
}
|
|
202
|
+
let subjectValid = false;
|
|
203
|
+
let operatorValid = false;
|
|
204
|
+
try {
|
|
205
|
+
subjectValid = await ed2.verifyAsync(subjectSig, body, bytes(input.subjectPublicKeyHex, 32));
|
|
206
|
+
} catch {
|
|
207
|
+
}
|
|
208
|
+
const id = new TextEncoder().encode(input.licenseId);
|
|
209
|
+
const counterBody = new Uint8Array(body.length + subjectSig.length + id.length);
|
|
210
|
+
counterBody.set(body);
|
|
211
|
+
counterBody.set(subjectSig, body.length);
|
|
212
|
+
counterBody.set(id, body.length + subjectSig.length);
|
|
213
|
+
try {
|
|
214
|
+
operatorValid = await ed2.verifyAsync(bytes(input.operatorSignatureHex, 64), counterBody, bytes(input.operatorPublicKeyHex, 32));
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
return { subjectValid, operatorValid };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/reporting.ts
|
|
221
|
+
async function digest(bytes) {
|
|
222
|
+
const { createHash } = await import("crypto");
|
|
223
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
224
|
+
}
|
|
225
|
+
var hashPattern = /^[a-f0-9]{64}$/;
|
|
226
|
+
var idPattern = /^[A-Za-z0-9_.:-]{1,100}$/;
|
|
227
|
+
var PramptaReporter = class {
|
|
228
|
+
constructor(config) {
|
|
229
|
+
this.config = config;
|
|
230
|
+
const url = new URL(config.baseUrl);
|
|
231
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
232
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local) || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
|
|
233
|
+
throw new Error("Use an HTTPS API origin (HTTP is permitted only on loopback)");
|
|
234
|
+
}
|
|
235
|
+
if (!config.providerId || !config.token) throw new Error("Provider credential required");
|
|
236
|
+
this.timeoutMs = config.timeoutMs ?? 3e3;
|
|
237
|
+
if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 6e4) {
|
|
238
|
+
throw new Error("timeoutMs must be between 1 and 60000");
|
|
239
|
+
}
|
|
240
|
+
this.baseUrl = url.origin;
|
|
241
|
+
}
|
|
242
|
+
config;
|
|
243
|
+
baseUrl;
|
|
244
|
+
timeoutMs;
|
|
245
|
+
/** No PRAMPTA network call: hash locally and commit to the durable outbox.
|
|
246
|
+
* A storage failure is surfaced, never reported as success. Run from the
|
|
247
|
+
* provider's generation-completed job, not as an authorization gate. */
|
|
248
|
+
async report(input) {
|
|
249
|
+
if (!input.eventKey || input.eventKey.length > 1e3 || !idPattern.test(input.outputId) || input.subjectId.length < 2 || input.subjectId.length > 255) throw new Error("Invalid event identity");
|
|
250
|
+
if (!/(Z|[+-]\d{2}:\d{2})$/.test(input.occurredAt) || !Number.isFinite(Date.parse(input.occurredAt))) {
|
|
251
|
+
throw new Error("occurredAt must contain a timezone");
|
|
252
|
+
}
|
|
253
|
+
if (Date.parse(input.occurredAt) > Date.now() + 3e5) throw new Error("Event time is in the future");
|
|
254
|
+
if (input.purpose !== void 0 && !["personal", "commercial", "educational", "research", "editorial", "unknown"].includes(input.purpose)) {
|
|
255
|
+
throw new Error("Invalid declared purpose");
|
|
256
|
+
}
|
|
257
|
+
if (input.output === void 0 === (input.outputHash === void 0)) {
|
|
258
|
+
throw new Error("Provide exactly one of output or outputHash");
|
|
259
|
+
}
|
|
260
|
+
const outputHash = input.outputHash ?? await digest(typeof input.output === "string" ? new TextEncoder().encode(input.output) : input.output);
|
|
261
|
+
if (!hashPattern.test(outputHash)) throw new Error("Invalid SHA-256 output hash");
|
|
262
|
+
const type = input.eventType ?? "output.created";
|
|
263
|
+
if (!["output.created", "output.modified", "output.published", "publication.removed"].includes(type)) {
|
|
264
|
+
throw new Error("Invalid event type");
|
|
265
|
+
}
|
|
266
|
+
if (type === "output.modified") {
|
|
267
|
+
if (!input.parentOutputHash || !hashPattern.test(input.parentOutputHash) || input.parentOutputHash === outputHash) {
|
|
268
|
+
throw new Error("Modified outputs need a different parent hash");
|
|
269
|
+
}
|
|
270
|
+
} else if (input.parentOutputHash !== void 0) throw new Error("Parent hash is only for modifications");
|
|
271
|
+
if (type === "output.published" || type === "publication.removed") {
|
|
272
|
+
const url = new URL(input.publicationUrl ?? "");
|
|
273
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || /\s/.test(input.publicationUrl) || input.publicationUrl.length > 2048) throw new Error("Unsafe publication URL");
|
|
274
|
+
} else if (input.publicationUrl !== void 0) throw new Error("URL is only for publication events");
|
|
275
|
+
const eventId = await digest(new TextEncoder().encode(JSON.stringify([input.eventKey, input.subjectId])));
|
|
276
|
+
const event = {
|
|
277
|
+
schema_version: "pg.observation.v1",
|
|
278
|
+
event_id: eventId,
|
|
279
|
+
output_id: input.outputId,
|
|
280
|
+
subject_id: input.subjectId,
|
|
281
|
+
event_type: type,
|
|
282
|
+
output_hash: outputHash,
|
|
283
|
+
occurred_at: new Date(input.occurredAt).toISOString(),
|
|
284
|
+
declared_purpose: input.purpose ?? "unknown",
|
|
285
|
+
...input.parentOutputHash !== void 0 ? { parent_output_hash: input.parentOutputHash } : {},
|
|
286
|
+
...input.publicationUrl !== void 0 ? { publication_url: input.publicationUrl } : {}
|
|
287
|
+
};
|
|
288
|
+
await this.config.outbox.put(event);
|
|
289
|
+
return { status: "queued", eventId };
|
|
290
|
+
}
|
|
291
|
+
/** One bounded attempt by your queue worker. Keep/retry on `retry`, quarantine
|
|
292
|
+
* on `rejected`, acknowledge only `accepted`. Never changes /verify behavior. */
|
|
293
|
+
async deliver(event) {
|
|
294
|
+
const controller = new AbortController();
|
|
295
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
296
|
+
try {
|
|
297
|
+
const response = await fetch(`${this.baseUrl}/v1/observations`, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
redirect: "error",
|
|
300
|
+
signal: controller.signal,
|
|
301
|
+
headers: {
|
|
302
|
+
"Content-Type": "application/json",
|
|
303
|
+
"X-Provider-ID": this.config.providerId,
|
|
304
|
+
Authorization: `Bearer ${this.config.token}`
|
|
305
|
+
},
|
|
306
|
+
body: JSON.stringify(event)
|
|
307
|
+
});
|
|
308
|
+
if (response.status === 429 || response.status >= 500 || response.status === 408) {
|
|
309
|
+
const hint = response.headers.get("Retry-After");
|
|
310
|
+
const delay = hint ? /^\d+$/.test(hint) ? Number(hint) * 1e3 : Date.parse(hint) - Date.now() : 1e3;
|
|
311
|
+
return {
|
|
312
|
+
status: "retry",
|
|
313
|
+
httpStatus: response.status,
|
|
314
|
+
retryAfterMs: Number.isFinite(delay) ? Math.max(1e3, Math.min(delay, 864e5)) : 1e3
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
if (!response.ok) return { status: "rejected", httpStatus: response.status };
|
|
318
|
+
const body = await response.json();
|
|
319
|
+
if (typeof body.id !== "string" || !body.id || body.authorization !== "not_granted_by_this_record" || body.payload?.event_id !== event.event_id || body.provider_id !== this.config.providerId || body.payload?.subject_id !== event.subject_id || body.payload?.output_hash !== event.output_hash) {
|
|
320
|
+
return { status: "retry", httpStatus: response.status, retryAfterMs: 1e3 };
|
|
321
|
+
}
|
|
322
|
+
return { status: "accepted", observationId: body.id };
|
|
323
|
+
} catch {
|
|
324
|
+
return { status: "retry", retryAfterMs: 1e3 };
|
|
325
|
+
} finally {
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// src/index.ts
|
|
332
|
+
var PramptaError = class extends Error {
|
|
333
|
+
constructor(message) {
|
|
334
|
+
super(message);
|
|
335
|
+
this.name = "PramptaError";
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
var PramptaNetworkError = class extends PramptaError {
|
|
339
|
+
constructor(cause_) {
|
|
340
|
+
super(`PRAMPTA backend unreachable: ${cause_}`);
|
|
341
|
+
this.cause_ = cause_;
|
|
342
|
+
this.name = "PramptaNetworkError";
|
|
343
|
+
}
|
|
344
|
+
cause_;
|
|
345
|
+
};
|
|
346
|
+
var PramptaTimeoutError = class extends PramptaError {
|
|
347
|
+
constructor(timeoutMs) {
|
|
348
|
+
super(`PRAMPTA request timed out after ${timeoutMs}ms`);
|
|
349
|
+
this.timeoutMs = timeoutMs;
|
|
350
|
+
this.name = "PramptaTimeoutError";
|
|
351
|
+
}
|
|
352
|
+
timeoutMs;
|
|
353
|
+
};
|
|
354
|
+
var PramptaApiError = class extends PramptaError {
|
|
355
|
+
constructor(status, detail) {
|
|
356
|
+
super(`PRAMPTA API error ${status}: ${detail}`);
|
|
357
|
+
this.status = status;
|
|
358
|
+
this.detail = detail;
|
|
359
|
+
this.name = "PramptaApiError";
|
|
360
|
+
}
|
|
361
|
+
status;
|
|
362
|
+
detail;
|
|
363
|
+
};
|
|
364
|
+
var PramptaSchemaError = class extends PramptaError {
|
|
365
|
+
constructor(message) {
|
|
366
|
+
super(`PRAMPTA schema error: ${message}`);
|
|
367
|
+
this.name = "PramptaSchemaError";
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
var PramptaSignatureError = class extends PramptaError {
|
|
371
|
+
constructor(message) {
|
|
372
|
+
super(`PRAMPTA signature verification failed: ${message}`);
|
|
373
|
+
this.name = "PramptaSignatureError";
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
var PramptaRefusalError = class extends PramptaError {
|
|
377
|
+
constructor(decision) {
|
|
378
|
+
const desc = REFUSAL_DESCRIPTIONS[decision.reason || ""] || decision.reason;
|
|
379
|
+
super(`Generation refused: ${desc}`);
|
|
380
|
+
this.decision = decision;
|
|
381
|
+
this.name = "PramptaRefusalError";
|
|
382
|
+
}
|
|
383
|
+
decision;
|
|
384
|
+
get reason() {
|
|
385
|
+
return this.decision.reason || "UNKNOWN";
|
|
386
|
+
}
|
|
387
|
+
get isHardRefusal() {
|
|
388
|
+
return this.decision.isHardRefusal;
|
|
389
|
+
}
|
|
390
|
+
get decisionId() {
|
|
391
|
+
return this.decision.decisionId;
|
|
392
|
+
}
|
|
393
|
+
get licenseId() {
|
|
394
|
+
return this.decision.licenseId;
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
var PramptaFailClosedError = class extends PramptaError {
|
|
398
|
+
constructor(cause_) {
|
|
399
|
+
super(`PRAMPTA fail-closed: generation blocked because verification failed (${cause_})`);
|
|
400
|
+
this.cause_ = cause_;
|
|
401
|
+
this.name = "PramptaFailClosedError";
|
|
402
|
+
}
|
|
403
|
+
cause_;
|
|
404
|
+
};
|
|
405
|
+
var REFUSAL_DESCRIPTIONS = {
|
|
406
|
+
PG_CAMPAIGN_REQUIRED: "This license requires a campaign_id on every generation",
|
|
407
|
+
PG_BUDGET_EXHAUSTED: "The license's prepaid balance cannot cover this generation \u2014 top up the balance",
|
|
408
|
+
PG_RECEIPTS_OVERDUE: "Too many authorized generations without receipts \u2014 submit receipts to resume",
|
|
409
|
+
PG_NO_SUBJECT: "Subject not found in registry",
|
|
410
|
+
PG_SUBJECT_PENDING: "Subject registration is pending approval",
|
|
411
|
+
PG_SUBJECT_DISPUTED: "Subject is under dispute \u2014 generation blocked",
|
|
412
|
+
PG_SUBJECT_WITHDRAWN: "Subject has been withdrawn from registry",
|
|
413
|
+
PG_SUBJECT_OPTED_OUT: "Subject has opted out of AI generation",
|
|
414
|
+
PG_NO_LICENSE: "No valid license for this subject/licensee pair",
|
|
415
|
+
PG_NO_PAIR: "Provider-licensee pair not established",
|
|
416
|
+
PG_INVALID_SIGNATURE: "License signature verification failed",
|
|
417
|
+
PG_IMMUTABLE_DENIAL: "Content category permanently denied by subject",
|
|
418
|
+
PG_SCOPE_VIOLATION: "Intended use outside license scope",
|
|
419
|
+
PG_EXTENSION_REFUSED: "License extension constraint not satisfied",
|
|
420
|
+
PG_BACKEND_UNAVAILABLE: "Backend unreachable \u2014 fail-closed",
|
|
421
|
+
// Identity & entitlement layer (docs/PRAMPTA-Identity-Entitlement-Architecture.md §7).
|
|
422
|
+
PG_PROVIDER_NOT_ALLOWED: "This license is not portable to this provider",
|
|
423
|
+
PG_IDENTITY_REVOKED: "Underlying provider identity has been revoked \u2014 stop retrying",
|
|
424
|
+
PG_ENTITLEMENT_INACTIVE: "Source purchase is no longer active (refunded / reversed)",
|
|
425
|
+
PG_REGION_BLOCKED: "License is not valid in this territory",
|
|
426
|
+
PG_USAGE_LIMIT: "License usage cap reached",
|
|
427
|
+
PG_CONCURRENCY_LIMIT: "Too many concurrent generations under this license"
|
|
428
|
+
};
|
|
429
|
+
var HARD_REFUSAL_CODES = /* @__PURE__ */ new Set([
|
|
430
|
+
"PG_SUBJECT_OPTED_OUT",
|
|
431
|
+
"PG_IMMUTABLE_DENIAL",
|
|
432
|
+
"PG_INVALID_SIGNATURE",
|
|
433
|
+
"PG_SUBJECT_DISPUTED",
|
|
434
|
+
"PG_SUBJECT_WITHDRAWN",
|
|
435
|
+
// A revoked identity or reversed purchase means the underlying grant is
|
|
436
|
+
// gone — the client MUST stop retrying (backing off won't help).
|
|
437
|
+
"PG_IDENTITY_REVOKED",
|
|
438
|
+
"PG_ENTITLEMENT_INACTIVE"
|
|
439
|
+
]);
|
|
440
|
+
var SOFT_REFUSAL_CODES = /* @__PURE__ */ new Set([
|
|
441
|
+
"PG_NO_LICENSE",
|
|
442
|
+
"PG_SCOPE_VIOLATION",
|
|
443
|
+
"PG_PROVIDER_NOT_ALLOWED",
|
|
444
|
+
"PG_REGION_BLOCKED",
|
|
445
|
+
"PG_USAGE_LIMIT",
|
|
446
|
+
"PG_CONCURRENCY_LIMIT"
|
|
447
|
+
]);
|
|
448
|
+
function envVar(key) {
|
|
449
|
+
if (typeof process !== "undefined" && process.env) return process.env[key] ?? "";
|
|
450
|
+
return "";
|
|
451
|
+
}
|
|
452
|
+
async function hashPrompt(prompt) {
|
|
453
|
+
return sha2562(prompt);
|
|
454
|
+
}
|
|
455
|
+
async function sha2562(input) {
|
|
456
|
+
const data = new TextEncoder().encode(input);
|
|
457
|
+
return sha256Bytes(data);
|
|
458
|
+
}
|
|
459
|
+
async function verifyMerkleProof(leafHash, proof, rootHash) {
|
|
460
|
+
let current = leafHash;
|
|
461
|
+
for (const step of proof) {
|
|
462
|
+
current = step.position === "right" ? await sha2562(current + step.hash) : await sha2562(step.hash + current);
|
|
463
|
+
}
|
|
464
|
+
return current === rootHash;
|
|
465
|
+
}
|
|
466
|
+
var TOFU_WARNING = "PRAMPTA: verifying operator signatures WITHOUT a pinned key (trust-on-first-use). This provides no protection against a malicious or compromised endpoint. Set operatorPublicKeyHex (from PRAMPTA's docs) before production.";
|
|
467
|
+
function parsePins(raw) {
|
|
468
|
+
return raw ? raw.split(/[\s,]+/).filter(Boolean) : [];
|
|
469
|
+
}
|
|
470
|
+
async function keyFingerprint(publicKeyHex) {
|
|
471
|
+
return `pg-ed25519:${(await sha256Bytes(hexToBytes(publicKeyHex))).slice(0, 32)}`;
|
|
472
|
+
}
|
|
473
|
+
async function sha256Bytes(data) {
|
|
474
|
+
if (typeof globalThis.crypto?.subtle !== "undefined") {
|
|
475
|
+
const exact = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
476
|
+
const hash = await globalThis.crypto.subtle.digest("SHA-256", exact);
|
|
477
|
+
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
478
|
+
}
|
|
479
|
+
const { createHash } = await import("crypto");
|
|
480
|
+
return createHash("sha256").update(data).digest("hex");
|
|
481
|
+
}
|
|
482
|
+
function hexToBytes(hex) {
|
|
483
|
+
if (typeof hex !== "string" || !/^(?:[0-9a-fA-F]{2})+$/.test(hex)) {
|
|
484
|
+
throw new PramptaSignatureError("Invalid hexadecimal key or signature");
|
|
485
|
+
}
|
|
486
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
487
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
488
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
489
|
+
}
|
|
490
|
+
return bytes;
|
|
491
|
+
}
|
|
492
|
+
var LEET = { "0": "o", "1": "i", "3": "e", "4": "a", "5": "s", "7": "t", "@": "a", "$": "s" };
|
|
493
|
+
var SQUEEZE_MIN = 5;
|
|
494
|
+
function stripDiacritics(t) {
|
|
495
|
+
let out = "";
|
|
496
|
+
for (const ch of t.normalize("NFC")) {
|
|
497
|
+
const base = ch.normalize("NFKD")[0] || ch;
|
|
498
|
+
if (/[a-zA-Z]/.test(base)) out += base;
|
|
499
|
+
else out += ch;
|
|
500
|
+
}
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
function normalizeForMatch(text) {
|
|
504
|
+
let t = stripDiacritics(text.toLowerCase());
|
|
505
|
+
t = t.replace(/[0134578@$]/g, (c) => LEET[c] || c);
|
|
506
|
+
return t.replace(/[-_]+/g, " ").replace(/[^\p{L}\p{N} ]+/gu, " ").replace(/ +/g, " ").trim();
|
|
507
|
+
}
|
|
508
|
+
var squeeze = (t) => t.replace(/ /g, "");
|
|
509
|
+
function matchSubjects(text, entries) {
|
|
510
|
+
const norm = normalizeForMatch(text);
|
|
511
|
+
const haystack = ` ${norm} `;
|
|
512
|
+
const squeezedHaystack = squeeze(norm);
|
|
513
|
+
const hits = [];
|
|
514
|
+
for (const entry of entries) {
|
|
515
|
+
const candidates = [entry.subject_id, ...entry.aliases || []];
|
|
516
|
+
for (const cand of candidates) {
|
|
517
|
+
const needle = normalizeForMatch(cand);
|
|
518
|
+
if (!needle) continue;
|
|
519
|
+
if (haystack.includes(` ${needle} `)) {
|
|
520
|
+
hits.push(entry);
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
const sq = squeeze(needle);
|
|
524
|
+
if (sq.length >= SQUEEZE_MIN && squeezedHaystack.includes(sq)) {
|
|
525
|
+
hits.push(entry);
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return hits;
|
|
531
|
+
}
|
|
532
|
+
var Prampta = class _Prampta {
|
|
533
|
+
baseUrl;
|
|
534
|
+
providerId;
|
|
535
|
+
licenseeId;
|
|
536
|
+
token;
|
|
537
|
+
timeoutMs;
|
|
538
|
+
maxRetries;
|
|
539
|
+
retryBackoffMs;
|
|
540
|
+
failClosed;
|
|
541
|
+
verifySignature;
|
|
542
|
+
enforce;
|
|
543
|
+
requireUserBinding;
|
|
544
|
+
registryDirectory;
|
|
545
|
+
pregenDirectory;
|
|
546
|
+
pinnedKeys;
|
|
547
|
+
keySetCache = null;
|
|
548
|
+
keySetPromise = null;
|
|
549
|
+
tofuWarned = false;
|
|
550
|
+
constructor(config = {}) {
|
|
551
|
+
this.baseUrl = (config.baseUrl || envVar("PRAMPTA_BASE_URL")).replace(/\/+$/, "");
|
|
552
|
+
this.providerId = config.providerId || envVar("PRAMPTA_PROVIDER_ID");
|
|
553
|
+
this.licenseeId = config.licenseeId || envVar("PRAMPTA_LICENSEE_ID");
|
|
554
|
+
this.token = config.token || envVar("PRAMPTA_TOKEN");
|
|
555
|
+
this.timeoutMs = config.timeoutMs ?? 3e3;
|
|
556
|
+
this.maxRetries = Math.max(0, config.maxRetries ?? 2);
|
|
557
|
+
this.retryBackoffMs = config.retryBackoffMs ?? 200;
|
|
558
|
+
this.failClosed = config.failClosed ?? true;
|
|
559
|
+
this.verifySignature = config.verifyDecisionSignature ?? true;
|
|
560
|
+
this.enforce = config.enforce ?? true;
|
|
561
|
+
this.requireUserBinding = config.requireUserBinding ?? false;
|
|
562
|
+
this.registryDirectory = config.registryDirectory;
|
|
563
|
+
this.pregenDirectory = config.pregenDirectory;
|
|
564
|
+
this.pinnedKeys = parsePins(config.operatorPublicKeyHex || envVar("PRAMPTA_OPERATOR_PUBLIC_KEY") || "");
|
|
565
|
+
if (!this.baseUrl) throw new PramptaError("baseUrl is required (or set PRAMPTA_BASE_URL)");
|
|
566
|
+
if (!this.providerId) throw new PramptaError("providerId is required (or set PRAMPTA_PROVIDER_ID)");
|
|
567
|
+
if (!this.token) throw new PramptaError("token is required (or set PRAMPTA_TOKEN)");
|
|
568
|
+
if (this.registryDirectory && (!this.verifySignature || this.pinnedKeys.length === 0)) {
|
|
569
|
+
throw new PramptaError("registryDirectory requires signature verification and pinned operator keys");
|
|
570
|
+
}
|
|
571
|
+
this.registryDirectory?.assertEndpoint("", this.baseUrl);
|
|
572
|
+
if (this.pregenDirectory && !this.pregenDirectory.registries.some((r) => sameEndpoint(r.api, this.baseUrl))) {
|
|
573
|
+
throw new PramptaError(`${this.baseUrl} is not a registry listed in the PRE-GEN directory`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/** V-11: a PG code is answered only by the registry that owns its namespace. */
|
|
577
|
+
assertPregenEndpoint(subjectId) {
|
|
578
|
+
if (!this.pregenDirectory) return;
|
|
579
|
+
try {
|
|
580
|
+
pregenIssuerOf(subjectId);
|
|
581
|
+
} catch {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const owner = this.pregenDirectory.registryFor(subjectId);
|
|
585
|
+
if (!owner || !sameEndpoint(owner.api, this.baseUrl)) {
|
|
586
|
+
throw new PramptaSchemaError(`${subjectId} belongs to ${owner ? owner.api : "no listed registry"}, not ${this.baseUrl}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// ── Key Discovery ───────────────────────────────────────────────────
|
|
590
|
+
/** Fetch /keys and check it is self-signed by its current key. Cached. */
|
|
591
|
+
async fetchVerifiedKeySet() {
|
|
592
|
+
if (this.keySetCache) return this.keySetCache;
|
|
593
|
+
if (!this.keySetPromise) {
|
|
594
|
+
this.keySetPromise = (async () => {
|
|
595
|
+
const ks = await this.get("/keys");
|
|
596
|
+
const entries = {};
|
|
597
|
+
for (const e of ks.keys || []) entries[e.key_id] = e.public_key_hex || "";
|
|
598
|
+
const currentHex = entries[ks.current_key_id] || "";
|
|
599
|
+
const sig = ks.signature || "";
|
|
600
|
+
if (!currentHex || !sig) throw new PramptaSignatureError("Operator key set from /keys is malformed");
|
|
601
|
+
const body = { ...ks };
|
|
602
|
+
delete body.signature;
|
|
603
|
+
const ok = await ed3.verifyAsync(hexToBytes(sig), new TextEncoder().encode(canonicalJson(body)), hexToBytes(currentHex));
|
|
604
|
+
if (!ok) throw new PramptaSignatureError("Operator key set signature is invalid");
|
|
605
|
+
this.keySetCache = ks;
|
|
606
|
+
return ks;
|
|
607
|
+
})();
|
|
608
|
+
}
|
|
609
|
+
return this.keySetPromise;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Public key hex to verify a decision signed by `keyId`, enforcing the
|
|
613
|
+
* pinning trust model. A pinned match returns immediately (no network); an
|
|
614
|
+
* unpinned key id fails closed in pinned mode; unpinned/TOFU mode resolves
|
|
615
|
+
* from the self-consistent /keys set with a warning.
|
|
616
|
+
*/
|
|
617
|
+
async resolveOperatorKey(keyId) {
|
|
618
|
+
for (const hex of this.pinnedKeys) {
|
|
619
|
+
if (await keyFingerprint(hex) === keyId) return hex;
|
|
620
|
+
}
|
|
621
|
+
if (this.pinnedKeys.length > 0) {
|
|
622
|
+
throw new PramptaSignatureError(
|
|
623
|
+
`Decision was signed by operator key '${keyId}', which is not in your pinned set. If PRAMPTA rotated keys, confirm the new key out of band and add it to operatorPublicKeyHex (pin current + next to rotate without downtime). Refusing to trust an unpinned key.`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const ks = await this.fetchVerifiedKeySet();
|
|
627
|
+
const entries = {};
|
|
628
|
+
for (const e of ks.keys || []) entries[e.key_id] = e.public_key_hex || "";
|
|
629
|
+
const match = entries[keyId];
|
|
630
|
+
if (!match) {
|
|
631
|
+
throw new PramptaSignatureError(`Decision key '${keyId}' is not present in the operator key set from /keys.`);
|
|
632
|
+
}
|
|
633
|
+
if (!this.tofuWarned) {
|
|
634
|
+
console.warn(TOFU_WARNING);
|
|
635
|
+
this.tofuWarned = true;
|
|
636
|
+
}
|
|
637
|
+
return match;
|
|
638
|
+
}
|
|
639
|
+
// ── Subject Detection ────────────────────────────────────────────────
|
|
640
|
+
subjectIndexEntries = null;
|
|
641
|
+
subjectIndexFetchedAt = 0;
|
|
642
|
+
static SUBJECT_INDEX_TTL_MS = 3e5;
|
|
643
|
+
/** Fetch the raw subject detection index (/v1/subjects/index). */
|
|
644
|
+
async fetchSubjectIndex() {
|
|
645
|
+
return this.get("/v1/subjects/index");
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Detect registered subjects mentioned in `text`, using a TTL-cached
|
|
649
|
+
* copy of the subject index. Call verify()/assertAllowed() per hit.
|
|
650
|
+
*/
|
|
651
|
+
async matchSubjects(text, opts) {
|
|
652
|
+
const now = Date.now();
|
|
653
|
+
if (opts?.forceRefresh || !this.subjectIndexEntries || now - this.subjectIndexFetchedAt > _Prampta.SUBJECT_INDEX_TTL_MS) {
|
|
654
|
+
const payload = await this.fetchSubjectIndex();
|
|
655
|
+
this.subjectIndexEntries = payload.subjects || [];
|
|
656
|
+
this.subjectIndexFetchedAt = now;
|
|
657
|
+
}
|
|
658
|
+
return matchSubjects(text, this.subjectIndexEntries);
|
|
659
|
+
}
|
|
660
|
+
/** Record what a local text match found for a prompt, BEFORE the user
|
|
661
|
+
* answers. Purely advisory — creates no entitlement or licence. Pass the
|
|
662
|
+
* returned detectionId to confirmDetection() and optionally to verify(). */
|
|
663
|
+
async reportDetection(promptHash, candidateSubjectIds) {
|
|
664
|
+
const data = await this.post("/v1/subjects/detections/", {
|
|
665
|
+
prompt_hash: promptHash,
|
|
666
|
+
candidate_subject_ids: candidateSubjectIds
|
|
667
|
+
});
|
|
668
|
+
return {
|
|
669
|
+
detectionId: data.detection_id,
|
|
670
|
+
status: data.status,
|
|
671
|
+
candidateSubjectIds: data.candidate_subject_ids || [],
|
|
672
|
+
confirmedSubjectId: data.confirmed_subject_id ?? null,
|
|
673
|
+
createdAt: data.created_at,
|
|
674
|
+
confirmedAt: data.confirmed_at ?? null
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
/** Record what the user actually said. `subjectId` undefined/null means
|
|
678
|
+
* they rejected every candidate. Still advisory-only. */
|
|
679
|
+
async confirmDetection(detectionId, subjectId) {
|
|
680
|
+
const data = await this.post(`/v1/subjects/detections/${detectionId}/confirm`, {
|
|
681
|
+
subject_id: subjectId ?? null
|
|
682
|
+
});
|
|
683
|
+
return {
|
|
684
|
+
detectionId: data.detection_id,
|
|
685
|
+
status: data.status,
|
|
686
|
+
candidateSubjectIds: data.candidate_subject_ids || [],
|
|
687
|
+
confirmedSubjectId: data.confirmed_subject_id ?? null,
|
|
688
|
+
createdAt: data.created_at,
|
|
689
|
+
confirmedAt: data.confirmed_at ?? null
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
// ── Signature Verification ──────────────────────────────────────────
|
|
693
|
+
/**
|
|
694
|
+
* Verify operator Ed25519 signature over decision body.
|
|
695
|
+
* Fails closed on any error.
|
|
696
|
+
*/
|
|
697
|
+
async verifyDecision(raw, decision) {
|
|
698
|
+
if (typeof raw.allowed !== "boolean") throw new PramptaSchemaError("allowed must be a boolean");
|
|
699
|
+
const disposition = raw.disposition ?? (raw.allowed ? "allow" : "deny");
|
|
700
|
+
if (!["allow", "deny", "review", "not_blocked"].includes(disposition) || raw.allowed !== (disposition === "allow")) {
|
|
701
|
+
throw new PramptaSchemaError("allowed and disposition are inconsistent or unsupported");
|
|
702
|
+
}
|
|
703
|
+
if (raw.schema_version !== void 0 && !["pg.decision.v1", "1.0"].includes(raw.schema_version)) {
|
|
704
|
+
throw new PramptaSchemaError("Unsupported decision schema_version");
|
|
705
|
+
}
|
|
706
|
+
if (typeof raw.decision_id !== "string" || !raw.decision_id.trim()) {
|
|
707
|
+
throw new PramptaSchemaError("decision_id must be a nonempty string");
|
|
708
|
+
}
|
|
709
|
+
if (raw.license_id != null && (typeof raw.license_id !== "string" || !raw.license_id.trim())) {
|
|
710
|
+
throw new PramptaSchemaError("license_id must be null or a nonempty string");
|
|
711
|
+
}
|
|
712
|
+
for (const field of ["subject_id", "provider_id", "licensee_id", "prompt_hash", "model", "modality", "generation_id", "detection_id", "provider_identity_link_id"]) {
|
|
713
|
+
if (raw[field] !== void 0 && typeof raw[field] !== "string") throw new PramptaSchemaError(`${field} must be a string`);
|
|
714
|
+
}
|
|
715
|
+
if (raw.intended_use !== void 0 && (!raw.intended_use || typeof raw.intended_use !== "object" || Array.isArray(raw.intended_use))) {
|
|
716
|
+
throw new PramptaSchemaError("intended_use must be an object");
|
|
717
|
+
}
|
|
718
|
+
for (const field of ["channel", "product_name", "project_name", "territory", "campaign_id", "use_case", "modality"]) {
|
|
719
|
+
if (raw.intended_use?.[field] !== void 0 && typeof raw.intended_use[field] !== "string") throw new PramptaSchemaError(`${field} must be a string`);
|
|
720
|
+
}
|
|
721
|
+
for (const field of ["categories", "rights"]) {
|
|
722
|
+
const value = raw.intended_use?.[field];
|
|
723
|
+
if (value !== void 0 && (!Array.isArray(value) || value.some((v) => typeof v !== "string"))) throw new PramptaSchemaError(`${field} must be an array of strings`);
|
|
724
|
+
}
|
|
725
|
+
if (!this.verifySignature) return;
|
|
726
|
+
const sig = decision.operatorSignature;
|
|
727
|
+
if (!sig) throw new PramptaSignatureError("Decision missing operator_signature");
|
|
728
|
+
const keyId = decision.operatorKeyId;
|
|
729
|
+
if (!keyId) throw new PramptaSignatureError("Decision missing operator_key_id");
|
|
730
|
+
const pubKeyHex = await this.resolveOperatorKey(keyId);
|
|
731
|
+
const bodyForSigning = { ...raw };
|
|
732
|
+
delete bodyForSigning.operator_signature;
|
|
733
|
+
const canonical = canonicalJson(bodyForSigning);
|
|
734
|
+
const messageBytes = new TextEncoder().encode(canonical);
|
|
735
|
+
const pubKeyBytes = hexToBytes(pubKeyHex);
|
|
736
|
+
const sigBytes = hexToBytes(sig);
|
|
737
|
+
let valid;
|
|
738
|
+
try {
|
|
739
|
+
valid = await ed3.verifyAsync(sigBytes, messageBytes, pubKeyBytes);
|
|
740
|
+
} catch (e) {
|
|
741
|
+
throw new PramptaSignatureError(`Ed25519 verification error: ${e}`);
|
|
742
|
+
}
|
|
743
|
+
if (!valid) {
|
|
744
|
+
throw new PramptaSignatureError(
|
|
745
|
+
"Operator signature is invalid \u2014 decision cannot be trusted. This may indicate a compromised backend, proxy tampering, or key mismatch."
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
749
|
+
if (!Number.isSafeInteger(raw.expires_at) || raw.expires_at <= 0 || raw.issued_at !== void 0 && (!Number.isSafeInteger(raw.issued_at) || raw.issued_at <= 0 || raw.issued_at >= raw.expires_at || raw.issued_at > now + 60)) {
|
|
750
|
+
throw new PramptaSchemaError("Decision has invalid validity timestamps");
|
|
751
|
+
}
|
|
752
|
+
if (decision.expiresAt <= now) {
|
|
753
|
+
throw new PramptaSignatureError(
|
|
754
|
+
`Decision expired at ${decision.expiresAt}, current time ${now}`
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Verify that the signed decision is bound to the correct request context.
|
|
760
|
+
* Prevents replay attacks where a valid decision for one context is used in another.
|
|
761
|
+
*/
|
|
762
|
+
verifyContextBinding(decision, expected) {
|
|
763
|
+
if ((expected.providerUserId || expected.providerIdentityLinkId) && decision.allowed && (decision.providerUserBinding !== "verified" || !decision.providerIdentityLinkId)) {
|
|
764
|
+
throw new PramptaSignatureError("Decision missing verified provider-user binding");
|
|
765
|
+
}
|
|
766
|
+
if (decision.promptHash !== expected.promptHash) {
|
|
767
|
+
throw new PramptaSignatureError(
|
|
768
|
+
`Decision prompt_hash mismatch: sent ${expected.promptHash}, got ${decision.promptHash}`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
if (decision.subjectId !== expected.subjectId) {
|
|
772
|
+
throw new PramptaSignatureError(
|
|
773
|
+
`Decision subject_id mismatch: sent ${expected.subjectId}, got ${decision.subjectId}`
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
if (decision.providerId !== expected.providerId) {
|
|
777
|
+
throw new PramptaSignatureError(
|
|
778
|
+
`Decision provider_id mismatch: sent ${expected.providerId}, got ${decision.providerId}`
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
if (decision.licenseeId !== expected.licenseeId) {
|
|
782
|
+
throw new PramptaSignatureError(
|
|
783
|
+
`Decision licensee_id mismatch: sent ${expected.licenseeId}, got ${decision.licenseeId}`
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
if (decision.modality !== expected.modality) {
|
|
787
|
+
throw new PramptaSignatureError(
|
|
788
|
+
`Decision modality mismatch: sent ${expected.modality}, got ${decision.modality}`
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
if (decision.model !== expected.model) {
|
|
792
|
+
throw new PramptaSignatureError(
|
|
793
|
+
`Decision model mismatch: sent ${expected.model}, got ${decision.model}`
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
for (const [name, got, sent] of [
|
|
797
|
+
["generation_id", decision.generationId, expected.generationId],
|
|
798
|
+
["detection_id", decision.detectionId, expected.detectionId],
|
|
799
|
+
["provider_identity_link_id", decision.providerIdentityLinkId, expected.providerIdentityLinkId]
|
|
800
|
+
]) {
|
|
801
|
+
if (sent && got !== sent) throw new PramptaSignatureError(`Decision ${name} mismatch`);
|
|
802
|
+
}
|
|
803
|
+
if (expected.intendedUse) {
|
|
804
|
+
const iu = decision.intendedUse || {};
|
|
805
|
+
const pairs = [
|
|
806
|
+
["product_name", String(iu.product_name || ""), expected.intendedUse.productName],
|
|
807
|
+
["project_name", String(iu.project_name || ""), expected.intendedUse.projectName],
|
|
808
|
+
["channel", String(iu.channel || ""), expected.intendedUse.channel],
|
|
809
|
+
["territory", String(iu.territory || ""), expected.intendedUse.territory],
|
|
810
|
+
["campaign_id", String(iu.campaign_id || ""), expected.intendedUse.campaignId],
|
|
811
|
+
["use_case", String(iu.use_case || ""), expected.intendedUse.useCase || ""],
|
|
812
|
+
["modality", String(iu.modality || ""), expected.intendedUse.modality]
|
|
813
|
+
];
|
|
814
|
+
for (const [name, got, sent] of pairs) {
|
|
815
|
+
if (got !== sent) {
|
|
816
|
+
throw new PramptaSignatureError(`Decision ${name} mismatch: sent ${sent}, got ${got}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
for (const [field, sent] of [["categories", expected.intendedUse.categories], ["rights", expected.intendedUse.rights]]) {
|
|
820
|
+
const gotCats = new Set(iu[field] ?? []);
|
|
821
|
+
const sentCats = new Set(sent);
|
|
822
|
+
const same = gotCats.size === sentCats.size && [...sentCats].every((c) => gotCats.has(c));
|
|
823
|
+
if (!same) {
|
|
824
|
+
throw new PramptaSignatureError(
|
|
825
|
+
`Decision ${field} mismatch: sent ${[...sentCats].sort()}, got ${[...gotCats].sort()}`
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// ── Core API ─────────────────────────────────────────────────────────
|
|
832
|
+
async verifyGeneration(request) {
|
|
833
|
+
request = { ...request, intendedUse: request.intendedUse ? {
|
|
834
|
+
...request.intendedUse,
|
|
835
|
+
categories: request.intendedUse.categories?.slice(),
|
|
836
|
+
rights: request.intendedUse.rights?.slice()
|
|
837
|
+
} : void 0 };
|
|
838
|
+
this.registryDirectory?.assertEndpoint(request.subjectId, this.baseUrl);
|
|
839
|
+
this.assertPregenEndpoint(request.subjectId);
|
|
840
|
+
const promptHash = request.promptHash || (request.prompt ? await sha2562(request.prompt) : null);
|
|
841
|
+
if (!promptHash) {
|
|
842
|
+
throw new PramptaSchemaError(
|
|
843
|
+
"Either prompt or promptHash is required. SDK does not allow empty prompt hashes to prevent unbound decisions."
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
if (this.requireUserBinding && !request.providerUserId && !request.providerIdentityLinkId) {
|
|
847
|
+
throw new PramptaSchemaError(
|
|
848
|
+
"requireUserBinding=true but neither providerUserId nor providerIdentityLinkId was supplied. This client is configured to always act for a specific end user \u2014 pass one of them, or construct new Prampta({ requireUserBinding: false, ... }) if this call is genuinely meant to be unbound."
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
const payload = {
|
|
852
|
+
subject_id: request.subjectId,
|
|
853
|
+
prompt_hash: promptHash,
|
|
854
|
+
modality: request.modality || "",
|
|
855
|
+
model: request.model || "",
|
|
856
|
+
intended_use: {
|
|
857
|
+
channel: request.intendedUse?.channel || "",
|
|
858
|
+
product_name: request.intendedUse?.productName || "",
|
|
859
|
+
project_name: request.intendedUse?.projectName || "",
|
|
860
|
+
categories: request.intendedUse?.categories || [],
|
|
861
|
+
modality: request.modality || "",
|
|
862
|
+
territory: request.intendedUse?.territory || "",
|
|
863
|
+
campaign_id: request.intendedUse?.campaignId || "",
|
|
864
|
+
use_case: request.intendedUse?.useCase || "",
|
|
865
|
+
rights: request.intendedUse?.rights || []
|
|
866
|
+
},
|
|
867
|
+
// Omitted entirely when unset: an empty string is not the same as "no
|
|
868
|
+
// end user", and sending one would make a pre-v2 caller look like it
|
|
869
|
+
// asserted an unknown identity.
|
|
870
|
+
...request.providerUserId ? { provider_user_id: request.providerUserId } : {},
|
|
871
|
+
...request.providerIdentityLinkId ? { provider_identity_link_id: request.providerIdentityLinkId } : {},
|
|
872
|
+
...request.generationId ? { generation_id: request.generationId } : {},
|
|
873
|
+
...request.idempotencyKey ? { idempotency_key: request.idempotencyKey } : {},
|
|
874
|
+
...request.detectionId ? { detection_id: request.detectionId } : {},
|
|
875
|
+
...request.returnUrl ? { return_url: request.returnUrl } : {}
|
|
876
|
+
};
|
|
877
|
+
const extraHeaders = request.expectedSchemaVersion ? { "X-Prampta-Expected-Schema-Version": request.expectedSchemaVersion } : void 0;
|
|
878
|
+
try {
|
|
879
|
+
const data = await this.post("/v1/verify/", payload, extraHeaders);
|
|
880
|
+
const decision = this.parseDecision(data);
|
|
881
|
+
await this.verifyDecision(data, decision);
|
|
882
|
+
this.registryDirectory?.assertDecision(
|
|
883
|
+
request.subjectId,
|
|
884
|
+
decision.licenseId,
|
|
885
|
+
decision.operatorKeyId,
|
|
886
|
+
this.baseUrl
|
|
887
|
+
);
|
|
888
|
+
this.verifyContextBinding(decision, {
|
|
889
|
+
promptHash,
|
|
890
|
+
subjectId: request.subjectId,
|
|
891
|
+
providerId: this.providerId,
|
|
892
|
+
licenseeId: this.licenseeId,
|
|
893
|
+
modality: request.modality || "",
|
|
894
|
+
model: request.model || "",
|
|
895
|
+
generationId: request.generationId,
|
|
896
|
+
detectionId: request.detectionId,
|
|
897
|
+
providerIdentityLinkId: request.providerIdentityLinkId,
|
|
898
|
+
providerUserId: request.providerUserId,
|
|
899
|
+
intendedUse: {
|
|
900
|
+
productName: request.intendedUse?.productName || "",
|
|
901
|
+
projectName: request.intendedUse?.projectName || "",
|
|
902
|
+
channel: request.intendedUse?.channel || "",
|
|
903
|
+
territory: request.intendedUse?.territory || "",
|
|
904
|
+
categories: request.intendedUse?.categories || [],
|
|
905
|
+
campaignId: request.intendedUse?.campaignId || "",
|
|
906
|
+
useCase: request.intendedUse?.useCase || "",
|
|
907
|
+
modality: request.modality || "",
|
|
908
|
+
rights: request.intendedUse?.rights || []
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
if (this.pregenDirectory && decision.disposition === "allow") {
|
|
912
|
+
try {
|
|
913
|
+
await pregenCheckDecision(
|
|
914
|
+
data,
|
|
915
|
+
{ ...payload, provider_id: this.providerId, licensee_id: this.licenseeId || "" },
|
|
916
|
+
{ directory: this.pregenDirectory }
|
|
917
|
+
);
|
|
918
|
+
} catch (e) {
|
|
919
|
+
throw new PramptaSignatureError(`PRE-GEN check failed: ${e.message}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return decision;
|
|
923
|
+
} catch (e) {
|
|
924
|
+
if (this.failClosed) {
|
|
925
|
+
if (e instanceof PramptaRefusalError || e instanceof PramptaSchemaError || e instanceof PramptaSignatureError) {
|
|
926
|
+
throw e;
|
|
927
|
+
}
|
|
928
|
+
throw new PramptaFailClosedError(e);
|
|
929
|
+
}
|
|
930
|
+
throw e;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/** Flat convenience API mirroring the Python SDK: intended-use fields
|
|
934
|
+
* (categories, channel, productName, …) are accepted at the top level.
|
|
935
|
+
* Either `prompt` or `promptHash` is REQUIRED — decisions are bound to
|
|
936
|
+
* the prompt hash and the SDK refuses to request unbound ones. */
|
|
937
|
+
async verify(subjectId, options = {}) {
|
|
938
|
+
const { categories, channel, productName, projectName, territory, campaignId, useCase, rights, intendedUse, ...rest } = options;
|
|
939
|
+
const iu = intendedUse ?? {
|
|
940
|
+
...channel ? { channel } : {},
|
|
941
|
+
...productName ? { productName } : {},
|
|
942
|
+
...projectName ? { projectName } : {},
|
|
943
|
+
...categories ? { categories } : {},
|
|
944
|
+
...territory ? { territory } : {},
|
|
945
|
+
...campaignId ? { campaignId } : {},
|
|
946
|
+
...useCase ? { useCase } : {},
|
|
947
|
+
...rights ? { rights } : {}
|
|
948
|
+
};
|
|
949
|
+
return this.verifyGeneration({
|
|
950
|
+
subjectId,
|
|
951
|
+
...rest,
|
|
952
|
+
...Object.keys(iu).length ? { intendedUse: iu } : {}
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
async assertAllowed(subjectId, options = {}) {
|
|
956
|
+
const decision = await this.verifyGeneration({ subjectId, ...options });
|
|
957
|
+
if (!decision.allowed && decision.disposition !== "not_blocked") {
|
|
958
|
+
if (this.enforce) throw new PramptaRefusalError(decision);
|
|
959
|
+
console.warn(`PRAMPTA monitor mode: would REFUSE (${decision.reason}) but enforce=false \u2014 proceeding.`);
|
|
960
|
+
}
|
|
961
|
+
return decision;
|
|
962
|
+
}
|
|
963
|
+
/** Strict license-backed path. Unlike assertAllowed, this never accepts
|
|
964
|
+
* reporting-only not_blocked, an internal no-license allow, or monitor mode.
|
|
965
|
+
* It validates an authorization; it does not prove output compliance. */
|
|
966
|
+
async assertLicensed(subjectId, options = {}) {
|
|
967
|
+
if (!this.verifySignature || this.pinnedKeys.length === 0) {
|
|
968
|
+
throw new PramptaSignatureError("assertLicensed requires signature verification and pinned operator keys");
|
|
969
|
+
}
|
|
970
|
+
if (!options.generationId) throw new PramptaSchemaError("assertLicensed requires a generationId");
|
|
971
|
+
const decision = await this.verifyGeneration({ subjectId, ...options });
|
|
972
|
+
if (!decision.allowed || decision.disposition !== "allow" || !decision.licenseId) {
|
|
973
|
+
throw new PramptaRefusalError(decision);
|
|
974
|
+
}
|
|
975
|
+
return decision;
|
|
976
|
+
}
|
|
977
|
+
async withAuthorization(request, generateFn) {
|
|
978
|
+
const decision = await this.assertAllowed(request.subjectId, request);
|
|
979
|
+
const output = await generateFn(decision);
|
|
980
|
+
const metadata = this.createOutputMetadata(decision);
|
|
981
|
+
return { output, decision, metadata };
|
|
982
|
+
}
|
|
983
|
+
// ── Subject Resolution ───────────────────────────────────────────────
|
|
984
|
+
async getSubject(subjectId) {
|
|
985
|
+
this.registryDirectory?.assertEndpoint(subjectId, this.baseUrl);
|
|
986
|
+
try {
|
|
987
|
+
const data = await this.get(`/v1/subjects/${encodeURIComponent(subjectId)}`);
|
|
988
|
+
return {
|
|
989
|
+
subjectId: data.subject_id,
|
|
990
|
+
status: data.status,
|
|
991
|
+
visibility: data.visibility || "public",
|
|
992
|
+
rulesText: data.rules_text || "",
|
|
993
|
+
aliases: data.aliases || [],
|
|
994
|
+
publicKeyHex: data.public_key_hex,
|
|
995
|
+
registeredAt: data.registered_at
|
|
996
|
+
};
|
|
997
|
+
} catch {
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
// ── License Request ──────────────────────────────────────────────────
|
|
1002
|
+
async requestLicense(input) {
|
|
1003
|
+
this.registryDirectory?.assertEndpoint(input.subjectId, this.baseUrl);
|
|
1004
|
+
const data = await this.post("/v1/license-requests/", {
|
|
1005
|
+
subject_id: input.subjectId,
|
|
1006
|
+
use_case: input.useCase,
|
|
1007
|
+
purpose: input.purpose || "",
|
|
1008
|
+
duration_days: input.durationDays || 365,
|
|
1009
|
+
message: input.message || ""
|
|
1010
|
+
});
|
|
1011
|
+
return { requestId: data.request_id, status: data.status };
|
|
1012
|
+
}
|
|
1013
|
+
// ── Provenance / Output Metadata ─────────────────────────────────────
|
|
1014
|
+
createOutputMetadata(decision) {
|
|
1015
|
+
return {
|
|
1016
|
+
prampta_decision_id: decision.decisionId,
|
|
1017
|
+
prampta_license_id: decision.licenseId,
|
|
1018
|
+
prampta_watermark: decision.watermarkPayload,
|
|
1019
|
+
prampta_obligations: decision.obligations,
|
|
1020
|
+
prampta_issued_at: decision.issuedAt
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Report an output after generation. With a registered provider signing key,
|
|
1025
|
+
* opt into v3 so eventType is bound to the provider's signature. This is a
|
|
1026
|
+
* provider attestation, not proof of complete reporting or actual execution.
|
|
1027
|
+
*/
|
|
1028
|
+
async submitReceipt(decision, result = {}) {
|
|
1029
|
+
const payload = {
|
|
1030
|
+
decision_id: decision.decisionId,
|
|
1031
|
+
prompt_hash: decision.promptHash,
|
|
1032
|
+
output_hash: result.outputHash ?? "",
|
|
1033
|
+
model: result.model ?? decision.model,
|
|
1034
|
+
watermark_embedded: result.watermarkEmbedded ?? false,
|
|
1035
|
+
obligations_applied: result.obligationsApplied ?? decision.obligations ?? {},
|
|
1036
|
+
generated_at: result.generatedAt ?? Math.floor(Date.now() / 1e3)
|
|
1037
|
+
};
|
|
1038
|
+
if (result.providerSigningKeyHex !== void 0) {
|
|
1039
|
+
const eventType = result.eventType ?? "output_accepted";
|
|
1040
|
+
const keyHex = result.providerSigningKeyHex;
|
|
1041
|
+
if (!["preview", "output_accepted", "output_delivered", "output_published"].includes(eventType)) {
|
|
1042
|
+
throw new PramptaError("Unsupported receipt eventType");
|
|
1043
|
+
}
|
|
1044
|
+
if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {
|
|
1045
|
+
throw new PramptaError("providerSigningKeyHex must be a 32-byte Ed25519 private key");
|
|
1046
|
+
}
|
|
1047
|
+
if (!decision.subjectId || !this.providerId || !this.licenseeId) {
|
|
1048
|
+
throw new PramptaError("Signed v3 receipts require a bound subject, provider, and licensee");
|
|
1049
|
+
}
|
|
1050
|
+
if (!String(payload.output_hash).trim()) {
|
|
1051
|
+
throw new PramptaError("Signed v3 receipts require an output hash");
|
|
1052
|
+
}
|
|
1053
|
+
const body = {
|
|
1054
|
+
v: "pg.receipt.v3",
|
|
1055
|
+
...payload,
|
|
1056
|
+
subject_id: decision.subjectId,
|
|
1057
|
+
licensee_id: this.licenseeId,
|
|
1058
|
+
provider_id: this.providerId,
|
|
1059
|
+
event_type: eventType
|
|
1060
|
+
};
|
|
1061
|
+
const signature = await ed3.signAsync(
|
|
1062
|
+
new TextEncoder().encode(canonicalJson(body)),
|
|
1063
|
+
hexToBytes(keyHex)
|
|
1064
|
+
);
|
|
1065
|
+
payload.schema_version = "pg.receipt.v3";
|
|
1066
|
+
payload.event_type = eventType;
|
|
1067
|
+
payload.provider_signature = Array.from(signature, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1068
|
+
} else if (result.eventType && result.eventType !== "output_accepted") {
|
|
1069
|
+
throw new PramptaError("A non-default eventType requires a provider signing key and pg.receipt.v3");
|
|
1070
|
+
}
|
|
1071
|
+
return this.post("/v1/receipts/", payload);
|
|
1072
|
+
}
|
|
1073
|
+
// ── C2PA bridge (PRE-GEN v1.1 addendum §11) ─────────────────────────────
|
|
1074
|
+
/**
|
|
1075
|
+
* Get (issuing if needed) the signed `pg.assertion.v1` for a decision this
|
|
1076
|
+
* provider/licensee pair already submitted a receipt for — embed it in
|
|
1077
|
+
* your OWN C2PA manifest (PRAMPTA does not build the manifest itself).
|
|
1078
|
+
* Idempotent: safe to call again for the same decisionId, always returns
|
|
1079
|
+
* the same assertion. Requires `submitReceipt()` to have run first.
|
|
1080
|
+
*/
|
|
1081
|
+
async createAssertion(decisionId) {
|
|
1082
|
+
return this.post("/v1/assertions/", { decision_id: decisionId });
|
|
1083
|
+
}
|
|
1084
|
+
/** Fetch a previously issued assertion. Public — no auth required, since a
|
|
1085
|
+
* third party checking a C2PA manifest's embedded assertion needs to
|
|
1086
|
+
* verify it without going through PRAMPTA's own auth. */
|
|
1087
|
+
async getAssertion(decisionId) {
|
|
1088
|
+
return this.get(`/v1/assertions/${decisionId}`);
|
|
1089
|
+
}
|
|
1090
|
+
// ── Anchored audit log (PRE-GEN v1.1 addendum §10) ──────────────────────
|
|
1091
|
+
/** The most recently published Merkle root over the audit log. Public,
|
|
1092
|
+
* unauthenticated. */
|
|
1093
|
+
async getMerkleRoot() {
|
|
1094
|
+
return this.get("/v1/audit/merkle-root");
|
|
1095
|
+
}
|
|
1096
|
+
/** Proof that `eventId` is one of the leaves committed to by a Merkle
|
|
1097
|
+
* root, without downloading the rest of the log. Check it offline with
|
|
1098
|
+
* the standalone `verifyMerkleProof(eventHash, proof, rootHash)` — don't
|
|
1099
|
+
* just trust the 200 response; the proof exists so you don't have to.
|
|
1100
|
+
* Public, unauthenticated. */
|
|
1101
|
+
async getInclusionProof(eventId) {
|
|
1102
|
+
return this.get(`/v1/audit/inclusion-proof/${eventId}`);
|
|
1103
|
+
}
|
|
1104
|
+
// ── Utility ──────────────────────────────────────────────────────────
|
|
1105
|
+
async health() {
|
|
1106
|
+
try {
|
|
1107
|
+
await this.get("/healthz");
|
|
1108
|
+
return true;
|
|
1109
|
+
} catch {
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
async version() {
|
|
1114
|
+
return this.get("/version");
|
|
1115
|
+
}
|
|
1116
|
+
static async hashPrompt(prompt) {
|
|
1117
|
+
return sha2562(prompt);
|
|
1118
|
+
}
|
|
1119
|
+
// ── Internal ─────────────────────────────────────────────────────────
|
|
1120
|
+
parseDecision(data) {
|
|
1121
|
+
if (!data.decision_id) throw new PramptaSchemaError("Response missing decision_id");
|
|
1122
|
+
return {
|
|
1123
|
+
schemaVersion: data.schema_version || "pg.decision.v1",
|
|
1124
|
+
decisionId: data.decision_id,
|
|
1125
|
+
allowed: data.allowed ?? false,
|
|
1126
|
+
reason: data.reason ?? null,
|
|
1127
|
+
subjectId: data.subject_id || "",
|
|
1128
|
+
licenseeId: data.licensee_id || "",
|
|
1129
|
+
providerId: data.provider_id || "",
|
|
1130
|
+
licenseId: data.license_id ?? null,
|
|
1131
|
+
promptHash: data.prompt_hash || "",
|
|
1132
|
+
model: data.model || "",
|
|
1133
|
+
modality: data.modality || "",
|
|
1134
|
+
intendedUse: data.intended_use || {},
|
|
1135
|
+
obligations: data.obligations || {},
|
|
1136
|
+
rulesText: data.rules_text || "",
|
|
1137
|
+
rulesTextHash: data.rules_text_hash || "",
|
|
1138
|
+
watermarkPayload: data.watermark_payload ?? null,
|
|
1139
|
+
isHardRefusal: data.is_hard_refusal ?? false,
|
|
1140
|
+
issuedAt: data.issued_at || 0,
|
|
1141
|
+
expiresAt: data.expires_at || 0,
|
|
1142
|
+
operatorKeyId: data.operator_key_id || "",
|
|
1143
|
+
operatorSignature: data.operator_signature || "",
|
|
1144
|
+
subjectAuthority: data.subject_authority || "",
|
|
1145
|
+
denied: !(data.allowed ?? false) && data.disposition !== "not_blocked",
|
|
1146
|
+
disposition: data.disposition ?? (data.allowed === true ? "allow" : "deny"),
|
|
1147
|
+
policyVersion: data.policy_version || "",
|
|
1148
|
+
revocationEpoch: data.revocation_epoch || 0,
|
|
1149
|
+
maxCacheAgeSeconds: data.max_cache_age_seconds || 0,
|
|
1150
|
+
cacheScope: data.cache_scope || "not_cacheable",
|
|
1151
|
+
providerUserBinding: data.provider_user_binding || "unbound",
|
|
1152
|
+
providerIdentityLinkId: data.provider_identity_link_id || "",
|
|
1153
|
+
generationId: data.generation_id || "",
|
|
1154
|
+
detectionId: data.detection_id || "",
|
|
1155
|
+
remediation: data.remediation ?? null
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
headers() {
|
|
1159
|
+
return {
|
|
1160
|
+
"Content-Type": "application/json",
|
|
1161
|
+
"X-Provider-ID": this.providerId,
|
|
1162
|
+
// Omitted without a connected user: a provider-level check.
|
|
1163
|
+
...this.licenseeId ? { "X-Licensee-ID": this.licenseeId } : {},
|
|
1164
|
+
Authorization: `Bearer ${this.token}`
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
/** Transient errors worth retrying: connectivity, timeout, 429, and 5xx. */
|
|
1168
|
+
isRetryable(e) {
|
|
1169
|
+
if (e instanceof PramptaNetworkError || e instanceof PramptaTimeoutError) return true;
|
|
1170
|
+
if (e instanceof PramptaApiError) return e.status >= 500 || e.status === 429;
|
|
1171
|
+
return false;
|
|
1172
|
+
}
|
|
1173
|
+
async withRetry(fn) {
|
|
1174
|
+
let lastErr;
|
|
1175
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
1176
|
+
try {
|
|
1177
|
+
return await fn();
|
|
1178
|
+
} catch (e) {
|
|
1179
|
+
lastErr = e;
|
|
1180
|
+
if (attempt >= this.maxRetries || !this.isRetryable(e)) throw e;
|
|
1181
|
+
const delay = this.retryBackoffMs * Math.pow(2, attempt);
|
|
1182
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
throw lastErr;
|
|
1186
|
+
}
|
|
1187
|
+
async post(path, payload, extraHeaders) {
|
|
1188
|
+
return this.withRetry(() => this.postOnce(path, payload, extraHeaders));
|
|
1189
|
+
}
|
|
1190
|
+
async postOnce(path, payload, extraHeaders) {
|
|
1191
|
+
let resp;
|
|
1192
|
+
try {
|
|
1193
|
+
resp = await fetch(`${this.baseUrl}${path}`, {
|
|
1194
|
+
method: "POST",
|
|
1195
|
+
headers: { ...this.headers(), ...extraHeaders },
|
|
1196
|
+
redirect: "error",
|
|
1197
|
+
body: JSON.stringify(payload),
|
|
1198
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
1199
|
+
});
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") throw new PramptaTimeoutError(this.timeoutMs);
|
|
1202
|
+
throw new PramptaNetworkError(e);
|
|
1203
|
+
}
|
|
1204
|
+
if (!resp.ok) {
|
|
1205
|
+
const text = await resp.text();
|
|
1206
|
+
let detail = text;
|
|
1207
|
+
try {
|
|
1208
|
+
detail = JSON.parse(text).detail ?? text;
|
|
1209
|
+
} catch {
|
|
1210
|
+
}
|
|
1211
|
+
throw new PramptaApiError(resp.status, String(detail));
|
|
1212
|
+
}
|
|
1213
|
+
return resp.json();
|
|
1214
|
+
}
|
|
1215
|
+
async get(path) {
|
|
1216
|
+
return this.withRetry(() => this.getOnce(path));
|
|
1217
|
+
}
|
|
1218
|
+
async getOnce(path) {
|
|
1219
|
+
let resp;
|
|
1220
|
+
try {
|
|
1221
|
+
resp = await fetch(`${this.baseUrl}${path}`, {
|
|
1222
|
+
method: "GET",
|
|
1223
|
+
headers: this.headers(),
|
|
1224
|
+
redirect: "error",
|
|
1225
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
1226
|
+
});
|
|
1227
|
+
} catch (e) {
|
|
1228
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") throw new PramptaTimeoutError(this.timeoutMs);
|
|
1229
|
+
throw new PramptaNetworkError(e);
|
|
1230
|
+
}
|
|
1231
|
+
if (!resp.ok) throw new PramptaApiError(resp.status, await resp.text());
|
|
1232
|
+
return resp.json();
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
function sameEndpoint(a, b) {
|
|
1236
|
+
try {
|
|
1237
|
+
const x = new URL(a), y = new URL(b);
|
|
1238
|
+
return x.origin === y.origin && x.pathname.replace(/\/+$/, "") === y.pathname.replace(/\/+$/, "");
|
|
1239
|
+
} catch {
|
|
1240
|
+
return false;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
export {
|
|
1244
|
+
HARD_REFUSAL_CODES,
|
|
1245
|
+
Prampta,
|
|
1246
|
+
PramptaApiError,
|
|
1247
|
+
PramptaError,
|
|
1248
|
+
PramptaFailClosedError,
|
|
1249
|
+
PramptaNetworkError,
|
|
1250
|
+
PramptaRefusalError,
|
|
1251
|
+
PramptaReporter,
|
|
1252
|
+
PramptaSchemaError,
|
|
1253
|
+
PramptaSignatureError,
|
|
1254
|
+
PramptaTimeoutError,
|
|
1255
|
+
REFUSAL_DESCRIPTIONS,
|
|
1256
|
+
RegistryDirectoryError,
|
|
1257
|
+
SOFT_REFUSAL_CODES,
|
|
1258
|
+
TrustedRegistryDirectory,
|
|
1259
|
+
canonicalJson,
|
|
1260
|
+
hashPrompt,
|
|
1261
|
+
loadDirectory as loadPregenDirectory,
|
|
1262
|
+
matchSubjects,
|
|
1263
|
+
normalizeForMatch,
|
|
1264
|
+
verifyLicenseSignatures,
|
|
1265
|
+
verifyMerkleProof
|
|
1266
|
+
};
|