@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.
@@ -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, Readonly<{ scope: AuthScope; principalId?: string }>>();
36
+ readonly #entries = new Map<string, TokenBinding>();
25
37
 
26
38
  constructor(config: TokenRegistryConfig = {}) {
27
- if (config.primaryToken) this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full' }));
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({ scope, ...(principalId === undefined ? {} : { principalId }) });
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)) return binding.scope;
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
- return this.#entries.size > 0;
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. */