flowviant 0.102.2 → 0.104.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/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
package/bin/lib/preview.mjs
CHANGED
|
@@ -30,23 +30,12 @@
|
|
|
30
30
|
|
|
31
31
|
import { spawn, execFileSync } from 'node:child_process';
|
|
32
32
|
import { createHash } from 'node:crypto';
|
|
33
|
-
import {
|
|
34
|
-
readFileSync,
|
|
35
|
-
writeFileSync,
|
|
36
|
-
renameSync,
|
|
37
|
-
existsSync,
|
|
38
|
-
mkdirSync,
|
|
39
|
-
chmodSync,
|
|
40
|
-
openSync,
|
|
41
|
-
closeSync,
|
|
42
|
-
statSync,
|
|
43
|
-
rmSync,
|
|
44
|
-
unlinkSync,
|
|
45
|
-
} from 'node:fs';
|
|
33
|
+
import { writeFileSync, renameSync, existsSync, mkdirSync, chmodSync, rmSync } from 'node:fs';
|
|
46
34
|
import { join } from 'node:path';
|
|
47
35
|
import { homedir, platform, arch } from 'node:os';
|
|
48
36
|
import { startAuthProxy } from './authproxy.mjs';
|
|
49
37
|
import { forgetInfraPid, isListening, noteInfraPid, originFor } from './listeners.mjs';
|
|
38
|
+
import { forgetPreviewPid, recordPreviewPid } from './previewRegistry.mjs';
|
|
50
39
|
|
|
51
40
|
// ── cloudflared: pinned, verified, or not fetched at all ───────────────────
|
|
52
41
|
|
|
@@ -152,265 +141,6 @@ async function ensureCloudflared(log) {
|
|
|
152
141
|
*/
|
|
153
142
|
export const TUNNEL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com(?![a-z0-9.-])/i;
|
|
154
143
|
|
|
155
|
-
// ── Orphan reaping ─────────────────────────────────────────────────────────
|
|
156
|
-
// cloudflared is detached so we can kill its whole group — which also means it
|
|
157
|
-
// SURVIVES an ungraceful daemon death (SIGKILL, crash, box sleep), leaving a
|
|
158
|
-
// public hostname pointed at a worktree with nobody minding it. We record each
|
|
159
|
-
// group's pid + a signature and reap ours at the next start.
|
|
160
|
-
//
|
|
161
|
-
// The registry is a read-modify-write over one file in a directory that TWO
|
|
162
|
-
// daemons can legitimately share (the 0.51.2 instance lock is keyed on a
|
|
163
|
-
// CREDENTIAL, so two daemons serving two different projects are fine and both
|
|
164
|
-
// write here). It was unlocked, written back when previews were serial. A lost
|
|
165
|
-
// entry is precisely the case reaping exists for.
|
|
166
|
-
|
|
167
|
-
const FLOWVIANT_DIR = join(homedir(), '.flowviant');
|
|
168
|
-
const PREVIEW_REGISTRY = join(FLOWVIANT_DIR, 'previews.json');
|
|
169
|
-
const REGISTRY_LOCK = join(FLOWVIANT_DIR, 'previews.lock');
|
|
170
|
-
const LOCK_STALE_MS = 15_000;
|
|
171
|
-
|
|
172
|
-
/** Best-effort exclusive lock. Returns a release function; on failure returns
|
|
173
|
-
* null and the caller proceeds unlocked — losing an entry is bad, but refusing
|
|
174
|
-
* to record one at all is worse. */
|
|
175
|
-
function acquireLock() {
|
|
176
|
-
try {
|
|
177
|
-
mkdirSync(FLOWVIANT_DIR, { recursive: true });
|
|
178
|
-
} catch {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
for (let i = 0; i < 30; i++) {
|
|
182
|
-
try {
|
|
183
|
-
const fd = openSync(REGISTRY_LOCK, 'wx');
|
|
184
|
-
closeSync(fd);
|
|
185
|
-
return () => {
|
|
186
|
-
try {
|
|
187
|
-
unlinkSync(REGISTRY_LOCK);
|
|
188
|
-
} catch {
|
|
189
|
-
/* already released */
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
} catch {
|
|
193
|
-
// Held — unless it was left behind by something that died holding it.
|
|
194
|
-
try {
|
|
195
|
-
if (Date.now() - statSync(REGISTRY_LOCK).mtimeMs > LOCK_STALE_MS) {
|
|
196
|
-
unlinkSync(REGISTRY_LOCK);
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
} catch {
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
// Spin briefly. This lock is held for one file write.
|
|
203
|
-
const until = Date.now() + 20;
|
|
204
|
-
while (Date.now() < until) {
|
|
205
|
-
/* busy-wait: 20ms, 30 times, then give up entirely */
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return null;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function readRegistry() {
|
|
213
|
-
try {
|
|
214
|
-
const v = JSON.parse(readFileSync(PREVIEW_REGISTRY, 'utf8'));
|
|
215
|
-
return Array.isArray(v) ? v : [];
|
|
216
|
-
} catch {
|
|
217
|
-
return [];
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Atomic: write a sibling temp file and rename over the target, so a reader
|
|
222
|
-
* never sees a half-written array. */
|
|
223
|
-
function writeRegistry(list) {
|
|
224
|
-
try {
|
|
225
|
-
mkdirSync(FLOWVIANT_DIR, { recursive: true });
|
|
226
|
-
const tmp = `${PREVIEW_REGISTRY}.${process.pid}.tmp`;
|
|
227
|
-
writeFileSync(tmp, JSON.stringify(list));
|
|
228
|
-
renameSync(tmp, PREVIEW_REGISTRY);
|
|
229
|
-
} catch {
|
|
230
|
-
/* best-effort */
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function mutateRegistry(fn) {
|
|
235
|
-
const release = acquireLock();
|
|
236
|
-
try {
|
|
237
|
-
writeRegistry(fn(readRegistry()));
|
|
238
|
-
} finally {
|
|
239
|
-
release?.();
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* WHEN a process started, as the kernel reports it — or null when this box
|
|
245
|
-
* will not say. Paired with a pid it names ONE process: a pid alone is
|
|
246
|
-
* recycled, and the owner check below trusted signal 0 on a bare pid.
|
|
247
|
-
*/
|
|
248
|
-
export function processStartOf(pid) {
|
|
249
|
-
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
250
|
-
if (platform() === 'linux') {
|
|
251
|
-
try {
|
|
252
|
-
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
253
|
-
// Field 22 (starttime), counted after the LAST ')' — the comm field in
|
|
254
|
-
// parentheses may itself hold spaces and parentheses.
|
|
255
|
-
const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
256
|
-
return rest[19] ? `l:${rest[19]}` : null;
|
|
257
|
-
} catch {
|
|
258
|
-
return null;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
if (platform() === 'darwin') {
|
|
262
|
-
try {
|
|
263
|
-
const out = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
|
|
264
|
-
encoding: 'utf8',
|
|
265
|
-
timeout: 5_000,
|
|
266
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
267
|
-
}).trim();
|
|
268
|
-
return out ? `d:${out}` : null;
|
|
269
|
-
} catch {
|
|
270
|
-
return null;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
return null;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/** Signal-0 liveness (EPERM = alive and not ours), for the OWNER check below. */
|
|
277
|
-
function processAlive(pid) {
|
|
278
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
279
|
-
try {
|
|
280
|
-
process.kill(pid, 0);
|
|
281
|
-
return true;
|
|
282
|
-
} catch (e) {
|
|
283
|
-
return e.code === 'EPERM';
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function recordPreviewPid(pid, sig) {
|
|
288
|
-
if (!pid) return;
|
|
289
|
-
// `owner` is the DAEMON that spawned it. The registry is shared by design —
|
|
290
|
-
// two daemons serving two projects both write here — so without an owner a
|
|
291
|
-
// starting daemon reaped its PEER's live tunnels: killed them, wiped their
|
|
292
|
-
// entries, and the peer kept heartbeating a URL that 530s (its probe watches
|
|
293
|
-
// the origin port, which was still alive).
|
|
294
|
-
//
|
|
295
|
-
// `ownerStart` pins the owner to ONE process (audit 2026-09-24). A daemon that
|
|
296
|
-
// is SIGKILLed with a share open leaves cloudflared up; if its pid is reused
|
|
297
|
-
// by the next start — any process, another user's included, since EPERM
|
|
298
|
-
// counts as alive — a pid-only check read the entry as a live peer's and
|
|
299
|
-
// skipped it forever, leaving a public hostname aimed at a dead gate's
|
|
300
|
-
// ephemeral port for anything that binds it next.
|
|
301
|
-
const ownerStart = processStartOf(process.pid);
|
|
302
|
-
mutateRegistry((list) => [...list, { pid, sig, owner: process.pid, ...(ownerStart ? { ownerStart } : {}) }]);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function forgetPreviewPid(pid) {
|
|
306
|
-
if (!pid) return;
|
|
307
|
-
mutateRegistry((list) => list.filter((e) => e.pid !== pid));
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Only kill a pid we can VERIFY is still one of ours — its command line must
|
|
312
|
-
* still contain the signature we stored. A reused pid belonging to something
|
|
313
|
-
* unrelated won't match, so we never kill a stranger.
|
|
314
|
-
*
|
|
315
|
-
* MACOS READS IT THROUGH `ps`, because "Linux-only, elsewhere just clear the
|
|
316
|
-
* registry" was the worst of both. A macOS daemon killed ungracefully leaves
|
|
317
|
-
* cloudflared running — it is spawned detached — so the public hostname keeps
|
|
318
|
-
* resolving to this machine while the gate dies with the daemon. The reaper
|
|
319
|
-
* then verified nothing, killed nothing, and DELETED the entry, so no later run
|
|
320
|
-
* could ever find that process. The tunnel served 502 until anything else on
|
|
321
|
-
* the box bound the gate's old port, which `startAuthProxy` obtained with
|
|
322
|
-
* `listen(0)` and is therefore squarely inside the kernel's ephemeral reuse
|
|
323
|
-
* pool — at which point a live public hostname forwarded straight to an
|
|
324
|
-
* unrelated local service with no gate, no password and no grant check. Exactly
|
|
325
|
-
* what this file's header says the reap exists to prevent.
|
|
326
|
-
*
|
|
327
|
-
* Three states, not two: `true` (ours), `false` (verified NOT ours, or gone),
|
|
328
|
-
* and `null` (we could not look — the caller keeps the record rather than
|
|
329
|
-
* dropping it).
|
|
330
|
-
*/
|
|
331
|
-
function stillOurs(pid, sig) {
|
|
332
|
-
if (typeof sig !== 'string' || sig.length === 0) return false;
|
|
333
|
-
if (platform() === 'linux') {
|
|
334
|
-
try {
|
|
335
|
-
const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
|
|
336
|
-
return cmd.includes(sig);
|
|
337
|
-
} catch {
|
|
338
|
-
return false; // process gone / unreadable
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
if (platform() === 'darwin') {
|
|
342
|
-
try {
|
|
343
|
-
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
344
|
-
encoding: 'utf8',
|
|
345
|
-
timeout: 5_000,
|
|
346
|
-
});
|
|
347
|
-
return cmd.includes(sig);
|
|
348
|
-
} catch {
|
|
349
|
-
// `ps` exits non-zero when the pid is gone — which is a real answer.
|
|
350
|
-
return false;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
// Windows: no way to check from here. UNKNOWN, never "not ours" — see the
|
|
354
|
-
// caller, which keeps the record so a later run on a platform that can look
|
|
355
|
-
// is still able to.
|
|
356
|
-
return null;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/** Reap tunnel process groups left behind by a previously-crashed daemon.
|
|
360
|
-
* Call once at daemon startup, before any work begins.
|
|
361
|
-
*
|
|
362
|
-
* ORPHANS ONLY: an entry whose owning daemon is STILL ALIVE belongs to a
|
|
363
|
-
* peer serving another project (or to the process we are replacing, whose
|
|
364
|
-
* own teardown handles it) — killing those and wiping their entries was a
|
|
365
|
-
* peer daemon's startup silently breaking every live share on the box. Only
|
|
366
|
-
* the entries this pass handled are removed; a peer's records survive. */
|
|
367
|
-
/** Is the daemon that recorded an entry still the process at that pid? With
|
|
368
|
-
* a recorded start time, only when the pid's start time still matches (a
|
|
369
|
-
* recycled pid is an orphaned entry); where this box cannot report a start
|
|
370
|
-
* time now, or the entry predates the field, signal 0 is all there is. */
|
|
371
|
-
function ownerStillRunning(owner, ownerStart) {
|
|
372
|
-
if (!processAlive(owner)) return false;
|
|
373
|
-
if (typeof ownerStart !== 'string') return true;
|
|
374
|
-
const now = processStartOf(owner);
|
|
375
|
-
return now == null ? true : now === ownerStart;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
export function reapOrphanPreviews(log) {
|
|
379
|
-
const list = readRegistry();
|
|
380
|
-
if (list.length === 0) return;
|
|
381
|
-
let killed = 0;
|
|
382
|
-
const handled = new Set();
|
|
383
|
-
for (const { pid, sig, owner, ownerStart } of list) {
|
|
384
|
-
if (Number.isInteger(owner) && owner !== process.pid && ownerStillRunning(owner, ownerStart)) continue;
|
|
385
|
-
const ours = stillOurs(pid, sig);
|
|
386
|
-
/**
|
|
387
|
-
* THE RECORD OUTLIVES A REAP THAT COULD NOT LOOK. `handled.add` ran BEFORE
|
|
388
|
-
* this check, so an entry was dropped whether or not anything was killed —
|
|
389
|
-
* and on any platform `stillOurs` could not read, that deleted the only
|
|
390
|
-
* trace of a tunnel still serving the public internet. Kept on `null`
|
|
391
|
-
* (unknown); removed on `true` (we killed it) and on `false` (the process
|
|
392
|
-
* is gone, or the pid now belongs to somebody else and the entry is stale
|
|
393
|
-
* either way).
|
|
394
|
-
*/
|
|
395
|
-
if (ours === null) continue;
|
|
396
|
-
handled.add(pid);
|
|
397
|
-
if (!ours) continue;
|
|
398
|
-
try {
|
|
399
|
-
process.kill(-pid, 'SIGKILL'); // whole group
|
|
400
|
-
killed++;
|
|
401
|
-
} catch {
|
|
402
|
-
try {
|
|
403
|
-
process.kill(pid, 'SIGKILL');
|
|
404
|
-
killed++;
|
|
405
|
-
} catch {
|
|
406
|
-
/* already gone */
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
if (handled.size) mutateRegistry((cur) => cur.filter((e) => !handled.has(e.pid)));
|
|
411
|
-
if (killed) log?.(`reaped ${killed} orphaned preview tunnel${killed === 1 ? '' : 's'} from a previous run.`);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
144
|
// ── The one thing this file does ───────────────────────────────────────────
|
|
415
145
|
|
|
416
146
|
/** How long we wait for cloudflared to hand us a hostname. */
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE FRAME IS THE APP'S AND ONLY THE APP'S — the preview gate's frame rules,
|
|
3
|
+
* as pure functions over headers. Split out of `authproxy.mjs` (SOLID SRP,
|
|
4
|
+
* 2026-09-26): which browser may frame a share, and with which cookie
|
|
5
|
+
* attribute, changes for browser-engine reasons that have nothing to do with
|
|
6
|
+
* how the gate supervises sockets, so it gets one home the HTTP path imports
|
|
7
|
+
* and a test that reads headers in and headers out with no server running.
|
|
8
|
+
*
|
|
9
|
+
* Two rules live here (header rules 4a and 4b in `authproxy.mjs`):
|
|
10
|
+
*
|
|
11
|
+
* (a) THE COOKIE. `SameSite=Lax` is kept for a top-level visit — it is the
|
|
12
|
+
* CSRF boundary and works in every browser ever shipped. A FRAMED
|
|
13
|
+
* navigation (`Sec-Fetch-Dest: iframe`) instead gets `SameSite=None;
|
|
14
|
+
* Partitioned` (CHIPS): partitioning keys the cookie to the top-level
|
|
15
|
+
* site that framed it, so evil.com framing this hostname gets its OWN
|
|
16
|
+
* empty jar, never the viewer's grant — which is why None here is not
|
|
17
|
+
* the CSRF hole it would be unpartitioned. Branching on Sec-Fetch-Dest
|
|
18
|
+
* rather than always sending both attributes matters: Safari 18.5–26.1
|
|
19
|
+
* DROPPED any Set-Cookie carrying `Partitioned` outright, so stamping it
|
|
20
|
+
* unconditionally would break the working top-level path on those
|
|
21
|
+
* builds. A browser too old to send Sec-Fetch-Dest gets Lax and the
|
|
22
|
+
* frame fails exactly as it always did — Open remains the escape. The
|
|
23
|
+
* backstop for the browsers that ignore `Partitioned` is
|
|
24
|
+
* `crossSiteAbuse` in `previewGatePolicy.mjs`.
|
|
25
|
+
* (b) THE FRAME POLICY. In grant mode the forwarded response's frame policy
|
|
26
|
+
* is REWRITTEN to `frame-ancestors 'self' <app origin>` (origin taken
|
|
27
|
+
* from `authorizeUrl`, the one app fact this gate already holds). That
|
|
28
|
+
* permits exactly ONE cross-origin framer — the app the viewer is
|
|
29
|
+
* already authenticated to — instead of switching clickjacking
|
|
30
|
+
* protection off for everyone; enforced `frame-ancestors` outranks
|
|
31
|
+
* `X-Frame-Options` in every current engine, and the stripped XFO is
|
|
32
|
+
* the belt-and-braces for the rest. Password-only mode rewrites
|
|
33
|
+
* NOTHING: there is no app origin to allow and no cookie that could
|
|
34
|
+
* authenticate a frame, so the origin's own policy stands verbatim.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The one app fact this gate holds: the origin allowed to frame us. Derived
|
|
38
|
+
* from `authorizeUrl` (server-built from PUBLIC_APP_URL) rather than a new
|
|
39
|
+
* wire field, so an older server changes nothing and no floor is needed.
|
|
40
|
+
* Null when the gate runs password-only (`grants` false) or the URL does not
|
|
41
|
+
* parse — and a null origin rewrites nothing. */
|
|
42
|
+
export function appOriginOf(grants, authorizeUrl) {
|
|
43
|
+
let appOrigin = null;
|
|
44
|
+
try {
|
|
45
|
+
if (grants) appOrigin = new URL(authorizeUrl).origin;
|
|
46
|
+
} catch {
|
|
47
|
+
appOrigin = null;
|
|
48
|
+
}
|
|
49
|
+
return appOrigin;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** FRAMED means Partitioned (rule (a) above). Sec-Fetch-Dest survives the
|
|
53
|
+
* whole redirect chain (it describes the navigation, not the hop), so the
|
|
54
|
+
* callback sees `iframe` exactly when the Workbench is the one asking. */
|
|
55
|
+
export function grantCookieSite(secFetchDest) {
|
|
56
|
+
const dest = String(secFetchDest || '').toLowerCase();
|
|
57
|
+
const framed = dest === 'iframe' || dest === 'frame' || dest === 'embed' || dest === 'object';
|
|
58
|
+
return framed ? 'SameSite=None; Partitioned' : 'SameSite=Lax';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Replace the origin's frame policy with ours: `frame-ancestors 'self'
|
|
63
|
+
* <app origin>`. Rules that must survive any edit:
|
|
64
|
+
* - REWRITE the directive inside an existing enforced CSP, never append a
|
|
65
|
+
* second policy beside it — multiple CSP headers intersect, so an origin
|
|
66
|
+
* `frame-ancestors 'none'` would still win over anything we added.
|
|
67
|
+
* - APPEND a policy holding only our directive when the origin stated no
|
|
68
|
+
* frame-ancestors at all — a public tunnel hostname deserves a frame
|
|
69
|
+
* policy even when localhost never needed one.
|
|
70
|
+
* - LEAVE Report-Only alone: browsers ignore frame-ancestors there, and
|
|
71
|
+
* rewriting a report channel would be editing the driver's telemetry.
|
|
72
|
+
* - DELETE X-Frame-Options: an enforced frame-ancestors makes every current
|
|
73
|
+
* engine ignore it anyway; deleting is for the stragglers.
|
|
74
|
+
*/
|
|
75
|
+
export function rewriteFramePolicy(headers, appOrigin) {
|
|
76
|
+
if (!appOrigin) return headers;
|
|
77
|
+
const framePolicy = () => `frame-ancestors 'self' ${appOrigin}`;
|
|
78
|
+
const out = { ...headers };
|
|
79
|
+
delete out['x-frame-options'];
|
|
80
|
+
let sawDirective = false;
|
|
81
|
+
// Node joins duplicate response headers with ', ', so one string can hold
|
|
82
|
+
// SEVERAL policies (comma-separated), each holding several directives
|
|
83
|
+
// (semicolon-separated). Split on BOTH levels or replacing a directive
|
|
84
|
+
// eats the tail of a neighbouring policy — CSP source lists never contain
|
|
85
|
+
// a comma, so the outer split is safe.
|
|
86
|
+
const rewriteOne = (v) =>
|
|
87
|
+
String(v)
|
|
88
|
+
.split(',')
|
|
89
|
+
.map((policy) =>
|
|
90
|
+
policy
|
|
91
|
+
.split(';')
|
|
92
|
+
.map((part) => {
|
|
93
|
+
if (/^\s*frame-ancestors(\s|$)/i.test(part)) {
|
|
94
|
+
sawDirective = true;
|
|
95
|
+
return ` ${framePolicy()}`;
|
|
96
|
+
}
|
|
97
|
+
return part;
|
|
98
|
+
})
|
|
99
|
+
.join(';')
|
|
100
|
+
)
|
|
101
|
+
.join(',');
|
|
102
|
+
const csp = out['content-security-policy'];
|
|
103
|
+
if (csp !== undefined) {
|
|
104
|
+
out['content-security-policy'] = Array.isArray(csp) ? csp.map(rewriteOne) : rewriteOne(csp);
|
|
105
|
+
}
|
|
106
|
+
if (!sawDirective) {
|
|
107
|
+
const existing = out['content-security-policy'];
|
|
108
|
+
if (existing === undefined) out['content-security-policy'] = framePolicy();
|
|
109
|
+
else out['content-security-policy'] = Array.isArray(existing) ? [...existing, framePolicy()] : [existing, framePolicy()];
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHO GETS THROUGH THE PREVIEW GATE — the credential, abuse and cross-site
|
|
3
|
+
* decisions, with no server in sight. Split out of `authproxy.mjs` (SOLID SRP,
|
|
4
|
+
* 2026-09-26): these are security rules that change on their own schedule
|
|
5
|
+
* (a threshold, a Fetch Metadata shape, a credential class), and the HTTP
|
|
6
|
+
* request path and the WebSocket upgrade path must ask them IN THE SAME ORDER.
|
|
7
|
+
* Before the split each transport spelled the order out by hand; now both ask
|
|
8
|
+
* `admit(req)` and differ only in how they write the answer onto the wire
|
|
9
|
+
* (a response vs raw bytes on an upgrade socket).
|
|
10
|
+
*
|
|
11
|
+
* `authproxy.mjs` still owns the server, the forwarding and the sockets; it
|
|
12
|
+
* builds one `createGatePolicy` per share, which holds that share's failure
|
|
13
|
+
* counts and the last expired grant seen.
|
|
14
|
+
*
|
|
15
|
+
* Two things this file gets wrong easily, both fixed here and both worth
|
|
16
|
+
* keeping fixed:
|
|
17
|
+
* - the comparison is over a secret, so it is constant-time over a digest
|
|
18
|
+
* rather than `===` over a string (`sameSecret`, whose one home is
|
|
19
|
+
* `grant.mjs`).
|
|
20
|
+
* - ONLY a wrong Basic password counts as an attempt (see `credential`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { GRANT_COOKIE, cookieValues, sameSecret, verifyGrant } from './grant.mjs';
|
|
24
|
+
|
|
25
|
+
/** Wrong-password attempts from ONE source before that source's Basic
|
|
26
|
+
* attempts are refused outright. A quick tunnel's hostname is unguessable,
|
|
27
|
+
* so this is not the primary control — but it is KNOWN to every past
|
|
28
|
+
* member, tester and password recipient, whose access cannot be recalled.
|
|
29
|
+
* The threshold is per source because one global counter made 25 wrong
|
|
30
|
+
* guesses from any URL holder a kill switch on everyone else's share. */
|
|
31
|
+
export const MAX_FAILED = 25;
|
|
32
|
+
|
|
33
|
+
/** Counted failures across ALL sources before the whole share tears down.
|
|
34
|
+
* The per-source block above answers a single abuser; this answers a
|
|
35
|
+
* DISTRIBUTED guessing run (addresses rotating to stay under MAX_FAILED
|
|
36
|
+
* each) — the original self-closing-incident property, kept at a threshold
|
|
37
|
+
* ordinary use cannot reach: a blocked source stops counting, so getting
|
|
38
|
+
* here takes eight independent sources each burning their full allowance. */
|
|
39
|
+
export const MAX_FAILED_TOTAL = 200;
|
|
40
|
+
|
|
41
|
+
/** Bound on the per-source map — a rotating attacker must not grow daemon
|
|
42
|
+
* memory without limit. Eviction is oldest-first and CAN un-block an evicted
|
|
43
|
+
* source, but cycling 500 fresh sources costs at least 500 counted failures,
|
|
44
|
+
* and the global backstop closes the share long before that. */
|
|
45
|
+
export const MAX_SOURCES = 500;
|
|
46
|
+
|
|
47
|
+
/** An Authorization header in the Basic scheme — the only shape the gate's own
|
|
48
|
+
* password ever arrives in, and so the only one it grades. */
|
|
49
|
+
export const isBasic = (v) => typeof v === 'string' && /^\s*basic(\s|$)/i.test(v);
|
|
50
|
+
|
|
51
|
+
/** cloudflared forwards the real client address in Cf-Connecting-Ip; a
|
|
52
|
+
* direct local connection (the machine's own curl, the tests) has only the
|
|
53
|
+
* socket. The header is attacker-writable in principle, but lying in it
|
|
54
|
+
* only SPREADS one attacker across per-source counters — which is exactly
|
|
55
|
+
* the shape MAX_FAILED_TOTAL exists to answer. */
|
|
56
|
+
export const sourceOf = (req) =>
|
|
57
|
+
String(req.headers['cf-connecting-ip'] || req.socket?.remoteAddress || 'unknown');
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The CSRF backstop for the partitioned cookie (header rule 4a): a browser
|
|
61
|
+
* old enough to ignore `Partitioned` while honouring `SameSite=None` will
|
|
62
|
+
* attach the grant to cross-site requests, so a request that FETCH METADATA
|
|
63
|
+
* says is cross-site is refused unless it is a plain navigation GET/HEAD —
|
|
64
|
+
* the two shapes this product legitimately serves cross-site (the Open
|
|
65
|
+
* link, the Workbench frame's own src; a rendered attacker frame is then
|
|
66
|
+
* stopped by the frame-ancestors rewrite). Applies ONLY to grant-cookie
|
|
67
|
+
* auth: a password in an Authorization header is never attached cross-site
|
|
68
|
+
* by a browser, and clients that send no Sec-Fetch headers never held a
|
|
69
|
+
* None cookie, so absence stays permitted.
|
|
70
|
+
*
|
|
71
|
+
* Why a backstop at all: Chromium 76–113 (and same-vintage webviews/forks)
|
|
72
|
+
* sends Sec-Fetch-Dest — so it takes the framed-cookie branch — while
|
|
73
|
+
* ignoring the `Partitioned` attribute it has never heard of, storing a PLAIN
|
|
74
|
+
* unpartitioned SameSite=None cookie. Every browser in that class sends
|
|
75
|
+
* Sec-Fetch-Site (it shipped alongside Dest), and a browser sending neither
|
|
76
|
+
* never got a None cookie in the first place.
|
|
77
|
+
*/
|
|
78
|
+
export const crossSiteAbuse = (req) => {
|
|
79
|
+
if (String(req.headers['sec-fetch-site'] || '').toLowerCase() !== 'cross-site') return false;
|
|
80
|
+
const mode = String(req.headers['sec-fetch-mode'] || '').toLowerCase();
|
|
81
|
+
// Cross-site fetch/XHR/img/script with the cookie — nothing legitimate
|
|
82
|
+
// looks like this: the framed app's own subresources are same-origin.
|
|
83
|
+
if (mode && mode !== 'navigate') return true;
|
|
84
|
+
// A cross-site POST navigation is the classic auto-submitted CSRF form.
|
|
85
|
+
return !(req.method === 'GET' || req.method === 'HEAD');
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** A top-level browser navigation, and nothing else. A 302 is re-issued as
|
|
89
|
+
* GET and drops the body, so bouncing a POST would turn an unauthenticated
|
|
90
|
+
* write into a mystery GET; and this is what keeps curl and native clients
|
|
91
|
+
* on the password path. */
|
|
92
|
+
export const isBrowserNav = (req) =>
|
|
93
|
+
(req.method === 'GET' || req.method === 'HEAD') &&
|
|
94
|
+
/text\/html/.test(req.headers.accept || '');
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One share's gate policy. `grants` says whether the grant door is installed
|
|
98
|
+
* (all three of secret, share id and authorize URL arrived); `expected` is the
|
|
99
|
+
* full `Basic …` header the automation password produces.
|
|
100
|
+
*
|
|
101
|
+
* `onAbuse` fires once, after MAX_FAILED_TOTAL rejected attempts across all
|
|
102
|
+
* sources, so the caller can tear the whole share down rather than leaving a
|
|
103
|
+
* URL under attack. A single source is blocked on its own, at MAX_FAILED,
|
|
104
|
+
* without ending anybody else's share.
|
|
105
|
+
*/
|
|
106
|
+
export function createGatePolicy({ grants, grantSecret, shareId, expected, log, onAbuse }) {
|
|
107
|
+
let failedTotal = 0;
|
|
108
|
+
let abused = false;
|
|
109
|
+
/** source → wrong-password count, insertion-ordered so eviction below is
|
|
110
|
+
* oldest-first. A source at MAX_FAILED is BLOCKED: its Basic attempts are
|
|
111
|
+
* refused BEFORE the comparison and stop counting toward the total. */
|
|
112
|
+
const failedBySource = new Map();
|
|
113
|
+
/** The payload of the last validly-signed but EXPIRED grant seen, so a
|
|
114
|
+
* tester can be re-bounced with the token inside it. */
|
|
115
|
+
let lastExpired = null;
|
|
116
|
+
|
|
117
|
+
const sourceBlocked = (req) => (failedBySource.get(sourceOf(req)) ?? 0) >= MAX_FAILED;
|
|
118
|
+
|
|
119
|
+
const noteFailure = (req) => {
|
|
120
|
+
const src = sourceOf(req);
|
|
121
|
+
const count = (failedBySource.get(src) ?? 0) + 1;
|
|
122
|
+
// Delete-then-set so Map insertion order tracks recency, making the
|
|
123
|
+
// eviction below an LRU rather than "whoever failed first".
|
|
124
|
+
failedBySource.delete(src);
|
|
125
|
+
failedBySource.set(src, count);
|
|
126
|
+
if (failedBySource.size > MAX_SOURCES) {
|
|
127
|
+
failedBySource.delete(failedBySource.keys().next().value);
|
|
128
|
+
}
|
|
129
|
+
if (count === MAX_FAILED) {
|
|
130
|
+
log?.(`preview gate: ${count} failed attempts from ${src} — refusing that source.`);
|
|
131
|
+
}
|
|
132
|
+
// MONOTONE, never reset by a success: a distributed run has no successes
|
|
133
|
+
// to hide behind, and a legitimate share cannot reach the backstop —
|
|
134
|
+
// every source stops counting at MAX_FAILED, so 200 needs eight distinct
|
|
135
|
+
// sources each exhausting their own allowance.
|
|
136
|
+
failedTotal += 1;
|
|
137
|
+
if (failedTotal >= MAX_FAILED_TOTAL && !abused) {
|
|
138
|
+
abused = true;
|
|
139
|
+
log?.(`preview gate: ${failedTotal} failed attempts across sources — closing the share.`);
|
|
140
|
+
try {
|
|
141
|
+
onAbuse?.();
|
|
142
|
+
} catch {
|
|
143
|
+
/* the caller's teardown is best-effort */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A PURE CREDENTIAL PREDICATE — no method, no Accept, no path. That is what
|
|
150
|
+
* lets the websocket upgrade handler reuse it verbatim, and it is why routing
|
|
151
|
+
* decisions live in the request handler instead.
|
|
152
|
+
*
|
|
153
|
+
* Tristate-plus: 'ok' | 'none' | 'expired' | 'forged' | 'badpass' | 'blocked'.
|
|
154
|
+
*/
|
|
155
|
+
const credential = (req) => {
|
|
156
|
+
if (abused) return 'badpass';
|
|
157
|
+
// ONLY A WRONG PASSWORD COUNTS AS AN ATTEMPT, and this is a reason rather
|
|
158
|
+
// than a preference. A forged HMAC is not brute-forceable, so counting it
|
|
159
|
+
// buys nothing — while counting it would hand any stranger who finds the
|
|
160
|
+
// hostname a kill switch on the owner's share, because onAbuse tears the
|
|
161
|
+
// whole thing down. An expired-but-validly-signed grant must never count
|
|
162
|
+
// either, or a viewer who left a tab open overnight closes the share on
|
|
163
|
+
// their own reload.
|
|
164
|
+
//
|
|
165
|
+
// AND ONLY A *BASIC* HEADER IS A PASSWORD ATTEMPT (audit 2026-09-24). Any
|
|
166
|
+
// Authorization header used to be graded against the gate password, so a
|
|
167
|
+
// previewed SPA calling its own `/api` with `Authorization: Bearer …` was
|
|
168
|
+
// answered 401 with a Basic challenge — over a valid grant cookie — and 25
|
|
169
|
+
// such calls blocked that viewer, eight viewers tore the share down, and
|
|
170
|
+
// the origin never saw its own header. A Bearer (or any other scheme) is
|
|
171
|
+
// the APP's credential: it is not ours to grade and it rides through.
|
|
172
|
+
//
|
|
173
|
+
// A VALID GRANT COOKIE IS ASKED FIRST, for the same reason: an app that
|
|
174
|
+
// itself speaks Basic sends a header that is not our password, and a
|
|
175
|
+
// viewer the app has vouched for must not be graded on it. A grant is
|
|
176
|
+
// HMAC-verified, so asking it first opens no brute-force path.
|
|
177
|
+
let grantVerdict = 'none';
|
|
178
|
+
if (grants) {
|
|
179
|
+
// EVERY value for our name, not the first — a duplicate must not shadow.
|
|
180
|
+
for (const raw of cookieValues(req.headers.cookie, GRANT_COOKIE)) {
|
|
181
|
+
const r = verifyGrant(raw, { secret: grantSecret, shareId });
|
|
182
|
+
if (r.ok) return 'ok';
|
|
183
|
+
if (r.reason === 'exp' && grantVerdict === 'none') {
|
|
184
|
+
lastExpired = r.payload;
|
|
185
|
+
grantVerdict = 'expired';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (grantVerdict === 'none' && String(req.headers.cookie ?? '').includes(GRANT_COOKIE)) {
|
|
189
|
+
grantVerdict = 'forged';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (isBasic(req.headers['authorization'])) {
|
|
194
|
+
// A blocked source is refused BEFORE the comparison — a block that
|
|
195
|
+
// still grades guesses would let the brute force run to a correct hit.
|
|
196
|
+
if (sourceBlocked(req)) return 'blocked';
|
|
197
|
+
if (sameSecret(req.headers['authorization'], expected)) {
|
|
198
|
+
// Per-source only: a shared NAT recovers when one person behind it
|
|
199
|
+
// gets the password right; failedTotal stays monotone (see above).
|
|
200
|
+
failedBySource.delete(sourceOf(req));
|
|
201
|
+
return 'ok';
|
|
202
|
+
}
|
|
203
|
+
noteFailure(req);
|
|
204
|
+
return 'badpass';
|
|
205
|
+
}
|
|
206
|
+
return grantVerdict;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** True when THIS request authenticated with the password rather than the
|
|
210
|
+
* cookie — the credential class CSRF cannot ride. */
|
|
211
|
+
const viaPassword = (req) => sameSecret(req.headers['authorization'], expected);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* THE ONE ADMISSION ORDER both transports ask: the credential first, then —
|
|
215
|
+
* only for a caller who HAS one — the cross-site backstop. The grant is a
|
|
216
|
+
* cookie, and a cookie can be ridden (header rule 4a's backstop); a
|
|
217
|
+
* refusal there is never a bounce, because the caller has a credential and
|
|
218
|
+
* the request SHAPE is what is refused.
|
|
219
|
+
*
|
|
220
|
+
* Returns the credential verdict ('blocked', 'none', 'expired', 'forged',
|
|
221
|
+
* 'badpass'), 'cross-site', or 'ok'.
|
|
222
|
+
*/
|
|
223
|
+
const admit = (req) => {
|
|
224
|
+
const verdict = credential(req);
|
|
225
|
+
if (verdict !== 'ok') return verdict;
|
|
226
|
+
if (grants && crossSiteAbuse(req) && !viaPassword(req)) return 'cross-site';
|
|
227
|
+
return 'ok';
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
admit,
|
|
232
|
+
viaPassword,
|
|
233
|
+
/** The payload of the last validly-signed but expired grant seen. */
|
|
234
|
+
lastExpired: () => lastExpired,
|
|
235
|
+
};
|
|
236
|
+
}
|