Issue batch: migration-feedback fixes (#337-#344) + nightly issue-arm and #351 compat - #353
Merged
Conversation
…#342) core.addFiles() emits `restriction-failed` and then rethrows. The rethrow is deliberate — a direct `await core.addFiles()` caller narrows on it — but the react headless surface reaches addFiles from three fire-and-forget DOM callbacks that have nobody to rethrow to: - getInputProps().onChange -> `void addFiles(...)` - getDropzoneProps().onDrop -> `void dragDrop.handleDrop(...)` - useUpupUpload's DragDropDeps.setFiles -> bare `core.addFiles(files)`, which the controller invokes un-awaited for BOTH drop and paste So every ordinary user-facing restriction failure (wrong type, too large, over the limit) surfaced a second time as an unhandled promise rejection and polluted error reporting. Fix: swallow the rejection on those three paths only. Nothing is lost — core emits `restriction-failed` carrying the identical error BEFORE it throws, so the event bus is the surviving channel. `await addFiles()` still rejects. Census: the visual panels in all six frameworks were never affected — their setFiles routes through createUploaderController's handleSetSelectedFiles, which try/catches and never rejects (vue's useUploaderController already documents this as "the void contract"). This headless dep was the sole call site reaching core.addFiles bare, so core's DragDropController is untouched. RED before the fix (tests/prop-getters-unhandled-rejection.test.ts): Test Files 1 failed (1) Tests 4 failed | 1 passed (5) AssertionError: expected [ ...(1) ] to deeply equal [] - [] + [ + UpupValidationError { + "message": "File type \"text/plain\" is not accepted", + "code": "TYPE_MISMATCH", + "reason": "TYPE_MISMATCH", + }, + ] The one test that passed RED is "leaves a DIRECT await core.addFiles() rejection intact" — pinning that the rethrow itself is preserved. GREEN after: 5 passed (5); full @upupjs/react suite 611 passed (69 files); `pnpm --filter @upupjs/react typecheck` exit 0.
The three getters each merged `overrides` differently, so what happened to a
key depended on which getter you passed it to:
- getDropzoneProps composed the four drag handlers and DROPPED every other
override key — className/style/id silently vanished
- getRootProps spread overrides, then wrote its own keys over the top,
including a literal `aria-describedby: undefined` that deleted a caller's
value
- getInputProps spread overrides, then replaced `style` wholesale with
`{ display: 'none' }`
All three now follow ONE rule:
1. `...overrides` is spread FIRST — anything you pass survives.
2. Getter-OWNED keys are applied after and win, limited to values derived
from live core state plus the ones without which the element stops being
an uploader element:
root -> aria-busy
dropzone -> aria-dropeffect
input -> type, multiple, accept, style.display
3. Event handlers are COMPOSED, never replaced: the getter's handler runs
first, then the caller's (the order composeEventHandlers already used, so
no behavior change for existing handler overrides).
4. `style` is MERGED, not replaced.
5. role / aria-label / tabIndex / aria-hidden become overridable DEFAULTS.
`accept` is claimed only when core actually declares `allowedFileTypes` —
writing `undefined` unconditionally would delete a caller's own accept, which
is the same silent-drop bug this contract exists to end.
Headless-surface only: createPropGetters has exactly two call sites (itself and
useUpupUpload), the visual components render their own input, so no DOM
contract string or parity fixture is touched.
RED before the fix (tests/prop-getters-override-contract.test.ts):
Test Files 1 failed (1)
Tests 7 failed | 14 passed (21)
x getDropzoneProps keeps non-handler override keys
expected undefined to be 'my-zone'
x getRootProps does not clobber an aria-describedby override with undefined
expected undefined to be 'help-text'
x an accept override survives when core declares no file-type filter
expected undefined to be '.csv'
x getInputProps keeps override style keys and still hides the input
expected { display: 'none' } to deeply equal { Object (position, width, ...) }
x root role and aria-label are overridable
expected 'application' to be 'group'
x dropzone role, aria-label and tabIndex are overridable
expected 'region' to be 'button'
x input tabIndex and aria-hidden are overridable
expected -1 to be +0
GREEN after: 21 passed (21); full @upupjs/react suite 632 passed (70 files) —
the pre-existing prop-getters tests still pass unchanged, since every default
is preserved when no override is supplied. typecheck exit 0.
Docs: apps/landing/content/docs/guides/headless.mdx gains a "Prop getters and
overrides" section stating the five rules and tabulating the owned keys.
The error-handling docs tell you to narrow failures with `instanceof UpupError`
and `UpupErrorCode`, and `useUpupUpload().error` is already typed
`UpupError | null` — but neither name was reachable from @upupjs/react, so a
react-only consumer had to add a direct @upupjs/core dependency purely to type
a catch block.
@upupjs/react's entry now re-exports, verbatim from @upupjs/core, 8 runtime
names:
UpupErrorCode
UpupError
UpupAuthError
UpupNetworkError
UpupValidationError
UpupQuotaError
UpupStorageError
UpupConfigError
plus the type-only `RestrictionFailedReason`. React's pinned public list goes
27 -> 35 runtime names; the added names are exactly the 8 above.
@upupjs/preact and @upupjs/next inherit them: both are a one-line
`export * from '@upupjs/react'`, and both pins assert equality WITH react's
list rather than duplicating it, so they follow automatically. Verified at the
runtime level rather than trusting the equality pin — probing preact's BUILT
bundle after rebuilding core -> react -> preact:
preact dist total exports: 35
missing: []
instanceof UpupError from preact dist: true
New tests/error-exports.test.ts pins IDENTITY, not just presence: a re-export
that produced a second class object would satisfy the name pin while silently
breaking every `instanceof` across the package boundary. It asserts each class
is the same object core exports, and that an error raised by the engine itself
narrows through react-only imports.
RED before the change:
Test Files 2 failed (2)
Tests 11 failed (11)
AssertionError: expected undefined to be type of 'function'
AssertionError: expected undefined to be { AUTH_EXPIRED: 'AUTH_EXPIRED', ...(22) }
AssertionError: The instanceof assertion needs a constructor but undefined was given.
AssertionError: expected [ Array(27) ] to deeply equal [ Array(35) ]
GREEN after: react 642 passed (71 files), preact 22 passed, next 23 passed;
typecheck exit 0 for react, preact and next.
Deliberately NOT included: `uploadErrorFromResponse`. Despite the docs showing
`import { uploadErrorFromResponse } from '@upupjs/core'`, it is exported only
from @upupjs/core/internal, not core's public entry — promoting an internal to
the public allow-list is a separate API decision. The stale docs import is
reported to the issue batch rather than fixed here.
`Promise<unknown> | void` tripped @typescript-eslint/no-invalid-void-type
("void is not valid as a constituent in a union type"). The callers' return
types are themselves void-unions (`Promise<void> | void` from
PropGetterDeps.addFiles), so no narrower parameter type accepts them — take
`unknown` and keep the existing duck-typed thenable check.
Caught by `pnpm --filter @upupjs/react lint`, which the pre-commit hook does
not run (it runs oxlint + prettier + unit suites; eslint is a pre-push gate).
react: lint exit 0, typecheck exit 0, 642 passed (71 files).
CLAUDE.md names `UpupError + subclasses + uploadErrorFromResponse` as the one
public error taxonomy, and apps/landing/content/docs/api-reference/
error-codes.mdx:240 already instructs users to
`import { uploadErrorFromResponse } from '@upupjs/core'` — but the curated
allow-list never caught up with that documented intent, so the import did not
resolve.
CORRECTION to the earlier report on this issue: it was NOT "exported only from
./internal". It was exported from NEITHER entry. Verified by probing the built
artifacts directly rather than trusting the pins:
public entry has uploadErrorFromResponse: false
internal entry has uploadErrorFromResponse: false
Core's own pin carried a comment asserting "(via ./internal)", which was simply
wrong; that comment is corrected here. All four in-tree call sites
(direct-upload, multipart-upload, server-credentials, server-transfer) reach it
by relative import inside core's own src, so nothing consumed it through the
internal subpath and this promotion is purely additive — ./internal and its pin
are untouched, and no alias or second binding is introduced.
Surfaces:
- @upupjs/core `.` : 51 -> 52 runtime names (+uploadErrorFromResponse)
- @upupjs/react : 35 -> 36, re-exported alongside the taxonomy so
framework-only consumers get the full documented
error toolkit without a direct core dependency
- @upupjs/preact / @upupjs/next inherit (both are `export * from
'@upupjs/react'`; both pins assert equality with react's list)
Verified in preact's BUILT bundle after rebuilding core -> react -> preact,
not merely via the equality pin:
preact dist total exports: 36
missing: []
uploadErrorFromResponse identity === core: true
built err instanceof preact UpupError: true
built err instanceof preact UpupStorageError: true
built err.code: STORAGE_ERROR
RED before the change:
core — expected [ 'ACCEPT_PRESETS', ...(50) ] to deeply equal [ ...(51) ]
react — expected [ Array(35) ] to deeply equal [ Array(36) ]
expected undefined to be type of 'function'
Tests 1 failed | 1 passed (core), 3 failed | 10 passed (react)
GREEN after: core 1585 passed (134 files), react 644 (71), preact 22, next 23;
typecheck exit 0 for all four; lint 20/20; size exit 0; knip exit 0.
NOT included, deliberately: the `UploadErrorFromResponseArgs` parameter
interface stays unexported. Inline object-literal call sites type-check
structurally, so the ergonomic gap only affects someone building the args
object separately — widening core's public TYPE surface is its own decision.
…pes/node
`readBody` returned the assembled Node `Buffer` straight into
`toWebRequest({ body })`. Under @types/node >=22 a Buffer types as
`Buffer<ArrayBufferLike>`, which is not assignable to `BodyInit`, so the
dependabot lockfile regen in PR #351 turned both `@upupjs/next:typecheck`
and `@upupjs/next:build` red:
src/pages-handler.ts(59,17): error TS2322: Type 'Buffer<ArrayBufferLike> |
undefined' is not assignable to type 'BodyInit | null | undefined'.
Type 'Buffer<ArrayBufferLike>' is not assignable to type 'BodyInit | null
| undefined'.
Copy into a fresh `Uint8Array` (same bytes, backed by a real ArrayBuffer) and
annotate the helper as `RequestInit['body']` — a bare `Uint8Array` annotation
means `Uint8Array<ArrayBufferLike>` and reproduces the identical error:
packages/next/src/pages-handler.ts(66,17): error TS2322: Type
'Uint8Array<ArrayBufferLike> | undefined' is not assignable to type
'BodyInit | null | undefined'.
RED before the fix (packages/next/src/__tests__/pages-handler.spec.ts):
FAIL src/__tests__/pages-handler.spec.ts > createUpupPagesHandler > hands
the body to the bridge as a plain Uint8Array, not a Node Buffer
AssertionError: expected Buffer[ 123, 34, 110, 97, 109, ... ] to be an
instance of Uint8Array
> 135| expect(bridged.body).toBeInstanceOf(Uint8Array)
Verified both ways: `pnpm --filter @upupjs/next typecheck` (TS 5.3.3 /
@types/node 20) exit 0, and tsc 5.9.3 with typeRoots pinned to
@types/node 26.1.2 over the real source exit 0.
Two gaps around serving stored objects, both hit while migrating an app that
serves gated downloads.
1. There was no way to sign a GET for an EXISTING key. The only signed-GET
producer lived inside the upload flow, so "give me a fresh URL for a key I
stored last month" meant standing up a second handler with an identity
keyStrategy and using half of it. `getDownloadUrl(config, key, opts?)` is
that operation on its own — no handler, no route, no token. It takes the
storage slice of UpupServerConfig (pass the whole config, or `{ storage }`),
and throws UpupConfigError for a storage.type with no S3 API.
2. The download-URL TTL was hardcoded at 3 days, so an app that issued
15-minute links for gated content silently got 3-day links. The new
`downloadUrlExpiresIn` (seconds) on UpupServerConfig sets it for every
signed GET the server hands out: `downloadUrl` on /presign and
/multipart/complete, and `url` on /files/:provider/transfer. Expiry
resolves per-call `expiresIn` -> `config.downloadUrlExpiresIn` -> 3 days.
The upload URL's own 1-hour expiry is untouched.
Public API pin (packages/server/tests/public-api.test.ts) gains ONE runtime
name: `getDownloadUrl`. New exported types: `UpupStorageConfig` (the storage
object, extracted from the inline UpupServerConfig shape),
`DownloadUrlConfig`, `GetDownloadUrlOptions`.
The non-S3 provider guard moved out of handler.ts into src/storage.ts
(`assertS3Storage`) so the handler's construct-time check and getDownloadUrl's
per-call check are the same code and the same message, not two copies.
RED before the implementation (packages/server/tests/download-url.test.ts):
FAIL tests/download-url.test.ts [ tests/download-url.test.ts ]
Error: Cannot find module '/src/download-url' imported from
.../packages/server/tests/download-url.test.ts
> 40| import { getDownloadUrl } from '../src/download-url'
Gates: server unit suite 276 passed | 35 skipped, typecheck (src + test tree)
exit 0, docs:links:check / docs:api-sync:check (my entry) /
docs:snippets:coverage / docs:snippets:check all OK.
…eforeUpload (#338) A deployment whose storage endpoint is not browser-reachable — a private MinIO behind a same-origin proxy route, a docker-internal hostname in local dev, a VPC-only endpoint — could not use the shipped handlers at all: `hooks` had no response-side seam, so there was no way to rewrite `uploadUrl` before it left the server. One hook, `onPresignResponse`, closes that. It fires on the three presign-side responses, discriminated by `ctx.phase`: presign POST /presign PresignedUrlResponse multipart-init POST /multipart/init MultipartInitResponse + token multipart-sign-part POST /multipart/sign-part MultipartSignPartResponse One hook name rather than three, because the rewrite is the same operation each time and covering only /presign would leave multipart uploads pointed at the unreachable host. Returning an object replaces the payload; returning nothing keeps it. ctx is { req, phase, key, metadata?, userId } — `key` is always the key that is IN the payload (on sign-part it comes from the VERIFIED token, never the client), and `metadata` is absent on sign-part, which sees only a token and a part number. Trust model unchanged. The hook runs after every auth, policy, and token check and after the upload token is issued; it cannot alter a status code or turn a rejection into a success, and a request that would 401/403 never reaches it. Two tests pin that directly ("never runs for a request rejected before the route", "never runs for a request the auth gate rejected"). Responses still go out through the Responder, so response-contract.test.ts is untouched. Also: `onBeforeUpload` throwing an UpupError now serializes that error's message and code into the 403, so a quota check can say "Storage limit exceeded — upgrade to keep uploading" instead of the opaque "Upload rejected". Returning `false` keeps the generic body byte-for-byte. Any NON-UpupError throw is re-thrown and stays a generic 500 with the cause going only to onError — pinned by a test asserting the thrown message does not appear in the response body. New exported types (types only — the runtime public-API pin is unchanged): PresignResponsePhase, PresignResponseContext, PresignResponseBody, PresignResponseRewrite. RED before the implementation (6 of 11 cases in packages/server/tests/presign-response-hook.test.ts): × replaces the /presign payload when the hook returns an object expected 'https://internal-minio:9000/bucket/u1…' to be 'https://app.example.com/api/s3/bucket…' × replaces the /multipart/init payload, token included expected undefined to be 'eu-west-1' × replaces the /multipart/sign-part payload × reports phase, key, metadata and userId per response × surfaces an UpupError thrown by the hook with its message and code × surfaces it on /multipart/init too The two UpupError cases were 500s before, with the hook's message reaching only the logs: [upup:server] {"route":"presign",…,"status":500,"code":"STORAGE_ERROR", "message":"Internal error","error":{"name":"UpupQuotaError","message": "Storage limit exceeded — upgrade to keep uploading",…}} Gates: server unit suite 287 passed | 35 skipped (handler-extended and trust-model unmodified), typecheck (src + test tree) exit 0, docs:links:check OK.
…adata (#337) `storage` was a single static object, so `createUpupHandler` only fit single-bucket apps — an app routing images / quarantined-but-unscanned documents / general documents to three buckets with their own credentials and endpoints had to keep a hand-rolled presign route. It now also accepts a resolver: storage: ctx => ctx.storageId ? byIdentity(ctx.storageId) : BUCKETS[classify(ctx.metadata)] ctx is { req, phase, userId, metadata?, fileName?, contentType?, size?, storageId? }, and every route resolves through it: presign, multipart init, the three multipart continuations, and drive-transfer (which resolves AFTER the drive reports the real name/type/size, not from the client's claim). The token carrier (the hard part of this change) ------------------------------------------------ A multipart upload picks its bucket at init, but sign-part/complete/abort arrive later with only a token — none of the metadata the decision came from. Re-running the resolver blind would send them elsewhere; accepting a client-supplied hint would let anyone redirect a continuation into a bucket of their choosing. So init stamps an opaque STORAGE IDENTITY into the HMAC-signed upload token (new optional `sid`), and each continuation hands it back to the resolver as `ctx.storageId`. The server then re-derives the identity of whatever the resolver returned and answers 403 AUTH_DENIED on a mismatch — a resolver that ignores storageId fails closed instead of writing parts into the wrong bucket. No unsigned storage hint is ever accepted. The identity is SHA-256(bucket, endpoint, region), truncated — deterministic (so a token issued by one worker verifies on any other) and free of credentials, so rotating an access key does not strand in-flight uploads and nothing secret sits in a token the client can read. A token with no `sid` while storage is a resolver is REJECTED, not guessed at; tokens live an hour, so the client restarts from init. Static configs are unaffected ----------------------------- A static object emits NO `sid` (there is one destination to bind to) and skips every resolver path, so its tokens and behavior are byte-identical to before — pinned by two tests asserting the token has no `sid` and that the old flow still 200s. Also in this change ------------------- - keyStrategy ctx gains `metadata` and `req` (the issue's second ask). The existing { userId, fileName, contentType, size } fields are untouched, so existing strategies keep working. - FileMetadata gains an optional free-form `metadata` object, the wire field the client uses to say which class of upload this is. It is UNTRUSTED and documented as such in the type, the guide, and the API reference. - S3 clients are now cached per destination (endpoint|region|bucket| accessKeyId|forcePathStyle) instead of constructed per call, so multi-bucket traffic does not rebuild a connection pool on every presign. The secret key is never part of the cache key and is never logged. - Construct-time guards apply to static configs only; a resolver's result is validated per request (bucket/region present, S3-capable provider) and a bad one fails that request with 500 "Storage configuration error" through the Responder, with the real cause going to onError alone. There is deliberately no fallback bucket — a misrouted write is worse than a failed one. - /health reports checks.storage "skipped" and summary.storageType "dynamic" for a resolver: there is no single destination to probe, and calling an integrator's resolver from an unauthenticated liveness route would reach a real backend. - RENAME carried over from #338 in this same branch: PresignResponseContext's `metadata` is now `file` (it was always the FileMetadata), freeing `metadata` to mean the client's free-form hints consistently across PresignResponseContext, KeyStrategyContext and StorageResolverContext. One name, one meaning, per the repo's naming rule. - hooks.onPresignResponse's inline signature became the exported `OnPresignResponse` type so the `| void` idiom could carry a scoped, described eslint exemption rather than being degraded to `| undefined` (which would force every inspect-only hook to end in `return undefined`). New exported types (types only — the runtime public-API pin is unchanged): UpupStorageResolver, StorageResolverPhase, StorageResolverContext, UpupClientMetadata, OnPresignResponse. RED before the implementation (packages/server/tests/storage-routing.test.ts): FAIL tests/storage-routing.test.ts [ tests/storage-routing.test.ts ] Error: Cannot find module '/src/resolve-storage' imported from .../packages/server/tests/storage-routing.test.ts > 63| import { storageIdentity } from '../src/resolve-storage' Gates: server unit suite 300 passed | 35 skipped (handler-extended, response-contract and trust-model unmodified and green), typecheck (src + test tree) exit 0, eslint --max-warnings 0 clean, test:quality OK (374 test files, 0 exceptions), @upupjs/next typecheck exit 0 against the rebuilt server dist, docs:links:check / docs:snippets:check OK.
The previous commit made createS3Client memoize, and added `_resetS3ClientCacheForTests` with nothing calling it. Exercise the behavior instead of leaving a dead export: one client is reused for a repeated destination, bucket/region/endpoint each key separately, and a rotated accessKeyId yields a NEW client rather than one still signing with the old key. Gates: server typecheck exit 0, eslint --max-warnings 0 exit 0, test:quality exit 0 (374 test files), s3-client.test.ts 8 passed.
Review or Edit in CodeSandboxOpen the branch in Web Editor • VS Code • Insiders |
This was referenced Aug 12, 2026
Headless: restriction failures from the file input also surface as unhandled promise rejections
#342
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Batch resolution of the 2026-08-05 migration-feedback issues (#337–#344) plus two CI fixes surfaced along the way. Written as a handoff: everything a developer needs to continue is in this body, the linked issues, and the commit messages.
What this PR resolves
maxFileSize/acceptvalidation silently stops applying in v3, with a workingonBeforeUploadexample; cross-linked from the server-auth guidec90ef25fsourcesadds camera/microphone/screen; passsources={['local','url']}for the v1 surface. The code half (hide capture sources that can never satisfyallowedFileTypes) is assessed and posted on the issue as a scoped 3.2 candidatec90ef25frestriction-failedevent unchanged34e132da,f014a694stylemerged); fixed a 4th defect the issue missed ('aria-describedby': undefinedclobbering); 21 contract tests; documented in the headless guide6f6b395dUpupError+ 6 subclasses,UpupErrorCode,uploadErrorFromResponse) re-exported from@upupjs/react; preact/next inherit (verified against built bundles with class-identity tests).uploadErrorFromResponsepromoted to core's public entry — the documented import was real on neither entryb336ed58,fec4fb29,92ea13dfgetDownloadUrl(config, key, opts?)primitive for existing keys +downloadUrlExpiresInconfig knob (per-call → config → 3-day default); non-S3 storage throwsUpupConfigError38f1d732hooks.onPresignResponse(response, ctx)— one hook, three phases (presign,multipart-init,multipart-sign-part) so proxied-storage deployments can rewrite every URL the client receives;onBeforeUploadthrowing anUpupErrornow surfaces its message+code in the 403 (non-UpupErrorthrows stay generic — no leakage, pinned by test)d177cf19storageaccepts `(ctx) => StorageConfigfor per-request multi-bucket routing;keyStrategyctx gainsmetadata+req`; per-destination S3 client cachepages-handlerbody annotatedRequestInit['body']+ copied to a plainUint8Array— typechecks under current AND newer@types/node0bf51053ghhad no repo context); fixed withGH_REPOenv on both arms7ffe2891Also: one minor changeset for the fixed group (
5fbd2c67).Design decision worth review: #337 storage binding for multipart continuations
Multipart continuations (sign-part/complete/abort) must reach the same storage the init resolved, tamper-proof. The HMAC-signed upload token now carries an optional
sid=SHA-256(bucket + "\n" + endpoint + "\n" + region)(hex, 32 chars):sidbreaks the HMAC (existing 403); a validly-signed token whosesiddoesn't match what the resolver returns → 403AUTH_DENIEDwith zero storage calls (pinned by a test that proves no S3 call happened). Resolver-form +sid-less token also 403s (covers a static→resolver config switch mid-flight; tokens live 1h, client restarts from init).ctx.metadatais documented as attacker-controlled in the types, guide, and API reference.Related calls:
/healthreportsstorage: "skipped"/storageType: "dynamic"for resolvers (an unauthenticated probe must not invoke integrator code); no fallback bucket (a misrouted write is worse than a failed one); drive-transfer resolves storage after the drive reports real name/type/size.Verification
test28/28 tasks,typecheck31/31,lint20/20,build14/14,prettier-check,test:quality,test:scripts,knip,size,vocab:check,docs:api-sync:check— all exit 0 (raw exit codes viartk proxy).response-contract.test.ts,handler-extended.test.ts, and all public-api pins pass with additive-only changes (+getDownloadUrlon server; core.51→52; react 27→36; preact/next inherit).prop-getters-override-contract.test.ts(21),error-exports.test.ts(identity, not just names),download-url.test.ts(5),presign-response-hook.test.ts(11),storage-routing.test.ts(13+).Known follow-ups (deliberately out of scope)
@types/nodefloat also reddens@upupjs/core+@upupjs/storybook-configtest fixtures (Uint8Array<ArrayBufferLike>vsBlobPart) andapps/next-example(TS2305). After this merges, rebase chore(deps): bump nanoid from 3.3.11 to 3.3.16 #351 and fix the residue.apps/e2e-test/.../ingestion-verification.spec.ts:118, AI-thumbs ↔$ai_generationcorrelation) — 3 nights running, uninvestigated. With7ffe2891merged, the next scheduled red will finally file its tracking issue.FileRow0% progress gate.UploadErrorFromResponseArgsdeliberately not exported (structural typing covers documented usage).Issues #337–#344 should be closed when this reaches
master(auto-close keywords don't fire on adevmerge).