@kanca-app/ikas 0.2.0 → 0.3.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.js CHANGED
@@ -145,7 +145,7 @@ function classifyGraphql(status, json) {
145
145
 
146
146
  // src/shared.ts
147
147
  var SDK_NAME = "@kanca-app/ikas";
148
- var SDK_VERSION = "0.2.0";
148
+ var SDK_VERSION = "0.3.0";
149
149
  var DEFAULT_ENDPOINT = "https://kanca.onurhan.dev";
150
150
  var MAX_NAME = 200;
151
151
  var MAX_ERROR_MESSAGE = 500;
@@ -378,6 +378,126 @@ var Transport = class {
378
378
  }
379
379
  };
380
380
 
381
+ // src/install.ts
382
+ var INSTALL_RECHECK_DELAY_MS = 3e3;
383
+ var MASS_REVOCATION_MIN = 5;
384
+ var PROBE = "query getAuthorizedApp { getAuthorizedApp { id } }";
385
+ async function probe(client) {
386
+ const c = client;
387
+ if (!c || typeof c !== "object" || !c._client || typeof c._client.request !== "function") {
388
+ return { state: "unknown", reason: "UNSUPPORTED_CLIENT" };
389
+ }
390
+ if (typeof c._checkToken === "function") {
391
+ try {
392
+ await c._checkToken();
393
+ } catch {
394
+ return { state: "unknown", reason: "TOKEN_CHECK_FAILED" };
395
+ }
396
+ }
397
+ let data;
398
+ try {
399
+ data = await c._client.request({ document: PROBE });
400
+ } catch (err) {
401
+ const resp = err?.response;
402
+ const status = resp?.status;
403
+ if (typeof status !== "number") return { state: "unknown", reason: "NETWORK" };
404
+ const first = Array.isArray(resp.errors) ? resp.errors[0] : void 0;
405
+ const code = first?.extensions?.code;
406
+ return { state: "unknown", reason: typeof code === "string" && code ? code.slice(0, 100) : `HTTP_${status}` };
407
+ }
408
+ if (data && typeof data === "object" && "getAuthorizedApp" in data) {
409
+ const app = data.getAuthorizedApp;
410
+ if (app === null) return { state: "removed" };
411
+ const id = app && typeof app === "object" ? app.id : void 0;
412
+ if (typeof id === "string" && id) return { state: "installed" };
413
+ }
414
+ return { state: "unknown", reason: "UNEXPECTED_RESPONSE" };
415
+ }
416
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
417
+ async function checkInstallState(merchantId, client, recheckDelayMs) {
418
+ if (typeof merchantId !== "string" || !merchantId) return { merchantId: "", state: "unknown", reason: "MISSING_MERCHANT_ID" };
419
+ try {
420
+ const first = await probe(client);
421
+ if (first.state === "installed") return { merchantId, state: "installed" };
422
+ if (first.state === "unknown") return { merchantId, state: "unknown", reason: first.reason };
423
+ await sleep2(recheckDelayMs);
424
+ const second = await probe(client);
425
+ if (second.state === "removed") return { merchantId, state: "uninstalled" };
426
+ if (second.state === "installed") return { merchantId, state: "installed" };
427
+ return { merchantId, state: "unknown", reason: second.reason };
428
+ } catch {
429
+ return { merchantId, state: "unknown", reason: "UNEXPECTED_RESPONSE" };
430
+ }
431
+ }
432
+ function isMassRevocation(uninstalled, checked) {
433
+ return uninstalled >= MASS_REVOCATION_MIN && uninstalled * 2 > checked;
434
+ }
435
+ async function checkInstallStates(stores, concurrency, recheckDelayMs) {
436
+ const list = Array.isArray(stores) ? stores : [];
437
+ const results = new Array(list.length);
438
+ let next = 0;
439
+ const worker = async () => {
440
+ while (next < list.length) {
441
+ const i = next++;
442
+ const store = list[i];
443
+ results[i] = await checkInstallState(store?.merchantId, store?.client, recheckDelayMs);
444
+ }
445
+ };
446
+ await Promise.all(Array.from({ length: Math.min(concurrency, list.length) }, worker));
447
+ const uninstalled = results.filter((r) => r.state === "uninstalled").length;
448
+ if (!isMassRevocation(uninstalled, results.length)) return { results, massRevocation: false };
449
+ return {
450
+ massRevocation: true,
451
+ results: results.map((r) => r.state === "uninstalled" ? { merchantId: r.merchantId, state: "unknown", reason: "MASS_REVOCATION" } : r)
452
+ };
453
+ }
454
+
455
+ // src/oauth.ts
456
+ var OAUTH_REFRESH = "oauth:refresh";
457
+ var OAUTH_TOKEN = "oauth:token";
458
+ var STORE_SUFFIXES = [".myikas.com", ".myikas.dev"];
459
+ var OAUTH_ERROR = /^[a-z0-9_]{1,64}$/;
460
+ function ikasTokenUrl(url) {
461
+ const host = url.hostname.toLowerCase();
462
+ const suffix = STORE_SUFFIXES.find((s) => host.endsWith(s));
463
+ if (!suffix) return void 0;
464
+ if (url.pathname.replace(/\/+$/, "") !== "/api/admin/oauth/token") return void 0;
465
+ const store = host.slice(0, -suffix.length);
466
+ if (!store || store.includes(".")) return void 0;
467
+ return store === "api" ? {} : { storeName: store };
468
+ }
469
+ function oauthNameFromBody(body) {
470
+ try {
471
+ let params;
472
+ if (body instanceof URLSearchParams) params = body;
473
+ else if (typeof body === "string") params = new URLSearchParams(body);
474
+ return params?.get("grant_type") === "refresh_token" ? OAUTH_REFRESH : OAUTH_TOKEN;
475
+ } catch {
476
+ return OAUTH_TOKEN;
477
+ }
478
+ }
479
+ function oauthErrorCode(data, status) {
480
+ const code = data && typeof data === "object" ? data.error : void 0;
481
+ return typeof code === "string" && OAUTH_ERROR.test(code) ? code : `HTTP_${status}`;
482
+ }
483
+ function fromStatus(status, data) {
484
+ const ok = status < 400;
485
+ return ok ? { status, ok } : { status, ok, error: { code: oauthErrorCode(data, status) } };
486
+ }
487
+ function validStatus(v) {
488
+ return typeof v === "number" && Number.isInteger(v) && v >= 100 && v <= 599;
489
+ }
490
+ function refreshResultOutcome(result) {
491
+ const r = result;
492
+ if (r && typeof r === "object" && validStatus(r.status)) return fromStatus(r.status, r.data);
493
+ return {};
494
+ }
495
+ function refreshErrorOutcome(err) {
496
+ const resp = err?.response;
497
+ if (resp && typeof resp === "object" && validStatus(resp.status)) return fromStatus(resp.status, resp.data);
498
+ return { ok: false, error: { code: "NETWORK" } };
499
+ }
500
+
381
501
  // src/index.ts
