@elinpf/dsh-ops-access-hub 0.2.0 → 0.4.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/README.md +32 -4
- package/README.zh.md +31 -4
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +203 -12
- package/lib/import.js +8 -0
- package/lib/index.d.ts +6 -3
- package/lib/index.js +4 -2
- package/lib/server.d.ts +35 -5
- package/lib/server.js +312 -27
- package/lib/store.d.ts +135 -5
- package/lib/store.js +219 -4
- package/lib/tokens.d.ts +110 -0
- package/lib/tokens.js +131 -0
- package/lib/web.d.ts +10 -1
- package/lib/web.js +246 -3
- package/package.json +3 -2
package/lib/store.js
CHANGED
|
@@ -17,13 +17,27 @@
|
|
|
17
17
|
* "tiers": { "ro": { "fields": { ... }, "probe": { ... } }, "rw": { "fields": { ... } } },
|
|
18
18
|
* "updatedAt": "<ISO>" } },
|
|
19
19
|
* "requests": { "<uuid>": { "kind": "...", "name": "...", "tier": "rw",
|
|
20
|
-
* "fields": { ... }, "status": "pending", ... } }
|
|
20
|
+
* "fields": { ... }, "status": "pending", ... } },
|
|
21
|
+
* "cases": { "<uuid>": { "title": "...", "symptoms": [...],
|
|
22
|
+
* "rootCause": "...", "fix": "...", "hitCount": 0, ... } },
|
|
23
|
+
* "tokens": { "<uuid>": { "name": "alice", "role": "read",
|
|
24
|
+
* "hash": "<sha256 hex>", "prefix": "AbCdEfGh", "createdAt": "<ISO>",
|
|
25
|
+
* "createdBy": "admin", "expiresAt": "<ISO>" } } }
|
|
21
26
|
* ```
|
|
22
27
|
*
|
|
23
28
|
* `requests` is the agent-registration approval queue (see server.ts
|
|
24
29
|
* `/requests` routes); a decided request keeps its metadata but its `fields`
|
|
25
30
|
* are wiped.
|
|
26
31
|
*
|
|
32
|
+
* `cases` is the troubleshooting knowledge base (see server.ts `/cases`
|
|
33
|
+
* routes): distilled postmortems an agent records after an investigation
|
|
34
|
+
* resolves, searchable by later sessions. Cases hold no secret material.
|
|
35
|
+
*
|
|
36
|
+
* `tokens` is the named-token roster (ADR-0009, see tokens.ts): every issued
|
|
37
|
+
* credential keeps its label, role, digest and lifecycle timestamps — never
|
|
38
|
+
* the plaintext, which exists only in the create response. The static
|
|
39
|
+
* bootstrap tokens are env/flag configuration and have no record here.
|
|
40
|
+
*
|
|
27
41
|
* The hub is dumb storage: file fields hold their *content* (inlined at
|
|
28
42
|
* import time) and no kind-specific schema validation happens here.
|
|
29
43
|
*
|
|
@@ -37,6 +51,11 @@ import { appendFile, chmod, mkdir, open, readFile, rename } from 'node:fs/promis
|
|
|
37
51
|
import { join } from 'node:path';
|
|
38
52
|
import { randomUUID } from 'node:crypto';
|
|
39
53
|
import { decryptDoc, encryptDoc, loadMasterKey } from './crypto.js';
|
|
54
|
+
import { hashEqual, isTokenActive, MAX_TOKENS } from './tokens.js';
|
|
55
|
+
/** Profile name / kind charset; kinds additionally can never contain `/` (path segment). Shared by the HTTP surface and the offline importer. */
|
|
56
|
+
export const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._@-]*$/;
|
|
57
|
+
/** Hard caps on the knowledge base, enforced by the store (it owns the doc). */
|
|
58
|
+
export const MAX_CASES = 500;
|
|
40
59
|
export class HubStore {
|
|
41
60
|
dataDir;
|
|
42
61
|
dataFile;
|
|
@@ -171,9 +190,205 @@ export class HubStore {
|
|
|
171
190
|
request.fields = {};
|
|
172
191
|
return request;
|
|
173
192
|
}
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
193
|
+
/** The cases map, created lazily (old data files predate the knowledge base). */
|
|
194
|
+
cases() {
|
|
195
|
+
return (this.doc.cases ??= {});
|
|
196
|
+
}
|
|
197
|
+
/** Case index rows — metadata only, never the full text fields. */
|
|
198
|
+
listCases() {
|
|
199
|
+
return Object.values(this.cases()).map((c) => ({
|
|
200
|
+
id: c.id,
|
|
201
|
+
title: c.title,
|
|
202
|
+
symptoms: c.symptoms,
|
|
203
|
+
tags: c.tags,
|
|
204
|
+
hitCount: c.hitCount,
|
|
205
|
+
updatedAt: c.updatedAt,
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
getCase(id) {
|
|
209
|
+
return this.cases()[id];
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Create a case, or update one when `id` is given (only the provided
|
|
213
|
+
* fields change; hitCount/createdAt survive). Returns null when updating
|
|
214
|
+
* an absent id. Throws when the knowledge base is at MAX_CASES.
|
|
215
|
+
*/
|
|
216
|
+
putCase(input, id) {
|
|
217
|
+
const now = new Date().toISOString();
|
|
218
|
+
if (id !== undefined) {
|
|
219
|
+
const existing = this.cases()[id];
|
|
220
|
+
if (!existing)
|
|
221
|
+
return null;
|
|
222
|
+
Object.assign(existing, input);
|
|
223
|
+
existing.updatedAt = now;
|
|
224
|
+
return existing;
|
|
225
|
+
}
|
|
226
|
+
if (Object.keys(this.cases()).length >= MAX_CASES) {
|
|
227
|
+
throw new Error(`knowledge base is full (${MAX_CASES} cases); delete stale cases first`);
|
|
228
|
+
}
|
|
229
|
+
const record = {
|
|
230
|
+
id: randomUUID(),
|
|
231
|
+
title: input.title ?? '',
|
|
232
|
+
symptoms: input.symptoms ?? [],
|
|
233
|
+
rootCause: input.rootCause ?? '',
|
|
234
|
+
fix: input.fix ?? '',
|
|
235
|
+
...(input.evidence !== undefined ? { evidence: input.evidence } : {}),
|
|
236
|
+
...(input.methodology !== undefined ? { methodology: input.methodology } : {}),
|
|
237
|
+
...(input.difficulty !== undefined ? { difficulty: input.difficulty } : {}),
|
|
238
|
+
tags: input.tags ?? [],
|
|
239
|
+
...(input.environment !== undefined ? { environment: input.environment } : {}),
|
|
240
|
+
hitCount: 0,
|
|
241
|
+
createdAt: now,
|
|
242
|
+
updatedAt: now,
|
|
243
|
+
};
|
|
244
|
+
this.cases()[record.id] = record;
|
|
245
|
+
return record;
|
|
246
|
+
}
|
|
247
|
+
/** Bump a case's hit count. Returns false when absent. */
|
|
248
|
+
hitCase(id) {
|
|
249
|
+
const record = this.cases()[id];
|
|
250
|
+
if (!record)
|
|
251
|
+
return false;
|
|
252
|
+
record.hitCount += 1;
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
/** Delete a case. Returns false when absent. */
|
|
256
|
+
deleteCase(id) {
|
|
257
|
+
if (!this.cases()[id])
|
|
258
|
+
return false;
|
|
259
|
+
delete this.cases()[id];
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
/** The tokens map, created lazily (old data files predate named tokens). */
|
|
263
|
+
tokens() {
|
|
264
|
+
return (this.doc.tokens ??= {});
|
|
265
|
+
}
|
|
266
|
+
/** Every issued token record, revoked ones included (callers project to `TokenView`). */
|
|
267
|
+
listTokens() {
|
|
268
|
+
return Object.values(this.tokens());
|
|
269
|
+
}
|
|
270
|
+
getToken(id) {
|
|
271
|
+
return this.tokens()[id];
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The first *non-revoked* token carrying this label, for uniqueness checks.
|
|
275
|
+
* A revoked label is reusable — the person it named is gone.
|
|
276
|
+
*/
|
|
277
|
+
findTokenByName(name) {
|
|
278
|
+
return Object.values(this.tokens()).find((t) => t.revokedAt === undefined && t.name === name);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The token a presented digest authenticates as, or undefined when no live
|
|
282
|
+
* token matches. Callers must reject the request either way — a digest that
|
|
283
|
+
* matches only revoked/expired records is an authentication failure.
|
|
284
|
+
*/
|
|
285
|
+
findActiveTokenByHash(hash, now) {
|
|
286
|
+
return Object.values(this.tokens()).find((t) => isTokenActive(t, now) && hashEqual(t.hash, hash));
|
|
287
|
+
}
|
|
288
|
+
/** Whether a digest belongs to any record at all (live or not), for error wording only. */
|
|
289
|
+
hasTokenHash(hash) {
|
|
290
|
+
return Object.values(this.tokens()).some((t) => hashEqual(t.hash, hash));
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Record one issued token. The caller mints the plaintext and passes only
|
|
294
|
+
* its digest + prefix; uniqueness of `name` and validation of the parsed
|
|
295
|
+
* inputs are the caller's job. Throws when the roster is at MAX_TOKENS.
|
|
296
|
+
*/
|
|
297
|
+
putToken(data) {
|
|
298
|
+
if (Object.keys(this.tokens()).length >= MAX_TOKENS) {
|
|
299
|
+
throw new Error(`token roster is full (${MAX_TOKENS} tokens); revoke stale tokens first`);
|
|
300
|
+
}
|
|
301
|
+
const token = {
|
|
302
|
+
id: randomUUID(),
|
|
303
|
+
name: data.name,
|
|
304
|
+
role: data.role,
|
|
305
|
+
hash: data.hash,
|
|
306
|
+
prefix: data.prefix,
|
|
307
|
+
createdAt: new Date().toISOString(),
|
|
308
|
+
createdBy: data.createdBy,
|
|
309
|
+
...(data.expiresAt !== undefined ? { expiresAt: data.expiresAt } : {}),
|
|
310
|
+
};
|
|
311
|
+
this.tokens()[token.id] = token;
|
|
312
|
+
return token;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Revoke one token. Returns false when the id is unknown or the token is
|
|
316
|
+
* already revoked — revocation is a one-way, terminal state.
|
|
317
|
+
*/
|
|
318
|
+
revokeToken(id) {
|
|
319
|
+
const token = this.tokens()[id];
|
|
320
|
+
if (!token || token.revokedAt !== undefined)
|
|
321
|
+
return false;
|
|
322
|
+
token.revokedAt = new Date().toISOString();
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Apply an operator edit to a live token (ADR-0010). Returns the record plus
|
|
327
|
+
* the fields that actually changed — an empty list means the patch matched
|
|
328
|
+
* the current values, so the caller can skip both the save and the audit
|
|
329
|
+
* line. Throws on a revoked record or a label clash; `null` when the id is
|
|
330
|
+
* unknown. Validation of the patch values is the caller's job.
|
|
331
|
+
*/
|
|
332
|
+
updateToken(id, patch) {
|
|
333
|
+
const token = this.tokens()[id];
|
|
334
|
+
if (!token)
|
|
335
|
+
return null;
|
|
336
|
+
if (token.revokedAt !== undefined)
|
|
337
|
+
throw new Error('token is revoked and can no longer be edited');
|
|
338
|
+
const changes = [];
|
|
339
|
+
if (patch.name !== undefined && patch.name !== token.name) {
|
|
340
|
+
const clash = Object.values(this.tokens()).find((t) => t.id !== id && t.revokedAt === undefined && t.name === patch.name);
|
|
341
|
+
if (clash)
|
|
342
|
+
throw new Error(`token name '${patch.name}' is already in use`);
|
|
343
|
+
token.name = patch.name;
|
|
344
|
+
changes.push('name');
|
|
345
|
+
}
|
|
346
|
+
if (patch.role !== undefined && patch.role !== token.role) {
|
|
347
|
+
token.role = patch.role;
|
|
348
|
+
changes.push('role');
|
|
349
|
+
}
|
|
350
|
+
if (patch.expiresAt !== undefined) {
|
|
351
|
+
// null = clear the expiry (back to a never-expiring token).
|
|
352
|
+
const next = patch.expiresAt ?? undefined;
|
|
353
|
+
if (next !== token.expiresAt) {
|
|
354
|
+
if (next === undefined)
|
|
355
|
+
delete token.expiresAt;
|
|
356
|
+
else
|
|
357
|
+
token.expiresAt = next;
|
|
358
|
+
changes.push('expiresAt');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return { token, changes };
|
|
362
|
+
}
|
|
363
|
+
/** Append one audit line for a token-roster action (kind fixed to 'token', name = token label). */
|
|
364
|
+
async auditToken(role, action, token, actor, changes) {
|
|
365
|
+
await this.appendAudit({
|
|
366
|
+
ts: new Date().toISOString(),
|
|
367
|
+
role,
|
|
368
|
+
action,
|
|
369
|
+
kind: 'token',
|
|
370
|
+
name: token.name,
|
|
371
|
+
...(actor !== undefined ? { actor } : {}),
|
|
372
|
+
...(changes !== undefined ? { changes } : {}),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/** Append one audit line for a case action (kind fixed to 'case', name = case id). */
|
|
376
|
+
async auditCase(role, action, record, actor) {
|
|
377
|
+
await this.appendAudit({
|
|
378
|
+
ts: new Date().toISOString(),
|
|
379
|
+
role,
|
|
380
|
+
action,
|
|
381
|
+
kind: 'case',
|
|
382
|
+
name: record.id,
|
|
383
|
+
title: record.title,
|
|
384
|
+
...(actor !== undefined ? { actor } : {}),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/** Append one audit line. Field values are never recorded; `actor` names the (named) token used. */
|
|
388
|
+
async audit(role, action, kind, name, tier, actor) {
|
|
389
|
+
await this.appendAudit({ ts: new Date().toISOString(), role, action, kind, name, tier, ...(actor !== undefined ? { actor } : {}) });
|
|
390
|
+
}
|
|
391
|
+
async appendAudit(record) {
|
|
177
392
|
await mkdir(this.dataDir, { recursive: true });
|
|
178
393
|
// A crash mid-append can leave a torn tail line without a newline; a
|
|
179
394
|
// naive append would fuse the next record onto it and lose both. Start
|
package/lib/tokens.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named-token model for the hub (ADR-0009).
|
|
3
|
+
*
|
|
4
|
+
* A hub used to speak exactly two shared, environment-injected Bearer tokens
|
|
5
|
+
* (one admin, one read). That is enough for "one consumer, one operator", but
|
|
6
|
+
* not for a team: everybody shares the same secret, nobody can be revoked
|
|
7
|
+
* alone, and an audit line cannot say *who* resolved a credential.
|
|
8
|
+
*
|
|
9
|
+
* A named token is an independently issued credential with a human label, a
|
|
10
|
+
* role (`admin`/`read`) and an optional expiry. Only the SHA-256 digest of the
|
|
11
|
+
* plaintext is persisted — the plaintext is returned exactly once, by the
|
|
12
|
+
* create call, and has no recovery path. The label becomes the audit `actor`,
|
|
13
|
+
* so every recorded action names the holder.
|
|
14
|
+
*
|
|
15
|
+
* The static env/flag tokens stay valid as bootstrap/break-glass credentials
|
|
16
|
+
* (see `./server.js`), so revoking every named token can never lock the
|
|
17
|
+
* operator out of the hub.
|
|
18
|
+
*
|
|
19
|
+
* This module is pure: it holds the model, the minting/hashing helpers and
|
|
20
|
+
* the input parsers; persistence lives in `./store.js`.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
export type TokenRole = 'admin' | 'read';
|
|
25
|
+
/** Roles a named token may carry; anything else is a 400. */
|
|
26
|
+
export declare const TOKEN_ROLES: readonly TokenRole[];
|
|
27
|
+
/**
|
|
28
|
+
* Token label charset — unicode letters/digits first, then letters/digits and
|
|
29
|
+
* `. _ @ + -` plus spaces, 1..64 characters. Deliberately wider than
|
|
30
|
+
* `NAME_PATTERN`: a label names a *person* (Chinese names are expected) and
|
|
31
|
+
* only ever travels in a JSON body and the audit log, never in a path.
|
|
32
|
+
*/
|
|
33
|
+
export declare const TOKEN_NAME_PATTERN: RegExp;
|
|
34
|
+
/** Hard cap on issued-but-not-purged token records (the store owns the doc). */
|
|
35
|
+
export declare const MAX_TOKENS = 200;
|
|
36
|
+
/** How many plaintext characters of a token are kept for identification. */
|
|
37
|
+
export declare const TOKEN_PREFIX_CHARS = 8;
|
|
38
|
+
/**
|
|
39
|
+
* One issued credential. `hash` is the only material stored for the secret
|
|
40
|
+
* itself; `prefix` exists so an operator can tell two live tokens apart in a
|
|
41
|
+
* listing without the hub being able to re-display them.
|
|
42
|
+
*/
|
|
43
|
+
export interface HubToken {
|
|
44
|
+
id: string;
|
|
45
|
+
/** Human label of the holder; unique among non-revoked tokens; recorded as the audit `actor`. */
|
|
46
|
+
name: string;
|
|
47
|
+
role: TokenRole;
|
|
48
|
+
/** SHA-256 hex of the plaintext token — the plaintext itself is never stored. */
|
|
49
|
+
hash: string;
|
|
50
|
+
/** First {@link TOKEN_PREFIX_CHARS} characters of the plaintext, for identification only. */
|
|
51
|
+
prefix: string;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
/** Label of the issuer (a named-token name, or `cli` for offline issuance). */
|
|
54
|
+
createdBy: string;
|
|
55
|
+
/** Optional ISO expiry; once past, the token authenticates as invalid. */
|
|
56
|
+
expiresAt?: string;
|
|
57
|
+
/** Set when revoked; a revoked token can never authenticate again. */
|
|
58
|
+
revokedAt?: string;
|
|
59
|
+
}
|
|
60
|
+
/** Metadata-safe projection of a token — never carries `hash`. */
|
|
61
|
+
export type TokenView = Omit<HubToken, 'hash'>;
|
|
62
|
+
/** Mint a fresh plaintext token (the only copy that will ever exist). */
|
|
63
|
+
export declare function generateToken(): string;
|
|
64
|
+
/** SHA-256 hex digest of a plaintext token; what the store keeps and compares. */
|
|
65
|
+
export declare function hashToken(token: string): string;
|
|
66
|
+
/** The identifying head of a plaintext token. */
|
|
67
|
+
export declare function tokenPrefix(token: string): string;
|
|
68
|
+
/** Constant-time comparison of two hex digests (same length always). */
|
|
69
|
+
export declare function hashEqual(a: string, b: string): boolean;
|
|
70
|
+
/** A token still accepts requests when it is neither revoked nor expired. */
|
|
71
|
+
export declare function isTokenActive(token: Pick<HubToken, 'revokedAt' | 'expiresAt'>, now?: string): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* How the roster surfaces one token. `active` and `expiring` both still
|
|
74
|
+
* authenticate; the split exists so an operator can renew *before* a holder is
|
|
75
|
+
* locked out (ADR-0010). `expired`/`revoked` are the two ways a record stops
|
|
76
|
+
* working.
|
|
77
|
+
*/
|
|
78
|
+
export type TokenStatus = 'active' | 'expiring' | 'expired' | 'revoked';
|
|
79
|
+
/** A token whose expiry is at most this far out is reported as `expiring`. */
|
|
80
|
+
export declare const EXPIRING_SOON_MS: number;
|
|
81
|
+
/**
|
|
82
|
+
* Classify a token for the roster and the CLI. Survives a hand-edited data
|
|
83
|
+
* file: an unparseable `expiresAt` reads as expired instead of crashing.
|
|
84
|
+
*/
|
|
85
|
+
export declare function tokenStatus(token: Pick<HubToken, 'revokedAt' | 'expiresAt'>, now?: Date): TokenStatus;
|
|
86
|
+
/**
|
|
87
|
+
* An edit applied through `PATCH /tokens/:id` / `token update`. An absent field
|
|
88
|
+
* means "leave it alone"; `expiresAt: null` is the explicit "clear the expiry" —
|
|
89
|
+
* with `undefined` already spoken for, clearing needs its own value.
|
|
90
|
+
*/
|
|
91
|
+
export interface TokenPatch {
|
|
92
|
+
name?: string;
|
|
93
|
+
role?: TokenRole;
|
|
94
|
+
expiresAt?: string | null;
|
|
95
|
+
}
|
|
96
|
+
/** The patch fields that actually differ from the record; audit `changes` verbatim. */
|
|
97
|
+
export type TokenChange = 'name' | 'role' | 'expiresAt';
|
|
98
|
+
/**
|
|
99
|
+
* Normalize the `expiresAt` half of a patch body: absent → keep, `null`/`''` →
|
|
100
|
+
* clear, anything else → a validated future ISO timestamp (throws when past).
|
|
101
|
+
*/
|
|
102
|
+
export declare function parseExpiresAtPatch(raw: unknown, now?: Date): string | null | undefined;
|
|
103
|
+
/** Drop the digest before a token record leaves the process. */
|
|
104
|
+
export declare function toTokenView(token: HubToken): TokenView;
|
|
105
|
+
/** Parse a role from a request body; throws on anything but `admin`/`read`. */
|
|
106
|
+
export declare function parseTokenRole(raw: unknown): TokenRole;
|
|
107
|
+
/** Parse and trim a token label; throws when it cannot name a person safely. */
|
|
108
|
+
export declare function parseTokenName(raw: unknown): string;
|
|
109
|
+
/** Parse an optional ISO expiry; throws when it is unparseable or already past. */
|
|
110
|
+
export declare function parseExpiresAt(raw: unknown, now?: Date): string | undefined;
|
package/lib/tokens.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named-token model for the hub (ADR-0009).
|
|
3
|
+
*
|
|
4
|
+
* A hub used to speak exactly two shared, environment-injected Bearer tokens
|
|
5
|
+
* (one admin, one read). That is enough for "one consumer, one operator", but
|
|
6
|
+
* not for a team: everybody shares the same secret, nobody can be revoked
|
|
7
|
+
* alone, and an audit line cannot say *who* resolved a credential.
|
|
8
|
+
*
|
|
9
|
+
* A named token is an independently issued credential with a human label, a
|
|
10
|
+
* role (`admin`/`read`) and an optional expiry. Only the SHA-256 digest of the
|
|
11
|
+
* plaintext is persisted — the plaintext is returned exactly once, by the
|
|
12
|
+
* create call, and has no recovery path. The label becomes the audit `actor`,
|
|
13
|
+
* so every recorded action names the holder.
|
|
14
|
+
*
|
|
15
|
+
* The static env/flag tokens stay valid as bootstrap/break-glass credentials
|
|
16
|
+
* (see `./server.js`), so revoking every named token can never lock the
|
|
17
|
+
* operator out of the hub.
|
|
18
|
+
*
|
|
19
|
+
* This module is pure: it holds the model, the minting/hashing helpers and
|
|
20
|
+
* the input parsers; persistence lives in `./store.js`.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
25
|
+
/** Roles a named token may carry; anything else is a 400. */
|
|
26
|
+
export const TOKEN_ROLES = ['admin', 'read'];
|
|
27
|
+
/**
|
|
28
|
+
* Token label charset — unicode letters/digits first, then letters/digits and
|
|
29
|
+
* `. _ @ + -` plus spaces, 1..64 characters. Deliberately wider than
|
|
30
|
+
* `NAME_PATTERN`: a label names a *person* (Chinese names are expected) and
|
|
31
|
+
* only ever travels in a JSON body and the audit log, never in a path.
|
|
32
|
+
*/
|
|
33
|
+
export const TOKEN_NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}._@+ -]{0,63}$/u;
|
|
34
|
+
/** Hard cap on issued-but-not-purged token records (the store owns the doc). */
|
|
35
|
+
export const MAX_TOKENS = 200;
|
|
36
|
+
/** Entropy behind every issued token: 24 random bytes → 32 base64url chars. */
|
|
37
|
+
const TOKEN_BYTES = 24;
|
|
38
|
+
/** How many plaintext characters of a token are kept for identification. */
|
|
39
|
+
export const TOKEN_PREFIX_CHARS = 8;
|
|
40
|
+
/** Mint a fresh plaintext token (the only copy that will ever exist). */
|
|
41
|
+
export function generateToken() {
|
|
42
|
+
return randomBytes(TOKEN_BYTES).toString('base64url');
|
|
43
|
+
}
|
|
44
|
+
/** SHA-256 hex digest of a plaintext token; what the store keeps and compares. */
|
|
45
|
+
export function hashToken(token) {
|
|
46
|
+
return createHash('sha256').update(token, 'utf8').digest('hex');
|
|
47
|
+
}
|
|
48
|
+
/** The identifying head of a plaintext token. */
|
|
49
|
+
export function tokenPrefix(token) {
|
|
50
|
+
return token.slice(0, TOKEN_PREFIX_CHARS);
|
|
51
|
+
}
|
|
52
|
+
/** Constant-time comparison of two hex digests (same length always). */
|
|
53
|
+
export function hashEqual(a, b) {
|
|
54
|
+
const ba = Buffer.from(a, 'utf8');
|
|
55
|
+
const bb = Buffer.from(b, 'utf8');
|
|
56
|
+
if (ba.length !== bb.length)
|
|
57
|
+
return false;
|
|
58
|
+
return timingSafeEqual(ba, bb);
|
|
59
|
+
}
|
|
60
|
+
/** A token still accepts requests when it is neither revoked nor expired. */
|
|
61
|
+
export function isTokenActive(token, now = new Date().toISOString()) {
|
|
62
|
+
if (token.revokedAt !== undefined)
|
|
63
|
+
return false;
|
|
64
|
+
if (token.expiresAt !== undefined && token.expiresAt <= now)
|
|
65
|
+
return false;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
/** A token whose expiry is at most this far out is reported as `expiring`. */
|
|
69
|
+
export const EXPIRING_SOON_MS = 7 * 24 * 60 * 60 * 1000;
|
|
70
|
+
/**
|
|
71
|
+
* Classify a token for the roster and the CLI. Survives a hand-edited data
|
|
72
|
+
* file: an unparseable `expiresAt` reads as expired instead of crashing.
|
|
73
|
+
*/
|
|
74
|
+
export function tokenStatus(token, now = new Date()) {
|
|
75
|
+
if (token.revokedAt !== undefined)
|
|
76
|
+
return 'revoked';
|
|
77
|
+
if (token.expiresAt !== undefined) {
|
|
78
|
+
const expiry = Date.parse(token.expiresAt);
|
|
79
|
+
if (!Number.isFinite(expiry) || expiry <= now.getTime())
|
|
80
|
+
return 'expired';
|
|
81
|
+
if (expiry - now.getTime() <= EXPIRING_SOON_MS)
|
|
82
|
+
return 'expiring';
|
|
83
|
+
}
|
|
84
|
+
return 'active';
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Normalize the `expiresAt` half of a patch body: absent → keep, `null`/`''` →
|
|
88
|
+
* clear, anything else → a validated future ISO timestamp (throws when past).
|
|
89
|
+
*/
|
|
90
|
+
export function parseExpiresAtPatch(raw, now = new Date()) {
|
|
91
|
+
if (raw === undefined)
|
|
92
|
+
return undefined;
|
|
93
|
+
if (raw === null || raw === '')
|
|
94
|
+
return null;
|
|
95
|
+
// parseExpiresAt rejects the past/unparseable and never returns undefined here.
|
|
96
|
+
return parseExpiresAt(raw, now) ?? null;
|
|
97
|
+
}
|
|
98
|
+
/** Drop the digest before a token record leaves the process. */
|
|
99
|
+
export function toTokenView(token) {
|
|
100
|
+
const { hash: _hash, ...view } = token;
|
|
101
|
+
return view;
|
|
102
|
+
}
|
|
103
|
+
/** Parse a role from a request body; throws on anything but `admin`/`read`. */
|
|
104
|
+
export function parseTokenRole(raw) {
|
|
105
|
+
if (raw === 'admin' || raw === 'read')
|
|
106
|
+
return raw;
|
|
107
|
+
throw new Error("role must be 'admin' or 'read'");
|
|
108
|
+
}
|
|
109
|
+
/** Parse and trim a token label; throws when it cannot name a person safely. */
|
|
110
|
+
export function parseTokenName(raw) {
|
|
111
|
+
if (typeof raw !== 'string')
|
|
112
|
+
throw new Error('name must be a string');
|
|
113
|
+
const name = raw.trim();
|
|
114
|
+
if (!TOKEN_NAME_PATTERN.test(name)) {
|
|
115
|
+
throw new Error(`invalid name: must match ${TOKEN_NAME_PATTERN.source} (1..64 chars)`);
|
|
116
|
+
}
|
|
117
|
+
return name;
|
|
118
|
+
}
|
|
119
|
+
/** Parse an optional ISO expiry; throws when it is unparseable or already past. */
|
|
120
|
+
export function parseExpiresAt(raw, now = new Date()) {
|
|
121
|
+
if (raw === undefined || raw === null || raw === '')
|
|
122
|
+
return undefined;
|
|
123
|
+
if (typeof raw !== 'string')
|
|
124
|
+
throw new Error('expiresAt must be an ISO date string');
|
|
125
|
+
const ms = Date.parse(raw);
|
|
126
|
+
if (!Number.isFinite(ms))
|
|
127
|
+
throw new Error('expiresAt must be an ISO date string');
|
|
128
|
+
if (ms <= now.getTime())
|
|
129
|
+
throw new Error('expiresAt must be in the future');
|
|
130
|
+
return new Date(ms).toISOString();
|
|
131
|
+
}
|
package/lib/web.d.ts
CHANGED
|
@@ -4,10 +4,19 @@
|
|
|
4
4
|
* the page itself prompts for a Bearer token (kept in localStorage) and all
|
|
5
5
|
* data fetches carry it, so no secret material is embedded in the HTML.
|
|
6
6
|
*
|
|
7
|
+
* Admin views: entry list/editor, the named-token roster and the audit log with
|
|
8
|
+
* its actor column. The token input reports what it resolves to via `/whoami`.
|
|
9
|
+
*
|
|
10
|
+
* The roster is the fine-grained control surface (ADR-0010): a status badge
|
|
11
|
+
* per record (active / expiring / expired / revoked), a search box plus status
|
|
12
|
+
* and role filters over the roster, and an edit dialog that patches a live
|
|
13
|
+
* token's label, role or expiry in place — the secret never changes, so a
|
|
14
|
+
* correction never forces the holder to reconfigure.
|
|
15
|
+
*
|
|
7
16
|
* UI copy is Chinese per repo convention for operator-facing surfaces.
|
|
8
17
|
* NOTE: this string must not contain backticks or `${` sequences — the
|
|
9
18
|
* inline JS uses string concatenation instead of template literals.
|
|
10
19
|
*
|
|
11
20
|
* @module
|
|
12
21
|
*/
|
|
13
|
-
export declare const WEB_UI_HTML = "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>dsh-ops-access-hub</title>\n<style>\n :root { color-scheme: light dark; }\n * { box-sizing: border-box; }\n body { font-family: system-ui, -apple-system, \"Segoe UI\", sans-serif; margin: 0; background: #0f1419; color: #d8dee6; }\n header { display: flex; gap: 8px; align-items: center; padding: 12px 16px; background: #161d26; border-bottom: 1px solid #2a3441; flex-wrap: wrap; }\n header h1 { font-size: 16px; margin: 0 12px 0 0; }\n input, select, textarea, button { font: inherit; border-radius: 4px; border: 1px solid #3a4655; background: #0f1419; color: inherit; padding: 6px 8px; }\n button { cursor: pointer; background: #1f2937; }\n button:hover { background: #2a3646; }\n button.danger { border-color: #7f2d2d; color: #f0a0a0; }\n main { padding: 16px; max-width: 1100px; margin: 0 auto; }\n table { width: 100%; border-collapse: collapse; margin-top: 12px; }\n th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #2a3441; vertical-align: top; }\n th { color: #8b98a9; font-weight: 600; font-size: 13px; }\n .badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; margin-right: 4px; border: 1px solid #3a4655; }\n .badge.verified { border-color: #2f7d4f; color: #7fd6a4; }\n .badge.mismatch { border-color: #a33; color: #f0a0a0; }\n .badge.unverifiable { border-color: #8a6d1d; color: #e6c96a; }\n .muted { color: #8b98a9; }\n #msg { margin: 8px 0; min-height: 20px; color: #f0a0a0; white-space: pre-wrap; }\n #msg.ok { color: #7fd6a4; }\n dialog { background: #161d26; color: inherit; border: 1px solid #3a4655; border-radius: 8px; padding: 16px; width: min(640px, 92vw); }\n dialog label { display: block; margin: 8px 0 2px; font-size: 13px; color: #8b98a9; }\n dialog input, dialog select, dialog textarea { width: 100%; }\n dialog textarea { min-height: 160px; font-family: ui-monospace, monospace; font-size: 13px; }\n .row { display: flex; gap: 8px; }\n .row > div { flex: 1; }\n section { margin-top: 24px; }\n h2 { font-size: 15px; }\n</style>\n</head>\n<body>\n<header>\n <h1>dsh-ops-access-hub</h1>\n <input id=\"token\" type=\"password\" placeholder=\"Bearer token\" size=\"36\">\n <button id=\"saveToken\">\u4FDD\u5B58 token</button>\n <button id=\"refresh\">\u5237\u65B0</button>\n <button id=\"newEntry\">\u65B0\u5EFA\u6761\u76EE</button>\n <button id=\"showAudit\">\u5BA1\u8BA1\u8BB0\u5F55</button>\n <span id=\"whoami\" class=\"muted\"></span>\n</header>\n<main>\n <div id=\"msg\"></div>\n <section>\n <h2>\u6761\u76EE</h2>\n <table>\n <thead><tr><th>kind / name</th><th>\u663E\u793A\u540D</th><th>\u63CF\u8FF0</th><th>\u73AF\u5883</th><th>tier</th><th>\u66F4\u65B0\u65F6\u95F4</th><th>\u64CD\u4F5C</th></tr></thead>\n <tbody id=\"entries\"></tbody>\n </table>\n </section>\n <section id=\"auditSection\" style=\"display:none\">\n <h2>\u5BA1\u8BA1\u8BB0\u5F55(\u6700\u8FD1 100 \u6761)</h2>\n <table>\n <thead><tr><th>\u65F6\u95F4</th><th>\u89D2\u8272</th><th>\u52A8\u4F5C</th><th>\u6761\u76EE</th><th>tier</th></tr></thead>\n <tbody id=\"audit\"></tbody>\n </table>\n </section>\n</main>\n<dialog id=\"editor\">\n <h2 id=\"editorTitle\">\u7F16\u8F91\u6761\u76EE</h2>\n <div class=\"row\">\n <div><label>kind</label><input id=\"fKind\" placeholder=\"k8s\"></div>\n <div><label>profile \u540D</label><input id=\"fName\" placeholder=\"prod\"></div>\n <div><label>tier</label><select id=\"fTier\"><option value=\"ro\">ro</option><option value=\"rw\">rw</option></select></div>\n </div>\n <div class=\"row\">\n <div><label>\u663E\u793A\u540D(envelope.name)</label><input id=\"fDisp\"></div>\n <div><label>\u73AF\u5883(envelope.environment)</label><input id=\"fEnv\"></div>\n </div>\n <label>\u63CF\u8FF0(envelope.description)</label><input id=\"fDesc\">\n <label>\u5B57\u6BB5(JSON object,\u503C\u4E3A\u5B57\u6BB5\u5185\u5BB9)</label>\n <textarea id=\"fFields\" spellcheck=\"false\"></textarea>\n <div id=\"formErr\" style=\"color:#f0a0a0;min-height:18px;margin-top:4px\"></div>\n <div style=\"margin-top:12px;text-align:right\">\n <button id=\"cancelEdit\">\u53D6\u6D88</button>\n <button id=\"saveEntry\">\u4FDD\u5B58</button>\n </div>\n</dialog>\n<script>\n(function () {\n var token = localStorage.getItem('hubToken') || '';\n var tokenInput = document.getElementById('token');\n tokenInput.value = token;\n var msg = document.getElementById('msg');\n var entriesBody = document.getElementById('entries');\n var auditSection = document.getElementById('auditSection');\n var auditBody = document.getElementById('audit');\n var editor = document.getElementById('editor');\n var formErr = document.getElementById('formErr');\n var entriesCache = [];\n\n function esc(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, function (c) {\n return { '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[c];\n });\n }\n function say(text, ok) {\n msg.textContent = text || '';\n msg.className = ok ? 'ok' : '';\n }\n function api(path, opts) {\n opts = opts || {};\n opts.headers = { 'Authorization': 'Bearer ' + token };\n if (opts.body) opts.headers['Content-Type'] = 'application/json';\n return fetch(path, opts).then(function (res) {\n return res.json().then(function (body) { return { status: res.status, body: body }; });\n });\n }\n function probeBadge(tierInfo) {\n if (!tierInfo) return '';\n var p = tierInfo.probe;\n if (!p) return '<span class=\"badge\">\u65E0 probe</span>';\n return '<span class=\"badge ' + esc(p.status) + '\" title=\"' + esc(p.detail || '') + ' ' + esc(p.probedAt) + '\">' + esc(p.status) + '</span>';\n }\n function tierCell(e, tier) {\n var info = e.tiers && e.tiers[tier];\n if (!info) return '<span class=\"muted\">' + tier + ': \u2014</span><br>';\n return tier + ': ' + probeBadge(info) +\n ' <button data-edit=\"' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '\">\u7F16\u8F91</button>' +\n ' <button class=\"danger\" data-del=\"' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '\">\u5220\u9664</button><br>';\n }\n function render() {\n entriesBody.innerHTML = entriesCache.map(function (e) {\n var env = e.envelope || {};\n return '<tr><td><b>' + esc(e.kind) + '</b> / ' + esc(e.name) + '</td><td>' + esc(env.name || '') +\n '</td><td>' + esc(env.description || '') + '</td><td>' + esc(env.environment || '') +\n '</td><td>' + tierCell(e, 'ro') + tierCell(e, 'rw') +\n '</td><td class=\"muted\">' + esc(e.updatedAt || '') + '</td><td></td></tr>';\n }).join('') || '<tr><td colspan=\"7\" class=\"muted\">(\u7A7A)</td></tr>';\n }\n function load() {\n api('/entries').then(function (r) {\n if (r.status !== 200) { say('\u52A0\u8F7D\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n entriesCache = r.body;\n render();\n say('');\n }).catch(function (err) { say('\u8BF7\u6C42\u5931\u8D25:' + err.message); });\n }\n document.getElementById('saveToken').onclick = function () {\n token = tokenInput.value.trim();\n localStorage.setItem('hubToken', token);\n say('token \u5DF2\u4FDD\u5B58', true);\n load();\n };\n document.getElementById('refresh').onclick = load;\n entriesBody.addEventListener('click', function (ev) {\n var t = ev.target;\n if (!(t instanceof HTMLElement)) return;\n var edit = t.getAttribute('data-edit');\n var del = t.getAttribute('data-del');\n if (edit) { var p = edit.split('|'); openEditor(p[0], p[1], p[2]); }\n if (del) {\n var q = del.split('|');\n if (!confirm('\u786E\u8BA4\u5220\u9664 ' + q[0] + '/' + q[1] + ' \u7684 ' + q[2] + ' tier?')) return;\n api('/entries/' + encodeURIComponent(q[0]) + '/' + encodeURIComponent(q[1]) + '/' + q[2], { method: 'DELETE' })\n .then(function (r) {\n if (r.status !== 200) { say('\u5220\u9664\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n say('\u5DF2\u5220\u9664', true); load();\n });\n }\n });\n function openEditor(kind, name, tier) {\n document.getElementById('fKind').value = kind || '';\n document.getElementById('fName').value = name || '';\n document.getElementById('fTier').value = tier || 'ro';\n document.getElementById('fKind').disabled = !!kind;\n document.getElementById('fName').disabled = !!name;\n document.getElementById('fTier').disabled = !!tier;\n formErr.textContent = '';\n var env = {};\n for (var i = 0; i < entriesCache.length; i++) {\n if (entriesCache[i].kind === kind && entriesCache[i].name === name) env = entriesCache[i].envelope || {};\n }\n document.getElementById('fDisp').value = env.name || '';\n document.getElementById('fDesc').value = env.description || '';\n document.getElementById('fEnv').value = env.environment || '';\n var fieldsBox = document.getElementById('fFields');\n fieldsBox.value = '{}';\n if (kind && name && tier) {\n api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier).then(function (r) {\n if (r.status === 200) fieldsBox.value = JSON.stringify(r.body.fields || {}, null, 2);\n });\n }\n editor.showModal();\n }\n document.getElementById('newEntry').onclick = function () { openEditor('', '', ''); };\n document.getElementById('cancelEdit').onclick = function () { editor.close(); };\n document.getElementById('saveEntry').onclick = function () {\n var kind = document.getElementById('fKind').value.trim();\n var name = document.getElementById('fName').value.trim();\n var tier = document.getElementById('fTier').value;\n var fields;\n try { fields = JSON.parse(document.getElementById('fFields').value); }\n catch (err) { formErr.textContent = '\u5B57\u6BB5 JSON \u89E3\u6790\u5931\u8D25:' + err.message; return; }\n if (!fields || typeof fields !== 'object' || Array.isArray(fields)) { formErr.textContent = '\u5B57\u6BB5\u5FC5\u987B\u662F JSON object'; return; }\n var envelope = {};\n var disp = document.getElementById('fDisp').value.trim();\n var desc = document.getElementById('fDesc').value.trim();\n var envv = document.getElementById('fEnv').value.trim();\n if (disp) envelope.name = disp;\n if (desc) envelope.description = desc;\n if (envv) envelope.environment = envv;\n api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier, {\n method: 'PUT',\n body: JSON.stringify({ fields: fields, envelope: envelope }),\n }).then(function (r) {\n if (r.status !== 200) { formErr.textContent = '\u4FDD\u5B58\u5931\u8D25:' + (r.body && r.body.error || r.status); return; }\n editor.close(); say('\u5DF2\u4FDD\u5B58', true); load();\n });\n };\n document.getElementById('showAudit').onclick = function () {\n auditSection.style.display = auditSection.style.display === 'none' ? '' : 'none';\n if (auditSection.style.display === 'none') return;\n api('/audit?limit=100').then(function (r) {\n if (r.status !== 200) { say('\u5BA1\u8BA1\u52A0\u8F7D\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n auditBody.innerHTML = r.body.map(function (a) {\n return '<tr><td class=\"muted\">' + esc(a.ts) + '</td><td>' + esc(a.role) + '</td><td>' + esc(a.action) +\n '</td><td>' + esc(a.kind) + ' / ' + esc(a.name) + '</td><td>' + esc(a.tier) + '</td></tr>';\n }).join('') || '<tr><td colspan=\"5\" class=\"muted\">(\u7A7A)</td></tr>';\n });\n };\n load();\n})();\n</script>\n</body>\n</html>\n";
|
|
22
|
+
export declare const WEB_UI_HTML = "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>dsh-ops-access-hub</title>\n<style>\n :root { color-scheme: light dark; }\n * { box-sizing: border-box; }\n body { font-family: system-ui, -apple-system, \"Segoe UI\", sans-serif; margin: 0; background: #0f1419; color: #d8dee6; }\n header { display: flex; gap: 8px; align-items: center; padding: 12px 16px; background: #161d26; border-bottom: 1px solid #2a3441; flex-wrap: wrap; }\n header h1 { font-size: 16px; margin: 0 12px 0 0; }\n input, select, textarea, button { font: inherit; border-radius: 4px; border: 1px solid #3a4655; background: #0f1419; color: inherit; padding: 6px 8px; }\n button { cursor: pointer; background: #1f2937; }\n button:hover { background: #2a3646; }\n button.danger { border-color: #7f2d2d; color: #f0a0a0; }\n main { padding: 16px; max-width: 1100px; margin: 0 auto; }\n table { width: 100%; border-collapse: collapse; margin-top: 12px; }\n th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #2a3441; vertical-align: top; }\n th { color: #8b98a9; font-weight: 600; font-size: 13px; }\n .badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; margin-right: 4px; border: 1px solid #3a4655; }\n .badge.verified { border-color: #2f7d4f; color: #7fd6a4; }\n .badge.mismatch { border-color: #a33; color: #f0a0a0; }\n .badge.unverifiable { border-color: #8a6d1d; color: #e6c96a; }\n .badge.expired { border-color: #a33; color: #f0a0a0; }\n .badge.revoked { border-color: #4a5666; color: #8b98a9; }\n .muted { color: #8b98a9; }\n #msg { margin: 8px 0; min-height: 20px; color: #f0a0a0; white-space: pre-wrap; }\n #msg.ok { color: #7fd6a4; }\n dialog { background: #161d26; color: inherit; border: 1px solid #3a4655; border-radius: 8px; padding: 16px; width: min(640px, 92vw); }\n dialog label { display: block; margin: 8px 0 2px; font-size: 13px; color: #8b98a9; }\n dialog input, dialog select, dialog textarea { width: 100%; }\n dialog textarea { min-height: 160px; font-family: ui-monospace, monospace; font-size: 13px; }\n .row { display: flex; gap: 8px; }\n .row > div { flex: 1; }\n section { margin-top: 24px; }\n h2 { font-size: 15px; }\n</style>\n</head>\n<body>\n<header>\n <h1>dsh-ops-access-hub</h1>\n <input id=\"token\" type=\"password\" placeholder=\"Bearer token\" size=\"36\">\n <button id=\"saveToken\">\u4FDD\u5B58 token</button>\n <button id=\"refresh\">\u5237\u65B0</button>\n <button id=\"newEntry\">\u65B0\u5EFA\u6761\u76EE</button>\n <button id=\"showTokens\">token \u7BA1\u7406</button>\n <button id=\"showAudit\">\u5BA1\u8BA1\u8BB0\u5F55</button>\n <span id=\"whoami\" class=\"muted\"></span>\n</header>\n<main>\n <div id=\"msg\"></div>\n <section>\n <h2>\u6761\u76EE</h2>\n <table>\n <thead><tr><th>kind / name</th><th>\u663E\u793A\u540D</th><th>\u63CF\u8FF0</th><th>\u73AF\u5883</th><th>tier</th><th>\u66F4\u65B0\u65F6\u95F4</th><th>\u64CD\u4F5C</th></tr></thead>\n <tbody id=\"entries\"></tbody>\n </table>\n </section>\n <section id=\"tokenSection\" style=\"display:none\">\n <h2>\u5177\u540D token(\u6BCF\u4EBA\u4E00\u4E2A,\u53EF\u5355\u72EC\u540A\u9500)</h2>\n <div class=\"row\" style=\"align-items:center;margin-bottom:8px\">\n <div><button id=\"newToken\">\u65B0\u5EFA token</button></div>\n <div class=\"muted\">\u660E\u6587\u53EA\u5728\u521B\u5EFA\u65F6\u663E\u793A\u4E00\u6B21,\u8BF7\u7ACB\u5373\u4EA4\u4ED8\u6301\u6709\u4EBA;\u670D\u52A1\u7AEF\u53EA\u5B58\u6458\u8981</div>\n </div>\n <div class=\"row\" style=\"align-items:center;margin-bottom:8px\">\n <div><input id=\"tokenFilter\" placeholder=\"\u641C\u7D22 \u540D\u79F0 / \u524D\u7F00 / \u521B\u5EFA\u8005\" style=\"width:100%\"></div>\n <div><select id=\"tokenStatusFilter\">\n <option value=\"\">\u5168\u90E8\u72B6\u6001</option>\n <option value=\"active\">\u6709\u6548</option>\n <option value=\"expiring\">\u5373\u5C06\u8FC7\u671F</option>\n <option value=\"expired\">\u5DF2\u8FC7\u671F</option>\n <option value=\"revoked\">\u5DF2\u540A\u9500</option>\n </select></div>\n <div><select id=\"tokenRoleFilter\">\n <option value=\"\">\u5168\u90E8\u89D2\u8272</option>\n <option value=\"admin\">admin</option>\n <option value=\"read\">read</option>\n </select></div>\n </div>\n <div id=\"tokenSummary\" class=\"muted\"></div>\n <div id=\"issuedBox\" style=\"display:none;border:1px solid #8a6d1d;border-radius:6px;padding:8px;margin:8px 0\">\n <div class=\"muted\">\u65B0 token \u660E\u6587(\u53EA\u663E\u793A\u8FD9\u4E00\u6B21,\u5173\u95ED\u6216\u5237\u65B0\u540E\u65E0\u6CD5\u518D\u6B21\u67E5\u770B):</div>\n <code id=\"issuedToken\" style=\"word-break:break-all\"></code>\n <button id=\"copyIssued\">\u590D\u5236</button>\n </div>\n <table>\n <thead><tr><th>\u540D\u79F0(\u6301\u6709\u4EBA)</th><th>\u89D2\u8272</th><th>\u524D\u7F00</th><th>\u521B\u5EFA\u8005</th><th>\u521B\u5EFA\u65F6\u95F4</th><th>\u5230\u671F\u65F6\u95F4</th><th>\u72B6\u6001</th><th>\u64CD\u4F5C</th></tr></thead>\n <tbody id=\"tokens\"></tbody>\n </table>\n </section>\n <section id=\"auditSection\" style=\"display:none\">\n <h2>\u5BA1\u8BA1\u8BB0\u5F55(\u6700\u8FD1 100 \u6761)</h2>\n <table>\n <thead><tr><th>\u65F6\u95F4</th><th>\u89D2\u8272</th><th>\u64CD\u4F5C\u8005(token)</th><th>\u52A8\u4F5C</th><th>\u6761\u76EE</th><th>tier</th></tr></thead>\n <tbody id=\"audit\"></tbody>\n </table>\n </section>\n</main>\n<dialog id=\"tokenEditor\">\n <h2>\u65B0\u5EFA token</h2>\n <label>\u6301\u6709\u4EBA\u540D\u79F0(\u5BA1\u8BA1\u4E2D\u7684\u64CD\u4F5C\u8005)</label><input id=\"tName\" placeholder=\"alice\">\n <div class=\"row\">\n <div><label>\u89D2\u8272</label><select id=\"tRole\"><option value=\"read\">read</option><option value=\"admin\">admin</option></select></div>\n <div><label>\u6709\u6548\u671F(\u53EF\u9009,ISO \u65F6\u95F4,\u7559\u7A7A\u4E3A\u957F\u671F)</label><input id=\"tExpires\" placeholder=\"2026-12-31T00:00:00Z\"></div>\n </div>\n <div id=\"tokenErr\" style=\"color:#f0a0a0;min-height:18px;margin-top:4px\"></div>\n <div style=\"margin-top:12px;text-align:right\">\n <button id=\"cancelToken\">\u53D6\u6D88</button>\n <button id=\"createToken\">\u7B7E\u53D1</button>\n </div>\n</dialog>\n<dialog id=\"tokenEdit\">\n <h2>\u7F16\u8F91 token</h2>\n <div class=\"muted\">\u53EA\u6539\u6807\u7B7E / \u89D2\u8272 / \u6709\u6548\u671F:token \u660E\u6587\u4E0D\u53D8,\u6301\u6709\u4EBA\u65E0\u9700\u91CD\u65B0\u914D\u7F6E</div>\n <label>\u6301\u6709\u4EBA\u540D\u79F0(\u5BA1\u8BA1\u4E2D\u7684\u64CD\u4F5C\u8005)</label><input id=\"eName\">\n <div class=\"row\">\n <div><label>\u89D2\u8272</label><select id=\"eRole\"><option value=\"read\">read</option><option value=\"admin\">admin</option></select></div>\n <div><label>\u6709\u6548\u671F(ISO \u65F6\u95F4)</label><input id=\"eExpires\" placeholder=\"2026-12-31T00:00:00Z\"></div>\n </div>\n <label style=\"margin-top:8px\"><input type=\"checkbox\" id=\"eClearExpires\" style=\"width:auto\"> \u6E05\u9664\u6709\u6548\u671F(\u6539\u4E3A\u957F\u671F\u6709\u6548)</label>\n <div id=\"tokenEditErr\" style=\"color:#f0a0a0;min-height:18px;margin-top:4px\"></div>\n <div style=\"margin-top:12px;text-align:right\">\n <button id=\"cancelTokenEdit\">\u53D6\u6D88</button>\n <button id=\"saveTokenEdit\">\u4FDD\u5B58\u4FEE\u6539</button>\n </div>\n</dialog>\n<dialog id=\"editor\">\n <h2 id=\"editorTitle\">\u7F16\u8F91\u6761\u76EE</h2>\n <div class=\"row\">\n <div><label>kind</label><input id=\"fKind\" placeholder=\"k8s\"></div>\n <div><label>profile \u540D</label><input id=\"fName\" placeholder=\"prod\"></div>\n <div><label>tier</label><select id=\"fTier\"><option value=\"ro\">ro</option><option value=\"rw\">rw</option></select></div>\n </div>\n <div class=\"row\">\n <div><label>\u663E\u793A\u540D(envelope.name)</label><input id=\"fDisp\"></div>\n <div><label>\u73AF\u5883(envelope.environment)</label><input id=\"fEnv\"></div>\n </div>\n <label>\u63CF\u8FF0(envelope.description)</label><input id=\"fDesc\">\n <label>\u5B57\u6BB5(JSON object,\u503C\u4E3A\u5B57\u6BB5\u5185\u5BB9)</label>\n <textarea id=\"fFields\" spellcheck=\"false\"></textarea>\n <div id=\"formErr\" style=\"color:#f0a0a0;min-height:18px;margin-top:4px\"></div>\n <div style=\"margin-top:12px;text-align:right\">\n <button id=\"cancelEdit\">\u53D6\u6D88</button>\n <button id=\"saveEntry\">\u4FDD\u5B58</button>\n </div>\n</dialog>\n<script>\n(function () {\n var token = localStorage.getItem('hubToken') || '';\n var tokenInput = document.getElementById('token');\n tokenInput.value = token;\n var msg = document.getElementById('msg');\n var entriesBody = document.getElementById('entries');\n var auditSection = document.getElementById('auditSection');\n var auditBody = document.getElementById('audit');\n var tokenSection = document.getElementById('tokenSection');\n var tokenBody = document.getElementById('tokens');\n var issuedBox = document.getElementById('issuedBox');\n var issuedToken = document.getElementById('issuedToken');\n var tokenEditor = document.getElementById('tokenEditor');\n var tokenErr = document.getElementById('tokenErr');\n var tokenEdit = document.getElementById('tokenEdit');\n var tokenEditErr = document.getElementById('tokenEditErr');\n var tokenSummary = document.getElementById('tokenSummary');\n var tokensCache = [];\n var editingTokenId = '';\n var whoami = document.getElementById('whoami');\n var editor = document.getElementById('editor');\n var formErr = document.getElementById('formErr');\n var entriesCache = [];\n\n function esc(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, function (c) {\n return { '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[c];\n });\n }\n function say(text, ok) {\n msg.textContent = text || '';\n msg.className = ok ? 'ok' : '';\n }\n function api(path, opts) {\n opts = opts || {};\n opts.headers = { 'Authorization': 'Bearer ' + token };\n if (opts.body) opts.headers['Content-Type'] = 'application/json';\n return fetch(path, opts).then(function (res) {\n return res.json().then(function (body) { return { status: res.status, body: body }; });\n });\n }\n function probeBadge(tierInfo) {\n if (!tierInfo) return '';\n var p = tierInfo.probe;\n if (!p) return '<span class=\"badge\">\u65E0 probe</span>';\n return '<span class=\"badge ' + esc(p.status) + '\" title=\"' + esc(p.detail || '') + ' ' + esc(p.probedAt) + '\">' + esc(p.status) + '</span>';\n }\n function tierCell(e, tier) {\n var info = e.tiers && e.tiers[tier];\n if (!info) return '<span class=\"muted\">' + tier + ': \u2014</span><br>';\n return tier + ': ' + probeBadge(info) +\n ' <button data-edit=\"' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '\">\u7F16\u8F91</button>' +\n ' <button class=\"danger\" data-del=\"' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '\">\u5220\u9664</button><br>';\n }\n function render() {\n entriesBody.innerHTML = entriesCache.map(function (e) {\n var env = e.envelope || {};\n return '<tr><td><b>' + esc(e.kind) + '</b> / ' + esc(e.name) + '</td><td>' + esc(env.name || '') +\n '</td><td>' + esc(env.description || '') + '</td><td>' + esc(env.environment || '') +\n '</td><td>' + tierCell(e, 'ro') + tierCell(e, 'rw') +\n '</td><td class=\"muted\">' + esc(e.updatedAt || '') + '</td><td></td></tr>';\n }).join('') || '<tr><td colspan=\"7\" class=\"muted\">(\u7A7A)</td></tr>';\n }\n function load() {\n api('/entries').then(function (r) {\n if (r.status !== 200) { say('\u52A0\u8F7D\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n entriesCache = r.body;\n render();\n say('');\n }).catch(function (err) { say('\u8BF7\u6C42\u5931\u8D25:' + err.message); });\n api('/whoami').then(function (r) {\n if (r.status !== 200) { whoami.textContent = ''; return; }\n whoami.textContent = r.body.actor + ' / ' + r.body.role + (r.body.source === 'static' ? ' (\u9759\u6001)' : ' (\u5177\u540D)');\n });\n }\n document.getElementById('saveToken').onclick = function () {\n token = tokenInput.value.trim();\n localStorage.setItem('hubToken', token);\n say('token \u5DF2\u4FDD\u5B58', true);\n load();\n };\n document.getElementById('refresh').onclick = load;\n entriesBody.addEventListener('click', function (ev) {\n var t = ev.target;\n if (!(t instanceof HTMLElement)) return;\n var edit = t.getAttribute('data-edit');\n var del = t.getAttribute('data-del');\n if (edit) { var p = edit.split('|'); openEditor(p[0], p[1], p[2]); }\n if (del) {\n var q = del.split('|');\n if (!confirm('\u786E\u8BA4\u5220\u9664 ' + q[0] + '/' + q[1] + ' \u7684 ' + q[2] + ' tier?')) return;\n api('/entries/' + encodeURIComponent(q[0]) + '/' + encodeURIComponent(q[1]) + '/' + q[2], { method: 'DELETE' })\n .then(function (r) {\n if (r.status !== 200) { say('\u5220\u9664\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n say('\u5DF2\u5220\u9664', true); load();\n });\n }\n });\n function openEditor(kind, name, tier) {\n document.getElementById('fKind').value = kind || '';\n document.getElementById('fName').value = name || '';\n document.getElementById('fTier').value = tier || 'ro';\n document.getElementById('fKind').disabled = !!kind;\n document.getElementById('fName').disabled = !!name;\n document.getElementById('fTier').disabled = !!tier;\n formErr.textContent = '';\n var env = {};\n for (var i = 0; i < entriesCache.length; i++) {\n if (entriesCache[i].kind === kind && entriesCache[i].name === name) env = entriesCache[i].envelope || {};\n }\n document.getElementById('fDisp').value = env.name || '';\n document.getElementById('fDesc').value = env.description || '';\n document.getElementById('fEnv').value = env.environment || '';\n var fieldsBox = document.getElementById('fFields');\n fieldsBox.value = '{}';\n if (kind && name && tier) {\n api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier).then(function (r) {\n if (r.status === 200) fieldsBox.value = JSON.stringify(r.body.fields || {}, null, 2);\n });\n }\n editor.showModal();\n }\n document.getElementById('newEntry').onclick = function () { openEditor('', '', ''); };\n document.getElementById('cancelEdit').onclick = function () { editor.close(); };\n document.getElementById('saveEntry').onclick = function () {\n var kind = document.getElementById('fKind').value.trim();\n var name = document.getElementById('fName').value.trim();\n var tier = document.getElementById('fTier').value;\n var fields;\n try { fields = JSON.parse(document.getElementById('fFields').value); }\n catch (err) { formErr.textContent = '\u5B57\u6BB5 JSON \u89E3\u6790\u5931\u8D25:' + err.message; return; }\n if (!fields || typeof fields !== 'object' || Array.isArray(fields)) { formErr.textContent = '\u5B57\u6BB5\u5FC5\u987B\u662F JSON object'; return; }\n var envelope = {};\n var disp = document.getElementById('fDisp').value.trim();\n var desc = document.getElementById('fDesc').value.trim();\n var envv = document.getElementById('fEnv').value.trim();\n if (disp) envelope.name = disp;\n if (desc) envelope.description = desc;\n if (envv) envelope.environment = envv;\n api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier, {\n method: 'PUT',\n body: JSON.stringify({ fields: fields, envelope: envelope }),\n }).then(function (r) {\n if (r.status !== 200) { formErr.textContent = '\u4FDD\u5B58\u5931\u8D25:' + (r.body && r.body.error || r.status); return; }\n editor.close(); say('\u5DF2\u4FDD\u5B58', true); load();\n });\n };\n document.getElementById('showAudit').onclick = function () {\n auditSection.style.display = auditSection.style.display === 'none' ? '' : 'none';\n if (auditSection.style.display === 'none') return;\n api('/audit?limit=100').then(function (r) {\n if (r.status !== 200) { say('\u5BA1\u8BA1\u52A0\u8F7D\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n auditBody.innerHTML = r.body.map(function (a) {\n return '<tr><td class=\"muted\">' + esc(a.ts) + '</td><td>' + esc(a.role) + '</td><td>' + esc(a.actor || '\u2014') +\n '</td><td>' + esc(a.action + (a.changes && a.changes.length ? ' (' + a.changes.join(', ') + ')' : '')) +\n '</td><td>' + esc(a.kind) + ' / ' + esc(a.name) + '</td><td>' + esc(a.tier) + '</td></tr>';\n }).join('') || '<tr><td colspan=\"6\" class=\"muted\">(\u7A7A)</td></tr>';\n });\n };\n // Mirrors tokenStatus() in tokens.ts: active and expiring both still\n // authenticate; the split is what lets an operator renew in time.\n var EXPIRING_SOON_MS = 7 * 24 * 60 * 60 * 1000;\n function tokenStatus(t) {\n if (t.revokedAt) return 'revoked';\n if (t.expiresAt) {\n var ms = Date.parse(t.expiresAt);\n if (!isFinite(ms) || ms <= Date.now()) return 'expired';\n if (ms - Date.now() <= EXPIRING_SOON_MS) return 'expiring';\n }\n return 'active';\n }\n function daysLeft(iso) {\n return Math.ceil((Date.parse(iso) - Date.now()) / 86400000);\n }\n function localTime(iso) {\n if (!iso) return '';\n var d = new Date(iso);\n return isNaN(d.getTime()) ? String(iso) : d.toLocaleString();\n }\n function statusCell(t) {\n var s = tokenStatus(t);\n if (s === 'revoked') return '<span class=\"badge revoked\">\u5DF2\u540A\u9500</span>';\n if (s === 'expired') return '<span class=\"badge expired\">\u5DF2\u8FC7\u671F</span>';\n if (s === 'expiring') return '<span class=\"badge unverifiable\">' + daysLeft(t.expiresAt) + ' \u5929\u540E\u8FC7\u671F</span>';\n return '<span class=\"badge verified\">\u6709\u6548</span>';\n }\n function findToken(id) {\n for (var i = 0; i < tokensCache.length; i++) if (tokensCache[i].id === id) return tokensCache[i];\n return null;\n }\n function renderTokens() {\n var q = document.getElementById('tokenFilter').value.trim().toLowerCase();\n var statusFilter = document.getElementById('tokenStatusFilter').value;\n var roleFilter = document.getElementById('tokenRoleFilter').value;\n var counts = { active: 0, expiring: 0, expired: 0, revoked: 0 };\n tokensCache.forEach(function (t) { counts[tokenStatus(t)] += 1; });\n var rows = tokensCache.filter(function (t) {\n if (roleFilter && t.role !== roleFilter) return false;\n if (statusFilter && tokenStatus(t) !== statusFilter) return false;\n if (!q) return true;\n return (t.name + ' ' + t.prefix + ' ' + t.createdBy).toLowerCase().indexOf(q) !== -1;\n });\n tokenSummary.textContent = '\u5171 ' + tokensCache.length + ' \u6761:\u6709\u6548 ' + counts.active + ' \u00B7 \u5373\u5C06\u8FC7\u671F ' +\n counts.expiring + ' \u00B7 \u5DF2\u8FC7\u671F ' + counts.expired + ' \u00B7 \u5DF2\u540A\u9500 ' + counts.revoked +\n (rows.length === tokensCache.length ? '' : ' \u2014 \u5F53\u524D\u7B5B\u9009\u547D\u4E2D ' + rows.length + ' \u6761');\n tokenBody.innerHTML = rows.map(function (t) {\n var actions = t.revokedAt ? '<span class=\"muted\">\u2014</span>' :\n '<button data-edit-token=\"' + esc(t.id) + '\">\u7F16\u8F91</button> ' +\n '<button class=\"danger\" data-revoke=\"' + esc(t.id) + '\">\u540A\u9500</button>';\n return '<tr><td><b>' + esc(t.name) + '</b></td><td>' + esc(t.role) + '</td><td class=\"muted\">' + esc(t.prefix) +\n '...</td><td class=\"muted\">' + esc(t.createdBy) + '</td><td class=\"muted\">' + esc(localTime(t.createdAt)) +\n '</td><td class=\"muted\">' + esc(t.expiresAt ? localTime(t.expiresAt) : '\u957F\u671F') + '</td><td>' + statusCell(t) +\n '</td><td>' + actions + '</td></tr>';\n }).join('') || '<tr><td colspan=\"8\" class=\"muted\">(\u6CA1\u6709\u5339\u914D\u7684 token)</td></tr>';\n }\n function loadTokens() {\n api('/tokens').then(function (r) {\n if (r.status !== 200) {\n tokensCache = [];\n tokenSummary.textContent = '';\n tokenBody.innerHTML = '<tr><td colspan=\"8\" class=\"muted\">\u52A0\u8F7D\u5931\u8D25(\u9700\u8981 admin token):' + esc(r.body && r.body.error || r.status) + '</td></tr>';\n return;\n }\n tokensCache = r.body;\n renderTokens();\n });\n }\n document.getElementById('tokenFilter').oninput = renderTokens;\n document.getElementById('tokenStatusFilter').onchange = renderTokens;\n document.getElementById('tokenRoleFilter').onchange = renderTokens;\n document.getElementById('showTokens').onclick = function () {\n tokenSection.style.display = tokenSection.style.display === 'none' ? '' : 'none';\n if (tokenSection.style.display === 'none') { issuedBox.style.display = 'none'; return; }\n loadTokens();\n };\n document.getElementById('newToken').onclick = function () {\n document.getElementById('tName').value = '';\n document.getElementById('tRole').value = 'read';\n document.getElementById('tExpires').value = '';\n tokenErr.textContent = '';\n tokenEditor.showModal();\n };\n document.getElementById('cancelToken').onclick = function () { tokenEditor.close(); };\n document.getElementById('createToken').onclick = function () {\n var body = {\n name: document.getElementById('tName').value,\n role: document.getElementById('tRole').value,\n expiresAt: document.getElementById('tExpires').value,\n };\n api('/tokens', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {\n if (r.status !== 200) { tokenErr.textContent = '\u7B7E\u53D1\u5931\u8D25:' + (r.body && r.body.error || r.status); return; }\n tokenEditor.close();\n issuedBox.style.display = '';\n issuedToken.textContent = r.body.token;\n say('\u5DF2\u7B7E\u53D1 token ' + r.body.name + '(' + r.body.role + '),\u660E\u6587\u53EA\u663E\u793A\u8FD9\u4E00\u6B21', true);\n loadTokens();\n });\n };\n document.getElementById('copyIssued').onclick = function () {\n if (navigator.clipboard) navigator.clipboard.writeText(issuedToken.textContent || '');\n };\n document.getElementById('eClearExpires').onchange = function () {\n var box = document.getElementById('eExpires');\n box.disabled = this.checked;\n if (this.checked) box.value = '';\n };\n function openTokenEditor(id) {\n var t = findToken(id);\n if (!t) return;\n editingTokenId = id;\n document.getElementById('eName').value = t.name;\n document.getElementById('eRole').value = t.role;\n document.getElementById('eExpires').value = t.expiresAt || '';\n document.getElementById('eExpires').disabled = false;\n document.getElementById('eClearExpires').checked = false;\n tokenEditErr.textContent = '';\n tokenEdit.showModal();\n }\n document.getElementById('cancelTokenEdit').onclick = function () { tokenEdit.close(); };\n document.getElementById('saveTokenEdit').onclick = function () {\n var patch = {\n name: document.getElementById('eName').value,\n role: document.getElementById('eRole').value,\n };\n if (document.getElementById('eClearExpires').checked) patch.expiresAt = null;\n else {\n var exp = document.getElementById('eExpires').value.trim();\n if (exp) patch.expiresAt = exp;\n }\n api('/tokens/' + encodeURIComponent(editingTokenId), { method: 'PATCH', body: JSON.stringify(patch) }).then(function (r) {\n if (r.status !== 200) { tokenEditErr.textContent = '\u4FDD\u5B58\u5931\u8D25:' + (r.body && r.body.error || r.status); return; }\n tokenEdit.close();\n var changed = r.body.changes || [];\n say(changed.length ? '\u5DF2\u66F4\u65B0 ' + r.body.name + ':' + changed.join(', ') : '\u4E0E\u5F53\u524D\u503C\u4E00\u81F4,\u65E0\u9700\u4FEE\u6539', true);\n loadTokens();\n });\n };\n tokenBody.addEventListener('click', function (ev) {\n var t = ev.target;\n if (!(t instanceof HTMLElement)) return;\n var editId = t.getAttribute('data-edit-token');\n if (editId) { openTokenEditor(editId); return; }\n var revokeId = t.getAttribute('data-revoke');\n if (!revokeId) return;\n var token = findToken(revokeId);\n var label = token ? token.name : revokeId;\n if (!confirm('\u786E\u8BA4\u540A\u9500 ' + label + ' \u7684 token?\u6301\u6709\u4EBA\u5C06\u7ACB\u5373\u5931\u53BB\u8BBF\u95EE\u6743\u9650,\u4E14\u4E0D\u53EF\u6062\u590D')) return;\n api('/tokens/' + encodeURIComponent(revokeId), { method: 'DELETE' }).then(function (r) {\n if (r.status !== 200) { say('\u540A\u9500\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n say('\u5DF2\u540A\u9500 ' + label, true);\n loadTokens();\n });\n });\n load();\n})();\n</script>\n</body>\n</html>\n";
|