Decision: Module Interface Contract

Decision

Every protection module exports this exact shape:

export default {
  name: 'domain-lock',       // unique kebab-case slug — used in config flags and error messages
  deps: [],                  // names of modules that must start before this one
  supported: () => Boolean,  // feature-detects required browser APIs — returns false if unavailable
  start: (config, report) => () => void  // activates protection, returns stop function
}

supported() — required

Called by core before start(). If it returns false, the module is skipped gracefully — no error, no crash, optional debug log. This handles:

// Example: anti-debug module
supported: () => typeof SharedArrayBuffer !== 'undefined' && typeof Atomics !== 'undefined'

start(config, report) — required

start: (config, report) => {
  const interval = setInterval(() => {
    if (detected()) report('devtools-detect', 'high', 'devtools opened')
  }, 1000)
  return () => clearInterval(interval)  // stop function
}

deps: string[] — optional

Module names that core must start first. Core resolves the dependency graph before starting any modules. Circular deps throw at init time, not silently.

// blob-serving needs non-extractable-keys to be running first
deps: ['non-extractable-keys']

Why

Why supported() instead of try/catch in start()? Separates detection from activation. Core can log which modules were skipped and why in debug mode, without catching errors that might mask real bugs inside start().

Why pass report as a parameter instead of importing it? Keeps modules free of side-effect imports. Each module is a pure object — no global state. Makes unit testing trivial: pass a mock report function, assert it was called with the right args.

Why return a stop function instead of storing state in the module object? Module objects are singletons (ES module exports). Storing state on them would break multiple simultaneous instances (e.g. testing). Closures inside start() scope state correctly per-call.

Why deps as names not import references? Modules don’t import each other — they declare names. Core owns the registry and resolves the graph. This prevents circular import issues at the module file level.

What core does with this

// Core startup sequence
const modules = enabledModules(config)
const ordered = topoSort(modules)           // respects deps[]
for (const mod of ordered) {
  if (!mod.supported()) { skip(mod); continue }
  const stop = mod.start(config, report)
  registry.register(mod.name, stop)
}

Teardown: registry.stopAll() calls every registered stop function in reverse order.