@amxts/perms 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/players.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The players on the server: who each one is in the permissions, what he
3
+ * may do, and AMX Mod X's flags written from his `amxx.*` nodes, so Pawn
4
+ * plugins' `access` and `cmd_access` keep working.
5
+ */
6
+ import { Cvar, Player, clearTimeout, server, setTimeout } from "@amxts/core";
7
+ import { Hash_Sha256 } from "@amxts/core/constants";
8
+ import { admins_flush, admins_push, hash_string, read_flags, remove_user_flags, set_user_flags } from "@amxts/core/natives";
9
+ import type { User } from "./model";
10
+ import { allUsers, answer, nextExpiry, primaryGroup, sourcesOf, userWith } from "./model";
11
+
12
+ /** AMX Mod X's admin flags: the `amxx.<name>` node and its letter in `users.ini`. */
13
+ export interface AdminFlag {
14
+ name: string;
15
+ letter: string;
16
+ }
17
+
18
+ export const FLAGS: AdminFlag[] = [
19
+ { name: "immunity", letter: "a" },
20
+ { name: "reservation", letter: "b" },
21
+ { name: "kick", letter: "c" },
22
+ { name: "ban", letter: "d" },
23
+ { name: "slay", letter: "e" },
24
+ { name: "map", letter: "f" },
25
+ { name: "cvar", letter: "g" },
26
+ { name: "cfg", letter: "h" },
27
+ { name: "chat", letter: "i" },
28
+ { name: "vote", letter: "j" },
29
+ { name: "password", letter: "k" },
30
+ { name: "rcon", letter: "l" },
31
+ { name: "levelA", letter: "m" },
32
+ { name: "levelB", letter: "n" },
33
+ { name: "levelC", letter: "o" },
34
+ { name: "levelD", letter: "p" },
35
+ { name: "levelE", letter: "q" },
36
+ { name: "levelF", letter: "r" },
37
+ { name: "levelG", letter: "s" },
38
+ { name: "levelH", letter: "t" },
39
+ { name: "menu", letter: "u" },
40
+ { name: "banTemp", letter: "v" },
41
+ { name: "admin", letter: "y" },
42
+ ];
43
+
44
+ // The cvars admin.amxx registers, which server configs set.
45
+ const mode = new Cvar("amx_mode", "1");
46
+ const passwordField = new Cvar("amx_password_field", "_pw");
47
+ const defaultAccess = new Cvar("amx_default_access", "z");
48
+
49
+ interface PlayerPerms {
50
+ user: User | null;
51
+ sources: Map<string, boolean>[];
52
+ /** What the sources answered, for "change": the flags and the group; the nodes are asked fresh. */
53
+ signature: string;
54
+ }
55
+
56
+ const online = new Map<number, PlayerPerms>();
57
+ const listeners: ((player: Player) => void)[] = [];
58
+ let writesFlags = true;
59
+ let expiry = 0;
60
+
61
+ export function onChange(listener: (player: Player) => void) {
62
+ listeners.push(listener);
63
+ }
64
+
65
+ /** Whether the module writes the players' flags; not while admin.amxx does. */
66
+ export function setWritesFlags(on: boolean) {
67
+ writesFlags = on;
68
+ }
69
+
70
+ export function sha256(text: string) {
71
+ return `sha256:${hash_string(text, Hash_Sha256)}`;
72
+ }
73
+
74
+ /**
75
+ * The player's entry: by SteamID, then IP, then name with the password his
76
+ * info carries. With `amx_mode 2`, as admin.amxx does, a player without an
77
+ * entry - or with a name entry's wrong password - is kicked; otherwise he
78
+ * plays with the default group's rights.
79
+ */
80
+ function identify(player: Player) {
81
+ const entry = entryOf(player);
82
+ if (entry != null || mode.number != 2) return entry;
83
+
84
+ player.kick("You have no entry on this server");
85
+ return null;
86
+ }
87
+
88
+ function entryOf(player: Player) {
89
+ const bySteam = player.steamId.startsWith("STEAM_") && !player.steamId.includes("LAN") ? userWith(player.steamId) : null;
90
+ if (bySteam != null) return bySteam;
91
+
92
+ const byIp = userWith(`ip:${player.ip}`);
93
+ if (byIp != null) return byIp;
94
+
95
+ const byName = userWith(`name:${player.name}`);
96
+ if (byName == null) return null;
97
+ if (byName.password == "" || sha256(player.info.get(passwordField.value)) == byName.password) return byName;
98
+
99
+ player.print(`[perms] Wrong password for this name: set it with setinfo ${passwordField.value} <password>`, "console");
100
+ return null;
101
+ }
102
+
103
+ /** The letters of the player's `amxx.*` nodes; `amx_default_access` when he has none. */
104
+ function lettersOf(sources: Map<string, boolean>[]) {
105
+ const letters = FLAGS.filter(flag => answer(sources, `amxx.${flag.name}`)).map<string>(flag => flag.letter).join("");
106
+ return letters != "" ? letters : defaultAccess.value;
107
+ }
108
+
109
+ /** Works out a player's permissions again, writes his flags, and tells the listeners when anything changed. */
110
+ export function refresh(player: Player) {
111
+ if (!player.isConnected) return;
112
+
113
+ const now = Date.now();
114
+ const user = mode.number == 0 ? null : identify(player);
115
+ if (!player.isConnected) return;
116
+
117
+ const sources = sourcesOf(user, now);
118
+ const letters = lettersOf(sources);
119
+ const signature = `${letters}|${primaryGroup(user, now)}|${JSON.stringify(sources.map<string[]>(source => sourceKeys(source)))}`;
120
+ const before = online.get(player.id);
121
+ online.set(player.id, { user, sources, signature });
122
+
123
+ if (writesFlags) {
124
+ remove_user_flags(player.id);
125
+ set_user_flags(player.id, read_flags(letters));
126
+ }
127
+ if (before == null || before.signature != signature) listeners.forEach(listener => listener(player));
128
+ }
129
+
130
+ function sourceKeys(source: Map<string, boolean>) {
131
+ const keys: string[] = [];
132
+ source.forEach((value, node) => keys.push(value ? node : `-${node}`));
133
+ return keys;
134
+ }
135
+
136
+ export function refreshAll() {
137
+ server.players.forEach(refresh);
138
+ fillAdminCache();
139
+ scheduleExpiry();
140
+ }
141
+
142
+ export function forget(id: number) {
143
+ online.delete(id);
144
+ }
145
+
146
+ export function has(player: Player, node: string) {
147
+ const state = online.get(player.id);
148
+ return answer(state != null ? state.sources : sourcesOf(null, Date.now()), node);
149
+ }
150
+
151
+ export function userOf(player: Player) {
152
+ return online.get(player.id)?.user ?? null;
153
+ }
154
+
155
+ /** The player's entry id, or the id his entry would get: his SteamID, or his IP when he has none. */
156
+ export function idOf(player: Player) {
157
+ const user = userOf(player);
158
+ if (user != null) return user.id;
159
+ return player.steamId.startsWith("STEAM_") && !player.steamId.includes("LAN") ? player.steamId : `ip:${player.ip}`;
160
+ }
161
+
162
+ /** AMX Mod X's own list of admins, as admin.amxx fills it, for plugins that read it with admins_lookup. */
163
+ function fillAdminCache() {
164
+ if (!writesFlags) return;
165
+
166
+ admins_flush();
167
+ const now = Date.now();
168
+ for (const user of allUsers()) {
169
+ const letters = lettersOf(sourcesOf(user, now));
170
+ const kind = user.id.startsWith("ip:") ? "d" : user.id.startsWith("name:") ? "" : "c";
171
+ const auth = user.id.startsWith("STEAM_") ? user.id : user.id.substring(user.id.indexOf(":") + 1);
172
+ admins_push(auth, user.password, read_flags(letters), read_flags(user.password == "" ? `${kind}e` : kind));
173
+ }
174
+ }
175
+
176
+ /** Wakes up when the nearest grant of an online player or a group ends, and works it out then. */
177
+ function scheduleExpiry() {
178
+ if (expiry != 0) clearTimeout(expiry);
179
+
180
+ const users: User[] = [];
181
+ online.forEach((state) => {
182
+ if (state.user != null) users.push(state.user);
183
+ });
184
+ const next = nextExpiry(users, Date.now());
185
+ expiry = next == 0 ? 0 : setTimeout(() => refreshAll(), Math.min(next - Date.now() + 50, 24 * 3600 * 1000));
186
+ }
package/src/server.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The module on the server: the store opened with the project's options and
3
+ * `.env`, players worked out as they join and rename, the commands, and
4
+ * admin.amxx's commands for the configs and plugins that use them.
5
+ */
6
+ import { env, game, server } from "@amxts/core";
7
+ import { find_plugin_byfile } from "@amxts/core/natives";
8
+ import type { PermsOptions } from "./types";
9
+ import { declare, setThisServer } from "./model";
10
+ import { DEFAULT_STORE, setStore } from "./editor";
11
+ import { run } from "./commands";
12
+ import { forget, refresh, refreshAll, setWritesFlags } from "./players";
13
+ import { open, reload } from "./state";
14
+
15
+ /** Plugins that write the players' flags themselves. */
16
+ const ADMIN_PLUGINS = ["admin.amxx", "admin_sql.amxx"];
17
+
18
+ /** A player who joined the game; one only connecting is worked out when he joins. */
19
+ function refreshSlot(id: number) {
20
+ const player = server.players.find(each => each.id == id);
21
+ if (player != null) refresh(player);
22
+ }
23
+
24
+ /** The words of a command; the engine splits a line at `:`, so `STEAM_0 : 1 : 5` is put back into `STEAM_0:1:5`. */
25
+ function splitWords(text: string) {
26
+ return text.replace(/\s*:\s*/g, ":").trim().split(" ").filter(each => each != "");
27
+ }
28
+
29
+ export function start(options: PermsOptions) {
30
+ setThisServer(env("PERMS_SERVER", ""));
31
+ const editor = env("PERMS_EDITOR_URL", "");
32
+ setStore(editor != "" ? editor : options.editor != "" ? options.editor : DEFAULT_STORE);
33
+ declare("perms.view", "See groups and permissions with the perms commands in the game", false, false);
34
+
35
+ server.addEventListener("init", () => {
36
+ const running = ADMIN_PLUGINS.filter(file => find_plugin_byfile(file) >= 0);
37
+ if (running.length > 0) {
38
+ setWritesFlags(false);
39
+ console.error(`[perms] ${running.join(", ")} is running and gives the players' flags itself - perms does not; comment it out in plugins.ini`);
40
+ }
41
+ open(options.file, env("PERMS_DB", ""));
42
+ });
43
+
44
+ server.addEventListener("authorized", event => refreshSlot(event.player.id));
45
+ server.addEventListener("putInServer", event => refreshSlot(event.player.id));
46
+ server.addEventListener("disconnected", event => forget(event.player.id));
47
+ game.addEventListener("changeName", event => refresh(event.player), true);
48
+
49
+ server.addServerCommand("perms [args]", ({ args }) => {
50
+ run(null, splitWords(args ?? ""), line => console.log(line));
51
+ });
52
+ server.addCommand("perms [args]", ({ player, args }) => {
53
+ run(player, splitWords(args ?? ""), line => player.print(line, "console"));
54
+ });
55
+ server.addCommand("/perms [args]", ({ player, args }) => {
56
+ run(player, splitWords(args ?? ""), line => player.print(line, "console"));
57
+ });
58
+ server.addServerCommand("amx_reloadadmins", () => {
59
+ reload();
60
+ });
61
+ server.addEventListener("configsExecuted", () => refreshAll());
62
+ }
package/src/state.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Where the permissions live - the file or MySQL - and the one way they
3
+ * change: `commit(changes)` applies them to the model, writes them to the
4
+ * store and works the online players out again.
5
+ */
6
+ import { setInterval } from "@amxts/core";
7
+ import type { Change, SkipCode } from "./model";
8
+ import { apply, dropExpired, reasonOf } from "./model";
9
+ import * as database from "./db-store";
10
+ import { filePath, readFile, setFile, writeFile } from "./file-store";
11
+ import { refreshAll } from "./players";
12
+
13
+ /** A change the model refused, and why. */
14
+ export interface Skipped {
15
+ change: Change;
16
+ code: SkipCode;
17
+ reason: string;
18
+ }
19
+
20
+ export interface Committed {
21
+ done: Change[];
22
+ skipped: Skipped[];
23
+ }
24
+
25
+ const POLL_MS = 5000;
26
+
27
+ /** Starts the store: MySQL when `url` is given, else the file; reads it. */
28
+ export async function open(file: string, url: string) {
29
+ setFile(file);
30
+ if (url == "") {
31
+ reloadFromFile();
32
+ return;
33
+ }
34
+
35
+ database.useDatabase(url);
36
+ try {
37
+ await database.createTables();
38
+ await database.readDatabase();
39
+ console.log("[perms] permissions read from MySQL");
40
+ } catch (error) {
41
+ console.error(`[perms] MySQL: ${error.message} - trying again every ${POLL_MS / 1000} s`);
42
+ }
43
+ refreshAll();
44
+ setInterval(() => pollDatabase(), POLL_MS);
45
+ }
46
+
47
+ function reloadFromFile() {
48
+ if (!readFile()) {
49
+ console.error(`[perms] ${filePath()} could not be read - the permissions read before stay`);
50
+ return;
51
+ }
52
+ refreshAll();
53
+ }
54
+
55
+ async function pollDatabase() {
56
+ try {
57
+ if (!(await database.changedElsewhere())) return;
58
+ await database.readDatabase();
59
+ refreshAll();
60
+ } catch (error) {
61
+ console.error(`[perms] MySQL: ${error.message}`);
62
+ }
63
+ }
64
+
65
+ /** Reads the store again: `perms reload`, `amx_reloadadmins`. */
66
+ export async function reload() {
67
+ if (!database.usesDatabase()) {
68
+ reloadFromFile();
69
+ return;
70
+ }
71
+
72
+ await database.readDatabase();
73
+ refreshAll();
74
+ }
75
+
76
+ /** Applies changes in order, writes those that applied, and works the players out again. */
77
+ export async function commit(changes: Change[]) {
78
+ const committed: Committed = { done: [], skipped: [] };
79
+ for (const change of changes) {
80
+ const code = apply(change);
81
+ if (code == "") committed.done.push(change);
82
+ else committed.skipped.push({ change, code, reason: reasonOf(code, change) });
83
+ }
84
+ if (committed.done.length == 0) return committed;
85
+
86
+ dropExpired(Date.now());
87
+ if (database.usesDatabase()) await database.writeChanges(committed.done);
88
+ else writeFile();
89
+ refreshAll();
90
+ return committed;
91
+ }
92
+
93
+ /** Writes the whole model to the store, after an import. */
94
+ export async function writeEverything() {
95
+ if (database.usesDatabase()) await database.writeAll();
96
+ else writeFile();
97
+ refreshAll();
98
+ }
99
+
100
+ export function storageName() {
101
+ return database.usesDatabase() ? "mysql" : "file";
102
+ }
package/src/time.ts ADDED
@@ -0,0 +1,48 @@
1
+ /** Moments and durations as files, commands and the editor write them. */
2
+
3
+ const UNITS = new Map<string, number>([["s", 1], ["m", 60], ["h", 3600], ["d", 86400], ["w", 604800]]);
4
+
5
+ /** Seconds in `30d`, `12h`, `90m`, `45s`, `2w`; -1 for anything else. */
6
+ export function durationSeconds(text: string) {
7
+ const unit = UNITS.get(text.substring(text.length - 1).toLowerCase()) ?? 0;
8
+ const amount = Number(text.substring(0, text.length - 1));
9
+ if (unit == 0 || text.length < 2 || isNaN(amount) || amount <= 0) return -1;
10
+ return amount * unit;
11
+ }
12
+
13
+ /** `2026-12-01`, `2026-12-01T00:00`, `2026-12-01T00:00:00Z` (UTC) as ms since 1970; 0 for empty or unreadable text. */
14
+ export function momentOf(text: string) {
15
+ const date = text.trim().replace(" ", "T").replace("Z", "");
16
+ const halves = `${date}T00:00`.split("T");
17
+ const parts = halves[0].split("-");
18
+ const clock = halves[1].split(":");
19
+ if (parts.length != 3 || clock.length < 2) return 0;
20
+
21
+ const year = Number(parts[0]);
22
+ const month = Number(parts[1]);
23
+ const dayOfMonth = Number(parts[2]);
24
+ const hours = Number(clock[0]);
25
+ const minutes = Number(clock[1]);
26
+ const seconds = clock.length > 2 ? Number(clock[2]) : 0;
27
+ if ([year, month, dayOfMonth, hours, minutes, seconds].some(value => isNaN(value))) return 0;
28
+ return Date.UTC(year, month - 1, dayOfMonth, hours, minutes, Math.floor(seconds));
29
+ }
30
+
31
+ /** A moment as the file and the editor write it, `2026-12-01T00:00:00Z`; "" for 0. */
32
+ export function textOf(moment: number) {
33
+ if (moment == 0) return "";
34
+ return `${new Date(moment).toISOString().substring(0, 19)}Z`;
35
+ }
36
+
37
+ /** What is left of a moment, for the console: `29d 4h`, `3h 10m`, `5m`; "" for 0. */
38
+ export function leftOf(moment: number, now: number) {
39
+ if (moment == 0) return "";
40
+
41
+ const seconds = Math.max(0, Math.floor((moment - now) / 1000));
42
+ const days = Math.floor(seconds / 86400);
43
+ const hours = Math.floor((seconds % 86400) / 3600);
44
+ const minutes = Math.floor((seconds % 3600) / 60);
45
+ if (days > 0) return `${days}d ${hours}h`;
46
+ if (hours > 0) return `${hours}h ${minutes}m`;
47
+ return `${minutes}m`;
48
+ }
package/src/types.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { Player } from "@amxts/core";
2
+
3
+ /** The module's options in `amxts.config.ts`, under `perms`. */
4
+ export interface PermsOptions {
5
+ /** The file under `configs/` the permissions are kept in, without its extension; `"perms"` reads `perms.yaml` (or `.json`). */
6
+ file: string;
7
+ /** The editor's store; `PERMS_EDITOR_URL` in `.env` overrides it. */
8
+ editor: string;
9
+ }
10
+
11
+ /** A permission a plugin declares, for the web editor and `perms nodes`. */
12
+ export interface PermissionInfo {
13
+ /** What it lets a player do, as the editor shows it: `"Kick players"`. */
14
+ description: string;
15
+ /** Whether a player no group or grant says anything about has it; `false` by default. */
16
+ everyone?: boolean;
17
+ }
18
+
19
+ /** How long a grant or a group lasts, and where. */
20
+ export interface GrantOptions {
21
+ /** Seconds from now; left out, it is permanent. */
22
+ for?: number;
23
+ /** The server it counts on (`PERMS_SERVER`); left out, every server. */
24
+ server?: string;
25
+ }
26
+
27
+ /** A player's permissions changed: he joined, a command or the editor changed them, or one expired. */
28
+ export interface PermsChangeEvent {
29
+ player: Player;
30
+ }
31
+
32
+ declare module "@amxts/core" {
33
+ interface ModuleOptions {
34
+ perms?: Partial<PermsOptions>;
35
+ }
36
+ }
@@ -0,0 +1,72 @@
1
+ // Perms' test kit: the AMX Mod X natives the module calls that the fake
2
+ // server leaves out - a player's flags written, AMX Mod X's admin list,
3
+ // hash_string, find_plugin_byfile - and what a test reads back:
4
+ //
5
+ // import { setup } from "@amxts/core/test-utils";
6
+ // import { permsOf } from "@amxts/perms/testing";
7
+ //
8
+ // const server = await setup({ files: { "addons/amxmodx/configs/perms.yaml": "..." } });
9
+ // const bob = server.join("Bob", { steamId: "STEAM_0:1:5" });
10
+ // bob.flags; // what set_user_flags wrote
11
+ // permsOf(server).admins; // what admins_push gave AMX Mod X
12
+ import type { FakeServer } from "@amxts/core/test-utils";
13
+ import { createHash } from "node:crypto";
14
+ import { defineTestKit } from "@amxts/core/test-utils";
15
+
16
+ /** An entry of AMX Mod X's admin list, as admins_push gave it. */
17
+ export interface PushedAdmin {
18
+ auth: string;
19
+ password: string;
20
+ access: number;
21
+ flags: number;
22
+ }
23
+
24
+ export interface PermsKit {
25
+ /** AMX Mod X's admin list. */
26
+ admins: PushedAdmin[];
27
+ /** The plugin files find_plugin_byfile finds, as if they were loaded: `["admin.amxx"]`. */
28
+ loadedPlugins: string[];
29
+ }
30
+
31
+ const HASH_SHA256 = 3;
32
+
33
+ const kits = new WeakMap<FakeServer, PermsKit>();
34
+
35
+ function install(server: FakeServer): PermsKit {
36
+ const kit: PermsKit = { admins: [], loadedPlugins: [] };
37
+ kits.set(server, kit);
38
+
39
+ server.defineNative("set_user_flags", (call, [id, flags]) => {
40
+ const player = call.server.player(id);
41
+ if (player) player.flags |= flags;
42
+ return 0;
43
+ });
44
+ server.defineNative("remove_user_flags", (call, [id, flags]) => {
45
+ const player = call.server.player(id);
46
+ if (player) player.flags &= ~flags;
47
+ return 0;
48
+ });
49
+ server.defineNative("admins_flush", () => {
50
+ kit.admins.length = 0;
51
+ return 0;
52
+ });
53
+ server.defineNative("admins_push", (call, [auth, password, access, flags]) => {
54
+ kit.admins.push({ auth: call.memory.text(auth), password: call.memory.text(password), access, flags });
55
+ return 0;
56
+ });
57
+ server.defineNative("hash_string", (call, [text, type, out, length]) => {
58
+ if (type !== HASH_SHA256) throw new Error(`hash_string: the test kit hashes SHA-256 only, not type ${type}`);
59
+ return call.memory.setText(out, length, createHash("sha256").update(call.memory.text(text)).digest("hex"));
60
+ });
61
+ server.defineNative("find_plugin_byfile", (call, [file]) => kit.loadedPlugins.indexOf(call.memory.text(file)));
62
+ return kit;
63
+ }
64
+
65
+ export default defineTestKit({ install });
66
+
67
+ /** The kit of a server Perms runs on. */
68
+ export function permsOf(server: FakeServer) {
69
+ const kit = kits.get(server);
70
+ if (!kit) throw new Error("Perms' test kit is not on this server: load @amxts/perms (or call setup()) first");
71
+ return kit;
72
+ }