5 revocable permission
GOAL
Give a third-party HUD plugin access to your game's live hit feed through a handle you can KILL later. Once revoked, every stale reference the plugin stashed must throw on its own — no hunting through the plugin's code for leftover pointers.
CONCEPT
Proxy.revocable(target, handler) hands back a { proxy, revoke } pair. Once revoke() has run, EVERY operation on the proxy — get, set, `in`, Object.keys, all of it — throws a TypeError. That makes it a capability: holding the handle IS the permission, and whoever granted it can take it back at any moment, without cooperation.
HINT
- An empty handler {} does the job: full access until revoked. - grantAccess returns { handle: proxy, revoke }. - Grants are independent — revoking one leaves the others alive.
MIRRORS
teardown in any plugin-friendly engine or UI library. You pass overlay plugins a reference to the game (or its input feed); after game.destroy() those stale references produce the familiar "reading 'players' of undefined" crash SOMEWHERE later. With a revocable handle the failure lands on the exact stale call site, the instant the leak is exercised, as a clear TypeError.
Run
node 05-revocable-permission.cjs (fails until you fix the TODO)Source
/*
* proto-dojo 05-proxy-reflect / 05-revocable-permission
* -----------------------------------------------
* GOAL: Give a third-party HUD plugin access to your game's live hit
* feed through a handle you can KILL later. Once revoked, every
* stale reference the plugin stashed must throw on its own — no
* hunting through the plugin's code for leftover pointers.
*
* CONCEPT: Proxy.revocable(target, handler) hands back a { proxy,
* revoke } pair. Once revoke() has run, EVERY operation on the
* proxy — get, set, `in`, Object.keys, all of it — throws a
* TypeError. That makes it a capability: holding the handle IS
* the permission, and whoever granted it can take it back at
* any moment, without cooperation.
*
* HINT: - An empty handler {} does the job: full access until
* revoked.
* - grantAccess returns { handle: proxy, revoke }.
* - Grants are independent — revoking one leaves the others
* alive.
*
* MIRRORS: teardown in any plugin-friendly engine or UI library. You
* pass overlay plugins a reference to the game (or its input
* feed); after game.destroy() those stale references produce
* the familiar "reading 'players' of undefined" crash
* SOMEWHERE later. With a revocable handle the failure lands
* on the exact stale call site, the instant the leak is
* exercised, as a clear TypeError.
*
* Run: node 05-revocable-permission.cjs (fails until you fix the TODO)
*/
'use strict';
const assert = require('node:assert');
const listeners = [];
const hitFeed = {
player: 'knight',
lastDamage: 7,
subscribe(cb) { listeners.push(cb); return listeners.length; },
takeHit(damage) { this.lastDamage = damage; for (const cb of listeners) cb(damage); },
};
/* ------------------------------------------------------------------ */
/* TODO 1: use Proxy.revocable(api, {}) and return { handle, revoke } */
/* where `handle` is the proxy — never the raw api object. */
/* ------------------------------------------------------------------ */
function grantAccess(api) {
// TODO 1: replace this leak with Proxy.revocable(api, {})
return { handle: api, revoke() {} };
}
/* ------------------------- checks --------------------------------- */
const grant = grantAccess(hitFeed);
assert.notStrictEqual(grant.handle, hitFeed,
'TODO 1: grantAccess handed out the RAW feed — once the plugin holds that ' +
'reference you can never take it back. Hand out a revocable proxy.');
assert.strictEqual(grant.handle.player, 'knight',
'before revocation the handle must behave exactly like the feed');
const seen = [];
grant.handle.subscribe((d) => seen.push(d));
hitFeed.takeHit(12);
assert.deepStrictEqual(seen, [12],
'method calls through the handle must work before revocation');
const grant2 = grantAccess(hitFeed); // a second, independent grant
grant.revoke(); // e.g. game.destroy() / plugin unloaded
assert.throws(() => grant.handle.player, TypeError,
'TODO 1: after revoke() ANY property read on the dead handle must throw ' +
'a TypeError — a no-op revoke() is not revocation');
assert.throws(() => ('lastDamage' in grant.handle), TypeError,
'even the `in` operator must throw on a revoked handle — every trap is dead');
assert.strictEqual(grant2.handle.lastDamage, 12,
'revocation is per-handle: grant2 must still work after grant was revoked');
assert.strictEqual(hitFeed.lastDamage, 12,
'the owner with a direct reference is of course unaffected');
// Side effects performed BEFORE revocation are not undone: the plugin's
// callback is already inside `listeners`. Revocation kills the handle,
// not history — unsubscribe listeners separately in a real teardown.
hitFeed.takeHit(15);
assert.deepStrictEqual(seen, [12, 15],
'the previously-registered callback still fires — revoke() is not an undo');
console.log('OK 05-revocable-permission — dead handle throws, live grant unaffected');