@prampta/sdk 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -381,6 +381,36 @@ interface LicenseRequestResult {
381
381
  requestId: string;
382
382
  status: string;
383
383
  }
384
+ /** One generation as `generateAuthorized` records it. Stored by the
385
+ * provider, in its own database, so a crash never leads to generating twice. */
386
+ interface GenerationJournalEntry {
387
+ generationId: string;
388
+ /** authorized: allows held, generation may have started.
389
+ * generated: output exists, receipts may be missing.
390
+ * reported: receipts filed. released: nothing produced, allows given back. */
391
+ stage: "authorized" | "generated" | "reported" | "released";
392
+ decisions: SignedDecision[];
393
+ outputHash?: string;
394
+ }
395
+ interface GenerationJournal {
396
+ get(generationId: string): Promise<GenerationJournalEntry | null>;
397
+ /** Resolve only after a durable write. */
398
+ put(entry: GenerationJournalEntry): Promise<void>;
399
+ }
400
+ type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash">;
401
+ interface AuthorizedGeneration<T> {
402
+ /** generated: ran now. recovered: output existed from an earlier run, only
403
+ * receipts were sent. already_reported: nothing left to do. */
404
+ status: "generated" | "recovered" | "already_reported";
405
+ /** Only for status "generated"; after a recovery the output is wherever
406
+ * your earlier run stored it. */
407
+ output?: T;
408
+ decisions: SignedDecision[];
409
+ outputHash: string;
410
+ /** Receipts that could not be filed yet. Call again with the same
411
+ * generationId (needs a journal) or submitReceipt() for each. */
412
+ unreported: SignedDecision[];
413
+ }
384
414
  interface OutputMetadata {
385
415
  prampta_decision_id: string;
386
416
  prampta_license_id: string | null;
@@ -484,6 +514,7 @@ declare class Prampta {
484
514
  private keySetCache;
485
515
  private keySetPromise;
486
516
  private tofuWarned;
517
+ private readonly inFlight;
487
518
  constructor(config?: PramptaConfig);
488
519
  /** V-11: a PG code is answered only by the registry that owns its namespace. */
489
520
  private assertPregenEndpoint;
@@ -559,6 +590,41 @@ declare class Prampta {
559
590
  decision: SignedDecision;
560
591
  metadata: OutputMetadata;
561
592
  }>;
593
+ /**
594
+ * The whole cycle in one call, for every subject in the output:
595
+ * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
596
+ * licence-backed allow from all of them, generate, file one receipt per
597
+ * subject. If any subject refuses, or `generate` throws, the allows already
598
+ * held are released so they do not count against usage limits.
599
+ *
600
+ * `generate` must throw only when nothing was produced. With a `journal`,
601
+ * calling again with the same generationId after a crash never generates
602
+ * twice: it resends missing receipts, or refuses if the crash happened
603
+ * mid-generation (then check your own records, releaseDecision() if nothing
604
+ * was produced, and use a new generationId). Concurrent calls with the
605
+ * same generationId in this process share one run.
606
+ */
607
+ generateAuthorized<T>(request: Omit<VerifyRequest, "subjectId" | "generationId"> & {
608
+ subjectIds: string[];
609
+ generationId: string;
610
+ }, generate: (decisions: SignedDecision[]) => Promise<{
611
+ output: T;
612
+ outputHash?: string;
613
+ outputBytes?: Uint8Array | string;
614
+ }>, options?: {
615
+ journal?: GenerationJournal;
616
+ receipt?: ReceiptOptions;
617
+ }): Promise<AuthorizedGeneration<T>>;
618
+ private runAuthorized;
619
+ /** Returns the decisions whose receipt could not be filed. A receipt that
620
+ * already exists (an earlier attempt landed) counts as filed. */
621
+ private fileReceipts;
622
+ /** True when every allow was given back. */
623
+ private releaseAll;
624
+ /** Give back an allow that produced nothing (failed or cancelled job), so
625
+ * it does not count against the licence's usage limit. Only before a
626
+ * receipt and within 24 hours; the release is recorded in the audit log. */
627
+ releaseDecision(decisionId: string, reason?: string): Promise<Record<string, unknown>>;
562
628
  getSubject(subjectId: string): Promise<SubjectInfo | null>;
563
629
  requestLicense(input: LicenseRequestInput): Promise<LicenseRequestResult>;
564
630
  createOutputMetadata(decision: SignedDecision): OutputMetadata;
@@ -612,4 +678,4 @@ declare class Prampta {
612
678
  private getOnce;
613
679
  }
614
680
 
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 };
681
+ 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
@@ -381,6 +381,36 @@ interface LicenseRequestResult {
381
381
  requestId: string;
382
382
  status: string;
383
383
  }
384
+ /** One generation as `generateAuthorized` records it. Stored by the
385
+ * provider, in its own database, so a crash never leads to generating twice. */
386
+ interface GenerationJournalEntry {
387
+ generationId: string;
388
+ /** authorized: allows held, generation may have started.
389
+ * generated: output exists, receipts may be missing.
390
+ * reported: receipts filed. released: nothing produced, allows given back. */
391
+ stage: "authorized" | "generated" | "reported" | "released";
392
+ decisions: SignedDecision[];
393
+ outputHash?: string;
394
+ }
395
+ interface GenerationJournal {
396
+ get(generationId: string): Promise<GenerationJournalEntry | null>;
397
+ /** Resolve only after a durable write. */
398
+ put(entry: GenerationJournalEntry): Promise<void>;
399
+ }
400
+ type ReceiptOptions = Omit<NonNullable<Parameters<Prampta["submitReceipt"]>[1]>, "outputHash">;
401
+ interface AuthorizedGeneration<T> {
402
+ /** generated: ran now. recovered: output existed from an earlier run, only
403
+ * receipts were sent. already_reported: nothing left to do. */
404
+ status: "generated" | "recovered" | "already_reported";
405
+ /** Only for status "generated"; after a recovery the output is wherever
406
+ * your earlier run stored it. */
407
+ output?: T;
408
+ decisions: SignedDecision[];
409
+ outputHash: string;
410
+ /** Receipts that could not be filed yet. Call again with the same
411
+ * generationId (needs a journal) or submitReceipt() for each. */
412
+ unreported: SignedDecision[];
413
+ }
384
414
  interface OutputMetadata {
385
415
  prampta_decision_id: string;
386
416
  prampta_license_id: string | null;
@@ -484,6 +514,7 @@ declare class Prampta {
484
514
  private keySetCache;
485
515
  private keySetPromise;
486
516
  private tofuWarned;
517
+ private readonly inFlight;
487
518
  constructor(config?: PramptaConfig);
488
519
  /** V-11: a PG code is answered only by the registry that owns its namespace. */
489
520
  private assertPregenEndpoint;
@@ -559,6 +590,41 @@ declare class Prampta {
559
590
  decision: SignedDecision;
560
591
  metadata: OutputMetadata;
561
592
  }>;
593
+ /**
594
+ * The whole cycle in one call, for every subject in the output:
595
+ * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
596
+ * licence-backed allow from all of them, generate, file one receipt per
597
+ * subject. If any subject refuses, or `generate` throws, the allows already
598
+ * held are released so they do not count against usage limits.
599
+ *
600
+ * `generate` must throw only when nothing was produced. With a `journal`,
601
+ * calling again with the same generationId after a crash never generates
602
+ * twice: it resends missing receipts, or refuses if the crash happened
603
+ * mid-generation (then check your own records, releaseDecision() if nothing
604
+ * was produced, and use a new generationId). Concurrent calls with the
605
+ * same generationId in this process share one run.
606
+ */
607
+ generateAuthorized<T>(request: Omit<VerifyRequest, "subjectId" | "generationId"> & {
608
+ subjectIds: string[];
609
+ generationId: string;
610
+ }, generate: (decisions: SignedDecision[]) => Promise<{
611
+ output: T;
612
+ outputHash?: string;
613
+ outputBytes?: Uint8Array | string;
614
+ }>, options?: {
615
+ journal?: GenerationJournal;
616
+ receipt?: ReceiptOptions;
617
+ }): Promise<AuthorizedGeneration<T>>;
618
+ private runAuthorized;
619
+ /** Returns the decisions whose receipt could not be filed. A receipt that
620
+ * already exists (an earlier attempt landed) counts as filed. */
621
+ private fileReceipts;
622
+ /** True when every allow was given back. */
623
+ private releaseAll;
624
+ /** Give back an allow that produced nothing (failed or cancelled job), so
625
+ * it does not count against the licence's usage limit. Only before a
626
+ * receipt and within 24 hours; the release is recorded in the audit log. */
627
+ releaseDecision(decisionId: string, reason?: string): Promise<Record<string, unknown>>;
562
628
  getSubject(subjectId: string): Promise<SubjectInfo | null>;
563
629
  requestLicense(input: LicenseRequestInput): Promise<LicenseRequestResult>;
564
630
  createOutputMetadata(decision: SignedDecision): OutputMetadata;
@@ -612,4 +678,4 @@ declare class Prampta {
612
678
  private getOnce;
613
679
  }
614
680
 
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 };
681
+ 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
@@ -602,6 +602,7 @@ var Prampta = class _Prampta {
602
602
  keySetCache = null;
603
603
  keySetPromise = null;
604
604
  tofuWarned = false;
605
+ inFlight = /* @__PURE__ */ new Map();
605
606
  constructor(config = {}) {
606
607
  this.baseUrl = (config.baseUrl || envVar("PRAMPTA_BASE_URL")).replace(/\/+$/, "");
607
608
  this.providerId = config.providerId || envVar("PRAMPTA_PROVIDER_ID");
@@ -1035,6 +1036,94 @@ var Prampta = class _Prampta {
1035
1036
  const metadata = this.createOutputMetadata(decision);
1036
1037
  return { output, decision, metadata };
1037
1038
  }
1039
+ /**
1040
+ * The whole cycle in one call, for every subject in the output:
1041
+ * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
1042
+ * licence-backed allow from all of them, generate, file one receipt per
1043
+ * subject. If any subject refuses, or `generate` throws, the allows already
1044
+ * held are released so they do not count against usage limits.
1045
+ *
1046
+ * `generate` must throw only when nothing was produced. With a `journal`,
1047
+ * calling again with the same generationId after a crash never generates
1048
+ * twice: it resends missing receipts, or refuses if the crash happened
1049
+ * mid-generation (then check your own records, releaseDecision() if nothing
1050
+ * was produced, and use a new generationId). Concurrent calls with the
1051
+ * same generationId in this process share one run.
1052
+ */
1053
+ async generateAuthorized(request, generate, options = {}) {
1054
+ const id = request.generationId;
1055
+ if (!id) throw new PramptaSchemaError("generateAuthorized requires a generationId");
1056
+ const running = this.inFlight.get(id);
1057
+ if (running) return running;
1058
+ const run = this.runAuthorized(request, generate, options).finally(() => this.inFlight.delete(id));
1059
+ this.inFlight.set(id, run);
1060
+ return run;
1061
+ }
1062
+ async runAuthorized(request, generate, { journal, receipt }) {
1063
+ const { subjectIds, generationId, ...rest } = request;
1064
+ const subjects = [...new Set(subjectIds)];
1065
+ if (subjects.length === 0) throw new PramptaSchemaError("generateAuthorized requires at least one subjectId");
1066
+ const saved = await journal?.get(generationId);
1067
+ if (saved?.stage === "authorized") {
1068
+ 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.`);
1069
+ }
1070
+ if (saved?.stage === "reported") {
1071
+ return { status: "already_reported", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: [] };
1072
+ }
1073
+ if (saved?.stage === "generated") {
1074
+ const unreported2 = await this.fileReceipts(saved.decisions, saved.outputHash ?? "", receipt);
1075
+ if (unreported2.length === 0) await journal.put({ ...saved, stage: "reported" });
1076
+ return { status: "recovered", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: unreported2 };
1077
+ }
1078
+ const settled = await Promise.allSettled(subjects.map((s) => this.assertLicensed(s, { ...rest, generationId })));
1079
+ const decisions = settled.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
1080
+ const failed = settled.find((r) => r.status === "rejected");
1081
+ if (failed) {
1082
+ await this.releaseAll(decisions, "another subject in the same output was refused");
1083
+ throw failed.reason;
1084
+ }
1085
+ if (decisions.some((d) => d.expiresAt <= Math.floor(Date.now() / 1e3))) {
1086
+ await this.releaseAll(decisions, "expired before generation started");
1087
+ throw new PramptaError("An authorization expired before generation started; ask again");
1088
+ }
1089
+ await journal?.put({ generationId, stage: "authorized", decisions });
1090
+ let result;
1091
+ try {
1092
+ result = await generate(decisions);
1093
+ } catch (e) {
1094
+ if (await this.releaseAll(decisions, "generation failed")) {
1095
+ await journal?.put({ generationId, stage: "released", decisions });
1096
+ }
1097
+ throw e;
1098
+ }
1099
+ const bytes = typeof result.outputBytes === "string" ? new TextEncoder().encode(result.outputBytes) : result.outputBytes;
1100
+ const outputHash = result.outputHash ?? (bytes ? await sha256Bytes(bytes) : "");
1101
+ await journal?.put({ generationId, stage: "generated", decisions, outputHash });
1102
+ const unreported = await this.fileReceipts(decisions, outputHash, receipt);
1103
+ if (unreported.length === 0) await journal?.put({ generationId, stage: "reported", decisions, outputHash });
1104
+ return { status: "generated", output: result.output, decisions, outputHash, unreported };
1105
+ }
1106
+ /** Returns the decisions whose receipt could not be filed. A receipt that
1107
+ * already exists (an earlier attempt landed) counts as filed. */
1108
+ async fileReceipts(decisions, outputHash, receipt) {
1109
+ const results = await Promise.allSettled(decisions.map((d) => this.submitReceipt(d, { ...receipt, outputHash })));
1110
+ return decisions.filter((_, i) => {
1111
+ const r = results[i];
1112
+ if (r.status === "fulfilled") return false;
1113
+ return !(r.reason instanceof PramptaApiError && r.reason.status === 409 && /already exists/.test(r.reason.detail));
1114
+ });
1115
+ }
1116
+ /** True when every allow was given back. */
1117
+ async releaseAll(decisions, reason) {
1118
+ const results = await Promise.allSettled(decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1119
+ return results.every((r) => r.status === "fulfilled" || r.reason instanceof PramptaApiError && r.reason.status === 409 && /nothing_to_release/.test(r.reason.detail));
1120
+ }
1121
+ /** Give back an allow that produced nothing (failed or cancelled job), so
1122
+ * it does not count against the licence's usage limit. Only before a
1123
+ * receipt and within 24 hours; the release is recorded in the audit log. */
1124
+ async releaseDecision(decisionId, reason = "") {
1125
+ return this.post(`/v1/receipts/${encodeURIComponent(decisionId)}/release`, { reason });
1126
+ }
1038
1127
  // ── Subject Resolution ───────────────────────────────────────────────
1039
1128
  async getSubject(subjectId) {
1040
1129
  this.registryDirectory?.assertEndpoint(subjectId, this.baseUrl);
@@ -1263,7 +1352,7 @@ var Prampta = class _Prampta {
1263
1352
  detail = JSON.parse(text).detail ?? text;
1264
1353
  } catch {
1265
1354
  }
1266
- throw new PramptaApiError(resp.status, String(detail));
1355
+ throw new PramptaApiError(resp.status, typeof detail === "string" ? detail : JSON.stringify(detail));
1267
1356
  }
1268
1357
  return resp.json();
1269
1358
  }
package/dist/index.mjs CHANGED
@@ -547,6 +547,7 @@ var Prampta = class _Prampta {
547
547
  keySetCache = null;
548
548
  keySetPromise = null;
549
549
  tofuWarned = false;
550
+ inFlight = /* @__PURE__ */ new Map();
550
551
  constructor(config = {}) {
551
552
  this.baseUrl = (config.baseUrl || envVar("PRAMPTA_BASE_URL")).replace(/\/+$/, "");
552
553
  this.providerId = config.providerId || envVar("PRAMPTA_PROVIDER_ID");
@@ -980,6 +981,94 @@ var Prampta = class _Prampta {
980
981
  const metadata = this.createOutputMetadata(decision);
981
982
  return { output, decision, metadata };
982
983
  }
984
+ /**
985
+ * The whole cycle in one call, for every subject in the output:
986
+ * ask PRAMPTA fresh for each subject (no cached allow is reused), require a
987
+ * licence-backed allow from all of them, generate, file one receipt per
988
+ * subject. If any subject refuses, or `generate` throws, the allows already
989
+ * held are released so they do not count against usage limits.
990
+ *
991
+ * `generate` must throw only when nothing was produced. With a `journal`,
992
+ * calling again with the same generationId after a crash never generates
993
+ * twice: it resends missing receipts, or refuses if the crash happened
994
+ * mid-generation (then check your own records, releaseDecision() if nothing
995
+ * was produced, and use a new generationId). Concurrent calls with the
996
+ * same generationId in this process share one run.
997
+ */
998
+ async generateAuthorized(request, generate, options = {}) {
999
+ const id = request.generationId;
1000
+ if (!id) throw new PramptaSchemaError("generateAuthorized requires a generationId");
1001
+ const running = this.inFlight.get(id);
1002
+ if (running) return running;
1003
+ const run = this.runAuthorized(request, generate, options).finally(() => this.inFlight.delete(id));
1004
+ this.inFlight.set(id, run);
1005
+ return run;
1006
+ }
1007
+ async runAuthorized(request, generate, { journal, receipt }) {
1008
+ const { subjectIds, generationId, ...rest } = request;
1009
+ const subjects = [...new Set(subjectIds)];
1010
+ if (subjects.length === 0) throw new PramptaSchemaError("generateAuthorized requires at least one subjectId");
1011
+ const saved = await journal?.get(generationId);
1012
+ if (saved?.stage === "authorized") {
1013
+ 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.`);
1014
+ }
1015
+ if (saved?.stage === "reported") {
1016
+ return { status: "already_reported", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: [] };
1017
+ }
1018
+ if (saved?.stage === "generated") {
1019
+ const unreported2 = await this.fileReceipts(saved.decisions, saved.outputHash ?? "", receipt);
1020
+ if (unreported2.length === 0) await journal.put({ ...saved, stage: "reported" });
1021
+ return { status: "recovered", decisions: saved.decisions, outputHash: saved.outputHash ?? "", unreported: unreported2 };
1022
+ }
1023
+ const settled = await Promise.allSettled(subjects.map((s) => this.assertLicensed(s, { ...rest, generationId })));
1024
+ const decisions = settled.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
1025
+ const failed = settled.find((r) => r.status === "rejected");
1026
+ if (failed) {
1027
+ await this.releaseAll(decisions, "another subject in the same output was refused");
1028
+ throw failed.reason;
1029
+ }
1030
+ if (decisions.some((d) => d.expiresAt <= Math.floor(Date.now() / 1e3))) {
1031
+ await this.releaseAll(decisions, "expired before generation started");
1032
+ throw new PramptaError("An authorization expired before generation started; ask again");
1033
+ }
1034
+ await journal?.put({ generationId, stage: "authorized", decisions });
1035
+ let result;
1036
+ try {
1037
+ result = await generate(decisions);
1038
+ } catch (e) {
1039
+ if (await this.releaseAll(decisions, "generation failed")) {
1040
+ await journal?.put({ generationId, stage: "released", decisions });
1041
+ }
1042
+ throw e;
1043
+ }
1044
+ const bytes = typeof result.outputBytes === "string" ? new TextEncoder().encode(result.outputBytes) : result.outputBytes;
1045
+ const outputHash = result.outputHash ?? (bytes ? await sha256Bytes(bytes) : "");
1046
+ await journal?.put({ generationId, stage: "generated", decisions, outputHash });
1047
+ const unreported = await this.fileReceipts(decisions, outputHash, receipt);
1048
+ if (unreported.length === 0) await journal?.put({ generationId, stage: "reported", decisions, outputHash });
1049
+ return { status: "generated", output: result.output, decisions, outputHash, unreported };
1050
+ }
1051
+ /** Returns the decisions whose receipt could not be filed. A receipt that
1052
+ * already exists (an earlier attempt landed) counts as filed. */
1053
+ async fileReceipts(decisions, outputHash, receipt) {
1054
+ const results = await Promise.allSettled(decisions.map((d) => this.submitReceipt(d, { ...receipt, outputHash })));
1055
+ return decisions.filter((_, i) => {
1056
+ const r = results[i];
1057
+ if (r.status === "fulfilled") return false;
1058
+ return !(r.reason instanceof PramptaApiError && r.reason.status === 409 && /already exists/.test(r.reason.detail));
1059
+ });
1060
+ }
1061
+ /** True when every allow was given back. */
1062
+ async releaseAll(decisions, reason) {
1063
+ const results = await Promise.allSettled(decisions.map((d) => this.releaseDecision(d.decisionId, reason)));
1064
+ return results.every((r) => r.status === "fulfilled" || r.reason instanceof PramptaApiError && r.reason.status === 409 && /nothing_to_release/.test(r.reason.detail));
1065
+ }
1066
+ /** Give back an allow that produced nothing (failed or cancelled job), so
1067
+ * it does not count against the licence's usage limit. Only before a
1068
+ * receipt and within 24 hours; the release is recorded in the audit log. */
1069
+ async releaseDecision(decisionId, reason = "") {
1070
+ return this.post(`/v1/receipts/${encodeURIComponent(decisionId)}/release`, { reason });
1071
+ }
983
1072
  // ── Subject Resolution ───────────────────────────────────────────────
984
1073
  async getSubject(subjectId) {
985
1074
  this.registryDirectory?.assertEndpoint(subjectId, this.baseUrl);
@@ -1208,7 +1297,7 @@ var Prampta = class _Prampta {
1208
1297
  detail = JSON.parse(text).detail ?? text;
1209
1298
  } catch {
1210
1299
  }
1211
- throw new PramptaApiError(resp.status, String(detail));
1300
+ throw new PramptaApiError(resp.status, typeof detail === "string" ? detail : JSON.stringify(detail));
1212
1301
  }
1213
1302
  return resp.json();
1214
1303
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prampta/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "PRAMPTA SDK — authorization before generation",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",