run402 4.47.0 → 4.49.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/git-remote-run402.mjs +84 -13
- package/gitvault-surface.json +1 -1
- package/lib/credentials.mjs +44 -5
- package/lib/repos.mjs +8 -0
- package/package.json +1 -1
- package/sdk/dist/namespaces/deploy.js +64 -7
- package/sdk/dist/namespaces/deploy.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +18 -6
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +23 -6
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/node/gitvault-address.d.ts +18 -5
- package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-address.js +57 -9
- package/sdk/dist/node/gitvault-address.js.map +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts +42 -0
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-keystore.js +130 -0
- package/sdk/dist/node/gitvault-keystore.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +130 -8
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +418 -39
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
package/git-remote-run402.mjs
CHANGED
|
@@ -104,7 +104,7 @@ import { pathToFileURL } from "node:url";
|
|
|
104
104
|
import { getSdk } from "./lib/sdk.mjs";
|
|
105
105
|
import { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } from "./lib/wallet-context.mjs";
|
|
106
106
|
import { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } from "#sdk";
|
|
107
|
-
import { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo } from "#sdk/node";
|
|
107
|
+
import { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo, readPinnedGitvaultRepo, pinGitvaultRepo } from "#sdk/node";
|
|
108
108
|
|
|
109
109
|
const out = (line) => process.stdout.write(`${line}\n`);
|
|
110
110
|
/** Every helper response block is terminated by a blank line. */
|
|
@@ -350,6 +350,26 @@ async function main(argv) {
|
|
|
350
350
|
return resolvedRepo.repo_dir;
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
/**
|
|
354
|
+
* One materialize per push session (gitvault-client-round-trips design
|
|
355
|
+
* D1). Git guarantees `list` precedes `push` in the same helper process,
|
|
356
|
+
* so a `list` that resolves an EXISTING vault against a real repository
|
|
357
|
+
* stashes its vault instance + materialized base here; `runPush` reuses
|
|
358
|
+
* BOTH — skipping its own `openOrCreateVault` + `materialize()` entirely
|
|
359
|
+
* — instead of materializing the same state a second and third time.
|
|
360
|
+
* `null` whenever there is nothing safe to share: `list` never ran, ran
|
|
361
|
+
* repo-free (a bare `git ls-remote`), or found an UNALLOCATED vault
|
|
362
|
+
* (first-push-allocates keeps its own unchanged flow — design D7's own
|
|
363
|
+
* "base-sharing subtlety" risk note). Reuse also requires the SAME
|
|
364
|
+
* resolved repository AND the same resolved wallet as `push` is about to
|
|
365
|
+
* use — in ordinary usage (`push` run from inside the repo it targets)
|
|
366
|
+
* these always match `list`'s own resolution; the check just makes a
|
|
367
|
+
* mismatch (an unusual `git -C otherdir push`) fail safe into `push`'s
|
|
368
|
+
* original, unshared flow rather than reuse a snapshot read under a
|
|
369
|
+
* different identity.
|
|
370
|
+
*/
|
|
371
|
+
let sharedListSession = null;
|
|
372
|
+
|
|
353
373
|
/** This repository's own HEAD branch (`refs/heads/<name>`), or `null` when detached, unborn, or unreadable — never a failure by itself. */
|
|
354
374
|
async function localHeadBranchRef(repoDir) {
|
|
355
375
|
try {
|
|
@@ -377,15 +397,17 @@ async function main(argv) {
|
|
|
377
397
|
|
|
378
398
|
/**
|
|
379
399
|
* Open the vault lazily — `capabilities` and `option` must never touch the
|
|
380
|
-
* network.
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
* `
|
|
385
|
-
*
|
|
400
|
+
* network. Both address forms resolve (and, on the first successful
|
|
401
|
+
* resolution, PIN `repo_id` in local git state — task 4.5 for slug-form,
|
|
402
|
+
* gitvault-client-round-trips design D4 widening the same mechanism to
|
|
403
|
+
* id-form) through `gitvault.resolveOrCreateAddress` with
|
|
404
|
+
* `allow_create: false` — a read never allocates, and `allow_create` is
|
|
405
|
+
* meaningless for id-form's own dispatch anyway (it never creates,
|
|
406
|
+
* pinned or not). A repo-free call (`repoDir` undefined — `list` outside
|
|
407
|
+
* any checkout) resolves exactly as it always has, just with nothing to
|
|
408
|
+
* pin.
|
|
386
409
|
*/
|
|
387
410
|
const openVault = async (repoDir) => {
|
|
388
|
-
if (addressForm === "id") return (await getSdk().gitvault.open(repoDir ? { ...target, repo_dir: repoDir } : target)).vault;
|
|
389
411
|
const result = await getSdk().gitvault.resolveOrCreateAddress({ address, allow_create: false, ...(repoDir ? { repo_dir: repoDir } : {}) });
|
|
390
412
|
return result.handle.vault;
|
|
391
413
|
};
|
|
@@ -397,15 +419,36 @@ async function main(argv) {
|
|
|
397
419
|
* `list`/`fetch` stay pure reads and never create anything (see
|
|
398
420
|
* `runList`'s own not-found handling below).
|
|
399
421
|
*
|
|
422
|
+
* Id-form (gitvault-client-round-trips design D4): a PINNED repo_id means
|
|
423
|
+
* this checkout has already resolved (or pushed to) this vault before, so
|
|
424
|
+
* there is nothing left to allocate — the read-only, pin-aware
|
|
425
|
+
* `resolveOrCreateAddress` path (same one `openVault` uses) is enough,
|
|
426
|
+
* and cheaper than re-running the allocation-capable flow on every push.
|
|
427
|
+
* With NO pin yet, this is unchanged: `gitvault.openOrCreate` runs its
|
|
428
|
+
* six-stage creation journal when the vault does not exist, exactly as
|
|
429
|
+
* before — and, on success, PINS the resolved id for every later push on
|
|
430
|
+
* this checkout (this is the "first successful resolution" the pin exists
|
|
431
|
+
* for; `resolveOrCreateAddress`'s own pin-on-resolve only covers
|
|
432
|
+
* slug-form, since id-form's OWN allocation path — this one — never
|
|
433
|
+
* routes through it).
|
|
434
|
+
*
|
|
400
435
|
* Prints the one-shot recovery receipt and the keystore path to stderr the
|
|
401
436
|
* moment allocation happens, per the client-surface spec: an agent reads
|
|
402
437
|
* stderr, and the receipt is worth exactly as many copies as get kept.
|
|
403
438
|
*/
|
|
404
439
|
async function openOrCreateVault(repoDir) {
|
|
440
|
+
if (addressForm === "id" && repoDir) {
|
|
441
|
+
const pinned = await readPinnedGitvaultRepo(repoDir);
|
|
442
|
+
if (pinned) {
|
|
443
|
+
const result = await getSdk().gitvault.resolveOrCreateAddress({ address, repo_dir: repoDir, allow_create: false });
|
|
444
|
+
return result.handle.vault;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
405
447
|
const result =
|
|
406
448
|
addressForm === "id"
|
|
407
449
|
? await getSdk().gitvault.openOrCreate({ ...target, repo_dir: repoDir })
|
|
408
450
|
: await getSdk().gitvault.resolveOrCreateAddress({ address, repo_dir: repoDir, allow_create: true });
|
|
451
|
+
if (addressForm === "id" && repoDir) await pinGitvaultRepo(repoDir, result.handle.repo_id);
|
|
409
452
|
if (!result.found && result.created) {
|
|
410
453
|
note("");
|
|
411
454
|
note(`vault ${result.handle.repo_id} allocated (genesis ${result.created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
|
|
@@ -424,11 +467,26 @@ async function main(argv) {
|
|
|
424
467
|
async function runList() {
|
|
425
468
|
// `list` needs no repository (a repository-free `git ls-remote` outside
|
|
426
469
|
// any checkout must keep working) — the binding walk falls back to cwd,
|
|
427
|
-
// same as `capabilities`/`option`'s repository-free tier.
|
|
470
|
+
// same as `capabilities`/`option`'s repository-free tier. Wallet
|
|
471
|
+
// resolution is UNCHANGED (still cwd-based, never repoDir-based) — only
|
|
472
|
+
// the vault-open call below additionally threads a repository when one
|
|
473
|
+
// resolves, purely so a later `push` in this same session can reuse the
|
|
474
|
+
// resulting vault instance (design D1); the repo-free ls-remote case is
|
|
475
|
+
// unaffected (`repoDir` just stays `null`).
|
|
428
476
|
applyWalletForDir(process.cwd());
|
|
477
|
+
let repoDir = null;
|
|
478
|
+
try {
|
|
479
|
+
repoDir = await requireRepo();
|
|
480
|
+
} catch {
|
|
481
|
+
// Not resolvable as a repository (e.g. `git ls-remote` outside any
|
|
482
|
+
// checkout) — `list` still works, and there is nothing for `push` to
|
|
483
|
+
// share later in that case.
|
|
484
|
+
}
|
|
485
|
+
let vault;
|
|
429
486
|
let state;
|
|
430
487
|
try {
|
|
431
|
-
|
|
488
|
+
vault = await openVault(repoDir ?? undefined);
|
|
489
|
+
state = await vault.materialize();
|
|
432
490
|
} catch (err) {
|
|
433
491
|
// An unallocated vault is not an error here: `list` is the read half of
|
|
434
492
|
// the protocol dance and must never create anything on its own (D2
|
|
@@ -436,13 +494,15 @@ async function main(argv) {
|
|
|
436
494
|
// exactly what a fresh repository looks like to git, and `push` still
|
|
437
495
|
// runs `list` first either way — this is what lets a first push land in
|
|
438
496
|
// one command instead of `list` failing the whole exchange before
|
|
439
|
-
// `push` ever gets a turn.
|
|
497
|
+
// `push` ever gets a turn. Nothing to share with `push` either
|
|
498
|
+
// (design D7): an unallocated vault has no base to reuse.
|
|
440
499
|
if (isVaultNotFound(err)) {
|
|
441
500
|
endBlock();
|
|
442
501
|
return;
|
|
443
502
|
}
|
|
444
503
|
throw err;
|
|
445
504
|
}
|
|
505
|
+
if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, base: state };
|
|
446
506
|
const refs = state.refs ?? {};
|
|
447
507
|
for (const ref of Object.keys(refs).sort()) out(`${refs[ref]} ${ref}`);
|
|
448
508
|
// A snapshot-only vault holds protocol refs but no branch heads, so a
|
|
@@ -569,8 +629,18 @@ async function main(argv) {
|
|
|
569
629
|
return 0;
|
|
570
630
|
}
|
|
571
631
|
|
|
572
|
-
|
|
573
|
-
|
|
632
|
+
// Design D1: reuse `list`'s vault + materialized base for this push's
|
|
633
|
+
// FIRST admission attempt when it resolved the SAME repository under
|
|
634
|
+
// the SAME wallet — skips `openOrCreateVault` and `materialize()`
|
|
635
|
+
// entirely instead of resolving/materializing the vault a second and
|
|
636
|
+
// third time in one `list → push` exchange. A conflict retry inside
|
|
637
|
+
// `vault.push` re-materializes from storage exactly as it always has;
|
|
638
|
+
// only the FIRST attempt's base changes here. Any mismatch (no prior
|
|
639
|
+
// `list`, an unallocated vault `list` had nothing to share for, or a
|
|
640
|
+
// different repository/wallet) falls back to the original flow.
|
|
641
|
+
const shared = sharedListSession && sharedListSession.repoDir === repoDir && sharedListSession.walletName === (resolvedWallet?.name ?? null) ? sharedListSession : null;
|
|
642
|
+
const vault = shared ? shared.vault : await openOrCreateVault(repoDir);
|
|
643
|
+
const base = shared ? shared.base : await vault.materialize();
|
|
574
644
|
const updates = [];
|
|
575
645
|
for (const spec of allowed) {
|
|
576
646
|
const expectedOld = base.refs?.[spec.dst] ?? null;
|
|
@@ -601,6 +671,7 @@ async function main(argv) {
|
|
|
601
671
|
// forward unchanged — a healthy HEAD is never moved.
|
|
602
672
|
const published = await vault.push({
|
|
603
673
|
transaction: { updates },
|
|
674
|
+
base,
|
|
604
675
|
...(headFix.head_target ? { head_target: headFix.head_target } : {}),
|
|
605
676
|
});
|
|
606
677
|
if (verbosity >= 1) note(`published generation ${published.generation} (${published.form})`);
|
package/gitvault-surface.json
CHANGED
package/lib/credentials.mjs
CHANGED
|
@@ -26,8 +26,10 @@ Usage:
|
|
|
26
26
|
run402 credentials <subcommand> [args...]
|
|
27
27
|
|
|
28
28
|
Project credentials (on the gateway — named, revocable, rotatable):
|
|
29
|
-
issue --kind <anon|service> --name <name> [--project <id>] [--expires <iso8601>]
|
|
29
|
+
issue --kind <anon|service> --name <name> [--project <id>] [--expires <iso8601>] [--import]
|
|
30
30
|
Mint one. The secret is printed ONCE.
|
|
31
|
+
--import also writes it into this machine's
|
|
32
|
+
local key cache (the cold-restart re-key path).
|
|
31
33
|
list [--project <id>] [--include-revoked]
|
|
32
34
|
List credentials (metadata only, never secrets)
|
|
33
35
|
status [--project <id>] Are you still on the retiring legacy key?
|
|
@@ -87,7 +89,7 @@ const SUB_HELP = {
|
|
|
87
89
|
issue: `run402 credentials issue — mint a named project credential
|
|
88
90
|
|
|
89
91
|
Usage:
|
|
90
|
-
run402 credentials issue --kind <anon|service> --name <name> [--project <id>]
|
|
92
|
+
run402 credentials issue --kind <anon|service> --name <name> [--project <id>] [--import]
|
|
91
93
|
[--expires <iso8601>]
|
|
92
94
|
|
|
93
95
|
--kind "anon" is the tenant-facing key; "service" is the privileged one.
|
|
@@ -95,6 +97,11 @@ Usage:
|
|
|
95
97
|
returns 409 CREDENTIAL_NAME_TAKEN — that collision is the idempotency
|
|
96
98
|
story, so a retried create never mints a second credential by accident.
|
|
97
99
|
--expires Optional; must be in the future and within one year.
|
|
100
|
+
--import Also write the minted secret into this machine's local key cache
|
|
101
|
+
(what deploys and data-plane commands read) — the cold-restart
|
|
102
|
+
re-key path in ONE step instead of issue-then-project-keys-import.
|
|
103
|
+
A first --kind anon --import on a machine with no cached entry
|
|
104
|
+
still needs a service key first, same as project-keys import.
|
|
98
105
|
|
|
99
106
|
The secret is printed ONCE, on stdout, inside the JSON. Pipe it:
|
|
100
107
|
run402 credentials issue --kind service --name ci | jq -r .secret
|
|
@@ -407,7 +414,8 @@ const ISSUE_VALUE_FLAGS = ["--project", "--kind", "--name", "--expires"];
|
|
|
407
414
|
|
|
408
415
|
async function issue(args) {
|
|
409
416
|
const a = normalizeArgv(args);
|
|
410
|
-
assertKnownFlags(a, [...ISSUE_VALUE_FLAGS, "--help", "-h"], ISSUE_VALUE_FLAGS);
|
|
417
|
+
assertKnownFlags(a, [...ISSUE_VALUE_FLAGS, "--import", "--help", "-h"], ISSUE_VALUE_FLAGS);
|
|
418
|
+
const importToCache = a.includes("--import");
|
|
411
419
|
const kind = flagValue(a, "--kind");
|
|
412
420
|
const name = flagValue(a, "--name");
|
|
413
421
|
const expiresAt = flagValue(a, "--expires");
|
|
@@ -415,7 +423,7 @@ async function issue(args) {
|
|
|
415
423
|
requirePositionalCount(rest, ISSUE_VALUE_FLAGS, {
|
|
416
424
|
min: 0,
|
|
417
425
|
max: 0,
|
|
418
|
-
command: "run402 credentials issue --kind <anon|service> --name <name> [--project <id>]",
|
|
426
|
+
command: "run402 credentials issue --kind <anon|service> --name <name> [--project <id>] [--import]",
|
|
419
427
|
missing: "",
|
|
420
428
|
});
|
|
421
429
|
if (kind !== "anon" && kind !== "service") {
|
|
@@ -432,8 +440,39 @@ async function issue(args) {
|
|
|
432
440
|
hint: "The name identifies this credential in 'list' and is how you rotate it later, e.g. --name ci-deploy",
|
|
433
441
|
});
|
|
434
442
|
}
|
|
443
|
+
// Validate the --import precondition BEFORE minting: refusing after the
|
|
444
|
+
// mint would burn a show-once secret on a usage error.
|
|
445
|
+
const existing = importToCache ? getProject(projectId) : undefined;
|
|
446
|
+
if (importToCache && kind === "anon" && !existing?.service_key) {
|
|
447
|
+
fail({
|
|
448
|
+
code: "BAD_USAGE",
|
|
449
|
+
message: `--import for an anon key writes the whole cache entry, and no service key is cached for ${projectId} yet.`,
|
|
450
|
+
hint: "Run 'run402 credentials issue --kind service --name <name> --import' first (same rule as project-keys import).",
|
|
451
|
+
details: { project_id: projectId },
|
|
452
|
+
});
|
|
453
|
+
}
|
|
435
454
|
try {
|
|
436
|
-
|
|
455
|
+
const res = await getSdk().credentials.issue(projectId, { kind, name, expiresAt: expiresAt || undefined });
|
|
456
|
+
if (importToCache && res?.secret) {
|
|
457
|
+
// The cold-restart re-key path (gitvault-deploy-lane 6.5a): the minted
|
|
458
|
+
// secret goes straight into the local cache the deploy and data-plane
|
|
459
|
+
// commands read, so a fresh machine re-keys in one command per kind
|
|
460
|
+
// instead of the four-command issue-then-project-keys-import dance.
|
|
461
|
+
// The secret never rides argv; it came back on the mint response.
|
|
462
|
+
saveProject(projectId, {
|
|
463
|
+
anon_key: kind === "anon" ? res.secret : existing?.anon_key ?? "",
|
|
464
|
+
service_key: kind === "service" ? res.secret : existing?.service_key ?? "",
|
|
465
|
+
site_url: existing?.site_url,
|
|
466
|
+
deployed_at: existing?.deployed_at,
|
|
467
|
+
last_deployment_id: existing?.last_deployment_id,
|
|
468
|
+
org_id: existing?.org_id,
|
|
469
|
+
source: "credentials_issue_import",
|
|
470
|
+
cached_at: new Date().toISOString(),
|
|
471
|
+
});
|
|
472
|
+
emitIssued({ ...res, imported_to_local_cache: true });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
emitIssued(res);
|
|
437
476
|
} catch (err) {
|
|
438
477
|
reportSdkError(err);
|
|
439
478
|
}
|
package/lib/repos.mjs
CHANGED
|
@@ -1558,6 +1558,14 @@ async function gc(args) {
|
|
|
1558
1558
|
fail({ code: "BAD_USAGE", message: "--intent-core / --verifier-receipt only apply with --submit.", hint: "Add --submit, or drop the flags to plan." });
|
|
1559
1559
|
}
|
|
1560
1560
|
const target = await vaultTarget(a);
|
|
1561
|
+
// gitvault-client-round-trips design D3 (task 4.2): re-apply the local
|
|
1562
|
+
// object cache's eviction window as a periodic backstop. Best-effort —
|
|
1563
|
+
// a sweep failure must never block the actual gc plan/submit.
|
|
1564
|
+
try {
|
|
1565
|
+
await sdk.gitvault.sweepObjectCache(target);
|
|
1566
|
+
} catch {
|
|
1567
|
+
// never let cache housekeeping fail a real gc operation
|
|
1568
|
+
}
|
|
1561
1569
|
|
|
1562
1570
|
try {
|
|
1563
1571
|
if (submitting) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "run402",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.49.0",
|
|
4
4
|
"description": "CLI for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -3873,10 +3873,21 @@ async function uploadInlineCas(client, projectId, bytes, contentType) {
|
|
|
3873
3873
|
* `/apply/v1/plans*` use SIWX, which the kernel's getAuth provides
|
|
3874
3874
|
* automatically — only the apikey-gated paths need this helper.
|
|
3875
3875
|
*
|
|
3876
|
-
*
|
|
3877
|
-
*
|
|
3878
|
-
*
|
|
3879
|
-
*
|
|
3876
|
+
* COLD-RESTART RECOVERY (gitvault-deploy-lane 6.5a, the #624 class one layer
|
|
3877
|
+
* in): when the local credential cache has no entry for the project — the
|
|
3878
|
+
* returning-agent case, where the wallet survives but the once-issued project
|
|
3879
|
+
* keys did not — this helper mints a SHORT-LIVED anon token via
|
|
3880
|
+
* `POST /projects/v1/:project_id/tokens` (the route the platform built as
|
|
3881
|
+
* exactly this recovery path; the gateway's own 401 envelope names it as the
|
|
3882
|
+
* `mint_token` next_action) and uses it as the apikey, memoized in-process
|
|
3883
|
+
* until shortly before it expires. A wallet-holding agent on a fresh machine
|
|
3884
|
+
* deploys with zero extra commands; nothing durable is written anywhere.
|
|
3885
|
+
*
|
|
3886
|
+
* The mint is attempted only on a cache miss and never retried within a
|
|
3887
|
+
* process on failure; a client that cannot mint (no signer, no authority)
|
|
3888
|
+
* falls through to the pre-existing behavior — the request goes out without
|
|
3889
|
+
* an apikey and the gateway rejects with 401, whose envelope names the
|
|
3890
|
+
* recovery.
|
|
3880
3891
|
*/
|
|
3881
3892
|
async function apikeyHeaders(client, projectId) {
|
|
3882
3893
|
// A CI session and a delegate both already authorize these routes via
|
|
@@ -3886,9 +3897,55 @@ async function apikeyHeaders(client, projectId) {
|
|
|
3886
3897
|
if (isCiClient(client) || isDelegateClient(client))
|
|
3887
3898
|
return {};
|
|
3888
3899
|
const project = await client.getProject(projectId);
|
|
3889
|
-
if (
|
|
3890
|
-
return {};
|
|
3891
|
-
|
|
3900
|
+
if (project?.anon_key)
|
|
3901
|
+
return { apikey: project.anon_key };
|
|
3902
|
+
const minted = await mintedAnonToken(client, projectId);
|
|
3903
|
+
return minted ? { apikey: minted } : {};
|
|
3904
|
+
}
|
|
3905
|
+
/**
|
|
3906
|
+
* Per-client, per-project memo of short-lived minted anon tokens (plus a
|
|
3907
|
+
* one-shot "minting failed, don't hammer" marker). Held only in process
|
|
3908
|
+
* memory — a short-lived token must never land in a durable cache.
|
|
3909
|
+
*/
|
|
3910
|
+
const mintedTokenMemo = new WeakMap();
|
|
3911
|
+
/** Refresh margin: stop using a minted token this long before it expires. */
|
|
3912
|
+
const MINTED_TOKEN_REFRESH_MARGIN_MS = 30_000;
|
|
3913
|
+
async function mintedAnonToken(client, projectId) {
|
|
3914
|
+
let perProject = mintedTokenMemo.get(client);
|
|
3915
|
+
if (!perProject) {
|
|
3916
|
+
perProject = new Map();
|
|
3917
|
+
mintedTokenMemo.set(client, perProject);
|
|
3918
|
+
}
|
|
3919
|
+
const memo = perProject.get(projectId);
|
|
3920
|
+
if (memo) {
|
|
3921
|
+
if ("failed" in memo)
|
|
3922
|
+
return null;
|
|
3923
|
+
if (Date.now() < memo.expiresAtMs - MINTED_TOKEN_REFRESH_MARGIN_MS)
|
|
3924
|
+
return memo.token;
|
|
3925
|
+
}
|
|
3926
|
+
try {
|
|
3927
|
+
const issued = await client.request(`/projects/v1/${encodeURIComponent(projectId)}/tokens`, {
|
|
3928
|
+
method: "POST",
|
|
3929
|
+
// Least authority: the apikey-gated deploy legs need exactly what the
|
|
3930
|
+
// anon key carries, never the service key's power.
|
|
3931
|
+
body: { kind: "anon" },
|
|
3932
|
+
context: "minting a short-lived anon token for deploy (local project keys not cached)",
|
|
3933
|
+
});
|
|
3934
|
+
if (!issued?.secret) {
|
|
3935
|
+
perProject.set(projectId, { failed: true });
|
|
3936
|
+
return null;
|
|
3937
|
+
}
|
|
3938
|
+
const ttlMs = (typeof issued.expires_in === "number" && issued.expires_in > 0 ? issued.expires_in : 300) * 1000;
|
|
3939
|
+
perProject.set(projectId, { token: issued.secret, expiresAtMs: Date.now() + ttlMs });
|
|
3940
|
+
return issued.secret;
|
|
3941
|
+
}
|
|
3942
|
+
catch {
|
|
3943
|
+
// No signer, no authority, or the route is unavailable: fall through to
|
|
3944
|
+
// the pre-existing no-apikey behavior. Marked so one deploy's many
|
|
3945
|
+
// apikey-gated legs don't each re-attempt a doomed mint.
|
|
3946
|
+
perProject.set(projectId, { failed: true });
|
|
3947
|
+
return null;
|
|
3948
|
+
}
|
|
3892
3949
|
}
|
|
3893
3950
|
function isCiClient(client) {
|
|
3894
3951
|
return isCiSessionCredentials(client.credentials);
|