@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.
- package/dist/adapters/devices.d.ts +9 -0
- package/dist/adapters/devices.js +15 -0
- package/dist/adapters/filesystem.d.ts +1 -0
- package/dist/adapters/image-cache.d.ts +4 -0
- package/dist/adapters/image-cache.js +12 -0
- package/dist/adapters/services.d.ts +1 -0
- package/dist/adapters/services.js +3 -0
- package/dist/hooks/members.hooks.d.ts +5 -0
- package/dist/hooks/members.hooks.js +64 -9
- package/dist/image-cache/service.d.ts +4 -0
- package/dist/image-cache/service.js +36 -0
- package/dist/local-node/adapters.d.ts +7 -0
- package/dist/local-node/peer-bus.d.ts +27 -0
- package/dist/local-node/peer-bus.js +33 -0
- package/dist/local-node/service.d.ts +2 -0
- package/dist/local-node/service.js +35 -1
- package/dist/offline/service.d.ts +15 -2
- package/dist/offline/service.js +56 -26
- package/dist/offline/store.d.ts +37 -12
- package/dist/offline/store.js +164 -34
- package/dist/reference/capabilities.js +3 -0
- package/dist/reference/service.js +48 -8
- package/dist/sync/online-gates.js +0 -2
- package/dist/sync/transport-rest.js +19 -1
- package/package.json +1 -1
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
* Devices service — stable per-device UUID for DD lease.
|
|
3
3
|
* Persists via KV sync so DD stays same for life of device (until wipe).
|
|
4
4
|
*/
|
|
5
|
+
export interface DeviceMetadata {
|
|
6
|
+
deviceName?: string | null;
|
|
7
|
+
platform?: string | null;
|
|
8
|
+
osVersion?: string | null;
|
|
9
|
+
appVersion?: string | null;
|
|
10
|
+
}
|
|
11
|
+
/** Host apps inject OS/device details so licenses and admin dashboards can identify terminals. */
|
|
12
|
+
export declare function setDeviceMetadataProvider(provider: (() => Promise<DeviceMetadata> | DeviceMetadata) | null): void;
|
|
13
|
+
export declare function getDeviceMetadata(): Promise<DeviceMetadata>;
|
|
5
14
|
export declare function setAppInstanceId(id: string): void;
|
|
6
15
|
export declare function getAppInstanceId(): string;
|
|
7
16
|
export declare function getAppInstanceIdAsync(): Promise<string>;
|
package/dist/adapters/devices.js
CHANGED
|
@@ -6,6 +6,21 @@ import { getItemSync, setItemSync, getItem, setItem } from "./kv";
|
|
|
6
6
|
import { generateUUID } from "../utils/uuid";
|
|
7
7
|
let appInstanceId = null;
|
|
8
8
|
const APP_ID_KEY = "teinc_app_instance_id";
|
|
9
|
+
let deviceMetadataProvider = null;
|
|
10
|
+
/** Host apps inject OS/device details so licenses and admin dashboards can identify terminals. */
|
|
11
|
+
export function setDeviceMetadataProvider(provider) {
|
|
12
|
+
deviceMetadataProvider = provider;
|
|
13
|
+
}
|
|
14
|
+
export async function getDeviceMetadata() {
|
|
15
|
+
if (!deviceMetadataProvider)
|
|
16
|
+
return {};
|
|
17
|
+
try {
|
|
18
|
+
return await deviceMetadataProvider();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
9
24
|
// For Tauri desktop, try to restore from localStorage synchronously at import to avoid async race generating a new UUID on every cargo tauri dev restart
|
|
10
25
|
try {
|
|
11
26
|
if (typeof localStorage !== "undefined") {
|
|
@@ -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
|
-
|
|
72
|
+
if (!businessId)
|
|
73
|
+
throw new Error("No active business");
|
|
68
74
|
validateInviteInput({ email: data.email, phone: data.phone });
|
|
69
|
-
return
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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) {
|
|
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
|
-
|
|
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 };
|
package/dist/offline/service.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* verify the signed license, unlock enrolled members locally, and only needs
|
|
6
6
|
* the network to exchange the device challenge for fresh session tokens.
|
|
7
7
|
*/
|
|
8
|
-
import { getAppInstanceIdAsync } from "../adapters/devices";
|
|
8
|
+
import { getAppInstanceIdAsync, getDeviceMetadata } from "../adapters/devices";
|
|
9
9
|
import { getHttpClient } from "../adapters/http";
|
|
10
10
|
import { getAuthToken } from "../adapters/auth";
|
|
11
|
+
import { getConnectivityState } from "../adapters/connectivity";
|
|
11
12
|
import { syncLogger } from "../utils/sync-logger";
|
|
12
13
|
import { offlineRandomBytes } from "./random";
|
|
13
14
|
import { base64UrlDecode, base64UrlEncode, canonicalDeviceChallenge, decodeLicensePayload, evaluateOfflineLicense, generateOfflineDeviceKeypair, loadAndVerifyOfflineLicense, newOfflineNonce, signDeviceChallenge, verifyOfflineLicense, } from "./license";
|
|
@@ -35,11 +36,15 @@ export async function activateOfflineDevice(args) {
|
|
|
35
36
|
if (!getAuthToken())
|
|
36
37
|
throw new Error("Sign in online before activating this device");
|
|
37
38
|
const deviceId = args.appInstanceId ?? (await getAppInstanceIdAsync());
|
|
39
|
+
const metadata = await getDeviceMetadata();
|
|
38
40
|
const keypair = await generateOfflineDeviceKeypair(randomBytes);
|
|
39
41
|
const response = await getHttpClient().post(`/businesses/${args.businessId}/devices/${encodeURIComponent(deviceId)}/activate`, {
|
|
40
42
|
device_public_key: keypair.publicKeyB64,
|
|
41
43
|
app_instance_id: deviceId,
|
|
42
|
-
platform: args.platform ?? "unknown",
|
|
44
|
+
platform: args.platform ?? metadata.platform ?? "unknown",
|
|
45
|
+
device_name: args.deviceName ?? metadata.deviceName ?? undefined,
|
|
46
|
+
os_version: args.osVersion ?? metadata.osVersion ?? undefined,
|
|
47
|
+
app_version: args.appVersion ?? metadata.appVersion ?? undefined,
|
|
43
48
|
});
|
|
44
49
|
const data = response.data.data;
|
|
45
50
|
if (!data?.license || !data.public_key) {
|
|
@@ -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) {
|
package/dist/offline/store.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/offline/store.js
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
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
|
|
44
|
-
await
|
|
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
|
|
47
|
-
|
|
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
|
|
67
|
-
const next =
|
|
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
|
|
73
|
-
await setItem(OFFLINE_STORAGE_KEYS.MEMBERS,
|
|
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
|
|
125
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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:
|
|
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