@kanca-app/ikas 0.1.1 → 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.cjs CHANGED
@@ -22,7 +22,6 @@ var src_exports = {};
22
22
  __export(src_exports, {
23
23
  SDK_NAME: () => SDK_NAME,
24
24
  SDK_VERSION: () => SDK_VERSION,
25
- UNINSTALL_SCOPES: () => UNINSTALL_SCOPES,
26
25
  createKanca: () => createKanca,
27
26
  operationName: () => operationName
28
27
  });
@@ -175,7 +174,7 @@ function classifyGraphql(status, json) {
175
174
 
176
175
  // src/shared.ts
177
176
  var SDK_NAME = "@kanca-app/ikas";
178
- var SDK_VERSION = "0.1.1";
177
+ var SDK_VERSION = "0.3.0";
179
178
  var DEFAULT_ENDPOINT = "https://kanca.onurhan.dev";
180
179
  var MAX_NAME = 200;
181
180
  var MAX_ERROR_MESSAGE = 500;
@@ -260,7 +259,7 @@ function errorMessage(err) {
260
259
  function joinUrl(endpoint, path) {
261
260
  return endpoint.replace(/\/+$/, "") + path;
262
261
  }
263
- var encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : void 0;
262
+ var encoder = typeof TextEncoder !== "undefined" ? /* @__PURE__ */ new TextEncoder() : void 0;
264
263
  function byteLength(s) {
265
264
  return encoder ? encoder.encode(s).length : s.length * 3;
266
265
  }
@@ -408,6 +407,126 @@ var Transport = class {
408
407
  }
409
408
  };
410
409
 
410
+ // src/install.ts
411
+ var INSTALL_RECHECK_DELAY_MS = 3e3;
412
+ var MASS_REVOCATION_MIN = 5;
413
+ var PROBE = "query getAuthorizedApp { getAuthorizedApp { id } }";
414
+ async function probe(client) {
415
+ const c = client;
416
+ if (!c || typeof c !== "object" || !c._client || typeof c._client.request !== "function") {
417
+ return { state: "unknown", reason: "UNSUPPORTED_CLIENT" };
418
+ }
419
+ if (typeof c._checkToken === "function") {
420
+ try {
421
+ await c._checkToken();
422
+ } catch {
423
+ return { state: "unknown", reason: "TOKEN_CHECK_FAILED" };
424
+ }
425
+ }
426
+ let data;
427
+ try {
428
+ data = await c._client.request({ document: PROBE });
429
+ } catch (err) {
430
+ const resp = err?.response;
431
+ const status = resp?.status;
432
+ if (typeof status !== "number") return { state: "unknown", reason: "NETWORK" };
433
+ const first = Array.isArray(resp.errors) ? resp.errors[0] : void 0;
434
+ const code = first?.extensions?.code;
435
+ return { state: "unknown", reason: typeof code === "string" && code ? code.slice(0, 100) : `HTTP_${status}` };
436
+ }
437
+ if (data && typeof data === "object" && "getAuthorizedApp" in data) {
438
+ const app = data.getAuthorizedApp;
439
+ if (app === null) return { state: "removed" };
440
+ const id = app && typeof app === "object" ? app.id : void 0;
441
+ if (typeof id === "string" && id) return { state: "installed" };
442
+ }
443
+ return { state: "unknown", reason: "UNEXPECTED_RESPONSE" };
444
+ }
445
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
446
+ async function checkInstallState(merchantId, client, recheckDelayMs) {
447
+ if (typeof merchantId !== "string" || !merchantId) return { merchantId: "", state: "unknown", reason: "MISSING_MERCHANT_ID" };
448
+ try {
449
+ const first = await probe(client);
450
+ if (first.state === "installed") return { merchantId, state: "installed" };
451
+ if (first.state === "unknown") return { merchantId, state: "unknown", reason: first.reason };
452
+ await sleep2(recheckDelayMs);
453
+ const second = await probe(client);
454
+ if (second.state === "removed") return { merchantId, state: "uninstalled" };
455
+ if (second.state === "installed") return { merchantId, state: "installed" };
456
+ return { merchantId, state: "unknown", reason: second.reason };
457
+ } catch {
458
+ return { merchantId, state: "unknown", reason: "UNEXPECTED_RESPONSE" };
459
+ }
460
+ }
461
+ function isMassRevocation(uninstalled, checked) {
462
+ return uninstalled >= MASS_REVOCATION_MIN && uninstalled * 2 > checked;
463
+ }
464
+ async function checkInstallStates(stores, concurrency, recheckDelayMs) {
465
+ const list = Array.isArray(stores) ? stores : [];
466
+ const results = new Array(list.length);
467
+ let next = 0;
468
+ const worker = async () => {
469
+ while (next < list.length) {
470
+ const i = next++;
471
+ const store = list[i];
472
+ results[i] = await checkInstallState(store?.merchantId, store?.client, recheckDelayMs);
473
+ }
474
+ };
475
+ await Promise.all(Array.from({ length: Math.min(concurrency, list.length) }, worker));
476
+ const uninstalled = results.filter((r) => r.state === "uninstalled").length;
477
+ if (!isMassRevocation(uninstalled, results.length)) return { results, massRevocation: false };
478
+ return {
479
+ massRevocation: true,
480
+ results: results.map((r) => r.state === "uninstalled" ? { merchantId: r.merchantId, state: "unknown", reason: "MASS_REVOCATION" } : r)
481
+ };
482
+ }
483
+
484
+ // src/oauth.ts
485
+ var OAUTH_REFRESH = "oauth:refresh";
486
+ var OAUTH_TOKEN = "oauth:token";
487
+ var STORE_SUFFIXES = [".myikas.com", ".myikas.dev"];
488
+ var OAUTH_ERROR = /^[a-z0-9_]{1,64}$/;
489
+ function ikasTokenUrl(url) {
490
+ const host = url.hostname.toLowerCase();
491
+ const suffix = STORE_SUFFIXES.find((s) => host.endsWith(s));
492
+ if (!suffix) return void 0;
493
+ if (url.pathname.replace(/\/+$/, "") !== "/api/admin/oauth/token") return void 0;
494
+ const store = host.slice(0, -suffix.length);
495
+ if (!store || store.includes(".")) return void 0;
496
+ return store === "api" ? {} : { storeName: store };
497
+ }
498
+ function oauthNameFromBody(body) {
499
+ try {
500
+ let params;
501
+ if (body instanceof URLSearchParams) params = body;
502
+ else if (typeof body === "string") params = new URLSearchParams(body);
503
+ return params?.get("grant_type") === "refresh_token" ? OAUTH_REFRESH : OAUTH_TOKEN;
504
+ } catch {
505
+ return OAUTH_TOKEN;
506
+ }
507
+ }
508
+ function oauthErrorCode(data, status) {
509
+ const code = data && typeof data === "object" ? data.error : void 0;
510
+ return typeof code === "string" && OAUTH_ERROR.test(code) ? code : `HTTP_${status}`;
511
+ }
512
+ function fromStatus(status, data) {
513
+ const ok = status < 400;
514
+ return ok ? { status, ok } : { status, ok, error: { code: oauthErrorCode(data, status) } };
515
+ }
516
+ function validStatus(v) {
517
+ return typeof v === "number" && Number.isInteger(v) && v >= 100 && v <= 599;
518
+ }
519
+ function refreshResultOutcome(result) {
520
+ const r = result;
521
+ if (r && typeof r === "object" && validStatus(r.status)) return fromStatus(r.status, r.data);
522
+ return {};
523
+ }
524
+ function refreshErrorOutcome(err) {
525
+ const resp = err?.response;
526
+ if (resp && typeof resp === "object" && validStatus(resp.status)) return fromStatus(resp.status, resp.data);
527
+ return { ok: false, error: { code: "NETWORK" } };
528
+ }
529
+
411
530
  // src/index.ts
412
531
  var exitFlushers = /* @__PURE__ */ new Set();
413
532
  var exitHookInstalled = false;
@@ -425,11 +544,6 @@ function registerExitFlush(fn) {
425
544
  } catch {
426
545
  }
427
546
  }
