@teincfood/core 0.8.4 → 0.9.0
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/db/operations.d.ts +8 -0
- package/dist/db/operations.js +11 -0
- package/dist/domain/orders/service.js +4 -3
- package/dist/hooks/analytics.hooks.d.ts +72 -0
- package/dist/hooks/analytics.hooks.js +17 -8
- package/dist/hooks/dashboard.hooks.d.ts +14 -0
- package/dist/hooks/dashboard.hooks.js +14 -7
- package/dist/hooks/orders.hooks.js +6 -3
- package/dist/hooks/realtime-stubs.js +48 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/reference/api.js +12 -2
- package/dist/reference/entitlement-clock.d.ts +39 -0
- package/dist/reference/entitlement-clock.js +116 -0
- package/dist/reference/entitlement-crypto.d.ts +10 -0
- package/dist/reference/entitlement-crypto.js +111 -0
- package/dist/reference/entitlements.d.ts +51 -0
- package/dist/reference/entitlements.js +124 -0
- package/dist/reference/operations.d.ts +7 -0
- package/dist/reference/operations.js +26 -0
- package/dist/reference/service.js +33 -1
- package/dist/repositories/order.repository.js +11 -0
- package/dist/sync/command-queue.service.js +13 -13
- package/dist/sync/engine.js +18 -9
- package/dist/sync/online-gates.d.ts +9 -0
- package/dist/sync/online-gates.js +36 -3
- package/dist/sync/order-snapshots.service.d.ts +10 -0
- package/dist/sync/order-snapshots.service.js +96 -0
- package/dist/sync/sync-jobs.service.js +35 -8
- package/dist/sync/transition-endpoints.d.ts +9 -1
- package/dist/sync/transition-endpoints.js +16 -5
- package/dist/sync/transition-errors.d.ts +32 -0
- package/dist/sync/transition-errors.js +61 -0
- package/dist/sync/transport-rest.d.ts +5 -3
- package/dist/sync/transport-rest.js +10 -3
- package/dist/utils/error.js +3 -0
- package/dist/utils/order-transitions.js +17 -1
- package/package.json +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SHA-256 + HMAC-SHA256 (pure TS, no dependencies).
|
|
3
|
+
*
|
|
4
|
+
* Used to verify server-signed entitlement envelopes on every platform
|
|
5
|
+
* (Hermes / Tauri / Node) without relying on `crypto.subtle` availability.
|
|
6
|
+
* Test vectors: SHA256("") = e3b0c442...7852b855, HMAC per RFC 4231.
|
|
7
|
+
*/
|
|
8
|
+
function rotr(x, n) {
|
|
9
|
+
return (x >>> n) | (x << (32 - n));
|
|
10
|
+
}
|
|
11
|
+
const K = [
|
|
12
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
13
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
14
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
15
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
16
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
17
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
18
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
19
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
20
|
+
];
|
|
21
|
+
function utf8Bytes(input) {
|
|
22
|
+
const bytes = [];
|
|
23
|
+
for (let i = 0; i < input.length; i++) {
|
|
24
|
+
let code = input.charCodeAt(i);
|
|
25
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < input.length) {
|
|
26
|
+
const next = input.charCodeAt(i + 1);
|
|
27
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
28
|
+
code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
|
|
29
|
+
i++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (code < 0x80)
|
|
33
|
+
bytes.push(code);
|
|
34
|
+
else if (code < 0x800)
|
|
35
|
+
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
|
36
|
+
else if (code < 0x10000)
|
|
37
|
+
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
|
38
|
+
else
|
|
39
|
+
bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
|
40
|
+
}
|
|
41
|
+
return bytes;
|
|
42
|
+
}
|
|
43
|
+
export function sha256Bytes(message) {
|
|
44
|
+
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a;
|
|
45
|
+
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;
|
|
46
|
+
const bitLen = message.length * 8;
|
|
47
|
+
const padded = [...message, 0x80];
|
|
48
|
+
while (padded.length % 64 !== 56)
|
|
49
|
+
padded.push(0);
|
|
50
|
+
// 64-bit length: high 32 bits assumed 0 (payloads far below 2^32 bits)
|
|
51
|
+
padded.push(0, 0, 0, 0, (bitLen >>> 24) & 0xff, (bitLen >>> 16) & 0xff, (bitLen >>> 8) & 0xff, bitLen & 0xff);
|
|
52
|
+
const w = new Array(64);
|
|
53
|
+
for (let off = 0; off < padded.length; off += 64) {
|
|
54
|
+
for (let i = 0; i < 16; i++) {
|
|
55
|
+
w[i] = ((padded[off + i * 4] << 24) | (padded[off + i * 4 + 1] << 16) | (padded[off + i * 4 + 2] << 8) | padded[off + i * 4 + 3]) | 0;
|
|
56
|
+
}
|
|
57
|
+
for (let i = 16; i < 64; i++) {
|
|
58
|
+
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
|
59
|
+
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
|
60
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
|
|
61
|
+
}
|
|
62
|
+
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
|
|
63
|
+
for (let i = 0; i < 64; i++) {
|
|
64
|
+
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
65
|
+
const ch = (e & f) ^ (~e & g);
|
|
66
|
+
const t1 = (h + S1 + ch + K[i] + w[i]) | 0;
|
|
67
|
+
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
68
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
69
|
+
const t2 = (S0 + maj) | 0;
|
|
70
|
+
h = g;
|
|
71
|
+
g = f;
|
|
72
|
+
f = e;
|
|
73
|
+
e = (d + t1) | 0;
|
|
74
|
+
d = c;
|
|
75
|
+
c = b;
|
|
76
|
+
b = a;
|
|
77
|
+
a = (t1 + t2) | 0;
|
|
78
|
+
}
|
|
79
|
+
h0 = (h0 + a) | 0;
|
|
80
|
+
h1 = (h1 + b) | 0;
|
|
81
|
+
h2 = (h2 + c) | 0;
|
|
82
|
+
h3 = (h3 + d) | 0;
|
|
83
|
+
h4 = (h4 + e) | 0;
|
|
84
|
+
h5 = (h5 + f) | 0;
|
|
85
|
+
h6 = (h6 + g) | 0;
|
|
86
|
+
h7 = (h7 + h) | 0;
|
|
87
|
+
}
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const v of [h0, h1, h2, h3, h4, h5, h6, h7]) {
|
|
90
|
+
out.push((v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
export function sha256Hex(input) {
|
|
95
|
+
return sha256Bytes(utf8Bytes(input))
|
|
96
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
97
|
+
.join("");
|
|
98
|
+
}
|
|
99
|
+
export function hmacSha256Hex(key, message) {
|
|
100
|
+
let keyBytes = utf8Bytes(key);
|
|
101
|
+
if (keyBytes.length > 64)
|
|
102
|
+
keyBytes = sha256Bytes(keyBytes);
|
|
103
|
+
while (keyBytes.length < 64)
|
|
104
|
+
keyBytes.push(0);
|
|
105
|
+
const oKey = keyBytes.map((b) => b ^ 0x5c);
|
|
106
|
+
const iKey = keyBytes.map((b) => b ^ 0x36);
|
|
107
|
+
const inner = sha256Bytes([...iKey, ...utf8Bytes(message)]);
|
|
108
|
+
return sha256Bytes([...oKey, ...inner])
|
|
109
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
110
|
+
.join("");
|
|
111
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription entitlements — offline evaluation with own counter.
|
|
3
|
+
*
|
|
4
|
+
* Rules (fail-closed):
|
|
5
|
+
* - Envelope signature must verify, else NOT entitled (tamper-proof cache).
|
|
6
|
+
* - Expiry uses the monotonic entitlement clock anchored to backend
|
|
7
|
+
* `server_time`, never `Date.now()` / device wall-clock.
|
|
8
|
+
* - Stale cache beyond `max_offline_days` (7) forces reconnect (read-only).
|
|
9
|
+
* - Unknown/missing state => not entitled. The backend remains the authority
|
|
10
|
+
* (HTTP 402 on gated calls); this is the offline UX gate that cannot be
|
|
11
|
+
* bypassed by editing SQLite, backdating the clock, or poisoning HLC.
|
|
12
|
+
*/
|
|
13
|
+
export declare const ENTITLEMENTS_KEY: "subscription:entitlements";
|
|
14
|
+
/** Keys that only the server may write (no local merge, no LAN override). */
|
|
15
|
+
export declare const SERVER_AUTHORITATIVE_KEYS: readonly string[];
|
|
16
|
+
export declare function isServerAuthoritativeKey(key: string): boolean;
|
|
17
|
+
export interface EntitlementEnvelope {
|
|
18
|
+
version: string;
|
|
19
|
+
server_time: string;
|
|
20
|
+
expires_at: string | null;
|
|
21
|
+
grace_until: string | null;
|
|
22
|
+
max_offline_days: number;
|
|
23
|
+
algorithm: string;
|
|
24
|
+
signature: string;
|
|
25
|
+
}
|
|
26
|
+
export type EntitlementState = "active" | "trialing" | "grace" | "expired" | "stale-blocked" | "unverified" | "no-plan";
|
|
27
|
+
export interface EntitlementEvaluation {
|
|
28
|
+
state: EntitlementState;
|
|
29
|
+
/** May the gated feature be used offline right now? */
|
|
30
|
+
entitled: boolean;
|
|
31
|
+
/** Anchored server instant used for the decision (ms). */
|
|
32
|
+
serverNowMs: number;
|
|
33
|
+
/** True when the cache is too stale and the user must reconnect. */
|
|
34
|
+
mustReconnect: boolean;
|
|
35
|
+
reason: string;
|
|
36
|
+
}
|
|
37
|
+
export declare function canonicalEnvelopePayload(businessId: string, envelope: Pick<EntitlementEnvelope, "version" | "server_time" | "expires_at" | "grace_until">): string;
|
|
38
|
+
export declare function signEntitlementEnvelope(businessId: string, envelope: Pick<EntitlementEnvelope, "version" | "server_time" | "expires_at" | "grace_until">, secret: string): string;
|
|
39
|
+
export declare function verifyEntitlementEnvelope(businessId: string, envelope: EntitlementEnvelope | null | undefined, secret: string): boolean;
|
|
40
|
+
export interface EvaluateInput {
|
|
41
|
+
businessId: string;
|
|
42
|
+
envelope: EntitlementEnvelope | null | undefined;
|
|
43
|
+
/** Raw entitlements payload (`data` map with features + subscription_status). */
|
|
44
|
+
features: Record<string, unknown> | null | undefined;
|
|
45
|
+
secret: string;
|
|
46
|
+
}
|
|
47
|
+
export declare function evaluateEntitlements(input: EvaluateInput): Promise<EntitlementEvaluation>;
|
|
48
|
+
/** Fail-closed feature read: unknown/expired/unverified => false. */
|
|
49
|
+
export declare function getEntitledFeature(evaluation: EntitlementEvaluation, features: Record<string, unknown> | null | undefined, key: string): boolean;
|
|
50
|
+
/** Fail-closed numeric limit read: unknown/expired/unverified => 0. */
|
|
51
|
+
export declare function getEntitledLimit(evaluation: EntitlementEvaluation, features: Record<string, unknown> | null | undefined, key: string): number;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription entitlements — offline evaluation with own counter.
|
|
3
|
+
*
|
|
4
|
+
* Rules (fail-closed):
|
|
5
|
+
* - Envelope signature must verify, else NOT entitled (tamper-proof cache).
|
|
6
|
+
* - Expiry uses the monotonic entitlement clock anchored to backend
|
|
7
|
+
* `server_time`, never `Date.now()` / device wall-clock.
|
|
8
|
+
* - Stale cache beyond `max_offline_days` (7) forces reconnect (read-only).
|
|
9
|
+
* - Unknown/missing state => not entitled. The backend remains the authority
|
|
10
|
+
* (HTTP 402 on gated calls); this is the offline UX gate that cannot be
|
|
11
|
+
* bypassed by editing SQLite, backdating the clock, or poisoning HLC.
|
|
12
|
+
*/
|
|
13
|
+
import { hmacSha256Hex } from "./entitlement-crypto";
|
|
14
|
+
import { anchorServerTime, getLastAnchorMs, getServerNowMs } from "./entitlement-clock";
|
|
15
|
+
export const ENTITLEMENTS_KEY = "subscription:entitlements";
|
|
16
|
+
/** Keys that only the server may write (no local merge, no LAN override). */
|
|
17
|
+
export const SERVER_AUTHORITATIVE_KEYS = [ENTITLEMENTS_KEY];
|
|
18
|
+
export function isServerAuthoritativeKey(key) {
|
|
19
|
+
return SERVER_AUTHORITATIVE_KEYS.includes(key);
|
|
20
|
+
}
|
|
21
|
+
export function canonicalEnvelopePayload(businessId, envelope) {
|
|
22
|
+
return [businessId, envelope.version, envelope.server_time, envelope.expires_at ?? "", envelope.grace_until ?? ""].join("|");
|
|
23
|
+
}
|
|
24
|
+
export function signEntitlementEnvelope(businessId, envelope, secret) {
|
|
25
|
+
return hmacSha256Hex(secret, canonicalEnvelopePayload(businessId, envelope));
|
|
26
|
+
}
|
|
27
|
+
/** Constant-time-ish comparison to avoid trivial timing shortcuts. */
|
|
28
|
+
function signaturesEqual(a, b) {
|
|
29
|
+
if (a.length !== b.length)
|
|
30
|
+
return false;
|
|
31
|
+
let diff = 0;
|
|
32
|
+
for (let i = 0; i < a.length; i++)
|
|
33
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
34
|
+
return diff === 0;
|
|
35
|
+
}
|
|
36
|
+
export function verifyEntitlementEnvelope(businessId, envelope, secret) {
|
|
37
|
+
if (!envelope || !secret)
|
|
38
|
+
return false;
|
|
39
|
+
if (!envelope.version || !envelope.server_time || !envelope.signature)
|
|
40
|
+
return false;
|
|
41
|
+
const expected = signEntitlementEnvelope(businessId, envelope, secret);
|
|
42
|
+
return signaturesEqual(expected, envelope.signature.toLowerCase()) ||
|
|
43
|
+
signaturesEqual(expected, envelope.signature);
|
|
44
|
+
}
|
|
45
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
46
|
+
export async function evaluateEntitlements(input) {
|
|
47
|
+
const { businessId, envelope, features, secret } = input;
|
|
48
|
+
if (!envelope || !verifyEntitlementEnvelope(businessId, envelope, secret)) {
|
|
49
|
+
return {
|
|
50
|
+
state: "unverified",
|
|
51
|
+
entitled: false,
|
|
52
|
+
serverNowMs: await getServerNowMs(),
|
|
53
|
+
mustReconnect: true,
|
|
54
|
+
reason: "Entitlement signature invalid — reconnect to verify subscription",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Anchor the monotonic clock to this trusted server instant.
|
|
58
|
+
// Stale envelopes never reset the anchor (see anchorServerTime).
|
|
59
|
+
await anchorServerTime(envelope.server_time);
|
|
60
|
+
const serverNowMs = await getServerNowMs();
|
|
61
|
+
const lastAnchorMs = await getLastAnchorMs();
|
|
62
|
+
const maxOfflineDays = Number.isFinite(envelope.max_offline_days) ? envelope.max_offline_days : 7;
|
|
63
|
+
if (lastAnchorMs != null && serverNowMs - lastAnchorMs > maxOfflineDays * DAY_MS) {
|
|
64
|
+
return {
|
|
65
|
+
state: "stale-blocked",
|
|
66
|
+
entitled: false,
|
|
67
|
+
serverNowMs,
|
|
68
|
+
mustReconnect: true,
|
|
69
|
+
reason: `Subscription not verified for ${maxOfflineDays} days — reconnect required`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const status = String(features?.["subscription_status"] ?? "none");
|
|
73
|
+
const expiresMs = envelope.expires_at ? Date.parse(envelope.expires_at) : NaN;
|
|
74
|
+
const graceMs = envelope.grace_until ? Date.parse(envelope.grace_until) : NaN;
|
|
75
|
+
if (!Number.isFinite(expiresMs)) {
|
|
76
|
+
// Legacy row without a period: entitled only when backend says so.
|
|
77
|
+
const entitled = status === "active" || status === "trialing";
|
|
78
|
+
return {
|
|
79
|
+
state: entitled ? status : "no-plan",
|
|
80
|
+
entitled,
|
|
81
|
+
serverNowMs,
|
|
82
|
+
mustReconnect: false,
|
|
83
|
+
reason: entitled ? "Active subscription (no period bound)" : "No subscription plan",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (serverNowMs <= expiresMs) {
|
|
87
|
+
return {
|
|
88
|
+
state: status === "trialing" ? "trialing" : "active",
|
|
89
|
+
entitled: true,
|
|
90
|
+
serverNowMs,
|
|
91
|
+
mustReconnect: false,
|
|
92
|
+
reason: "Subscription in period",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (Number.isFinite(graceMs) && serverNowMs <= graceMs) {
|
|
96
|
+
return {
|
|
97
|
+
state: "grace",
|
|
98
|
+
entitled: true,
|
|
99
|
+
serverNowMs,
|
|
100
|
+
mustReconnect: false,
|
|
101
|
+
reason: "Within grace period — renew soon",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
state: "expired",
|
|
106
|
+
entitled: false,
|
|
107
|
+
serverNowMs,
|
|
108
|
+
mustReconnect: false,
|
|
109
|
+
reason: "Subscription expired — upgrade to continue",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Fail-closed feature read: unknown/expired/unverified => false. */
|
|
113
|
+
export function getEntitledFeature(evaluation, features, key) {
|
|
114
|
+
if (!evaluation.entitled || !features)
|
|
115
|
+
return false;
|
|
116
|
+
return features[key] === true;
|
|
117
|
+
}
|
|
118
|
+
/** Fail-closed numeric limit read: unknown/expired/unverified => 0. */
|
|
119
|
+
export function getEntitledLimit(evaluation, features, key) {
|
|
120
|
+
if (!evaluation.entitled || !features)
|
|
121
|
+
return 0;
|
|
122
|
+
const v = Number(features[key]);
|
|
123
|
+
return Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
|
|
124
|
+
}
|
|
@@ -8,6 +8,13 @@ export declare function getReferenceData<T>(db: SQLiteDatabase, businessId: stri
|
|
|
8
8
|
export declare function getReferenceVersion(db: SQLiteDatabase, businessId: string, key: ReferenceDataKey): Promise<string | null>;
|
|
9
9
|
export declare function setReferenceData<T>(db: SQLiteDatabase, businessId: string, record: ReferenceDataRecord<T>): Promise<void>;
|
|
10
10
|
export declare function mergeReferenceData<T extends Record<string, unknown>>(db: SQLiteDatabase, businessId: string, key: ReferenceDataKey, incoming: Record<string, unknown>, hlc: string, eventId: string): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Server-authoritative write for verified payloads (subscription:entitlements).
|
|
13
|
+
* Overwrites only when the incoming server version is newer — a stale or
|
|
14
|
+
* replayed envelope can never rewind the cache. Local callers must never use
|
|
15
|
+
* set/mergeReferenceData for these keys.
|
|
16
|
+
*/
|
|
17
|
+
export declare function writeServerReferenceData<T>(db: SQLiteDatabase, businessId: string, key: ReferenceDataKey, payload: T, serverVersion: string): Promise<boolean>;
|
|
11
18
|
export declare function deleteReferenceData(db: SQLiteDatabase, businessId: string, key: ReferenceDataKey): Promise<void>;
|
|
12
19
|
export declare function deleteReferenceDataForBusiness(db: SQLiteDatabase, businessId: string): Promise<void>;
|
|
13
20
|
export declare function getAllReferenceDataKeys(db: SQLiteDatabase, businessId: string): Promise<ReferenceDataKey[]>;
|
|
@@ -6,6 +6,7 @@ import { generateUUID } from "../utils/uuid";
|
|
|
6
6
|
import { openSyncDatabase } from "../db/connection";
|
|
7
7
|
import { getAppInstanceId } from "../adapters/devices";
|
|
8
8
|
import { notifyDbChange } from "../db/operations";
|
|
9
|
+
import { isServerAuthoritativeKey } from "./entitlements";
|
|
9
10
|
export async function getReferenceData(db, businessId, key) {
|
|
10
11
|
const row = await db.getFirstAsync("SELECT * FROM reference_data WHERE business_id = ? AND key = ?;", businessId, key);
|
|
11
12
|
if (!row)
|
|
@@ -37,6 +38,11 @@ export async function setReferenceData(db, businessId, record) {
|
|
|
37
38
|
notifyDbChange(businessId, `reference_data:${record.key}`);
|
|
38
39
|
}
|
|
39
40
|
export async function mergeReferenceData(db, businessId, key, incoming, hlc, eventId) {
|
|
41
|
+
// Server-authoritative keys (subscription:entitlements) never merge locally:
|
|
42
|
+
// LWW poisoning via a forged future HLC would otherwise stick forever.
|
|
43
|
+
// Use writeServerReferenceData() for the verified server path instead.
|
|
44
|
+
if (isServerAuthoritativeKey(key))
|
|
45
|
+
return;
|
|
40
46
|
const existing = await getReferenceData(db, businessId, key);
|
|
41
47
|
if (!existing) {
|
|
42
48
|
await setReferenceData(db, businessId, {
|
|
@@ -63,6 +69,26 @@ export async function mergeReferenceData(db, businessId, key, incoming, hlc, eve
|
|
|
63
69
|
lastHlc: hlc,
|
|
64
70
|
});
|
|
65
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Server-authoritative write for verified payloads (subscription:entitlements).
|
|
74
|
+
* Overwrites only when the incoming server version is newer — a stale or
|
|
75
|
+
* replayed envelope can never rewind the cache. Local callers must never use
|
|
76
|
+
* set/mergeReferenceData for these keys.
|
|
77
|
+
*/
|
|
78
|
+
export async function writeServerReferenceData(db, businessId, key, payload, serverVersion) {
|
|
79
|
+
const existing = await getReferenceData(db, businessId, key);
|
|
80
|
+
if (existing && compareHLCString(serverVersion, existing.version) <= 0)
|
|
81
|
+
return false;
|
|
82
|
+
await setReferenceData(db, businessId, {
|
|
83
|
+
key,
|
|
84
|
+
version: serverVersion,
|
|
85
|
+
payload,
|
|
86
|
+
downloadedAt: new Date().toISOString(),
|
|
87
|
+
lastEventId: `server_${serverVersion}`,
|
|
88
|
+
lastHlc: serverVersion,
|
|
89
|
+
});
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
66
92
|
export async function deleteReferenceData(db, businessId, key) {
|
|
67
93
|
await db.runAsync("DELETE FROM reference_data WHERE business_id = ? AND key = ?;", businessId, key);
|
|
68
94
|
}
|
|
@@ -14,7 +14,9 @@ import { menuService, businessService, rolesService } from "../adapters/services
|
|
|
14
14
|
import { cacheImageBatch, pruneImageCache } from "../adapters/image-cache";
|
|
15
15
|
import { getConnectivityState } from "../adapters/connectivity";
|
|
16
16
|
import { canBusinessSync } from "../sync/sync-guard";
|
|
17
|
-
import { getReferenceData, getReferenceVersion, mergeReferenceData, } from "./operations";
|
|
17
|
+
import { getReferenceData, getReferenceVersion, mergeReferenceData, writeServerReferenceData, } from "./operations";
|
|
18
|
+
import { anchorServerTime } from "./entitlement-clock";
|
|
19
|
+
import { isServerAuthoritativeKey } from "./entitlements";
|
|
18
20
|
import { fetchReferenceVersions, fetchReferenceData, fetchReferenceChanges, } from "./api";
|
|
19
21
|
const DEFAULT_SYNC_KEYS = [
|
|
20
22
|
"menu:items",
|
|
@@ -173,7 +175,26 @@ class ReferenceSyncService {
|
|
|
173
175
|
version = fallback.version;
|
|
174
176
|
}
|
|
175
177
|
// LWW: newer HLC wins — local edits that are newer than server are preserved.
|
|
178
|
+
// Server-authoritative keys (subscription:entitlements) bypass LWW merge:
|
|
179
|
+
// only a newer verified server version overwrites, and the monotonic
|
|
180
|
+
// clock anchors to the envelope server_time. Never wall-clock.
|
|
176
181
|
const hlc = version ?? generateUUID();
|
|
182
|
+
if (isServerAuthoritativeKey(key)) {
|
|
183
|
+
const record = payload;
|
|
184
|
+
const envelope = record && typeof record === "object" ? record.envelope : undefined;
|
|
185
|
+
if (!envelope?.version || !envelope?.server_time) {
|
|
186
|
+
syncLogger.error("ReferenceSync", `Refusing unverified ${key} payload (no envelope)`, {
|
|
187
|
+
businessId,
|
|
188
|
+
});
|
|
189
|
+
throw new Error("Missing entitlement envelope");
|
|
190
|
+
}
|
|
191
|
+
const wrote = await writeServerReferenceData(db, businessId, key, payload, envelope.version);
|
|
192
|
+
try {
|
|
193
|
+
await anchorServerTime(envelope.server_time);
|
|
194
|
+
}
|
|
195
|
+
catch { }
|
|
196
|
+
return wrote;
|
|
197
|
+
}
|
|
177
198
|
await mergeReferenceData(db, businessId, key, payload, hlc, `evt_${hlc}`);
|
|
178
199
|
return true;
|
|
179
200
|
}
|
|
@@ -283,6 +304,17 @@ class ReferenceSyncService {
|
|
|
283
304
|
version: this.hashPayload(payload),
|
|
284
305
|
};
|
|
285
306
|
}
|
|
307
|
+
case "subscription:entitlements": {
|
|
308
|
+
// Direct full fetch; the syncKey server path requires the signed
|
|
309
|
+
// envelope and anchors the monotonic clock. Never fabricate one here.
|
|
310
|
+
const data = await fetchReferenceData(businessId, ["subscription:entitlements"]);
|
|
311
|
+
const payload = data["subscription:entitlements"];
|
|
312
|
+
const envelope = payload?.envelope;
|
|
313
|
+
return {
|
|
314
|
+
payload,
|
|
315
|
+
version: envelope?.version ?? this.hashPayload(payload),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
286
318
|
default:
|
|
287
319
|
throw new Error(`No fallback fetch available for ${key}`);
|
|
288
320
|
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { syncEngine } from "../sync/engine";
|
|
11
11
|
import { buildCommand } from "../sync/command-builder";
|
|
12
|
+
import { assertOnlineFor, isOnlineCapable } from "../sync/online-gates";
|
|
13
|
+
import { localNodeTransport } from "../sync/transport-local-node";
|
|
12
14
|
import { generateUUID } from "../utils/uuid";
|
|
13
15
|
async function executeCommand(command) {
|
|
14
16
|
console.debug("[OrderRepository] Dispatching command", JSON.stringify({
|
|
@@ -112,6 +114,10 @@ export const orderRepository = {
|
|
|
112
114
|
return executeCommand(command);
|
|
113
115
|
},
|
|
114
116
|
async confirmHandover(input) {
|
|
117
|
+
// Handover confirm is strictly online (dual merchant/rider confirm).
|
|
118
|
+
// A connected Local Node may relay; otherwise fail loudly, never queue.
|
|
119
|
+
if (!isOnlineCapable() && !localNodeTransport.isAvailable())
|
|
120
|
+
assertOnlineFor("delivery:confirm_handover");
|
|
115
121
|
const command = buildCommand({
|
|
116
122
|
commandType: "delivery:confirm_handover",
|
|
117
123
|
businessId: input.businessId,
|
|
@@ -126,6 +132,11 @@ export const orderRepository = {
|
|
|
126
132
|
return event.payload;
|
|
127
133
|
},
|
|
128
134
|
async assignRider(input) {
|
|
135
|
+
// Delivery assignment is strictly online: auto rider-match, GPS proximity
|
|
136
|
+
// and merchant/rider dual-confirm all require the backend. A connected
|
|
137
|
+
// Local Node may relay to the cloud; otherwise fail loudly — never queue.
|
|
138
|
+
if (!isOnlineCapable() && !localNodeTransport.isAvailable())
|
|
139
|
+
assertOnlineFor("delivery:assign");
|
|
129
140
|
const command = buildCommand({
|
|
130
141
|
commandType: "delivery:assign",
|
|
131
142
|
businessId: input.businessId,
|
|
@@ -12,6 +12,8 @@ import { getSyncDatabase, openSyncDatabase } from "../db/connection";
|
|
|
12
12
|
import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandStatus, deleteCommand, insertEvent, } from "../db/operations";
|
|
13
13
|
import { syncLogger } from "../utils/sync-logger";
|
|
14
14
|
import { recordAttempt } from "../utils/attempt-stats";
|
|
15
|
+
import { TransitionError, isReplayDeferred, TRANSITION_ERROR_CODES } from "./transition-errors";
|
|
16
|
+
import { extractApiErrorCode } from "../utils/error";
|
|
15
17
|
import { generateEventId } from "../utils/uuid";
|
|
16
18
|
import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
|
|
17
19
|
import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
|
|
@@ -239,7 +241,7 @@ class CommandQueueService {
|
|
|
239
241
|
async processCommand(command) {
|
|
240
242
|
if (!canBusinessSync(command.business_id)) {
|
|
241
243
|
// No active business/session — defer until login+selection.
|
|
242
|
-
throw new
|
|
244
|
+
throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "No business/auth, deferring queued command");
|
|
243
245
|
}
|
|
244
246
|
const db = getSyncDatabase();
|
|
245
247
|
const parsedCommand = {
|
|
@@ -277,19 +279,19 @@ class CommandQueueService {
|
|
|
277
279
|
});
|
|
278
280
|
}
|
|
279
281
|
catch (error) {
|
|
280
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
281
282
|
// Replay with no transport available: leave the row exactly as-is for
|
|
282
283
|
// the next trigger (do not mark, do not count). Must reset the
|
|
283
284
|
// in_flight flag first — rows stuck in_flight are invisible to the
|
|
284
285
|
// outbox query and would wedge the queue forever.
|
|
285
|
-
if (
|
|
286
|
+
if (isReplayDeferred(error)) {
|
|
286
287
|
await updateCommandStatus(db, command.command_id, "pending", "deferred:no-transport");
|
|
287
288
|
throw error;
|
|
288
289
|
}
|
|
289
290
|
const apiErr = error;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
291
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
292
|
+
// 409 ORDER_NUMBER_TAKEN is retryable once with new RRR (exact backend code, any envelope)
|
|
293
|
+
const code = extractApiErrorCode(error);
|
|
294
|
+
if (apiErr?.status === 409 && code === "ORDER_NUMBER_TAKEN") {
|
|
293
295
|
if (command.command_type === "order:create") {
|
|
294
296
|
try {
|
|
295
297
|
const payload = command.payload;
|
|
@@ -312,8 +314,7 @@ class CommandQueueService {
|
|
|
312
314
|
await updateCommandStatus(db, command.command_id, "failed", `order_number_taken:${message}`);
|
|
313
315
|
throw error;
|
|
314
316
|
}
|
|
315
|
-
const isNonRetryable = apiErr?.
|
|
316
|
-
typeof apiErr.status === "number" &&
|
|
317
|
+
const isNonRetryable = typeof apiErr?.status === "number" &&
|
|
317
318
|
apiErr.status >= 400 &&
|
|
318
319
|
apiErr.status < 500;
|
|
319
320
|
if (isNonRetryable) {
|
|
@@ -388,11 +389,10 @@ class CommandQueueService {
|
|
|
388
389
|
await this.processCommand(command);
|
|
389
390
|
}
|
|
390
391
|
catch (e) {
|
|
391
|
-
const
|
|
392
|
-
const isNonRetryable =
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
apiErr.status < 500;
|
|
392
|
+
const replayErr = e;
|
|
393
|
+
const isNonRetryable = typeof replayErr?.status === "number" &&
|
|
394
|
+
replayErr.status >= 400 &&
|
|
395
|
+
replayErr.status < 500;
|
|
396
396
|
if (isNonRetryable) {
|
|
397
397
|
// Already marked permanent in processCommand — continue to next.
|
|
398
398
|
continue;
|
package/dist/sync/engine.js
CHANGED
|
@@ -16,12 +16,14 @@ import { localNodeTransport } from "./transport-local-node";
|
|
|
16
16
|
import { commandQueueService } from "./command-queue.service";
|
|
17
17
|
import { getConnectivityState, setConnectivityState } from "../adapters/connectivity";
|
|
18
18
|
import { ApiError } from "../adapters/http";
|
|
19
|
+
import { errorCodeOf } from "./transition-errors";
|
|
19
20
|
import { syncLogger } from "../utils/sync-logger";
|
|
20
21
|
import { CLOUD_COMMAND_TIMEOUT_MS } from "../utils/constants";
|
|
21
22
|
import { CAPABILITY_BY_COMMAND } from "../reference/capabilities";
|
|
22
23
|
import { getReferenceData } from "../reference/operations";
|
|
23
24
|
import { openSyncDatabase } from "../db/connection";
|
|
24
25
|
import { canBusinessSync } from "./sync-guard";
|
|
26
|
+
import { TRANSITION_ERROR_CODES, TransitionError } from "./transition-errors";
|
|
25
27
|
function withTimeout(promise, ms) {
|
|
26
28
|
return new Promise((resolve, reject) => {
|
|
27
29
|
const timer = setTimeout(() => {
|
|
@@ -56,7 +58,7 @@ export class SyncEngine {
|
|
|
56
58
|
async execute(command, opts) {
|
|
57
59
|
if (!command.business_id || !canBusinessSync(command.business_id)) {
|
|
58
60
|
// No active business/auth — don't route to cloud/LAN or queue with empty bid.
|
|
59
|
-
throw new
|
|
61
|
+
throw new TransitionError(TRANSITION_ERROR_CODES.NO_ACTIVE_BUSINESS, "Sync requires active business and login — business_id missing or not authenticated");
|
|
60
62
|
}
|
|
61
63
|
syncLogger.command(command, "issued");
|
|
62
64
|
// Perms guard (offline-capable): block before routing
|
|
@@ -93,7 +95,7 @@ export class SyncEngine {
|
|
|
93
95
|
// itself without executing). If no real transport is available, leave
|
|
94
96
|
// the row untouched for a later trigger.
|
|
95
97
|
if (opts?.forReplay) {
|
|
96
|
-
throw new
|
|
98
|
+
throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "No transport available, keeping command queued");
|
|
97
99
|
}
|
|
98
100
|
const event = await commandQueueService.enqueue(command);
|
|
99
101
|
this.broadcast(event);
|
|
@@ -148,15 +150,22 @@ export class SyncEngine {
|
|
|
148
150
|
}
|
|
149
151
|
catch (error) {
|
|
150
152
|
// Definitive server rejection (4xx): the backend answered with a real
|
|
151
|
-
// decision (e.g. PAYMENT_REQUIRED, FORBIDDEN).
|
|
152
|
-
//
|
|
153
|
-
// queueing a command that can never succeed.
|
|
154
|
-
|
|
153
|
+
// decision (e.g. INVALID_TRANSITION, PAYMENT_REQUIRED, FORBIDDEN).
|
|
154
|
+
// Surface it instead of masking it as a connectivity problem and
|
|
155
|
+
// silently queueing a command that can never succeed. Shape-based
|
|
156
|
+
// (status range), never instanceof: apps throw their own ApiError
|
|
157
|
+
// class, and TransitionError guards carry no HTTP status.
|
|
158
|
+
const rejectionStatus = typeof error?.status === "number"
|
|
159
|
+
? error.status
|
|
160
|
+
: undefined;
|
|
161
|
+
if (rejectionStatus !== undefined && rejectionStatus >= 400 && rejectionStatus < 500) {
|
|
162
|
+
const code = errorCodeOf(error);
|
|
155
163
|
syncLogger.warn("SyncEngine", "Cloud command rejected by server", {
|
|
156
164
|
command_type: command.command_type,
|
|
157
165
|
command_id: command.command_id,
|
|
158
|
-
status:
|
|
159
|
-
|
|
166
|
+
status: rejectionStatus,
|
|
167
|
+
code,
|
|
168
|
+
message: error instanceof Error ? error.message : String(error),
|
|
160
169
|
});
|
|
161
170
|
throw error;
|
|
162
171
|
}
|
|
@@ -173,7 +182,7 @@ export class SyncEngine {
|
|
|
173
182
|
// itself without executing). If no real transport is available, leave
|
|
174
183
|
// the row untouched for a later trigger.
|
|
175
184
|
if (opts?.forReplay) {
|
|
176
|
-
throw new
|
|
185
|
+
throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "No transport available, keeping command queued");
|
|
177
186
|
}
|
|
178
187
|
const event = await commandQueueService.enqueue(command);
|
|
179
188
|
this.broadcast(event);
|
|
@@ -15,3 +15,12 @@ export declare function isOnlineCapable(): boolean;
|
|
|
15
15
|
* and delivery/order transitions the domain already gates.
|
|
16
16
|
*/
|
|
17
17
|
export declare function assertOnlineFor(commandType: string, payload?: Record<string, unknown>): void;
|
|
18
|
+
/**
|
|
19
|
+
* Offline entitlement gate (fail-closed). Host apps evaluate the cached
|
|
20
|
+
* signed envelope via `evaluateEntitlements` and pass `entitled` here for
|
|
21
|
+
* gated commands (menu/member/promo/terminal/dispatch creation, online
|
|
22
|
+
* orders). Expired, stale (> max_offline_days), or unverified => throw.
|
|
23
|
+
* RBAC (`hasCapabilitySync`) stays fail-open (backend 403 backstop);
|
|
24
|
+
* entitlements are the opposite: no proof => no access.
|
|
25
|
+
*/
|
|
26
|
+
export declare function assertEntitledFor(commandType: string, entitled: boolean, evaluationReason?: string): void;
|