@prampta/sdk 0.10.0 → 0.11.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/dist/index.mjs CHANGED
@@ -355,6 +355,14 @@ var PramptaReporter = class {
355
355
 
356
356
  // src/pregen.ts
357
357
  import * as ed3 from "@noble/ed25519";
358
+ import { issuerOf } from "@pregen/verify";
359
+ var NotGeneratedError = class extends Error {
360
+ notGenerated = true;
361
+ constructor(message = "not generated") {
362
+ super(message);
363
+ this.name = "NotGeneratedError";
364
+ }
365
+ };
358
366
  var PregenError = class extends Error {
359
367
  constructor(code, message) {
360
368
  super(`${code}: ${message}`);
@@ -369,13 +377,32 @@ var CLOCK_TOLERANCE = 60;
369
377
  var JOB_ID = /^[A-Za-z0-9_.:-]{1,100}$/;
370
378
  var HEX64 = /^[0-9a-f]{64}$/;
371
379
  var STATUSES = /* @__PURE__ */ new Set(["allow", "deny", "review", "not_blocked"]);
380
+ var LEVELS = /* @__PURE__ */ new Set(["self", "asserted", "consented", "verified"]);
381
+ var PURPOSES = /* @__PURE__ */ new Set(["personal", "educational", "research", "editorial", "commercial"]);
382
+ var ANSWER = {
383
+ required: ["signers", "request_id", "profile", "status", "level", "obligations", "issued_at", "expires_at"],
384
+ optional: ["reason", "basis", "ext", "critical"]
385
+ };
386
+ var ACK_FIELDS = ["signers", "report", "answer", "answer_status", "counted", "released", "provider_key_id", "received_at"];
372
387
  var enc = new TextEncoder();
373
388
  var now = () => Math.floor(Date.now() / 1e3);
374
389
  var hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
375
390
  var unhex = (s) => {
376
- if (!/^(?:[0-9a-fA-F]{2})+$/.test(s)) throw new PregenError("PG_MALFORMED", "not hex");
391
+ if (typeof s !== "string" || !/^(?:[0-9a-fA-F]{2})+$/.test(s)) throw new PregenError("PG_CONFIG", "not hex");
377
392
  return Uint8Array.from(s.match(/../g), (h) => parseInt(h, 16));
378
393
  };
394
+ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
395
+ var str = (v) => typeof v === "string" && v.length > 0;
396
+ var keysWithin = (o, allowed) => Object.keys(o).every((k) => allowed.includes(k));
397
+ var isPgCode = (s) => {
398
+ try {
399
+ issuerOf(s);
400
+ return true;
401
+ } catch {
402
+ return false;
403
+ }
404
+ };
405
+ var malformed = (why) => new PregenError("PG_MALFORMED", why);
379
406
  async function sha2562(data) {
380
407
  return hex(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", data)));
381
408
  }
@@ -386,13 +413,13 @@ function messageId(type, body) {
386
413
  return sha2562(enc.encode(`PREGEN-V5-ID\0${type}\0${canonicalJson(body)}`));
387
414
  }
388
415
  var keyIdOf = async (pub) => "pg-ed25519:" + (await sha2562(pub)).slice(0, 32);
389
- async function verifyEnvelope(env, type, role, keys) {
416
+ async function verifyEnvelope(env, type, role, keyFor) {
390
417
  const e = env;
391
- if (!e || typeof e !== "object" || Object.keys(e).sort().join() !== "body,signatures,type" || e.type !== type || !e.body || typeof e.body !== "object" || !Array.isArray(e.signatures)) {
392
- throw new PregenError("PG_MALFORMED", `not a ${type}`);
418
+ if (!isObj(e) || Object.keys(e).sort().join() !== "body,signatures,type" || e.type !== type || !isObj(e.body) || !Array.isArray(e.signatures)) {
419
+ throw malformed(`not a ${type}`);
393
420
  }
394
421
  const s = e.body.signers;
395
- const pub = Array.isArray(s) && s.length === 1 && s[0]?.role === role && e.signatures.length === 1 ? keys.get(s[0].key_id) : void 0;
422
+ const pub = Array.isArray(s) && s.length === 1 && s[0]?.role === role && e.signatures.length === 1 && typeof s[0].key_id === "string" ? await keyFor(s[0].key_id) : void 0;
396
423
  if (!pub) throw new PregenError("PG_SIGNATURE", `not signed by a pinned ${role} key`);
397
424
  let ok = false;
398
425
  try {
@@ -402,34 +429,113 @@ async function verifyEnvelope(env, type, role, keys) {
402
429
  if (!ok) throw new PregenError("PG_SIGNATURE", `${type} signature does not verify`);
403
430
  return e.body;
404
431
  }
432
+ function checkBasis(b) {
433
+ if (!isObj(b)) return false;
434
+ if (b.kind === "grant") {
435
+ const g = b.grant;
436
+ return keysWithin(b, ["kind", "grant"]) && isObj(g) && keysWithin(g, ["format", "id", "license_id"]) && str(g.format) && typeof g.id === "string" && Object.values(g).every((v) => typeof v === "string");
437
+ }
438
+ return b.kind === "policy" && keysWithin(b, ["kind", "rule"]) && str(b.rule);
439
+ }
405
440
  async function checkAnswer(body, request, at) {
406
- if (Array.isArray(body.critical) && body.critical.length) throw new PregenError("PG_CRITICAL", body.critical.join());
441
+ if (!ANSWER.required.every((k) => k in body) || !keysWithin(body, [...ANSWER.required, ...ANSWER.optional])) throw malformed("fields");
442
+ if (!str(body.request_id) || !str(body.profile) || !STATUSES.has(body.status) || !LEVELS.has(body.level)) throw malformed("values");
443
+ if (!Number.isSafeInteger(body.issued_at) || !Number.isSafeInteger(body.expires_at)) throw malformed("times must be integers");
444
+ if (!Array.isArray(body.obligations) || !body.obligations.every((o) => isObj(o) && Object.keys(o).sort().join() === "id,params" && str(o.id) && isObj(o.params))) throw malformed("obligations");
445
+ if ("ext" in body && !isObj(body.ext)) throw malformed("ext");
446
+ if ("critical" in body && !(Array.isArray(body.critical) && body.critical.every(str))) throw malformed("critical");
447
+ const shaped = body.status === "allow" ? checkBasis(body.basis) && !("reason" in body) : str(body.reason) && !("basis" in body);
448
+ if (!shaped) throw malformed("an allow has a valid basis and no reason; a refusal a reason and no basis");
449
+ if (body.critical?.length) throw new PregenError("PG_CRITICAL", body.critical.join());
407
450
  if (!(body.expires_at - body.issued_at > 0 && body.expires_at - body.issued_at <= MAX_LIFETIME)) throw new PregenError("PG_TIME", "lifetime");
408
451
  if (body.issued_at > at + CLOCK_TOLERANCE) throw new PregenError("PG_TIME", "issued in the future");
409
452
  if (body.request_id !== await messageId("pg.request.v1", request)) throw new PregenError("PG_BINDING", "answer is for another request");
410
453
  if (body.profile !== request.profile) throw new PregenError("PG_BINDING", "profile");
411
- if (!STATUSES.has(body.status)) throw new PregenError("PG_MALFORMED", "status");
412
- const shaped = body.status === "allow" ? "basis" in body && !("reason" in body) : "reason" in body && !("basis" in body);
413
- if (!shaped) throw new PregenError("PG_MALFORMED", "an allow has a basis and no reason; a refusal the reverse");
454
+ }
455
+ function readInput(input) {
456
+ let c;
457
+ try {
458
+ c = structuredClone(input);
459
+ } catch {
460
+ throw malformed("the input must be plain data");
461
+ }
462
+ const u = c?.use;
463
+ if (!isObj(c) || !JOB_ID.test(c.jobId ?? "")) throw malformed("jobId: 1-100 of A-Z a-z 0-9 _ . : -");
464
+ if (!Array.isArray(c.subjects) || !c.subjects.length || !c.subjects.every(str)) {
465
+ throw malformed("subjects: name at least one; with no registered person there is nothing to ask");
466
+ }
467
+ if (!str(c.model) || !str(c.modality)) throw malformed("model and modality");
468
+ if (c.prompt !== void 0 && typeof c.prompt !== "string") throw malformed("prompt");
469
+ if (c.promptHash !== void 0 && !HEX64.test(c.promptHash)) throw malformed("promptHash: 64 lowercase hex");
470
+ if (c.prompt === void 0 && c.promptHash === void 0) throw malformed("give prompt or promptHash");
471
+ if (c.licenseeId !== void 0 && typeof c.licenseeId !== "string") throw malformed("licenseeId");
472
+ if (!isObj(u) || !PURPOSES.has(u.purpose) || !keysWithin(u, ["purpose", "rights", "territory", "channel"]) || u.rights !== void 0 && !(Array.isArray(u.rights) && u.rights.every((r) => typeof r === "string")) || u.territory !== void 0 && typeof u.territory !== "string" || u.channel !== void 0 && typeof u.channel !== "string") {
473
+ throw malformed("use: purpose personal|educational|research|editorial|commercial; optional rights, territory, channel");
474
+ }
475
+ return c;
414
476
  }
415
477
  function createPregen(config) {
416
- const url = new URL(config.baseUrl);
478
+ const fail2 = (why) => {
479
+ throw new PregenError("PG_CONFIG", why);
480
+ };
481
+ if (!isObj(config)) fail2("a configuration object is required");
482
+ let url;
483
+ try {
484
+ url = new URL(config.baseUrl);
485
+ } catch {
486
+ return fail2("baseUrl must be a URL");
487
+ }
417
488
  const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
418
- if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) throw new PregenError("PG_CONFIG", "use an HTTPS registry URL");
419
- if (!config.providerId || !config.token) throw new PregenError("PG_CONFIG", "providerId and token are required");
420
- if (!config.store) throw new PregenError("PG_CONFIG", "a store is required (postgresStore(pool), or memoryStore() for one process)");
421
- const base = url.origin, mode = config.mode ?? "enforce", timeoutMs = config.timeoutMs ?? 5e3;
422
- const providerId = config.providerId, store = config.store;
489
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) fail2("use an HTTPS registry URL");
490
+ if (!str(config.providerId) || !str(config.token)) fail2("providerId and token are required strings");
491
+ const store = config.store;
492
+ if (!isObj(store) || !["claim", "update", "get", "pending"].every((m) => typeof store[m] === "function")) {
493
+ fail2("a store is required (postgresStore(pool), or memoryStore() for one process)");
494
+ }
495
+ const mode = config.mode === void 0 ? "enforce" : config.mode;
496
+ if (mode !== "enforce" && mode !== "observe") fail2(`mode must be "enforce" or "observe", not ${JSON.stringify(mode)}`);
497
+ const timeoutMs = config.timeoutMs ?? 5e3;
498
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 6e4) fail2("timeoutMs: 1 to 60000");
499
+ const base = url.origin, providerId = config.providerId, dir = config.pregenDirectory;
423
500
  const secret = unhex(config.providerSigningKeyHex);
424
- if (secret.length !== 32) throw new PregenError("PG_CONFIG", "providerSigningKeyHex must be a 32-byte Ed25519 private key");
425
- const pins = (Array.isArray(config.operatorPublicKeyHex) ? config.operatorPublicKeyHex : config.operatorPublicKeyHex.split(/[\s,]+/)).filter(Boolean);
426
- if (!pins.length) throw new PregenError("PG_CONFIG", "operatorPublicKeyHex is required");
501
+ if (secret.length !== 32) fail2("providerSigningKeyHex must be a 32-byte Ed25519 private key");
502
+ const rawPins = config.operatorPublicKeyHex;
503
+ const pins = (Array.isArray(rawPins) ? rawPins : typeof rawPins === "string" ? rawPins.split(/[\s,]+/) : []).filter(Boolean);
504
+ if (!pins.length && !dir || pins.some((p) => unhex(p).length !== 32)) {
505
+ fail2("operatorPublicKeyHex: one or more 32-byte public keys, hex (or give pregenDirectory)");
506
+ }
507
+ const sameBase = (api) => {
508
+ try {
509
+ const u = new URL(api);
510
+ return u.origin === base && /^\/*$/.test(u.pathname);
511
+ } catch {
512
+ return false;
513
+ }
514
+ };
515
+ const listed = dir?.registries.find((r) => sameBase(r.api));
516
+ if (dir && !listed) fail2(`${base} is not a registry listed in the PRE-GEN directory`);
427
517
  const ready = (async () => {
428
518
  const registry = /* @__PURE__ */ new Map();
429
519
  for (const p of pins) registry.set(await keyIdOf(unhex(p)), unhex(p));
430
520
  const pub = await ed3.getPublicKeyAsync(secret);
431
521
  return { registry, pub, keyId: await keyIdOf(pub) };
432
522
  })();
523
+ const fromDirectory = /* @__PURE__ */ new Map();
524
+ async function keyFor(keyId) {
525
+ const { registry } = await ready;
526
+ if (pins.length) return registry.get(keyId);
527
+ const code = listed.prefix ? `PG-${listed.prefix}-0` : "PG-0";
528
+ let p = fromDirectory.get(keyId);
529
+ if (!p) {
530
+ p = dir.publicKey(code, keyId).then(unhex, () => {
531
+ fromDirectory.delete(keyId);
532
+ return void 0;
533
+ });
534
+ fromDirectory.set(keyId, p);
535
+ }
536
+ return p;
537
+ }
538
+ const directoryVerdict = (subject, keyId) => !dir || !isPgCode(subject) ? "valid" : dir.checkSigner(subject, keyId);
433
539
  async function post(path, payload) {
434
540
  const resp = await fetch(base + path, {
435
541
  method: "POST",
@@ -445,14 +551,17 @@ function createPregen(config) {
445
551
  }
446
552
  return { status: resp.status, data };
447
553
  }
448
- const errorCode = (r) => r.data?.detail?.error ?? `HTTP_${r.status}`;
554
+ const errorCode = (r) => typeof r.data?.detail?.error === "string" && r.data.detail.error || `HTTP_${r.status}`;
449
555
  async function seal(type, body) {
450
556
  const { keyId } = await ready;
451
557
  body = { ...body, signers: [{ role: "provider", key_id: keyId }] };
452
558
  return { type, body, signatures: [hex(await ed3.signAsync(signingInput(type, body), secret))] };
453
559
  }
454
560
  async function ask(request) {
455
- const { registry } = await ready;
561
+ const subject = request.subject_ref;
562
+ if (dir && isPgCode(subject) && !(dir.registryFor(subject) && sameBase(dir.registryFor(subject).api))) {
563
+ return { request, answer: null, error: "PG_WRONG_REGISTRY" };
564
+ }
456
565
  let r = null;
457
566
  for (let attempt = 0; attempt < 3; attempt++) {
458
567
  if (attempt) await new Promise((ok) => setTimeout(ok, 200 * 2 ** attempt));
@@ -468,7 +577,9 @@ function createPregen(config) {
468
577
  if (r.status !== 200) return { request, answer: null, error: errorCode(r) };
469
578
  const answer = r.data?.answer;
470
579
  try {
471
- await verifyEnvelope(answer, "pg.answer.v1", "registry", registry);
580
+ await verifyEnvelope(answer, "pg.answer.v1", "registry", keyFor);
581
+ const verdict = directoryVerdict(subject, answer.body.signers[0].key_id);
582
+ if (verdict !== "valid") throw new PregenError(`PG_KEY_${verdict.toUpperCase()}`, "the directory does not list this key");
472
583
  } catch (e) {
473
584
  return { request, answer: null, error: e instanceof PregenError ? e.code : "PG_MALFORMED" };
474
585
  }
@@ -481,9 +592,11 @@ function createPregen(config) {
481
592
  }
482
593
  }
483
594
  const usableAllow = (a, at) => !a.error && a.answer?.body.status === "allow" && a.answer.body.expires_at > at;
595
+ const obligationsOfAsk = (a) => a.answer && a.error !== "PG_MALFORMED" ? a.answer.body.obligations.map((o) => ({ subject: a.request.subject_ref, id: o.id, params: o.params })) : [];
596
+ const obligationsOf = (job) => job.asks.flatMap(obligationsOfAsk);
597
+ const applies = (applied, o) => applied.some((x) => x.subject === o.subject && x.id === o.id);
484
598
  async function report(a, outcome, outputHash, applied) {
485
599
  const answer = a.answer ? await messageId("pg.answer.v1", a.answer.body) : null;
486
- const required = new Set((a.answer?.body.obligations ?? []).map((o) => o.id));
487
600
  const body = {
488
601
  request_id: await messageId("pg.request.v1", a.request),
489
602
  answer,
@@ -491,19 +604,38 @@ function createPregen(config) {
491
604
  job_id: a.request.job_id,
492
605
  outcome,
493
606
  output_hash: outcome === "generated" ? outputHash : "",
494
- obligations_applied: outcome === "generated" ? [...new Set(applied)].filter((id) => required.has(id)).sort() : [],
607
+ obligations_applied: outcome === "generated" ? [...new Set(obligationsOfAsk(a).filter((o) => applies(applied, o)).map((o) => o.id))].sort() : [],
495
608
  occurred_at: now()
496
609
  };
497
610
  if (answer === null) body.request = a.request;
498
- return { report: await seal("pg.report.v1", body) };
611
+ return { subject: a.request.subject_ref, answerStatus: a.answer?.body.status ?? "none", report: await seal("pg.report.v1", body) };
499
612
  }
613
+ async function checkAck(r, env) {
614
+ const { keyId } = await ready;
615
+ const ack = await verifyEnvelope(env, "pg.report_ack.v1", "registry", keyFor);
616
+ const verdict = directoryVerdict(r.subject, ack.signers[0].key_id);
617
+ if (verdict !== "valid") throw new PregenError(`PG_KEY_${verdict.toUpperCase()}`, "the directory does not list this key");
618
+ if (Object.keys(ack).sort().join() !== [...ACK_FIELDS].sort().join()) throw malformed("acknowledgement fields");
619
+ if (ack.report !== await messageId("pg.report.v1", r.report.body) || ack.answer !== r.report.body.answer || ack.answer_status !== r.answerStatus || ack.provider_key_id !== keyId) {
620
+ throw new PregenError("PG_BINDING", "the acknowledgement is not for this report");
621
+ }
622
+ if (!(ack.counted === null || HEX64.test(ack.counted)) || !Array.isArray(ack.released) || !ack.released.every((x) => typeof x === "string" && HEX64.test(x)) || !Number.isSafeInteger(ack.received_at)) {
623
+ throw malformed("acknowledgement values");
624
+ }
625
+ }
626
+ const reread = async (job) => await store.get(providerId, job.jobId) ?? job;
500
627
  async function send(job) {
501
- const { registry } = await ready;
628
+ const { keyId } = await ready;
502
629
  for (const r of job.reports) {
503
630
  if (r.ack || r.rejected) continue;
504
631
  let res;
505
632
  try {
506
633
  res = await post("/v2/reports", r.report);
634
+ if (res.status === 401 && r.report.body.signers?.[0]?.key_id !== keyId) {
635
+ const { signers: _, ...body } = r.report.body;
636
+ r.report = await seal("pg.report.v1", body);
637
+ res = await post("/v2/reports", r.report);
638
+ }
507
639
  } catch {
508
640
  r.error = "PG_UNREACHABLE";
509
641
  continue;
@@ -518,59 +650,85 @@ function createPregen(config) {
518
650
  continue;
519
651
  }
520
652
  try {
521
- const ack = await verifyEnvelope(res.data?.ack, "pg.report_ack.v1", "registry", registry);
522
- if (ack.report !== await messageId("pg.report.v1", r.report.body)) throw new PregenError("PG_BINDING", "ack for another report");
653
+ await checkAck(r, res.data?.ack);
523
654
  r.ack = res.data.ack;
524
655
  delete r.error;
525
656
  } catch (e) {
526
- r.error = e.code ?? "PG_MALFORMED";
657
+ r.error = e instanceof PregenError ? e.code : "PG_MALFORMED";
527
658
  }
528
659
  }
529
- if (job.reports.every((r) => r.ack || r.rejected)) job.stage = "done";
530
- await store.put(job);
531
- return job;
660
+ const next = { ...job, stage: job.reports.every((r) => r.ack || r.rejected) ? "done" : "finished" };
661
+ return await store.update(next, "finished") ? next : reread(job);
532
662
  }
533
- function result(job, output, detail) {
663
+ function result(job, output, override) {
534
664
  const unknown = job.stage === "claimed" || job.stage === "asked";
535
- const conflict = job.reports.some((r) => r.rejected);
665
+ const rejected = job.reports.some((r) => r.rejected);
536
666
  return {
537
667
  jobId: job.jobId,
538
- status: unknown || conflict ? "needs_check" : job.stage === "done" ? "confirmed" : "report_pending",
668
+ status: unknown || rejected || override ? "needs_check" : job.stage === "done" ? "confirmed" : "report_pending",
539
669
  generated: unknown ? null : !!job.generated,
540
670
  ...output !== void 0 ? { output } : {},
541
671
  ...job.outputHash ? { outputHash: job.outputHash } : {},
542
672
  answers: job.asks.map((a) => ({
543
673
  subject: a.request.subject_ref,
544
674
  status: a.answer?.body.status ?? null,
545
- ...a.answer?.body.reason ? { reason: a.answer.body.reason } : {},
675
+ ...typeof a.answer?.body.reason === "string" ? { reason: a.answer.body.reason } : {},
546
676
  ...a.error ? { error: a.error } : {}
547
677
  })),
548
678
  unmetObligations: job.unmetObligations ?? [],
549
- detail: detail ?? (unknown ? "running in another worker, or interrupted: check your generator, then resolve()" : conflict ? "the registry refused a report: see its error" : void 0)
679
+ detail: override ?? (unknown ? "the outcome is unknown (interrupted, the generator threw, or running in another worker): check your generator for this job, then resolve()" : rejected ? "the registry refused a report: see its error" : void 0)
550
680
  };
551
681
  }
552
682
  async function finish(job, generated, outputHash = "", applied = []) {
553
683
  const asks = generated ? job.asks : job.asks.filter((a) => a.answer?.body.status === "allow");
554
- job.reports = await Promise.all(asks.map((a) => report(a, generated ? "generated" : "not_generated", outputHash, applied)));
555
- job.unmetObligations = generated ? obligationsOf(job).filter((o) => !applied.includes(o.id)) : [];
556
- Object.assign(job, { stage: "finished", generated, ...generated ? { outputHash } : {} });
557
- await store.put(job);
558
- return send(job);
684
+ const next = {
685
+ ...job,
686
+ stage: "finished",
687
+ generated,
688
+ ...generated ? { outputHash } : {},
689
+ reports: await Promise.all(asks.map((a) => report(a, generated ? "generated" : "not_generated", outputHash, applied))),
690
+ unmetObligations: generated ? obligationsOf(job).filter((o) => !applies(applied, o)) : []
691
+ };
692
+ if (await store.update(next, "asked")) return send(next);
693
+ const stored = await reread(job);
694
+ return stored.stage === "finished" ? send(stored) : stored;
559
695
  }
560
- const obligationsOf = (job) => job.asks.flatMap((a) => a.answer && (mode === "observe" || a.answer.body.status === "allow") ? (a.answer.body.obligations ?? []).map((o) => ({ subject: a.request.subject_ref, id: o.id, params: o.params ?? {} })) : []);
561
696
  async function outputHashOf(r) {
697
+ if (!isObj(r)) throw malformed("the generator must return { output, outputHash | outputBytes }");
562
698
  if (r.outputHash !== void 0) {
563
- if (!HEX64.test(r.outputHash)) throw new PregenError("PG_MALFORMED", "outputHash must be 64 lowercase hex characters");
699
+ if (typeof r.outputHash !== "string" || !HEX64.test(r.outputHash)) throw malformed("outputHash must be 64 lowercase hex characters");
564
700
  return r.outputHash;
565
701
  }
566
- if (r.outputBytes === void 0) throw new PregenError("PG_MALFORMED", "return outputHash or outputBytes");
567
- return sha2562(typeof r.outputBytes === "string" ? enc.encode(r.outputBytes) : r.outputBytes);
702
+ if (typeof r.outputBytes === "string") return sha2562(enc.encode(r.outputBytes));
703
+ if (r.outputBytes instanceof Uint8Array) return sha2562(r.outputBytes);
704
+ throw malformed("return outputHash or outputBytes");
705
+ }
706
+ function appliedOf(list) {
707
+ if (list === void 0) return [];
708
+ if (!Array.isArray(list) || !list.every((x) => isObj(x) && str(x.subject) && str(x.id))) {
709
+ throw malformed("obligationsApplied: the { subject, id } obligations you carried out");
710
+ }
711
+ return list.map((x) => ({ subject: x.subject, id: x.id }));
712
+ }
713
+ async function currentKeyset() {
714
+ const resp = await fetch(`${base}/v2/keys/${encodeURIComponent(providerId)}`, { redirect: "error", signal: AbortSignal.timeout(timeoutMs) });
715
+ const chain = resp.ok ? (await resp.json())?.keysets : null;
716
+ const keyset = Array.isArray(chain) && chain.length ? chain[chain.length - 1] : null;
717
+ if (!keyset || !isObj(keyset.body) || !Array.isArray(keyset.body.keys)) return null;
718
+ return {
719
+ keyset,
720
+ id: await messageId("pg.keyset.v1", keyset.body),
721
+ current: keyset.body.keys.find((k) => k.status === "current")
722
+ };
723
+ }
724
+ async function flush() {
725
+ const out = [];
726
+ for (const job of await store.pending(providerId)) out.push(result(await send(job)));
727
+ return out;
568
728
  }
569
729
  async function run(input, fn) {
570
730
  const subjects = [...new Set(input.subjects)].sort();
571
- const promptHash = input.promptHash ?? (input.prompt !== void 0 ? await sha2562(enc.encode(input.prompt)) : "");
572
- if (!HEX64.test(promptHash)) throw new PregenError("PG_MALFORMED", "give prompt or a 64-hex promptHash");
573
- const use = JSON.parse(JSON.stringify(input.use));
731
+ const promptHash = input.promptHash ?? await sha2562(enc.encode(input.prompt));
574
732
  const manifest = {
575
733
  job_id: input.jobId,
576
734
  provider_id: providerId,
@@ -579,7 +737,7 @@ function createPregen(config) {
579
737
  model: input.model,
580
738
  modality: input.modality,
581
739
  mode,
582
- asks: subjects.map((s) => ({ subject_ref: s, registry: "", profile: PROFILE, use }))
740
+ asks: subjects.map((s) => ({ subject_ref: s, registry: "", profile: PROFILE, use: input.use }))
583
741
  };
584
742
  const jobHash = await sha2562(enc.encode(canonicalJson(manifest)));
585
743
  const fresh = { providerId, jobId: input.jobId, jobHash, stage: "claimed", asks: [], reports: [] };
@@ -588,8 +746,7 @@ function createPregen(config) {
588
746
  if (existing.jobHash !== jobHash) throw new PregenError("PG_JOB_CONFLICT", `job ${input.jobId} was used for another generation`);
589
747
  return result(existing.stage === "finished" ? await send(existing) : existing);
590
748
  }
591
- const job = fresh;
592
- job.asks = await Promise.all(subjects.map((s) => ask({
749
+ const asked = { ...fresh, stage: "asked", asks: await Promise.all(subjects.map((s) => ask({
593
750
  job_id: input.jobId,
594
751
  job_hash: jobHash,
595
752
  subject_ref: s,
@@ -599,60 +756,58 @@ function createPregen(config) {
599
756
  model: input.model,
600
757
  modality: input.modality,
601
758
  profile: PROFILE,
602
- use,
759
+ use: input.use,
603
760
  mode
604
- })));
605
- job.stage = "asked";
606
- await store.put(job);
761
+ }))) };
762
+ if (!await store.update(asked, "claimed")) return result(await reread(fresh));
607
763
  const at = now();
608
- if (mode === "enforce" && !job.asks.every((a) => usableAllow(a, at))) return result(await finish(job, false));
764
+ if (mode === "enforce" && !asked.asks.every((a) => usableAllow(a, at))) return result(await finish(asked, false));
609
765
  let out;
610
766
  try {
611
- out = await fn({ obligations: obligationsOf(job) });
767
+ out = await fn({ obligations: obligationsOf(asked) });
612
768
  } catch (e) {
613
- await finish(job, false);
769
+ if (e?.notGenerated === true) await finish(asked, false);
614
770
  throw e;
615
771
  }
616
- const outputHash = await outputHashOf(out);
617
- return result(await finish(job, true, outputHash, out.obligationsApplied ?? []), out.output);
772
+ const done = await finish(asked, true, await outputHashOf(out), appliedOf(out.obligationsApplied));
773
+ return result(done, out.output, done.generated ? void 0 : "another worker recorded that this job was not generated");
618
774
  }
619
775
  return {
620
776
  /** Ask, verify, generate (only when permitted), report. See the module comment. */
621
777
  async generate(input, fn) {
622
- if (!JOB_ID.test(input?.jobId ?? "")) throw new PregenError("PG_MALFORMED", "jobId: 1-100 of A-Z a-z 0-9 _ . : -");
623
- if (!Array.isArray(input.subjects) || !input.subjects.length) {
624
- throw new PregenError("PG_MALFORMED", "subjects: name at least one; with no registered person there is nothing to ask");
625
- }
626
- return run(input, fn);
778
+ const copy = readInput(input);
779
+ if (typeof fn !== "function") throw malformed("pass your generator function");
780
+ return run(copy, fn);
627
781
  },
628
782
  /** Resend every report still waiting (after a restart, an outage). Run at startup and every minute or so. */
629
- async flush() {
630
- const out = [];
631
- for (const job of await store.pending(providerId)) out.push(result(await send(job)));
632
- return out;
633
- },
783
+ flush,
634
784
  /** Where a job stands, or null if this store never saw it. */
635
785
  async status(jobId) {
636
786
  const job = await store.get(providerId, jobId);
637
787
  return job && result(job);
638
788
  },
639
- /** After `needs_check`: record what your generator actually did with this job. */
789
+ /** After `needs_check`: record what your generator actually did with this job. Safe to call from several workers:
790
+ * the first recorded outcome stands. */
640
791
  async resolve(jobId, outcome) {
792
+ if (!isObj(outcome) || typeof outcome.generated !== "boolean") throw malformed("outcome: { generated: true | false, ... }");
641
793
  const job = await store.get(providerId, jobId);
642
794
  if (!job) throw new PregenError("PG_UNKNOWN_JOB", jobId);
643
- if (job.stage === "finished" || job.stage === "done") return result(job.stage === "finished" ? await send(job) : job);
795
+ if (job.stage === "finished") return result(await send(job));
796
+ if (job.stage === "done") return result(job);
644
797
  if (!outcome.generated) {
645
798
  if (job.stage === "claimed") {
646
- Object.assign(job, { stage: "done", generated: false });
647
- await store.put(job);
648
- return result(job);
799
+ const closed = { ...job, stage: "done", generated: false };
800
+ if (await store.update(closed, "claimed")) return result(closed);
801
+ const stored = await reread(job);
802
+ return result(stored.stage === "finished" ? await send(stored) : stored);
649
803
  }
650
804
  return result(await finish(job, false));
651
805
  }
652
806
  if (job.stage === "claimed") throw new PregenError("PG_JOB", "generation cannot have started before the answers were stored");
653
- return result(await finish(job, true, await outputHashOf(outcome), outcome.obligationsApplied ?? []));
807
+ return result(await finish(job, true, await outputHashOf(outcome), appliedOf(outcome.obligationsApplied)));
654
808
  },
655
- /** Register this provider's signing key with the registry, once (a `first` keyset, §4.9). */
809
+ /** Register this provider's signing key with the registry, once (a `first` keyset, §4.9). Calling it again
810
+ * with the same key is harmless. */
656
811
  async registerKey() {
657
812
  const { pub, keyId } = await ready;
658
813
  const keyset = await seal("pg.keyset.v1", {
@@ -663,8 +818,44 @@ function createPregen(config) {
663
818
  keys: [{ key_id: keyId, public_key_hex: hex(pub), status: "current" }]
664
819
  });
665
820
  const r = await post("/v2/keys", keyset);
821
+ if (r.status === 409) {
822
+ const latest = await currentKeyset();
823
+ if (latest?.current?.key_id === keyId) return { keyId, keysetId: latest.id };
824
+ }
666
825
  if (r.status !== 200) throw new PregenError(errorCode(r), r.data?.detail?.error_description ?? "key not registered");
667
- return { keyId };
826
+ return { keyId, keysetId: r.data.keyset_id };
827
+ },
828
+ /** Replace this client's signing key with `newSigningKeyHex` (a `rotation` keyset signed by both keys).
829
+ * Then create the client again with the new key, in every worker. Reports already acknowledged keep their
830
+ * acknowledgements; reports still waiting, or sent by a worker that still has the old key, are signed again
831
+ * with the new key by the next `flush()` of a client that has it. */
832
+ async rotateKey(newSigningKeyHex) {
833
+ const next = unhex(newSigningKeyHex);
834
+ if (next.length !== 32) throw new PregenError("PG_CONFIG", "the new key must be a 32-byte Ed25519 private key");
835
+ const { pub, keyId } = await ready;
836
+ await flush();
837
+ const chain = await currentKeyset();
838
+ if (!chain || chain.current?.key_id !== keyId) {
839
+ throw new PregenError("PG_KEYSET", "the registry's current key is not this client's key: registerKey() first");
840
+ }
841
+ const latest = chain.keyset;
842
+ const nextPub = await ed3.getPublicKeyAsync(next), nextId = await keyIdOf(nextPub);
843
+ const body = {
844
+ kind: "rotation",
845
+ provider_id: providerId,
846
+ previous: await messageId("pg.keyset.v1", latest.body),
847
+ recovery_registry: latest.body.recovery_registry,
848
+ keys: [
849
+ { key_id: nextId, public_key_hex: hex(nextPub), status: "current" },
850
+ { key_id: keyId, public_key_hex: hex(pub), status: "retired" }
851
+ ],
852
+ signers: [{ role: "provider", key_id: nextId }, { role: "provider_previous", key_id: keyId }]
853
+ };
854
+ const data = signingInput("pg.keyset.v1", body);
855
+ const keyset = { type: "pg.keyset.v1", body, signatures: [hex(await ed3.signAsync(data, next)), hex(await ed3.signAsync(data, secret))] };
856
+ const r = await post("/v2/keys", keyset);
857
+ if (r.status !== 200) throw new PregenError(errorCode(r), r.data?.detail?.error_description ?? "key not rotated");
858
+ return { keyId: nextId, keysetId: r.data.keyset_id };
668
859
  }
669
860
  };
670
861
  }
@@ -678,8 +869,10 @@ function memoryStore() {
678
869
  jobs.set(k(job.providerId, job.jobId), structuredClone(job));
679
870
  return null;
680
871
  },
681
- async put(job) {
872
+ async update(job, from) {
873
+ if (jobs.get(k(job.providerId, job.jobId))?.stage !== from) return false;
682
874
  jobs.set(k(job.providerId, job.jobId), structuredClone(job));
875
+ return true;
683
876
  },
684
877
  async get(p, j) {
685
878
  const f = jobs.get(k(p, j));
@@ -690,12 +883,18 @@ function memoryStore() {
690
883
  }
691
884
  };
692
885
  }
886
+ var postgresSchema = `CREATE TABLE IF NOT EXISTS pregen_jobs (
887
+ provider_id text NOT NULL, job_id text NOT NULL, stage text NOT NULL, job jsonb NOT NULL,
888
+ updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (provider_id, job_id))`;
693
889
  function postgresStore(pool) {
694
890
  let ready = null;
695
891
  const q = async (text, values) => {
696
- ready ??= pool.query(`CREATE TABLE IF NOT EXISTS pregen_jobs (
697
- provider_id text NOT NULL, job_id text NOT NULL, stage text NOT NULL, job jsonb NOT NULL,
698
- updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (provider_id, job_id))`).catch((e) => {
892
+ ready ??= pool.query(`DO $$ BEGIN
893
+ IF to_regclass('pregen_jobs') IS NULL THEN
894
+ PERFORM pg_advisory_xact_lock(hashtext('pregen_jobs'));
895
+ ${postgresSchema};
896
+ END IF;
897
+ END $$`).catch((e) => {
699
898
  ready = null;
700
899
  throw e;
701
900
  });
@@ -709,11 +908,12 @@ function postgresStore(pool) {
709
908
  ON CONFLICT DO NOTHING RETURNING job_id`, [job.providerId, job.jobId, job.stage, JSON.stringify(job)]);
710
909
  return won.length ? null : one(job.providerId, job.jobId);
711
910
  },
712
- async put(job) {
713
- await q(
714
- "UPDATE pregen_jobs SET stage = $3, job = $4, updated_at = now() WHERE provider_id = $1 AND job_id = $2",
715
- [job.providerId, job.jobId, job.stage, JSON.stringify(job)]
716
- );
911
+ async update(job, from) {
912
+ return (await q(
913
+ `UPDATE pregen_jobs SET stage = $3, job = $4, updated_at = now()
914
+ WHERE provider_id = $1 AND job_id = $2 AND stage = $5 RETURNING job_id`,
915
+ [job.providerId, job.jobId, job.stage, JSON.stringify(job), from]
916
+ )).length > 0;
717
917
  },
718
918
  get: one,
719
919
  async pending(p) {
@@ -1815,6 +2015,7 @@ function sameEndpoint(a, b) {
1815
2015
  }
1816
2016
  export {
1817
2017
  HARD_REFUSAL_CODES,
2018
+ NotGeneratedError,
1818
2019
  Prampta,
1819
2020
  PramptaApiError,
1820
2021
  PramptaError,
@@ -1839,6 +2040,7 @@ export {
1839
2040
  memoryStore,
1840
2041
  messageId,
1841
2042
  normalizeForMatch,
2043
+ postgresSchema,
1842
2044
  postgresStore,
1843
2045
  signingInput,
1844
2046
  singleProcessJournal,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prampta/sdk",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "PRAMPTA SDK — authorization before generation",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -24,7 +24,7 @@
24
24
  "typecheck": "tsc --noEmit",
25
25
  "test": "vitest run",
26
26
  "typecheck:examples": "tsc -p tsconfig.examples.json",
27
- "prepublishOnly": "npm run typecheck && npm run typecheck:examples && npm test",
27
+ "prepublishOnly": "npm run typecheck && npm run typecheck:examples && npm test && bash ../../scripts/sdk-v2-e2e/run.sh",
28
28
  "prepack": "npm run build"
29
29
  },
30
30
  "keywords": [
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@noble/ed25519": "^3.1.0",
52
- "@pregen/verify": "^0.6.0"
52
+ "@pregen/verify": "^0.7.0"
53
53
  },
54
54
  "repository": {
55
55
  "type": "git",