382
502
  var exitFlushers = /* @__PURE__ */ new Set();
383
503
  var exitHookInstalled = false;
@@ -395,11 +515,6 @@ function registerExitFlush(fn) {
395
515
  } catch {
396
516
  }
397
517
  }
398
- var UNINSTALL_SCOPES = [
399
- "store/app/deleted",
400
- "store/app/uninstalled",
401
- "store/authorizedApp/deleted"
402
- ];
403
518
  var MARK = /* @__PURE__ */ Symbol.for("kanca.instrumented");
404
519
  var now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
405
520
  function clampInt(v, def, min, max) {
@@ -441,12 +556,18 @@ function parseWebhookMeta(text) {
441
556
  return null;
442
557
  }
443
558
  }
444
- function noopKanca() {
559
+ function checkInstallsOptions(options) {
560
+ return clampInt(options?.concurrency, 3, 1, 10);
561
+ }
562
+ function noopKanca(recheckDelayMs) {
445
563
  return {
446
564
  webhook: (handler) => async (req, ...rest) => handler(req, ...rest),
447
565
  fetch: ((input, init) => globalThis.fetch(input, init)),
448
566
  instrument: (client) => client,
449
567
  track: () => void 0,
568
+ checkInstall: (client, fields) => checkInstallState(fields?.merchantId, client, recheckDelayMs),
569
+ checkInstalls: (stores, options) => checkInstallStates(stores, checkInstallsOptions(options), recheckDelayMs),
570
+ trackRefresh: async (call) => await call(),
450
571
  flush: () => Promise.resolve(),
451
572
  enabled: false
452
573
  };
@@ -457,20 +578,21 @@ function createKanca(options = {}) {
457
578
  if (debug) console.debug(`[kanca] ${msg}`, ...args);
458
579
  };
459
580
  const warn = (msg) => console.warn(msg);
581
+ const recheckDelayMs = clampInt(options.installRecheckDelayMs, INSTALL_RECHECK_DELAY_MS, 0, 6e4);
460
582
  if (options.disabled) {
461
583
  log("disabled; SDK is a no-op");
462
- return noopKanca();
584
+ return noopKanca(recheckDelayMs);
463
585
  }
464
586
  const key = typeof options.key === "string" ? options.key.trim() : "";
465
587
  if (!key) {
466
588
  log("no key (KANCA_KEY) configured; SDK is a no-op");
467
- return noopKanca();
589
+ return noopKanca(recheckDelayMs);
468
590
  }
469
591
  if (!key.startsWith("kk_")) {
470
592
  warn(
471
593
  key.startsWith("kp_") ? "[kanca] createKanca() needs a server key (kk_...), got a public key (kp_...). Public keys are for reportVitals()/<KancaVitals/> only. SDK disabled." : "[kanca] KANCA_KEY must start with kk_. SDK disabled."
472
594
  );
473
- return noopKanca();
595
+ return noopKanca(recheckDelayMs);
474
596
  }
475
597
  const maxBatchSize = clampInt(options.maxBatchSize, 100, 1, MAX_EVENTS_PER_BATCH);
476
598
  const transport = new Transport({
@@ -557,7 +679,6 @@ function createKanca(options = {}) {
557
679
  durationMs,
558
680
  error
559
681
  });
560
- if (m?.scope && UNINSTALL_SCOPES.includes(m.scope)) emit({ ...base, type: "uninstall", name: "uninstall" });
561
682
  })
562
683
  );
