@teincfood/core 0.10.3 → 0.10.5

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") {
@@ -21,6 +21,7 @@ export interface FileSystemAdapter {
21
21
  encoding?: string;
22
22
  }): Promise<string>;
23
23
  readAsBase64Async?(path: string): Promise<string>;
24
+ writeAsBase64Async?(path: string, base64: string): Promise<void>;
24
25
  }
25
26
  export declare function setFileSystemAdapter(a: FileSystemAdapter | null): void;
26
27
  export declare function getFileSystemAdapter(): FileSystemAdapter | null;
@@ -7,8 +7,12 @@ export declare const imageCacheService: {
7
7
  skipIfCached?: boolean;
8
8
  }): Promise<string[]>;
9
9
  pruneImageCache(urls: (string | null | undefined)[]): Promise<void>;
10
+ hasCachedImage?(url: string | null | undefined): Promise<boolean>;
11
+ cacheImageFromBase64?(url: string | null | undefined, base64: string | null | undefined): Promise<boolean>;
10
12
  };
11
13
  export declare function cacheImageBatch(urls: (string | null | undefined)[], opts?: {
12
14
  skipIfCached?: boolean;
13
15
  }): Promise<string[]>;
14
16
  export declare function pruneImageCache(urls: (string | null | undefined)[]): Promise<void>;
