@amxts/perms 0.0.0-stage → 0.1.1

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/index.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Perms - permissions and groups for the server, in place of AMX Mod X's
3
+ * admin.amxx: permission nodes with wildcards and negation, groups with
4
+ * inheritance and weights, temporary grants, a file or MySQL, AMX Mod X's
5
+ * flags for Pawn plugins, and a web editor. How to use it: README.md.
6
+ */
7
+ import { Player } from "@amxts/core";
8
+ import { callingPlugin, onPluginStop } from "@amxts/core/kit";
9
+ import type { GrantOptions, PermissionInfo, PermsChangeEvent, PermsOptions } from "./types";
10
+ import { changeOf, declare as declareNode, primaryGroup, groupsOf as groupsOfUser } from "./model";
11
+ import * as players from "./players";
12
+ import { commit } from "./state";
13
+ import { start } from "./server";
14
+
15
+ export * from "./types";
16
+
17
+ export default defineModule<PermsOptions>({
18
+ meta: { name: "perms", configKey: "perms" },
19
+ requires: ["@amxts/config-core"],
20
+ imports: [{ from: "@amxts/perms", as: "perms" }],
21
+ defaults: { file: "perms", editor: "" },
22
+ setup(options) {
23
+ start(options);
24
+ },
25
+ });
26
+
27
+ /**
28
+ * Declares the plugin's permissions, so the web editor lists them with their
29
+ * descriptions and `perms nodes` prints them:
30
+ *
31
+ * perms.declare({
32
+ * "myplugin.kick": { description: "Kick players" },
33
+ * "myplugin.help": { description: "Use /help", everyone: true },
34
+ * });
35
+ */
36
+ export function declare(permissions: Record<string, PermissionInfo>) {
37
+ const nodes = Object.keys(permissions);
38
+ for (const node of nodes) {
39
+ const info = permissions[node]!;
40
+ if (!declareNode(node, info.description, info.everyone ?? false, false)) console.error(`[perms] "${node}" is declared twice - the first declaration stays`);
41
+ }
42
+ players.refreshAll();
43
+ }
44
+
45
+ /** Whether the player has a permission: `perms.has(player, "myplugin.kick")`. */
46
+ export function has(player: Player, node: string) {
47
+ return players.has(player, node);
48
+ }
49
+
50
+ /** The player's primary group: his group of the highest weight, `"default"` when he has none. */
51
+ export function groupOf(player: Player) {
52
+ return primaryGroup(players.userOf(player), Date.now());
53
+ }
54
+
55
+ /** The player's groups, highest weight first, `"default"` last. */
56
+ export function groupsOf(player: Player) {
57
+ return groupsOfUser(players.userOf(player), Date.now()).map<string>(group => group.name);
58
+ }
59
+
60
+ function forPlayer(player: Player, op: "memberAdd" | "memberRemove" | "grant" | "revoke", options: GrantDetails) {
61
+ const change = changeOf(op);
62
+ change.user = players.idOf(player);
63
+ change.until = options.seconds > 0 ? Date.now() + options.seconds * 1000 : 0;
64
+ change.server = options.server;
65
+ return change;
66
+ }
67
+
68
+ interface GrantDetails {
69
+ seconds: number;
70
+ server: string;
71
+ }
72
+
73
+ /** Puts the player into a group: `perms.addGroup(player, "vip", { for: 30 * 86400 })`; permanent without `for`. */
74
+ export function addGroup(player: Player, group: string, options: GrantOptions = {}) {
75
+ const change = forPlayer(player, "memberAdd", { seconds: options.for ?? 0, server: options.server ?? "" });
76
+ change.group = group;
77
+ commit([change]);
78
+ }
79
+
80
+ /** Takes the player out of a group. */
81
+ export function removeGroup(player: Player, group: string, options: GrantOptions = {}) {
82
+ const change = forPlayer(player, "memberRemove", { seconds: 0, server: options.server ?? "" });
83
+ change.group = group;
84
+ commit([change]);
85
+ }
86
+
87
+ /** Gives the player a permission of his own, or takes it away with `false`: `perms.set(player, "myplugin.trail", true, { for: 3600 })`. */
88
+ export function set(player: Player, node: string, value: boolean, options: GrantOptions = {}) {
89
+ const change = forPlayer(player, "grant", { seconds: options.for ?? 0, server: options.server ?? "" });
90
+ change.node = node;
91
+ change.value = value;
92
+ commit([change]);
93
+ }
94
+
95
+ /** Removes a permission of the player's own, so his groups decide again. */
96
+ export function unset(player: Player, node: string, options: GrantOptions = {}) {
97
+ const change = forPlayer(player, "revoke", { seconds: 0, server: options.server ?? "" });
98
+ change.node = node;
99
+ commit([change]);
100
+ }
101
+
102
+ /**
103
+ * Hears a player's permissions change - he joined, a command or the editor
104
+ * changed them, or one ran out:
105
+ *
106
+ * perms.addEventListener("change", ({ player }) => {
107
+ * if (perms.has(player, "myplugin.trail")) startTrail(player);
108
+ * });
109
+ */
110
+ export function addEventListener(_type: "change", listener: (event: PermsChangeEvent) => void) {
111
+ listeners.push({ listener, from: callingPlugin() });
112
+ }
113
+
114
+ interface Listener {
115
+ listener: (event: PermsChangeEvent) => void;
116
+ from: number;
117
+ }
118
+
119
+ let listeners: Listener[] = [];
120
+
121
+ players.onChange((player) => {
122
+ listeners.forEach(each => each.listener({ player }));
123
+ });
124
+
125
+ onPluginStop((plugin) => {
126
+ listeners = listeners.filter(each => each.from != plugin);
127
+ });
package/src/model.ts ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The permission model: groups, players, their grants, and the answer to
3
+ * "does this player have this node". Every change - a command, a plugin's
4
+ * call, the web editor, the database's - is a `Change`, applied here.
5
+ */
6
+
7
+ /** A permission given (`value` true) or taken away (false) - `node` may end in `*`. */
8
+ export interface Grant {
9
+ node: string;
10
+ value: boolean;
11
+ /** When it ends, ms since 1970; 0 never. */
12
+ until: number;
13
+ /** The server it counts on; "" every server. */
14
+ server: string;
15
+ }
16
+
17
+ /** A player's place in a group. */
18
+ export interface Membership {
19
+ group: string;
20
+ until: number;
21
+ server: string;
22
+ }
23
+
24
+ export interface Group {
25
+ name: string;
26
+ weight: number;
27
+ inherits: string[];
28
+ grants: Grant[];
29
+ }
30
+
31
+ /** A player as he is recognised: `STEAM_0:1:5`, `ip:1.2.3.4` or `name:Bob`. */
32
+ export interface User {
33
+ id: string;
34
+ name: string;
35
+ /** A `name:` entry's password, `sha256:<hex>`; "" for none. */
36
+ password: string;
37
+ groups: Membership[];
38
+ grants: Grant[];
39
+ }
40
+
41
+ export type ChangeOp
42
+ = | "grant" | "revoke"
43
+ | "groupCreate" | "groupDelete" | "groupWeight" | "parentAdd" | "parentRemove"
44
+ | "memberAdd" | "memberRemove" | "userCreate";
45
+
46
+ /** One change, the editor's contract in one shape: the fields an op does not use stay empty. */
47
+ export interface Change {
48
+ op: ChangeOp;
49
+ /** The group a grant is for, or the group a player joins. */
50
+ group: string;
51
+ /** The player a grant or a membership is for. */
52
+ user: string;
53
+ node: string;
54
+ value: boolean;
55
+ until: number;
56
+ server: string;
57
+ weight: number;
58
+ parent: string;
59
+ name: string;
60
+ }
61
+
62
+ /** Why a change was skipped, as the editor's contract names it; "" when it was applied. */
63
+ export type SkipCode = "" | "groupMissing" | "groupExists" | "userMissing" | "parentMissing" | "cycle" | "defaultGroup" | "invalid";
64
+
65
+ export interface Declared {
66
+ node: string;
67
+ description: string;
68
+ byDefault: boolean;
69
+ /** Declared by a Pawn plugin, through perms_declare. */
70
+ pawn: boolean;
71
+ }
72
+
73
+ export const DEFAULT_GROUP = "default";
74
+
75
+ let groups: Group[] = [];
76
+ let users: User[] = [];
77
+ const declared: Declared[] = [];
78
+ let thisServer = "";
79
+
80
+ /** A blank change of an op, to fill. */
81
+ export function changeOf(op: ChangeOp) {
82
+ const change: Change = { op, group: "", user: "", node: "", value: true, until: 0, server: "", weight: 0, parent: "", name: "" };
83
+ return change;
84
+ }
85
+
86
+ /** Replaces every group and player, as a store read them; `default` is made when missing. */
87
+ export function replaceAll(newGroups: Group[], newUsers: User[]) {
88
+ groups = newGroups;
89
+ users = newUsers;
90
+ if (groupNamed(DEFAULT_GROUP) == null) groups.unshift({ name: DEFAULT_GROUP, weight: 0, inherits: [], grants: [] });
91
+ }
92
+
93
+ export function allGroups() {
94
+ return groups;
95
+ }
96
+
97
+ export function allUsers() {
98
+ return users;
99
+ }
100
+
101
+ export function setThisServer(name: string) {
102
+ thisServer = name;
103
+ }
104
+
105
+ export function serverName() {
106
+ return thisServer;
107
+ }
108
+
109
+ export function groupNamed(name: string) {
110
+ return groups.find(group => group.name == name) ?? null;
111
+ }
112
+
113
+ /** A player's entry; `name:` ids match in any case. */
114
+ export function userWith(id: string) {
115
+ const wanted = normalId(id);
116
+ return users.find(user => user.id == wanted) ?? null;
117
+ }
118
+
119
+ /** `name:` ids are kept in lower case, the rest as written. */
120
+ export function normalId(id: string) {
121
+ return id.startsWith("name:") ? id.toLowerCase() : id;
122
+ }
123
+
124
+ // ---------------------------------------------------------------- declared nodes
125
+
126
+ /** Declares a node; `false` when another plugin declared it first. */
127
+ export function declare(node: string, description: string, byDefault: boolean, pawn: boolean) {
128
+ if (declared.some(each => each.node == node)) return false;
129
+
130
+ declared.push({ node, description, byDefault, pawn });
131
+ return true;
132
+ }
133
+
134
+ export function declaredNodes() {
135
+ return declared;
136
+ }
137
+
138
+ // ---------------------------------------------------------------- answers
139
+
140
+ function counts(until: number, server: string, now: number) {
141
+ return (until == 0 || until > now) && (server == "" || server == thisServer);
142
+ }
143
+
144
+ /** A group's own grants that count now, as node -> value; a negation wins over a grant of the same node. */
145
+ function grantsOf(list: Grant[], now: number) {
146
+ const map = new Map<string, boolean>();
147
+ for (const grant of list) {
148
+ if (!counts(grant.until, grant.server, now)) continue;
149
+ if (map.has(grant.node) && !map.get(grant.node)) continue;
150
+ map.set(grant.node, grant.value);
151
+ }
152
+ return map;
153
+ }
154
+
155
+ /** The groups a player is in now, highest weight first, `default` last. */
156
+ export function groupsOf(user: User | null, now: number) {
157
+ const list: Group[] = [];
158
+ if (user != null) {
159
+ for (const membership of user.groups) {
160
+ const group = groupNamed(membership.group);
161
+ if (group != null && group.name != DEFAULT_GROUP && counts(membership.until, membership.server, now) && !list.includes(group)) list.push(group);
162
+ }
163
+ }
164
+ list.sort((a, b) => b.weight - a.weight);
165
+ list.push(groupNamed(DEFAULT_GROUP)!);
166
+ return list;
167
+ }
168
+
169
+ /** A group and what it inherits, depth first, each once; a cycle stops where it closes. */
170
+ function withParents(group: Group, into: Group[]) {
171
+ if (into.includes(group)) return;
172
+
173
+ into.push(group);
174
+ for (const name of group.inherits) {
175
+ const parent = groupNamed(name);
176
+ if (parent != null) withParents(parent, into);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Where a player's answers come from, first first: his own grants, then each
182
+ * of his groups with what it inherits. A node is answered by the first source
183
+ * that says anything about it.
184
+ */
185
+ export function sourcesOf(user: User | null, now: number) {
186
+ const sources: Map<string, boolean>[] = [];
187
+ if (user != null) sources.push(grantsOf(user.grants, now));
188
+
189
+ const chain: Group[] = [];
190
+ for (const group of groupsOf(user, now)) withParents(group, chain);
191
+ for (const group of chain) sources.push(grantsOf(group.grants, now));
192
+ return sources;
193
+ }
194
+
195
+ /** The source's answer for a node: the node itself, then `a.b.*`, `a.*`, `*`; -1 when it says nothing. */
196
+ function lookup(source: Map<string, boolean>, node: string) {
197
+ if (source.has(node)) return source.get(node) ? 1 : 0;
198
+
199
+ let prefix = node;
200
+ while (true) {
201
+ const dot = prefix.lastIndexOf(".");
202
+ if (dot < 0) break;
203
+
204
+ prefix = prefix.substring(0, dot);
205
+ const wildcard = `${prefix}.*`;
206
+ if (source.has(wildcard)) return source.get(wildcard) ? 1 : 0;
207
+ }
208
+ if (source.has("*")) return source.get("*") ? 1 : 0;
209
+ return -1;
210
+ }
211
+
212
+ /** Whether the sources give the node; a node none of them names is its declared default. */
213
+ export function answer(sources: Map<string, boolean>[], node: string) {
214
+ for (const source of sources) {
215
+ const found = lookup(source, node);
216
+ if (found >= 0) return found == 1;
217
+ }
218
+ const known = declared.find(each => each.node == node);
219
+ return known != null && known.byDefault;
220
+ }
221
+
222
+ /** The grant that answers a node for `perms check`: "<source>: <node>", or "the default". */
223
+ export function explain(user: User | null, node: string, now: number) {
224
+ const names: string[] = [];
225
+ if (user != null) names.push(`player ${user.id}`);
226
+
227
+ const chain: Group[] = [];
228
+ for (const group of groupsOf(user, now)) withParents(group, chain);
229
+ for (const group of chain) names.push(`group ${group.name}`);
230
+
231
+ const sources = sourcesOf(user, now);
232
+ for (let i = 0; i < sources.length; i++) {
233
+ if (lookup(sources[i], node) >= 0) return names[i];
234
+ }
235
+ return "the default";
236
+ }
237
+
238
+ /** The player's primary group: the active one of the highest weight. */
239
+ export function primaryGroup(user: User | null, now: number) {
240
+ return groupsOf(user, now)[0].name;
241
+ }
242
+
243
+ /** The nearest moment a grant or a membership of these players, or of any group, ends; 0 for none. */
244
+ export function nextExpiry(online: User[], now: number) {
245
+ const ends: number[] = [];
246
+ for (const group of groups) group.grants.forEach(grant => ends.push(grant.until));
247
+ for (const user of online) {
248
+ user.grants.forEach(grant => ends.push(grant.until));
249
+ user.groups.forEach(membership => ends.push(membership.until));
250
+ }
251
+ return ends.filter(until => until > now).reduce((next, until) => (next == 0 || until < next ? until : next), 0);
252
+ }
253
+
254
+ // ---------------------------------------------------------------- changes
255
+
256
+ /** The player's entry, made when he has none yet. */
257
+ function userOrNew(id: string, name: string) {
258
+ const found = userWith(id);
259
+ if (found != null) return found;
260
+
261
+ const user: User = { id: normalId(id), name, password: "", groups: [], grants: [] };
262
+ users.push(user);
263
+ return user;
264
+ }
265
+
266
+ function grantsOfOwner(change: Change) {
267
+ if (change.user != "") return userOrNew(change.user, "").grants;
268
+ return groupNamed(change.group)!.grants;
269
+ }
270
+
271
+ function setGrant(list: Grant[], change: Change) {
272
+ const kept = list.filter(grant => !(grant.node == change.node && grant.server == change.server));
273
+ if (change.op == "grant") kept.push({ node: change.node, value: change.value, until: change.until, server: change.server });
274
+ list.length = 0;
275
+ for (const grant of kept) list.push(grant);
276
+ }
277
+
278
+ /** Whether `parent` already inherits `group`, so adding it as a parent would close a loop. */
279
+ function inherits(from: Group, wanted: string) {
280
+ const chain: Group[] = [];
281
+ withParents(from, chain);
282
+ return chain.some(group => group.name == wanted);
283
+ }
284
+
285
+ /** Applies a change; what made it skip, or "" when it was applied. */
286
+ export function apply(change: Change): SkipCode {
287
+ const op = change.op;
288
+ if (op == "grant" || op == "revoke") return applyGrant(change);
289
+ if (op == "memberAdd" || op == "memberRemove") return applyMembership(change);
290
+ if (op == "userCreate") return applyUserCreate(change);
291
+ return applyGroup(change);
292
+ }
293
+
294
+ function applyGrant(change: Change): SkipCode {
295
+ if (change.node == "") return "invalid";
296
+ if (change.user == "" && groupNamed(change.group) == null) return "groupMissing";
297
+ if (change.op == "revoke" && change.user != "" && userWith(change.user) == null) return "userMissing";
298
+
299
+ setGrant(grantsOfOwner(change), change);
300
+ return "";
301
+ }
302
+
303
+ function applyMembership(change: Change): SkipCode {
304
+ if (change.user == "" || change.group == "") return "invalid";
305
+ if (change.op == "memberRemove" && userWith(change.user) == null) return "userMissing";
306
+ if (groupNamed(change.group) == null) return "groupMissing";
307
+
308
+ const user = userOrNew(change.user, "");
309
+ user.groups = user.groups.filter(membership => !(membership.group == change.group && membership.server == change.server));
310
+ if (change.op == "memberAdd") user.groups.push({ group: change.group, until: change.until, server: change.server });
311
+ return "";
312
+ }
313
+
314
+ function applyUserCreate(change: Change): SkipCode {
315
+ if (change.user == "") return "invalid";
316
+
317
+ const user = userOrNew(change.user, change.name);
318
+ if (change.name != "") user.name = change.name;
319
+ return "";
320
+ }
321
+
322
+ function applyGroup(change: Change): SkipCode {
323
+ const op = change.op;
324
+ const group = groupNamed(change.group);
325
+ if (change.group == "") return "invalid";
326
+
327
+ if (op == "groupCreate") {
328
+ if (group != null) return "groupExists";
329
+
330
+ groups.push({ name: change.group, weight: change.weight, inherits: [], grants: [] });
331
+ return "";
332
+ }
333
+
334
+ if (group == null) return "groupMissing";
335
+ if (op == "groupDelete") return deleteGroup(group);
336
+ if (op == "groupWeight") {
337
+ group.weight = change.weight;
338
+ return "";
339
+ }
340
+
341
+ const parent = groupNamed(change.parent);
342
+ if (parent == null) return "parentMissing";
343
+ group.inherits = group.inherits.filter(name => name != parent.name);
344
+ if (op == "parentRemove") return "";
345
+
346
+ if (parent == group || inherits(parent, group.name)) return "cycle";
347
+ group.inherits.push(parent.name);
348
+ return "";
349
+ }
350
+
351
+ function deleteGroup(group: Group): SkipCode {
352
+ if (group.name == DEFAULT_GROUP) return "defaultGroup";
353
+
354
+ groups = groups.filter(each => each != group);
355
+ for (const each of groups) each.inherits = each.inherits.filter(name => name != group.name);
356
+ for (const user of users) user.groups = user.groups.filter(membership => membership.group != group.name);
357
+ return "";
358
+ }
359
+
360
+ /** A skip code in words, for the console and the editor's `reason`. */
361
+ export function reasonOf(code: SkipCode, change: Change) {
362
+ if (code == "groupMissing") return `There is no group "${change.group}"`;
363
+ if (code == "groupExists") return `The group "${change.group}" exists already`;
364
+ if (code == "userMissing") return `There is no player "${change.user}"`;
365
+ if (code == "parentMissing") return `There is no group "${change.parent}" to inherit`;
366
+ if (code == "cycle") return `"${change.group}" would inherit itself through "${change.parent}"`;
367
+ if (code == "defaultGroup") return `The group "default" cannot be deleted`;
368
+ return `The change "${change.op}" is not complete`;
369
+ }
370
+
371
+ /** Drops grants and memberships that ended; whether any did, so the store writes them out. */
372
+ export function dropExpired(now: number) {
373
+ const before = countEntries();
374
+ for (const group of groups) group.grants = group.grants.filter(grant => grant.until == 0 || grant.until > now);
375
+ for (const user of users) {
376
+ user.grants = user.grants.filter(grant => grant.until == 0 || grant.until > now);
377
+ user.groups = user.groups.filter(membership => membership.until == 0 || membership.until > now);
378
+ }
379
+ return countEntries() != before;
380
+ }
381
+
382
+ function countEntries() {
383
+ let count = 0;
384
+ for (const group of groups) count += group.grants.length;
385
+ for (const user of users) count += user.grants.length + user.groups.length;
386
+ return count;
387
+ }
package/src/natives.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Perms for Pawn plugins: the permission check, the groups, and the
3
+ * `perms_changed(id)` forward. AMX Mod X's flags come from the `amxx.*`
4
+ * nodes, so `access` and `cmd_access` work without this include.
5
+ */
6
+ import { Forward, Player, plugin } from "@amxts/core";
7
+ import * as perms from "./index";
8
+ import { declare } from "./model";
9
+ import { onChange, refreshAll } from "./players";
10
+
11
+ plugin({ name: "Perms", version: "0.1.1", author: "kukson777", description: "Permissions and groups in place of admin.amxx" });
12
+
13
+ const changed = new Forward<Player>("perms_changed");
14
+ onChange(player => changed.emit(player));
15
+
16
+ /** Whether the player has a permission node, e.g. "myplugin.kick". */
17
+ export function perms_has(player: Player, node: string) {
18
+ return perms.has(player, node);
19
+ }
20
+
21
+ /** Declares a node the plugin checks, for the web editor; byDefault gives it to every player no grant says otherwise for. */
22
+ export function perms_declare(node: string, description: string, byDefault = false) {
23
+ declare(node, description, byDefault, true);
24
+ refreshAll();
25
+ }
26
+
27
+ /** The player's primary group: his group of the highest weight, "default" when he has none. */
28
+ export function perms_get_group(player: Player) {
29
+ return perms.groupOf(player);
30
+ }
31
+
32
+ /** Whether the player is in a group now. */
33
+ export function perms_in_group(player: Player, group: string) {
34
+ return perms.groupsOf(player).includes(group);
35
+ }
36
+
37
+ /** Puts the player into a group, for seconds (0 - for good). */
38
+ export function perms_add_group(player: Player, group: string, seconds = 0) {
39
+ if (seconds > 0) perms.addGroup(player, group, { for: seconds });
40
+ else perms.addGroup(player, group);
41
+ }
42
+
43
+ /** Takes the player out of a group. */
44
+ export function perms_remove_group(player: Player, group: string) {
45
+ perms.removeGroup(player, group);
46
+ }