@teincfood/core 0.10.18 → 0.10.20
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.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/offline/service.d.ts +34 -9
- package/dist/offline/service.js +53 -6
- package/dist/offline/store.d.ts +2 -0
- package/dist/offline/store.js +17 -0
- package/dist/sync/device-lease.service.d.ts +19 -0
- package/dist/sync/device-lease.service.js +121 -33
- package/dist/sync/startup-readiness.d.ts +9 -0
- package/dist/sync/startup-readiness.js +4 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export * from "./sync/command-builder";
|
|
|
59
59
|
export { commandQueueService } from "./sync/command-queue.service";
|
|
60
60
|
export { buildCommand } from "./sync/command-builder";
|
|
61
61
|
export { generateLocalOrderNumber, generateLocalCalloutNumber, isLocalOrderNumber, generateOrderNumber, generateOrderNumberForBusiness, isOfflineOrderNumber, parseOrderNumber, resetCalloutForTests, } from "./sync/order-number";
|
|
62
|
-
export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease } from "./sync/device-lease.service";
|
|
62
|
+
export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease, getLastLeaseFailure, type DeviceLeaseFailure, } from "./sync/device-lease.service";
|
|
63
63
|
export { useDeviceLease, ensureDeviceLease } from "./sync/use-device-lease";
|
|
64
64
|
export { getStartupReadiness, ensureStartupReady, REQUIRED_ORDER_KEYS, type StartupReadiness, } from "./sync/startup-readiness";
|
|
65
65
|
export { useStartupReadiness, type UseStartupReadinessOptions, type UseStartupReadinessResult, } from "./hooks/startup-readiness.hooks";
|
package/dist/index.js
CHANGED
|
@@ -61,7 +61,7 @@ export * from "./sync/command-builder";
|
|
|
61
61
|
export { commandQueueService } from "./sync/command-queue.service";
|
|
62
62
|
export { buildCommand } from "./sync/command-builder";
|
|
63
63
|
export { generateLocalOrderNumber, generateLocalCalloutNumber, isLocalOrderNumber, generateOrderNumber, generateOrderNumberForBusiness, isOfflineOrderNumber, parseOrderNumber, resetCalloutForTests, } from "./sync/order-number";
|
|
64
|
-
export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease } from "./sync/device-lease.service";
|
|
64
|
+
export { deviceLeaseService, getDeviceNumber, isOnlineOnly, ensureLease, getLastLeaseFailure, } from "./sync/device-lease.service";
|
|
65
65
|
export { useDeviceLease, ensureDeviceLease } from "./sync/use-device-lease";
|
|
66
66
|
export { getStartupReadiness, ensureStartupReady, REQUIRED_ORDER_KEYS, } from "./sync/startup-readiness";
|
|
67
67
|
export { useStartupReadiness, } from "./hooks/startup-readiness.hooks";
|
|
@@ -34,12 +34,26 @@ export interface OfflineSessionState {
|
|
|
34
34
|
keyId: string | null;
|
|
35
35
|
members: OfflineMemberCredential[];
|
|
36
36
|
}
|
|
37
|
+
interface OfflineActivationBaseArgs {
|
|
38
|
+
businessId: string;
|
|
39
|
+
appInstanceId?: string;
|
|
40
|
+
platform?: string;
|
|
41
|
+
deviceName?: string;
|
|
42
|
+
osVersion?: string;
|
|
43
|
+
appVersion?: string;
|
|
44
|
+
}
|
|
37
45
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
46
|
+
* Activate the terminal license without creating any member PIN. The backend
|
|
47
|
+
* activation also enrolls the signed-in user, but the quick-login PIN is a
|
|
48
|
+
* separate, optional local credential.
|
|
40
49
|
*/
|
|
41
|
-
export declare function
|
|
42
|
-
|
|
50
|
+
export declare function activateOfflineLicense(args: OfflineActivationBaseArgs): Promise<OfflineActivationResult>;
|
|
51
|
+
/**
|
|
52
|
+
* First-time activation plus the optional local quick-login PIN. Kept as the
|
|
53
|
+
* mobile-compatible entry point; desktop can call activateOfflineLicense()
|
|
54
|
+
* first and set the PIN later.
|
|
55
|
+
*/
|
|
56
|
+
export declare function activateOfflineDevice(args: OfflineActivationBaseArgs & {
|
|
43
57
|
member: {
|
|
44
58
|
userId: string;
|
|
45
59
|
fullName: string;
|
|
@@ -48,12 +62,23 @@ export declare function activateOfflineDevice(args: {
|
|
|
48
62
|
capabilities: string[];
|
|
49
63
|
};
|
|
50
64
|
pin: string;
|
|
51
|
-
appInstanceId?: string;
|
|
52
|
-
platform?: string;
|
|
53
|
-
deviceName?: string;
|
|
54
|
-
osVersion?: string;
|
|
55
|
-
appVersion?: string;
|
|
56
65
|
}): Promise<OfflineActivationResult>;
|
|
66
|
+
/**
|
|
67
|
+
* Set or change the local 6-digit quick-login PIN. Existing PINs must be
|
|
68
|
+
* confirmed first; changing a PIN never requires the network.
|
|
69
|
+
*/
|
|
70
|
+
export declare function setOfflineMemberPin(args: {
|
|
71
|
+
businessId: string;
|
|
72
|
+
userId: string;
|
|
73
|
+
newPin: string;
|
|
74
|
+
currentPin?: string;
|
|
75
|
+
member?: {
|
|
76
|
+
fullName: string;
|
|
77
|
+
email?: string | null;
|
|
78
|
+
role: string;
|
|
79
|
+
capabilities: string[];
|
|
80
|
+
};
|
|
81
|
+
}): Promise<OfflineMemberCredential>;
|
|
57
82
|
/** Refresh the signed license while online using the normal access token. */
|
|
58
83
|
export declare function refreshOfflineLicense(businessId: string): Promise<OfflineActivationResult>;
|
|
59
84
|
/**
|
package/dist/offline/service.js
CHANGED
|
@@ -12,7 +12,7 @@ import { getConnectivityState } from "../adapters/connectivity";
|
|
|
12
12
|
import { syncLogger } from "../utils/sync-logger";
|
|
13
13
|
import { offlineRandomBytes } from "./random";
|
|
14
14
|
import { base64UrlDecode, base64UrlEncode, canonicalDeviceChallenge, decodeLicensePayload, evaluateOfflineLicense, generateOfflineDeviceKeypair, loadAndVerifyOfflineLicense, newOfflineNonce, signDeviceChallenge, verifyOfflineLicense, } from "./license";
|
|
15
|
-
import { clearOfflineDevice, createOfflineMemberCredential, getDevicePrivateKey, getOfflineMember, getStoredLicense, getStoredLicenseKeyId, getStoredLicensePublicKey, listOfflineMembers, markOfflineMemberLogin, removeOfflineMember, saveDevicePrivateKey, saveStoredLicense, saveStoredLicenseKeyId, saveStoredLicensePublicKey, verifyOfflineMemberPin, } from "./store";
|
|
15
|
+
import { clearOfflineDevice, createOfflineMemberCredential, getDevicePrivateKey, getOfflineMember, getStoredLicense, getStoredLicenseKeyId, getStoredLicensePublicKey, listOfflineMembers, markOfflineMemberLogin, removeOfflineMember, saveDevicePrivateKey, saveStoredLicense, saveStoredLicenseKeyId, saveStoredLicensePublicKey, updateOfflineMemberPin, verifyOfflineMemberPin, } from "./store";
|
|
16
16
|
function randomBytes(length) {
|
|
17
17
|
return offlineRandomBytes(length);
|
|
18
18
|
}
|
|
@@ -29,10 +29,11 @@ async function getPrivateKeyBytes() {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* Activate the terminal license without creating any member PIN. The backend
|
|
33
|
+
* activation also enrolls the signed-in user, but the quick-login PIN is a
|
|
34
|
+
* separate, optional local credential.
|
|
34
35
|
*/
|
|
35
|
-
export async function
|
|
36
|
+
export async function activateOfflineLicense(args) {
|
|
36
37
|
if (!getAuthToken())
|
|
37
38
|
throw new Error("Sign in online before activating this device");
|
|
38
39
|
const deviceId = args.appInstanceId ?? (await getAppInstanceIdAsync());
|
|
@@ -57,6 +58,15 @@ export async function activateOfflineDevice(args) {
|
|
|
57
58
|
await saveStoredLicense(args.businessId, data.license);
|
|
58
59
|
await saveStoredLicensePublicKey(args.businessId, data.public_key);
|
|
59
60
|
await saveStoredLicenseKeyId(args.businessId, data.key_id);
|
|
61
|
+
return { ...data, payload: verified };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* First-time activation plus the optional local quick-login PIN. Kept as the
|
|
65
|
+
* mobile-compatible entry point; desktop can call activateOfflineLicense()
|
|
66
|
+
* first and set the PIN later.
|
|
67
|
+
*/
|
|
68
|
+
export async function activateOfflineDevice(args) {
|
|
69
|
+
const result = await activateOfflineLicense(args);
|
|
60
70
|
await createOfflineMemberCredential({
|
|
61
71
|
businessId: args.businessId,
|
|
62
72
|
userId: args.member.userId,
|
|
@@ -70,14 +80,51 @@ export async function activateOfflineDevice(args) {
|
|
|
70
80
|
// the intent explicit and is idempotent.
|
|
71
81
|
await enrollMemberOnDevice({
|
|
72
82
|
businessId: args.businessId,
|
|
73
|
-
deviceId,
|
|
83
|
+
deviceId: args.appInstanceId,
|
|
74
84
|
userId: args.member.userId,
|
|
75
85
|
}).catch((error) => {
|
|
76
86
|
syncLogger.warn("OfflineAuth", "Member enrollment call failed", {
|
|
77
87
|
error: String(error),
|
|
78
88
|
});
|
|
79
89
|
});
|
|
80
|
-
return
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Set or change the local 6-digit quick-login PIN. Existing PINs must be
|
|
94
|
+
* confirmed first; changing a PIN never requires the network.
|
|
95
|
+
*/
|
|
96
|
+
export async function setOfflineMemberPin(args) {
|
|
97
|
+
if (!/^\d{6}$/.test(args.newPin)) {
|
|
98
|
+
throw new Error("Offline PIN must be exactly 6 digits");
|
|
99
|
+
}
|
|
100
|
+
const existing = await getOfflineMember(args.userId, args.businessId);
|
|
101
|
+
if (existing) {
|
|
102
|
+
if (existing.pin_hash) {
|
|
103
|
+
if (!args.currentPin) {
|
|
104
|
+
throw new Error("Enter your current 6-digit PIN");
|
|
105
|
+
}
|
|
106
|
+
const valid = await verifyOfflineMemberPin(args.userId, args.currentPin, args.businessId);
|
|
107
|
+
if (!valid)
|
|
108
|
+
throw new Error("Current PIN is incorrect");
|
|
109
|
+
}
|
|
110
|
+
await updateOfflineMemberPin(args.userId, args.businessId, args.newPin);
|
|
111
|
+
const updated = await getOfflineMember(args.userId, args.businessId);
|
|
112
|
+
if (!updated)
|
|
113
|
+
throw new Error("Could not update the offline PIN");
|
|
114
|
+
return updated;
|
|
115
|
+
}
|
|
116
|
+
if (!args.member) {
|
|
117
|
+
throw new Error("Sign in online once before setting up an offline PIN");
|
|
118
|
+
}
|
|
119
|
+
return createOfflineMemberCredential({
|
|
120
|
+
businessId: args.businessId,
|
|
121
|
+
userId: args.userId,
|
|
122
|
+
fullName: args.member.fullName,
|
|
123
|
+
email: args.member.email,
|
|
124
|
+
role: args.member.role,
|
|
125
|
+
capabilities: args.member.capabilities,
|
|
126
|
+
pin: args.newPin,
|
|
127
|
+
});
|
|
81
128
|
}
|
|
82
129
|
/** Refresh the signed license while online using the normal access token. */
|
|
83
130
|
export async function refreshOfflineLicense(businessId) {
|
package/dist/offline/store.d.ts
CHANGED
|
@@ -70,6 +70,8 @@ export declare function createOfflineMemberCredential(args: {
|
|
|
70
70
|
capabilities: string[];
|
|
71
71
|
pin: string;
|
|
72
72
|
}): Promise<OfflineMemberCredential>;
|
|
73
|
+
/** Replace an existing member's PIN without changing their role/capabilities. */
|
|
74
|
+
export declare function updateOfflineMemberPin(userId: string, businessId: string, pin: string): Promise<void>;
|
|
73
75
|
export declare const OFFLINE_PIN_MAX_ATTEMPTS = 5;
|
|
74
76
|
export interface OfflinePinLockState {
|
|
75
77
|
failedAttempts: number;
|
package/dist/offline/store.js
CHANGED
|
@@ -201,6 +201,23 @@ export async function createOfflineMemberCredential(args) {
|
|
|
201
201
|
await saveOfflineMember(member);
|
|
202
202
|
return member;
|
|
203
203
|
}
|
|
204
|
+
/** Replace an existing member's PIN without changing their role/capabilities. */
|
|
205
|
+
export async function updateOfflineMemberPin(userId, businessId, pin) {
|
|
206
|
+
if (!/^\d{6}$/.test(pin)) {
|
|
207
|
+
throw new Error("Offline PIN must be exactly 6 digits");
|
|
208
|
+
}
|
|
209
|
+
const member = await getOfflineMember(userId, businessId);
|
|
210
|
+
if (!member)
|
|
211
|
+
throw new Error("This member has no offline credential on this device");
|
|
212
|
+
const salt = randomHex(32);
|
|
213
|
+
await saveOfflineMember({
|
|
214
|
+
...member,
|
|
215
|
+
pin_salt: salt,
|
|
216
|
+
pin_hash: await hashPin(pin, salt),
|
|
217
|
+
failed_attempts: 0,
|
|
218
|
+
locked_until: null,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
204
221
|
export const OFFLINE_PIN_MAX_ATTEMPTS = 5;
|
|
205
222
|
export async function getOfflinePinLockState(userId, businessId) {
|
|
206
223
|
const member = await getOfflineMember(userId, businessId);
|
|
@@ -4,6 +4,25 @@
|
|
|
4
4
|
* KV persists device_number per businessId.
|
|
5
5
|
*/
|
|
6
6
|
import { getAppInstanceIdAsync } from "../adapters/devices";
|
|
7
|
+
/**
|
|
8
|
+
* Why the last device-lease attempt failed. Surfaced by startup readiness so a
|
|
9
|
+
* plan/terminal limit is not reported as "offline data incomplete".
|
|
10
|
+
*/
|
|
11
|
+
export interface DeviceLeaseFailure {
|
|
12
|
+
code: "TERMINAL_LIMIT_REACHED" | "DEVICE_LIMIT_REACHED" | "AUTH_REQUIRED" | "OFFLINE" | "LEASE_FAILED";
|
|
13
|
+
message: string;
|
|
14
|
+
status?: number;
|
|
15
|
+
limit?: number;
|
|
16
|
+
current?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function getLastLeaseFailure(businessId: string): DeviceLeaseFailure | null;
|
|
19
|
+
/**
|
|
20
|
+
* Normalise the HTTP-client error into a user-presentable lease failure.
|
|
21
|
+
* Desktop injects its own ApiError shape; mobile/core may inject another, so
|
|
22
|
+
* inspect both the error and the backend `{ error: { code, message, details } }`
|
|
23
|
+
* envelope instead of relying on one class.
|
|
24
|
+
*/
|
|
25
|
+
export declare function classifyDeviceLeaseFailure(error: unknown): DeviceLeaseFailure;
|
|
7
26
|
export declare function getDeviceNumber(businessId: string): string | null;
|
|
8
27
|
export declare function isOnlineOnly(businessId: string): boolean;
|
|
9
28
|
export declare function ensureLease(businessId: string): Promise<string | null>;
|
|
@@ -10,6 +10,80 @@ import { getAppInstanceIdAsync } from "../adapters/devices";
|
|
|
10
10
|
import { getConnectivityState } from "../adapters/connectivity";
|
|
11
11
|
const LEASE_KEY = (businessId) => `teinc_device_number:${businessId}`;
|
|
12
12
|
const LEASE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
13
|
+
const leaseFailures = new Map();
|
|
14
|
+
export function getLastLeaseFailure(businessId) {
|
|
15
|
+
return leaseFailures.get(businessId) ?? null;
|
|
16
|
+
}
|
|
17
|
+
function rememberLeaseFailure(businessId, failure) {
|
|
18
|
+
leaseFailures.set(businessId, failure);
|
|
19
|
+
}
|
|
20
|
+
function clearLeaseFailure(businessId) {
|
|
21
|
+
leaseFailures.delete(businessId);
|
|
22
|
+
}
|
|
23
|
+
function asRecord(value) {
|
|
24
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
25
|
+
? value
|
|
26
|
+
: null;
|
|
27
|
+
}
|
|
28
|
+
function asNumber(value) {
|
|
29
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Normalise the HTTP-client error into a user-presentable lease failure.
|
|
33
|
+
* Desktop injects its own ApiError shape; mobile/core may inject another, so
|
|
34
|
+
* inspect both the error and the backend `{ error: { code, message, details } }`
|
|
35
|
+
* envelope instead of relying on one class.
|
|
36
|
+
*/
|
|
37
|
+
export function classifyDeviceLeaseFailure(error) {
|
|
38
|
+
const e = (asRecord(error) ?? {});
|
|
39
|
+
const responseData = asRecord(e.data) ?? asRecord(e.response?.data);
|
|
40
|
+
const errorBody = asRecord(responseData?.error);
|
|
41
|
+
const details = asRecord(errorBody?.details);
|
|
42
|
+
const status = asNumber(e.status);
|
|
43
|
+
const code = typeof errorBody?.code === "string"
|
|
44
|
+
? errorBody.code
|
|
45
|
+
: typeof e.code === "string"
|
|
46
|
+
? e.code
|
|
47
|
+
: undefined;
|
|
48
|
+
const message = typeof errorBody?.message === "string"
|
|
49
|
+
? errorBody.message
|
|
50
|
+
: typeof e.message === "string"
|
|
51
|
+
? e.message
|
|
52
|
+
: "";
|
|
53
|
+
const limit = asNumber(details?.limit);
|
|
54
|
+
const current = asNumber(details?.current);
|
|
55
|
+
if (status === 402 || code === "TERMINAL_LIMIT_REACHED") {
|
|
56
|
+
return {
|
|
57
|
+
code: "TERMINAL_LIMIT_REACHED",
|
|
58
|
+
message: message ||
|
|
59
|
+
"This business has reached the number of linked terminals allowed by its plan.",
|
|
60
|
+
status,
|
|
61
|
+
limit,
|
|
62
|
+
current,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (status === 429 || code === "DEVICE_LIMIT_REACHED") {
|
|
66
|
+
return {
|
|
67
|
+
code: "DEVICE_LIMIT_REACHED",
|
|
68
|
+
message: message || "This business has reached the maximum number of linked terminals.",
|
|
69
|
+
status,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (status === 401 ||
|
|
73
|
+
message.includes("TOKEN_MISSING") ||
|
|
74
|
+
message.includes("No authentication token")) {
|
|
75
|
+
return {
|
|
76
|
+
code: "AUTH_REQUIRED",
|
|
77
|
+
message: message || "Sign-in is still finishing. Retry in a moment.",
|
|
78
|
+
status,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
code: "LEASE_FAILED",
|
|
83
|
+
message: message || "The terminal could not be activated.",
|
|
84
|
+
status,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
13
87
|
export function getDeviceNumber(businessId) {
|
|
14
88
|
const v = getSync(LEASE_KEY(businessId));
|
|
15
89
|
if (v && /^(0[1-9]|[1-9][0-9])$/.test(v))
|
|
@@ -23,6 +97,7 @@ export async function ensureLease(businessId) {
|
|
|
23
97
|
// Offline-first: keep leased DD even offline — if online check/lease fails, return cached
|
|
24
98
|
const existingSync = getDeviceNumber(businessId);
|
|
25
99
|
if (existingSync) {
|
|
100
|
+
clearLeaseFailure(businessId);
|
|
26
101
|
// Opportunistically refresh touch if online, but never discard cached on failure
|
|
27
102
|
if (getConnectivityState().isOnline) {
|
|
28
103
|
try {
|
|
@@ -48,6 +123,7 @@ export async function ensureLease(businessId) {
|
|
|
48
123
|
setSync(LEASE_KEY(businessId), asyncCached);
|
|
49
124
|
}
|
|
50
125
|
catch { }
|
|
126
|
+
clearLeaseFailure(businessId);
|
|
51
127
|
return asyncCached;
|
|
52
128
|
}
|
|
53
129
|
}
|
|
@@ -55,13 +131,18 @@ export async function ensureLease(businessId) {
|
|
|
55
131
|
// No cached DD — must lease online. If offline, keep null (caller blocks POS with clear message)
|
|
56
132
|
if (!getConnectivityState().isOnline) {
|
|
57
133
|
syncLogger.debug("DeviceLease", "Offline and no cached DD — need online once", { businessId });
|
|
134
|
+
rememberLeaseFailure(businessId, {
|
|
135
|
+
code: "OFFLINE",
|
|
136
|
+
message: "Connect to the internet once so this terminal can be activated.",
|
|
137
|
+
});
|
|
58
138
|
return null;
|
|
59
139
|
}
|
|
60
140
|
try {
|
|
61
141
|
const http = getHttpClient();
|
|
62
142
|
const deviceUuid = await getAppInstanceIdAsync();
|
|
63
143
|
const res = await http.post(`/businesses/${businessId}/devices/lease`, { device_uuid: deviceUuid, app_instance_id: deviceUuid });
|
|
64
|
-
const
|
|
144
|
+
const payload = res.data;
|
|
145
|
+
const data = payload.data ?? payload;
|
|
65
146
|
const dn = data.device_number;
|
|
66
147
|
if (dn && /^(0[1-9]|[1-9][0-9])$/.test(dn)) {
|
|
67
148
|
setSync(LEASE_KEY(businessId), dn);
|
|
@@ -70,51 +151,57 @@ export async function ensureLease(businessId) {
|
|
|
70
151
|
await getItem(LEASE_KEY(businessId));
|
|
71
152
|
}
|
|
72
153
|
catch { }
|
|
154
|
+
clearLeaseFailure(businessId);
|
|
73
155
|
syncLogger.info("DeviceLease", "Leased device number", { businessId, device_number: dn });
|
|
74
156
|
return dn;
|
|
75
157
|
}
|
|
158
|
+
rememberLeaseFailure(businessId, {
|
|
159
|
+
code: "LEASE_FAILED",
|
|
160
|
+
message: "The server did not return a terminal number. Try again.",
|
|
161
|
+
});
|
|
76
162
|
}
|
|
77
163
|
catch (e) {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
catch { }
|
|
90
|
-
const again = getDeviceNumber(businessId);
|
|
91
|
-
if (again)
|
|
92
|
-
return again;
|
|
93
|
-
return null;
|
|
164
|
+
const failure = classifyDeviceLeaseFailure(e);
|
|
165
|
+
rememberLeaseFailure(businessId, failure);
|
|
166
|
+
if (failure.code === "TERMINAL_LIMIT_REACHED" || failure.code === "DEVICE_LIMIT_REACHED") {
|
|
167
|
+
syncLogger.warn("DeviceLease", "Terminal limit reached — activation needs a free slot", {
|
|
168
|
+
businessId,
|
|
169
|
+
code: failure.code,
|
|
170
|
+
limit: failure.limit,
|
|
171
|
+
current: failure.current,
|
|
172
|
+
});
|
|
173
|
+
return cachedLeaseFallback(businessId);
|
|
94
174
|
}
|
|
95
|
-
if (
|
|
96
|
-
syncLogger.
|
|
97
|
-
return
|
|
175
|
+
if (failure.code === "AUTH_REQUIRED") {
|
|
176
|
+
syncLogger.debug("DeviceLease", "No auth yet — lease deferred until login", { businessId });
|
|
177
|
+
return cachedLeaseFallback(businessId);
|
|
98
178
|
}
|
|
99
|
-
// Network/offline failure while leasing — keep any cached that may exist
|
|
179
|
+
// Network/offline failure while leasing — keep any cached that may exist.
|
|
100
180
|
syncLogger.warn("DeviceLease", "Lease failed — keep cached if any", {
|
|
101
181
|
businessId,
|
|
102
182
|
error: String(e),
|
|
103
183
|
});
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
184
|
+
return cachedLeaseFallback(businessId);
|
|
185
|
+
}
|
|
186
|
+
return cachedLeaseFallback(businessId);
|
|
187
|
+
}
|
|
188
|
+
async function cachedLeaseFallback(businessId) {
|
|
189
|
+
try {
|
|
190
|
+
const fallback = await getItem(LEASE_KEY(businessId));
|
|
191
|
+
if (fallback && /^(0[1-9]|[1-9][0-9])$/.test(fallback)) {
|
|
192
|
+
try {
|
|
193
|
+
setSync(LEASE_KEY(businessId), fallback);
|
|
112
194
|
}
|
|
195
|
+
catch { }
|
|
196
|
+
clearLeaseFailure(businessId);
|
|
197
|
+
return fallback;
|
|
113
198
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
199
|
+
}
|
|
200
|
+
catch { }
|
|
201
|
+
const again = getDeviceNumber(businessId);
|
|
202
|
+
if (again) {
|
|
203
|
+
clearLeaseFailure(businessId);
|
|
204
|
+
return again;
|
|
118
205
|
}
|
|
119
206
|
return null;
|
|
120
207
|
}
|
|
@@ -141,6 +228,7 @@ export async function refreshLease(businessId) {
|
|
|
141
228
|
export function clearLeaseForTests(businessId) {
|
|
142
229
|
removeItemSync(LEASE_KEY(businessId));
|
|
143
230
|
removeItemSync(`${LEASE_KEY(businessId)}:leased_at`);
|
|
231
|
+
clearLeaseFailure(businessId);
|
|
144
232
|
}
|
|
145
233
|
export const deviceLeaseService = {
|
|
146
234
|
getDeviceNumber,
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Seller-only. Fleet/rider surfaces are fully online and never gate here.
|
|
13
13
|
*/
|
|
14
|
+
import { type DeviceLeaseFailure } from "./device-lease.service";
|
|
15
|
+
import { type ConnectivityState } from "../adapters/connectivity";
|
|
14
16
|
import type { ReferenceDataKey } from "../reference/types";
|
|
15
17
|
/**
|
|
16
18
|
* Reference keys required before the app presents itself as ready to take
|
|
@@ -31,6 +33,13 @@ export interface StartupReadiness {
|
|
|
31
33
|
* by itself block readiness.
|
|
32
34
|
*/
|
|
33
35
|
entitlementUnverified: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Why the terminal lease failed, when `needsLease` is true. Lets the UI show
|
|
38
|
+
* "terminal limit reached" instead of the generic offline-data message.
|
|
39
|
+
*/
|
|
40
|
+
leaseError: DeviceLeaseFailure | null;
|
|
41
|
+
/** Connectivity snapshot used for this check. */
|
|
42
|
+
network: ConnectivityState;
|
|
34
43
|
}
|
|
35
44
|
/** Evaluate the local cache only (no network). */
|
|
36
45
|
export declare function getStartupReadiness(businessId: string): Promise<StartupReadiness>;
|
|
@@ -15,7 +15,7 @@ import { openSyncDatabase } from "../db/connection";
|
|
|
15
15
|
import { getReferenceData } from "../reference/operations";
|
|
16
16
|
import { referenceSyncService } from "../reference/service";
|
|
17
17
|
import { evaluateBusinessEntitlements } from "../reference/entitlement-guard";
|
|
18
|
-
import { ensureLease } from "./device-lease.service";
|
|
18
|
+
import { ensureLease, getLastLeaseFailure, } from "./device-lease.service";
|
|
19
19
|
import { getConnectivityState } from "../adapters/connectivity";
|
|
20
20
|
import { syncLogger } from "../utils/sync-logger";
|
|
21
21
|
/**
|
|
@@ -52,6 +52,7 @@ export async function getStartupReadiness(businessId) {
|
|
|
52
52
|
syncLogger.warn("StartupReadiness", "lease check failed", { err: String(e) });
|
|
53
53
|
}
|
|
54
54
|
const needsLease = !deviceNumber;
|
|
55
|
+
const leaseError = needsLease ? getLastLeaseFailure(businessId) : null;
|
|
55
56
|
let entitlementUnverified = missingKeys.includes("subscription:entitlements");
|
|
56
57
|
if (!entitlementUnverified) {
|
|
57
58
|
try {
|
|
@@ -67,6 +68,8 @@ export async function getStartupReadiness(businessId) {
|
|
|
67
68
|
missingKeys,
|
|
68
69
|
needsLease,
|
|
69
70
|
entitlementUnverified,
|
|
71
|
+
leaseError,
|
|
72
|
+
network: getConnectivityState(),
|
|
70
73
|
};
|
|
71
74
|
}
|
|
72
75
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teincfood/core",
|
|
3
|
-
"version": "0.10.
|
|
4
|
-
"description": "TeincFood shared offline-first core
|
|
3
|
+
"version": "0.10.20",
|
|
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",
|
|
7
7
|
"types": "dist/index.d.ts",
|