@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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 +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
package/src/operator-token.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { promises as fs } from "node:fs";
|
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { configDir } from "./config.ts";
|
|
33
33
|
import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
|
34
|
+
import { isLoopbackOrigin } from "./vault-hub-origin-env.ts";
|
|
34
35
|
|
|
35
36
|
export const OPERATOR_TOKEN_FILENAME = "operator.token";
|
|
36
37
|
/** Default operator-token lifetime — 90 days, was 365d through 0.5.7 (#213). */
|
|
@@ -462,3 +463,153 @@ export async function useOperatorTokenWithAutoRotate(
|
|
|
462
463
|
status: { kind: "rotated" },
|
|
463
464
|
};
|
|
464
465
|
}
|
|
466
|
+
|
|
467
|
+
export interface SelfHealOperatorTokenOpts {
|
|
468
|
+
/**
|
|
469
|
+
* The hub's CURRENT issuer (its public origin once exposed). The stale
|
|
470
|
+
* on-disk token is re-minted under this value. Must be the resolved hub
|
|
471
|
+
* origin, never a raw flag — callers pass `r.hubOrigin` from lifecycle.
|
|
472
|
+
*/
|
|
473
|
+
issuer: string;
|
|
474
|
+
/** configDir override (where operator.token lives). Defaults to `configDir()`. */
|
|
475
|
+
configDir?: string;
|
|
476
|
+
/** Operator-facing log sink. Defaults to a no-op (silent). */
|
|
477
|
+
log?: (line: string) => void;
|
|
478
|
+
/** Override the JWT-sign clock — tests pin time. Forwarded to `issueOperatorToken`. */
|
|
479
|
+
now?: () => Date;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Disambiguated outcome of {@link selfHealOperatorTokenIssuer}. Modelled on
|
|
484
|
+
* {@link RotationStatus} — a small discriminated union the caller logs and
|
|
485
|
+
* tests assert.
|
|
486
|
+
*
|
|
487
|
+
* - `absent` — no `operator.token` on disk; nothing to heal.
|
|
488
|
+
* - `fresh` — the token's `iss` already matches the current issuer; the file
|
|
489
|
+
* is left byte-identical (no rewrite, no log).
|
|
490
|
+
* - `rotated` — the token was genuine-but-stale; re-minted under the current
|
|
491
|
+
* issuer with the SAME scope-set + sub. `path` / `expiresAt` / `scopeSet`
|
|
492
|
+
* describe the freshly-written token.
|
|
493
|
+
* - `skipped` — a guard fired; the on-disk file is left untouched. `reason`:
|
|
494
|
+
* - `unverifiable`: the on-disk token's signature did NOT verify against
|
|
495
|
+
* this hub's current keys (bad/unknown/expired kid, jose `exp` failure,
|
|
496
|
+
* or a revoked jti). We must NOT resurrect or trust it — the operator
|
|
497
|
+
* recovers via `parachute auth rotate-operator`.
|
|
498
|
+
* - `aud-mismatch`: the token carries a non-operator audience. A
|
|
499
|
+
* hand-stashed scope-narrow JWT must not be silently re-minted as a
|
|
500
|
+
* full operator token (parallels the {@link useOperatorTokenWithAutoRotate}
|
|
501
|
+
* privilege guard).
|
|
502
|
+
* - `no-sub`: the token lacks a `sub` claim, so we can't re-mint (don't
|
|
503
|
+
* know who it belongs to).
|
|
504
|
+
* - `no-scope-set`: the token lacks (or has an unrecognized) `pa_scope_set`
|
|
505
|
+
* claim. Falling back to a default would widen scope (hub#224 hardening);
|
|
506
|
+
* refuse instead. Operator recovers via explicit rotate-operator.
|
|
507
|
+
* - `issuer-loopback`: the TARGET issuer is loopback. Re-minting to a
|
|
508
|
+
* loopback `iss` would downgrade a good public token; never do it.
|
|
509
|
+
*/
|
|
510
|
+
export type OperatorIssuerHealStatus =
|
|
511
|
+
| { kind: "absent" }
|
|
512
|
+
| { kind: "fresh" }
|
|
513
|
+
| { kind: "rotated"; path: string; scopeSet: OperatorScopeSet; expiresAt: string }
|
|
514
|
+
| {
|
|
515
|
+
kind: "skipped";
|
|
516
|
+
reason: "unverifiable" | "aud-mismatch" | "no-sub" | "no-scope-set" | "issuer-loopback";
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Self-heal the operator token's `iss` when the hub's origin changed after
|
|
521
|
+
* the token was minted.
|
|
522
|
+
*
|
|
523
|
+
* The bug this closes (hub#481, same family as the rc.17 Cloudflare 401 P0):
|
|
524
|
+
* `parachute init`/setup mints `~/.parachute/operator.token` stamped with the
|
|
525
|
+
* hub's origin-at-creation-time (`http://127.0.0.1:1939` on a box set up
|
|
526
|
+
* before exposure). After `parachute expose` brings the hub up on a public
|
|
527
|
+
* origin, on-box services validate incoming bearers' `iss` against the hub's
|
|
528
|
+
* CURRENT origin, so the stale-`iss` operator token is rejected on every CLI
|
|
529
|
+
* auth flow with `bearer token invalid — unexpected "iss" claim value`. That
|
|
530
|
+
* breaks `vault create`, `mcp-install`, and `/api/auth/mint-token`. The token
|
|
531
|
+
* is genuine — it just predates the origin change — so re-issuing it under the
|
|
532
|
+
* current issuer (preserving its scope-set + sub) is the right repair.
|
|
533
|
+
*
|
|
534
|
+
* Hooked into `parachute start hub` (parallel to how rc.17 hooked
|
|
535
|
+
* `selfHealVaultHubOrigin` into `start vault`): existing broken deploys
|
|
536
|
+
* self-correct on the next `start/restart hub`.
|
|
537
|
+
*
|
|
538
|
+
* ## Security invariant (reviewer: verify this holds)
|
|
539
|
+
*
|
|
540
|
+
* Re-mint is gated on `validateAccessToken(db, token)` succeeding WITHOUT an
|
|
541
|
+
* `expectedIssuer` argument — i.e. the on-disk token's SIGNATURE verifies
|
|
542
|
+
* against THIS hub's current public keys (by kid) and passes jose's `exp`
|
|
543
|
+
* check and the revocation check, while NOT pinning `iss`. An attacker cannot
|
|
544
|
+
* forge that signature, so the ONLY tokens that can ever be re-minted are ones
|
|
545
|
+
* this hub itself previously minted. There is no path to mint a token from an
|
|
546
|
+
* untrusted/forged input. Further:
|
|
547
|
+
* - scope-set is preserved verbatim (`payload.pa_scope_set`) — no widening;
|
|
548
|
+
* - expired tokens are refused (jose `exp` → `skipped: unverifiable`);
|
|
549
|
+
* - revoked tokens are refused (validateAccessToken's revocation check);
|
|
550
|
+
* - a non-operator audience is refused (`skipped: aud-mismatch`);
|
|
551
|
+
* - a loopback TARGET issuer is refused (`skipped: issuer-loopback`) — never
|
|
552
|
+
* downgrade a good public token back to loopback.
|
|
553
|
+
* This is strictly a re-issue of the hub's own still-valid credential under
|
|
554
|
+
* the hub's own new issuer.
|
|
555
|
+
*/
|
|
556
|
+
export async function selfHealOperatorTokenIssuer(
|
|
557
|
+
db: Database,
|
|
558
|
+
opts: SelfHealOperatorTokenOpts,
|
|
559
|
+
): Promise<OperatorIssuerHealStatus> {
|
|
560
|
+
const dir = opts.configDir ?? configDir();
|
|
561
|
+
const token = await readOperatorTokenFile(dir);
|
|
562
|
+
if (!token) return { kind: "absent" };
|
|
563
|
+
|
|
564
|
+
// Target-issuer loopback guard FIRST. Re-minting to a loopback `iss` would
|
|
565
|
+
// downgrade a good public token to a non-reachable issuer, recreating the
|
|
566
|
+
// exact iss-mismatch this fix prevents (mirrors `isLoopbackOrigin`'s role in
|
|
567
|
+
// vault-hub-origin-env.ts). Never do it — bail before touching the token.
|
|
568
|
+
if (isLoopbackOrigin(opts.issuer)) return { kind: "skipped", reason: "issuer-loopback" };
|
|
569
|
+
|
|
570
|
+
// Verify the on-disk token WITHOUT pinning `iss`: this checks the signature
|
|
571
|
+
// against the hub's current keys (by kid) + jose's `exp` + revocation, but
|
|
572
|
+
// deliberately does NOT reject a stale issuer. A throw here means the token
|
|
573
|
+
// is unverifiable (bad/unknown/expired kid, expired-by-jose, revoked) — we
|
|
574
|
+
// must NOT resurrect or trust it. Leave the disk file untouched; the operator
|
|
575
|
+
// recovers via `parachute auth rotate-operator`.
|
|
576
|
+
let payload: Awaited<ReturnType<typeof validateAccessToken>>["payload"];
|
|
577
|
+
try {
|
|
578
|
+
({ payload } = await validateAccessToken(db, token));
|
|
579
|
+
} catch {
|
|
580
|
+
return { kind: "skipped", reason: "unverifiable" };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// `iss` already current → no-op. Do NOT rewrite the file; it must stay
|
|
584
|
+
// byte-identical so repeated `start hub`s don't churn it.
|
|
585
|
+
if (payload.iss === opts.issuer) return { kind: "fresh" };
|
|
586
|
+
|
|
587
|
+
// `iss` differs → genuine-but-stale. Apply the same provenance guards
|
|
588
|
+
// `useOperatorTokenWithAutoRotate` uses before re-minting.
|
|
589
|
+
if (payload.aud !== OPERATOR_TOKEN_AUDIENCE) {
|
|
590
|
+
return { kind: "skipped", reason: "aud-mismatch" };
|
|
591
|
+
}
|
|
592
|
+
const sub = typeof payload.sub === "string" && payload.sub.length > 0 ? payload.sub : null;
|
|
593
|
+
if (!sub) return { kind: "skipped", reason: "no-sub" };
|
|
594
|
+
const claimedSet = payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM];
|
|
595
|
+
if (!isOperatorScopeSet(claimedSet)) {
|
|
596
|
+
// No recognized scope-set → falling back to a default would widen scope
|
|
597
|
+
// (hub#224). Refuse; never widen.
|
|
598
|
+
return { kind: "skipped", reason: "no-scope-set" };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Re-mint preserving scope-set + sub. `issueOperatorToken` writes the new
|
|
602
|
+
// token to disk atomically (mint → writeOperatorTokenFile).
|
|
603
|
+
const issued = await issueOperatorToken(db, sub, {
|
|
604
|
+
dir,
|
|
605
|
+
issuer: opts.issuer,
|
|
606
|
+
scopeSet: claimedSet,
|
|
607
|
+
...(opts.now !== undefined ? { now: opts.now } : {}),
|
|
608
|
+
});
|
|
609
|
+
return {
|
|
610
|
+
kind: "rotated",
|
|
611
|
+
path: issued.path,
|
|
612
|
+
scopeSet: issued.scopeSet,
|
|
613
|
+
expiresAt: issued.expiresAt,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending-login state for the two-step TOTP login (hub#473).
|
|
3
|
+
*
|
|
4
|
+
* When a user with 2FA enrolled posts a correct username+password to `/login`,
|
|
5
|
+
* we do NOT mint a session yet — the user still has to prove the second
|
|
6
|
+
* factor. We stash the half-authenticated state under an opaque token and hand
|
|
7
|
+
* the browser a short-lived `parachute_hub_pending_login` cookie. The user
|
|
8
|
+
* then posts their TOTP / backup code to `/login/2fa`, which looks up the
|
|
9
|
+
* pending state, verifies the factor, and only then mints the real session.
|
|
10
|
+
*
|
|
11
|
+
* Storage: a process-local Map with per-entry expiry — same posture as the
|
|
12
|
+
* rate-limiter (`rate-limit.ts`). Persistence isn't worth a DB write: the
|
|
13
|
+
* window is 5 minutes, and a process restart simply forces the user to
|
|
14
|
+
* re-enter their password (the password POST is cheap to repeat, and losing
|
|
15
|
+
* an in-flight half-login on restart is fine — no security regression). This
|
|
16
|
+
* also avoids a second schema migration for a 5-minute-lived ephemeral row.
|
|
17
|
+
*
|
|
18
|
+
* The token is a 32-byte base64url random (same shape as a session id), so it
|
|
19
|
+
* is unguessable and opaque to the client. It carries no claims — everything
|
|
20
|
+
* is server-side in the Map.
|
|
21
|
+
*/
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
24
|
+
|
|
25
|
+
export const PENDING_LOGIN_COOKIE_NAME = "parachute_hub_pending_login";
|
|
26
|
+
/** Pending logins are valid for 5 minutes — long enough to open an
|
|
27
|
+
* authenticator app, short enough to bound a half-authenticated window. */
|
|
28
|
+
export const PENDING_LOGIN_TTL_MS = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
interface PendingLogin {
|
|
31
|
+
userId: string;
|
|
32
|
+
/** The post-2FA redirect target resolved at password-verify time. */
|
|
33
|
+
next: string;
|
|
34
|
+
/** Absolute expiry (ms epoch). */
|
|
35
|
+
expiresAtMs: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const pending = new Map<string, PendingLogin>();
|
|
39
|
+
|
|
40
|
+
function gc(nowMs: number): void {
|
|
41
|
+
for (const [token, p] of pending) {
|
|
42
|
+
if (p.expiresAtMs <= nowMs) pending.delete(token);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a pending-login entry and return its opaque token. The caller sets
|
|
48
|
+
* the token as a cookie on the "enter your code" response.
|
|
49
|
+
*/
|
|
50
|
+
export function createPendingLogin(
|
|
51
|
+
userId: string,
|
|
52
|
+
next: string,
|
|
53
|
+
now: () => Date = () => new Date(),
|
|
54
|
+
): string {
|
|
55
|
+
const nowMs = now().getTime();
|
|
56
|
+
gc(nowMs);
|
|
57
|
+
const token = randomBytes(32).toString("base64url");
|
|
58
|
+
pending.set(token, { userId, next, expiresAtMs: nowMs + PENDING_LOGIN_TTL_MS });
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a pending-login token to its state, or null if absent/expired.
|
|
64
|
+
* Does NOT consume — the caller consumes only after the second factor
|
|
65
|
+
* verifies (so a failed 2FA attempt can retry against the same pending login
|
|
66
|
+
* without re-entering the password).
|
|
67
|
+
*/
|
|
68
|
+
export function getPendingLogin(
|
|
69
|
+
token: string | null,
|
|
70
|
+
now: () => Date = () => new Date(),
|
|
71
|
+
): { userId: string; next: string } | null {
|
|
72
|
+
if (!token) return null;
|
|
73
|
+
const nowMs = now().getTime();
|
|
74
|
+
gc(nowMs);
|
|
75
|
+
const p = pending.get(token);
|
|
76
|
+
if (!p) return null;
|
|
77
|
+
if (p.expiresAtMs <= nowMs) {
|
|
78
|
+
pending.delete(token);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return { userId: p.userId, next: p.next };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Delete a pending-login entry (after successful 2FA, or on cancel). Idempotent. */
|
|
85
|
+
export function consumePendingLogin(token: string | null): void {
|
|
86
|
+
if (token) pending.delete(token);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Test-only: clear all pending logins between cases. */
|
|
90
|
+
export function _resetPendingLogins(): void {
|
|
91
|
+
pending.clear();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildPendingLoginCookie(token: string, req: Request): string {
|
|
95
|
+
const parts = [`${PENDING_LOGIN_COOKIE_NAME}=${token}`, "HttpOnly"];
|
|
96
|
+
if (isHttpsRequest(req)) parts.push("Secure");
|
|
97
|
+
// Path=/login so the cookie only rides /login and /login/2fa requests.
|
|
98
|
+
parts.push("SameSite=Lax", "Path=/login", `Max-Age=${Math.floor(PENDING_LOGIN_TTL_MS / 1000)}`);
|
|
99
|
+
return parts.join("; ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function buildPendingLoginClearCookie(req: Request): string {
|
|
103
|
+
const parts = [`${PENDING_LOGIN_COOKIE_NAME}=`, "HttpOnly"];
|
|
104
|
+
if (isHttpsRequest(req)) parts.push("Secure");
|
|
105
|
+
parts.push("SameSite=Lax", "Path=/login", "Max-Age=0");
|
|
106
|
+
return parts.join("; ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function parsePendingLoginCookie(cookieHeader: string | null): string | null {
|
|
110
|
+
if (!cookieHeader) return null;
|
|
111
|
+
for (const part of cookieHeader.split(";")) {
|
|
112
|
+
const [name, ...rest] = part.trim().split("=");
|
|
113
|
+
if (name === PENDING_LOGIN_COOKIE_NAME) return rest.join("=");
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|