@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.
- package/LICENSE +21 -0
- package/dist/auth/index.d.ts +125 -0
- package/dist/auth/index.js +189 -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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hadoku-cardtable contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge provenance and the tier floor — the platform's half.
|
|
3
|
+
*
|
|
4
|
+
* An edge-router style gateway resolves a caller's tier once and stamps every
|
|
5
|
+
* request it proxies with a shared-secret header plus tier / user-id /
|
|
6
|
+
* user-key / user-name / verified headers. A game's own tunnel host is
|
|
7
|
+
* reachable DIRECTLY, bypassing that edge — so a direct hit could forge those
|
|
8
|
+
* headers. We believe them only when the shared secret checks out; otherwise
|
|
9
|
+
* we overwrite them with the most restrictive identity.
|
|
10
|
+
*
|
|
11
|
+
* Overwriting rather than deleting is deliberate: a handler that treats an
|
|
12
|
+
* ABSENT tier as "bare local, trust it" would hand a direct caller the keys.
|
|
13
|
+
* An explicit `public` cannot be read that way.
|
|
14
|
+
*
|
|
15
|
+
* Knows no game: every value below is either a pure header read or injected
|
|
16
|
+
* by the app that binds this module (its edge-auth secret, its bare-local
|
|
17
|
+
* dev hatch, its dev tier).
|
|
18
|
+
*/
|
|
19
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
20
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
21
|
+
/**
|
|
22
|
+
* The platform tier ladder, complete. `wife` sits above `service` and below
|
|
23
|
+
* `admin` deliberately: full reach across the apps, none of the
|
|
24
|
+
* credential-management surface. `member` is a self-signed-up account: a
|
|
25
|
+
* passkey and a name, no key anyone handed out.
|
|
26
|
+
*/
|
|
27
|
+
export declare const TIER_ORDER: readonly ["public", "member", "friend", "service", "wife", "admin"];
|
|
28
|
+
export type Tier = (typeof TIER_ORDER)[number];
|
|
29
|
+
export interface AssertFloorOptions {
|
|
30
|
+
/** Named in the refusal message, e.g. `CARDTABLE` for `CARDTABLE_MIN_TIER=…`. */
|
|
31
|
+
envPrefix: string;
|
|
32
|
+
/**
|
|
33
|
+
* Let the app choose `public` as its own floor. Off by default: a floor an
|
|
34
|
+
* app never meant to be public should refuse to boot rather than silently
|
|
35
|
+
* admit everyone. Apps that are genuinely public (the next game might be)
|
|
36
|
+
* opt in explicitly.
|
|
37
|
+
*/
|
|
38
|
+
allowPublic?: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The floor must be a tier this build knows. An unknown name ranks as public
|
|
42
|
+
* in {@link rankOf}, so a floor this build has not heard of would silently
|
|
43
|
+
* admit everyone, the direct tunnel hits included. Refusing to boot is the
|
|
44
|
+
* only safe answer — and, unless the app opts in, `public` itself is refused
|
|
45
|
+
* the same way, since an app that calls this at all is declaring it wants a
|
|
46
|
+
* gate.
|
|
47
|
+
*/
|
|
48
|
+
export declare function assertFloor(tier: string, opts: AssertFloorOptions): Tier;
|
|
49
|
+
export declare function rankOf(tier: string): number;
|
|
50
|
+
/**
|
|
51
|
+
* The app's own edge-auth binding: its shared secret and its bare-local dev
|
|
52
|
+
* hatch. Read live (as functions, not snapshotted values) so an app that
|
|
53
|
+
* flips these at runtime — tests do — sees the new value on the very next
|
|
54
|
+
* call, exactly as the single-process `CONFIG` object this used to read
|
|
55
|
+
* directly always did.
|
|
56
|
+
*/
|
|
57
|
+
export interface EdgeAuthConfig {
|
|
58
|
+
/** The shared secret this build believes `X-Edge-Auth` against. `''` = fail closed. */
|
|
59
|
+
edgeAuthSecret(): string;
|
|
60
|
+
/** The bare-local escape hatch: believe whatever tier a header-less caller claims. */
|
|
61
|
+
allowUnverifiedEdge(): boolean;
|
|
62
|
+
/** The tier granted a header-less caller while the hatch above is open. */
|
|
63
|
+
devTier(): string;
|
|
64
|
+
}
|
|
65
|
+
export interface EdgeAuth {
|
|
66
|
+
/** Force a public/anon identity unless edge provenance is proven. */
|
|
67
|
+
sanitizeHeaders(headers: Record<string, unknown>): void;
|
|
68
|
+
/**
|
|
69
|
+
* Did this request carry the edge's own seal? Not the bare-local hatch,
|
|
70
|
+
* which believes any header: a route only the edge may call asks this
|
|
71
|
+
* rather than a tier.
|
|
72
|
+
*/
|
|
73
|
+
edgeSealed(req: Request): boolean;
|
|
74
|
+
/** Express middleware: sanitize provenance on every inbound request. */
|
|
75
|
+
edgeProvenance(req: Request, res: Response, next: NextFunction): void;
|
|
76
|
+
}
|
|
77
|
+
/** Build the three functions that need the app's own edge-auth secret and dev hatch. */
|
|
78
|
+
export declare function createEdgeAuth(config: EdgeAuthConfig): EdgeAuth;
|
|
79
|
+
/**
|
|
80
|
+
* The caller's tier as the app's headers carry it, except that a verified
|
|
81
|
+
* member IS a friend here: an app that admits members alongside the tiers it
|
|
82
|
+
* hands out may choose to treat its own verification the same as a vouch.
|
|
83
|
+
* Never above friend: admin stays admin's. Pure — only `sanitizeHeaders`
|
|
84
|
+
* decides whether a header is trustworthy in the first place.
|
|
85
|
+
*/
|
|
86
|
+
export declare function tierOf(req: Request): string;
|
|
87
|
+
/**
|
|
88
|
+
* Is this caller verified (`X-Hadoku-Verified: 1`)? Absent means not
|
|
89
|
+
* verified. Pure: trustworthiness is `sanitizeHeaders`'s job, done before this
|
|
90
|
+
* is ever read.
|
|
91
|
+
*/
|
|
92
|
+
export declare function verifiedOf(req: Request): boolean;
|
|
93
|
+
/** `verifiedOf` for a socket's handshake, whose headers went through the same sanitizing. */
|
|
94
|
+
export declare function verifiedFromHeaders(headers: IncomingHttpHeaders): boolean;
|
|
95
|
+
/** The caller's userId as the edge stamped it; null when the edge named nobody. */
|
|
96
|
+
export declare function callerIdOf(req: Request): string | null;
|
|
97
|
+
export declare function userIdOf(req: Request): string;
|
|
98
|
+
/**
|
|
99
|
+
* The caller's own live key (`X-User-Key`), edge-stamped and stripped from an
|
|
100
|
+
* unvouched request. A credential: never log it or store it. null when the
|
|
101
|
+
* edge sent none.
|
|
102
|
+
*/
|
|
103
|
+
export declare function userKeyOf(req: Request): string | null;
|
|
104
|
+
/**
|
|
105
|
+
* The caller's registry userId (`X-User-Id`), edge-stamped and stripped from
|
|
106
|
+
* an unvouched request. null when the edge sent none, never a stand-in word.
|
|
107
|
+
*/
|
|
108
|
+
export declare function accountIdOf(req: Request): string | null;
|
|
109
|
+
/**
|
|
110
|
+
* The caller's DISPLAY NAME, edge-injected beside `X-User-Id` (same trust
|
|
111
|
+
* class: exclusively edge-set, stripped from unvouched requests). null when
|
|
112
|
+
* the edge sent none — an unnamed key.
|
|
113
|
+
*/
|
|
114
|
+
export declare function userNameOf(req: Request): string | null;
|
|
115
|
+
/** `userNameOf` for a socket's handshake, whose headers went through the same sanitizing. */
|
|
116
|
+
export declare function userNameFromHeaders(headers: IncomingHttpHeaders): string | null;
|
|
117
|
+
/**
|
|
118
|
+
* A header value as the text its sender meant. The edge sends a name's UTF-8
|
|
119
|
+
* bytes and Node reads every header byte as one Latin-1 character, so
|
|
120
|
+
* `王蟲様` arrives as `ç\x8e\x8bè…`. Bytes that are valid UTF-8 are read as
|
|
121
|
+
* UTF-8; anything else (plain ASCII, or a lone Latin-1 byte) is kept as it came.
|
|
122
|
+
*/
|
|
123
|
+
export declare function utf8HeaderValue(value: string): string;
|
|
124
|
+
/** The one admin check: `rankOf(tierOf(req)) >= rankOf('admin')`. */
|
|
125
|
+
export declare function isAdmin(req: Request): boolean;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge provenance and the tier floor — the platform's half.
|
|
3
|
+
*
|
|
4
|
+
* An edge-router style gateway resolves a caller's tier once and stamps every
|
|
5
|
+
* request it proxies with a shared-secret header plus tier / user-id /
|
|
6
|
+
* user-key / user-name / verified headers. A game's own tunnel host is
|
|
7
|
+
* reachable DIRECTLY, bypassing that edge — so a direct hit could forge those
|
|
8
|
+
* headers. We believe them only when the shared secret checks out; otherwise
|
|
9
|
+
* we overwrite them with the most restrictive identity.
|
|
10
|
+
*
|
|
11
|
+
* Overwriting rather than deleting is deliberate: a handler that treats an
|
|
12
|
+
* ABSENT tier as "bare local, trust it" would hand a direct caller the keys.
|
|
13
|
+
* An explicit `public` cannot be read that way.
|
|
14
|
+
*
|
|
15
|
+
* Knows no game: every value below is either a pure header read or injected
|
|
16
|
+
* by the app that binds this module (its edge-auth secret, its bare-local
|
|
17
|
+
* dev hatch, its dev tier).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The platform tier ladder, complete. `wife` sits above `service` and below
|
|
21
|
+
* `admin` deliberately: full reach across the apps, none of the
|
|
22
|
+
* credential-management surface. `member` is a self-signed-up account: a
|
|
23
|
+
* passkey and a name, no key anyone handed out.
|
|
24
|
+
*/
|
|
25
|
+
export const TIER_ORDER = ['public', 'member', 'friend', 'service', 'wife', 'admin'];
|
|
26
|
+
/**
|
|
27
|
+
* The floor must be a tier this build knows. An unknown name ranks as public
|
|
28
|
+
* in {@link rankOf}, so a floor this build has not heard of would silently
|
|
29
|
+
* admit everyone, the direct tunnel hits included. Refusing to boot is the
|
|
30
|
+
* only safe answer — and, unless the app opts in, `public` itself is refused
|
|
31
|
+
* the same way, since an app that calls this at all is declaring it wants a
|
|
32
|
+
* gate.
|
|
33
|
+
*/
|
|
34
|
+
export function assertFloor(tier, opts) {
|
|
35
|
+
const allowPublic = opts.allowPublic ?? false;
|
|
36
|
+
if (!TIER_ORDER.includes(tier) || (!allowPublic && tier === 'public')) {
|
|
37
|
+
throw new Error(`${opts.envPrefix}_MIN_TIER=${tier} is not a tier floor this build knows`);
|
|
38
|
+
}
|
|
39
|
+
return tier;
|
|
40
|
+
}
|
|
41
|
+
export function rankOf(tier) {
|
|
42
|
+
const i = TIER_ORDER.indexOf(tier);
|
|
43
|
+
// An unknown tier is not a high one. A future tier name this build has never
|
|
44
|
+
// heard of must not out-rank the floor by accident.
|
|
45
|
+
return i === -1 ? 0 : i;
|
|
46
|
+
}
|
|
47
|
+
/** Constant-time compare. Length is not secret (the token is fixed-length). */
|
|
48
|
+
function ctEqual(a, b) {
|
|
49
|
+
if (a.length !== b.length)
|
|
50
|
+
return false;
|
|
51
|
+
let diff = 0;
|
|
52
|
+
for (let i = 0; i < a.length; i++)
|
|
53
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
54
|
+
return diff === 0;
|
|
55
|
+
}
|
|
56
|
+
/** Build the three functions that need the app's own edge-auth secret and dev hatch. */
|
|
57
|
+
export function createEdgeAuth(config) {
|
|
58
|
+
function edgeProvenanceOk(headers) {
|
|
59
|
+
const got = headers['x-edge-auth'];
|
|
60
|
+
const secret = config.edgeAuthSecret();
|
|
61
|
+
return secret !== '' && typeof got === 'string' && ctEqual(got, secret);
|
|
62
|
+
}
|
|
63
|
+
function sanitizeHeaders(headers) {
|
|
64
|
+
if (config.edgeAuthSecret() === '') {
|
|
65
|
+
if (config.allowUnverifiedEdge()) {
|
|
66
|
+
// Explicit bare-local opt-out (warned at boot). Believe whatever tier
|
|
67
|
+
// the caller claims — and, when it claims none, GRANT the dev tier
|
|
68
|
+
// rather than leaving the header absent.
|
|
69
|
+
//
|
|
70
|
+
// That second half is the whole point. Without it the escape hatch does
|
|
71
|
+
// not open anything: a browser at localhost sends no X-Hadoku-Tier, so
|
|
72
|
+
// the floor below reads it as `public` and 404s every route including
|
|
73
|
+
// the app's own index.html. The hatch would be a flag that changes
|
|
74
|
+
// nothing observable, which is worse than not having one.
|
|
75
|
+
if (typeof headers['x-hadoku-tier'] !== 'string' || headers['x-hadoku-tier'] === '') {
|
|
76
|
+
headers['x-hadoku-tier'] = config.devTier();
|
|
77
|
+
headers['x-user-id'] ??= 'local-dev';
|
|
78
|
+
headers['x-user-name'] ??= 'local-dev';
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// else fall through: nothing can prove provenance → degrade every request
|
|
83
|
+
}
|
|
84
|
+
else if (edgeProvenanceOk(headers)) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
headers['x-hadoku-tier'] = 'public';
|
|
88
|
+
// Content visibility is stamped by the edge from the user's prefs row, so it
|
|
89
|
+
// is exactly as forgeable as the tier and gets pinned the same way. Level 1
|
|
90
|
+
// is the platform's SFW floor.
|
|
91
|
+
headers['x-hadoku-content-level'] = '1';
|
|
92
|
+
delete headers['x-user-id'];
|
|
93
|
+
delete headers['x-user-name'];
|
|
94
|
+
delete headers['x-user-key'];
|
|
95
|
+
delete headers['x-hadoku-verified'];
|
|
96
|
+
}
|
|
97
|
+
function edgeSealed(req) {
|
|
98
|
+
return edgeProvenanceOk(req.headers);
|
|
99
|
+
}
|
|
100
|
+
function edgeProvenance(req, _res, next) {
|
|
101
|
+
sanitizeHeaders(req.headers);
|
|
102
|
+
next();
|
|
103
|
+
}
|
|
104
|
+
return { sanitizeHeaders, edgeSealed, edgeProvenance };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The caller's tier as the app's headers carry it, except that a verified
|
|
108
|
+
* member IS a friend here: an app that admits members alongside the tiers it
|
|
109
|
+
* hands out may choose to treat its own verification the same as a vouch.
|
|
110
|
+
* Never above friend: admin stays admin's. Pure — only `sanitizeHeaders`
|
|
111
|
+
* decides whether a header is trustworthy in the first place.
|
|
112
|
+
*/
|
|
113
|
+
export function tierOf(req) {
|
|
114
|
+
const t = req.headers['x-hadoku-tier'];
|
|
115
|
+
const tier = typeof t === 'string' && t !== '' ? t : 'public';
|
|
116
|
+
return tier === 'member' && verifiedOf(req) ? 'friend' : tier;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Is this caller verified (`X-Hadoku-Verified: 1`)? Absent means not
|
|
120
|
+
* verified. Pure: trustworthiness is `sanitizeHeaders`'s job, done before this
|
|
121
|
+
* is ever read.
|
|
122
|
+
*/
|
|
123
|
+
export function verifiedOf(req) {
|
|
124
|
+
return verifiedFromHeaders(req.headers);
|
|
125
|
+
}
|
|
126
|
+
/** `verifiedOf` for a socket's handshake, whose headers went through the same sanitizing. */
|
|
127
|
+
export function verifiedFromHeaders(headers) {
|
|
128
|
+
return headers['x-hadoku-verified'] === '1';
|
|
129
|
+
}
|
|
130
|
+
/** The caller's userId as the edge stamped it; null when the edge named nobody. */
|
|
131
|
+
export function callerIdOf(req) {
|
|
132
|
+
const u = req.headers['x-user-id'];
|
|
133
|
+
return typeof u === 'string' && u !== '' ? u : null;
|
|
134
|
+
}
|
|
135
|
+
export function userIdOf(req) {
|
|
136
|
+
return callerIdOf(req) ?? 'anon';
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The caller's own live key (`X-User-Key`), edge-stamped and stripped from an
|
|
140
|
+
* unvouched request. A credential: never log it or store it. null when the
|
|
141
|
+
* edge sent none.
|
|
142
|
+
*/
|
|
143
|
+
export function userKeyOf(req) {
|
|
144
|
+
const k = req.headers['x-user-key'];
|
|
145
|
+
return typeof k === 'string' && k !== '' ? k : null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The caller's registry userId (`X-User-Id`), edge-stamped and stripped from
|
|
149
|
+
* an unvouched request. null when the edge sent none, never a stand-in word.
|
|
150
|
+
*/
|
|
151
|
+
export function accountIdOf(req) {
|
|
152
|
+
const u = req.headers['x-user-id'];
|
|
153
|
+
return typeof u === 'string' && u !== '' ? u : null;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The caller's DISPLAY NAME, edge-injected beside `X-User-Id` (same trust
|
|
157
|
+
* class: exclusively edge-set, stripped from unvouched requests). null when
|
|
158
|
+
* the edge sent none — an unnamed key.
|
|
159
|
+
*/
|
|
160
|
+
export function userNameOf(req) {
|
|
161
|
+
return userNameFromHeaders(req.headers);
|
|
162
|
+
}
|
|
163
|
+
/** `userNameOf` for a socket's handshake, whose headers went through the same sanitizing. */
|
|
164
|
+
export function userNameFromHeaders(headers) {
|
|
165
|
+
const n = headers['x-user-name'];
|
|
166
|
+
return typeof n === 'string' && n !== '' ? utf8HeaderValue(n) : null;
|
|
167
|
+
}
|
|
168
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
169
|
+
/**
|
|
170
|
+
* A header value as the text its sender meant. The edge sends a name's UTF-8
|
|
171
|
+
* bytes and Node reads every header byte as one Latin-1 character, so
|
|
172
|
+
* `王蟲様` arrives as `ç\x8e\x8bè…`. Bytes that are valid UTF-8 are read as
|
|
173
|
+
* UTF-8; anything else (plain ASCII, or a lone Latin-1 byte) is kept as it came.
|
|
174
|
+
*/
|
|
175
|
+
export function utf8HeaderValue(value) {
|
|
176
|
+
const codes = Array.from(value, (c) => c.charCodeAt(0));
|
|
177
|
+
if (!codes.some((c) => c >= 0x80) || codes.some((c) => c > 0xff))
|
|
178
|
+
return value;
|
|
179
|
+
try {
|
|
180
|
+
return STRICT_UTF8.decode(Buffer.from(value, 'latin1'));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** The one admin check: `rankOf(tierOf(req)) >= rankOf('admin')`. */
|
|
187
|
+
export function isAdmin(req) {
|
|
188
|
+
return rankOf(tierOf(req)) >= rankOf('admin');
|
|
189
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import { type Report } from '../telemetry/telemetry.js';
|
|
3
|
+
export declare const CAPACITY_WINDOW_MS: number;
|
|
4
|
+
export declare const SAMPLE_MS = 15000;
|
|
5
|
+
export type Gauges = Record<string, () => number>;
|
|
6
|
+
export interface CapacityDeps {
|
|
7
|
+
gauges: Gauges;
|
|
8
|
+
report?: Report;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
windowMs?: number;
|
|
11
|
+
sampleMs?: number;
|
|
12
|
+
/** Counts for the window, read once as each post goes out; the reader resets its own. */
|
|
13
|
+
counts?: () => Record<string, unknown>;
|
|
14
|
+
/** Process memory; `process.memoryUsage` in production. */
|
|
15
|
+
memory?: () => {
|
|
16
|
+
rss: number;
|
|
17
|
+
heapUsed: number;
|
|
18
|
+
};
|
|
19
|
+
/** A request path counted on its own, under `uncountedField`, never timed. Default: none. */
|
|
20
|
+
uncountedPath?: (path: string) => boolean;
|
|
21
|
+
/** The capacity event's field for `uncountedPath`'s count. Default: `'uncounted'`. */
|
|
22
|
+
uncountedField?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface CapacitySampler {
|
|
25
|
+
/** Express middleware: counts each finished request and its duration. */
|
|
26
|
+
requests: (req: Request, res: Response, next: NextFunction) => void;
|
|
27
|
+
/** Begin sampling. Called once the server listens: the gauges read what boot builds. */
|
|
28
|
+
start(): void;
|
|
29
|
+
sample(): void;
|
|
30
|
+
flush(): void;
|
|
31
|
+
stop(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare function capacitySampler(deps: CapacityDeps): CapacitySampler;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What load this server carried, and how close it came to a ceiling: sampled every `SAMPLE_MS`,
|
|
3
|
+
* posted to telemetry as one `capacity` event per window with each gauge's peak and the request
|
|
4
|
+
* rate and latency. Read beside the edge's request log, it says how much traffic a given amount
|
|
5
|
+
* of headroom absorbed — the evidence for "how many players can this take".
|
|
6
|
+
*
|
|
7
|
+
* Gauges are callbacks so this file knows nothing of sockets, rooms or Forge: the app hands them
|
|
8
|
+
* in, and nothing is read before `start()`. The event loop's delay is measured here
|
|
9
|
+
* (`monitorEventLoopDelay`), because a server whose loop stalls is out of capacity whatever its
|
|
10
|
+
* gauges say.
|
|
11
|
+
*
|
|
12
|
+
* One request path is counted on its own, never timed: `uncountedPath`, under
|
|
13
|
+
* `uncountedField` in the posted event. A page that asks for ~120 images at once would otherwise
|
|
14
|
+
* swamp the request rate and pull p95 down to nothing; which path that is (cardtable's card art)
|
|
15
|
+
* and what the field is called are the app's own choice, not this module's.
|
|
16
|
+
*/
|
|
17
|
+
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
18
|
+
import { report as defaultReport } from "../telemetry/telemetry.js";
|
|
19
|
+
export const CAPACITY_WINDOW_MS = 5 * 60_000;
|
|
20
|
+
export const SAMPLE_MS = 15_000;
|
|
21
|
+
const MB = 1024 * 1024;
|
|
22
|
+
export function capacitySampler(deps) {
|
|
23
|
+
const report = deps.report ?? defaultReport;
|
|
24
|
+
const now = deps.now ?? Date.now;
|
|
25
|
+
const memory = deps.memory ?? (() => process.memoryUsage());
|
|
26
|
+
const uncountedPath = deps.uncountedPath ?? (() => false);
|
|
27
|
+
const uncountedField = deps.uncountedField ?? 'uncounted';
|
|
28
|
+
const loop = monitorEventLoopDelay({ resolution: 20 });
|
|
29
|
+
loop.enable();
|
|
30
|
+
let windowStart = now();
|
|
31
|
+
let peaks = {};
|
|
32
|
+
let last = {};
|
|
33
|
+
let samples = 0;
|
|
34
|
+
let durations = [];
|
|
35
|
+
let errors = 0;
|
|
36
|
+
/** Requests matching `uncountedPath`: counted, never timed. */
|
|
37
|
+
let uncounted = 0;
|
|
38
|
+
const sample = () => {
|
|
39
|
+
samples += 1;
|
|
40
|
+
const m = memory();
|
|
41
|
+
const reading = {
|
|
42
|
+
...Object.fromEntries(Object.entries(deps.gauges).map(([k, read]) => [k, read()])),
|
|
43
|
+
rssMb: Math.round(m.rss / MB),
|
|
44
|
+
heapMb: Math.round(m.heapUsed / MB),
|
|
45
|
+
};
|
|
46
|
+
for (const [k, v] of Object.entries(reading))
|
|
47
|
+
peaks[k] = Math.max(peaks[k] ?? v, v);
|
|
48
|
+
last = reading;
|
|
49
|
+
};
|
|
50
|
+
const flush = () => {
|
|
51
|
+
const start = windowStart;
|
|
52
|
+
windowStart = now();
|
|
53
|
+
const seconds = Math.max(1, (windowStart - start) / 1000);
|
|
54
|
+
const sorted = [...durations].sort((a, b) => a - b);
|
|
55
|
+
const pct = (p) => sorted.length === 0 ? null : sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
|
|
56
|
+
const context = {
|
|
57
|
+
from: new Date(start).toISOString(),
|
|
58
|
+
minutes: Math.round(seconds / 60),
|
|
59
|
+
samples,
|
|
60
|
+
peak: peaks,
|
|
61
|
+
now: last,
|
|
62
|
+
requests: sorted.length,
|
|
63
|
+
perMin: Math.round((sorted.length / seconds) * 60),
|
|
64
|
+
p50Ms: pct(0.5),
|
|
65
|
+
p95Ms: pct(0.95),
|
|
66
|
+
maxMs: sorted.at(-1) ?? null,
|
|
67
|
+
errors5xx: errors,
|
|
68
|
+
loopP99Ms: Math.round(loop.percentile(99) / 1e6),
|
|
69
|
+
loopMaxMs: Math.round(loop.max / 1e6),
|
|
70
|
+
};
|
|
71
|
+
context[uncountedField] = uncounted;
|
|
72
|
+
Object.assign(context, deps.counts?.());
|
|
73
|
+
report('capacity', context);
|
|
74
|
+
peaks = {};
|
|
75
|
+
samples = 0;
|
|
76
|
+
durations = [];
|
|
77
|
+
errors = 0;
|
|
78
|
+
uncounted = 0;
|
|
79
|
+
loop.reset();
|
|
80
|
+
};
|
|
81
|
+
let timers = [];
|
|
82
|
+
return {
|
|
83
|
+
requests: (req, res, next) => {
|
|
84
|
+
if (uncountedPath(req.path)) {
|
|
85
|
+
uncounted += 1;
|
|
86
|
+
next();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const t = process.hrtime.bigint();
|
|
90
|
+
res.on('finish', () => {
|
|
91
|
+
durations.push(Number(process.hrtime.bigint() - t) / 1e6);
|
|
92
|
+
if (res.statusCode >= 500)
|
|
93
|
+
errors += 1;
|
|
94
|
+
});
|
|
95
|
+
next();
|
|
96
|
+
},
|
|
97
|
+
start: () => {
|
|
98
|
+
if (timers.length > 0)
|
|
99
|
+
return;
|
|
100
|
+
windowStart = now();
|
|
101
|
+
durations = [];
|
|
102
|
+
loop.reset();
|
|
103
|
+
timers = [
|
|
104
|
+
setInterval(sample, deps.sampleMs ?? SAMPLE_MS),
|
|
105
|
+
setInterval(flush, deps.windowMs ?? CAPACITY_WINDOW_MS),
|
|
106
|
+
];
|
|
107
|
+
for (const t of timers)
|
|
108
|
+
t.unref();
|
|
109
|
+
},
|
|
110
|
+
sample,
|
|
111
|
+
flush,
|
|
112
|
+
stop: () => {
|
|
113
|
+
for (const t of timers)
|
|
114
|
+
clearInterval(t);
|
|
115
|
+
timers = [];
|
|
116
|
+
loop.disable();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CAPACITY_WINDOW_MS, SAMPLE_MS, capacitySampler, type CapacityDeps, type CapacitySampler, type Gauges, } from './capacity.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CAPACITY_WINDOW_MS, SAMPLE_MS, capacitySampler, } from "./capacity.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymising a shared record (account erase, `index.ts`): a room, a pod, a tournament or an
|
|
3
|
+
* event that other players own too keeps its shape, and the erased person in it becomes a
|
|
4
|
+
* placeholder id nobody can be traced back to.
|
|
5
|
+
*
|
|
6
|
+
* One mechanism for every store, so a store's erase is "which object to walk"
|
|
7
|
+
* and never a list of the fields that happen to hold an id today: a field added
|
|
8
|
+
* tomorrow is covered without anyone remembering this file.
|
|
9
|
+
*/
|
|
10
|
+
/** The id an erased person becomes: fresh per erase, so two erased people never merge into one. */
|
|
11
|
+
export declare function placeholderId(): string;
|
|
12
|
+
/**
|
|
13
|
+
* Replace every string in `ids` with `to`, in place, wherever it sits in `value`: a string
|
|
14
|
+
* value, an array element, an object key, a Map key or value, a Set member. Functions are
|
|
15
|
+
* left alone, an object reached twice is walked once, and nothing is written that does not
|
|
16
|
+
* change (a record may share a frozen constant). Answers how many it replaced.
|
|
17
|
+
*/
|
|
18
|
+
export declare function replaceIds(value: unknown, ids: ReadonlySet<string>, to: string): number;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymising a shared record (account erase, `index.ts`): a room, a pod, a tournament or an
|
|
3
|
+
* event that other players own too keeps its shape, and the erased person in it becomes a
|
|
4
|
+
* placeholder id nobody can be traced back to.
|
|
5
|
+
*
|
|
6
|
+
* One mechanism for every store, so a store's erase is "which object to walk"
|
|
7
|
+
* and never a list of the fields that happen to hold an id today: a field added
|
|
8
|
+
* tomorrow is covered without anyone remembering this file.
|
|
9
|
+
*/
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
/** The id an erased person becomes: fresh per erase, so two erased people never merge into one. */
|
|
12
|
+
export function placeholderId() {
|
|
13
|
+
return `erased:${randomBytes(4).toString('hex')}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Replace every string in `ids` with `to`, in place, wherever it sits in `value`: a string
|
|
17
|
+
* value, an array element, an object key, a Map key or value, a Set member. Functions are
|
|
18
|
+
* left alone, an object reached twice is walked once, and nothing is written that does not
|
|
19
|
+
* change (a record may share a frozen constant). Answers how many it replaced.
|
|
20
|
+
*/
|
|
21
|
+
export function replaceIds(value, ids, to) {
|
|
22
|
+
const seen = new WeakSet();
|
|
23
|
+
const swap = (v) => (typeof v === 'string' && ids.has(v) ? { v: to, n: 1 } : { v, n: walk(v) });
|
|
24
|
+
function walk(node) {
|
|
25
|
+
if (node === null || typeof node !== 'object' || seen.has(node))
|
|
26
|
+
return 0;
|
|
27
|
+
seen.add(node);
|
|
28
|
+
let n = 0;
|
|
29
|
+
if (Array.isArray(node)) {
|
|
30
|
+
for (let i = 0; i < node.length; i++) {
|
|
31
|
+
const r = swap(node[i]);
|
|
32
|
+
if (r.v !== node[i])
|
|
33
|
+
node[i] = r.v;
|
|
34
|
+
n += r.n;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if (node instanceof Map) {
|
|
38
|
+
for (const [k, v] of [...node]) {
|
|
39
|
+
const key = swap(k);
|
|
40
|
+
const val = swap(v);
|
|
41
|
+
if (key.v !== k)
|
|
42
|
+
node.delete(k);
|
|
43
|
+
if (key.v !== k || val.v !== v)
|
|
44
|
+
node.set(key.v, val.v);
|
|
45
|
+
n += key.n + val.n;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (node instanceof Set) {
|
|
49
|
+
for (const m of [...node]) {
|
|
50
|
+
const r = swap(m);
|
|
51
|
+
if (r.v !== m) {
|
|
52
|
+
node.delete(m);
|
|
53
|
+
node.add(r.v);
|
|
54
|
+
}
|
|
55
|
+
n += r.n;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const record = node;
|
|
60
|
+
for (const k of Object.keys(record)) {
|
|
61
|
+
const val = swap(record[k]);
|
|
62
|
+
if (ids.has(k)) {
|
|
63
|
+
delete record[k];
|
|
64
|
+
record[to] = val.v;
|
|
65
|
+
n += 1 + val.n;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
if (val.v !== record[k])
|
|
69
|
+
record[k] = val.v;
|
|
70
|
+
n += val.n;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return n;
|
|
75
|
+
}
|
|
76
|
+
return walk(value);
|
|
77
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account deletion's erase hook (hadoku_site IDENTITY_MODEL R12, "Erase hooks"; the caller is
|
|
3
|
+
* edge-router's `eraseMemberData`, which runs it on account delete and on the merged-away side of
|
|
4
|
+
* an account merge).
|
|
5
|
+
*
|
|
6
|
+
* The edge calls it with its own seal and `X-User-Id`, and wants 200 `{ erased: { <what>: <count> } }`
|
|
7
|
+
* back, zeros on a second call. Anything else fails the whole delete, which the edge retries, so a
|
|
8
|
+
* step that throws answers 500 rather than a partial count.
|
|
9
|
+
*
|
|
10
|
+
* Only the seal admits it — `edgeSealed` is an injected check, never a tier: this module knows no
|
|
11
|
+
* app's edge-auth binding. `deriveParticipantId` is likewise injected: the id a person is also
|
|
12
|
+
* known by (the `fk:`/`dev:`/`eph:` scheme, whichever app provides it) is platform but app-bound,
|
|
13
|
+
* not this kernel's to import.
|
|
14
|
+
*
|
|
15
|
+
* What is erased is a list of steps each store registers where it is built (`register`), so a
|
|
16
|
+
* store and its erase live side by side in the app. Steps run one at a time, in registration
|
|
17
|
+
* order, so two never rewrite one file at once.
|
|
18
|
+
*/
|
|
19
|
+
import type { Request, Response } from 'express';
|
|
20
|
+
/** `/api/_account/erase` — the one path every app answers this hook on. */
|
|
21
|
+
export declare const ERASE_PATH = "/api/_account/erase";
|
|
22
|
+
/** Whom one erase is for. */
|
|
23
|
+
export interface ErasedPerson {
|
|
24
|
+
/** The registry userId, as the edge sent it. */
|
|
25
|
+
userId: string;
|
|
26
|
+
/** The app's own id for this person (its identity scheme's derived id). */
|
|
27
|
+
participantId: string;
|
|
28
|
+
/** Both of the above: what a shared record names them by (`replaceIds`). */
|
|
29
|
+
ids: ReadonlySet<string>;
|
|
30
|
+
/** What they become in a shared record, one per erase (`placeholderId`). */
|
|
31
|
+
placeholder: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* One store's part of an erase: how many of the person's records it erased or anonymised,
|
|
35
|
+
* zero when none are left. A store with several kinds of record answers a count for each,
|
|
36
|
+
* by name, and `what` only names the step.
|
|
37
|
+
*/
|
|
38
|
+
export type EraseCounts = number | Record<string, number>;
|
|
39
|
+
export interface EraseStep {
|
|
40
|
+
what: string;
|
|
41
|
+
erase(person: ErasedPerson): EraseCounts | Promise<EraseCounts>;
|
|
42
|
+
}
|
|
43
|
+
export interface AccountErase {
|
|
44
|
+
/** Add a store's step. A second step under one name is a wiring bug and throws. */
|
|
45
|
+
register(step: EraseStep): void;
|
|
46
|
+
/** Run every step for this userId. Throws the first step's failure. */
|
|
47
|
+
erase(userId: string): Promise<Record<string, number>>;
|
|
48
|
+
/** The route's handler. */
|
|
49
|
+
handle(req: Request, res: Response): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export interface AccountEraseOptions {
|
|
52
|
+
log: (entry: Record<string, unknown>) => void;
|
|
53
|
+
/** Did this request carry the edge's own seal? Never a tier check. */
|
|
54
|
+
edgeSealed: (req: Request) => boolean;
|
|
55
|
+
/** This app's own identity scheme's derived id for a registry userId. */
|
|
56
|
+
deriveParticipantId: (userId: string) => string;
|
|
57
|
+
}
|
|
58
|
+
export declare function personOf(userId: string, deriveParticipantId: (userId: string) => string): ErasedPerson;
|
|
59
|
+
export declare function createAccountErase(opts: AccountEraseOptions): AccountErase;
|