editable-guard — write-permission gate
Source:
src/lib/editable-guard.ts· 45 lines · no side effects beyond console + dialog prompt
Purpose
editable-guard is the cross-cutting "may I write?" check. Every entry point that could mutate mapStore (store mutators, action dispatcher, IPC callbacks) calls assertEditable() first:
state.canEdit === true→ returnstrue; the caller proceeds.state.canEdit === false(expired trial / expired licence / tampered / machine mismatch / …) → returnsfalse, logs a throttled (5 s) warn, attempts to open the activation dialog.
It uses zustand's getState() (not the hook) so the same code path works inside event handlers, store actions, and IPC callbacks.
Public API
| Symbol | Kind | Signature | Summary |
|---|---|---|---|
assertEditable | fn | (action?: string) => boolean | Sync check with side effects (warn / prompt) |
isEditable | fn | () => boolean | Pure read — no prompt |
Detailed entries
assertEditable(action = 'edit'): boolean
export function assertEditable(action = 'edit'): boolean {
const { state, promptActivation } = useLicenseStore.getState();
if (state.canEdit) return true;
const now = Date.now();
if (now - lastWarn > WARN_INTERVAL) {
lastWarn = now;
console.warn(`[license] Blocked ${action}: status=${state.status}. ${state.reason}`);
try {
promptActivation();
} catch {
// promptActivation may not be wired before the dialog mounts.
}
}
return false;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
action: caller-provided label for the warn line ('addEntity','undo', …). Defaults to'edit'.- Behaviour:
- Read
useLicenseStore.getState()synchronously. - If
canEdit, return immediately. - Otherwise, every 5 s, log one warn and attempt to open the activation dialog (try/catch protects against the prompt being unwired during very-early app startup).
- Return
false; the caller should bail out.
- Read
Source: editable-guard.ts:21-36.
isEditable(): boolean
export function isEditable(): boolean {
return useLicenseStore.getState().state.canEdit;
}2
3
Pure read — never warns, never prompts. Use from React render paths to disable buttons:
<Button disabled={!isEditable()}>Add Lane</Button>This is a snapshot — components do not re-render when canEdit flips. If you need reactivity, subscribe to the store:
const canEdit = useLicenseStore(selectCanEdit);Internal state
let lastWarn = 0;
const WARN_INTERVAL = 5 * 1000;2
Module-level throttle timestamp. Shared across the process — every caller observes one clock, so the console is not flooded.
Side effects
- Reads
useLicenseStore. - Throttled
console.warn. - Throttled
promptActivation()invocation (no-op until the dialog mounts and registers).
Test coverage
No dedicated tests; covered indirectly by mapStore.test.ts (mocked license state must reject writes).
Consumers
src/store/mapStore.ts—addEntity/updateEntity/removeEntity/ etc. mutatorssrc/hooks/useActionDispatcher.ts— undo/redo entrysrc/hooks/useDrawCommit.ts— FSM CONFIRMsrc/components/menu/*— disabled state viaisEditable
Design notes
Why not enforce in the main-process IPC layer? Because entities live in renderer-side Zustand; the main process only broadcasts the canEdit flag and depends on the renderer to short-circuit before writing.
Why not in zundo middleware? Middleware cannot tell read from write and cannot drive UI side-effects (the activation prompt). Manual assertEditable at each mutator entry stays the cleanest seam.
Why throttle 5 s? Drag-driven writes can exceed 60 calls/s. Without throttling the console floods. 5 s balances "user notices they are locked" against log readability.
Source map
| Lines | Content |
|---|---|
| 11 | import useLicenseStore |
| 13–14 | Throttle vars |
| 21–36 | assertEditable |
| 42–44 | isEditable |
Integration pattern with mutators
A typical mapStore mutator looks like:
// src/store/mapStore.ts
addEntity(entity: MapEntity) {
if (!assertEditable('addEntity')) return;
set((s) => ({
entities: new Map(s.entities).set(entity.id, entity),
}));
}
updateEntity(id: string, patch: Partial<MapEntity>) {
if (!assertEditable('updateEntity')) return;
set((s) => {
const e = s.entities.get(id);
if (!e) return s;
const next = new Map(s.entities);
next.set(id, { ...e, ...patch } as MapEntity);
return { entities: next };
});
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Conventions:
- Every write mutator's first line is
if (!assertEditable('actionName')) return;. actionNameis a verb, useful in the console ('addEntity'/'undo'/'paste'/'reparent').- Read mutators (
getEntity,select) must not guard — read operations are licence-agnostic.
Cooperation with component disabled state
For reactive UI:
import { useLicenseStore, selectCanEdit } from '@/store/licenseStore';
function AddLaneButton() {
const canEdit = useLicenseStore(selectCanEdit);
return (
<Button disabled={!canEdit} onClick={addLane}>
Add Lane
</Button>
);
}2
3
4
5
6
7
8
9
10
The selector path re-renders when canEdit flips — better UX than the snapshot read. The click handler still must call assertEditable — disabled is a UX hint, not a security boundary (a hostile user can re-enable in DevTools).
Throttle boundary
const WARN_INTERVAL = 5 * 1000; // 5 sMultiple failures in the same 5 s window log once. Date.now() is monotonic enough; even a clock rollback never triggers an extra warn (the throttle var only increases).
The throttle is process-wide — every mutator shares the same warn quota. This is a feature, not a bug, since it prevents log floods during a 60 fps drag.
Debugging hint
If a mutator silently no-ops in tests:
- Check
useLicenseStore.getState().state.status— is it reallyexpired/tampered? - Check the console — at least one warn within 5 s.
- Check
useLicenseStore.getState().promptActivation— hasActivationDialogmounted and registered?
See also
licenseStore— state sourcelicense-bridge— renderer IPC wrapperelectron/license-manager— main-process state machinesrc/store/mapStore.ts— real-world mutator callersrc/hooks/useActionDispatcher.ts— undo/redo guard