@jini-ai/platform 0.6.12 → 0.6.15-rc.2

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +54 -0
  3. package/dist/asset-cache.js +27 -13
  4. package/dist/command.d.ts +1 -1
  5. package/dist/command.js +11 -8
  6. package/dist/download.js +26 -32
  7. package/dist/fetch-with-timeout.d.ts +77 -0
  8. package/dist/fetch-with-timeout.js +148 -2
  9. package/dist/fs/directory-user-config.js +2 -1
  10. package/dist/fs/directory-workspace.js +3 -1
  11. package/dist/fs/guarded-reader.d.ts +19 -47
  12. package/dist/fs/guarded-reader.js +82 -13
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/mail/adapter-module.d.ts +18 -0
  16. package/dist/mail/adapter-module.js +13 -1
  17. package/dist/mail/email-sender-registry.d.ts +15 -0
  18. package/dist/mail/email-sender-registry.js +22 -0
  19. package/dist/mail/email-sender.d.ts +109 -0
  20. package/dist/mail/email-sender.js +10 -0
  21. package/dist/mail/index.d.ts +16 -2
  22. package/dist/mail/index.js +9 -1
  23. package/dist/mail/mail-status-cache.d.ts +33 -0
  24. package/dist/mail/mail-status-cache.js +56 -0
  25. package/dist/mail/prepare-sender-email.d.ts +35 -0
  26. package/dist/mail/prepare-sender-email.js +68 -0
  27. package/dist/mail/provider.js +4 -1
  28. package/dist/mail/resolve-mailer.d.ts +95 -0
  29. package/dist/mail/resolve-mailer.js +348 -0
  30. package/dist/mail/senders.d.ts +5 -0
  31. package/dist/mail/senders.js +5 -0
  32. package/dist/secrets/credential-sets/vault.repo.memory.d.ts +1 -4
  33. package/dist/secrets/credential-sets/vault.repo.memory.js +3 -1
  34. package/dist/secrets/credential-sets/vault.service.d.ts +17 -9
  35. package/dist/secrets/credential-sets/vault.service.js +18 -9
  36. package/dist/secrets/credential-sets/vault.types.d.ts +10 -4
  37. package/package.json +8 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @jini-ai/platform
2
2
 
3
+ ## 0.6.13
4
+
5
+ ### Patch Changes
6
+
7
+ - 5634614: Add vendor-neutral email sender contracts, source discovery and template preparation to the mail
8
+ surface, with a universal `@jini-ai/platform/mail/senders` entry. Add the Forms sender to
9
+ `@jini-ai/cms/forms/email-sender`, deriving tokens from stable field IDs, resolving authorized submissions and
10
+ permitted recipients, and configuring sender-owned email actions through an injected guarded writer.
11
+ Delivery planning and production readiness remain host responsibilities.
12
+ The dedicated sender entry requires the optional platform peer at ^0.6.15; the existing Forms
13
+ entry continues to load without platform installed.
14
+ - 0728914: Engine guard clean-up (`pnpm guard` 106 -> 0): layer, subpath-isolation and module-state findings fixed by moving
15
+ shared contracts to their owners instead of reaching across domains. Additive public API: `@jini-ai/core/filesystem`
16
+ (guarded-reader contracts), `useFetchQueryScope` from `@jini-ai/ui` fetch-query (per-provider query scope),
17
+ `@jini-ai/chat/react` re-exports of the accepted-input, run-state and agent-plugin chat-state helpers. The Claude
18
+ filesystem probe cache in `@jini-ai/agent-runtime` is instance-owned. No behaviour change intended; host-facing
19
+ breaks are listed in `owner-store-to-server` and `guard-host-ports`.
20
+ - 26bd2d2: Expose the mail engine through `@jini-ai/platform/mail`: host contracts, redacted
21
+ provider diagnostics, permission-aware domain reads, sender verification, delivery
22
+ policy wrapping, the purpose-gate predicate, provider resolution and status caching.
23
+ Retain existing exports and keep credential binding, persistence and product policy
24
+ in host-supplied ports.
25
+ - c14aeb4: Add optional fetch injection, bounded/strict UTF-8 response text reading, text consumption within the fetch timeout scope, and descriptor-based guarded file reads. Existing defaults and positional fetch signatures remain unchanged.
26
+
27
+ Add an opt-in details overload to readFetchResponseText with retained-byte metadata, bounded-prefix truncation, and abortable reads with prompt cleanup. fetchTextWithTimeout now races every body read against its timeout, so an injected transport whose body ignores the abort signal still times out.
28
+
29
+ - Updated dependencies [7479159]
30
+ - Updated dependencies [c31264d]
31
+ - Updated dependencies [0728914]
32
+ - Updated dependencies [6ecbdb2]
33
+ - Updated dependencies [63bacc5]
34
+ - @jini-ai/core@0.6.13
35
+
3
36
  ## 0.6.12
