diff --git a/overlay.toml b/overlay.toml
index 97adc01..e80c61a 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -26,8 +26,9 @@
# the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant.
#
-# Current: 2 — the transport plus the read path (docs/rust-link/PROTOCOL.md §8).
-protocol = 3
+# Current: 4 — the transport, the read path, identity, and the permission mirror
+# (docs/rust-link/PROTOCOL.md §10).
+protocol = 4
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index 520dcc2..c32cdf7 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -56,7 +56,7 @@ namespace Oxide.Plugins
/// overlay's overlay.toml carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
///
- private const int ProtocolVersion = 3;
+ private const int ProtocolVersion = 4;
///
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -231,7 +231,10 @@ namespace Oxide.Plugins
"OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned",
"OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath",
"OnPlayerReported", "OnUserBanned", "OnUserUnbanned",
- "CanUserLogin", "OnUserApproved"
+ "CanUserLogin", "OnUserApproved",
+ "OnUserPermissionGranted", "OnUserPermissionRevoked",
+ "OnUserGroupAdded", "OnUserGroupRemoved",
+ "OnGroupPermissionGranted", "OnGroupPermissionRevoked"
};
private void Init()
@@ -286,6 +289,13 @@ namespace Oxide.Plugins
_cadence = null;
}
+ // A half-drained permission sync is abandoned rather than finished, and the site is
+ // told nothing — which is right: the report would describe a set that was only partly
+ // applied, and the next sync re-derives the whole answer from the store anyway. What
+ // must not happen is the timer outliving the plugin and writing during a reload.
+ _permJob = null;
+ StopPermTimer();
+
_running = false;
_stopping.Set();
Wake.Set();
@@ -754,6 +764,19 @@ namespace Oxide.Plugins
HandleLinkConfirm(cmd, reqId);
break;
+ case "perm.catalogue":
+ // Protocol 4. What this server's loaded plugins have registered — the option
+ // source the website's authoring form offers, so a grant can only be written
+ // against a name that will actually resolve.
+ HandlePermCatalogue(reqId);
+ break;
+
+ case "perm.sync":
+ // The first command that WRITES to the game (protocol 4, R2). Everything
+ // before it read, or spent a code the game itself had minted.
+ HandlePermSync(cmd, reqId);
+ break;
+
default:
Puts($"unknown command '{verb}'");
break;
@@ -1996,6 +2019,981 @@ namespace Oxide.Plugins
return Guid.NewGuid().ToString("N").Substring(0, CodeLength).ToUpperInvariant();
}
+ // ---- site-owned permissions (protocol 4) ----
+ //
+ // R2: the website is the author of record for groups and grants, and this framework's own
+ // permission store is an ENFORCEMENT CACHE. Every third-party plugin honours a site grant
+ // with no adapter, because they all already call `UserHasPermission`.
+ //
+ // ── One verb, and the plugin does the diffing (D32) ───────────────────────
+ //
+ // The site sends the whole set it authors for this server; this plugin compares it against
+ // the live store and writes only what differs. It is the same shape as a board, pointed the
+ // other way: one producer, re-sent on connect, reconciled by the receiver.
+ //
+ // The alternative — shipping this server's permission store to the website and diffing
+ // there — was rejected for two reasons. The store is the bigger of the two sets and would
+ // cross the wire constantly, and a website holding a copy of it has a second source of
+ // truth that is stale the moment it lands.
+ //
+ // ── The site must not be able to stall the game, either ───────────────────
+ //
+ // This is the first command whose work is UNBOUNDED: a community with two thousand linked
+ // players has thousands of grants in one frame, and applying them in the tick the frame
+ // arrives is a freeze the operator will blame on the game. So a sync is compiled into a
+ // list of single-store operations and drained a few hundred at a time on a timer, and the
+ // report goes back when the last one lands.
+ //
+ // That is the same rule the outbound queue lives under (§5), applied to the inbound half:
+ // **nothing the far side sends may cost the main thread an unbounded amount of work.**
+ //
+ // ── What it reports, and what it will not do ──────────────────────────────
+ //
+ // • A permission this server has not REGISTERED is reported `unresolved` and skipped.
+ // `GrantUserPermission` silently no-ops for an unregistered name — void, no throw, no
+ // log (PLAN.md §12.2 rule 1) — so without the pre-check a re-push into a server missing
+ // one plugin is a silent partial. Registering the name here instead would fabricate a
+ // permission the operator never installed (D33).
+ // • A holder the site does not author is reported `foreign` and never touched (D31).
+ // Somebody typing `oxide.grant` during an incident is drift, not an error, and the
+ // website offers an operator two answers to it.
+ // • A group member who has never connected is reported `pending`. The store has no user
+ // record to put in a group yet (§12.2 rule 4), and the membership lands on their first
+ // connection — a direct grant would have reached them immediately, and pretending the
+ // two are the same thing is how a site promises access it did not give.
+
+ ///
+ /// How many store operations one timer step performs.
+ ///
+ ///
+ /// Each is a dictionary lookup and, at most, one write to an in-memory structure the
+ /// framework flushes on its own schedule — microseconds. Two hundred of them is well
+ /// under a frame at 60fps, and it drains ten thousand operations in about two and a
+ /// half seconds, which is inside the sidecar's reply timeout.
+ ///
+ ///
+ private const int PermOpsPerStep = 200;
+
+ /// How often the sync drain runs while a job is in flight.
+ private const float PermStepSeconds = 0.05f;
+
+ ///
+ /// The ceiling on one sync, in operations.
+ ///
+ ///
+ /// A frame larger than this is refused whole rather than applied for a while and then
+ /// abandoned. The website has its own limit below this one, so reaching it means
+ /// something is wrong rather than something is big — and a half-applied permission set
+ /// is the state nobody can reason about.
+ ///
+ ///
+ private const int PermMaxOps = 20000;
+
+ /// The operations a compiled sync is made of. One store question each.
+ private enum PermOpKind
+ {
+ EnsureGroup,
+ EnsureGroupPermission,
+ EnsureMember,
+ EnsureGrant,
+ RetireGroupPermission,
+ RetireMember,
+ RetireGrant,
+ RetireGroup,
+ ScanPermission,
+ ScanGroup
+ }
+
+ private struct PermOp
+ {
+ public PermOpKind Kind;
+
+ /// A Steam id, or a group name — whichever the op is about.
+ public string Subject;
+
+ /// A permission name, a group name, or null.
+ public string Object;
+
+ /// Only uses these.
+ public string Title;
+
+ public int Rank;
+ }
+
+ ///
+ /// One sync in flight. There is at most one: a second arriving while this one drains is
+ /// answered `busy` rather than interleaved, because two writers reconciling the same
+ /// store against two different desired sets is a race with no correct outcome.
+ ///
+ private sealed class PermSyncJob
+ {
+ public string ReqId;
+ public string SetId;
+ public List Ops = new List();
+ public int Next;
+
+ public int Grants;
+ public int Revokes;
+ public int GroupsCreated;
+ public int GroupsRemoved;
+ public int GroupPermissions;
+ public int GroupPermissionsRemoved;
+ public int Members;
+ public int MembersRemoved;
+ public int AlreadyCorrect;
+ public int Absent;
+
+ public readonly List Unresolved = new List();
+ public readonly List