428
- var UNINSTALL_SCOPES = [
429
- "store/app/deleted",
430
- "store/app/uninstalled",
431
- "store/authorizedApp/deleted"
432
- ];
433
547
  var MARK = /* @__PURE__ */ Symbol.for("kanca.instrumented");
434
548
  var now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
435
549
  function clampInt(v, def, min, max) {
@@ -471,12 +585,18 @@ function parseWebhookMeta(text) {
471
585
  return null;
472
586
  }
473
587
  }
474
- function noopKanca() {
588
+ function checkInstallsOptions(options) {
589
+ return clampInt(options?.concurrency, 3, 1, 10);
590
+ }
591
+ function noopKanca(recheckDelayMs) {
475
592
  return {
476
593
  webhook: (handler) => async (req, ...rest) => handler(req, ...rest),
477
594
  fetch: ((input, init) => globalThis.fetch(input, init)),
478
595
  instrument: (client) => client,
479
596
  track: () => void 0,
597
+ checkInstall: (client, fields) => checkInstallState(fields?.merchantId, client, recheckDelayMs),
598
+ checkInstalls: (stores, options) => checkInstallStates(stores, checkInstallsOptions(options), recheckDelayMs),
599
+ trackRefresh: async (call) => await call(),
480
600
  flush: () => Promise.resolve(),
481
601
  enabled: false
482
602
  };
@@ -487,20 +607,21 @@ function createKanca(options = {}) {
487
607
  if (debug) console.debug(`[kanca] ${msg}`, ...args);
488
608
  };
489
609
  const warn = (msg) => console.warn(msg);
610
+ const recheckDelayMs = clampInt(options.installRecheckDelayMs, INSTALL_RECHECK_DELAY_MS, 0, 6e4);
490
611
  if (options.disabled) {
491
612
  log("disabled; SDK is a no-op");
492
- return noopKanca();
613
+ return noopKanca(recheckDelayMs);
493
614
  }
494
615
  const key = typeof options.key === "string" ? options.key.trim() : "";
495
616
  if (!key) {
496
617
  log("no key (KANCA_KEY) configured; SDK is a no-op");
497
- return noopKanca();
618
+ return noopKanca(recheckDelayMs);
498
619
  }
499
620
  if (!key.startsWith("kk_")) {
500
621
  warn(
501
622
  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."
502
623
  );
503
- return noopKanca();
624
+ return noopKanca(recheckDelayMs);
504
625
  }
505
626
  const maxBatchSize = clampInt(options.maxBatchSize, 100, 1, MAX_EVENTS_PER_BATCH);
506
627
  const transport = new Transport({
@@ -587,7 +708,6 @@ function createKanca(options = {}) {
587
708
  durationMs,
588
709
  error
589
710
  });
590
- if (m?.scope && UNINSTALL_SCOPES.includes(m.scope)) emit({ ...base, type: "uninstall", name: "uninstall" });
591
711
  })
592
712
  );
