@teincfood/core 0.10.3 → 0.10.4

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.
@@ -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,7 @@ 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";
12
13
  import { validateInviteInput } from "../validation/offline";
13
14
  import { notifySuccess, notifyError } from "../adapters/notify";
14
15
  import { extractApiErrorMessage } from "../utils/error";
@@ -24,6 +25,9 @@ function useIsAuthenticated() {
24
25
  return !!token;
25
26
  }
26
27
  const OFFLINE_FIRST = { retry: false, networkMode: "offlineFirst" };
28
+ function isQueued(result) {
29
+ return Boolean(result && typeof result === "object" && result.status === "pending");
30
+ }
27
31
  // ─── Keys ───
28
32
  export const MEMBER_KEYS = {
29
33
  all: ["business-members"],
@@ -64,12 +68,20 @@ export function useInviteMemberMutation() {
64
68
  const businessId = useBusinessId();
65
69
  return useMutation({
66
70
  mutationFn: (data) => {
67
- assertOnlineFor("member:invite");
71
+ if (!businessId)
72
+ throw new Error("No active business");
68
73
  validateInviteInput({ email: data.email, phone: data.phone });
69
- return businessService.inviteBusinessMember(businessId, data);
70
- },
71
- onSuccess: () => {
72
- notifySuccess("Invitation sent!");
74
+ return runOfflineCapableMutation({
75
+ run: () => businessService.inviteBusinessMember(businessId, data),
76
+ commandType: "member:invite",
77
+ businessId,
78
+ payload: data,
79
+ });
80
+ },
81
+ onSuccess: (result) => {
82
+ notifySuccess(isQueued(result)
83
+ ? "Invitation queued — it will send when this device reconnects."
84
+ : "Invitation sent!");
73
85
  queryClient.invalidateQueries({ queryKey: MEMBER_KEYS.list(businessId ?? "") });
74
86
  if (businessId)
75
87
  emitReferenceChanged(businessId, ["members"]);
@@ -84,11 +96,45 @@ export function useRemoveMemberMutation() {
84
96
  const businessId = useBusinessId();
85
97
  return useMutation({
86
98
  mutationFn: (userId) => {
87
- assertOnlineFor("member:manage");
88
- return businessService.removeBusinessMember(businessId, userId);
99
+ if (!businessId)
100
+ throw new Error("No active business");
101
+ return runOfflineCapableMutation({
102
+ run: () => businessService.removeBusinessMember(businessId, userId),
103
+ commandType: "member:remove",
104
+ businessId,
105
+ entityId: userId,
106
+ payload: { user_id: userId },
107
+ });
108
+ },
109
+ onSuccess: (result) => {
110
+ notifySuccess(isQueued(result) ? "Member removal queued" : "Member removed");
111
+ queryClient.invalidateQueries({ queryKey: MEMBER_KEYS.list(businessId ?? "") });
112
+ if (businessId)
113
+ emitReferenceChanged(businessId, ["members"]);
89
114
  },
90
- onSuccess: () => {
91
- notifySuccess("Member removed");
115
+ onError: (error) => {
116
+ notifyError(extractApiErrorMessage(error));
117
+ },
118
+ });
119
+ }
120
+ export function useUpdateMemberRoleMutation() {
121
+ const queryClient = useQueryClient();
122
+ const businessId = useBusinessId();
123
+ return useMutation({
124
+ mutationFn: ({ userId, role, status }) => {
125
+ if (!businessId)
126
+ throw new Error("No active business");
127
+ const payload = { ...(role ? { role } : {}), ...(status ? { status } : {}) };
128
+ return runOfflineCapableMutation({
129
+ run: () => businessService.updateBusinessMember(businessId, userId, payload),
130
+ commandType: "member:update",
131
+ businessId,
132
+ entityId: userId,
133
+ payload,
134
+ });
135
+ },
136
+ onSuccess: (result) => {
137
+ notifySuccess(isQueued(result) ? "Role change queued" : "Member role updated");
92
138
  queryClient.invalidateQueries({ queryKey: MEMBER_KEYS.list(businessId ?? "") });
93
139
  if (businessId)
94
140
  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");
@@ -65,7 +65,7 @@ export declare function exchangeOfflineSession(args: {
65
65
  }): Promise<OfflineExchangeResult>;
66
66
  /** Read local license/member state without touching the network. */
67
67
  export declare function getOfflineSessionState(businessId?: string): Promise<OfflineSessionState>;
68
- export declare function verifyOfflineLogin(userId: string, pin: string): Promise<OfflineMemberCredential | null>;
68
+ export declare function verifyOfflineLogin(userId: string, pin: string, businessId?: string): Promise<OfflineMemberCredential | null>;
69
69
  export declare function enrollMemberOnDevice(args: {
70
70
  businessId: string;
71
71
  deviceId?: string;
@@ -78,6 +78,6 @@ export declare function revokeMemberOnDevice(args: {
78
78
  }): Promise<void>;
79
79
  /** Owner action: revoke the terminal and wipe all local offline credentials. */
80
80
  export declare function removeOfflineDevice(businessId: string, reason?: string): Promise<void>;
81
- export declare function getStoredOfflineLicensePayload(): Promise<OfflineLicensePayload | null>;
81
+ export declare function getStoredOfflineLicensePayload(businessId?: string): Promise<OfflineLicensePayload | null>;
82
82
  export declare function evaluateStoredOfflineLicense(businessId?: string): Promise<OfflineLicenseEvaluation>;
83
83
  export { evaluateOfflineLicense };
@@ -49,10 +49,11 @@ export async function activateOfflineDevice(args) {
49
49
  if (!verified)
50
50
  throw new Error("Activation returned an invalid offline license");
51
51
  await saveDevicePrivateKey(base64UrlEncode(keypair.privateKey));
52
- await saveStoredLicense(data.license);
53
- await saveStoredLicensePublicKey(data.public_key);
54
- await saveStoredLicenseKeyId(data.key_id);
52
+ await saveStoredLicense(args.businessId, data.license);
53
+ await saveStoredLicensePublicKey(args.businessId, data.public_key);
54
+ await saveStoredLicenseKeyId(args.businessId, data.key_id);
55
55
  await createOfflineMemberCredential({
56
+ businessId: args.businessId,
56
57
  userId: args.member.userId,
57
58
  fullName: args.member.fullName,
58
59
  email: args.member.email,
@@ -84,9 +85,9 @@ export async function refreshOfflineLicense(businessId) {
84
85
  const verified = await verifyOfflineLicense(data.license, data.public_key, businessId);
85
86
  if (!verified)
86
87
  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);
88
+ await saveStoredLicense(businessId, data.license);
89
+ await saveStoredLicensePublicKey(businessId, data.public_key);
90
+ await saveStoredLicenseKeyId(businessId, data.key_id);
90
91
  return { ...data, payload: verified };
91
92
  }
92
93
  /**
@@ -95,8 +96,8 @@ export async function refreshOfflineLicense(businessId) {
95
96
  * expired while the terminal was offline.
96
97
  */
97
98
  export async function exchangeOfflineSession(args) {
98
- const token = await getStoredLicense();
99
- const publicKey = await getStoredLicensePublicKey();
99
+ const token = await getStoredLicense(args.businessId);
100
+ const publicKey = await getStoredLicensePublicKey(args.businessId);
100
101
  const privateKey = await getPrivateKeyBytes();
101
102
  if (!token || !publicKey || !privateKey) {
102
103
  throw new Error("This device has no offline license");
@@ -130,19 +131,19 @@ export async function exchangeOfflineSession(args) {
130
131
  const refreshed = await verifyOfflineLicense(data.license, data.public_key, args.businessId);
131
132
  if (!refreshed)
132
133
  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);
134
+ await saveStoredLicense(args.businessId, data.license);
135
+ await saveStoredLicensePublicKey(args.businessId, data.public_key);
136
+ await saveStoredLicenseKeyId(args.businessId, data.key_id);
137
+ await markOfflineMemberLogin(args.memberId, args.businessId);
137
138
  return { ...data, license_payload: refreshed };
138
139
  }
139
140
  /** Read local license/member state without touching the network. */
140
141
  export async function getOfflineSessionState(businessId) {
141
142
  const [token, publicKey, keyId, members] = await Promise.all([
142
- getStoredLicense(),
143
- getStoredLicensePublicKey(),
144
- getStoredLicenseKeyId(),
145
- listOfflineMembers(),
143
+ getStoredLicense(businessId),
144
+ getStoredLicensePublicKey(businessId),
145
+ getStoredLicenseKeyId(businessId),
146
+ listOfflineMembers(businessId),
146
147
  ]);
147
148
  const { payload, evaluation } = await loadAndVerifyOfflineLicense({
148
149
  token,
@@ -158,14 +159,14 @@ export async function getOfflineSessionState(businessId) {
158
159
  members,
159
160
  };
160
161
  }
161
- export async function verifyOfflineLogin(userId, pin) {
162
- const member = await getOfflineMember(userId);
162
+ export async function verifyOfflineLogin(userId, pin, businessId) {
163
+ const member = await getOfflineMember(userId, businessId);
163
164
  if (!member)
164
165
  return null;
165
- const ok = await verifyOfflineMemberPin(userId, pin);
166
+ const ok = await verifyOfflineMemberPin(userId, pin, businessId);
166
167
  if (!ok)
167
168
  return null;
168
- await markOfflineMemberLogin(userId);
169
+ await markOfflineMemberLogin(userId, businessId);
169
170
  return member;
170
171
  }
171
172
  export async function enrollMemberOnDevice(args) {
@@ -175,7 +176,7 @@ export async function enrollMemberOnDevice(args) {
175
176
  export async function revokeMemberOnDevice(args) {
176
177
  const deviceId = args.deviceId ?? (await getAppInstanceIdAsync());
177
178
  await getHttpClient().delete(`/businesses/${args.businessId}/devices/${encodeURIComponent(deviceId)}/members/${encodeURIComponent(args.userId)}`);
178
- await removeOfflineMember(args.userId);
179
+ await removeOfflineMember(args.userId, args.businessId);
179
180
  }
180
181
  /** Owner action: revoke the terminal and wipe all local offline credentials. */
181
182
  export async function removeOfflineDevice(businessId, reason) {
@@ -188,10 +189,10 @@ export async function removeOfflineDevice(businessId, reason) {
188
189
  error: String(error),
189
190
  });
190
191
  }
191
- await clearOfflineDevice();
192
+ await clearOfflineDevice(businessId);
192
193
  }
193
- export async function getStoredOfflineLicensePayload() {
194
- const token = await getStoredLicense();
194
+ export async function getStoredOfflineLicensePayload(businessId) {
195
+ const token = await getStoredLicense(businessId);
195
196
  return token ? decodeLicensePayload(token) : null;
196
197
  }
197
198
  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;
@@ -41,13 +53,14 @@ export interface OfflineMemberCredential {
41
53
  enrolled_at: string;
42
54
  last_login_at?: string | null;
43
55
  }
44
- export declare function listOfflineMembers(): Promise<OfflineMemberCredential[]>;
45
- export declare function getOfflineMember(userId: string): Promise<OfflineMemberCredential | null>;
56
+ export declare function listOfflineMembers(businessId?: string): Promise<OfflineMemberCredential[]>;
57
+ export declare function getOfflineMember(userId: string, businessId?: string): Promise<OfflineMemberCredential | null>;
46
58
  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>;
59
+ export declare function removeOfflineMember(userId: string, businessId?: string): Promise<void>;
60
+ export declare function markOfflineMemberLogin(userId: string, businessId?: string): Promise<void>;
61
+ export declare function clearOfflineDevice(businessId?: string): Promise<void>;
50
62
  export declare function createOfflineMemberCredential(args: {
63
+ businessId: string;
51
64
  userId: string;
52
65
  fullName: string;
53
66
  email?: string | null;
@@ -55,4 +68,4 @@ export declare function createOfflineMemberCredential(args: {
55
68
  capabilities: string[];
56
69
  pin: string;
57
70
  }): Promise<OfflineMemberCredential>;
58
- export declare function verifyOfflineMemberPin(userId: string, pin: string): Promise<boolean>;
71
+ export declare function verifyOfflineMemberPin(userId: string, pin: string, businessId?: string): Promise<boolean>;
@@ -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);
42
- }
43
- export async function saveStoredLicense(token) {
44
- await setItem(OFFLINE_STORAGE_KEYS.LICENSE, token);
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;
45
123
  }
46
- export async function getStoredLicensePublicKey() {
47
- return getItem(OFFLINE_STORAGE_KEYS.PUBLIC_KEY);
124
+ export async function listOfflineMembers(businessId) {
125
+ const members = (await getItem(OFFLINE_STORAGE_KEYS.MEMBERS)) ?? [];
126
+ return members.filter((member) => memberMatchesBusiness(member, businessId));
48
127
  }
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,8 +201,8 @@ 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 async function verifyOfflineMemberPin(userId, pin, businessId) {
205
+ const member = await getOfflineMember(userId, businessId);
126
206
  if (!member)
127
207
  return false;
128
208
  if (!/^\d{6}$/.test(pin))
@@ -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.4",
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",