@teincfood/core 0.10.4 → 0.10.6
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/adapters/devices.d.ts +9 -0
- package/dist/adapters/devices.js +15 -0
- package/dist/hooks/members.hooks.js +11 -2
- package/dist/offline/service.d.ts +13 -0
- package/dist/offline/service.js +31 -2
- package/dist/offline/store.d.ts +12 -0
- package/dist/offline/store.js +53 -3
- package/dist/reference/service.js +17 -4
- package/package.json +1 -1
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
* Devices service — stable per-device UUID for DD lease.
|
|
3
3
|
* Persists via KV sync so DD stays same for life of device (until wipe).
|
|
4
4
|
*/
|
|
5
|
+
export interface DeviceMetadata {
|
|
6
|
+
deviceName?: string | null;
|
|
7
|
+
platform?: string | null;
|
|
8
|
+
osVersion?: string | null;
|
|
9
|
+
appVersion?: string | null;
|
|
10
|
+
}
|
|
11
|
+
/** Host apps inject OS/device details so licenses and admin dashboards can identify terminals. */
|
|
12
|
+
export declare function setDeviceMetadataProvider(provider: (() => Promise<DeviceMetadata> | DeviceMetadata) | null): void;
|
|
13
|
+
export declare function getDeviceMetadata(): Promise<DeviceMetadata>;
|
|
5
14
|
export declare function setAppInstanceId(id: string): void;
|
|
6
15
|
export declare function getAppInstanceId(): string;
|
|
7
16
|
export declare function getAppInstanceIdAsync(): Promise<string>;
|
package/dist/adapters/devices.js
CHANGED
|
@@ -6,6 +6,21 @@ import { getItemSync, setItemSync, getItem, setItem } from "./kv";
|
|
|
6
6
|
import { generateUUID } from "../utils/uuid";
|
|
7
7
|
let appInstanceId = null;
|
|
8
8
|
const APP_ID_KEY = "teinc_app_instance_id";
|
|
9
|
+
let deviceMetadataProvider = null;
|
|
10
|
+
/** Host apps inject OS/device details so licenses and admin dashboards can identify terminals. */
|
|
11
|
+
export function setDeviceMetadataProvider(provider) {
|
|
12
|
+
deviceMetadataProvider = provider;
|
|
13
|
+
}
|
|
14
|
+
export async function getDeviceMetadata() {
|
|
15
|
+
if (!deviceMetadataProvider)
|
|
16
|
+
return {};
|
|
17
|
+
try {
|
|
18
|
+
return await deviceMetadataProvider();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
9
24
|
// For Tauri desktop, try to restore from localStorage synchronously at import to avoid async race generating a new UUID on every cargo tauri dev restart
|
|
10
25
|
try {
|
|
11
26
|
if (typeof localStorage !== "undefined") {
|
|
@@ -10,6 +10,7 @@ import { getAuthToken, subscribeAuth } from "../adapters/auth";
|
|
|
10
10
|
import { businessService, rolesService, fleetService } from "../adapters/services";
|
|
11
11
|
import { assertOnlineFor } from "../sync/online-gates";
|
|
12
12
|
import { runOfflineCapableMutation } from "../sync/offline-capable";
|
|
13
|
+
import { removeOfflineMember, updateOfflineMemberRole } from "../offline/store";
|
|
13
14
|
import { validateInviteInput } from "../validation/offline";
|
|
14
15
|
import { notifySuccess, notifyError } from "../adapters/notify";
|
|
15
16
|
import { extractApiErrorMessage } from "../utils/error";
|
|
@@ -106,7 +107,12 @@ export function useRemoveMemberMutation() {
|
|
|
106
107
|
payload: { user_id: userId },
|
|
107
108
|
});
|
|
108
109
|
},
|
|
109
|
-
onSuccess: (result) => {
|
|
110
|
+
onSuccess: async (result, userId) => {
|
|
111
|
+
// Revoke local offline access immediately, even when the server command
|
|
112
|
+
// is queued. Otherwise a removed member can still unlock this terminal.
|
|
113
|
+
if (businessId) {
|
|
114
|
+
await removeOfflineMember(userId, businessId).catch(() => undefined);
|
|
115
|
+
}
|
|
110
116
|
notifySuccess(isQueued(result) ? "Member removal queued" : "Member removed");
|
|
111
117
|
queryClient.invalidateQueries({ queryKey: MEMBER_KEYS.list(businessId ?? "") });
|
|
112
118
|
if (businessId)
|
|
@@ -133,7 +139,10 @@ export function useUpdateMemberRoleMutation() {
|
|
|
133
139
|
payload,
|
|
134
140
|
});
|
|
135
141
|
},
|
|
136
|
-
onSuccess: (result) => {
|
|
142
|
+
onSuccess: async (result, variables) => {
|
|
143
|
+
if (businessId && variables.role) {
|
|
144
|
+
await updateOfflineMemberRole(variables.userId, businessId, variables.role).catch(() => undefined);
|
|
145
|
+
}
|
|
137
146
|
notifySuccess(isQueued(result) ? "Role change queued" : "Member role updated");
|
|
138
147
|
queryClient.invalidateQueries({ queryKey: MEMBER_KEYS.list(businessId ?? "") });
|
|
139
148
|
if (businessId)
|
|
@@ -50,6 +50,9 @@ export declare function activateOfflineDevice(args: {
|
|
|
50
50
|
pin: string;
|
|
51
51
|
appInstanceId?: string;
|
|
52
52
|
platform?: string;
|
|
53
|
+
deviceName?: string;
|
|
54
|
+
osVersion?: string;
|
|
55
|
+
appVersion?: string;
|
|
53
56
|
}): Promise<OfflineActivationResult>;
|
|
54
57
|
/** Refresh the signed license while online using the normal access token. */
|
|
55
58
|
export declare function refreshOfflineLicense(businessId: string): Promise<OfflineActivationResult>;
|
|
@@ -78,6 +81,16 @@ export declare function revokeMemberOnDevice(args: {
|
|
|
78
81
|
}): Promise<void>;
|
|
79
82
|
/** Owner action: revoke the terminal and wipe all local offline credentials. */
|
|
80
83
|
export declare function removeOfflineDevice(businessId: string, reason?: string): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* Best-effort presence heartbeat. Updates the admin dashboard's last-seen and
|
|
86
|
+
* online state; never throws into app startup or sync loops.
|
|
87
|
+
*/
|
|
88
|
+
export declare function sendOfflineDeviceHeartbeat(businessId: string, overrides?: {
|
|
89
|
+
deviceName?: string;
|
|
90
|
+
platform?: string;
|
|
91
|
+
osVersion?: string;
|
|
92
|
+
appVersion?: string;
|
|
93
|
+
}): Promise<void>;
|
|
81
94
|
export declare function getStoredOfflineLicensePayload(businessId?: string): Promise<OfflineLicensePayload | null>;
|
|
82
95
|
export declare function evaluateStoredOfflineLicense(businessId?: string): Promise<OfflineLicenseEvaluation>;
|
|
83
96
|
export { evaluateOfflineLicense };
|
package/dist/offline/service.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* verify the signed license, unlock enrolled members locally, and only needs
|
|
6
6
|
* the network to exchange the device challenge for fresh session tokens.
|
|
7
7
|
*/
|
|
8
|
-
import { getAppInstanceIdAsync } from "../adapters/devices";
|
|
8
|
+
import { getAppInstanceIdAsync, getDeviceMetadata } from "../adapters/devices";
|
|
9
9
|
import { getHttpClient } from "../adapters/http";
|
|
10
10
|
import { getAuthToken } from "../adapters/auth";
|
|
11
|
+
import { getConnectivityState } from "../adapters/connectivity";
|
|
11
12
|
import { syncLogger } from "../utils/sync-logger";
|
|
12
13
|
import { offlineRandomBytes } from "./random";
|
|
13
14
|
import { base64UrlDecode, base64UrlEncode, canonicalDeviceChallenge, decodeLicensePayload, evaluateOfflineLicense, generateOfflineDeviceKeypair, loadAndVerifyOfflineLicense, newOfflineNonce, signDeviceChallenge, verifyOfflineLicense, } from "./license";
|
|
@@ -35,11 +36,15 @@ export async function activateOfflineDevice(args) {
|
|
|
35
36
|
if (!getAuthToken())
|
|
36
37
|
throw new Error("Sign in online before activating this device");
|
|
37
38
|
const deviceId = args.appInstanceId ?? (await getAppInstanceIdAsync());
|
|
39
|
+
const metadata = await getDeviceMetadata();
|
|
38
40
|
const keypair = await generateOfflineDeviceKeypair(randomBytes);
|
|
39
41
|
const response = await getHttpClient().post(`/businesses/${args.businessId}/devices/${encodeURIComponent(deviceId)}/activate`, {
|
|
40
42
|
device_public_key: keypair.publicKeyB64,
|
|
41
43
|
app_instance_id: deviceId,
|
|
42
|
-
platform: args.platform ?? "unknown",
|
|
44
|
+
platform: args.platform ?? metadata.platform ?? "unknown",
|
|
45
|
+
device_name: args.deviceName ?? metadata.deviceName ?? undefined,
|
|
46
|
+
os_version: args.osVersion ?? metadata.osVersion ?? undefined,
|
|
47
|
+
app_version: args.appVersion ?? metadata.appVersion ?? undefined,
|
|
43
48
|
});
|
|
44
49
|
const data = response.data.data;
|
|
45
50
|
if (!data?.license || !data.public_key) {
|
|
@@ -191,6 +196,30 @@ export async function removeOfflineDevice(businessId, reason) {
|
|
|
191
196
|
}
|
|
192
197
|
await clearOfflineDevice(businessId);
|
|
193
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Best-effort presence heartbeat. Updates the admin dashboard's last-seen and
|
|
201
|
+
* online state; never throws into app startup or sync loops.
|
|
202
|
+
*/
|
|
203
|
+
export async function sendOfflineDeviceHeartbeat(businessId, overrides = {}) {
|
|
204
|
+
if (!getAuthToken())
|
|
205
|
+
return;
|
|
206
|
+
const connectivity = getConnectivityState();
|
|
207
|
+
if (!connectivity.isOnline || connectivity.isCloudReachable === "unreachable")
|
|
208
|
+
return;
|
|
209
|
+
try {
|
|
210
|
+
const deviceId = await getAppInstanceIdAsync();
|
|
211
|
+
const metadata = await getDeviceMetadata();
|
|
212
|
+
await getHttpClient().patch(`/businesses/${businessId}/devices/${encodeURIComponent(deviceId)}/heartbeat`, {
|
|
213
|
+
device_name: overrides.deviceName ?? metadata.deviceName ?? undefined,
|
|
214
|
+
platform: overrides.platform ?? metadata.platform ?? undefined,
|
|
215
|
+
os_version: overrides.osVersion ?? metadata.osVersion ?? undefined,
|
|
216
|
+
app_version: overrides.appVersion ?? metadata.appVersion ?? undefined,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
syncLogger.debug("OfflineAuth", "Device heartbeat failed", { error: String(error) });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
194
223
|
export async function getStoredOfflineLicensePayload(businessId) {
|
|
195
224
|
const token = await getStoredLicense(businessId);
|
|
196
225
|
return token ? decodeLicensePayload(token) : null;
|
package/dist/offline/store.d.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface OfflineMemberCredential {
|
|
|
52
52
|
pin_hash: string;
|
|
53
53
|
enrolled_at: string;
|
|
54
54
|
last_login_at?: string | null;
|
|
55
|
+
failed_attempts?: number;
|
|
56
|
+
locked_until?: string | null;
|
|
55
57
|
}
|
|
56
58
|
export declare function listOfflineMembers(businessId?: string): Promise<OfflineMemberCredential[]>;
|
|
57
59
|
export declare function getOfflineMember(userId: string, businessId?: string): Promise<OfflineMemberCredential | null>;
|
|
@@ -68,4 +70,14 @@ export declare function createOfflineMemberCredential(args: {
|
|
|
68
70
|
capabilities: string[];
|
|
69
71
|
pin: string;
|
|
70
72
|
}): Promise<OfflineMemberCredential>;
|
|
73
|
+
export declare const OFFLINE_PIN_MAX_ATTEMPTS = 5;
|
|
74
|
+
export interface OfflinePinLockState {
|
|
75
|
+
failedAttempts: number;
|
|
76
|
+
lockedUntil: string | null;
|
|
77
|
+
locked: boolean;
|
|
78
|
+
remainingAttempts: number;
|
|
79
|
+
}
|
|
80
|
+
export declare function getOfflinePinLockState(userId: string, businessId?: string): Promise<OfflinePinLockState>;
|
|
71
81
|
export declare function verifyOfflineMemberPin(userId: string, pin: string, businessId?: string): Promise<boolean>;
|
|
82
|
+
/** Apply an offline role change to the local credential immediately. */
|
|
83
|
+
export declare function updateOfflineMemberRole(userId: string, businessId: string, role: string, capabilities?: string[]): Promise<void>;
|
package/dist/offline/store.js
CHANGED
|
@@ -201,18 +201,68 @@ export async function createOfflineMemberCredential(args) {
|
|
|
201
201
|
await saveOfflineMember(member);
|
|
202
202
|
return member;
|
|
203
203
|
}
|
|
204
|
+
export const OFFLINE_PIN_MAX_ATTEMPTS = 5;
|
|
205
|
+
export async function getOfflinePinLockState(userId, businessId) {
|
|
206
|
+
const member = await getOfflineMember(userId, businessId);
|
|
207
|
+
const failedAttempts = member?.failed_attempts ?? 0;
|
|
208
|
+
const lockedUntil = member?.locked_until ?? null;
|
|
209
|
+
const locked = Boolean(lockedUntil && new Date(lockedUntil).getTime() > Date.now());
|
|
210
|
+
return {
|
|
211
|
+
failedAttempts,
|
|
212
|
+
lockedUntil,
|
|
213
|
+
locked,
|
|
214
|
+
remainingAttempts: Math.max(OFFLINE_PIN_MAX_ATTEMPTS - failedAttempts, 0),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
204
217
|
export async function verifyOfflineMemberPin(userId, pin, businessId) {
|
|
205
218
|
const member = await getOfflineMember(userId, businessId);
|
|
206
219
|
if (!member)
|
|
207
220
|
return false;
|
|
208
|
-
if (
|
|
221
|
+
if (member.locked_until && new Date(member.locked_until).getTime() > Date.now()) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
if (!/^\d{6}$/.test(pin)) {
|
|
225
|
+
await recordFailedPinAttempt(member);
|
|
209
226
|
return false;
|
|
227
|
+
}
|
|
210
228
|
const candidate = await hashPin(pin, member.pin_salt);
|
|
211
|
-
if (candidate.length !== member.pin_hash.length)
|
|
229
|
+
if (candidate.length !== member.pin_hash.length) {
|
|
230
|
+
await recordFailedPinAttempt(member);
|
|
212
231
|
return false;
|
|
232
|
+
}
|
|
213
233
|
let diff = 0;
|
|
214
234
|
for (let i = 0; i < candidate.length; i++) {
|
|
215
235
|
diff |= candidate.charCodeAt(i) ^ member.pin_hash.charCodeAt(i);
|
|
216
236
|
}
|
|
217
|
-
|
|
237
|
+
if (diff === 0) {
|
|
238
|
+
if ((member.failed_attempts ?? 0) > 0 || member.locked_until) {
|
|
239
|
+
await saveOfflineMember({ ...member, failed_attempts: 0, locked_until: null });
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
await recordFailedPinAttempt(member);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
async function recordFailedPinAttempt(member) {
|
|
247
|
+
const attempts = (member.failed_attempts ?? 0) + 1;
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
const lockMs = attempts >= OFFLINE_PIN_MAX_ATTEMPTS
|
|
250
|
+
? Math.min(15 * 60000, 60000 * 2 ** (attempts - OFFLINE_PIN_MAX_ATTEMPTS))
|
|
251
|
+
: 0;
|
|
252
|
+
await saveOfflineMember({
|
|
253
|
+
...member,
|
|
254
|
+
failed_attempts: attempts,
|
|
255
|
+
locked_until: lockMs > 0 ? new Date(now + lockMs).toISOString() : null,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** Apply an offline role change to the local credential immediately. */
|
|
259
|
+
export async function updateOfflineMemberRole(userId, businessId, role, capabilities) {
|
|
260
|
+
const member = await getOfflineMember(userId, businessId);
|
|
261
|
+
if (!member)
|
|
262
|
+
return;
|
|
263
|
+
await saveOfflineMember({
|
|
264
|
+
...member,
|
|
265
|
+
role,
|
|
266
|
+
...(capabilities ? { capabilities } : {}),
|
|
267
|
+
});
|
|
218
268
|
}
|
|
@@ -469,16 +469,29 @@ class ReferenceSyncService {
|
|
|
469
469
|
// have the bytes in its durable image cache. Fetch only missing keys.
|
|
470
470
|
const peerConnectivity = getConnectivityState();
|
|
471
471
|
if (!peerConnectivity.isOnline || peerConnectivity.isCloudReachable === "unreachable") {
|
|
472
|
+
const missing = [];
|
|
472
473
|
for (const url of urls) {
|
|
473
474
|
if (!url || !url.startsWith("http"))
|
|
474
475
|
continue;
|
|
475
476
|
if (await hasCachedImage(url))
|
|
476
477
|
continue;
|
|
477
|
-
|
|
478
|
-
if (asset?.base64) {
|
|
479
|
-
await cacheImageFromBase64(url, asset.base64);
|
|
480
|
-
}
|
|
478
|
+
missing.push(url);
|
|
481
479
|
}
|
|
480
|
+
// A menu can reference dozens of images. Keep peer transfers bounded so
|
|
481
|
+
// a dark-LAN catch-up does not serialize 15s timeouts.
|
|
482
|
+
const queue = [...missing];
|
|
483
|
+
const worker = async () => {
|
|
484
|
+
for (;;) {
|
|
485
|
+
const url = queue.shift();
|
|
486
|
+
if (!url)
|
|
487
|
+
return;
|
|
488
|
+
const asset = await requestAssetFromPeer(businessId, url);
|
|
489
|
+
if (asset?.base64) {
|
|
490
|
+
await cacheImageFromBase64(url, asset.base64);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
await Promise.all(Array.from({ length: Math.min(3, queue.length) }, worker));
|
|
482
495
|
}
|
|
483
496
|
await pruneImageCache(urls);
|
|
484
497
|
}
|
package/package.json
CHANGED