@hadoku/service-kernel 0.0.0-stage → 0.1.4

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth/index.d.ts +125 -0
  3. package/dist/auth/index.js +189 -0
  4. package/dist/capacity/capacity.d.ts +33 -0
  5. package/dist/capacity/capacity.js +119 -0
  6. package/dist/capacity/index.d.ts +1 -0
  7. package/dist/capacity/index.js +1 -0
  8. package/dist/erase/anonymise.d.ts +18 -0
  9. package/dist/erase/anonymise.js +77 -0
  10. package/dist/erase/erase.d.ts +59 -0
  11. package/dist/erase/erase.js +72 -0
  12. package/dist/erase/index.d.ts +2 -0
  13. package/dist/erase/index.js +2 -0
  14. package/dist/health/health.d.ts +42 -0
  15. package/dist/health/health.js +52 -0
  16. package/dist/health/index.d.ts +1 -0
  17. package/dist/health/index.js +1 -0
  18. package/dist/http/index.d.ts +123 -0
  19. package/dist/http/index.js +192 -0
  20. package/dist/lifecycle/announce.d.ts +74 -0
  21. package/dist/lifecycle/announce.js +232 -0
  22. package/dist/lifecycle/bootClock.d.ts +7 -0
  23. package/dist/lifecycle/bootClock.js +14 -0
  24. package/dist/lifecycle/busy.d.ts +57 -0
  25. package/dist/lifecycle/busy.js +47 -0
  26. package/dist/lifecycle/flush-sockets.d.ts +41 -0
  27. package/dist/lifecycle/flush-sockets.js +61 -0
  28. package/dist/lifecycle/index.d.ts +4 -0
  29. package/dist/lifecycle/index.js +4 -0
  30. package/dist/limits/index.d.ts +1 -0
  31. package/dist/limits/index.js +1 -0
  32. package/dist/limits/limits.d.ts +76 -0
  33. package/dist/limits/limits.js +103 -0
  34. package/dist/log/index.d.ts +1 -0
  35. package/dist/log/index.js +1 -0
  36. package/dist/log/log.d.ts +7 -0
  37. package/dist/log/log.js +8 -0
  38. package/dist/relays/client-events.d.ts +51 -0
  39. package/dist/relays/client-events.js +53 -0
  40. package/dist/relays/index.d.ts +2 -0
  41. package/dist/relays/index.js +2 -0
  42. package/dist/relays/usage.d.ts +53 -0
  43. package/dist/relays/usage.js +75 -0
  44. package/dist/server/bus.d.ts +27 -0
  45. package/dist/server/bus.js +21 -0
  46. package/dist/server/index.d.ts +2 -0
  47. package/dist/server/index.js +2 -0
  48. package/dist/server/server.d.ts +157 -0
  49. package/dist/server/server.js +244 -0
  50. package/dist/state/index.d.ts +1 -0
  51. package/dist/state/index.js +1 -0
  52. package/dist/state/state-dir.d.ts +39 -0
  53. package/dist/state/state-dir.js +63 -0
  54. package/dist/telemetry/index.d.ts +1 -0
  55. package/dist/telemetry/index.js +1 -0
  56. package/dist/telemetry/telemetry.d.ts +105 -0
  57. package/dist/telemetry/telemetry.js +221 -0
  58. package/package.json +71 -4
  59. 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.4",
4
+ "gitHead": "d9e4fb24cfb06bd521804d51f42d2d0ded352a3e",
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.65",
60
+ "@wolffm/rooms-server": "npm:@hadoku/rooms-server@^0.45.65",
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.