@centia-io/sdk 0.0.58 → 0.1.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.
@@ -2570,6 +2570,224 @@
2570
2570
  };
2571
2571
  }
2572
2572
 
2573
+ //#endregion
2574
+ //#region src/auth/errors.ts
2575
+ /**
2576
+ * @author Martin Høgh <mh@mapcentia.com>
2577
+ * @copyright 2013-2026 MapCentia ApS
2578
+ * @license https://opensource.org/license/mit The MIT License
2579
+ *
2580
+ */
2581
+ /**
2582
+ * Thrown by {@link createTokenProvider} when the {@link TokenStore} has no
2583
+ * access token. Indicates the user has never logged in (or has logged out)
2584
+ * and a fresh interactive login is required. Distinct from
2585
+ * {@link SessionExpiredError}, which means the user *did* log in but the
2586
+ * refresh token has since expired.
2587
+ */
2588
+ var NotLoggedInError = class extends Error {
2589
+ constructor(message = "Not logged in: no access token in store") {
2590
+ super(message);
2591
+ this.name = "NotLoggedInError";
2592
+ }
2593
+ };
2594
+ /**
2595
+ * Thrown by {@link createTokenProvider} when the access token is expired and
2596
+ * the refresh token is either missing or also expired. Indicates the
2597
+ * stored credentials cannot be silently revived; the user must run an
2598
+ * interactive login again.
2599
+ */
2600
+ var SessionExpiredError = class extends Error {
2601
+ constructor(message = "Session expired: refresh token is missing or expired") {
2602
+ super(message);
2603
+ this.name = "SessionExpiredError";
2604
+ }
2605
+ };
2606
+
2607
+ //#endregion
2608
+ //#region src/auth/tokenProvider.ts
2609
+ /**
2610
+ * @author Martin Høgh <mh@mapcentia.com>
2611
+ * @copyright 2013-2026 MapCentia ApS
2612
+ * @license https://opensource.org/license/mit The MIT License
2613
+ *
2614
+ */
2615
+ const DEFAULT_SKEW_SECONDS = 30;
2616
+ /**
2617
+ * Build a {@link TokenProvider} that returns a fresh access token, refreshing
2618
+ * via `opts.authService` and persisting the result to `opts.store` whenever
2619
+ * the cached token is within `expirySkewSeconds` of expiry.
2620
+ *
2621
+ * **Behaviour:**
2622
+ * - If the store has no access token, throws {@link NotLoggedInError}.
2623
+ * - If the access token is fresh, returns it immediately.
2624
+ * - If expired and the refresh token is missing or expired, throws
2625
+ * {@link SessionExpiredError}.
2626
+ * - Otherwise calls `opts.authService.getRefreshToken(refresh_token)`,
2627
+ * persists `{ token, refresh_token? }` via `opts.store.set()`, and
2628
+ * returns the new access token.
2629
+ *
2630
+ * **In-process concurrency.** Multiple concurrent `getAccessToken()` calls
2631
+ * during a refresh share a single in-flight promise; the auth service is
2632
+ * called exactly once per refresh cycle. On failure the in-flight slot
2633
+ * clears so the next call retries.
2634
+ *
2635
+ * **Cross-process concurrency — known limitation.** This function does NOT
2636
+ * coordinate refresh across processes. Two processes that share a
2637
+ * configstore-backed {@link TokenStore} can each independently observe an
2638
+ * expired access token, both call `getRefreshToken` against the same
2639
+ * refresh token, and the OAuth provider will reject the second call with
2640
+ * `invalid_grant` once the refresh token rotates. Callers that run
2641
+ * multiple processes concurrently against the same store should treat
2642
+ * `invalid_grant` as a transient failure and re-read the store before
2643
+ * retrying. Closing this gap requires holding the file lock across the
2644
+ * network refresh, which the current implementation does not do.
2645
+ *
2646
+ * @param opts - Provider configuration.
2647
+ * @param opts.store - Where credentials are read from and written to.
2648
+ * @param opts.authService - Object exposing
2649
+ * `getRefreshToken(refreshToken)`. The existing
2650
+ * `Gc2Service` (returned by `CodeFlow#service`)
2651
+ * satisfies this structurally.
2652
+ * @param opts.expirySkewSeconds - How many seconds before `exp` to treat a
2653
+ * JWT as expired. Default `30`.
2654
+ * @returns A {@link TokenProvider} whose `getAccessToken()` resolves to a
2655
+ * non-expired access token, refreshing if necessary.
2656
+ */
2657
+ function createTokenProvider(opts) {
2658
+ var _opts$expirySkewSecon;
2659
+ const skew = (_opts$expirySkewSecon = opts.expirySkewSeconds) !== null && _opts$expirySkewSecon !== void 0 ? _opts$expirySkewSecon : DEFAULT_SKEW_SECONDS;
2660
+ let inFlight = null;
2661
+ async function refresh(refreshToken) {
2662
+ const refreshed = await opts.authService.getRefreshToken(refreshToken);
2663
+ const patch = { token: refreshed.access_token };
2664
+ if (refreshed.refresh_token) patch.refresh_token = refreshed.refresh_token;
2665
+ await opts.store.set(patch);
2666
+ return refreshed.access_token;
2667
+ }
2668
+ return { async getAccessToken() {
2669
+ const creds = await opts.store.get();
2670
+ if (!creds.token) throw new NotLoggedInError();
2671
+ if (!isExpired(creds.token, skew)) return creds.token;
2672
+ if (!creds.refresh_token || isExpired(creds.refresh_token, skew)) throw new SessionExpiredError();
2673
+ if (!inFlight) inFlight = refresh(creds.refresh_token).finally(() => {
2674
+ inFlight = null;
2675
+ });
2676
+ return inFlight;
2677
+ } };
2678
+ }
2679
+ function isExpired(jwt, skewSeconds) {
2680
+ const { exp } = jwtDecode(jwt);
2681
+ if (!exp) return false;
2682
+ return Math.floor(Date.now() / 1e3) + skewSeconds >= exp;
2683
+ }
2684
+
2685
+ //#endregion
2686
+ //#region src/auth/configstoreTokenStore.ts
2687
+ const LOCK_RETRIES = 5;
2688
+ const LOCK_RETRY_MIN_MS = 100;
2689
+ const LOCK_RETRY_MAX_MS = 500;
2690
+ const LOCK_STALE_MS = 1e4;
2691
+ /**
2692
+ * Build a Node-only file-backed {@link TokenStore} that persists OAuth
2693
+ * credentials at `~/.config/configstore/<name>.json` (or
2694
+ * `$XDG_CONFIG_HOME/configstore/<name>.json` if set), with both in-process
2695
+ * and cross-process write safety.
2696
+ *
2697
+ * **Shared-state intent.** The `name` is the file name on disk. Two processes
2698
+ * (e.g. `gc2-cli` and a local MCP server) that pass the same name share the
2699
+ * same on-disk credentials and therefore the same login session. The default
2700
+ * `'gc2-env'` matches the name `gc2-cli` already uses, so a one-time
2701
+ * `gc2 login` is observable to every process that calls
2702
+ * `createConfigstoreTokenStore()` with no argument. Pass a different name
2703
+ * to isolate.
2704
+ *
2705
+ * **In-process correctness.** A serial promise chain on `set()` ensures
2706
+ * concurrent same-process calls do not race on the shared configstore cache.
2707
+ *
2708
+ * **Cross-process correctness.** `proper-lockfile` serializes the
2709
+ * read-merge-write critical section across processes so two simultaneous
2710
+ * `set()` calls from different processes cannot corrupt the file.
2711
+ *
2712
+ * **Node-only.** The dynamic imports keep `configstore` and `proper-lockfile`
2713
+ * out of browser bundles even when this module is imported through the SDK
2714
+ * barrel. Calling this function in a browser environment will fail at
2715
+ * runtime when the deferred `await import('configstore')` cannot resolve.
2716
+ *
2717
+ * @param name - configstore file name (without `.json`). Default `'gc2-env'`
2718
+ * matches `gc2-cli`'s configstore so credentials are shared.
2719
+ * @returns A {@link TokenStore} suitable for passing to {@link createTokenProvider}.
2720
+ */
2721
+ function createConfigstoreTokenStore(name = "gc2-env") {
2722
+ let configstoreInstance = null;
2723
+ let setChain = Promise.resolve();
2724
+ async function getConfigstore() {
2725
+ var _default;
2726
+ if (configstoreInstance) return configstoreInstance;
2727
+ const mod = await import("configstore");
2728
+ const Configstore = (_default = mod.default) !== null && _default !== void 0 ? _default : mod;
2729
+ const { homedir } = await import("node:os");
2730
+ const { join } = await import("node:path");
2731
+ configstoreInstance = new Configstore(name, void 0, { configPath: join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "configstore", `${name}.json`) });
2732
+ return configstoreInstance;
2733
+ }
2734
+ async function getLockfile() {
2735
+ var _default2;
2736
+ const mod = await import("proper-lockfile");
2737
+ return (_default2 = mod.default) !== null && _default2 !== void 0 ? _default2 : mod;
2738
+ }
2739
+ function readAll(cs) {
2740
+ const result = {};
2741
+ const token = cs.get("token");
2742
+ const refresh_token = cs.get("refresh_token");
2743
+ const host = cs.get("host");
2744
+ if (token !== void 0) result.token = token;
2745
+ if (refresh_token !== void 0) result.refresh_token = refresh_token;
2746
+ if (host !== void 0) result.host = host;
2747
+ return result;
2748
+ }
2749
+ async function doLockedSet(patch) {
2750
+ const cs = await getConfigstore();
2751
+ const lockfile = await getLockfile();
2752
+ const filePath = cs.path;
2753
+ const { mkdirSync, writeFileSync } = await import("node:fs");
2754
+ const { dirname } = await import("node:path");
2755
+ try {
2756
+ mkdirSync(dirname(filePath), { recursive: true });
2757
+ writeFileSync(filePath, "{}", { flag: "wx" });
2758
+ } catch (e) {
2759
+ if ((e === null || e === void 0 ? void 0 : e.code) !== "EEXIST") throw e;
2760
+ }
2761
+ const release = await lockfile.lock(filePath, {
2762
+ retries: {
2763
+ retries: LOCK_RETRIES,
2764
+ minTimeout: LOCK_RETRY_MIN_MS,
2765
+ maxTimeout: LOCK_RETRY_MAX_MS,
2766
+ factor: 2
2767
+ },
2768
+ stale: LOCK_STALE_MS,
2769
+ realpath: false
2770
+ });
2771
+ try {
2772
+ configstoreInstance = null;
2773
+ const fresh = await getConfigstore();
2774
+ fresh.all = _objectSpread2(_objectSpread2({}, readAll(fresh)), patch);
2775
+ } finally {
2776
+ await release();
2777
+ }
2778
+ }
2779
+ return {
2780
+ async get() {
2781
+ return readAll(await getConfigstore());
2782
+ },
2783
+ async set(patch) {
2784
+ const next = setChain.then(() => doLockedSet(patch));
2785
+ setChain = next.catch(() => {});
2786
+ return next;
2787
+ }
2788
+ };
2789
+ }
2790
+
2573
2791
  //#endregion
