@mediaviz/sdk 1.8.0-dev.91 → 1.8.0-dev.95

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/sdk.esm.js CHANGED
@@ -459,6 +459,448 @@ function requireTypes () {
459
459
  return types;
460
460
  }
461
461
 
462
+ var src$1;
463
+ var hasRequiredSrc$1;
464
+
465
+ function requireSrc$1 () {
466
+ if (hasRequiredSrc$1) return src$1;
467
+ hasRequiredSrc$1 = 1;
468
+
469
+ const { OAuthClient } = requireClient();
470
+ const { OAuthError } = requireErrors();
471
+ const { OAuthErrorCode } = requireTypes();
472
+
473
+ src$1 = { OAuthClient, OAuthError, OAuthErrorCode };
474
+ return src$1;
475
+ }
476
+
477
+ var srcExports$1 = requireSrc$1();
478
+ var _oauth = /*@__PURE__*/getDefaultExportFromCjs(srcExports$1);
479
+
480
+ // Auto-generated — do not edit
481
+ const { OAuthClient, OAuthError, OAuthErrorCode } = _oauth;
482
+
483
+ var signing;
484
+ var hasRequiredSigning;
485
+
486
+ function requireSigning () {
487
+ if (hasRequiredSigning) return signing;
488
+ hasRequiredSigning = 1;
489
+
490
+ // HMAC scheme shared with the MediaViz producer: X-Signature = 'sha256=' +
491
+ // hex(HMAC-SHA256(secret, `${X-Timestamp}.` + rawBody)). The signing string is
492
+ // the timestamp joined to the *raw request body bytes* — never re-serialized
493
+ // JSON. Verification accepts the current and (during rotation) previous
494
+ // secret, rejects timestamps outside the skew window, and compares digests in
495
+ // constant time. Web Crypto only, so the same code runs in Node >= 18 and
496
+ // browsers.
497
+
498
+ const _encoder = new TextEncoder();
499
+
500
+ async function signWebhookPayload(secret, timestamp, rawBody) {
501
+ const key = await globalThis.crypto.subtle.importKey(
502
+ 'raw', _encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
503
+ const payload = _concat(_encoder.encode(`${timestamp}.`), toBytes(rawBody));
504
+ const digest = await globalThis.crypto.subtle.sign('HMAC', key, payload);
505
+ return 'sha256=' + _hex(new Uint8Array(digest));
506
+ }
507
+
508
+ async function verifyWebhookSignature(secretCurrent, secretPrevious, headers, rawBody, options = {}) {
509
+ const h = lowerHeaders(headers);
510
+ const timestamp = h['x-timestamp'];
511
+ const signature = h['x-signature'];
512
+ if (!timestamp || !signature) return false;
513
+ if (!/^-?\d+$/.test(String(timestamp))) return false;
514
+ const skewToleranceS = options.skewToleranceS ?? 300;
515
+ const now = options.now ?? Math.floor(Date.now() / 1000);
516
+ if (Math.abs(now - Number(timestamp)) > skewToleranceS) return false;
517
+ for (const secret of [secretCurrent, secretPrevious]) {
518
+ if (!secret) continue;
519
+ const expected = await signWebhookPayload(secret, String(timestamp), rawBody);
520
+ if (_constantTimeEqual(expected, String(signature))) return true;
521
+ }
522
+ return false;
523
+ }
524
+
525
+ // helpers
526
+ function toBytes(rawBody) {
527
+ if (typeof rawBody === 'string') return _encoder.encode(rawBody);
528
+ if (rawBody instanceof Uint8Array) return rawBody;
529
+ if (rawBody instanceof ArrayBuffer) return new Uint8Array(rawBody);
530
+ throw new TypeError('rawBody must be a string, Uint8Array, or ArrayBuffer');
531
+ }
532
+
533
+ function lowerHeaders(headers) {
534
+ const out = {};
535
+ const entries = typeof headers?.entries === 'function' ? headers.entries() : Object.entries(headers ?? {});
536
+ for (const [k, v] of entries) out[String(k).toLowerCase()] = v;
537
+ return out;
538
+ }
539
+
540
+ function _concat(a, b) {
541
+ const out = new Uint8Array(a.length + b.length);
542
+ out.set(a, 0);
543
+ out.set(b, a.length);
544
+ return out;
545
+ }
546
+
547
+ function _hex(bytes) {
548
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
549
+ }
550
+
551
+ function _constantTimeEqual(a, b) {
552
+ if (a.length !== b.length) return false;
553
+ let diff = 0;
554
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
555
+ return diff === 0;
556
+ }
557
+
558
+ signing = { signWebhookPayload, verifyWebhookSignature, toBytes, lowerHeaders };
559
+ return signing;
560
+ }
561
+
562
+ var store;
563
+ var hasRequiredStore;
564
+
565
+ function requireStore () {
566
+ if (hasRequiredStore) return store;
567
+ hasRequiredStore = 1;
568
+
569
+ // Persistence seam for the webhook consumer: dedupe ledger, signing secrets,
570
+ // and pull cursors. The default in-memory store suits a single long-lived
571
+ // process (worker, dev server); multi-process consumers supply an object with
572
+ // the same shape backed by Redis or a database.
573
+
574
+ const MAX_SEEN_ENTRIES = 100000;
575
+
576
+ class InMemoryWebhookStore {
577
+ constructor() {
578
+ this._seen = new Map(); // eventId -> expiresAt (ms)
579
+ this._secrets = new Map(); // subscriptionId -> {current, previous, previousExpiresAt}
580
+ this._cursors = new Map(); // subscriptionId -> cursor
581
+ }
582
+
583
+ markSeen(eventId, ttlS) {
584
+ const now = Date.now();
585
+ const expiresAt = this._seen.get(eventId);
586
+ if (expiresAt !== undefined && expiresAt > now) return false;
587
+ this._seen.delete(eventId);
588
+ this._seen.set(eventId, now + ttlS * 1000);
589
+ this._prune(now);
590
+ return true;
591
+ }
592
+
593
+ unmarkSeen(eventId) {
594
+ this._seen.delete(eventId);
595
+ }
596
+
597
+ saveSecret(subscriptionId, secret) {
598
+ this._secrets.set(subscriptionId, { current: secret, previous: null, previousExpiresAt: 0 });
599
+ }
600
+
601
+ rotateSecret(subscriptionId, newSecret, windowS) {
602
+ const entry = this._secrets.get(subscriptionId);
603
+ this._secrets.set(subscriptionId, {
604
+ current: newSecret,
605
+ previous: entry?.current ?? null,
606
+ previousExpiresAt: Date.now() + windowS * 1000,
607
+ });
608
+ }
609
+
610
+ getSecrets(subscriptionId) {
611
+ const entry = this._secrets.get(subscriptionId);
612
+ if (!entry) return [null, null];
613
+ const previous = entry.previous && entry.previousExpiresAt > Date.now() ? entry.previous : null;
614
+ return [entry.current, previous];
615
+ }
616
+
617
+ getCursor(subscriptionId) {
618
+ return this._cursors.get(subscriptionId) ?? null;
619
+ }
620
+
621
+ setCursor(subscriptionId, cursor) {
622
+ this._cursors.set(subscriptionId, cursor);
623
+ }
624
+
625
+ _prune(now) {
626
+ if (this._seen.size <= MAX_SEEN_ENTRIES) return;
627
+ for (const [id, expiresAt] of this._seen) {
628
+ if (expiresAt <= now) this._seen.delete(id);
629
+ if (this._seen.size <= MAX_SEEN_ENTRIES) return;
630
+ }
631
+ // still over the cap: evict oldest insertions
632
+ for (const id of this._seen.keys()) {
633
+ this._seen.delete(id);
634
+ if (this._seen.size <= MAX_SEEN_ENTRIES) return;
635
+ }
636
+ }
637
+ }
638
+
639
+ store = { InMemoryWebhookStore };
640
+ return store;
641
+ }
642
+
643
+ var consumer;
644
+ var hasRequiredConsumer;
645
+
646
+ function requireConsumer () {
647
+ if (hasRequiredConsumer) return consumer;
648
+ hasRequiredConsumer = 1;
649
+
650
+ const { verifyWebhookSignature, toBytes, lowerHeaders } = requireSigning();
651
+ const { InMemoryWebhookStore } = requireStore();
652
+
653
+ // Photo columns each producer model writes — used to select a model's results
654
+ // out of a fetched PhotoDisplay. Mirrors the producer vocabulary; personhood is
655
+ // predicate-based and writes no columns.
656
+ const WEBHOOK_MODEL_COLUMNS = {
657
+ blur: ['blur_value'],
658
+ colors: ['main_color_palette', 'color_averages', 'hue_values', 'light_values', 'saturation_values'],
659
+ face_recognition: ['number_of_faces', 'bounding_boxes_from_faces_model'],
660
+ image_classification: ['labels_from_classifications_model', 'feature_extraction_output'],
661
+ image_comparison: ['average_hash'],
662
+ image_describe: ['topic'],
663
+ ocr: ['ocr_output'],
664
+ similarity: ['similar_photo_ids_high', 'similar_photo_ids_medium', 'similar_photo_ids_low'],
665
+ evidence: ['evidence_score', 'aesthetic_score', 'face_score', 'content_score', 'similarity_set_ranking'],
666
+ personhood: [],
667
+ };
668
+
669
+ const WEBHOOK_OUTCOME_MODELS = {
670
+ curation: ['blur', 'colors', 'face_recognition', 'image_classification', 'image_comparison', 'similarity', 'evidence'],
671
+ similarity: ['image_classification', 'image_comparison', 'similarity'],
672
+ persons: ['face_recognition', 'personhood'],
673
+ };
674
+
675
+ // Client-side counterpart of the MediaViz webhook producer, giving photo-by-photo
676
+ // progress as a project upload is analyzed.
677
+ //
678
+ // Receiver half (no auth needed): classify inbound POSTs, echo the subscription
679
+ // verification challenge, verify signed `target.completed` deliveries (HMAC +
680
+ // timestamp skew), dedupe on event_id, and dispatch each new event to a single
681
+ // onEvent callback. `handleRequest` is framework-agnostic glue — adapt it to
682
+ // express/lambda/etc. in a few lines by feeding it the raw body and headers.
683
+ //
684
+ // API half (requires an authenticated client): subscription lifecycle
685
+ // orchestration — register/confirm/rotate store the signing secret the moment
686
+ // the producer issues it — plus history pulls that share the push dedupe
687
+ // ledger, and out-of-band result fetches (webhooks carry a pointer, never the
688
+ // result itself).
689
+ class WebhookConsumer {
690
+ constructor(ctx, subscriptionApi, options = {}) {
691
+ this._ctx = ctx;
692
+ this._api = subscriptionApi;
693
+ this.store = options.store ?? new InMemoryWebhookStore();
694
+ this.skewToleranceS = options.skewToleranceS ?? 300;
695
+ this.rotationWindowS = options.rotationWindowS ?? 86400;
696
+ this.dedupeTtlS = options.dedupeTtlS ?? 604800;
697
+ this.pageLimit = options.pageLimit ?? 100;
698
+ this._onEvent = options.onEvent ?? null;
699
+ }
700
+
701
+ // ── receiver half ─────────────────────────────────────────────────────────
702
+
703
+ onEvent(fn) {
704
+ this._onEvent = fn;
705
+ return fn;
706
+ }
707
+
708
+ useStore(store) {
709
+ this.store = store;
710
+ }
711
+
712
+ classifyMessage(body) {
713
+ const type = body?.event_type;
714
+ if (type === 'subscription.verification') return 'verification';
715
+ if (type === 'target.completed') return 'delivery';
716
+ return 'unknown';
717
+ }
718
+
719
+ handleChallenge(body) {
720
+ return { challenge: body.challenge };
721
+ }
722
+
723
+ async handleDelivery(headers, rawBody) {
724
+ let event;
725
+ try {
726
+ event = this._parseDelivery(rawBody);
727
+ } catch {
728
+ return 'bad_signature';
729
+ }
730
+ const [current, previous] = this.store.getSecrets(String(event.subscription_id));
731
+ const ok = await verifyWebhookSignature(current, previous, headers, rawBody, { skewToleranceS: this.skewToleranceS });
732
+ if (!ok) return 'bad_signature';
733
+ return this._dispatch(event);
734
+ }
735
+
736
+ async handleRequest(headers, rawBody) {
737
+ let body;
738
+ try {
739
+ body = JSON.parse(_text(rawBody));
740
+ } catch {
741
+ return { status: 400, body: { detail: 'invalid JSON' } };
742
+ }
743
+ switch (this.classifyMessage(body)) {
744
+ case 'verification':
745
+ return { status: 200, body: this.handleChallenge(body) };
746
+ case 'delivery': {
747
+ const ack = await this.handleDelivery(lowerHeaders(headers), rawBody);
748
+ if (ack === 'bad_signature') return { status: 401, body: { detail: 'bad signature' } };
749
+ return { status: 200, body: { status: ack } };
750
+ }
751
+ default:
752
+ return { status: 400, body: { detail: 'unrecognized event_type' } };
753
+ }
754
+ }
755
+
756
+ // ── API half ──────────────────────────────────────────────────────────────
757
+
758
+ async register(projectTableName, callbackUrl, targets) {
759
+ const res = await this._api.createSubscription(projectTableName, callbackUrl, targets);
760
+ this._saveSecretFrom(res);
761
+ return res;
762
+ }
763
+
764
+ async confirm(subscriptionId) {
765
+ const res = await this._api.verifySubscription(subscriptionId);
766
+ this._saveSecretFrom(res);
767
+ return res;
768
+ }
769
+
770
+ async rotateSecret(subscriptionId) {
771
+ const res = await this._api.rotateSubscriptionSecret(subscriptionId);
772
+ this.store.rotateSecret(String(subscriptionId), res.signing_secret, this.rotationWindowS);
773
+ return res.signing_secret;
774
+ }
775
+
776
+ async updateCallback(subscriptionId, callbackUrl) {
777
+ // A callback change resets the subscription to pending_verification and
778
+ // pauses push until the new URL passes the challenge — confirm right away.
779
+ await this._api.updateSubscription(subscriptionId, { callbackUrl });
780
+ return this.confirm(subscriptionId);
781
+ }
782
+
783
+ async disable(subscriptionId) {
784
+ return this._api.deleteSubscription(subscriptionId);
785
+ }
786
+
787
+ async listSubscriptions() {
788
+ return this._api.listSubscriptions();
789
+ }
790
+
791
+ async pullEvents(subscriptionId, { since, limit } = {}) {
792
+ return this._api.listSubscriptionEvents(subscriptionId, { since, limit: limit ?? this.pageLimit });
793
+ }
794
+
795
+ async reconcile(subscriptionId, projectTableName) {
796
+ const id = String(subscriptionId);
797
+ let cursor = this.store.getCursor(id);
798
+ let pulled = 0;
799
+ let dispatched = 0;
800
+ for (;;) {
801
+ const page = await this._api.listSubscriptionEvents(id, { since: cursor ?? undefined, limit: this.pageLimit });
802
+ const events = page?.events ?? [];
803
+ pulled += events.length;
804
+ for (const ev of events) {
805
+ const ack = await this._dispatch({
806
+ event_id: ev.event_id,
807
+ event_type: 'target.completed',
808
+ schema_version: '1',
809
+ subscription_id: id,
810
+ project_table_name: projectTableName,
811
+ photo_id: ev.photo_id,
812
+ target: ev.target,
813
+ completed_at: ev.completed_at,
814
+ result_location: this._ctx.baseUrl + _photoPath(projectTableName, ev.photo_id),
815
+ });
816
+ if (ack === 'handled') dispatched += 1;
817
+ }
818
+ if (page?.next_cursor && page.next_cursor !== cursor) {
819
+ cursor = page.next_cursor;
820
+ this.store.setCursor(id, cursor);
821
+ }
822
+ if (!events.length || events.length < this.pageLimit) break;
823
+ }
824
+ return { pulled, dispatched };
825
+ }
826
+
827
+ async fetchResult(event) {
828
+ this._ctx.requireTokens();
829
+ const path = _photoPath(event.project_table_name, event.photo_id);
830
+ const { data } = await this._ctx.client.request(path, 'GET', this._ctx.accessToken, this._ctx.refreshToken);
831
+ return { photo: data, selected: selectResultColumns(data, event.target) };
832
+ }
833
+
834
+ saveSecret(subscriptionId, secret) {
835
+ this.store.saveSecret(String(subscriptionId), secret);
836
+ }
837
+
838
+ // ── internal ──────────────────────────────────────────────────────────────
839
+
840
+ _saveSecretFrom(res) {
841
+ if (res?.signing_secret && res?.subscription_id != null) {
842
+ this.store.saveSecret(String(res.subscription_id), res.signing_secret);
843
+ }
844
+ }
845
+
846
+ _parseDelivery(rawBody) {
847
+ const event = JSON.parse(_text(rawBody));
848
+ for (const key of ['event_id', 'subscription_id', 'target']) {
849
+ if (typeof event?.[key] !== 'string' || !event[key]) throw new Error(`missing ${key}`);
850
+ }
851
+ if (!Number.isInteger(event.photo_id)) throw new Error('photo_id must be an integer');
852
+ return event;
853
+ }
854
+
855
+ async _dispatch(event) {
856
+ if (!this.store.markSeen(String(event.event_id), this.dedupeTtlS)) return 'duplicate';
857
+ if (this._onEvent) {
858
+ try {
859
+ await this._onEvent(event);
860
+ } catch (e) {
861
+ // release the dedupe claim so the producer's retry redelivers
862
+ this.store.unmarkSeen(String(event.event_id));
863
+ throw e;
864
+ }
865
+ }
866
+ return 'handled';
867
+ }
868
+ }
869
+
870
+ // helpers
871
+ function selectResultColumns(photo, target) {
872
+ if (target === 'all') return null;
873
+ const sep = String(target).indexOf(':');
874
+ const kind = sep === -1 ? String(target) : String(target).slice(0, sep);
875
+ const name = sep === -1 ? '' : String(target).slice(sep + 1);
876
+ let columns;
877
+ if (kind === 'model') {
878
+ columns = WEBHOOK_MODEL_COLUMNS[name];
879
+ if (!columns) throw new Error(`unknown model '${name}'`);
880
+ } else if (kind === 'outcome') {
881
+ const models = WEBHOOK_OUTCOME_MODELS[name];
882
+ if (!models) throw new Error(`unknown outcome '${name}'`);
883
+ columns = [...new Set(models.flatMap((m) => WEBHOOK_MODEL_COLUMNS[m]))];
884
+ } else {
885
+ throw new Error(`unknown target grammar '${target}' (expected 'model:<name>', 'outcome:<name>', or 'all')`);
886
+ }
887
+ const selected = {};
888
+ for (const c of columns) selected[c] = photo?.[c] ?? null;
889
+ return selected;
890
+ }
891
+
892
+ function _photoPath(projectTableName, photoId) {
893
+ return `/api/v1/photos/${encodeURIComponent(projectTableName)}/${encodeURIComponent(String(photoId))}`;
894
+ }
895
+
896
+ function _text(rawBody) {
897
+ return typeof rawBody === 'string' ? rawBody : new TextDecoder().decode(toBytes(rawBody));
898
+ }
899
+
900
+ consumer = { WebhookConsumer, selectResultColumns, WEBHOOK_MODEL_COLUMNS, WEBHOOK_OUTCOME_MODELS };
901
+ return consumer;
902
+ }
903
+
462
904
  var src;
463
905
  var hasRequiredSrc;
464
906
 
@@ -466,19 +908,19 @@ function requireSrc () {
466
908
  if (hasRequiredSrc) return src;
467
909
  hasRequiredSrc = 1;
468
910
 
469
- const { OAuthClient } = requireClient();
470
- const { OAuthError } = requireErrors();
471
- const { OAuthErrorCode } = requireTypes();
911
+ const { WebhookConsumer, selectResultColumns, WEBHOOK_MODEL_COLUMNS, WEBHOOK_OUTCOME_MODELS } = requireConsumer();
912
+ const { InMemoryWebhookStore } = requireStore();
913
+ const { signWebhookPayload, verifyWebhookSignature } = requireSigning();
472
914
 
473
- src = { OAuthClient, OAuthError, OAuthErrorCode };
915
+ src = { WebhookConsumer, InMemoryWebhookStore, signWebhookPayload, verifyWebhookSignature, selectResultColumns, WEBHOOK_MODEL_COLUMNS, WEBHOOK_OUTCOME_MODELS };
474
916
  return src;
475
917
  }
476
918
 
477
919
  var srcExports = requireSrc();
478
- var _oauth = /*@__PURE__*/getDefaultExportFromCjs(srcExports);
920
+ var _webhooks = /*@__PURE__*/getDefaultExportFromCjs(srcExports);
479
921
 
480
922
  // Auto-generated — do not edit
481
- const { OAuthClient, OAuthError, OAuthErrorCode } = _oauth;
923
+ const { WebhookConsumer, InMemoryWebhookStore, signWebhookPayload, verifyWebhookSignature, selectResultColumns, WEBHOOK_MODEL_COLUMNS, WEBHOOK_OUTCOME_MODELS } = _webhooks;
482
924
 
483
925
  class AiModelCredits {
484
926
  constructor(ctx) { this._ctx = ctx; }
@@ -1194,6 +1636,13 @@ class Person {
1194
1636
  const { data } = await this._ctx.client.request(path, 'GET', this._ctx.accessToken, this._ctx.refreshToken);
1195
1637
  return data;
1196
1638
  }
1639
+
1640
+ async removePerson(projectTableName, personId) {
1641
+ this._ctx.requireTokens();
1642
+ const path = `/api/v1/person/${encodeURIComponent(projectTableName)}/${encodeURIComponent(personId)}`;
1643
+ const { data } = await this._ctx.client.request(path, 'DELETE', this._ctx.accessToken, this._ctx.refreshToken);
1644
+ return data;
1645
+ }
1197
1646
  }
1198
1647
 
1199
1648
  function stripUndef$3(o) { const r = {}; for (const k in o) if (o[k] !== undefined) r[k] = o[k]; return r; }
@@ -1201,7 +1650,7 @@ function stripUndef$3(o) { const r = {}; for (const k in o) if (o[k] !== undefin
1201
1650
  class Projects {
1202
1651
  constructor(ctx) { this._ctx = ctx; }
1203
1652
 
1204
- async createProjectAndRun(name, private_ = undefined, type = undefined, description = undefined, directory = undefined, photoUploadVector = undefined, thumbnail = undefined, runName = undefined, { outcomes, models } = {}) {
1653
+ async createProjectAndRun(name, type, private_ = undefined, description = undefined, directory = undefined, photoUploadVector = undefined, thumbnail = undefined, runName = undefined, { outcomes, models } = {}) {
1205
1654
  this._ctx.requireTokens();
1206
1655
  let path = `/api/v1/project_outcome/`;
1207
1656
  const query = new URLSearchParams();
@@ -1894,6 +2343,7 @@ class MediaViz {
1894
2343
  this.search = new Search(_ctx);
1895
2344
  this.subscription = new Subscription(_ctx);
1896
2345
  this.users = new Users(_ctx);
2346
+ this.webhooks = new WebhookConsumer(_ctx, this.subscription);
1897
2347
  }
1898
2348
 
1899
2349
  async authenticate() {
@@ -1923,4 +2373,4 @@ class MediaViz {
1923
2373
  get refreshToken() { return this._refreshToken; }
1924
2374
  }
1925
2375
 
1926
- export { Admin, AiModelCredits, ApiError, Company, CuratedAlbums, CustomAlbums, EmailTokens, Health, Keywords, MediaViz, NotFoundError, OAuthClient, OAuthError, OAuthErrorCode, OauthAuthorization, OauthLogin, OauthToken, Person, Photos, Photoupload, Projects, RateLimitError, Search, ServerError, Subscription, Users, ValidationError, handleResponse };
2376
+ export { Admin, AiModelCredits, ApiError, Company, CuratedAlbums, CustomAlbums, EmailTokens, Health, InMemoryWebhookStore, Keywords, MediaViz, NotFoundError, OAuthClient, OAuthError, OAuthErrorCode, OauthAuthorization, OauthLogin, OauthToken, Person, Photos, Photoupload, Projects, RateLimitError, Search, ServerError, Subscription, Users, ValidationError, WEBHOOK_MODEL_COLUMNS, WEBHOOK_OUTCOME_MODELS, WebhookConsumer, handleResponse, selectResultColumns, signWebhookPayload, verifyWebhookSignature };