@teincfood/core 0.9.26 → 0.10.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.
@@ -11,6 +11,8 @@ export declare const useAuthStore: {
11
11
  user: AuthUser | null;
12
12
  };
13
13
  };
14
+ export declare function setLocalSessionActive(active: boolean): void;
15
+ export declare function isLocalSessionActive(): boolean;
14
16
  export declare function subscribeAuth(listener: () => void): () => void;
15
17
  export declare function setAuthProvider(fn: () => string | null): void;
16
18
  export declare function setAuthRefreshProvider(fn: () => string | null): void;
@@ -18,6 +20,8 @@ export declare function setAuthTokensProvider(fn: (at: string, rt: string) => vo
18
20
  export declare function setAuthClearProvider(fn: () => void): void;
19
21
  export declare function getAuthToken(): string | null;
20
22
  export declare function getRefreshToken(): string | null;
23
+ /** True when either a cloud token or an offline member session exists. */
24
+ export declare function hasSession(): boolean;
21
25
  export declare function setAuthTokens(at: string, rt: string): void;
22
26
  export declare function clearAuth(): void;
23
27
  export declare function notifyAuthChanged(): void;
@@ -12,6 +12,17 @@ let getAccessToken = null;
12
12
  let getRefresh = null;
13
13
  let setTokens = null;
14
14
  let clearFn = null;
15
+ // Offline member sessions have no cloud access token but are still valid local
16
+ // sessions. Cloud HTTP calls must still require a real token; local commands,
17
+ // LAN mesh startup and the outbox use `hasSession()`.
18
+ let localSessionActive = false;
19
+ export function setLocalSessionActive(active) {
20
+ localSessionActive = active;
21
+ notifyAuth();
22
+ }
23
+ export function isLocalSessionActive() {
24
+ return localSessionActive;
25
+ }
15
26
  const authListeners = new Set();
16
27
  export function subscribeAuth(listener) {
17
28
  authListeners.add(listener);
@@ -38,12 +49,18 @@ export function getAuthToken() {
38
49
  export function getRefreshToken() {
39
50
  return getRefresh ? getRefresh() : null;
40
51
  }
52
+ /** True when either a cloud token or an offline member session exists. */
53
+ export function hasSession() {
54
+ return localSessionActive || !!getAuthToken();
55
+ }
41
56
  export function setAuthTokens(at, rt) {
57
+ localSessionActive = false;
42
58
  if (setTokens)
43
59
  setTokens(at, rt);
44
60
  notifyAuth();
45
61
  }
46
62
  export function clearAuth() {
63
+ localSessionActive = false;
47
64
  if (clearFn)
48
65
  clearFn();
49
66
  notifyAuth();
@@ -57,6 +57,12 @@ export interface SessionTokens {
57
57
  */
58
58
  export declare const SKIP_AUTH_RETRY = "x-teinc-skip-auth-retry";
59
59
  export declare function hasAuthRetrySkip(config: unknown): boolean;
60
+ /**
61
+ * Host apps register a device-license exchange here. It lets an offline
62
+ * terminal recover from an expired/rotated refresh token without asking the
63
+ * member to sign in again.
64
+ */
65
+ export declare function setOfflineSessionRecoveryProvider(provider: (() => Promise<SessionTokens | null>) | null): void;
60
66
  export declare function refreshSession(): Promise<SessionTokens | null>;
61
67
  /**
62
68
  * Run fn; on access-expired, refresh once (single-flight) and retry once.
@@ -16,7 +16,7 @@
16
16
  * the backend rotates refresh tokens — two concurrent refreshes with the
17
17
  * same token revoke each other and 401 the session.
18
18
  */
19
- import { clearAuth, getRefreshToken, setAuthTokens, } from "../adapters/auth";
19
+ import { clearAuth, getRefreshToken, isLocalSessionActive, setAuthTokens, } from "../adapters/auth";
20
20
  import { getHttpClient } from "../adapters/http";
21
21
  import { syncLogger } from "../utils/sync-logger";
22
22
  /** Access token expired — recoverable via one refresh + retry. */
@@ -99,6 +99,25 @@ export function hasAuthRetrySkip(config) {
99
99
  }
100
100
  }
101
101
  let inflight = null;
102
+ let offlineRecovery = null;
103
+ /**
104
+ * Host apps register a device-license exchange here. It lets an offline
105
+ * terminal recover from an expired/rotated refresh token without asking the
106
+ * member to sign in again.
107
+ */
108
+ export function setOfflineSessionRecoveryProvider(provider) {
109
+ offlineRecovery = provider;
110
+ }
111
+ async function tryOfflineRecovery() {
112
+ if (!offlineRecovery)
113
+ return null;
114
+ try {
115
+ return await offlineRecovery();
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
102
121
  /**
103
122
  * Single-flight refresh. Returns fresh tokens (stored via provider) or
104
123
  * null. Calls clearAuth() (→ host logout) when the backend reports the
@@ -109,7 +128,11 @@ async function doRefresh() {
109
128
  try {
110
129
  const rt = getRefreshToken();
111
130
  if (!rt) {
112
- clearAuth();
131
+ const recovered = await tryOfflineRecovery();
132
+ if (recovered)
133
+ return recovered;
134
+ if (!isLocalSessionActive())
135
+ clearAuth();
113
136
  return null;
114
137
  }
115
138
  const http = getHttpClient();
@@ -129,6 +152,9 @@ async function doRefresh() {
129
152
  catch (e) {
130
153
  const failure = extractAuthFailure(e);
131
154
  if (isSessionDead(failure)) {
155
+ const recovered = await tryOfflineRecovery();
156
+ if (recovered)
157
+ return recovered;
132
158
  syncLogger.warn("Session", "Refresh token expired — logging out", {
133
159
  code: failure.code,
134
160
  });
package/dist/index.d.ts CHANGED
@@ -122,7 +122,7 @@ export * from "./local-node/service";
122
122
  export { setLocalNodeState } from "./adapters/local-node";
123
123
  export * from "./adapters/services";
124
124
  export * from "./adapters/devices";
125
- export { setAuthProvider, getAuthToken, setAuthRefreshProvider, getRefreshToken, setAuthTokensProvider, setAuthTokens, setAuthClearProvider, clearAuth } from "./adapters/auth";
125
+ export { setAuthProvider, getAuthToken, setAuthRefreshProvider, getRefreshToken, setAuthTokensProvider, setAuthTokens, setAuthClearProvider, clearAuth, hasSession, setLocalSessionActive, isLocalSessionActive, } from "./adapters/auth";
126
126
  export { imageCacheService } from "./adapters/image-cache";
127
127
  export * from "./adapters/business";
128
128
  export * from "./adapters/pending-orders";
@@ -155,7 +155,10 @@ export { performFullSync } from "./sync/full-sync";
155
155
  export { stopHealthProbe, probeCloudHealth } from "./sync/health-probe";
156
156
  export { notifyAuthChanged, subscribeAuth } from "./adapters/auth";
157
157
  export { isAuthenticated, hasActiveBusiness, canBusinessSync, setOfflineSyncEnabled, isOfflineSyncEnabled } from "./sync/sync-guard";
158
- export { ACCESS_EXPIRED_CODE, SESSION_DEAD_CODES, SKIP_AUTH_RETRY, extractAuthFailure, isAccessExpired, isSessionDead, hasAuthRetrySkip, refreshSession, withAuthRetry, } from "./auth/session";
158
+ export { ACCESS_EXPIRED_CODE, SESSION_DEAD_CODES, SKIP_AUTH_RETRY, extractAuthFailure, isAccessExpired, isSessionDead, hasAuthRetrySkip, refreshSession, withAuthRetry, setOfflineSessionRecoveryProvider, } from "./auth/session";
159
+ export * from "./offline/license";
160
+ export * from "./offline/store";
161
+ export * from "./offline/service";
159
162
  export type { AuthFailure, SessionTokens } from "./auth/session";
160
163
  export { getCachedImagePath, ensureImageCacheDir, getCachedImageInfo, cacheRemoteImage, cacheImageBatch, clearImageCache, pruneImageCache, resolveImageSource, stableCacheUrl } from "./image-cache/service";
161
164
  import { type SqliteDriver } from "./adapters/sqlite";
package/dist/index.js CHANGED
@@ -125,7 +125,7 @@ export * from "./local-node/service";
125
125
  export { setLocalNodeState } from "./adapters/local-node";
126
126
  export * from "./adapters/services";
127
127
  export * from "./adapters/devices";
128
- export { setAuthProvider, getAuthToken, setAuthRefreshProvider, getRefreshToken, setAuthTokensProvider, setAuthTokens, setAuthClearProvider, clearAuth } from "./adapters/auth";
128
+ export { setAuthProvider, getAuthToken, setAuthRefreshProvider, getRefreshToken, setAuthTokensProvider, setAuthTokens, setAuthClearProvider, clearAuth, hasSession, setLocalSessionActive, isLocalSessionActive, } from "./adapters/auth";
129
129
  export { imageCacheService } from "./adapters/image-cache";
130
130
  export * from "./adapters/business";
131
131
  export * from "./adapters/pending-orders";
@@ -170,7 +170,11 @@ export { stopHealthProbe, probeCloudHealth } from "./sync/health-probe";
170
170
  export { notifyAuthChanged, subscribeAuth } from "./adapters/auth";
171
171
  export { isAuthenticated, hasActiveBusiness, canBusinessSync, setOfflineSyncEnabled, isOfflineSyncEnabled } from "./sync/sync-guard";
172
172
  // ── Session lifecycle (access-expired → refresh → refresh-expired → logout)
173
- export { ACCESS_EXPIRED_CODE, SESSION_DEAD_CODES, SKIP_AUTH_RETRY, extractAuthFailure, isAccessExpired, isSessionDead, hasAuthRetrySkip, refreshSession, withAuthRetry, } from "./auth/session";
173
+ export { ACCESS_EXPIRED_CODE, SESSION_DEAD_CODES, SKIP_AUTH_RETRY, extractAuthFailure, isAccessExpired, isSessionDead, hasAuthRetrySkip, refreshSession, withAuthRetry, setOfflineSessionRecoveryProvider, } from "./auth/session";
174
+ // ── Offline device license + member unlock ──
175
+ export * from "./offline/license";
176
+ export * from "./offline/store";
177
+ export * from "./offline/service";
174
178
  // ── Image cache (durable) ──
175
179
  export { getCachedImagePath, ensureImageCacheDir, getCachedImageInfo, cacheRemoteImage, cacheImageBatch, clearImageCache, pruneImageCache, resolveImageSource, stableCacheUrl } from "./image-cache/service";
176
180
  // ── Init helper ──
@@ -79,6 +79,7 @@ export interface LocalNodeDeps {
79
79
  getAuth: () => {
80
80
  user: unknown | null;
81
81
  accessToken: string | null;
82
+ localSession?: boolean;
82
83
  };
83
84
  discovery: DiscoveryAdapter;
84
85
  transport: TransportAdapter;
@@ -82,7 +82,7 @@ class LocalNodeServiceCore {
82
82
  return;
83
83
  const auth = deps.getAuth();
84
84
  const business = deps.getBusiness();
85
- if (!auth.user || !auth.accessToken || !business) {
85
+ if (!auth.user || (!auth.accessToken && !auth.localSession) || !business) {
86
86
  if (this.mode !== "idle")
87
87
  await this.runShutdown();
88
88
  syncLogger.info("LocalNodeService", "Local Node idle (no active business)", {});
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Offline device license — Ed25519 verification and evaluation.
3
+ *
4
+ * The backend signs a device-bound license once the terminal is activated
5
+ * online. The app verifies it locally with the cached public key and uses the
6
+ * monotonic entitlement clock for expiry, so changing the device wall-clock
7
+ * cannot extend offline access.
8
+ */
9
+ export declare const OFFLINE_LICENSE_VERSION = "v1";
10
+ export interface OfflineLicenseFeatures {
11
+ [key: string]: unknown;
12
+ }
13
+ export interface OfflineLicensePayload {
14
+ v: number;
15
+ license_id: string;
16
+ business_id: string;
17
+ device_id: string;
18
+ device_number: string;
19
+ member_id: string;
20
+ features: OfflineLicenseFeatures;
21
+ business_type?: string | null;
22
+ subscription_status?: string | null;
23
+ current_period_end?: string | null;
24
+ grace_until?: string | null;
25
+ server_time: string;
26
+ issued_at: string;
27
+ expires_at: string;
28
+ max_offline_days: number;
29
+ }
30
+ export type OfflineLicenseState = "active" | "expired" | "invalid" | "unverified";
31
+ export interface OfflineLicenseEvaluation {
32
+ state: OfflineLicenseState;
33
+ valid: boolean;
34
+ entitled: boolean;
35
+ mustReconnect: boolean;
36
+ payload: OfflineLicensePayload | null;
37
+ serverNowMs: number;
38
+ reason: string;
39
+ }
40
+ export declare function base64UrlEncode(bytes: Uint8Array): string;
41
+ export declare function base64UrlDecode(value: string): Uint8Array;
42
+ export declare function textBytes(value: string): Uint8Array;
43
+ export declare function textFromBytes(value: Uint8Array): string;
44
+ export declare function splitLicense(token: string): {
45
+ version: string;
46
+ keyId: string;
47
+ payloadBytes: Uint8Array;
48
+ signature: Uint8Array;
49
+ } | null;
50
+ export declare function decodeLicensePayload(token: string): OfflineLicensePayload | null;
51
+ /**
52
+ * Verify a license token against the public key cached at activation.
53
+ * An optional business id prevents a valid license for one tenant being
54
+ * replayed into another tenant on the same device.
55
+ */
56
+ export declare function verifyOfflineLicense(token: string, publicKeyB64: string, expectedBusinessId?: string): Promise<OfflineLicensePayload | null>;
57
+ /** Evaluate a verified license using the server-anchored monotonic clock. */
58
+ export declare function evaluateOfflineLicense(payload: OfflineLicensePayload | null): Promise<OfflineLicenseEvaluation>;
59
+ export declare function loadAndVerifyOfflineLicense(args: {
60
+ token: string | null;
61
+ publicKey: string | null;
62
+ businessId?: string;
63
+ }): Promise<{
64
+ payload: OfflineLicensePayload | null;
65
+ evaluation: OfflineLicenseEvaluation;
66
+ }>;
67
+ /** Canonical message the device signs for `/auth/offline/exchange`. */
68
+ export declare function canonicalDeviceChallenge(args: {
69
+ deviceId: string;
70
+ licenseId: string;
71
+ memberId: string;
72
+ nonce: string;
73
+ timestamp: string;
74
+ }): string;
75
+ export declare function signDeviceChallenge(privateKey: Uint8Array, message: string): Promise<string>;
76
+ export declare function generateOfflineDeviceKeypair(randomBytes: (length: number) => Uint8Array): Promise<{
77
+ privateKey: Uint8Array;
78
+ publicKey: Uint8Array;
79
+ publicKeyB64: string;
80
+ }>;
81
+ export declare function newOfflineNonce(randomBytes: (length: number) => Uint8Array): string;
82
+ /** Remove the per-business offline fields from an entitlement-shaped map. */
83
+ export declare function licenseFeatures(payload: OfflineLicensePayload | null): Record<string, unknown> | null;
84
+ export declare const OFFLINE_LICENSE_MAX_DAYS = 30;
85
+ export declare const OFFLINE_LICENSE_DAY_MS: number;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Offline device license — Ed25519 verification and evaluation.
3
+ *
4
+ * The backend signs a device-bound license once the terminal is activated
5
+ * online. The app verifies it locally with the cached public key and uses the
6
+ * monotonic entitlement clock for expiry, so changing the device wall-clock
7
+ * cannot extend offline access.
8
+ */
9
+ import { sha512 } from "@noble/hashes/sha512";
10
+ import * as ed from "@noble/ed25519";
11
+ import { anchorServerTime, getServerNowMs } from "../reference/entitlement-clock";
12
+ // `@noble/ed25519` needs a SHA-512 implementation for Ed25519. Hermes has no
13
+ // native Ed25519, so use the audited pure-JS hash.
14
+ ed.etc.sha512Async = async (message) => sha512(message);
15
+ export const OFFLINE_LICENSE_VERSION = "v1";
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ const B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
18
+ export function base64UrlEncode(bytes) {
19
+ let out = "";
20
+ for (let i = 0; i < bytes.length; i += 3) {
21
+ const a = bytes[i];
22
+ const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
23
+ const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
24
+ const trio = (a << 16) | (b << 8) | c;
25
+ out += B64_ALPHABET[(trio >> 18) & 63];
26
+ out += B64_ALPHABET[(trio >> 12) & 63];
27
+ if (i + 1 < bytes.length)
28
+ out += B64_ALPHABET[(trio >> 6) & 63];
29
+ if (i + 2 < bytes.length)
30
+ out += B64_ALPHABET[trio & 63];
31
+ }
32
+ return out;
33
+ }
34
+ export function base64UrlDecode(value) {
35
+ const clean = value.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
36
+ const out = [];
37
+ let buffer = 0;
38
+ let bits = 0;
39
+ for (let i = 0; i < clean.length; i++) {
40
+ const index = B64_ALPHABET.indexOf(clean[i]);
41
+ if (index < 0)
42
+ throw new Error("Invalid base64url");
43
+ buffer = (buffer << 6) | index;
44
+ bits += 6;
45
+ if (bits >= 8) {
46
+ bits -= 8;
47
+ out.push((buffer >> bits) & 0xff);
48
+ }
49
+ }
50
+ return new Uint8Array(out);
51
+ }
52
+ export function textBytes(value) {
53
+ return new TextEncoder().encode(value);
54
+ }
55
+ export function textFromBytes(value) {
56
+ let out = "";
57
+ let i = 0;
58
+ while (i < value.length) {
59
+ const first = value[i++];
60
+ if (first < 0x80) {
61
+ out += String.fromCharCode(first);
62
+ }
63
+ else if (first < 0xe0) {
64
+ out += String.fromCharCode(((first & 0x1f) << 6) | (value[i++] & 0x3f));
65
+ }
66
+ else if (first < 0xf0) {
67
+ out += String.fromCharCode(((first & 0x0f) << 12) | ((value[i++] & 0x3f) << 6) | (value[i++] & 0x3f));
68
+ }
69
+ else {
70
+ const cp = ((first & 0x07) << 18) |
71
+ ((value[i++] & 0x3f) << 12) |
72
+ ((value[i++] & 0x3f) << 6) |
73
+ (value[i++] & 0x3f);
74
+ out += String.fromCodePoint(cp);
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ export function splitLicense(token) {
80
+ const parts = token.split(".");
81
+ if (parts.length !== 4 || parts[0] !== OFFLINE_LICENSE_VERSION)
82
+ return null;
83
+ try {
84
+ return {
85
+ version: parts[0],
86
+ keyId: parts[1],
87
+ payloadBytes: base64UrlDecode(parts[2]),
88
+ signature: base64UrlDecode(parts[3]),
89
+ };
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ export function decodeLicensePayload(token) {
96
+ const split = splitLicense(token);
97
+ if (!split)
98
+ return null;
99
+ try {
100
+ const parsed = JSON.parse(textFromBytes(split.payloadBytes));
101
+ if (!parsed?.license_id || !parsed?.business_id || !parsed?.device_id)
102
+ return null;
103
+ return parsed;
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ /**
110
+ * Verify a license token against the public key cached at activation.
111
+ * An optional business id prevents a valid license for one tenant being
112
+ * replayed into another tenant on the same device.
113
+ */
114
+ export async function verifyOfflineLicense(token, publicKeyB64, expectedBusinessId) {
115
+ const split = splitLicense(token);
116
+ if (!split)
117
+ return null;
118
+ try {
119
+ const publicKey = base64UrlDecode(publicKeyB64);
120
+ if (publicKey.length !== 32)
121
+ return null;
122
+ const ok = await ed.verifyAsync(split.signature, split.payloadBytes, publicKey);
123
+ if (!ok)
124
+ return null;
125
+ const payload = decodeLicensePayload(token);
126
+ if (!payload)
127
+ return null;
128
+ if (expectedBusinessId && payload.business_id !== expectedBusinessId)
129
+ return null;
130
+ return payload;
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ /** Evaluate a verified license using the server-anchored monotonic clock. */
137
+ export async function evaluateOfflineLicense(payload) {
138
+ if (!payload) {
139
+ return {
140
+ state: "invalid",
141
+ valid: false,
142
+ entitled: false,
143
+ mustReconnect: true,
144
+ payload: null,
145
+ serverNowMs: await getServerNowMs(),
146
+ reason: "Offline license is missing or invalid",
147
+ };
148
+ }
149
+ await anchorServerTime(payload.server_time);
150
+ const serverNowMs = await getServerNowMs();
151
+ const expiresMs = Date.parse(payload.expires_at);
152
+ const graceMs = payload.grace_until ? Date.parse(payload.grace_until) : NaN;
153
+ if (!Number.isFinite(expiresMs)) {
154
+ return {
155
+ state: "invalid",
156
+ valid: false,
157
+ entitled: false,
158
+ mustReconnect: true,
159
+ payload,
160
+ serverNowMs,
161
+ reason: "Offline license has no valid expiry",
162
+ };
163
+ }
164
+ if (serverNowMs <= expiresMs || (Number.isFinite(graceMs) && serverNowMs <= graceMs)) {
165
+ return {
166
+ state: "active",
167
+ valid: true,
168
+ entitled: true,
169
+ mustReconnect: false,
170
+ payload,
171
+ serverNowMs,
172
+ reason: "Offline license is valid",
173
+ };
174
+ }
175
+ return {
176
+ state: "expired",
177
+ valid: true,
178
+ entitled: false,
179
+ mustReconnect: true,
180
+ payload,
181
+ serverNowMs,
182
+ reason: `Offline license expired — reconnect to renew (max ${payload.max_offline_days} days)`,
183
+ };
184
+ }
185
+ export async function loadAndVerifyOfflineLicense(args) {
186
+ if (!args.token || !args.publicKey) {
187
+ return {
188
+ payload: null,
189
+ evaluation: await evaluateOfflineLicense(null),
190
+ };
191
+ }
192
+ const payload = await verifyOfflineLicense(args.token, args.publicKey, args.businessId);
193
+ return {
194
+ payload,
195
+ evaluation: await evaluateOfflineLicense(payload),
196
+ };
197
+ }
198
+ /** Canonical message the device signs for `/auth/offline/exchange`. */
199
+ export function canonicalDeviceChallenge(args) {
200
+ return [args.deviceId, args.licenseId, args.memberId, args.nonce, args.timestamp].join("|");
201
+ }
202
+ export async function signDeviceChallenge(privateKey, message) {
203
+ const signature = await ed.signAsync(textBytes(message), privateKey);
204
+ return base64UrlEncode(signature);
205
+ }
206
+ export async function generateOfflineDeviceKeypair(randomBytes) {
207
+ const privateKey = randomBytes(32);
208
+ if (privateKey.length !== 32)
209
+ throw new Error("randomBytes must return 32 bytes");
210
+ const publicKey = await ed.getPublicKeyAsync(privateKey);
211
+ return { privateKey, publicKey, publicKeyB64: base64UrlEncode(publicKey) };
212
+ }
213
+ export function newOfflineNonce(randomBytes) {
214
+ return base64UrlEncode(randomBytes(16));
215
+ }
216
+ /** Remove the per-business offline fields from an entitlement-shaped map. */
217
+ export function licenseFeatures(payload) {
218
+ return payload?.features ?? null;
219
+ }
220
+ export const OFFLINE_LICENSE_MAX_DAYS = 30;
221
+ export const OFFLINE_LICENSE_DAY_MS = DAY_MS;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Offline device activation, license refresh and token exchange.
3
+ *
4
+ * One backend call activates a terminal while online. After that the app can
5
+ * verify the signed license, unlock enrolled members locally, and only needs
6
+ * the network to exchange the device challenge for fresh session tokens.
7
+ */
8
+ import { evaluateOfflineLicense, type OfflineLicenseEvaluation, type OfflineLicensePayload } from "./license";
9
+ import { type OfflineMemberCredential } from "./store";
10
+ export interface OfflineActivationResult {
11
+ license: string;
12
+ public_key: string;
13
+ key_id: string;
14
+ license_payload: OfflineLicensePayload;
15
+ algorithm?: string;
16
+ device: Record<string, unknown>;
17
+ /** Verified decoded payload (client-side convenience). */
18
+ payload: OfflineLicensePayload;
19
+ }
20
+ export interface OfflineExchangeResult {
21
+ access_token: string;
22
+ refresh_token: string;
23
+ expires_in: number;
24
+ license: string;
25
+ license_payload: OfflineLicensePayload;
26
+ public_key: string;
27
+ key_id: string;
28
+ }
29
+ export interface OfflineSessionState {
30
+ hasDevice: boolean;
31
+ license: OfflineLicensePayload | null;
32
+ evaluation: OfflineLicenseEvaluation;
33
+ publicKey: string | null;
34
+ keyId: string | null;
35
+ members: OfflineMemberCredential[];
36
+ }
37
+ export declare function setOfflineRandomBytesProvider(provider: ((length: number) => Uint8Array) | null): void;
38
+ /**
39
+ * First-time activation. Generates the device keypair, registers the public
40
+ * key, stores the signed license and enrolls the current member's PIN.
41
+ */
42
+ export declare function activateOfflineDevice(args: {
43
+ businessId: string;
44
+ member: {
45
+ userId: string;
46
+ fullName: string;
47
+ email?: string | null;
48
+ role: string;
49
+ capabilities: string[];
50
+ };
51
+ pin: string;
52
+ appInstanceId?: string;
53
+ platform?: string;
54
+ }): Promise<OfflineActivationResult>;
55
+ /** Refresh the signed license while online using the normal access token. */
56
+ export declare function refreshOfflineLicense(businessId: string): Promise<OfflineActivationResult>;
57
+ /**
58
+ * Reconnect path: sign a fresh nonce with the device key and exchange the
59
+ * license for access/refresh tokens. Works even when the old refresh token
60
+ * expired while the terminal was offline.
61
+ */
62
+ export declare function exchangeOfflineSession(args: {
63
+ businessId: string;
64
+ memberId: string;
65
+ deviceId?: string;
66
+ }): Promise<OfflineExchangeResult>;
67
+ /** Read local license/member state without touching the network. */
68
+ export declare function getOfflineSessionState(businessId?: string): Promise<OfflineSessionState>;
69
+ export declare function verifyOfflineLogin(userId: string, pin: string): Promise<OfflineMemberCredential | null>;
70
+ export declare function enrollMemberOnDevice(args: {
71
+ businessId: string;
72
+ deviceId?: string;
73
+ userId: string;
74
+ }): Promise<void>;
75
+ export declare function revokeMemberOnDevice(args: {
76
+ businessId: string;
77
+ deviceId?: string;
78
+ userId: string;
79
+ }): Promise<void>;
80
+ /** Owner action: revoke the terminal and wipe all local offline credentials. */
81
+ export declare function removeOfflineDevice(businessId: string, reason?: string): Promise<void>;
82
+ export declare function getStoredOfflineLicensePayload(): Promise<OfflineLicensePayload | null>;
83
+ export declare function evaluateStoredOfflineLicense(businessId?: string): Promise<OfflineLicenseEvaluation>;
84
+ export { evaluateOfflineLicense };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Offline device activation, license refresh and token exchange.
3
+ *
4
+ * One backend call activates a terminal while online. After that the app can
5
+ * verify the signed license, unlock enrolled members locally, and only needs
6
+ * the network to exchange the device challenge for fresh session tokens.
7
+ */
8
+ import { getAppInstanceIdAsync } from "../adapters/devices";
9
+ import { getHttpClient } from "../adapters/http";
10
+ import { getAuthToken } from "../adapters/auth";
11
+ import { syncLogger } from "../utils/sync-logger";
12
+ import { base64UrlDecode, base64UrlEncode, canonicalDeviceChallenge, decodeLicensePayload, evaluateOfflineLicense, generateOfflineDeviceKeypair, loadAndVerifyOfflineLicense, newOfflineNonce, signDeviceChallenge, verifyOfflineLicense, } from "./license";
13
+ import { clearOfflineDevice, createOfflineMemberCredential, getDevicePrivateKey, getOfflineMember, getStoredLicense, getStoredLicenseKeyId, getStoredLicensePublicKey, listOfflineMembers, markOfflineMemberLogin, removeOfflineMember, saveDevicePrivateKey, saveStoredLicense, saveStoredLicenseKeyId, saveStoredLicensePublicKey, verifyOfflineMemberPin, } from "./store";
14
+ let randomBytesProvider = null;
15
+ export function setOfflineRandomBytesProvider(provider) {
16
+ randomBytesProvider = provider;
17
+ }
18
+ function randomBytes(length) {
19
+ if (randomBytesProvider)
20
+ return randomBytesProvider(length);
21
+ const cryptoObj = globalThis.crypto;
22
+ if (cryptoObj?.getRandomValues) {
23
+ return cryptoObj.getRandomValues(new Uint8Array(length));
24
+ }
25
+ throw new Error("No secure random source available. Call setOfflineRandomBytesProvider().");
26
+ }
27
+ async function getPrivateKeyBytes() {
28
+ const encoded = await getDevicePrivateKey();
29
+ if (!encoded)
30
+ return null;
31
+ try {
32
+ const raw = base64UrlDecode(encoded);
33
+ return raw.length === 32 ? raw : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /**
40
+ * First-time activation. Generates the device keypair, registers the public
41
+ * key, stores the signed license and enrolls the current member's PIN.
42
+ */
43
+ export async function activateOfflineDevice(args) {
44
+ if (!getAuthToken())
45
+ throw new Error("Sign in online before activating this device");
46
+ const deviceId = args.appInstanceId ?? (await getAppInstanceIdAsync());
47
+ const keypair = await generateOfflineDeviceKeypair(randomBytes);
48
+ const response = await getHttpClient().post(`/businesses/${args.businessId}/devices/${encodeURIComponent(deviceId)}/activate`, {
49
+ device_public_key: keypair.publicKeyB64,
50
+ app_instance_id: deviceId,
51
+ platform: args.platform ?? "unknown",
52
+ });
53
+ const data = response.data.data;
54
+ if (!data?.license || !data.public_key) {
55
+ throw new Error("Device activation returned no offline license");
56
+ }
57
+ const verified = await verifyOfflineLicense(data.license, data.public_key, args.businessId);
58
+ if (!verified)
59
+ throw new Error("Activation returned an invalid offline license");
60
+ await saveDevicePrivateKey(base64UrlEncode(keypair.privateKey));
61
+ await saveStoredLicense(data.license);
62
+ await saveStoredLicensePublicKey(data.public_key);
63
+ await saveStoredLicenseKeyId(data.key_id);
64
+ await createOfflineMemberCredential({
65
+ userId: args.member.userId,
66
+ fullName: args.member.fullName,
67
+ email: args.member.email,
68
+ role: args.member.role,
69
+ capabilities: args.member.capabilities,
70
+ pin: args.pin,
71
+ });
72
+ // Best-effort: activation already enrolls the current member, but this makes
73
+ // the intent explicit and is idempotent.
74
+ await enrollMemberOnDevice({
75
+ businessId: args.businessId,
76
+ deviceId,
77
+ userId: args.member.userId,
78
+ }).catch((error) => {
79
+ syncLogger.warn("OfflineAuth", "Member enrollment call failed", {
80
+ error: String(error),
81
+ });
82
+ });
83
+ return { ...data, payload: verified };
84
+ }
85
+ /** Refresh the signed license while online using the normal access token. */
86
+ export async function refreshOfflineLicense(businessId) {
87
+ const deviceId = await getAppInstanceIdAsync();
88
+ const response = await getHttpClient().post(`/businesses/${businessId}/devices/${encodeURIComponent(deviceId)}/license/refresh`);
89
+ const data = response.data.data;
90
+ if (!data?.license || !data.public_key) {
91
+ throw new Error("License refresh returned no license");
92
+ }
93
+ const verified = await verifyOfflineLicense(data.license, data.public_key, businessId);
94
+ if (!verified)
95
+ throw new Error("License refresh returned an invalid license");
96
+ await saveStoredLicense(data.license);
97
+ await saveStoredLicensePublicKey(data.public_key);
98
+ await saveStoredLicenseKeyId(data.key_id);
99
+ return { ...data, payload: verified };
100
+ }
101
+ /**
102
+ * Reconnect path: sign a fresh nonce with the device key and exchange the
103
+ * license for access/refresh tokens. Works even when the old refresh token
104
+ * expired while the terminal was offline.
105
+ */
106
+ export async function exchangeOfflineSession(args) {
107
+ const token = await getStoredLicense();
108
+ const publicKey = await getStoredLicensePublicKey();
109
+ const privateKey = await getPrivateKeyBytes();
110
+ if (!token || !publicKey || !privateKey) {
111
+ throw new Error("This device has no offline license");
112
+ }
113
+ const payload = await verifyOfflineLicense(token, publicKey, args.businessId);
114
+ if (!payload)
115
+ throw new Error("Stored offline license is invalid");
116
+ const deviceId = args.deviceId ?? (await getAppInstanceIdAsync());
117
+ const nonce = newOfflineNonce(randomBytes);
118
+ const timestamp = new Date().toISOString();
119
+ const message = canonicalDeviceChallenge({
120
+ deviceId,
121
+ licenseId: payload.license_id,
122
+ memberId: args.memberId,
123
+ nonce,
124
+ timestamp,
125
+ });
126
+ const signature = await signDeviceChallenge(privateKey, message);
127
+ const response = await getHttpClient().post("/auth/offline/exchange", {
128
+ license: token,
129
+ device_uuid: deviceId,
130
+ member_id: args.memberId,
131
+ nonce,
132
+ timestamp,
133
+ signature,
134
+ });
135
+ const data = response.data.data;
136
+ if (!data?.access_token || !data?.license) {
137
+ throw new Error("Offline exchange returned no tokens");
138
+ }
139
+ const refreshed = await verifyOfflineLicense(data.license, data.public_key, args.businessId);
140
+ if (!refreshed)
141
+ throw new Error("Offline exchange returned an invalid license");
142
+ await saveStoredLicense(data.license);
143
+ await saveStoredLicensePublicKey(data.public_key);
144
+ await saveStoredLicenseKeyId(data.key_id);
145
+ await markOfflineMemberLogin(args.memberId);
146
+ return { ...data, license_payload: refreshed };
147
+ }
148
+ /** Read local license/member state without touching the network. */
149
+ export async function getOfflineSessionState(businessId) {
150
+ const [token, publicKey, keyId, members] = await Promise.all([
151
+ getStoredLicense(),
152
+ getStoredLicensePublicKey(),
153
+ getStoredLicenseKeyId(),
154
+ listOfflineMembers(),
155
+ ]);
156
+ const { payload, evaluation } = await loadAndVerifyOfflineLicense({
157
+ token,
158
+ publicKey,
159
+ businessId,
160
+ });
161
+ return {
162
+ hasDevice: Boolean(token && publicKey),
163
+ license: payload,
164
+ evaluation,
165
+ publicKey,
166
+ keyId,
167
+ members,
168
+ };
169
+ }
170
+ export async function verifyOfflineLogin(userId, pin) {
171
+ const member = await getOfflineMember(userId);
172
+ if (!member)
173
+ return null;
174
+ const ok = await verifyOfflineMemberPin(userId, pin);
175
+ if (!ok)
176
+ return null;
177
+ await markOfflineMemberLogin(userId);
178
+ return member;
179
+ }
180
+ export async function enrollMemberOnDevice(args) {
181
+ const deviceId = args.deviceId ?? (await getAppInstanceIdAsync());
182
+ await getHttpClient().post(`/businesses/${args.businessId}/devices/${encodeURIComponent(deviceId)}/members/${encodeURIComponent(args.userId)}/enroll`);
183
+ }
184
+ export async function revokeMemberOnDevice(args) {
185
+ const deviceId = args.deviceId ?? (await getAppInstanceIdAsync());
186
+ await getHttpClient().delete(`/businesses/${args.businessId}/devices/${encodeURIComponent(deviceId)}/members/${encodeURIComponent(args.userId)}`);
187
+ await removeOfflineMember(args.userId);
188
+ }
189
+ /** Owner action: revoke the terminal and wipe all local offline credentials. */
190
+ export async function removeOfflineDevice(businessId, reason) {
191
+ const deviceId = await getAppInstanceIdAsync();
192
+ try {
193
+ await getHttpClient().post(`/businesses/${businessId}/devices/${encodeURIComponent(deviceId)}/revoke`, { reason: reason ?? "removed_from_device" });
194
+ }
195
+ catch (error) {
196
+ syncLogger.warn("OfflineAuth", "Device revoke call failed; clearing locally", {
197
+ error: String(error),
198
+ });
199
+ }
200
+ await clearOfflineDevice();
201
+ }
202
+ export async function getStoredOfflineLicensePayload() {
203
+ const token = await getStoredLicense();
204
+ return token ? decodeLicensePayload(token) : null;
205
+ }
206
+ export async function evaluateStoredOfflineLicense(businessId) {
207
+ const state = await getOfflineSessionState(businessId);
208
+ return state.evaluation;
209
+ }
210
+ export { evaluateOfflineLicense };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Offline license + member credential storage.
3
+ *
4
+ * The device private key must live in host secure storage (Keychain /
5
+ * Keystore / Tauri stronghold). The signed license, public key and member
6
+ * directory are non-secret and live in the shared KV store.
7
+ *
8
+ * Member PINs are stored as slow salted hashes. We never store the member's
9
+ * server password or its hash.
10
+ */
11
+ export declare const OFFLINE_STORAGE_KEYS: {
12
+ readonly LICENSE: "offline:license";
13
+ readonly PUBLIC_KEY: "offline:license:public_key";
14
+ readonly KEY_ID: "offline:license:key_id";
15
+ readonly MEMBERS: "offline:members";
16
+ readonly DEVICE_PRIVATE_KEY: "offline:device:private_key";
17
+ };
18
+ export interface OfflineSecureStorage {
19
+ getItem(key: string): Promise<string | null>;
20
+ setItem(key: string, value: string): Promise<void>;
21
+ removeItem(key: string): Promise<void>;
22
+ }
23
+ export declare function setOfflineSecureStorage(storage: OfflineSecureStorage | null): void;
24
+ export declare function getDevicePrivateKey(): Promise<string | null>;
25
+ export declare function saveDevicePrivateKey(privateKeyB64: string): Promise<void>;
26
+ export declare function removeDevicePrivateKey(): Promise<void>;
27
+ export declare function getStoredLicense(): Promise<string | null>;
28
+ export declare function saveStoredLicense(token: string): Promise<void>;
29
+ export declare function getStoredLicensePublicKey(): Promise<string | null>;
30
+ export declare function saveStoredLicensePublicKey(publicKey: string): Promise<void>;
31
+ export declare function getStoredLicenseKeyId(): Promise<string | null>;
32
+ export declare function saveStoredLicenseKeyId(keyId: string): Promise<void>;
33
+ export interface OfflineMemberCredential {
34
+ user_id: string;
35
+ full_name: string;
36
+ email?: string | null;
37
+ role: string;
38
+ capabilities: string[];
39
+ pin_salt: string;
40
+ pin_hash: string;
41
+ enrolled_at: string;
42
+ last_login_at?: string | null;
43
+ }
44
+ export declare function listOfflineMembers(): Promise<OfflineMemberCredential[]>;
45
+ export declare function getOfflineMember(userId: string): Promise<OfflineMemberCredential | null>;
46
+ export declare function saveOfflineMember(member: OfflineMemberCredential): Promise<void>;
47
+ export declare function removeOfflineMember(userId: string): Promise<void>;
48
+ export declare function markOfflineMemberLogin(userId: string): Promise<void>;
49
+ export declare function clearOfflineDevice(): Promise<void>;
50
+ export declare function createOfflineMemberCredential(args: {
51
+ userId: string;
52
+ fullName: string;
53
+ email?: string | null;
54
+ role: string;
55
+ capabilities: string[];
56
+ pin: string;
57
+ }): Promise<OfflineMemberCredential>;
58
+ export declare function verifyOfflineMemberPin(userId: string, pin: string): Promise<boolean>;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Offline license + member credential storage.
3
+ *
4
+ * The device private key must live in host secure storage (Keychain /
5
+ * Keystore / Tauri stronghold). The signed license, public key and member
6
+ * directory are non-secret and live in the shared KV store.
7
+ *
8
+ * Member PINs are stored as slow salted hashes. We never store the member's
9
+ * server password or its hash.
10
+ */
11
+ import { getItem, removeItem, setItem } from "../adapters/kv";
12
+ import { sha256Hex } from "../reference/entitlement-crypto";
13
+ export const OFFLINE_STORAGE_KEYS = {
14
+ LICENSE: "offline:license",
15
+ PUBLIC_KEY: "offline:license:public_key",
16
+ KEY_ID: "offline:license:key_id",
17
+ MEMBERS: "offline:members",
18
+ DEVICE_PRIVATE_KEY: "offline:device:private_key",
19
+ };
20
+ let secureStorage = null;
21
+ export function setOfflineSecureStorage(storage) {
22
+ secureStorage = storage;
23
+ }
24
+ function secure() {
25
+ if (!secureStorage) {
26
+ throw new Error("Offline secure storage is not configured. Call setOfflineSecureStorage() at boot.");
27
+ }
28
+ return secureStorage;
29
+ }
30
+ export async function getDevicePrivateKey() {
31
+ return secure().getItem(OFFLINE_STORAGE_KEYS.DEVICE_PRIVATE_KEY);
32
+ }
33
+ export async function saveDevicePrivateKey(privateKeyB64) {
34
+ await secure().setItem(OFFLINE_STORAGE_KEYS.DEVICE_PRIVATE_KEY, privateKeyB64);
35
+ }
36
+ export async function removeDevicePrivateKey() {
37
+ await secure().removeItem(OFFLINE_STORAGE_KEYS.DEVICE_PRIVATE_KEY);
38
+ }
39
+ export async function getStoredLicense() {
40
+ return getItem(OFFLINE_STORAGE_KEYS.LICENSE);
41
+ }
42
+ export async function saveStoredLicense(token) {
43
+ await setItem(OFFLINE_STORAGE_KEYS.LICENSE, token);
44
+ }
45
+ export async function getStoredLicensePublicKey() {
46
+ return getItem(OFFLINE_STORAGE_KEYS.PUBLIC_KEY);
47
+ }
48
+ export async function saveStoredLicensePublicKey(publicKey) {
49
+ await setItem(OFFLINE_STORAGE_KEYS.PUBLIC_KEY, publicKey);
50
+ }
51
+ export async function getStoredLicenseKeyId() {
52
+ return getItem(OFFLINE_STORAGE_KEYS.KEY_ID);
53
+ }
54
+ export async function saveStoredLicenseKeyId(keyId) {
55
+ await setItem(OFFLINE_STORAGE_KEYS.KEY_ID, keyId);
56
+ }
57
+ export async function listOfflineMembers() {
58
+ return (await getItem(OFFLINE_STORAGE_KEYS.MEMBERS)) ?? [];
59
+ }
60
+ export async function getOfflineMember(userId) {
61
+ const members = await listOfflineMembers();
62
+ return members.find((member) => member.user_id === userId) ?? null;
63
+ }
64
+ export async function saveOfflineMember(member) {
65
+ const members = await listOfflineMembers();
66
+ const next = members.filter((existing) => existing.user_id !== member.user_id);
67
+ next.push(member);
68
+ await setItem(OFFLINE_STORAGE_KEYS.MEMBERS, next);
69
+ }
70
+ export async function removeOfflineMember(userId) {
71
+ const members = await listOfflineMembers();
72
+ await setItem(OFFLINE_STORAGE_KEYS.MEMBERS, members.filter((member) => member.user_id !== userId));
73
+ }
74
+ export async function markOfflineMemberLogin(userId) {
75
+ const member = await getOfflineMember(userId);
76
+ if (!member)
77
+ return;
78
+ await saveOfflineMember({ ...member, last_login_at: new Date().toISOString() });
79
+ }
80
+ export async function clearOfflineDevice() {
81
+ await Promise.all([
82
+ removeItem(OFFLINE_STORAGE_KEYS.LICENSE),
83
+ removeItem(OFFLINE_STORAGE_KEYS.PUBLIC_KEY),
84
+ removeItem(OFFLINE_STORAGE_KEYS.KEY_ID),
85
+ removeItem(OFFLINE_STORAGE_KEYS.MEMBERS),
86
+ removeDevicePrivateKey().catch(() => undefined),
87
+ ]);
88
+ }
89
+ const PIN_ROUNDS = 20000;
90
+ function randomHex(length) {
91
+ const alphabet = "0123456789abcdef";
92
+ let out = "";
93
+ for (let i = 0; i < length; i++) {
94
+ out += alphabet[Math.floor(Math.random() * alphabet.length)];
95
+ }
96
+ return out;
97
+ }
98
+ async function hashPin(pin, salt) {
99
+ let digest = `${salt}:${pin}`;
100
+ for (let i = 0; i < PIN_ROUNDS; i++) {
101
+ digest = await sha256Hex(`${digest}:${salt}:${i}`);
102
+ }
103
+ return digest;
104
+ }
105
+ export async function createOfflineMemberCredential(args) {
106
+ if (!/^\d{6}$/.test(args.pin)) {
107
+ throw new Error("Offline PIN must be exactly 6 digits");
108
+ }
109
+ const salt = randomHex(32);
110
+ const member = {
111
+ user_id: args.userId,
112
+ full_name: args.fullName,
113
+ email: args.email ?? null,
114
+ role: args.role,
115
+ capabilities: args.capabilities,
116
+ pin_salt: salt,
117
+ pin_hash: await hashPin(args.pin, salt),
118
+ enrolled_at: new Date().toISOString(),
119
+ last_login_at: null,
120
+ };
121
+ await saveOfflineMember(member);
122
+ return member;
123
+ }
124
+ export async function verifyOfflineMemberPin(userId, pin) {
125
+ const member = await getOfflineMember(userId);
126
+ if (!member)
127
+ return false;
128
+ if (!/^\d{6}$/.test(pin))
129
+ return false;
130
+ const candidate = await hashPin(pin, member.pin_salt);
131
+ if (candidate.length !== member.pin_hash.length)
132
+ return false;
133
+ let diff = 0;
134
+ for (let i = 0; i < candidate.length; i++) {
135
+ diff |= candidate.charCodeAt(i) ^ member.pin_hash.charCodeAt(i);
136
+ }
137
+ return diff === 0;
138
+ }
@@ -7,7 +7,7 @@
7
7
  * the user has logged in and a business is selected. User-scoped queries
8
8
  * (invitations, fleet) gate on auth only.
9
9
  */
10
- import { getAuthToken } from "../adapters/auth";
10
+ import { hasSession } from "../adapters/auth";
11
11
  import { getActiveBusiness } from "../adapters/business";
12
12
  // Offline machinery (reference sync, drains, pulls, leases) runs for the
13
13
  // seller role only. Fleet managers and riders are fully online: the app
@@ -21,7 +21,7 @@ export function isOfflineSyncEnabled() {
21
21
  return offlineSyncEnabled;
22
22
  }
23
23
  export function isAuthenticated() {
24
- return !!getAuthToken();
24
+ return hasSession();
25
25
  }
26
26
  export function hasActiveBusiness() {
27
27
  return !!getActiveBusiness()?.id;
@@ -36,5 +36,5 @@ export function canBusinessSync(businessId) {
36
36
  if (!offlineSyncEnabled)
37
37
  return false;
38
38
  const bid = businessId ?? getActiveBusiness()?.id;
39
- return !!bid && !!getAuthToken();
39
+ return !!bid && hasSession();
40
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.26",
3
+ "version": "0.10.0",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -70,6 +70,8 @@
70
70
  "access": "public"
71
71
  },
72
72
  "dependencies": {
73
+ "@noble/ed25519": "^2.3.0",
74
+ "@noble/hashes": "^1.8.0",
73
75
  "@tanstack/query-async-storage-persister": "^5.102.8"
74
76
  }
75
77
  }