@hadoku/service-kernel 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/LICENSE +21 -0
- package/dist/auth/index.d.ts +118 -0
- package/dist/auth/index.js +171 -0
- package/dist/capacity/capacity.d.ts +33 -0
- package/dist/capacity/capacity.js +119 -0
- package/dist/capacity/index.d.ts +1 -0
- package/dist/capacity/index.js +1 -0
- package/dist/erase/anonymise.d.ts +18 -0
- package/dist/erase/anonymise.js +77 -0
- package/dist/erase/erase.d.ts +59 -0
- package/dist/erase/erase.js +72 -0
- package/dist/erase/index.d.ts +2 -0
- package/dist/erase/index.js +2 -0
- package/dist/health/health.d.ts +42 -0
- package/dist/health/health.js +52 -0
- package/dist/health/index.d.ts +1 -0
- package/dist/health/index.js +1 -0
- package/dist/http/index.d.ts +123 -0
- package/dist/http/index.js +192 -0
- package/dist/lifecycle/announce.d.ts +74 -0
- package/dist/lifecycle/announce.js +232 -0
- package/dist/lifecycle/bootClock.d.ts +7 -0
- package/dist/lifecycle/bootClock.js +14 -0
- package/dist/lifecycle/busy.d.ts +57 -0
- package/dist/lifecycle/busy.js +47 -0
- package/dist/lifecycle/flush-sockets.d.ts +41 -0
- package/dist/lifecycle/flush-sockets.js +61 -0
- package/dist/lifecycle/index.d.ts +4 -0
- package/dist/lifecycle/index.js +4 -0
- package/dist/limits/index.d.ts +1 -0
- package/dist/limits/index.js +1 -0
- package/dist/limits/limits.d.ts +76 -0
- package/dist/limits/limits.js +103 -0
- package/dist/log/index.d.ts +1 -0
- package/dist/log/index.js +1 -0
- package/dist/log/log.d.ts +7 -0
- package/dist/log/log.js +8 -0
- package/dist/relays/client-events.d.ts +51 -0
- package/dist/relays/client-events.js +53 -0
- package/dist/relays/index.d.ts +2 -0
- package/dist/relays/index.js +2 -0
- package/dist/relays/usage.d.ts +53 -0
- package/dist/relays/usage.js +75 -0
- package/dist/server/bus.d.ts +27 -0
- package/dist/server/bus.js +21 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +2 -0
- package/dist/server/server.d.ts +157 -0
- package/dist/server/server.js +244 -0
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/dist/state/state-dir.d.ts +39 -0
- package/dist/state/state-dir.js +63 -0
- package/dist/telemetry/index.d.ts +1 -0
- package/dist/telemetry/index.js +1 -0
- package/dist/telemetry/telemetry.d.ts +105 -0
- package/dist/telemetry/telemetry.js +221 -0
- package/package.json +71 -4
- package/README.md +0 -3
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security telemetry: who got in, who was turned away, and who is spending the costly acts — the
|
|
3
|
+
* platform half. Knows no game: the service id, the deployment site and the refusal surface are
|
|
4
|
+
* all parameters, so an app that posts under `service: 'cardtable'` and one that posts under a
|
|
5
|
+
* second game's id read apart in the same monitoring store. See the app's own adapter (this
|
|
6
|
+
* wave's `apps/server/src/telemetry.ts`) for the one place those values get bound.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is fire-and-forget. A post that fails is logged on stdout (pm2 keeps it), never
|
|
9
|
+
* retried, and never delays the request that caused it. One event per (name, caller, incident
|
|
10
|
+
* key) per window goes out; the rest are counted and ride on the next one as `suppressed`, so a
|
|
11
|
+
* scanner hammering the seal costs monitoring one row a minute, not one per request.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
14
|
+
/** What `report` does before boot installs a sink, and in every test that installs none. */
|
|
15
|
+
const NOOP = () => { };
|
|
16
|
+
let installed = NOOP;
|
|
17
|
+
/** The process's reporter. The app's boot installs the monitoring sink once. */
|
|
18
|
+
export function installTelemetry(sink) {
|
|
19
|
+
installed = sink;
|
|
20
|
+
}
|
|
21
|
+
/** Report one event. A no-op until a sink is installed. */
|
|
22
|
+
export function report(event, context, level) {
|
|
23
|
+
installed(event, context, level);
|
|
24
|
+
}
|
|
25
|
+
/** An event's level when its reporter names none: a refusal or a rate limit is a warning, the rest is traffic. */
|
|
26
|
+
function levelOf(event) {
|
|
27
|
+
return event.endsWith('-refused') || event === 'rate-limited' ? 'warn' : 'info';
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Which deployment a base path is: `/<service>` is prod, `/<service>-<site>` that site (a PPE
|
|
31
|
+
* wrapper's prefix, e.g. `/<service>-ppe`). Any other prefix names itself.
|
|
32
|
+
*/
|
|
33
|
+
export function siteOf(basePrefix, service) {
|
|
34
|
+
const m = new RegExp(`^/${service}(?:-([^/]+))?/?$`).exec(basePrefix);
|
|
35
|
+
return m === null ? basePrefix : (m[1] ?? 'prod');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A hosting alert's level, from what it says: 🚨 is critical; a resolution (the alerts post one
|
|
39
|
+
* under `<key>:resolved`) or a ✅ is info; every other alert is a warning.
|
|
40
|
+
*/
|
|
41
|
+
function alertLevel(key, text) {
|
|
42
|
+
if (text.startsWith('🚨'))
|
|
43
|
+
return 'critical';
|
|
44
|
+
if (key.endsWith(':resolved') || text.startsWith('✅'))
|
|
45
|
+
return 'info';
|
|
46
|
+
return 'warn';
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The hosting alerts (`play/placement/alerts.ts`) as service events, so the daily report lists
|
|
50
|
+
* each one beside the seal refusals. Wraps the log the alerts are given: every entry still goes
|
|
51
|
+
* to it, and each `alert` entry — one per message the alerts post to Discord — is also reported
|
|
52
|
+
* as `alert` with its key and text, at `alertLevel`. Every key is reported, so an alert kind
|
|
53
|
+
* added later needs nothing here.
|
|
54
|
+
*/
|
|
55
|
+
export function reportingAlerts(log, sink = report) {
|
|
56
|
+
return (entry) => {
|
|
57
|
+
log(entry);
|
|
58
|
+
if (entry.evt === 'alert' && typeof entry.key === 'string' && typeof entry.text === 'string') {
|
|
59
|
+
sink('alert', { key: entry.key, text: entry.text }, alertLevel(entry.key, entry.text));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** The throttle forgets keys past this many, oldest first, so it cannot grow without bound. */
|
|
64
|
+
const MAX_KEYS = 5000;
|
|
65
|
+
/**
|
|
66
|
+
* A reporter that posts to monitoring-api. Without a service key it posts
|
|
67
|
+
* nothing and says so once, the way the stats reporter does.
|
|
68
|
+
*/
|
|
69
|
+
export function monitoringSink(opts) {
|
|
70
|
+
const { log } = opts;
|
|
71
|
+
if (opts.serviceKey === '') {
|
|
72
|
+
log({
|
|
73
|
+
evt: 'telemetry-disabled',
|
|
74
|
+
reason: `${opts.serviceKeyEnvVar} is not set`,
|
|
75
|
+
});
|
|
76
|
+
return NOOP;
|
|
77
|
+
}
|
|
78
|
+
const windowMs = opts.windowMs ?? 60_000;
|
|
79
|
+
const now = opts.now ?? Date.now;
|
|
80
|
+
const doFetch = opts.fetch ?? fetch;
|
|
81
|
+
const url = `${opts.baseUrl}/health/api/telemetry`;
|
|
82
|
+
const last = new Map();
|
|
83
|
+
return (event, context, explicit) => {
|
|
84
|
+
const key = `${event}\u0000${String(context.userId ?? '')}\u0000${String(context.key ?? '')}`;
|
|
85
|
+
const t = now();
|
|
86
|
+
const seen = last.get(key);
|
|
87
|
+
if (seen !== undefined && t - seen.at < windowMs) {
|
|
88
|
+
seen.suppressed += 1;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const suppressed = seen?.suppressed ?? 0;
|
|
92
|
+
last.delete(key);
|
|
93
|
+
last.set(key, { at: t, suppressed: 0 });
|
|
94
|
+
if (last.size > MAX_KEYS)
|
|
95
|
+
last.delete(last.keys().next().value);
|
|
96
|
+
const level = explicit ?? levelOf(event);
|
|
97
|
+
const body = {
|
|
98
|
+
source: 'service',
|
|
99
|
+
level: level === 'critical' ? 'error' : level,
|
|
100
|
+
type: 'log',
|
|
101
|
+
message: `event=${event}`,
|
|
102
|
+
context: {
|
|
103
|
+
service: opts.service,
|
|
104
|
+
site: opts.site,
|
|
105
|
+
...context,
|
|
106
|
+
...(level === 'critical' ? { severity: level } : {}),
|
|
107
|
+
...(suppressed > 0 ? { suppressed } : {}),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
doFetch(url, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: {
|
|
113
|
+
'Content-Type': 'application/json',
|
|
114
|
+
'User-Agent': `Mozilla/5.0 hadoku-${opts.service}`,
|
|
115
|
+
'X-User-Key': opts.serviceKey,
|
|
116
|
+
},
|
|
117
|
+
body: JSON.stringify(body),
|
|
118
|
+
})
|
|
119
|
+
.then((res) => {
|
|
120
|
+
if (!res.ok)
|
|
121
|
+
log({ evt: 'telemetry-post-failed', event, status: res.status });
|
|
122
|
+
})
|
|
123
|
+
.catch((err) => log({
|
|
124
|
+
evt: 'telemetry-post-failed',
|
|
125
|
+
event,
|
|
126
|
+
error: err instanceof Error ? err.message : String(err),
|
|
127
|
+
}));
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function header(headers, name) {
|
|
131
|
+
const v = headers[name];
|
|
132
|
+
return typeof v === 'string' && v !== '' ? v : null;
|
|
133
|
+
}
|
|
134
|
+
/** The caller's user agent, cut short: enough to tell a browser from a script. */
|
|
135
|
+
export function userAgentOf(headers) {
|
|
136
|
+
return header(headers, 'user-agent')?.slice(0, 160) ?? null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Who a request or handshake says it is, from the edge's headers AFTER
|
|
140
|
+
* `sanitizeHeaders` — so a spoofed caller reports as public with a null userId,
|
|
141
|
+
* exactly what the handlers saw.
|
|
142
|
+
*/
|
|
143
|
+
export function callerContext(headers) {
|
|
144
|
+
return {
|
|
145
|
+
userId: header(headers, 'x-user-id'),
|
|
146
|
+
tier: header(headers, 'x-hadoku-tier') ?? 'public',
|
|
147
|
+
ua: userAgentOf(headers),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A seal below the edge refused a caller. With the edge in front this should
|
|
152
|
+
* never fire: one means a direct hit on the tunnel host, or an edge rule wider
|
|
153
|
+
* than this server's floor — both worth a look. `Surface` is an open string:
|
|
154
|
+
* this module knows no game's surface names, so the app's own adapter narrows
|
|
155
|
+
* it to a closed set.
|
|
156
|
+
*/
|
|
157
|
+
export function reportSealRefused(surface, headers, path) {
|
|
158
|
+
report('seal-refused', { surface, path, ...callerContext(headers) });
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Middleware, mounted below the tier floor: the first request this server ever
|
|
162
|
+
* admits from a user id reports `caller-first-seen` with its tier and user
|
|
163
|
+
* agent — the "who is getting in" signal, once per account, not per visit.
|
|
164
|
+
* Callers the edge named nobody (a null userId) are not accounts and are skipped.
|
|
165
|
+
*/
|
|
166
|
+
export function firstSeen(opts) {
|
|
167
|
+
const seen = new Set(readSeen(opts.file, opts.log));
|
|
168
|
+
const middleware = (req, _res, next) => {
|
|
169
|
+
const userId = opts.userIdOf(req);
|
|
170
|
+
if (userId !== null && !seen.has(userId)) {
|
|
171
|
+
seen.add(userId);
|
|
172
|
+
writeSeen(opts.file, seen, opts.log);
|
|
173
|
+
report('caller-first-seen', {
|
|
174
|
+
userId,
|
|
175
|
+
tier: opts.tierOf(req),
|
|
176
|
+
ua: userAgentOf(req.headers),
|
|
177
|
+
path: req.path,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
next();
|
|
181
|
+
};
|
|
182
|
+
return Object.assign(middleware, {
|
|
183
|
+
forget(userId) {
|
|
184
|
+
if (!seen.delete(userId))
|
|
185
|
+
return 0;
|
|
186
|
+
writeSeen(opts.file, seen, opts.log);
|
|
187
|
+
return 1;
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function readSeen(file, log) {
|
|
192
|
+
if (!existsSync(file))
|
|
193
|
+
return [];
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
196
|
+
if (Array.isArray(parsed) && parsed.every((x) => typeof x === 'string'))
|
|
197
|
+
return parsed;
|
|
198
|
+
log({ evt: 'first-seen-unreadable', file, reason: 'not a list of ids' });
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
log({
|
|
202
|
+
evt: 'first-seen-unreadable',
|
|
203
|
+
file,
|
|
204
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
function writeSeen(file, seen, log) {
|
|
210
|
+
try {
|
|
211
|
+
writeFileSync(`${file}.tmp`, JSON.stringify([...seen]));
|
|
212
|
+
renameSync(`${file}.tmp`, file);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
log({
|
|
216
|
+
evt: 'first-seen-write-failed',
|
|
217
|
+
file,
|
|
218
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hadoku/service-kernel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"gitHead": "25eb5a7d584ce320a079bea1ff111790bb676520",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"description": "The server parts any game keeps: edge auth and the tier gate, base path and bundle check, health and the busy probe, lifecycle, the JSON logger, the state directory, telemetry and capacity, per-caller limits, the browser relays, the erase registry, and createGameServer, which boots a game from its config and plugins. Knows no game; the app binds its service id, prefix and env names. MIT; imports only node built-ins, express, socket.io and @wolffm/*.",
|
|
8
|
+
"exports": {
|
|
9
|
+
"./auth": {
|
|
10
|
+
"types": "./dist/auth/index.d.ts",
|
|
11
|
+
"import": "./dist/auth/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./http": {
|
|
14
|
+
"types": "./dist/http/index.d.ts",
|
|
15
|
+
"import": "./dist/http/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./health": {
|
|
18
|
+
"types": "./dist/health/index.d.ts",
|
|
19
|
+
"import": "./dist/health/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./lifecycle": {
|
|
22
|
+
"types": "./dist/lifecycle/index.d.ts",
|
|
23
|
+
"import": "./dist/lifecycle/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./log": {
|
|
26
|
+
"types": "./dist/log/index.d.ts",
|
|
27
|
+
"import": "./dist/log/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./state": {
|
|
30
|
+
"types": "./dist/state/index.d.ts",
|
|
31
|
+
"import": "./dist/state/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./telemetry": {
|
|
34
|
+
"types": "./dist/telemetry/index.d.ts",
|
|
35
|
+
"import": "./dist/telemetry/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./capacity": {
|
|
38
|
+
"types": "./dist/capacity/index.d.ts",
|
|
39
|
+
"import": "./dist/capacity/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./limits": {
|
|
42
|
+
"types": "./dist/limits/index.d.ts",
|
|
43
|
+
"import": "./dist/limits/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./relays": {
|
|
46
|
+
"types": "./dist/relays/index.d.ts",
|
|
47
|
+
"import": "./dist/relays/index.js"
|
|
48
|
+
},
|
|
49
|
+
"./erase": {
|
|
50
|
+
"types": "./dist/erase/index.d.ts",
|
|
51
|
+
"import": "./dist/erase/index.js"
|
|
52
|
+
},
|
|
53
|
+
"./server": {
|
|
54
|
+
"types": "./dist/server/index.d.ts",
|
|
55
|
+
"import": "./dist/server/index.js"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@wolffm/rooms-protocol": "npm:@hadoku/rooms-protocol@^0.45.60",
|
|
60
|
+
"@wolffm/rooms-server": "npm:@hadoku/rooms-server@^0.45.60",
|
|
61
|
+
"express": "^5.1.0",
|
|
62
|
+
"socket.io": "^4.8.3"
|
|
63
|
+
},
|
|
64
|
+
"files": [
|
|
65
|
+
"dist",
|
|
66
|
+
"LICENSE"
|
|
67
|
+
],
|
|
68
|
+
"repository": {
|
|
69
|
+
"type": "git",
|
|
70
|
+
"url": "https://github.com/WolffM/hadoku-cardtable.git",
|
|
71
|
+
"directory": "packages/service-kernel"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/README.md
DELETED