@zhin.js/host-http 1.0.14 → 1.0.16
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/lib/console-events.d.ts +4 -0
- package/lib/console-events.js +14 -0
- package/lib/console-rpc-extended.d.ts +12 -0
- package/lib/console-rpc-extended.js +81 -0
- package/lib/console-rpc.d.ts +4 -0
- package/lib/console-rpc.js +36 -0
- package/lib/http-host.d.ts +22 -1
- package/lib/http-host.js +24 -11
- package/lib/index.d.ts +4 -3
- package/lib/index.js +2 -1
- package/lib/listener-group.d.ts +9 -0
- package/lib/listener-group.js +88 -0
- package/lib/token-registry.d.ts +7 -0
- package/lib/token-registry.js +62 -6
- package/package.json +1 -1
- package/src/console-events.ts +14 -0
- package/src/console-rpc-extended.ts +100 -1
- package/src/console-rpc.ts +40 -0
- package/src/http-host.ts +53 -12
- package/src/index.ts +8 -0
- package/src/listener-group.ts +108 -0
- package/src/token-registry.ts +68 -5
package/src/token-registry.ts
CHANGED
|
@@ -20,17 +20,35 @@ export type TokenRegistryConfig = {
|
|
|
20
20
|
readonly scopedTokens?: readonly ScopedTokenConfig[];
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
export type DynamicTokenConfig = ScopedTokenConfig & {
|
|
24
|
+
/** Unix epoch milliseconds; omitted credentials remain valid until explicitly revoked. */
|
|
25
|
+
readonly expiresAt?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type TokenBinding = Readonly<{
|
|
29
|
+
scope: AuthScope;
|
|
30
|
+
principalId?: string;
|
|
31
|
+
expiresAt?: number;
|
|
32
|
+
dynamic: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
|
|
23
35
|
export class TokenRegistry {
|
|
24
|
-
readonly #entries = new Map<string,
|
|
36
|
+
readonly #entries = new Map<string, TokenBinding>();
|
|
25
37
|
|
|
26
38
|
constructor(config: TokenRegistryConfig = {}) {
|
|
27
|
-
if (config.primaryToken)
|
|
39
|
+
if (config.primaryToken) {
|
|
40
|
+
this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', dynamic: false }));
|
|
41
|
+
}
|
|
28
42
|
for (const { token, scope, principalId } of config.scopedTokens ?? []) {
|
|
29
43
|
if (!token) continue;
|
|
30
44
|
if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
|
|
31
45
|
throw new Error('HTTP token principalId is invalid');
|
|
32
46
|
}
|
|
33
|
-
const candidate = Object.freeze({
|
|
47
|
+
const candidate = Object.freeze({
|
|
48
|
+
scope,
|
|
49
|
+
...(principalId === undefined ? {} : { principalId }),
|
|
50
|
+
dynamic: false,
|
|
51
|
+
});
|
|
34
52
|
const existing = this.#entries.get(token);
|
|
35
53
|
if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId)) {
|
|
36
54
|
throw new Error('HTTP token has conflicting authority bindings');
|
|
@@ -42,7 +60,9 @@ export class TokenRegistry {
|
|
|
42
60
|
resolve(token: string): AuthScope | null {
|
|
43
61
|
if (!token) return null;
|
|
44
62
|
for (const [known, binding] of this.#entries) {
|
|
45
|
-
if (timingSafeEqualString(known, token))
|
|
63
|
+
if (!timingSafeEqualString(known, token)) continue;
|
|
64
|
+
if (this.#expired(known, binding)) return null;
|
|
65
|
+
return binding.scope;
|
|
46
66
|
}
|
|
47
67
|
return null;
|
|
48
68
|
}
|
|
@@ -51,6 +71,7 @@ export class TokenRegistry {
|
|
|
51
71
|
if (!token) return null;
|
|
52
72
|
for (const [known, binding] of this.#entries) {
|
|
53
73
|
if (timingSafeEqualString(known, token)) {
|
|
74
|
+
if (this.#expired(known, binding)) return null;
|
|
54
75
|
return binding.principalId
|
|
55
76
|
? Object.freeze({ principalId: binding.principalId, scope: binding.scope })
|
|
56
77
|
: null;
|
|
@@ -60,7 +81,43 @@ export class TokenRegistry {
|
|
|
60
81
|
}
|
|
61
82
|
|
|
62
83
|
hasAnyToken(): boolean {
|
|
63
|
-
|
|
84
|
+
for (const [token, binding] of this.#entries) {
|
|
85
|
+
if (!this.#expired(token, binding)) return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Registers a runtime-issued credential and returns an idempotent revoker. */
|
|
91
|
+
register(config: DynamicTokenConfig): () => void {
|
|
92
|
+
if (!config.token) throw new Error('Dynamic HTTP token is required');
|
|
93
|
+
if (config.principalId !== undefined
|
|
94
|
+
&& (!config.principalId.trim() || config.principalId !== config.principalId.trim())) {
|
|
95
|
+
throw new Error('Dynamic HTTP token principalId is invalid');
|
|
96
|
+
}
|
|
97
|
+
if (config.expiresAt !== undefined
|
|
98
|
+
&& (!Number.isSafeInteger(config.expiresAt) || config.expiresAt <= Date.now())) {
|
|
99
|
+
throw new Error('Dynamic HTTP token expiresAt must be a future epoch millisecond');
|
|
100
|
+
}
|
|
101
|
+
const candidate: TokenBinding = Object.freeze({
|
|
102
|
+
scope: config.scope,
|
|
103
|
+
...(config.principalId === undefined ? {} : { principalId: config.principalId }),
|
|
104
|
+
...(config.expiresAt === undefined ? {} : { expiresAt: config.expiresAt }),
|
|
105
|
+
dynamic: true,
|
|
106
|
+
});
|
|
107
|
+
const existing = this.#entries.get(config.token);
|
|
108
|
+
if (existing) throw new Error('Dynamic HTTP token conflicts with an existing credential');
|
|
109
|
+
this.#entries.set(config.token, candidate);
|
|
110
|
+
let active = true;
|
|
111
|
+
return () => {
|
|
112
|
+
if (!active) return;
|
|
113
|
+
active = false;
|
|
114
|
+
if (this.#entries.get(config.token) === candidate) this.#entries.delete(config.token);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
revoke(token: string): boolean {
|
|
119
|
+
const binding = this.#entries.get(token);
|
|
120
|
+
return binding?.dynamic === true && this.#entries.delete(token);
|
|
64
121
|
}
|
|
65
122
|
|
|
66
123
|
primaryTokenPrefixForLog(): string {
|
|
@@ -70,6 +127,12 @@ export class TokenRegistry {
|
|
|
70
127
|
const first = this.#entries.keys().next().value;
|
|
71
128
|
return first ? first.slice(0, 6) : '';
|
|
72
129
|
}
|
|
130
|
+
|
|
131
|
+
#expired(token: string, binding: TokenBinding): boolean {
|
|
132
|
+
if (binding.expiresAt === undefined || binding.expiresAt > Date.now()) return false;
|
|
133
|
+
if (binding.dynamic) this.#entries.delete(token);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
73
136
|
}
|
|
74
137
|
|
|
75
138
|
/** WebSocket upgrade paths allowed for demo scope. */
|