4
37
 
5
38
  ### Patch Changes
package/README.md CHANGED
@@ -37,6 +37,7 @@ need a `PtySpawn` implementation supplied by the caller — this package defines
37
37
  so profile-only `PATH` entries are visible).
38
38
  - **Filesystem** — `pathContains`, `atomicCopyFile`, `removePathBestEffort`, `readLogTail`.
39
39
  - **HTTP readiness** — `waitForHttpOk`.
40
+ - **Deadline signals** — `createDeadlineSignal({ waitMs }, { signal, startedAt, now })` from `@jini-ai/platform/fetch-with-timeout`; subtracts elapsed milliseconds using the optional `now: () => number` clock (default `Date.now`) and combines the remaining budget with caller cancellation.
40
41
  - **Toolchain discovery** — `wellKnownUserToolchainBins` (npm/pnpm/bun/cargo/deno/go/pyenv,
41
42
  asdf/volta/mise/nvm/fnm shims).
42
43
  - **Proxy-aware env** — `resolveSystemProxyEnv`, `mergeProxyAwareEnv` (macOS `scutil` / Windows
@@ -98,6 +99,15 @@ The package also exposes `./http/guarded`, `./mail`, `./mail/smtp`,
98
99
  `./fs/guarded-reader`, `./fs/durable-json`, and `./secrets`. New APIs take a required
99
100
  object followed by an optional object. Application identity and policy values belong to the caller.
100
101
 
102
+ Email event senders use the universal `@jini-ai/platform/mail/senders` entry (also exported by `/mail`).
103
+ `EmailSender` exposes `listSources`, `describeSource`, `resolveEvent`, and `configureEmail`.
104
+ Each operation authorizes its workspace and principal; the sender owns values, recipients and binding.
105
+ Delivery providers are separate; `createEmailSenderRegistry` reuses core's keyed registry.
106
+ `listEmailSources` lists events across senders; `validateEmailSourceTemplate` checks compatibility.
107
+ `prepareSenderEmail` refuses disabled bindings and events before the latest activation.
108
+ Preparation renders through the injected version-checked template port and sends nothing.
109
+ Its result can be frozen in the feature's existing job; missing optional values produce token warnings.
110
+
101
111
  `@jini-ai/platform/web` loads these optional peers at import time: `parse5@7.3.0`,
102
112
  `hast-util-from-parse5@8.0.3`, `hast-util-to-mdast@10.1.2`,
103
113
  `mdast-util-to-markdown@2.1.2`, and `mdast-util-gfm-table@2.0.0`. Install all five
@@ -142,6 +152,49 @@ The SMTP subpath accepts a transport and clock; its nodemailer factory accepts t
142
152
  as a structural port. Core loads no mail vendor and adds no mail dependency.
143
153
 
144
154
  The guarded reader requires root, deny rules, all read/traversal limits, and a filesystem port.
155
+ Its existing options object also accepts `descriptorRead: { exactMode?: number, rejectGrowth?: boolean }`.
156
+ When present (including `{}`), reads open with `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`, verify
157
+ regular-file status and size with `fstatSync`, and read that same descriptor. `exactMode` compares
158
+ exact permission bits including setuid/setgid/sticky (`mode & 0o7777`); `rejectGrowth: true` rejects bytes beyond its initial size.
159
+ The filesystem's optional `fstatSync` and `constants` capabilities become required for this policy;
160
+ unsupported flags fail closed. No-follow protects the final path component; ancestor-directory
161
+ replacement races remain outside this guarantee. Omitting the policy retains existing reads.
162
+
163
+ `@jini-ai/platform/fetch-with-timeout` also exports `TextReadOptions`,
164
+ `readFetchResponseText({ response }, { maxResponseBytes?, strictUtf8? })`, and
165
+ `fetchTextWithTimeout({ url, timeoutMs, init? }, { fetch?, maxResponseBytes?, strictUtf8? })`.
166
+ Text defaults to unlimited bytes and replacement UTF-8 decoding. Caps must be nonnegative safe
167
+ integers; exactly the cap is accepted, overflow throws `RangeError`, and strict UTF-8 throws on
168
+ malformed/truncated input. Streamed bytes are counted before decoding, independent of Content-Length;
169
+ failed guarded consumption cancels and releases the reader without masking the original error.
170
+ Incoming chunk allocation is controlled by the upstream stream. The text-fetch helper maps timeout
171
+ errors during both headers and body consumption to `FetchTimeoutError`, returns `{ response, text }`,
172
+ and leaves the response metadata available with its body consumed. The existing positional
173
+ `fetchWithTimeout(url, init, { timeoutMs, fetch? })` now accepts transport injection and still returns
174
+ the original unconsumed response. Neither function retries.
175
+
176
+ For retained-byte metadata and optional truncation, use the single-object overload:
177
+
178
+ ```ts
179
+ const { text, byteLength, truncated } = await readFetchResponseText({
180
+ response,
181
+ maxResponseBytes: 1024,
182
+ result: "details",
183
+ onLimit: "truncate",
184
+ signal,
185
+ });
186
+ ```
187
+
188
+ This overload requires a nonnegative safe-integer cap. `onLimit` defaults to `"reject"`;
189
+ `strictUtf8` defaults to `false`. `byteLength` counts retained source bytes before decoding,
190
+ and `truncated` becomes true only after observing bytes beyond the cap. An exact-sized body
191
+ (including an empty body at a zero cap) requires EOF to establish completeness. Truncation
192
+ cancels consumption and flushes the decoder; cutting a UTF-8 character produces replacement
193
+ text by default or throws with `strictUtf8: true`. A signal interrupts pending reads and
194
+ preserves its abort reason. Details-mode cancellation failures are observed without waiting
195
+ for cancellation to settle, and every acquired reader's lock is released. Existing string
196
+ overloads, defaults, and text-fetch behavior remain unchanged.
197
+
145
198
  The durable JSON factory requires file path, filesystem/time/sleep/liveness/random/reporting ports,
146
199
  and host notices. It preserves atomic replacement and corrupt-file states; parent-directory
147
200
  persistence and the inherited simultaneous stale-lock-break race remain filesystem/locking limits.
@@ -169,6 +222,7 @@ vendor-shaped `NativeSmtpTransport` so the factory adapts the payload once. Poli
169
222
 
170
223
  | Subpath | API and required host ports/policy |
171
224
  | --- | --- |
225
+ | `./mail` | `createMailAdapterContext({ credential, kit }, { now, settings })` supplies a live clock and fresh empty settings by default; modules validate opaque per-install settings. |
172
226
  | `@jini-ai/analytics` (separate package) | `ingestHit({ input, deps }, { hooks, hash })`, `normalizeIngestContext({ input, dailySalt }, { hash })`, `deriveDailySalt({ rootKeySeed, workspaceId, utcDate, saltContext })`. The host supplies sink/config, host resolution, HKDF context, and privacy bounds. `LocalBufferSink({}, { initialHits })` is process-local. |
173
227
  | `@jini-ai/cms/trash` (separate package) | `createTrashService({ repo, adapters, transaction, idGen, entityPolicy }, { retentionDays, ...optional })`, `createTrashSweep({ repo, adapters, transaction, entityPolicy })`, and `startTrashSweeper({ sweep, clock, scheduler, leaseOwner }, optional)`. The host owns authorization, transactions, retention, scheduling, and adapters; follow-up hooks are in argument two. |
174
228
  | `./fs` | `writeFileAtomic({ filePath, content, fs, tempName })`, `writeJsonFileAtomic({ filePath, data, fs, tempName })`, `upsertEnvLine({ source, key, value })`, `readEnvLine({ source, key })`, `resolvePathWithin({ root, segment })`. Atomic writes preserve existing modes and require filesystem/temp-name ports. |
@@ -29,7 +29,7 @@ import { isPrivateAddress } from './net/address.js';
29
29
  /** Media extensions we are willing to cache + proxy. Anything else (HTML,
30
30
  * CSS, JS, fonts) is intentionally left alone so this surface never becomes a
31
31
  * general-purpose open proxy. */
32
- export const CACHEABLE_MEDIA_EXT = /\.(?:png|jpe?g|webp|gif|avif|svg|mp4|webm|mov|m4v|ogg|mp3|wav)(?:$|[?#])/i;
32
+ export const CACHEABLE_MEDIA_EXT = /\.(?:png|jpe?g|webp|gif|avif|mp4|webm|mov|m4v|ogg|mp3|wav)$/i;
33
33
  const EXT_TO_MIME = {
34
34
  '.png': 'image/png',
35
35
  '.jpg': 'image/jpeg',
@@ -37,7 +37,6 @@ const EXT_TO_MIME = {
37
37
  '.webp': 'image/webp',
38
38
  '.gif': 'image/gif',
39
39
  '.avif': 'image/avif',
40
- '.svg': 'image/svg+xml',
41
40
  '.mp4': 'video/mp4',
42
41
  '.webm': 'video/webm',
43
42
  '.mov': 'video/quicktime',
@@ -46,6 +45,7 @@ const EXT_TO_MIME = {
46
45
  '.mp3': 'audio/mpeg',
47
46
  '.wav': 'audio/wav',
48
47
  };
48
+ const CACHEABLE_MEDIA_MIME = new Set(Object.values(EXT_TO_MIME));
49
49
  /** True when `raw` is an absolute http(s) URL pointing at a media file we are
50
50
  * willing to route through the cache. Pure + side-effect free so the URL
51
51
  * rewriter and tests can share the exact predicate. */
@@ -55,11 +55,14 @@ export function isCacheableExternalUrl(raw) {
55
55
  const value = raw.trim();
56
56
  if (!/^https?:\/\//i.test(value))
57
57
  return false;
58
- // `String#split` always returns a non-empty array (even `''.split(x)` is
59
- // `['']`), so index 0 is never `undefined` — the `!` narrows a
60
- // `noUncheckedIndexedAccess` false positive, not a real fallback path.
61
- const pathOnly = value.split(/[?#]/)[0];
62
- return CACHEABLE_MEDIA_EXT.test(pathOnly) || CACHEABLE_MEDIA_EXT.test(value);
58
+ // Only the parsed pathname establishes eligibility; a query/fragment that
59
+ // happens to end in .png must not turn an arbitrary endpoint into media.
60
+ try {
61
+ return CACHEABLE_MEDIA_EXT.test(new URL(value).pathname);
62
+ }
63
+ catch {
64
+ return false;
65
+ }
63
66
  }
64
67
  /** Map a cacheable external URL to its same-origin proxy URL. `routePath`
65
68
  * defaults to `/api/asset-cache` but is caller-configurable since the route
@@ -158,13 +161,16 @@ export function createAssetCache(opts, options = {}) {
158
161
  const suppliedDispatcher = options.dispatcher ?? opts.dispatcher;
159
162
  const dispatcher = suppliedDispatcher ?? createAssetCacheDispatcher({}, opts.lookupImpl ? { lookupImpl: opts.lookupImpl } : {});
160
163
  const inflight = new Map();
161
- async function readFromDisk(key) {
164
+ async function readFromDisk({ key }, _options = {}) {
162
165
  const blobPath = path.join(cacheDir, key);
163
166
  const metaPath = `${blobPath}.json`;
164
167
  try {
165
168
  const [buf, metaRaw] = await Promise.all([readFile(blobPath), readFile(metaPath, 'utf8')]);
166
169
  const meta = JSON.parse(metaRaw);
167
- const contentType = typeof meta.contentType === 'string' && meta.contentType ? meta.contentType : 'application/octet-stream';
170
+ const contentType = meta.contentType;
171
+ // Old cache entries must satisfy the same passive-media policy as new fetches.
172
+ if (typeof contentType !== 'string' || !CACHEABLE_MEDIA_MIME.has(contentType))
173
+ return null;
168
174
  return { buf, contentType };
169
175
  }
170
176
  catch {
@@ -179,15 +185,23 @@ export function createAssetCache(opts, options = {}) {
179
185
  await rename(tmpPath, blobPath);
180
186
  await writeFile(`${blobPath}.json`, JSON.stringify({ contentType: result.contentType }));
181
187
  }
182
- function resolveContentType(headerValue, url) {
188
+ function resolveContentType({ headerValue, url }, _options = {}) {
189
+ // Moved from URL eligibility, which now uses URL.pathname instead of split:
190
+ // `String#split` always returns a non-empty array (even `''.split(x)` is
191
+ // `['']`), so index 0 is never `undefined` — the `!` narrows a
192
+ // `noUncheckedIndexedAccess` false positive, not a real fallback path.
183
193
  // `(headerValue ?? '')` is reachable (no content-type header at all). The
184
194
  // trailing `?? ''` after the optional-chained `.split(';')[0]?.trim()` is
185
195
  // not: `String#split` always returns a non-empty array, so `[0]` is never
186
196
  // `undefined` and `?.` never short-circuits — kept for
187
197
  // `noUncheckedIndexedAccess`, not a real fallback path.
188
198
  const header = (headerValue ?? '').split(';')[0].trim().toLowerCase();
189
- if (/^(?:image|video|audio)\//.test(header))
199
+ if (CACHEABLE_MEDIA_MIME.has(header))
190
200
  return header;
201
+ // An explicit active/unsupported type must never be relabelled by its URL.
202
+ if (header && header !== 'application/octet-stream') {
203
+ throw new AssetCacheError(415, `unsupported content-type: ${header}`);
204
+ }
191
205
  const ext = path.posix.extname(url.pathname).toLowerCase();
192
206
  const guessed = EXT_TO_MIME[ext];
193
207
  if (guessed)
@@ -262,7 +276,7 @@ export function createAssetCache(opts, options = {}) {
262
276
  const declaredLength = Number(response.headers.get('content-length') ?? '');
263
277
  if (Number.isFinite(declaredLength))
264
278
  assertWithinLimit(declaredLength);
265
- const contentType = resolveContentType(response.headers.get('content-type'), url);
279
+ const contentType = resolveContentType({ headerValue: response.headers.get('content-type'), url });
266
280
  const buf = await readBodyCapped(response, controller);
267
281
  const result = { buf, contentType };
268
282
  try {
@@ -283,7 +297,7 @@ export function createAssetCache(opts, options = {}) {
283
297
  }
284
298
  const url = rawUrl.trim();
285
299
  const key = assetCacheKey(url);
286
- const cached = await readFromDisk(key);
300
+ const cached = await readFromDisk({ key });
287
301
  if (cached)
288
302
  return cached;
289
303
  const existing = inflight.get(key);
package/dist/command.d.ts CHANGED
@@ -17,7 +17,7 @@ export type CommandInvocationRequest = {
17
17
  * @param request - The command, optional args, and env to base the invocation on.
18
18
  * @returns The `{ command, args }` (plus `windowsVerbatimArguments` when a shim is used) to spawn.
19
19
  */
20
- export declare function createCommandInvocation({ args, command, env }: CommandInvocationRequest): CommandInvocation;
20
+ export declare function createCommandInvocation({ args, command, env }: CommandInvocationRequest, _options?: {}): CommandInvocation;
21
21
  /**
22
22
  * Build the invocation for re-entering the repo-pinned package manager. Prefers
23
23
  * `npm_execpath` (routing Node-loadable managers through `process.execPath`),
package/dist/command.js CHANGED
@@ -25,10 +25,13 @@ import { extname } from "node:path";
25
25
  // into one literal arg with the `%` preserved. The two layers cancel, so the
26
26
  // child receives the original arg byte-for-byte while cmd never has a chance
27
27
  // to expand anything inside it.
28
- function quoteWindowsCommandArg(value) {
29
- if (!/[\s"&<>|^%]/.test(value))
28
+ function quoteWindowsCommandArg({ value }, _options = {}) {
29
+ if (/[\r\n]/.test(value))
30
+ throw new Error("Windows command shim arguments cannot contain line breaks");
31
+ if (value !== "" && !/[\s"&<>|^%=,;()]/.test(value))
30
32
  return value;
31
- const escaped = value.replace(/"/g, '""').replace(/%/g, '"^%"');
33
+ // Backslashes before the closing quote must survive the child's argv parser.
34
+ const escaped = value.replace(/"/g, '""').replace(/%/g, '"^%"').replace(/\\+$/, '$&$&');
32
35
  return `"${escaped}"`;
33
36
  }
34
37
  // Build the `cmd.exe /d /s /c "<line>"` invocation Node uses internally for
@@ -45,8 +48,8 @@ function quoteWindowsCommandArg(value) {
45
48
  // Without this, paths containing spaces (`C:\Users\First Last\...\foo.cmd`)
46
49
  // get split on the first space and cmd.exe reports "not recognized as an
47
50
  // internal or external command" — see issue #315.
48
- function buildCmdShimInvocation(command, args, env) {
49
- const inner = [command, ...args].map(quoteWindowsCommandArg).join(" ");
51
+ function buildCmdShimInvocation({ command, args, env }, _options = {}) {
52
+ const inner = [command, ...args].map((value) => quoteWindowsCommandArg({ value })).join(" ");
50
53
  return {
51
54
  args: ["/d", "/s", "/c", `"${inner}"`],
52
55
  command: env.ComSpec ?? process.env.ComSpec ?? "cmd.exe",
@@ -63,9 +66,9 @@ const nodeLoadablePackageManagerExtensions = new Set([".js", ".cjs", ".mjs"]);
63
66
  * @param request - The command, optional args, and env to base the invocation on.
64
67
  * @returns The `{ command, args }` (plus `windowsVerbatimArguments` when a shim is used) to spawn.
65
68
  */
66
- export function createCommandInvocation({ args = [], command, env = process.env }) {
69
+ export function createCommandInvocation({ args = [], command, env = process.env }, _options = {}) {
67
70
  if (process.platform === "win32" && /\.(bat|cmd)$/i.test(command)) {
68
- return buildCmdShimInvocation(command, args, env);
71
+ return buildCmdShimInvocation({ command, args, env });
69
72
  }
70
73
  return { args, command };
71
74
  }
@@ -94,7 +97,7 @@ export function createPackageManagerInvocation(args, env = process.env) {
94
97
  // package manager out of the box, while a bare `pnpm` only resolves when a
95
98
  // separate global install or a `corepack enable` shim is present. See #2438.
96
99
  if (process.platform === "win32") {
97
- return buildCmdShimInvocation("corepack", ["pnpm", ...args], env);
100
+ return buildCmdShimInvocation({ command: "corepack", args: ["pnpm", ...args], env });
98
101
  }
99
102
  return { args: ["pnpm", ...args], command: "corepack" };
100
103
  }
package/dist/download.js CHANGED
@@ -186,17 +186,16 @@ async function writeSentinel(basePath) {
186
186
  schemaVersion: STORE_SCHEMA_VERSION,
187
187
  });
188
188
  }
189
- async function resetOwnedBase(basePath) {
189
+ /** Clear only this target's data after rechecking store ownership; keep its lock held. */
190
+ async function resetOwnedTarget({ target }, _options = {}) {
191
+ const { basePath } = target;
190
192
  const sentinel = await readJson(join(basePath, STORE_SENTINEL));
191
193
  if (!isStoreSentinel(sentinel)) {
192
194
  throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_NOT_OWNED, `download base is not owned: ${basePath}`);
193
195
  }
194
- const entries = await readdir(basePath).catch(() => []);
195
- for (const entry of entries) {
196
- await rm(join(basePath, entry), { force: true, recursive: true }).catch(() => undefined);
196
+ for (const path of [target.finalPath, target.partialPath, target.manifestPath]) {
197
+ await rm(path, { force: true }).catch(() => undefined);
197
198
  }
198
- await writeSentinel(basePath);
199
- await ensureStoreDirs(basePath);
200
199
  }
201
200
  async function ensureStoreDirs(basePath) {
202
201
  await mkdir(join(basePath, STATE_DIR), { recursive: true });
@@ -540,8 +539,8 @@ async function releaseLock(lock) {
540
539
  return;
541
540
  await rm(lock.path, { force: true }).catch(() => undefined);
542
541
  }
543
- async function suspiciousReset(target) {
544
- await resetOwnedBase(target.basePath);
542
+ async function suspiciousReset({ target }, _options = {}) {
543
+ await resetOwnedTarget({ target });
545
544
  return { manifest: null, reset: true };
546
545
  }
547
546
  async function loadReusableState(target) {
@@ -549,18 +548,18 @@ async function loadReusableState(target) {
549
548
  const finalExists = await pathExists(target.finalPath);
550
549
  const partialExists = await pathExists(target.partialPath);
551
550
  if (manifest === "invalid") {
552
- return await suspiciousReset(target);
551
+ return await suspiciousReset({ target });
553
552
  }
554
553
  if (manifest == null) {
555
- return finalExists || partialExists ? await suspiciousReset(target) : { manifest: null };
554
+ return finalExists || partialExists ? await suspiciousReset({ target }) : { manifest: null };
556
555
  }
557
556
  if (!manifestMatchesTarget(manifest, target)) {
558
- return await suspiciousReset(target);
557
+ return await suspiciousReset({ target });
559
558
  }
560
559
  if (manifest.state === "complete")
561
560
  return await reusableCompleteState({ manifest, target });
562
561
  if (!partialExists)
563
- return await suspiciousReset(target);
562
+ return await suspiciousReset({ target });
564
563
  return { manifest };
565
564
  }
566
565
  /** Reuse a complete download only when its final file exists and still hashes to the requested checksum. */
@@ -570,10 +569,10 @@ async function reusableCompleteState(input) {
570
569
  // maps to `null` and fails the checksum comparison into the same reset.
571
570
  const actual = await hashFile(target.finalPath, target.checksum.algorithm).catch(() => null);
572
571
  if (actual !== target.checksum.value)
573
- return await suspiciousReset(target);
572
+ return await suspiciousReset({ target });
574
573
  const bytes = await statFileSize(target.finalPath);
575
574
  if (bytes == null)
576
- return await suspiciousReset(target);
575
+ return await suspiciousReset({ target });
577
576
  return {
578
577
  manifest,
579
578
  result: {
@@ -591,13 +590,13 @@ async function reusableCompleteState(input) {
591
590
  async function runManagedDownload(target, options) {
592
591
  await ensureManagedBase(target.basePath);
593
592
  await pruneManagedDownloads({ basePath: target.basePath }).catch(() => undefined);
594
- const held = { lock: await acquireLock(target) };
593
+ const lock = await acquireLock(target);
595
594
  try {
596
- const state = await loadStateAllowingOneReset({ held, target });
595
+ const state = await loadStateAllowingOneReset({ target });
597
596
  if (state.result != null)
598
597
  return state.result;
599
598
  const download = await downloadWithRetries(target, state.manifest, options);
600
- const bytes = await promoteVerifiedPartial(target);
599
+ const bytes = await promoteVerifiedPartial({ target });
601
600
  await writeJson(target.manifestPath, createManifest(target, "complete", { totalBytes: download.totalBytes }));
602
601
  return {
603
602
  bucket: target.bucket,
@@ -611,36 +610,31 @@ async function runManagedDownload(target, options) {
611
610
  };
612
611
  }
613
612
  finally {
614
- await releaseLock(held.lock);
613
+ await releaseLock(lock);
615
614
  }
616
615
  }
617
- /** Load reusable state, re-locking and reloading once if the first load had to reset the managed base. */
618
- async function loadStateAllowingOneReset(input) {
619
- const { held, target } = input;
616
+ /** Reload once after target cleanup, retaining the lock until the run finishes. */
617
+ async function loadStateAllowingOneReset({ target }, _options = {}) {
620
618
  const state = await loadReusableState(target);
621
619
  if (state.reset !== true)
622
620
  return state;
623
- // A reset removes the lock as part of the managed base cleanup, so
624
- // reacquire it before writing the fresh target state. `held.lock` is
625
- // nulled first so a failed re-acquire never releases (deletes) a lock
626
- // another process may have taken in the meantime.
627
- await releaseLock(held.lock);
628
- held.lock = null;
621
+ // Whole-base cleanup used to remove the lock and require re-acquisition.
622
+ // Keep it throughout target cleanup so another process cannot take it
623
+ // before we write fresh state, or have its own lock deleted by our finally.
629
624
  await ensureManagedBase(target.basePath);
630
- held.lock = await acquireLock(target);
631
625
  const reloaded = await loadReusableState(target);
632
626
  if (reloaded.reset === true) {
633
- throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "download state kept resetting after base cleanup");
627
+ throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "download state kept resetting after target cleanup");
634
628
  }
635
629
  return reloaded;
636
630
  }
637
631
  /** Verify the downloaded partial's checksum, promote it to the final path, and return the final size. */
638
- async function promoteVerifiedPartial(target) {
632
+ async function promoteVerifiedPartial({ target }, _options = {}) {
639
633
  const actual = await hashFile(target.partialPath, target.checksum.algorithm).catch((error) => {
640
634
  throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, `downloaded partial could not be hashed: ${errorMessage(error)}`);
641
635
  });
642
636
  if (actual !== target.checksum.value) {
643
- await resetOwnedBase(target.basePath).catch(() => undefined);
637
+ await resetOwnedTarget({ target }).catch(() => undefined);
644
638
  throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.CHECKSUM_MISMATCH, "downloaded file checksum did not match requested payload", {
645
639
  actual,
646
640
  expected: target.checksum.value,
@@ -650,7 +644,7 @@ async function promoteVerifiedPartial(target) {
650
644
  if (await pathExists(target.finalPath)) {
651
645
  const existing = await hashFile(target.finalPath, target.checksum.algorithm).catch(() => null);
652
646
  if (existing !== target.checksum.value) {
653
- await resetOwnedBase(target.basePath).catch(() => undefined);
647
+ await resetOwnedTarget({ target }).catch(() => undefined);
654
648
  throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "existing complete file did not match requested payload");
655
649
  }
656
650
  await rm(target.partialPath, { force: true }).catch(() => undefined);
@@ -86,7 +86,67 @@ export interface FetchWithTimeoutOptions {
86
86
  /** Milliseconds before the call is aborted. Pick one of {@link FETCH_TIMEOUT_MS}, or a specific
87
87
  * justified number for a call site that doesn't fit those classes. */
88
88
  timeoutMs: number;
89
+ /** Host-supplied transport; defaults to the global fetch. */
90
+ fetch?: typeof fetch;
91
+ }
92
+ /** Optional caller cancellation and elapsed-time accounting for a wait budget. */
93
+ export interface DeadlineSignalOptions {
94
+ signal?: AbortSignal;
95
+ /** Start time in milliseconds, on the same clock as `now`. */
96
+ startedAt?: number;
97
+ /** Clock port in milliseconds; defaults to Date.now and is read only with startedAt. */
98
+ now?: () => number;
89
99
  }
100
+ /**
101
+ * Creates one signal that aborts when the remaining wait budget expires or the caller cancels.
102
+ * Spent time is subtracted once; future start times cannot increase the original budget.
103
+ * @param input - Wait budget in milliseconds. Negative remaining budgets become zero.
104
+ * @param options - Optional caller signal, start time and clock port.
105
+ * @returns A native deadline signal preserving the first abort reason. Zero expires asynchronously,
106
+ * following AbortSignal.timeout(0); an already-aborted caller is reflected immediately.
107
+ * Creates a native timeout without mutating inputs or the caller's signal.
108
+ * @throws {RangeError} If the remaining delay is invalid for AbortSignal.timeout.
109
+ * @complexity O(1) time and space.
110
+ * @example createDeadlineSignal({ waitMs: 5_000 }, { signal, startedAt, now: Date.now })
111
+ */
112
+ export declare function createDeadlineSignal({ waitMs }: {
113
+ waitMs: number;
114
+ }, { signal, startedAt, now }?: DeadlineSignalOptions): AbortSignal;
115
+ /** Opt-in byte and decoding guards; defaults remain unlimited and replacement-decoded. */
116
+ export type TextReadOptions = {
117
+ /** Nonnegative safe integer. Actual streamed bytes, independent of Content-Length. */
118
+ maxResponseBytes?: number;
119
+ /** Reject malformed UTF-8, including an incomplete trailing character. */
120
+ strictUtf8?: boolean;
121
+ };
122
+ /**
123
+ * Consumes a response as UTF-8, checking bytes before decoding/retaining each chunk.
124
+ * Details mode reports retained source bytes and truncates only on observed overflow;
125
+ * reaching the cap still requires EOF to establish completeness. Defaults reject overflow
126
+ * and replace malformed UTF-8. A supplied signal interrupts even a stalled read.
127
+ * @throws {RangeError} An invalid cap or a response exceeding it.
128
+ * @throws {TypeError} Invalid UTF-8 when strict decoding is enabled.
129
+ * Read failures and abort reasons propagate; acquired readers are cancelled on failure
130
+ * and always released. Details mode observes cancellation failures without awaiting cleanup.
131
+ * @complexity O(n) time and retained text for n bytes, bounded by the optional cap.
132
+ * The upstream stream controls the allocation of individual incoming chunks.
133
+ * @example readFetchResponseText({ response, maxResponseBytes: 1024, result: "details", onLimit: "truncate" })
134
+ */
135
+ export declare function readFetchResponseText(input: {
136
+ response: Response;
137
+ maxResponseBytes: number;
138
+ result: "details";
139
+ onLimit?: "reject" | "truncate";
140
+ strictUtf8?: boolean;
141
+ signal?: AbortSignal;
142
+ }): Promise<{
143
+ text: string;
144
+ byteLength: number;
145
+ truncated: boolean;
146
+ }>;
147
+ export declare function readFetchResponseText({ response }: {
148
+ response: Response;
149
+ }, options?: TextReadOptions): Promise<string>;
90
150
  /**
91
151
  * `fetch`, but aborted after `options.timeoutMs` if it hasn't settled by then. If `init.signal` is
92
152
  * already set, the call aborts on EITHER that signal or the timeout, via `AbortSignal.any` — a
@@ -103,4 +163,21 @@ export interface FetchWithTimeoutOptions {
103
163
  * genuine network error) propagates unchanged.
104
164
  */
105
165
  export declare function fetchWithTimeout(url: string | URL, init: RequestInit | undefined, options: FetchWithTimeoutOptions): Promise<Response>;
166
+ /**
167
+ * Fetches and consumes text inside one timeout scope. The returned response keeps its metadata
168
+ * and has a consumed body. No retries; other fetch/read errors propagate unchanged.
169
+ * @throws {FetchTimeoutError} The timeout ends fetch or body consumption before caller abort.
170
+ * @throws {RangeError | TypeError} See {@link readFetchResponseText} for opt-in guards.
171
+ * @complexity O(n) time and text storage for n response bytes, optionally capped.
172
+ */
173
+ export declare function fetchTextWithTimeout(required: {
174
+ url: string | URL;
175
+ timeoutMs: number;
176
+ init?: RequestInit;
177
+ }, optional?: TextReadOptions & {
178
+ fetch?: typeof fetch;
179
+ }): Promise<{
180
+ response: Response;
181
+ text: string;
182
+ }>;
106
183
  //# sourceMappingURL=fetch-with-timeout.d.ts.map