@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.
Files changed (38) hide show
  1. package/dist/db/operations.d.ts +8 -0
  2. package/dist/db/operations.js +11 -0
  3. package/dist/domain/orders/service.js +4 -3
  4. package/dist/hooks/analytics.hooks.d.ts +72 -0
  5. package/dist/hooks/analytics.hooks.js +17 -8
  6. package/dist/hooks/dashboard.hooks.d.ts +14 -0
  7. package/dist/hooks/dashboard.hooks.js +14 -7
  8. package/dist/hooks/orders.hooks.js +6 -3
  9. package/dist/hooks/realtime-stubs.js +48 -0
  10. package/dist/index.d.ts +5 -0
  11. package/dist/index.js +5 -0
  12. package/dist/reference/api.js +12 -2
  13. package/dist/reference/entitlement-clock.d.ts +39 -0
  14. package/dist/reference/entitlement-clock.js +116 -0
  15. package/dist/reference/entitlement-crypto.d.ts +10 -0
  16. package/dist/reference/entitlement-crypto.js +111 -0
  17. package/dist/reference/entitlements.d.ts +51 -0
  18. package/dist/reference/entitlements.js +124 -0
  19. package/dist/reference/operations.d.ts +7 -0
  20. package/dist/reference/operations.js +26 -0
  21. package/dist/reference/service.js +33 -1
  22. package/dist/repositories/order.repository.js +11 -0
  23. package/dist/sync/command-queue.service.js +13 -13
  24. package/dist/sync/engine.js +18 -9
  25. package/dist/sync/online-gates.d.ts +9 -0
  26. package/dist/sync/online-gates.js +36 -3
  27. package/dist/sync/order-snapshots.service.d.ts +10 -0
  28. package/dist/sync/order-snapshots.service.js +96 -0
  29. package/dist/sync/sync-jobs.service.js +35 -8
  30. package/dist/sync/transition-endpoints.d.ts +9 -1
  31. package/dist/sync/transition-endpoints.js +16 -5
  32. package/dist/sync/transition-errors.d.ts +32 -0
  33. package/dist/sync/transition-errors.js +61 -0
  34. package/dist/sync/transport-rest.d.ts +5 -3
  35. package/dist/sync/transport-rest.js +10 -3
  36. package/dist/utils/error.js +3 -0
  37. package/dist/utils/order-transitions.js +17 -1
  38. 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 Error("ReplayDeferred: no business/auth, deferring queued command");
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 (message.includes("ReplayDeferred")) {
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
- // 409 ORDER_NUMBER_TAKEN is retryable once with new RRR
291
- const code = apiErr?.code ?? apiErr?.data?.code;
292
- if (apiErr?.status === 409 && String(code).includes("ORDER_NUMBER_TAKEN")) {
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?.name === "ApiError" &&
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 apiErr = e;
392
- const isNonRetryable = apiErr?.name === "ApiError" &&
393
- typeof apiErr.status === "number" &&
394
- apiErr.status >= 400 &&
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;
@@ -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 Error("Sync requires active business and login — business_id missing or not authenticated");
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 Error("ReplayDeferred: no transport available, keeping command queued");
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). Surface it to the
152
- // caller instead of masking it as a connectivity problem and silently
153
- // queueing a command that can never succeed.
154
- if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
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: error.status,
159
- message: error.message,
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 Error("ReplayDeferred: no transport available, keeping command queued");
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;