js-dojo

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');

Solution