@prampta/sdk 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,39 @@ const decision = await pg.assertLicensed(subjectCode, {
95
95
  // Apply the decision's obligations, generate, then submit a receipt.
96
96
  ```
97
97
 
98
+ ## The whole cycle in one call (0.8.0)
99
+
100
+ `generateAuthorized` does what every integration otherwise writes by hand:
101
+
102
+ - asks fresh for **every subject in the output** (a person, a voice, a brand…)
103
+ and generates only if **all** of them return a licence-backed `allow`;
104
+ - never reuses a stored allow, and checks each is still live right before it
105
+ starts, so a pause or revocation stops the next generation;
106
+ - files one receipt per subject;
107
+ - gives the allows back (`releaseDecision`) when a subject refuses or your
108
+ `generate` throws, so a failed job does not use up a licence limit;
109
+ - with a `journal` (your own database), never generates twice after a crash:
110
+ a repeat call resends missing receipts, or refuses a job that crashed
111
+ midway instead of rerunning it.
112
+
113
+ ```typescript
114
+ const result = await pg.generateAuthorized(
115
+ { subjectIds: [personCode, voiceCode], generationId: jobId, promptHash,
116
+ modality: "video", model: modelName,
117
+ intendedUse: { useCase: "commercial", rights: ["output_generation"] } },
118
+ async (decisions) => {
119
+ // Apply every decision's obligations here. Throw only if nothing was produced.
120
+ const video = await render(prompt);
121
+ return { output: video, outputBytes: video.bytes };
122
+ },
123
+ { journal: { get: (id) => db.jobs.get(id), put: (e) => db.jobs.put(e) } },
124
+ );
125
+ if (result.unreported.length) retryLater(jobId); // receipts are resent by calling again
126
+ ```
127
+
128
+ It uses `assertLicensed` (pinned operator keys required). The journal needs
129
+ `put` to resolve only after a durable write.
130
+
98
131
  ## Check answers against PRE-GEN (recommended)
99
132
 
100
133
  ```typescript
package/dist/index.d.mts CHANGED
@@ -32,6 +32,8 @@ declare class TrustedRegistryDirectory {
32
32
  assertEndpoint(subject: string, baseUrl: string): void;
33
33
  /** V-10: use only AFTER independently verifying the object's signature. */
34
34
  assertSigner(code: string, keyId: string): void;
35
+ /** V-14 */
36
+ private assertNotRevoked;
35
37
  assertDecision(subject: string, license: string | null, keyId: string, baseUrl: string): void;
36
38
  }
37
39
 
@@ -381,6 +383,36 @@ interface LicenseRequestResult {
381
383
  requestId: string;
382
384
  status: string;
383
385
  }
386
+ /** One generation as `generateAuthorized` records it. Stored by the
387
+ * provider, in its own database, so a crash never leads to generating twice. */
388
+ interface GenerationJournalEntry {
389
+ generationId: string;
390
+ /** authorized: allows held, generation may have started.
391
+ * generated: output exists, receipts may be missing.
392
+ * reported: receipts filed. released: nothing produced, allows given back. */
393
+ stage: "authorized" | "generated" | "reported" | "released";
394
+ decisions: SignedDecision[];
395
+ outputHash?: string;
396
+ }
397
+ interface GenerationJournal {
398
+ get(generationId: string): Promise<GenerationJournalEntry | null>;
399
+ /** Resolve only after a durable write. */
400
+ put(entry: GenerationJournalEntry): Promise<void>;
401
+ }
402
+ type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash">;
403
+ interface AuthorizedGeneration<T> {
404
+ /** generated: ran now. recovered: output existed from an earlier run, only
405
+ * receipts were sent. already_reported: nothing left to do. */
406
+ status: "generated" | "recovered" | "already_reported";
407
+ /** Only for status "generated"; after a recovery the output is wherever
408
+ * your earlier run stored it. */
409
+ output?: T;
410
+ decisions: SignedDecision[];
411
+ outputHash: string;
412
+ /** Receipts that could not be filed yet. Call again with the same
413
+ * generationId (needs a journal) or submitReceipt() for each. */
414
+ unreported: SignedDecision[];
415
+ }
384
416
  interface OutputMetadata {
385
417
  prampta_decision_id: string;
386
418
  prampta_license_id: string | null;
@@ -484,6 +516,7 @@ declare class Prampta {
484
516
  private keySetCache;
485
517
  private keySetPromise;
486
518
  private tofuWarned;
519
+ private readonly inFlight;
487
520
  constructor(config?: PramptaConfig);
488
521
  /** V-11: a PG code is answered only by the registry that owns its namespace. */
489
522
  private assertPregenEndpoint;
@@ -559,6 +592,41 @@ declare class Prampta {
559
592
  decision: SignedDecision;
560
593
  metadata: OutputMetadata;
561
594
  }>;
595
+ /**
596
+ * The whole cycle in one call, for every subject in the output:
597
+ * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
598
+ * licence-backed allow from all of them, generate, file one receipt per
599
+ * subject. If any subject refuses, or `generate` throws, the allows already
600
+ * held are released so they do not count against usage limits.
601
+ *
602
+ * `generate` must throw only when nothing was produced. With a `journal`,
603
+ * calling again with the same generationId after a crash never generates
604
+ * twice: it resends missing receipts, or refuses if the crash happened
605
+ * mid-generation (then check your own records, releaseDecision() if nothing
606
+ * was produced, and use a new generationId). Concurrent calls with the
607
+ * same generationId in this process share one run.
608
+ */
609
+ generateAuthorized<T>(request: Omit<VerifyRequest, "subjectId" | "generationId"> & {
610
+ subjectIds: string[];
611
+ generationId: string;
612
+ }, generate: (decisions: SignedDecision[]) => Promise<{
613
+ output: T;
614
+ outputHash?: string;
615
+ outputBytes?: Uint8Array | string;
616
+ }>, options?: {
617
+ journal?: GenerationJournal;
618
+ receipt?: ReceiptOptions;
619
+ }): Promise<AuthorizedGeneration<T>>;
620
+ private runAuthorized;
621
+ /** Returns the decisions whose receipt could not be filed. A receipt that
622
+ * already exists (an earlier attempt landed) counts as filed. */
623
+ private fileReceipts;
624
+ /** True when every allow was given back. */
625
+ private releaseAll;
626
+ /** Give back an allow that produced nothing (failed or cancelled job), so
627
+ * it does not count against the licence's usage limit. Only before a
628
+ * receipt and within 24 hours; the release is recorded in the audit log. */
629
+ releaseDecision(decisionId: string, reason?: string): Promise<Record<string, unknown>>;
562
630
  getSubject(subjectId: string): Promise<SubjectInfo | null>;
563
631
  requestLicense(input: LicenseRequestInput): Promise<LicenseRequestResult>;
564
632
  createOutputMetadata(decision: SignedDecision): OutputMetadata;
@@ -612,4 +680,4 @@ declare class Prampta {
612
680
  private getOnce;
613
681
  }
614
682
 
615
- export { type DeliveryResult, HARD_REFUSAL_CODES, type LicenseRequestInput, type LicenseRequestResult, type LicenseSignatureInput, type Observation, type ObservationOutbox, type OutputMetadata, Prampta, PramptaApiError, type PramptaConfig, PramptaError, PramptaFailClosedError, PramptaNetworkError, PramptaRefusalError, PramptaReporter, PramptaSchemaError, PramptaSignatureError, PramptaTimeoutError, REFUSAL_DESCRIPTIONS, RegistryDirectoryError, type RegistryDirectoryOptions, type ReportInput, SOFT_REFUSAL_CODES, type SignedDecision, type SubjectIndexEntry, type SubjectInfo, TrustedRegistryDirectory, type VerifyOptions, type VerifyRequest, canonicalJson, hashPrompt, matchSubjects, normalizeForMatch, verifyLicenseSignatures, verifyMerkleProof };
683
+ export { type AuthorizedGeneration, type DeliveryResult, type GenerationJournal, type GenerationJournalEntry, HARD_REFUSAL_CODES, type LicenseRequestInput, type LicenseRequestResult, type LicenseSignatureInput, type Observation, type ObservationOutbox, type OutputMetadata, Prampta, PramptaApiError, type PramptaConfig, PramptaError, PramptaFailClosedError, PramptaNetworkError, PramptaRefusalError, PramptaReporter, PramptaSchemaError, PramptaSignatureError, PramptaTimeoutError, REFUSAL_DESCRIPTIONS, type ReceiptOptions, RegistryDirectoryError, type RegistryDirectoryOptions, type ReportInput, SOFT_REFUSAL_CODES, type SignedDecision, type SubjectIndexEntry, type SubjectInfo, TrustedRegistryDirectory, type VerifyOptions, type VerifyRequest, canonicalJson, hashPrompt, matchSubjects, normalizeForMatch, verifyLicenseSignatures, verifyMerkleProof };
package/dist/index.d.ts CHANGED
@@ -32,6 +32,8 @@ declare class TrustedRegistryDirectory {
32
32
  assertEndpoint(subject: string, baseUrl: string): void;
33
33
  /** V-10: use only AFTER independently verifying the object's signature. */
34
34
  assertSigner(code: string, keyId: string): void;
35
+ /** V-14 */
36
+ private assertNotRevoked;
35
37
  assertDecision(subject: string, license: string | null, keyId: string, baseUrl: string): void;
36
38
  }
37
39
 
@@ -381,6 +383,36 @@ interface LicenseRequestResult {
381
383
  requestId: string;
382
384
  status: string;
383
385
  }
386
+ /** One generation as `generateAuthorized` records it. Stored by the
387
+ * provider, in its own database, so a crash never leads to generating twice. */
388
+ interface GenerationJournalEntry {
389
+ generationId: string;
390
+ /** authorized: allows held, generation may have started.
391
+ * generated: output exists, receipts may be missing.
392
+ * reported: receipts filed. released: nothing produced, allows given back. */
393
+ stage: "authorized" | "generated" | "reported" | "released";
394
+ decisions: SignedDecision[];
395
+ outputHash?: string;
396
+ }
397
+ interface GenerationJournal {
398
+ get(generationId: string): Promise<GenerationJournalEntry | null>;
399
+ /** Resolve only after a durable write. */
400
+ put(entry: GenerationJournalEntry): Promise<void>;
401
+ }
402
+ type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash">;
403
+ interface AuthorizedGeneration<T> {
404
+ /** generated: ran now. recovered: output existed from an earlier run, only
405
+ * receipts were sent. already_reported: nothing left to do. */
406
+ status: "generated" | "recovered" | "already_reported";
407
+ /** Only for status "generated"; after a recovery the output is wherever
408
+ * your earlier run stored it. */
409
+ output?: T;
410
+ decisions: SignedDecision[];
411
+ outputHash: string;
412
+ /** Receipts that could not be filed yet. Call again with the same
413
+ * generationId (needs a journal) or submitReceipt() for each. */
414
+ unreported: SignedDecision[];
415
+ }
384
416
  interface OutputMetadata {
385
417
  prampta_decision_id: string;
386
418
  prampta_license_id: string | null;
@@ -484,6 +516,7 @@ declare class Prampta {
484
516
  private keySetCache;
485
517
  private keySetPromise;
486
518
  private tofuWarned;
519
+ private readonly inFlight;
487
520
  constructor(config?: PramptaConfig);
488
521
  /** V-11: a PG code is answered only by the registry that owns its namespace. */
489
522
  private assertPregenEndpoint;
@@ -559,6 +592,41 @@ declare class Prampta {
559
592
  decision: SignedDecision;
560
593
  metadata: OutputMetadata;
561
594
  }>;
595
+ /**
596
+ * The whole cycle in one call, for every subject in the output:
597
+ * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
598
+ * licence-backed allow from all of them, generate, file one receipt per
599
+ * subject. If any subject refuses, or `generate` throws, the allows already
600
+ * held are released so they do not count against usage limits.
601
+ *
602
+ * `generate` must throw only when nothing was produced. With a `journal`,
603
+ * calling again with the same generationId after a crash never generates
604
+ * twice: it resends missing receipts, or refuses if the crash happened
605
+ * mid-generation (then check your own records, releaseDecision() if nothing
606
+ * was produced, and use a new generationId). Concurrent calls with the
607
+ * same generationId in this process share one run.
608
+ */
609
+ generateAuthorized<T>(request: Omit<VerifyRequest, "subjectId" | "generationId"> & {
610
+ subjectIds: string[];
611
+ generationId: string;
612
+ }, generate: (decisions: SignedDecision[]) => Promise<{
613
+ output: T;
614
+ outputHash?: string;
615
+ outputBytes?: Uint8Array | string;
616
+ }>, options?: {
617
+ journal?: GenerationJournal;
618
+ receipt?: ReceiptOptions;
619
+ }): Promise<AuthorizedGeneration<T>>;
620
+ private runAuthorized;
621
+ /** Returns the decisions whose receipt could not be filed. A receipt that
622
+ * already exists (an earlier attempt landed) counts as filed. */
623
+ private fileReceipts;
624
+ /** True when every allow was given back. */
625
+ private releaseAll;
626
+ /** Give back an allow that produced nothing (failed or cancelled job), so
627
+ * it does not count against the licence's usage limit. Only before a
628
+ * receipt and within 24 hours; the release is recorded in the audit log. */
629
+ releaseDecision(decisionId: string, reason?: string): Promise<Record<string, unknown>>;
562
630
  getSubject(subjectId: string): Promise<SubjectInfo | null>;
563
631
  requestLicense(input: LicenseRequestInput): Promise<LicenseRequestResult>;
564
632
  createOutputMetadata(decision: SignedDecision): OutputMetadata;
@@ -612,4 +680,4 @@ declare class Prampta {
612
680
  private getOnce;
613
681
  }
614
682
 
615
- export { type DeliveryResult, HARD_REFUSAL_CODES, type LicenseRequestInput, type LicenseRequestResult, type LicenseSignatureInput, type Observation, type ObservationOutbox, type OutputMetadata, Prampta, PramptaApiError, type PramptaConfig, PramptaError, PramptaFailClosedError, PramptaNetworkError, PramptaRefusalError, PramptaReporter, PramptaSchemaError, PramptaSignatureError, PramptaTimeoutError, REFUSAL_DESCRIPTIONS, RegistryDirectoryError, type RegistryDirectoryOptions, type ReportInput, SOFT_REFUSAL_CODES, type SignedDecision, type SubjectIndexEntry, type SubjectInfo, TrustedRegistryDirectory, type VerifyOptions, type VerifyRequest, canonicalJson, hashPrompt, matchSubjects, normalizeForMatch, verifyLicenseSignatures, verifyMerkleProof };
683
+ export { type AuthorizedGeneration, type DeliveryResult, type GenerationJournal, type GenerationJournalEntry, HARD_REFUSAL_CODES, type LicenseRequestInput, type LicenseRequestResult, type LicenseSignatureInput, type Observation, type ObservationOutbox, type OutputMetadata, Prampta, PramptaApiError, type PramptaConfig, PramptaError, PramptaFailClosedError, PramptaNetworkError, PramptaRefusalError, PramptaReporter, PramptaSchemaError, PramptaSignatureError, PramptaTimeoutError, REFUSAL_DESCRIPTIONS, type ReceiptOptions, RegistryDirectoryError, type RegistryDirectoryOptions, type ReportInput, SOFT_REFUSAL_CODES, type SignedDecision, type SubjectIndexEntry, type SubjectInfo, TrustedRegistryDirectory, type VerifyOptions, type VerifyRequest, canonicalJson, hashPrompt, matchSubjects, normalizeForMatch, verifyLicenseSignatures, verifyMerkleProof };
package/dist/index.js CHANGED
@@ -140,16 +140,18 @@ function registryNamespace(code) {
140
140
  return "";
141
141
  }
142
142
  var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
143
- constructor(registries, sequence, snapshotHash, seal) {
143
+ constructor(registries, revokedKeys, sequence, snapshotHash, seal) {
144
144
  this.sequence = sequence;
145
145
  this.snapshotHash = snapshotHash;
146
146
  if (seal !== verifiedConstruction) fail("Construct directories through signature verification");
147
147
  this.#registries = registries;
148
+ this.#revokedKeys = new Set(revokedKeys);
148
149
  Object.freeze(this);
149
150
  }
150
151
  sequence;
151
152
  snapshotHash;
152
153
  #registries;
154
+ #revokedKeys;
153
155
  static async verify(input, options) {
154
156
  const min = options.minSequence ?? 0;
155
157
  if (!Number.isSafeInteger(min) || min < 0) fail("minSequence must be a nonnegative safe integer");
@@ -189,6 +191,16 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
189
191
  }
190
192
  }
191
193
  if (!prefixes.has("")) fail("Missing origin namespace");
194
+ for (const r of d.registries) {
195
+ if (r.revoked !== void 0 && !(r.revoked && typeof r.revoked === "object" && typeof r.revoked.at === "string")) {
196
+ fail("Invalid registry revocation");
197
+ }
198
+ }
199
+ if (d.revoked_keys !== void 0 && !(Array.isArray(d.revoked_keys) && d.revoked_keys.every(
200
+ (k) => k && /^pg-ed25519:[0-9a-f]{32}$/.test(String(k.key_id)) && typeof k.at === "string"
201
+ ))) {
202
+ fail("Invalid revoked key list");
203
+ }
192
204
  const publicKey = hexBytes(options.stewardPublicKeyHex, 32);
193
205
  const hash = await sha256(publicKey);
194
206
  const fp = "pg-ed25519:" + hash.slice(0, 32);
@@ -206,7 +218,13 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
206
218
  if (!valid) fail("Invalid directory signature");
207
219
  const snapshotHash = await sha256(message);
208
220
  if (previous && previous.sequence === d.sequence && previous.hash !== snapshotHash) fail("Directory equivocation detected");
209
- return new _TrustedRegistryDirectory(d.registries, d.sequence, snapshotHash, verifiedConstruction);
221
+ return new _TrustedRegistryDirectory(
222
+ d.registries,
223
+ (d.revoked_keys ?? []).map((k) => k.key_id),
224
+ d.sequence,
225
+ snapshotHash,
226
+ verifiedConstruction
227
+ );
210
228
  }
211
229
  registryFor(code) {
212
230
  const prefix = registryNamespace(code);
@@ -222,7 +240,13 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
222
240
  }
223
241
  /** V-10: use only AFTER independently verifying the object's signature. */
224
242
  assertSigner(code, keyId) {
225
- if (!this.registryFor(code).key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
243
+ const owner = this.registryFor(code);
244
+ if (!owner.key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
245
+ this.assertNotRevoked(owner, keyId);
246
+ }
247
+ /** V-14 */
248
+ assertNotRevoked(owner, keyId) {
249
+ if (owner.revoked || this.#revokedKeys.has(keyId)) fail("Revoked registry or key");
226
250
  }
227
251
  assertDecision(subject, license, keyId, baseUrl) {
228
252
  this.assertEndpoint(subject, baseUrl);
@@ -230,6 +254,7 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
230
254
  else {
231
255
  const owner = this.#registries.find((r) => apiUrl(r.api) === apiUrl(baseUrl));
232
256
  if (!owner?.key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
257
+ this.assertNotRevoked(owner, keyId);
233
258
  }
234
259
  if (license) {
235
260
  this.assertEndpoint(license, baseUrl);
@@ -602,6 +627,7 @@ var Prampta = class _Prampta {
602
627
  keySetCache = null;
603
628
  keySetPromise = null;
604
629
  tofuWarned = false;
630
+ inFlight = /* @__PURE__ */ new Map();
605
631
  constructor(config = {}) {
606
632
  this.baseUrl = (config.baseUrl || envVar("PRAMPTA_BASE_URL")).replace(/\/+$/, "");
607
633
  this.providerId = config.providerId || envVar("PRAMPTA_PROVIDER_ID");
@@ -1035,6 +1061,94 @@ var Prampta = class _Prampta {
1035
1061
  const metadata = this.createOutputMetadata(decision);
1036
1062
  return { output, decision, metadata };
1037
1063
  }
1064
+ /**
1065
+ * The whole cycle in one call, for every subject in the output:
1066
+ * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
1067
+ * licence-backed allow from all of them, generate, file one receipt per
1068
+ * subject. If any subject refuses, or `generate` throws, the allows already
1069
+ * held are released so they do not count against usage limits.
1070
+ *
1071
+ * `generate` must throw only when nothing was produced. With a `journal`,
1072
+ * calling again with the same generationId after a crash never generates
1073
+ * twice: it resends missing receipts, or refuses if the crash happened
1074
+ * mid-generation (then check your own records, releaseDecision() if nothing
1075
+ * was produced, and use a new generationId). Concurrent calls with the
1076
+ * same generationId in this process share one run.
1077
+ */
1078
+ async generateAuthorized(request, generate, options = {}) {
1079
+ const id = request.generationId;
1080
+ if (!id) throw new PramptaSchemaError("generateAuthorized requires a generationId");
1081
+ const running = this.inFlight.get(id);
1082
+ if (running) return running;
1083
+ const run = this.runAuthorized(request, generate, options).finally(() => this.inFlight.delete(id));
1084
+ this.inFlight.set(id, run);
1085
+ return run;
1086
+ }
1087
+ async runAuthorized(request, generate, { journal, receipt }) {
1088
+ const { subjectIds, generationId, ...rest } = request;
1089
+ const subjects = [...new Set(subjectIds)];
1090
+ if (subjects.length === 0) throw new PramptaSchemaError("generateAuthorized requires at least one subjectId");
1091
+ const saved = await journal?.get(generationId);
1092
+ if (saved?.stage === "authorized") {
1093
+ throw new PramptaError(`Generation ${generationId} was interrupted after it was authorized; it is not run again. If nothing was produced, releaseDecision() each decision and use a new generationId.`);
1094
+ }
1095
+ if (saved?.stage === "reported") {
1096
+ return { status: "already_reported", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: [] };
1097
+ }
1098
+ if (saved?.stage === "generated") {
1099
+ const unreported2 = await this.fileReceipts(saved.decisions, saved.outputHash ?? "", receipt);
1100
+ if (unreported2.length === 0) await journal.put({ ...saved, stage: "reported" });
1101
+ return { status: "recovered", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: unreported2 };
1102
+ }
1103
+ const settled = await Promise.allSettled(subjects.map((s) => this.assertLicensed(s, { ...rest, generationId })));
1104
+ const decisions = settled.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
1105
+ const failed = settled.find((r) => r.status === "rejected");
1106
+ if (failed) {
1107
+ await this.releaseAll(decisions, "another subject in the same output was refused");
1108
+ throw failed.reason;
1109
+ }
1110
+ if (decisions.some((d) => d.expiresAt <= Math.floor(Date.now() / 1e3))) {
1111
+ await this.releaseAll(decisions, "expired before generation started");
1112
+ throw new PramptaError("An authorization expired before generation started; ask again");
1113
+ }
1114
+ await journal?.put({ generationId, stage: "authorized", decisions });
1115
+ let result;
1116
+ try {
1117
+ result = await generate(decisions);
1118
+ } catch (e) {
1119
+ if (await this.releaseAll(decisions, "generation failed")) {
1120
+ await journal?.put({ generationId, stage: "released", decisions });
1121
+ }
1122
+ throw e;
1123
+ }
1124
+ const bytes = typeof result.outputBytes === "string" ? new TextEncoder().encode(result.outputBytes) : result.outputBytes;
1125
+ const outputHash = result.outputHash ?? (bytes ? await sha256Bytes(bytes) : "");
1126
+ await journal?.put({ generationId, stage: "generated", decisions, outputHash });
1127
+ const unreported = await this.fileReceipts(decisions, outputHash, receipt);
1128
+ if (unreported.length === 0) await journal?.put({ generationId, stage: "reported", decisions, outputHash });
1129
+ return { status: "generated", output: result.output, decisions, outputHash, unreported };
1130
+ }
1131
+ /** Returns the decisions whose receipt could not be filed. A receipt that
1132
+ * already exists (an earlier attempt landed) counts as filed. */
1133
+ async fileReceipts(decisions, outputHash, receipt) {
1134
+ const results = await Promise.allSettled(decisions.map((d) => this.submitReceipt(d, { ...receipt, outputHash })));
1135
+ return decisions.filter((_, i) => {
1136
+ const r = results[i];
1137
+ if (r.status === "fulfilled") return false;
1138
+ return !(r.reason instanceof PramptaApiError && r.reason.status === 409 && /already exists/.test(r.reason.detail));
1139
+ });
1140
+ }
1141
+ /** True when every allow was given back. */
1142
+ async releaseAll(decisions, reason) {
1143
+ const results = await Promise.allSettled(decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1144
+ return results.every((r) => r.status === "fulfilled" || r.reason instanceof PramptaApiError && r.reason.status === 409 && /nothing_to_release/.test(r.reason.detail));
1145
+ }
1146
+ /** Give back an allow that produced nothing (failed or cancelled job), so
1147
+ * it does not count against the licence's usage limit. Only before a
1148
+ * receipt and within 24 hours; the release is recorded in the audit log. */
1149
+ async releaseDecision(decisionId, reason = "") {
1150
+ return this.post(`/v1/receipts/${encodeURIComponent(decisionId)}/release`, { reason });
1151
+ }
1038
1152
  // ── Subject Resolution ───────────────────────────────────────────────
1039
1153
  async getSubject(subjectId) {
1040
1154
  this.registryDirectory?.assertEndpoint(subjectId, this.baseUrl);
@@ -1263,7 +1377,7 @@ var Prampta = class _Prampta {
1263
1377
  detail = JSON.parse(text).detail ?? text;
1264
1378
  } catch {
1265
1379
  }
1266
- throw new PramptaApiError(resp.status, String(detail));
1380
+ throw new PramptaApiError(resp.status, typeof detail === "string" ? detail : JSON.stringify(detail));
1267
1381
  }
1268
1382
  return resp.json();
1269
1383
  }
package/dist/index.mjs CHANGED
@@ -85,16 +85,18 @@ function registryNamespace(code) {
85
85
  return "";
86
86
  }
87
87
  var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
88
- constructor(registries, sequence, snapshotHash, seal) {
88
+ constructor(registries, revokedKeys, sequence, snapshotHash, seal) {
89
89
  this.sequence = sequence;
90
90
  this.snapshotHash = snapshotHash;
91
91
  if (seal !== verifiedConstruction) fail("Construct directories through signature verification");
92
92
  this.#registries = registries;
93
+ this.#revokedKeys = new Set(revokedKeys);
93
94
  Object.freeze(this);
94
95
  }
95
96
  sequence;
96
97
  snapshotHash;
97
98
  #registries;
99
+ #revokedKeys;
98
100
  static async verify(input, options) {
99
101
  const min = options.minSequence ?? 0;
100
102
  if (!Number.isSafeInteger(min) || min < 0) fail("minSequence must be a nonnegative safe integer");
@@ -134,6 +136,16 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
134
136
  }
135
137
  }
136
138
  if (!prefixes.has("")) fail("Missing origin namespace");
139
+ for (const r of d.registries) {
140
+ if (r.revoked !== void 0 && !(r.revoked && typeof r.revoked === "object" && typeof r.revoked.at === "string")) {
141
+ fail("Invalid registry revocation");
142
+ }
143
+ }
144
+ if (d.revoked_keys !== void 0 && !(Array.isArray(d.revoked_keys) && d.revoked_keys.every(
145
+ (k) => k && /^pg-ed25519:[0-9a-f]{32}$/.test(String(k.key_id)) && typeof k.at === "string"
146
+ ))) {
147
+ fail("Invalid revoked key list");
148
+ }
137
149
  const publicKey = hexBytes(options.stewardPublicKeyHex, 32);
138
150
  const hash = await sha256(publicKey);
139
151
  const fp = "pg-ed25519:" + hash.slice(0, 32);
@@ -151,7 +163,13 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
151
163
  if (!valid) fail("Invalid directory signature");
152
164
  const snapshotHash = await sha256(message);
153
165
  if (previous && previous.sequence === d.sequence && previous.hash !== snapshotHash) fail("Directory equivocation detected");
154
- return new _TrustedRegistryDirectory(d.registries, d.sequence, snapshotHash, verifiedConstruction);
166
+ return new _TrustedRegistryDirectory(
167
+ d.registries,
168
+ (d.revoked_keys ?? []).map((k) => k.key_id),
169
+ d.sequence,
170
+ snapshotHash,
171
+ verifiedConstruction
172
+ );
155
173
  }
156
174
  registryFor(code) {
157
175
  const prefix = registryNamespace(code);
@@ -167,7 +185,13 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
167
185
  }
168
186
  /** V-10: use only AFTER independently verifying the object's signature. */
169
187
  assertSigner(code, keyId) {
170
- if (!this.registryFor(code).key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
188
+ const owner = this.registryFor(code);
189
+ if (!owner.key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
190
+ this.assertNotRevoked(owner, keyId);
191
+ }
192
+ /** V-14 */
193
+ assertNotRevoked(owner, keyId) {
194
+ if (owner.revoked || this.#revokedKeys.has(keyId)) fail("Revoked registry or key");
171
195
  }
172
196
  assertDecision(subject, license, keyId, baseUrl) {
173
197
  this.assertEndpoint(subject, baseUrl);
@@ -175,6 +199,7 @@ var TrustedRegistryDirectory = class _TrustedRegistryDirectory {
175
199
  else {
176
200
  const owner = this.#registries.find((r) => apiUrl(r.api) === apiUrl(baseUrl));
177
201
  if (!owner?.key_fingerprints.includes(keyId)) fail("Registry issuer/key mismatch");
202
+ this.assertNotRevoked(owner, keyId);
178
203
  }
179
204
  if (license) {
180
205
  this.assertEndpoint(license, baseUrl);
@@ -547,6 +572,7 @@ var Prampta = class _Prampta {
547
572
  keySetCache = null;
548
573
  keySetPromise = null;
549
574
  tofuWarned = false;
575
+ inFlight = /* @__PURE__ */ new Map();
550
576
  constructor(config = {}) {
551
577
  this.baseUrl = (config.baseUrl || envVar("PRAMPTA_BASE_URL")).replace(/\/+$/, "");
552
578
  this.providerId = config.providerId || envVar("PRAMPTA_PROVIDER_ID");
@@ -980,6 +1006,94 @@ var Prampta = class _Prampta {
980
1006
  const metadata = this.createOutputMetadata(decision);
981
1007
  return { output, decision, metadata };
982
1008
  }
1009
+ /**
1010
+ * The whole cycle in one call, for every subject in the output:
1011
+ * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
1012
+ * licence-backed allow from all of them, generate, file one receipt per
1013
+ * subject. If any subject refuses, or `generate` throws, the allows already
1014
+ * held are released so they do not count against usage limits.
1015
+ *
1016
+ * `generate` must throw only when nothing was produced. With a `journal`,
1017
+ * calling again with the same generationId after a crash never generates
1018
+ * twice: it resends missing receipts, or refuses if the crash happened
1019
+ * mid-generation (then check your own records, releaseDecision() if nothing
1020
+ * was produced, and use a new generationId). Concurrent calls with the
1021
+ * same generationId in this process share one run.
1022
+ */
1023
+ async generateAuthorized(request, generate, options = {}) {
1024
+ const id = request.generationId;
1025
+ if (!id) throw new PramptaSchemaError("generateAuthorized requires a generationId");
1026
+ const running = this.inFlight.get(id);
1027
+ if (running) return running;
1028
+ const run = this.runAuthorized(request, generate, options).finally(() => this.inFlight.delete(id));
1029
+ this.inFlight.set(id, run);
1030
+ return run;
1031
+ }
1032
+ async runAuthorized(request, generate, { journal, receipt }) {
1033
+ const { subjectIds, generationId, ...rest } = request;
1034
+ const subjects = [...new Set(subjectIds)];
1035
+ if (subjects.length === 0) throw new PramptaSchemaError("generateAuthorized requires at least one subjectId");
1036
+ const saved = await journal?.get(generationId);
1037
+ if (saved?.stage === "authorized") {
1038
+ throw new PramptaError(`Generation ${generationId} was interrupted after it was authorized; it is not run again. If nothing was produced, releaseDecision() each decision and use a new generationId.`);
1039
+ }
1040
+ if (saved?.stage === "reported") {
1041
+ return { status: "already_reported", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: [] };
1042
+ }
1043
+ if (saved?.stage === "generated") {
1044
+ const unreported2 = await this.fileReceipts(saved.decisions, saved.outputHash ?? "", receipt);
1045
+ if (unreported2.length === 0) await journal.put({ ...saved, stage: "reported" });
1046
+ return { status: "recovered", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: unreported2 };
1047
+ }
1048
+ const settled = await Promise.allSettled(subjects.map((s) => this.assertLicensed(s, { ...rest, generationId })));
1049
+ const decisions = settled.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
1050
+ const failed = settled.find((r) => r.status === "rejected");
1051
+ if (failed) {
1052
+ await this.releaseAll(decisions, "another subject in the same output was refused");
1053
+ throw failed.reason;
1054
+ }
1055
+ if (decisions.some((d) => d.expiresAt <= Math.floor(Date.now() / 1e3))) {
1056
+ await this.releaseAll(decisions, "expired before generation started");
1057
+ throw new PramptaError("An authorization expired before generation started; ask again");
1058
+ }
1059
+ await journal?.put({ generationId, stage: "authorized", decisions });
1060
+ let result;
1061
+ try {
1062
+ result = await generate(decisions);
1063
+ } catch (e) {
1064
+ if (await this.releaseAll(decisions, "generation failed")) {
1065
+ await journal?.put({ generationId, stage: "released", decisions });
1066
+ }
1067
+ throw e;
1068
+ }
1069
+ const bytes = typeof result.outputBytes === "string" ? new TextEncoder().encode(result.outputBytes) : result.outputBytes;
1070
+ const outputHash = result.outputHash ?? (bytes ? await sha256Bytes(bytes) : "");
1071
+ await journal?.put({ generationId, stage: "generated", decisions, outputHash });
1072
+ const unreported = await this.fileReceipts(decisions, outputHash, receipt);
1073
+ if (unreported.length === 0) await journal?.put({ generationId, stage: "reported", decisions, outputHash });
1074
+ return { status: "generated", output: result.output, decisions, outputHash, unreported };
1075
+ }
1076
+ /** Returns the decisions whose receipt could not be filed. A receipt that
1077
+ * already exists (an earlier attempt landed) counts as filed. */
1078
+ async fileReceipts(decisions, outputHash, receipt) {
1079
+ const results = await Promise.allSettled(decisions.map((d) => this.submitReceipt(d, { ...receipt, outputHash })));
1080
+ return decisions.filter((_, i) => {
1081
+ const r = results[i];
1082
+ if (r.status === "fulfilled") return false;
1083
+ return !(r.reason instanceof PramptaApiError && r.reason.status === 409 && /already exists/.test(r.reason.detail));
1084
+ });
1085
+ }
1086
+ /** True when every allow was given back. */
1087
+ async releaseAll(decisions, reason) {
1088
+ const results = await Promise.allSettled(decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1089
+ return results.every((r) => r.status === "fulfilled" || r.reason instanceof PramptaApiError && r.reason.status === 409 && /nothing_to_release/.test(r.reason.detail));
1090
+ }
1091
+ /** Give back an allow that produced nothing (failed or cancelled job), so
1092
+ * it does not count against the licence's usage limit. Only before a
1093
+ * receipt and within 24 hours; the release is recorded in the audit log. */
1094
+ async releaseDecision(decisionId, reason = "") {
1095
+ return this.post(`/v1/receipts/${encodeURIComponent(decisionId)}/release`, { reason });
1096
+ }
983
1097
  // ── Subject Resolution ───────────────────────────────────────────────
984
1098
  async getSubject(subjectId) {
985
1099
  this.registryDirectory?.assertEndpoint(subjectId, this.baseUrl);
@@ -1208,7 +1322,7 @@ var Prampta = class _Prampta {
1208
1322
  detail = JSON.parse(text).detail ?? text;
1209
1323
  } catch {
1210
1324
  }
1211
- throw new PramptaApiError(resp.status, String(detail));
1325
+ throw new PramptaApiError(resp.status, typeof detail === "string" ? detail : JSON.stringify(detail));
1212
1326
  }
1213
1327
  return resp.json();
1214
1328
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prampta/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "PRAMPTA SDK — authorization before generation",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@noble/ed25519": "^3.1.0",
50
- "@pregen/verify": "^0.4.0"
50
+ "@pregen/verify": "^0.5.0"
51
51
  },
52
52
  "repository": {
53
53
  "type": "git",