563
684
  afterDelivery();
@@ -585,11 +706,17 @@ function createKanca(options = {}) {
585
706
  let method = "GET";
586
707
  let gql = false;
587
708
  let opName;
709
+ let tokenUrl;
588
710
  try {
589
711
  url = toUrl(input);
590
712
  method = (init?.method ?? (isRequest(input) ? input.method : "GET")).toUpperCase();
591
713
  gql = !!url && isIkasGraphqlUrl(url);
592
- if (gql) {
714
+ tokenUrl = url ? ikasTokenUrl(url) : void 0;
715
+ if (tokenUrl) {
716
+ if (init && init.body != null) opName = Promise.resolve(oauthNameFromBody(bodyToString(init.body) ?? init.body));
717
+ else if (isRequest(input) && input.body) opName = input.clone().text().then(oauthNameFromBody, () => oauthNameFromBody(void 0));
718
+ else opName = Promise.resolve(oauthNameFromBody(void 0));
719
+ } else if (gql) {
593
720
  if (init && init.body != null) opName = Promise.resolve(operationNameFromBody(bodyToString(init.body)));
594
721
  else if (isRequest(input) && input.body) opName = input.clone().text().then(operationNameFromBody, () => "anonymous");
595
722
  else opName = Promise.resolve("anonymous");
@@ -607,7 +734,8 @@ function createKanca(options = {}) {
607
734
  const durationMs2 = now() - t0;
608
735
  try {
609
736
  const error = { code: "NETWORK", message: errorMessage(err) };
610
- if (gql) observe((opName ?? Promise.resolve("anonymous")).then((name) => emit({ type: "graphql", name, ts, ok: false, durationMs: durationMs2, error })));
737
+ if (tokenUrl) recordOAuth(opName, ts, durationMs2, tokenUrl.storeName, { ok: false, error: { code: "NETWORK" } }, void 0);
738
+ else if (gql) observe((opName ?? Promise.resolve("anonymous")).then((name) => emit({ type: "graphql", name, ts, ok: false, durationMs: durationMs2, error })));
611
739
  else emit({ type: "rest", name: `${method} ${url.host}${url.pathname}`, ts, ok: false, durationMs: durationMs2, error });
612
740
  } catch {
613
741
  }
@@ -616,7 +744,15 @@ function createKanca(options = {}) {
616
744
  const durationMs = now() - t0;
617
745
  try {
618
746
  const status = res.status;
619
- if (gql) {
747
+ if (tokenUrl) {
748
+ let clone;
749
+ try {
750
+ clone = status >= 400 ? res.clone() : void 0;
751
+ } catch {
752
+ clone = void 0;
753
+ }
754
+ recordOAuth(opName, ts, durationMs, tokenUrl.storeName, { status, ok: status < 400 }, clone);
755
+ } else if (gql) {
620
756
  let clone;
621
757
  try {
622
758
  clone = res.clone();
@@ -652,6 +788,32 @@ function createKanca(options = {}) {
652
788
  }
653
789
  return res;
654
790
  };
791
+ const recordOAuth = (name, ts, durationMs, storeName, outcome, errorBody) => {
792
+ observe(
793
+ (async () => {
794
+ let error = outcome.error;
795
+ if (errorBody && typeof outcome.status === "number") {
796
+ let data;
797
+ try {
798
+ data = JSON.parse(await errorBody.text());
799
+ } catch {
800
+ data = void 0;
801
+ }
802
+ error = { code: oauthErrorCode(data, outcome.status) };
803
+ }
804
+ emit({
805
+ type: "rest",
806
+ name: await (name ?? Promise.resolve(OAUTH_REFRESH)),
807
+ ts,
808
+ status: outcome.status,
809
+ ok: outcome.ok,
810
+ durationMs,
811
+ error,
812
+ attrs: storeName ? { storeName } : void 0
813
+ });
814
+ })()
815
+ );
816
+ };
655
817
  let warnedInstrument = false;
656
818
  const instrument = (client, opts) => {
657
819
  const c = client;
@@ -760,12 +922,52 @@ function createKanca(options = {}) {
760
922
  log("track failed", e);
761
923
  }
762
924
  };
925
+ const recordUninstall = (result) => {
926
+ if (result.state !== "uninstalled") return;
927
+ emit({ type: "uninstall", name: "uninstall", ts: Date.now(), merchantId: result.merchantId, attrs: { source: "check" } });
928
+ transport.scheduleSoon();
929
+ };
930
+ const checkInstall = async (client, fields) => {
931
+ const result = await checkInstallState(fields?.merchantId, client, recheckDelayMs);
932
+ recordUninstall(result);
933
+ return result;
934
+ };
935
+ const checkInstalls = async (stores, opts) => {
936
+ const out = await checkInstallStates(stores, checkInstallsOptions(opts), recheckDelayMs);
937
+ for (const r of out.results) recordUninstall(r);
938
+ return out;
939
+ };
940
+ const trackRefresh = async (call, fields) => {
941
+ const ts = Date.now();
942
+ const t0 = now();
943
+ const record = (outcome) => {
944
+ try {
945
+ const attrs = {};
946
+ if (typeof fields?.storeName === "string" && fields.storeName) attrs.storeName = fields.storeName;
947
+ emit({ type: "rest", name: OAUTH_REFRESH, ts, durationMs: now() - t0, merchantId: fields?.merchantId, attrs, ...outcome });
948
+ } catch (e) {
949
+ log("trackRefresh failed", e);
950
+ }
951
+ };
952
+ let result;
953
+ try {
954
+ result = await call();
955
+ } catch (err) {
956
+ record(refreshErrorOutcome(err));
957
+ throw err;
958
+ }
959
+ record(refreshResultOutcome(result));
960
+ return result;
961
+ };
763
962
  log(`enabled \u2192 ${options.endpoint || DEFAULT_ENDPOINT}`);
764
963
  return {
765
964
  webhook,
766
965
  fetch: kfetch,
767
966
  instrument,
768
967
  track,
968
+ checkInstall,
969
+ checkInstalls,
970
+ trackRefresh,
769
971
  flush,
770
972
  enabled: true
771
973
  };
@@ -773,7 +975,6 @@ function createKanca(options = {}) {
773
975
  export {
774
976
  SDK_NAME,
775
977
  SDK_VERSION,
776
- UNINSTALL_SCOPES,
777
978
  createKanca,
778
979
  operationName
779
980
  };