Skip to content

Commit f87d214

Browse files
committed
feat(sync): validate scopes server-side + make UI a sync consumer
1 parent 64a86fa commit f87d214

8 files changed

Lines changed: 99 additions & 6 deletions

File tree

packages/server/__tests__/sync.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,20 @@ describe('server config sync (Phase D)', () => {
114114
expect(div.map((x) => x.deviceId)).not.toContain('devA');
115115
d.ws.close();
116116
});
117+
118+
it('drops a push with an unknown scope (no revision, no broadcast)', async () => {
119+
const store = openStore();
120+
const before = store.getSyncState('demo').revision;
121+
const e = await connect(port);
122+
await wait(50);
123+
124+
send(e.ws, { type: 'sync_push', scope: 'garbage', config: { evil: true }, deviceId: 'devA', baseRevision: before });
125+
await wait(150);
126+
127+
expect(e.sync.find((m) => m.type === 'sync_update')).toBeUndefined();
128+
expect(store.getSyncState('demo').revision).toBe(before);
129+
e.ws.close();
130+
});
117131
});
118132

119133
describe('server config sync — disabled + secrets gate', () => {

packages/server/src/server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { advertise, type AdvertiseHandle } from '@wavegrid/discovery';
22
import { type Layout, loadWavegridConfig, type ResolvedConfig } from '@wavegrid/layout';
3-
import { openStore } from '@wavegrid/settings';
3+
import { isValidScope, openStore } from '@wavegrid/settings';
44
import * as fs from 'fs';
55
import http from 'http';
66
import { resolve } from 'path';
@@ -380,6 +380,8 @@ function broadcastSync(update: SyncUpdateMessage): void {
380380
/** Serialize + persist a client's config push, then broadcast the revision. */
381381
function handleSyncPush(msg: SyncPushMessage): void {
382382
if (!msg.scope) return;
383+
// Reject anything that isn't a known scope (project / device:<id> / secrets).
384+
if (!isValidScope(msg.scope)) return;
383385
// Replication off: the edit stays local to the laptop that made it.
384386
if (!syncEnabled()) return;
385387
// Secrets never ride the sync channel unless explicitly opted in.

packages/settings/__tests__/sync.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
applyUpdate,
88
deviceScope,
99
divergentDevices,
10+
isValidScope,
1011
mergeRemote,
1112
projectScope,
1213
readSyncState,
@@ -37,6 +38,47 @@ describe('config sync (settings layer)', () => {
3738
expect(s.entries[deviceScope('B')].config).toEqual({ shard: [0, 5] });
3839
});
3940

41+
it('validates scopes: project / device:<id> / secrets, rejects junk', () => {
42+
expect(isValidScope('project')).toBe(true);
43+
expect(isValidScope('device:abc123')).toBe(true);
44+
expect(isValidScope('secrets')).toBe(true);
45+
expect(isValidScope('secret:beyond')).toBe(true);
46+
expect(isValidScope('secrets:beyond')).toBe(true);
47+
// junk
48+
expect(isValidScope('')).toBe(false);
49+
expect(isValidScope('device:')).toBe(false);
50+
expect(isValidScope('haxor')).toBe(false);
51+
expect(isValidScope('__proto__')).toBe(false);
52+
expect(isValidScope(42)).toBe(false);
53+
expect(isValidScope(null)).toBe(false);
54+
});
55+
56+
it('rejects a write with an invalid scope', () => {
57+
const p = paths();
58+
expect(() =>
59+
applyUpdate(p, 'demo', { scope: 'garbage', config: { x: 1 }, deviceId: 'A' })
60+
).toThrow(/Invalid sync scope/);
61+
// nothing was persisted
62+
expect(readSyncState(p, 'demo').revision).toBe(0);
63+
});
64+
65+
it('mergeRemote drops entries with invalid scopes', () => {
66+
const p = paths();
67+
const remote: SyncState = {
68+
version: 1,
69+
revision: 3,
70+
entries: {
71+
[projectScope()]: { scope: projectScope(), config: { ok: true }, revision: 1, updatedAt: '2026-06-01T00:00:00Z', deviceId: 'B' },
72+
haxor: { scope: 'haxor', config: { evil: true }, revision: 3, updatedAt: '2026-06-01T00:00:00Z', deviceId: 'B' }
73+
},
74+
acks: {}
75+
};
76+
const { state, changed } = mergeRemote(p, 'demo', remote);
77+
expect(changed).toBe(true);
78+
expect(state.entries[projectScope()].config).toEqual({ ok: true });
79+
expect(state.entries.haxor).toBeUndefined();
80+
});
81+
4082
it('records the author as having acked its own write', () => {
4183
const p = paths();
4284
applyUpdate(p, 'demo', { scope: projectScope(), config: {}, deviceId: 'A' });

packages/settings/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export {
2222
type ConfigUpdate,
2323
deviceScope,
2424
type DivergentDevice,
25+
isValidScope,
2526
projectScope,
2627
type SyncEntry,
2728
type SyncScope,

packages/settings/src/sync.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ export function deviceScope(deviceId: string): SyncScope {
3535
return `device:${deviceId}`;
3636
}
3737

38+
/**
39+
* Whether `scope` is a well-formed sync scope. The wire type is a bare string,
40+
* so a peer could push anything; this is the one gate that keeps junk out of
41+
* the replicated document. Accepts the project scope, a `device:<id>` scope,
42+
* and the secret scopes (`secrets` / `secret:<name>` / `secrets:<name>`), which
43+
* the server separately gates behind `sync.secrets`.
44+
*/
45+
export function isValidScope(scope: unknown): scope is SyncScope {
46+
if (typeof scope !== 'string' || scope.length === 0) return false;
47+
if (scope === 'project' || scope === 'secrets') return true;
48+
return /^device:.+$/.test(scope) || /^secrets?:.+$/.test(scope);
49+
}
50+
3851
/** One revisioned config entry within a project. */
3952
export interface SyncEntry {
4053
scope: SyncScope;
@@ -106,6 +119,7 @@ function write(paths: StorePaths, project: string, state: SyncState): void {
106119
*/
107120
export function applyUpdate(paths: StorePaths, project: string, update: ConfigUpdate): ApplyResult {
108121
if (!update.scope) throw new Error('A config update requires a scope.');
122+
if (!isValidScope(update.scope)) throw new Error(`Invalid sync scope: ${update.scope}`);
109123
const state = readSyncState(paths, project);
110124
const staleBase =
111125
typeof update.baseRevision === 'number' && update.baseRevision < state.revision;
@@ -150,6 +164,7 @@ export function mergeRemote(
150164
const state = readSyncState(paths, project);
151165
let changed = false;
152166
for (const [scope, incoming] of Object.entries(remote.entries ?? {})) {
167+
if (!isValidScope(scope)) continue; // never let a peer inject a junk scope
153168
const current = state.entries[scope];
154169
if (!current || wins(incoming, current)) {
155170
state.entries[scope] = incoming;

packages/ui/src/app.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -441,9 +441,14 @@ function MasterSliders({
441441
/* ---------- Main page ---------- */
442442

443443
export default function Home() {
444-
const config = useConfig();
444+
const [configRev, setConfigRev] = useState(0);
445+
const config = useConfig(configRev);
445446
const { user, token, checked, login } = useAuth();
446-
const { connected, grid, orientation, playlistState, settings, send } = useSocket(config?.simulatorUrl ?? null, token);
447+
const { connected, grid, orientation, playlistState, settings, send } = useSocket(
448+
config?.simulatorUrl ?? null,
449+
token,
450+
useCallback(() => setConfigRev((n) => n + 1), [])
451+
);
447452
const isPhone = useIsPhone();
448453

449454
const NUM_CANNONS = config?.numCannons ?? 49;

packages/ui/src/lib/use-config.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@ export interface GridConfig {
2020
* Fetch the resolved layout/config from the runtime API route.
2121
* A single UI build serves any installation — the layout (fixtures,
2222
* topology, counts) is the source of geometry.
23+
*
24+
* `refetchKey` re-runs the fetch when it changes — bump it when a config
25+
* change is replicated over the sync channel so the browser stays current.
2326
*/
24-
export function useConfig(): GridConfig | null {
27+
export function useConfig(refetchKey: number = 0): GridConfig | null {
2528
const [config, setConfig] = useState<GridConfig | null>(null);
2629

2730
useEffect(() => {
@@ -37,7 +40,7 @@ export function useConfig(): GridConfig | null {
3740
gridColumns: layout.cols
3841
});
3942
});
40-
}, []);
43+
}, [refetchKey]);
4144

4245
return config;
4346
}

packages/ui/src/lib/use-socket.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,15 @@ export interface Settings {
3030
animation: string | null;
3131
}
3232

33-
export function useSocket(url: string | null, token: string | null) {
33+
export function useSocket(
34+
url: string | null,
35+
token: string | null,
36+
onSyncConfig?: () => void
37+
) {
3438
const wsRef = useRef<WebSocket | null>(null);
39+
// Keep the latest callback without re-subscribing the socket on every render.
40+
const onSyncConfigRef = useRef(onSyncConfig);
41+
onSyncConfigRef.current = onSyncConfig;
3542
const [connected, setConnected] = useState(false);
3643
const [grid, setGrid] = useState<CannonColor[]>([]);
3744
const [orientation, setOrientation] = useState<Orientation>({ rotation: 0, flipH: false, flipV: false });
@@ -72,6 +79,10 @@ export function useSocket(url: string | null, token: string | null) {
7279
speed: msg.speed ?? 1.0,
7380
animation: msg.animation ?? null
7481
});
82+
} else if (msg.type === 'sync_update' || msg.type === 'sync_state') {
83+
// A config change was replicated from another device — refetch it so
84+
// the browser reflects the new layout/light-map without a reload.
85+
onSyncConfigRef.current?.();
7586
}
7687
} catch {
7788
// ignore

0 commit comments

Comments
 (0)