17
+ export declare function hasCachedImage(url: string | null | undefined): Promise<boolean>;
18
+ export declare function cacheImageFromBase64(url: string | null | undefined, base64: string | null | undefined): Promise<boolean>;
@@ -1,3 +1,4 @@
1
+ import { hasCachedImage as hasCachedImageWithFileSystem, cacheImageFromBase64 as cacheImageFromBase64WithFileSystem, } from "../image-cache/service";
1
2
  /**
2
3
  * Image cache stub — no-op in core library.
3
4
  * Host apps with file-system access inject real impl via imageCacheService.
@@ -14,3 +15,14 @@ export async function cacheImageBatch(urls, opts) {
14
15
  export async function pruneImageCache(urls) {
15
16
  return imageCacheService.pruneImageCache(urls);
16
17
  }
18
+ export async function hasCachedImage(url) {
19
+ if (imageCacheService.hasCachedImage)
20
+ return imageCacheService.hasCachedImage(url);
21
+ return hasCachedImageWithFileSystem(url);
22
+ }
23
+ export async function cacheImageFromBase64(url, base64) {
24
+ if (imageCacheService.cacheImageFromBase64) {
25
+ return imageCacheService.cacheImageFromBase64(url, base64);
26
+ }
27
+ return cacheImageFromBase64WithFileSystem(url, base64);
28
+ }
@@ -51,6 +51,7 @@ export declare const menuService: {
51
51
  export declare const businessService: {
52
52
  fetchBusinessMembers: (_businessId: string) => Promise<unknown>;
53
53
  inviteBusinessMember: (_businessId: string, _data: Record<string, unknown>) => Promise<unknown>;
54
+ updateBusinessMember: (_businessId: string, _userId: string, _data: Record<string, unknown>) => Promise<unknown>;
54
55
  removeBusinessMember: (_businessId: string, _userId: string) => Promise<unknown>;
55
56
  fetchBusinessMemberInvitations: (_businessId: string) => Promise<unknown>;
56
57
  revokeBusinessMemberInvitation: (_businessId: string, _invitationId: string) => Promise<unknown>;
@@ -92,6 +92,9 @@ export const businessService = {
92
92
  inviteBusinessMember: async (_businessId, _data) => {
93
93
  notImpl("businessService.inviteBusinessMember");
94
94
  },
95
+ updateBusinessMember: async (_businessId, _userId, _data) => {
96
+ notImpl("businessService.updateBusinessMember");
97
+ },
95
98
  removeBusinessMember: async (_businessId, _userId) => {
96
99
  notImpl("businessService.removeBusinessMember");
97
100
  },
@@ -27,6 +27,11 @@ export declare function useInviteMemberMutation(): import("@tanstack/react-query
27
27
  role?: string;
28
28
  }, unknown>;
29
29
  export declare function useRemoveMemberMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, string, unknown>;
30
+ export declare function useUpdateMemberRoleMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, {
31
+ userId: string;
32
+ role?: string;
33
+ status?: string;
34
+ }, unknown>;
30
35
  export declare function useBusinessMemberInvitationsQuery(): import("@tanstack/react-query").UseQueryResult<unknown, Error>;
31
36
  export declare function useRevokeMemberInvitationMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, string, unknown>;
32
37
  export declare function useDeleteMemberInvitationMutation(): import("@tanstack/react-query").UseMutationResult<unknown, Error, string, unknown>;
@@ -9,6 +9,8 @@ import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
9
9
  import { getAuthToken, subscribeAuth } from "../adapters/auth";
10
10
  import { businessService, rolesService, fleetService } from "../adapters/services";
11
11
  import { assertOnlineFor } from "../sync/online-gates";
12
+ import { runOfflineCapableMutation } from "../sync/offline-capable";
13
+ import { removeOfflineMember, updateOfflineMemberRole } from "../offline/store";
12
14
  import { validateInviteInput } from "../validation/offline";
13
15
  import { notifySuccess, notifyError } from "../adapters/notify";
14
16
  import { extractApiErrorMessage } from "../utils/error";
@@ -24,6 +26,9 @@ function useIsAuthenticated() {
24
26
  return !!token;
25
27
  }
26
28
  const OFFLINE_FIRST = { retry: false, networkMode: "offlineFirst" };
29
+ function isQueued(result) {
30
+ return Boolean(result && typeof result === "object" && result.status === "pending");
31
+ }
27
32
  // ─── Keys ───
28
33
  export const MEMBER_KEYS = {
29
34
  all: ["business-members"],
@@ -64,12 +69,20 @@ export function useInviteMemberMutation() {
64
69
  const businessId = useBusinessId();
65
70
  return useMutation({
66
71
  mutationFn: (data) => {
67
- assertOnlineFor("member:invite");
72
+ if (!businessId)
73
+ throw new Error("No active business");
68
74
  validateInviteInput({ email: data.email, phone: data.phone });
69
- return businessService.inviteBusinessMember(businessId, data);
70
- },
71
- onSuccess: () => {
72
- notifySuccess("Invitation sent!");
75
+ return runOfflineCapableMutation({
76
+ run: () => businessService.inviteBusinessMember(businessId, data),
77
+ commandType: "member:invite",
78
+ businessId,
79
+ payload: data,
80
+ });
81
+ },
82
+ onSuccess: (result) => {
83
+ notifySuccess(isQueued(result)
84
+ ? "Invitation queued — it will send when this device reconnects."
85
+ : "Invitation sent!");
73
86
  queryClient.invalidateQueries({ queryKey: MEMBER_KEYS.list(businessId ?? "") });
74
87
  if (businessId)
75
88
  emitReferenceChanged(businessId, ["members"]);
@@ -84,11 +97,53 @@ export function useRemoveMemberMutation() {
84
97
  const businessId = useBusinessId();
85
98
  return useMutation({
86
99
  mutationFn: (userId) => {
87
- assertOnlineFor("member:manage");
88
- return businessService.removeBusinessMember(businessId, userId);
100
+ if (!businessId)
101
+ throw new Error("No active business");
102
+ return runOfflineCapableMutation({
103
+ run: () => businessService.removeBusinessMember(businessId, userId),
104
+ commandType: "member:remove",
105
+ businessId,
106
+ entityId: userId,
107
+ payload: { user_id: userId },
108
+ });
109
+ },
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
+ }
116
+ notifySuccess(isQueued(result) ? "Member removal queued" : "Member removed");
117
+ queryClient.invalidateQueries({ queryKey: MEMBER_KEYS.list(businessId ?? "") });
118
+ if (businessId)
119
+ emitReferenceChanged(businessId, ["members"]);
89
120
  },
90
- onSuccess: () => {
91
- notifySuccess("Member removed");
121
+ onError: (error) => {
122
+ notifyError(extractApiErrorMessage(error));
123
+ },
124
+ });
125
+ }
126
+ export function useUpdateMemberRoleMutation() {
127
+ const queryClient = useQueryClient();
128
+ const businessId = useBusinessId();
129
+ return useMutation({
130
+ mutationFn: ({ userId, role, status }) => {
131
+ if (!businessId)
132
+ throw new Error("No active business");
133
+ const payload = { ...(role ? { role } : {}), ...(status ? { status } : {}) };
134
+ return runOfflineCapableMutation({
135
+ run: () => businessService.updateBusinessMember(businessId, userId, payload),
136
+ commandType: "member:update",
137
+ businessId,
138
+ entityId: userId,
139
+ payload,
140
+ });
141
+ },
142
+ onSuccess: async (result, variables) => {
143
+ if (businessId && variables.role) {
144
+ await updateOfflineMemberRole(variables.userId, businessId, variables.role).catch(() => undefined);
145
+ }
146
+ notifySuccess(isQueued(result) ? "Role change queued" : "Member role updated");
92
147
  queryClient.invalidateQueries({ queryKey: MEMBER_KEYS.list(businessId ?? "") });
93
148
  if (businessId)
94
149
  emitReferenceChanged(businessId, ["members"]);
@@ -46,6 +46,10 @@ export declare function cacheRemoteImage(url: string | null | undefined, opts?:
46
46
  skipIfCached?: boolean;
47
47
  removeOnFailure?: boolean;
48
48
  }): Promise<ImageCacheEntry | null>;
49
+ /** True when the durable cache already has bytes for this exact URL key. */
50
+ export declare function hasCachedImage(url: string | null | undefined): Promise<boolean>;
51
+ /** Persist image bytes received from a LAN peer into the durable cache. */
52
+ export declare function cacheImageFromBase64(url: string | null | undefined, base64: string | null | undefined): Promise<boolean>;
49
53
  export declare function cacheImageBatch(urls: (string | null | undefined)[], opts?: {
50
54
  skipIfCached?: boolean;
51
55
  removeOnFailure?: boolean;
@@ -123,6 +123,42 @@ export async function cacheRemoteImage(url, opts = {}) {
123
123
  await fs.deleteAsync(localPath, { idempotent: true }).catch(() => { });
124
124
  return { remoteUrl: url, localUri: null, etag: null, lastModified: null, cachedAt: null };
125
125
  }
126
+ /** True when the durable cache already has bytes for this exact URL key. */
127
+ export async function hasCachedImage(url) {
128
+ if (!url)
129
+ return false;
130
+ const fs = getFileSystemAdapter();
131
+ if (!fs)
132
+ return false;
133
+ try {
134
+ const info = await fs.getInfoAsync(getCachedImagePath(url));
135
+ return info.exists && !info.isDirectory;
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
141
+ /** Persist image bytes received from a LAN peer into the durable cache. */
142
+ export async function cacheImageFromBase64(url, base64) {
143
+ if (!url || !base64)
144
+ return false;
145
+ const fs = getFileSystemAdapter();
146
+ if (!fs?.writeAsBase64Async)
147
+ return false;
148
+ try {
149
+ await ensureImageCacheDir();
150
+ const localPath = getCachedImagePath(url);
151
+ await fs.writeAsBase64Async(localPath, base64);
152
+ return true;
153
+ }
154
+ catch (error) {
155
+ syncLogger.warn("ImageCache", "Failed to store LAN image", {
156
+ url,
157
+ error: String(error),
158
+ });
159
+ return false;
160
+ }
161
+ }
126
162
  export async function cacheImageBatch(urls, opts = {}) {
127
163
  const results = await Promise.all(urls.map((u) => cacheRemoteImage(u, opts)));
128
164
  return results.filter((r) => r !== null);
@@ -36,6 +36,13 @@ export interface LocalNodeClient {
36
36
  requestReferenceData(businessId: string, keys: ReferenceDataKey[]): Promise<{
37
37
  data: Partial<Record<ReferenceDataKey, ReferenceDataRecord | null>>;
38
38
  }>;
39
+ /** Fetch cached image bytes from a peer on a dark LAN. */
40
+ requestAsset?(businessId: string, url: string): Promise<{
41
+ url: string;
42
+ base64: string | null;
43
+ contentType?: string | null;
44
+ error?: string | null;
45
+ }>;
39
46
  publishEvent?(event: DomainEvent): Promise<void> | void;
40
47
  }
41
48
  export interface TransportAdapter {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Peer request bus.
3
+ *
4
+ * `reference/service.ts` needs to ask the LAN mesh for missed reference data
5
+ * and image bytes, while `local-node/service.ts` owns the live peer clients.
6
+ * A tiny registry avoids a module cycle between those two services.
7
+ */
8
+ import type { ReferenceDataKey, ReferenceDataRecord } from "../reference/types";
9
+ export interface LanAssetResult {
10
+ url: string;
11
+ base64: string | null;
12
+ contentType?: string | null;
13
+ error?: string | null;
14
+ }
15
+ type ReferenceFetcher = (businessId: string, keys: ReferenceDataKey[]) => Promise<{
16
+ data: Partial<Record<ReferenceDataKey, ReferenceDataRecord | null>>;
17
+ }>;
18
+ type AssetFetcher = (businessId: string, url: string) => Promise<LanAssetResult | null>;
19
+ export declare function registerLocalNodePeerBus(bus: {
20
+ requestReference?: ReferenceFetcher;
21
+ requestAsset?: AssetFetcher;
22
+ }): void;
23
+ export declare function requestReferenceFromPeer(businessId: string, keys: ReferenceDataKey[]): Promise<{
24
+ data: Partial<Record<ReferenceDataKey, ReferenceDataRecord | null>>;
25
+ } | null>;
26
+ export declare function requestAssetFromPeer(businessId: string, url: string): Promise<LanAssetResult | null>;
27
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Peer request bus.
3
+ *
4
+ * `reference/service.ts` needs to ask the LAN mesh for missed reference data
5
+ * and image bytes, while `local-node/service.ts` owns the live peer clients.
6
+ * A tiny registry avoids a module cycle between those two services.
7
+ */
8
+ let referenceFetcher = null;
9
+ let assetFetcher = null;
10
+ export function registerLocalNodePeerBus(bus) {
11
+ referenceFetcher = bus.requestReference ?? null;
12
+ assetFetcher = bus.requestAsset ?? null;
13
+ }
14
+ export async function requestReferenceFromPeer(businessId, keys) {
15
+ if (!referenceFetcher)
16
+ return null;
17
+ try {
18
+ return await referenceFetcher(businessId, keys);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export async function requestAssetFromPeer(businessId, url) {
25
+ if (!assetFetcher)
26
+ return null;
27
+ try {
28
+ return await assetFetcher(businessId, url);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
@@ -22,6 +22,8 @@ declare class LocalNodeServiceCore {
22
22
  /** Whether discovery scanning was successfully started. */
23
23
  private discovering;
24
24
  init(deps: LocalNodeDeps): void;
25
+ private requestReferenceFromPeers;
26
+ private requestAssetFromPeers;
25
27
  private getDeps;
26
28
  private getDeviceId;
27
29
  private runLifecycle;
@@ -16,6 +16,7 @@ import { insertOrder } from "../db/operations";
16
16
  import { createLocalOrder, transitionLocalOrder } from "../domain/orders/service";
17
17
  import { getConnectivityState } from "../adapters/connectivity";
18
18
  import { deriveLanSecret as deriveSecretCore } from "./security";
19
+ import { registerLocalNodePeerBus } from "./peer-bus";
19
20
  const KEEP_AWAKE_TAG = "TeincLocalNode";
20
21
  const RECONNECT_BASE = 1000;
21
22
  const RECONNECT_MAX = 30000;
@@ -54,7 +55,40 @@ class LocalNodeServiceCore {
54
55
  * discovery event; back off quietly and re-probe after the window. */
55
56
  this.mismatchBackoff = new Map();
56
57
  }
57
- init(deps) { this.deps = deps; }
58
+ init(deps) {
59
+ this.deps = deps;
60
+ registerLocalNodePeerBus({
61
+ requestReference: (businessId, keys) => this.requestReferenceFromPeers(businessId, keys),
62
+ requestAsset: (businessId, url) => this.requestAssetFromPeers(businessId, url),
63
+ });
64
+ }
65
+ async requestReferenceFromPeers(businessId, keys) {
66
+ let lastError = null;
67
+ for (const peer of this.peers.values()) {
68
+ if (!peer.isConnected())
69
+ continue;
70
+ try {
71
+ return await peer.requestReferenceData(businessId, keys);
72
+ }
73
+ catch (error) {
74
+ lastError = error;
75
+ }
76
+ }
77
+ throw lastError instanceof Error ? lastError : new Error("No LAN peer served reference data");
78
+ }
79
+ async requestAssetFromPeers(businessId, url) {
80
+ for (const peer of this.peers.values()) {
81
+ if (!peer.isConnected() || !peer.requestAsset)
82
+ continue;
83
+ try {
84
+ const result = await peer.requestAsset(businessId, url);
85
+ if (result?.base64)
86
+ return result;
87
+ }
88
+ catch { }
89
+ }
90
+ return null;
91
+ }
58
92
  getDeps() {
59
93
  if (!this.deps)
60
94
  throw new Error("LocalNodeService not initialised: call initLocalNodeService(deps) first");
@@ -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>;
@@ -65,7 +68,7 @@ export declare function exchangeOfflineSession(args: {
65
68
  }): Promise<OfflineExchangeResult>;
66
69
  /** Read local license/member state without touching the network. */
67
70
  export declare function getOfflineSessionState(businessId?: string): Promise<OfflineSessionState>;
68
- export declare function verifyOfflineLogin(userId: string, pin: string): Promise<OfflineMemberCredential | null>;
71
+ export declare function verifyOfflineLogin(userId: string, pin: string, businessId?: string): Promise<OfflineMemberCredential | null>;
69
72
  export declare function enrollMemberOnDevice(args: {
70
73
  businessId: string;
71
74
  deviceId?: string;
@@ -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>;
81
- export declare function getStoredOfflineLicensePayload(): Promise<OfflineLicensePayload | null>;
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>;
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) {
@@ -49,10 +54,11 @@ export async function activateOfflineDevice(args) {
49
54
  if (!verified)
50
55
  throw new Error("Activation returned an invalid offline license");
51
56
  await saveDevicePrivateKey(base64UrlEncode(keypair.privateKey));
52
- await saveStoredLicense(data.license);
53
- await saveStoredLicensePublicKey(data.public_key);
54
- await saveStoredLicenseKeyId(data.key_id);
57
+ await saveStoredLicense(args.businessId, data.license);
58
+ await saveStoredLicensePublicKey(args.businessId, data.public_key);
59
+ await saveStoredLicenseKeyId(args.businessId, data.key_id);
55
60
  await createOfflineMemberCredential({
61
+ businessId: args.businessId,
56
62
  userId: args.member.userId,
57
63
  fullName: args.member.fullName,
58
64
  email: args.member.email,
@@ -84,9 +90,9 @@ export async function refreshOfflineLicense(businessId) {
84
90
  const verified = await verifyOfflineLicense(data.license, data.public_key, businessId);
85
91
  if (!verified)
86
92
  throw new Error("License refresh returned an invalid license");
87
- await saveStoredLicense(data.license);
88
- await saveStoredLicensePublicKey(data.public_key);
89
- await saveStoredLicenseKeyId(data.key_id);
93
+ await saveStoredLicense(businessId, data.license);
94
+ await saveStoredLicensePublicKey(businessId, data.public_key);
95
+ await saveStoredLicenseKeyId(businessId, data.key_id);
90
96
  return { ...data, payload: verified };
91
97
  }
92
98
  /**
@@ -95,8 +101,8 @@ export async function refreshOfflineLicense(businessId) {
95
101
  * expired while the terminal was offline.
96
102
  */
97
103
  export async function exchangeOfflineSession(args) {
98
- const token = await getStoredLicense();
99
- const publicKey = await getStoredLicensePublicKey();
104
+ const token = await getStoredLicense(args.businessId);
105
+ const publicKey = await getStoredLicensePublicKey(args.businessId);
100
106
  const privateKey = await getPrivateKeyBytes();
101
107
  if (!token || !publicKey || !privateKey) {
102
108
  throw new Error("This device has no offline license");
@@ -130,19 +136,19 @@ export async function exchangeOfflineSession(args) {
130
136
  const refreshed = await verifyOfflineLicense(data.license, data.public_key, args.businessId);
131
137
  if (!refreshed)
132
138
  throw new Error("Offline exchange returned an invalid license");
133
- await saveStoredLicense(data.license);
134
- await saveStoredLicensePublicKey(data.public_key);
135
- await saveStoredLicenseKeyId(data.key_id);
136
- await markOfflineMemberLogin(args.memberId);
139
+ await saveStoredLicense(args.businessId, data.license);
140
+ await saveStoredLicensePublicKey(args.businessId, data.public_key);
141
+ await saveStoredLicenseKeyId(args.businessId, data.key_id);
142
+ await markOfflineMemberLogin(args.memberId, args.businessId);
137
143
  return { ...data, license_payload: refreshed };
138
144
  }
139
145
  /** Read local license/member state without touching the network. */
140
146
  export async function getOfflineSessionState(businessId) {
141
147
  const [token, publicKey, keyId, members] = await Promise.all([
142
- getStoredLicense(),
143
- getStoredLicensePublicKey(),
144
- getStoredLicenseKeyId(),
145
- listOfflineMembers(),
148
+ getStoredLicense(businessId),
149
+ getStoredLicensePublicKey(businessId),
150
+ getStoredLicenseKeyId(businessId),
151
+ listOfflineMembers(businessId),
146
152
  ]);
147
153
  const { payload, evaluation } = await loadAndVerifyOfflineLicense({
148
154
  token,
@@ -158,14 +164,14 @@ export async function getOfflineSessionState(businessId) {
158
164
  members,
159
165
  };
160
166
  }
161
- export async function verifyOfflineLogin(userId, pin) {
162
- const member = await getOfflineMember(userId);
167
+ export async function verifyOfflineLogin(userId, pin, businessId) {
168
+ const member = await getOfflineMember(userId, businessId);
163
169
  if (!member)
164
170
  return null;
165
- const ok = await verifyOfflineMemberPin(userId, pin);
171
+ const ok = await verifyOfflineMemberPin(userId, pin, businessId);
166
172
  if (!ok)
167
173
  return null;
168
- await markOfflineMemberLogin(userId);
174
+ await markOfflineMemberLogin(userId, businessId);
169
175
  return member;
170
176
  }
171
177
  export async function enrollMemberOnDevice(args) {
@@ -175,7 +181,7 @@ export async function enrollMemberOnDevice(args) {
175
181
  export async function revokeMemberOnDevice(args) {
176
182
  const deviceId = args.deviceId ?? (await getAppInstanceIdAsync());
177
183
  await getHttpClient().delete(`/businesses/${args.businessId}/devices/${encodeURIComponent(deviceId)}/members/${encodeURIComponent(args.userId)}`);
178
- await removeOfflineMember(args.userId);
184
+ await removeOfflineMember(args.userId, args.businessId);
179
185
  }
180
186
  /** Owner action: revoke the terminal and wipe all local offline credentials. */
181
187
  export async function removeOfflineDevice(businessId, reason) {
@@ -188,10 +194,34 @@ export async function removeOfflineDevice(businessId, reason) {
188
194
  error: String(error),
189
195
  });
190
196
  }
191
- await clearOfflineDevice();
197
+ await clearOfflineDevice(businessId);
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
+ }
192
222
  }
193
- export async function getStoredOfflineLicensePayload() {
194
- const token = await getStoredLicense();
223
+ export async function getStoredOfflineLicensePayload(businessId) {
224
+ const token = await getStoredLicense(businessId);
195
225
  return token ? decodeLicensePayload(token) : null;
196
226
  }
197
227
  export async function evaluateStoredOfflineLicense(businessId) {
@@ -9,12 +9,28 @@
9
9
  * server password or its hash.
10
10
  */
11
11
  export declare const OFFLINE_STORAGE_KEYS: {
12
+ /** Business-id keyed map: { [businessId]: { token, public_key, key_id } } */
13
+ readonly LICENSES: "offline:licenses";
12
14
  readonly LICENSE: "offline:license";
13
15
  readonly PUBLIC_KEY: "offline:license:public_key";
14
16
  readonly KEY_ID: "offline:license:key_id";
15
17
  readonly MEMBERS: "offline:members";
16
18
  readonly DEVICE_PRIVATE_KEY: "offline:device:private_key";
17
19
  };
20
+ export interface OfflineLicenseRecord {
21
+ token: string;
22
+ public_key: string;
23
+ key_id: string;
24
+ saved_at: string;
25
+ }
26
+ export declare function getStoredLicense(businessId?: string): Promise<string | null>;
27
+ export declare function saveStoredLicense(businessId: string, token: string): Promise<void>;
28
+ export declare function getStoredLicensePublicKey(businessId?: string): Promise<string | null>;
29
+ export declare function saveStoredLicensePublicKey(businessId: string, publicKey: string): Promise<void>;
30
+ export declare function getStoredLicenseKeyId(businessId?: string): Promise<string | null>;
31
+ export declare function saveStoredLicenseKeyId(businessId: string, keyId: string): Promise<void>;
32
+ export declare function listStoredLicenseBusinessIds(): Promise<string[]>;
33
+ export declare function removeStoredLicense(businessId: string): Promise<void>;
18
34
  export interface OfflineSecureStorage {
19
35
  getItem(key: string): Promise<string | null>;
20
36
  setItem(key: string, value: string): Promise<void>;
@@ -24,14 +40,10 @@ export declare function setOfflineSecureStorage(storage: OfflineSecureStorage |
24
40
  export declare function getDevicePrivateKey(): Promise<string | null>;
25
41
  export declare function saveDevicePrivateKey(privateKeyB64: string): Promise<void>;
26
42
  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
43
  export interface OfflineMemberCredential {
34
44
  user_id: string;
45
+ /** Business this offline credential is scoped to. */
46
+ business_id?: string;
35
47
  full_name: string;
36
48
  email?: string | null;
37
49
  role: string;
@@ -40,14 +52,17 @@ export interface OfflineMemberCredential {
40
52
  pin_hash: string;
41
53
  enrolled_at: string;
42
54
  last_login_at?: string | null;
55
+ failed_attempts?: number;
56
+ locked_until?: string | null;
43
57
  }
44
- export declare function listOfflineMembers(): Promise<OfflineMemberCredential[]>;
45
- export declare function getOfflineMember(userId: string): Promise<OfflineMemberCredential | null>;
58
+ export declare function listOfflineMembers(businessId?: string): Promise<OfflineMemberCredential[]>;
59
+ export declare function getOfflineMember(userId: string, businessId?: string): Promise<OfflineMemberCredential | null>;
46
60
  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>;
61
+ export declare function removeOfflineMember(userId: string, businessId?: string): Promise<void>;
62
+ export declare function markOfflineMemberLogin(userId: string, businessId?: string): Promise<void>;
63
+ export declare function clearOfflineDevice(businessId?: string): Promise<void>;
50
64
  export declare function createOfflineMemberCredential(args: {
65
+ businessId: string;
51
66
  userId: string;
52
67
  fullName: string;
53
68
  email?: string | null;
@@ -55,4 +70,14 @@ export declare function createOfflineMemberCredential(args: {
55
70
  capabilities: string[];
56
71
  pin: string;
57
72
  }): Promise<OfflineMemberCredential>;
58
- export declare function verifyOfflineMemberPin(userId: string, pin: string): Promise<boolean>;
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>;
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>;
@@ -12,12 +12,89 @@ import { getItem, removeItem, setItem } from "../adapters/kv";
12
12
  import { sha256Hex } from "../reference/entitlement-crypto";
13
13
  import { offlineRandomBytes } from "./random";
14
14
  export const OFFLINE_STORAGE_KEYS = {
15
+ /** Business-id keyed map: { [businessId]: { token, public_key, key_id } } */
16
+ LICENSES: "offline:licenses",
17
+ // Legacy single-license keys, retained only for migration.
15
18
  LICENSE: "offline:license",
16
19
  PUBLIC_KEY: "offline:license:public_key",
17
20
  KEY_ID: "offline:license:key_id",
18
21
  MEMBERS: "offline:members",
19
22
  DEVICE_PRIVATE_KEY: "offline:device:private_key",
20
23
  };
24
+ async function getLicenseMap() {
25
+ return (await getItem(OFFLINE_STORAGE_KEYS.LICENSES)) ?? {};
26
+ }
27
+ async function getLicenseRecord(businessId) {
28
+ const map = await getLicenseMap();
29
+ if (businessId && map[businessId])
30
+ return map[businessId];
31
+ if (!businessId) {
32
+ const first = Object.values(map)[0];
33
+ if (first)
34
+ return first;
35
+ }
36
+ // Migration path from the original single-license build. Verification still
37
+ // checks the business id inside the signed payload, so this cannot leak a
38
+ // license into the wrong tenant.
39
+ const [token, publicKey, keyId] = await Promise.all([
40
+ getItem(OFFLINE_STORAGE_KEYS.LICENSE),
41
+ getItem(OFFLINE_STORAGE_KEYS.PUBLIC_KEY),
42
+ getItem(OFFLINE_STORAGE_KEYS.KEY_ID),
43
+ ]);
44
+ if (token && publicKey) {
45
+ return { token, public_key: publicKey, key_id: keyId ?? "", saved_at: new Date().toISOString() };
46
+ }
47
+ return null;
48
+ }
49
+ async function saveLicenseRecord(businessId, record) {
50
+ const map = await getLicenseMap();
51
+ map[businessId] = record;
52
+ await setItem(OFFLINE_STORAGE_KEYS.LICENSES, map);
53
+ // Drop legacy singletons once a scoped record exists.
54
+ await Promise.all([
55
+ removeItem(OFFLINE_STORAGE_KEYS.LICENSE),
56
+ removeItem(OFFLINE_STORAGE_KEYS.PUBLIC_KEY),
57
+ removeItem(OFFLINE_STORAGE_KEYS.KEY_ID),
58
+ ]);
59
+ }
60
+ export async function getStoredLicense(businessId) {
61
+ return (await getLicenseRecord(businessId))?.token ?? null;
62
+ }
63
+ export async function saveStoredLicense(businessId, token) {
64
+ const existing = await getLicenseRecord(businessId);
65
+ await saveLicenseRecord(businessId, {
66
+ token,
67
+ public_key: existing?.public_key ?? "",
68
+ key_id: existing?.key_id ?? "",
69
+ saved_at: new Date().toISOString(),
70
+ });
71
+ }
72
+ export async function getStoredLicensePublicKey(businessId) {
73
+ return (await getLicenseRecord(businessId))?.public_key ?? null;
74
+ }
75
+ export async function saveStoredLicensePublicKey(businessId, publicKey) {
76
+ const existing = await getLicenseRecord(businessId);
77
+ if (!existing)
78
+ throw new Error("Save the signed license before its public key");
79
+ await saveLicenseRecord(businessId, { ...existing, public_key: publicKey });
80
+ }
81
+ export async function getStoredLicenseKeyId(businessId) {
82
+ return (await getLicenseRecord(businessId))?.key_id ?? null;
83
+ }
84
+ export async function saveStoredLicenseKeyId(businessId, keyId) {
85
+ const existing = await getLicenseRecord(businessId);
86
+ if (!existing)
87
+ throw new Error("Save the signed license before its key id");
88
+ await saveLicenseRecord(businessId, { ...existing, key_id: keyId });
89
+ }
90
+ export async function listStoredLicenseBusinessIds() {
91
+ return Object.keys(await getLicenseMap());
92
+ }
93
+ export async function removeStoredLicense(businessId) {
94
+ const map = await getLicenseMap();
95
+ delete map[businessId];
96
+ await setItem(OFFLINE_STORAGE_KEYS.LICENSES, map);
97
+ }
21
98
  let secureStorage = null;
22
99
  export function setOfflineSecureStorage(storage) {
23
100
  secureStorage = storage;
@@ -37,49 +114,51 @@ export async function saveDevicePrivateKey(privateKeyB64) {
37
114
  export async function removeDevicePrivateKey() {
38
115
  await secure().removeItem(OFFLINE_STORAGE_KEYS.DEVICE_PRIVATE_KEY);
39
116
  }
40
- export async function getStoredLicense() {
41
- return getItem(OFFLINE_STORAGE_KEYS.LICENSE);
117
+ function memberMatchesBusiness(member, businessId) {
118
+ if (!businessId)
119
+ return true;
120
+ // Pre-migration rows had no business_id; keep them visible until an online
121
+ // activation rewrites them with the correct scope.
122
+ return !member.business_id || member.business_id === businessId;
42
123
  }
43
- export async function saveStoredLicense(token) {
44
- await setItem(OFFLINE_STORAGE_KEYS.LICENSE, token);
124
+ export async function listOfflineMembers(businessId) {
125
+ const members = (await getItem(OFFLINE_STORAGE_KEYS.MEMBERS)) ?? [];
126
+ return members.filter((member) => memberMatchesBusiness(member, businessId));
45
127
  }
46
- export async function getStoredLicensePublicKey() {
47
- return getItem(OFFLINE_STORAGE_KEYS.PUBLIC_KEY);
48
- }
49
- export async function saveStoredLicensePublicKey(publicKey) {
50
- await setItem(OFFLINE_STORAGE_KEYS.PUBLIC_KEY, publicKey);
51
- }
52
- export async function getStoredLicenseKeyId() {
53
- return getItem(OFFLINE_STORAGE_KEYS.KEY_ID);
54
- }
55
- export async function saveStoredLicenseKeyId(keyId) {
56
- await setItem(OFFLINE_STORAGE_KEYS.KEY_ID, keyId);
57
- }
58
- export async function listOfflineMembers() {
59
- return (await getItem(OFFLINE_STORAGE_KEYS.MEMBERS)) ?? [];
60
- }
61
- export async function getOfflineMember(userId) {
62
- const members = await listOfflineMembers();
128
+ export async function getOfflineMember(userId, businessId) {
129
+ const members = await listOfflineMembers(businessId);
63
130
  return members.find((member) => member.user_id === userId) ?? null;
64
131
  }
65
132
  export async function saveOfflineMember(member) {
66
- const members = await listOfflineMembers();
67
- const next = members.filter((existing) => existing.user_id !== member.user_id);
133
+ const all = (await getItem(OFFLINE_STORAGE_KEYS.MEMBERS)) ?? [];
134
+ const next = all.filter((existing) => existing.user_id !== member.user_id ||
135
+ (existing.business_id ?? "") !== (member.business_id ?? ""));
68
136
  next.push(member);
69
137
  await setItem(OFFLINE_STORAGE_KEYS.MEMBERS, next);
70
138
  }
71
- export async function removeOfflineMember(userId) {
72
- const members = await listOfflineMembers();
73
- await setItem(OFFLINE_STORAGE_KEYS.MEMBERS, members.filter((member) => member.user_id !== userId));
139
+ export async function removeOfflineMember(userId, businessId) {
140
+ const all = (await getItem(OFFLINE_STORAGE_KEYS.MEMBERS)) ?? [];
141
+ await setItem(OFFLINE_STORAGE_KEYS.MEMBERS, all.filter((member) => {
142
+ if (member.user_id !== userId)
143
+ return true;
144
+ return businessId ? member.business_id !== businessId : false;
145
+ }));
74
146
  }
75
- export async function markOfflineMemberLogin(userId) {
76
- const member = await getOfflineMember(userId);
147
+ export async function markOfflineMemberLogin(userId, businessId) {
148
+ const member = await getOfflineMember(userId, businessId);
77
149
  if (!member)
78
150
  return;
79
151
  await saveOfflineMember({ ...member, last_login_at: new Date().toISOString() });
80
152
  }
81
- export async function clearOfflineDevice() {
153
+ export async function clearOfflineDevice(businessId) {
154
+ if (businessId) {
155
+ await removeStoredLicense(businessId);
156
+ const all = (await getItem(OFFLINE_STORAGE_KEYS.MEMBERS)) ?? [];
157
+ await setItem(OFFLINE_STORAGE_KEYS.MEMBERS, all.filter((member) => member.business_id !== businessId));
158
+ return;
159
+ }
82
160
  await Promise.all([
161
+ removeItem(OFFLINE_STORAGE_KEYS.LICENSES),
83
162
  removeItem(OFFLINE_STORAGE_KEYS.LICENSE),
84
163
  removeItem(OFFLINE_STORAGE_KEYS.PUBLIC_KEY),
85
164
  removeItem(OFFLINE_STORAGE_KEYS.KEY_ID),
@@ -109,6 +188,7 @@ export async function createOfflineMemberCredential(args) {
109
188
  const salt = randomHex(32);
110
189
  const member = {
111
190
  user_id: args.userId,
191
+ business_id: args.businessId,
112
192
  full_name: args.fullName,
113
193
  email: args.email ?? null,
114
194
  role: args.role,
@@ -121,18 +201,68 @@ export async function createOfflineMemberCredential(args) {
121
201
  await saveOfflineMember(member);
122
202
  return member;
123
203
  }
124
- export async function verifyOfflineMemberPin(userId, pin) {
125
- const member = await getOfflineMember(userId);
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
+ }
217
+ export async function verifyOfflineMemberPin(userId, pin, businessId) {
218
+ const member = await getOfflineMember(userId, businessId);
126
219
  if (!member)
127
220
  return false;
128
- if (!/^\d{6}$/.test(pin))
221
+ if (member.locked_until && new Date(member.locked_until).getTime() > Date.now()) {
129
222
  return false;
223
+ }
224
+ if (!/^\d{6}$/.test(pin)) {
225
+ await recordFailedPinAttempt(member);
226
+ return false;
227
+ }
130
228
  const candidate = await hashPin(pin, member.pin_salt);
131
- if (candidate.length !== member.pin_hash.length)
229
+ if (candidate.length !== member.pin_hash.length) {
230
+ await recordFailedPinAttempt(member);
132
231
  return false;
232
+ }
133
233
  let diff = 0;
134
234
  for (let i = 0; i < candidate.length; i++) {
135
235
  diff |= candidate.charCodeAt(i) ^ member.pin_hash.charCodeAt(i);
136
236
  }
137
- 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
+ });
138
268
  }
@@ -108,6 +108,9 @@ export const CAPABILITY_BY_COMMAND = {
108
108
  "menu:update_category": "menu_manage",
109
109
  "menu:delete_category": "menu_manage",
110
110
  "payment:confirm_cash": "orders_accept",
111
+ "member:invite": "business_members_manage",
112
+ "member:update": "business_members_manage",
113
+ "member:remove": "business_members_manage",
111
114
  };
112
115
  export async function hasCapabilitySync(businessId, commandType) {
113
116
  const required = CAPABILITY_BY_COMMAND[commandType];
@@ -11,7 +11,8 @@ import { openSyncDatabase } from "../db/connection";
11
11
  import { syncLogger } from "../utils/sync-logger";
12
12
  import { generateUUID } from "../utils/uuid";
13
13
  import { menuService, businessService, rolesService } from "../adapters/services";
14
- import { cacheImageBatch, pruneImageCache } from "../adapters/image-cache";
14
+ import { cacheImageBatch, pruneImageCache, hasCachedImage, cacheImageFromBase64, } from "../adapters/image-cache";
15
+ import { requestReferenceFromPeer, requestAssetFromPeer } from "../local-node/peer-bus";
15
16
  import { getConnectivityState } from "../adapters/connectivity";
16
17
  import { canBusinessSync } from "../sync/sync-guard";
17
18
  import { getAuthToken } from "../adapters/auth";
@@ -123,17 +124,41 @@ class ReferenceSyncService {
123
124
  const businessId = options.businessId;
124
125
  const { isOnline, isCloudReachable } = getConnectivityState();
125
126
  if (!isOnline || isCloudReachable === "unreachable") {
126
- syncLogger.debug("ReferenceSync", "Skipping sync — cloud not reachable", {
127
- businessId,
128
- isOnline,
129
- isCloudReachable,
130
- });
131
- return {
127
+ // Dark LAN: a peer may still have the freshest reference rows and image
128
+ // bytes. Compare against the mesh before declaring the pass empty.
129
+ const keys = options.keys ?? DEFAULT_SYNC_KEYS;
130
+ const lanResult = {
132
131
  synced: [],
133
- skipped: options.keys ?? DEFAULT_SYNC_KEYS,
132
+ skipped: keys,
134
133
  failed: [],
135
134
  errors: {},
136
135
  };
136
+ try {
137
+ const db = await openSyncDatabase();
138
+ const peer = await requestReferenceFromPeer(businessId, keys);
139
+ if (peer?.data) {
140
+ for (const key of keys) {
141
+ const record = peer.data[key];
142
+ if (!record)
143
+ continue;
144
+ const written = await writeServerReferenceData(db, businessId, key, record.payload, record.version);
145
+ if (written)
146
+ lanResult.synced.push(key);
147
+ else
148
+ lanResult.skipped.push(key);
149
+ }
150
+ if (lanResult.synced.length > 0) {
151
+ await this.cacheReferenceImages(db, businessId, lanResult.synced);
152
+ }
153
+ }
154
+ }
155
+ catch (error) {
156
+ syncLogger.debug("ReferenceSync", "LAN reference catch-up unavailable", {
157
+ businessId,
158
+ error: String(error),
159
+ });
160
+ }
161
+ return lanResult;
137
162
  }
138
163
  syncLogger.info("ReferenceSync", "Starting reference data sync", {
139
164
  businessId,
@@ -440,6 +465,21 @@ class ReferenceSyncService {
440
465
  keys,
441
466
  count: cachedImages.length,
442
467
  });
468
+ // Fully dark LAN: the cloud download cannot run, but a peer may already
469
+ // have the bytes in its durable image cache. Fetch only missing keys.
470
+ const peerConnectivity = getConnectivityState();
471
+ if (!peerConnectivity.isOnline || peerConnectivity.isCloudReachable === "unreachable") {
472
+ for (const url of urls) {
473
+ if (!url || !url.startsWith("http"))
474
+ continue;
475
+ if (await hasCachedImage(url))
476
+ continue;
477
+ const asset = await requestAssetFromPeer(businessId, url);
478
+ if (asset?.base64) {
479
+ await cacheImageFromBase64(url, asset.base64);
480
+ }
481
+ }
482
+ }
443
483
  await pruneImageCache(urls);
444
484
  }
445
485
  catch (error) {
@@ -17,8 +17,6 @@ export const ONLINE_REQUIRED = {
17
17
  "order:confirm_handover": "Handover confirmation requires online",
18
18
  "chat:send": "Chat requires online",
19
19
  "notification:register": "Notifications require online",
20
- "member:invite": "Member invites require online",
21
- "member:manage": "Member management requires online",
22
20
  "fleet:manage": "Fleet changes require online",
23
21
  "settings:manage": "Settings changes require online",
24
22
  "kyc:submit": "KYC submission requires online",
@@ -5,7 +5,7 @@
5
5
  * resulting domain events. This transport is used when the cloud API is
6
6
  * reachable.
7
7
  */
8
- import { sellerService, kioskService, menuService } from "../adapters/services";
8
+ import { sellerService, kioskService, menuService, businessService } from "../adapters/services";
9
9
  import { syncLogger } from "../utils/sync-logger";
10
10
  import { generateEventId } from "../utils/uuid";
11
11
  import { createLocalOrder, transitionLocalOrder } from "../domain/orders/service";
@@ -130,6 +130,24 @@ export class RestTransport {
130
130
  case "payment:confirm_cash": {
131
131
  return this.executeOrderTransition(command, "confirm_cash", timestamp, () => sellerService.confirmCashPayment(entity_id), "payment.completed");
132
132
  }
133
+ case "member:invite": {
134
+ const response = (await businessService.inviteBusinessMember(business_id, (payload ?? {})));
135
+ const data = (response?.data ?? {});
136
+ return this.wrapResponse("member.invited", data, data.id ?? command.command_id, timestamp, command);
137
+ }
138
+ case "member:update": {
139
+ if (!entity_id)
140
+ throw new Error("member:update requires entity_id");
141
+ const response = (await businessService.updateBusinessMember(business_id, entity_id, (payload ?? {})));
142
+ const data = (response?.data ?? {});
143
+ return this.wrapResponse("member.updated", data, entity_id, timestamp, command);
144
+ }
145
+ case "member:remove": {
146
+ if (!entity_id)
147
+ throw new Error("member:remove requires entity_id");
148
+ await businessService.removeBusinessMember(business_id, entity_id);
149
+ return this.wrapResponse("member.removed", { user_id: entity_id, status: "removed" }, entity_id, timestamp, command);
150
+ }
133
151
  default:
134
152
  throw new Error(`RestTransport: unsupported command type ${command_type}`);
135
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
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",