@teincfood/core 0.10.25 → 0.10.26

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.
@@ -24,61 +24,73 @@ import { applyMenuCommandLocal } from "../reference/menu-apply";
24
24
  import { emitReferenceChanged } from "../reference/events";
25
25
  import { assertEntitledForCommand } from "../reference/entitlement-guard";
26
26
  import { isOnlineCapable } from "./online-gates";
27
+ /**
28
+ * Enforce the cached RBAC capabilities downloaded from the backend.
29
+ *
30
+ * Fail-open only when no capabilities record exists yet (first boot); once a
31
+ * record is present, an unknown capability is a real denial and must never
32
+ * reach the local cache or the outbox.
33
+ */
34
+ async function assertCachedCapability(businessId, required) {
35
+ try {
36
+ const db = await openSyncDatabase();
37
+ const rec = await getReferenceData(db, businessId, "capabilities");
38
+ const caps = rec?.payload?.data
39
+ ?.capabilities ?? [];
40
+ if (rec && caps.length > 0 && !caps.includes(required)) {
41
+ throw new ApiError(`Missing capability: ${required}`, 403, "FORBIDDEN");
42
+ }
43
+ }
44
+ catch (e) {
45
+ if (e instanceof ApiError && e.status === 403)
46
+ throw e;
47
+ }
48
+ }
27
49
  export async function runOfflineCapableMutation(options) {
28
- // Per-command capability guard before even attempting direct call
50
+ // Online-required gate first: these commands can never succeed offline —
51
+ // throw loud immediately instead of queuing what no transport can fulfill.
52
+ assertOnlineFor(options.commandType, options.payload);
53
+ // Per-command capability guard before touching local state or the network.
29
54
  const required = CAPABILITY_BY_COMMAND[options.commandType];
30
- if (required) {
31
- // Online-required gate first: these commands can never succeed offline —
32
- // throw loud immediately instead of queuing what no transport can fulfill.
33
- assertOnlineFor(options.commandType, options.payload);
34
- // Local-first for engine-routed domains (menu:*): persist to SQLite and the
35
- // durable outbox immediately, update the local reference cache, and let
36
- // replay upload to the cloud/LAN in the background. A slow or absent
37
- // network must never hold the form open.
38
- if (options.commandType.startsWith("menu:")) {
39
- // Preserve the entitlement gate that the engine normally applies before
40
- // queueing: local-first must not weaken subscription enforcement.
41
- await assertEntitledForCommand(options.businessId, options.commandType);
42
- const command = buildCommand({
43
- commandType: options.commandType,
44
- businessId: options.businessId,
45
- entityId: options.entityId,
46
- payload: options.payload,
55
+ // Local-first for engine-routed domains (menu:*): persist to SQLite and the
56
+ // durable outbox immediately, update the local reference cache, and let
57
+ // replay upload to the cloud/LAN in the background. A slow or absent
58
+ // network must never hold the form open.
59
+ if (options.commandType.startsWith("menu:")) {
60
+ if (required)
61
+ await assertCachedCapability(options.businessId, required);
62
+ // Preserve the entitlement gate that the engine normally applies before
63
+ // queueing: local-first must not weaken subscription enforcement.
64
+ await assertEntitledForCommand(options.businessId, options.commandType);
65
+ const command = buildCommand({
66
+ commandType: options.commandType,
67
+ businessId: options.businessId,
68
+ entityId: options.entityId,
69
+ payload: options.payload,
70
+ });
71
+ try {
72
+ const { keys } = await applyMenuCommandLocal(options.businessId, command);
73
+ if (keys.length)
74
+ emitReferenceChanged(options.businessId, keys);
75
+ }
76
+ catch (error) {
77
+ syncLogger.debug("OfflineCapable", "menu local apply failed", {
78
+ error: String(error),
47
79
  });
48
- try {
49
- const { keys } = await applyMenuCommandLocal(options.businessId, command);
50
- if (keys.length)
51
- emitReferenceChanged(options.businessId, keys);
52
- }
53
- catch (error) {
54
- syncLogger.debug("OfflineCapable", "menu local apply failed", {
80
+ }
81
+ const event = await commandQueueService.enqueue(command);
82
+ if (isOnlineCapable()) {
83
+ // Background only: save must return now.
84
+ void syncEngine.replayQueuedCommands().catch((error) => {
85
+ syncLogger.debug("OfflineCapable", "menu background replay failed", {
55
86
  error: String(error),
56
87
  });
57
- }
58
- const event = await commandQueueService.enqueue(command);
59
- if (isOnlineCapable()) {
60
- // Background only: save must return now.
61
- void syncEngine.replayQueuedCommands().catch((error) => {
62
- syncLogger.debug("OfflineCapable", "menu background replay failed", {
63
- error: String(error),
64
- });
65
- });
66
- }
67
- return event.payload;
68
- }
69
- try {
70
- const db = await openSyncDatabase();
71
- const rec = await getReferenceData(db, options.businessId, "capabilities");
72
- const caps = rec?.payload?.data?.capabilities ?? [];
73
- if (rec && caps.length > 0 && !caps.includes(required)) {
74
- throw new ApiError(`Missing capability: ${required}`, 403, "FORBIDDEN");
75
- }
76
- }
77
- catch (e) {
78
- if (e instanceof ApiError && e.status === 403)
79
- throw e;
88
+ });
80
89
  }
90
+ return event.payload;
81
91
  }
92
+ if (required)
93
+ await assertCachedCapability(options.businessId, required);
82
94
  try {
83
95
  // Cloud already known unreachable (or no network): skip the direct REST
84
96
  // attempt entirely — it would only burn a timeout before falling back.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.25",
3
+ "version": "0.10.26",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",