@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.
@@ -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>;
@@ -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 };
@@ -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;
@@ -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>;
@@ -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 (!/^\d{6}$/.test(pin))
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
- return diff === 0;
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
- const asset = await requestAssetFromPeer(businessId, url);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
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",