593
713
  afterDelivery();
@@ -615,11 +735,17 @@ function createKanca(options = {}) {
615
735
  let method = "GET";
616
736
  let gql = false;
617
737
  let opName;
738
+ let tokenUrl;
618
739
  try {
619
740
  url = toUrl(input);
620
741
  method = (init?.method ?? (isRequest(input) ? input.method : "GET")).toUpperCase();
621
742
  gql = !!url && isIkasGraphqlUrl(url);
622
- if (gql) {
743
+ tokenUrl = url ? ikasTokenUrl(url) : void 0;
744
+ if (tokenUrl) {
745
+ if (init && init.body != null) opName = Promise.resolve(oauthNameFromBody(bodyToString(init.body) ?? init.body));
746
+ else if (isRequest(input) && input.body) opName = input.clone().text().then(oauthNameFromBody, () => oauthNameFromBody(void 0));
747
+ else opName = Promise.resolve(oauthNameFromBody(void 0));
748
+ } else if (gql) {
623
749
  if (init && init.body != null) opName = Promise.resolve(operationNameFromBody(bodyToString(init.body)));
624
750
  else if (isRequest(input) && input.body) opName = input.clone().text().then(operationNameFromBody, () => "anonymous");
625
751
  else opName = Promise.resolve("anonymous");
@@ -637,7 +763,8 @@ function createKanca(options = {}) {
637
763
  const durationMs2 = now() - t0;
638
764
  try {
639
765
  const error = { code: "NETWORK", message: errorMessage(err) };
640
- if (gql) observe((opName ?? Promise.resolve("anonymous")).then((name) => emit({ type: "graphql", name, ts, ok: false, durationMs: durationMs2, error })));
766
+ if (tokenUrl) recordOAuth(opName, ts, durationMs2, tokenUrl.storeName, { ok: false, error: { code: "NETWORK" } }, void 0);
767
+ else if (gql) observe((opName ?? Promise.resolve("anonymous")).then((name) => emit({ type: "graphql", name, ts, ok: false, durationMs: durationMs2, error })));
641
768
  else emit({ type: "rest", name: `${method} ${url.host}${url.pathname}`, ts, ok: false, durationMs: durationMs2, error });
642
769
  } catch {
643
770
  }
@@ -646,7 +773,15 @@ function createKanca(options = {}) {
646
773
  const durationMs = now() - t0;
647
774
  try {
648
775
  const status = res.status;
649
- if (gql) {
776
+ if (tokenUrl) {
777
+ let clone;
778
+ try {
779
+ clone = status >= 400 ? res.clone() : void 0;
780
+ } catch {
781
+ clone = void 0;
782
+ }
783
+ recordOAuth(opName, ts, durationMs, tokenUrl.storeName, { status, ok: status < 400 }, clone);
784
+ } else if (gql) {
650
785
  let clone;
651
786
  try {
652
787
  clone = res.clone();
@@ -682,6 +817,32 @@ function createKanca(options = {}) {
682
817
  }
683
818
  return res;
684
819
  };
820
+ const recordOAuth = (name, ts, durationMs, storeName, outcome, errorBody) => {
821
+ observe(
822
+ (async () => {
823
+ let error = outcome.error;
824
+ if (errorBody && typeof outcome.status === "number") {
825
+ let data;
826
+ try {
827
+ data = JSON.parse(await errorBody.text());
828
+ } catch {
829
+ data = void 0;
830
+ }
831
+ error = { code: oauthErrorCode(data, outcome.status) };
832
+ }
833
+ emit({
834
+ type: "rest",
835
+ name: await (name ?? Promise.resolve(OAUTH_REFRESH)),
836
+ ts,
837
+ status: outcome.status,
838
+ ok: outcome.ok,
839
+ durationMs,
840
+ error,
841
+ attrs: storeName ? { storeName } : void 0
842
+ });
843
+ })()
844
+ );
845
+ };
685
846
  let warnedInstrument = false;
686
847
  const instrument = (client, opts) => {
687
848
  const c = client;
@@ -790,12 +951,52 @@ function createKanca(options = {}) {
790
951
  log("track failed", e);
791
952
  }
792
953
  };
954
+ const recordUninstall = (result) => {
955
+ if (result.state !== "uninstalled") return;
956
+ emit({ type: "uninstall", name: "uninstall", ts: Date.now(), merchantId: result.merchantId, attrs: { source: "check" } });
957
+ transport.scheduleSoon();
958
+ };
959
+ const checkInstall = async (client, fields) => {
960
+ const result = await checkInstallState(fields?.merchantId, client, recheckDelayMs);
961
+ recordUninstall(result);
962
+ return result;
963
+ };
964
+ const checkInstalls = async (stores, opts) => {
965
+ const out = await checkInstallStates(stores, checkInstallsOptions(opts), recheckDelayMs);
966
+ for (const r of out.results) recordUninstall(r);
967
+ return out;
968
+ };
969
+ const trackRefresh = async (call, fields) => {
970
+ const ts = Date.now();
971
+ const t0 = now();
972
+ const record = (outcome) => {
973
+ try {
974
+ const attrs = {};
975
+ if (typeof fields?.storeName === "string" && fields.storeName) attrs.storeName = fields.storeName;
976
+ emit({ type: "rest", name: OAUTH_REFRESH, ts, durationMs: now() - t0, merchantId: fields?.merchantId, attrs, ...outcome });
977
+ } catch (e) {
978
+ log("trackRefresh failed", e);
979
+ }
980
+ };
981
+ let result;
982
+ try {
983
+ result = await call();
984
+ } catch (err) {
985
+ record(refreshErrorOutcome(err));
986
+ throw err;
987
+ }
988
+ record(refreshResultOutcome(result));
989
+ return result;
990
+ };
793
991
  log(`enabled \u2192 ${options.endpoint || DEFAULT_ENDPOINT}`);
794
992
  return {
795
993
  webhook,
796
994
  fetch: kfetch,
797
995
  instrument,
798
996
  track,
997
+ checkInstall,
998
+ checkInstalls,
999
+ trackRefresh,
799
1000
  flush,
800
1001
  enabled: true
801
1002
  };