2574
2792
  //#region src/index.ts
2575
2793
  /**
@@ -2585,8 +2803,10 @@ exports.Claims = Claims;
2585
2803
  exports.CodeFlow = CodeFlow;
2586
2804
  exports.Gql = Gql;
2587
2805
  exports.Meta = Meta;
2806
+ exports.NotLoggedInError = NotLoggedInError;
2588
2807
  exports.PasswordFlow = PasswordFlow;
2589
2808
  exports.Rpc = Rpc;
2809
+ exports.SessionExpiredError = SessionExpiredError;
2590
2810
  exports.SignUp = SignUp;
2591
2811
  exports.Sql = Sql;
2592
2812
  exports.SqlNoToken = SqlNoToken;
@@ -2598,6 +2818,8 @@ exports.Ws = Ws;
2598
2818
  exports.createApi = createApi;
2599
2819
  exports.createCentiaAdminClient = createCentiaAdminClient;
2600
2820
  exports.createCentiaClient = createCentiaClient;
2821
+ exports.createConfigstoreTokenStore = createConfigstoreTokenStore;
2601
2822
  exports.createSqlBuilder = createSqlBuilder;
2823
+ exports.createTokenProvider = createTokenProvider;
2602
2824
  exports.isCentiaApiError = isCentiaApiError;
2603
2825
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centia-io/sdk",
3
- "version": "0.0.58",
3
+ "version": "0.1.0",
4
4
  "description": "Centia-io TypeScript SDK",
5
5
  "author": "Martin Høgh",
6
6
  "license": "MIT",
@@ -24,10 +24,20 @@
24
24
  "test": "vitest run",
25
25
  "test:watch": "vitest"
26
26
  },
27
+ "dependencies": {
28
+ "configstore": "^7.0.0",
29
+ "proper-lockfile": "^4.1.2"
30
+ },
27
31
  "devDependencies": {
32
+ "@types/configstore": "^6.0.2",
33
+ "@types/proper-lockfile": "^4.1.4",
28
34
  "tsdown": "^0.17.3",
35
+ "tsx": "^4.19.0",
29
36
  "typescript": "^5.6.3",
30
37
  "vitest": "^4.0.18"
31
38
  },
32
- "private": false
39
+ "private": false,
40
+ "engines": {
41
+ "node": ">=18"
42
+ }
33
43
  }