Skip to content

Issue batch: migration-feedback fixes (#337-#344) + nightly issue-arm and #351 compat - #353

Merged
AminDhouib merged 14 commits into
devfrom
fix/issue-batch-2026-08-11
Aug 12, 2026
Merged

Issue batch: migration-feedback fixes (#337-#344) + nightly issue-arm and #351 compat#353
AminDhouib merged 14 commits into
devfrom
fix/issue-batch-2026-08-11

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

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

Issue Resolution Commits
#344 Migration guide: prominent warning that v1 server-side maxFileSize/accept validation silently stops applying in v3, with a working onBeforeUpload example; cross-linked from the server-auth guide c90ef25f
#340 (docs half) Migration gotcha: v3's default sources adds camera/microphone/screen; pass sources={['local','url']} for the v1 surface. The code half (hide capture sources that can never satisfy allowedFileTypes) is assessed and posted on the issue as a scoped 3.2 candidate c90ef25f
#342 Restriction failures via file input / dropzone drop / paste no longer leak unhandled promise rejections (census found the bug wider than reported); restriction-failed event unchanged 34e132da, f014a694
#341 One override contract for all three headless prop getters (spread first, owned functional keys after, handlers composed, style merged); fixed a 4th defect the issue missed ('aria-describedby': undefined clobbering); 21 contract tests; documented in the headless guide 6f6b395d
#339 Full error surface (UpupError + 6 subclasses, UpupErrorCode, uploadErrorFromResponse) re-exported from @upupjs/react; preact/next inherit (verified against built bundles with class-identity tests). uploadErrorFromResponse promoted to core's public entry — the documented import was real on neither entry b336ed58, fec4fb29, 92ea13df
#343 getDownloadUrl(config, key, opts?) primitive for existing keys + downloadUrlExpiresIn config knob (per-call → config → 3-day default); non-S3 storage throws UpupConfigError 38f1d732
#338 hooks.onPresignResponse(response, ctx) — one hook, three phases (presign, multipart-init, multipart-sign-part) so proxied-storage deployments can rewrite every URL the client receives; onBeforeUpload throwing an UpupError now surfaces its message+code in the 403 (non-UpupError throws stay generic — no leakage, pinned by test) d177cf19
#337 storage accepts `(ctx) => StorageConfig Promisefor per-request multi-bucket routing;keyStrategyctx gainsmetadata+req`; per-destination S3 client cache
PR #351 unblock (partial) pages-handler body annotated RequestInit['body'] + copied to a plain Uint8Array — typechecks under current AND newer @types/node 0bf51053
Nightly loud-failure arm The tracking-issue steps never worked (no checkout ⇒ gh had no repo context); fixed with GH_REPO env on both arms 7ffe2891

Also: 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):

  • Destination identity only, never credentials — key rotation can't strand in-flight uploads; token bodies are readable base64.
  • Emitted only for the resolver form — static configs produce byte-identical tokens to today.
  • Fail-closed twice: editing sid breaks the HMAC (existing 403); a validly-signed token whose sid doesn't match what the resolver returns → 403 AUTH_DENIED with 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).
  • No unsigned client-supplied storage hint is accepted anywhere. ctx.metadata is documented as attacker-controlled in the types, guide, and API reference.

Related calls: /health reports storage: "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

  • Full local gates at the code HEAD: test 28/28 tasks, typecheck 31/31, lint 20/20, build 14/14, prettier-check, test:quality, test:scripts, knip, size, vocab:check, docs:api-sync:check — all exit 0 (raw exit codes via rtk proxy).
  • Trust-model exploit harness run against real MinIO (not just mocks): 6/6 — oversized-PUT rejection, forged-token 403, client-key ignored, multipart teardown, byte-correct round-trip — with the new storage-resolution code in the path.
  • response-contract.test.ts, handler-extended.test.ts, and all public-api pins pass with additive-only changes (+getDownloadUrl on server; core . 51→52; react 27→36; preact/next inherit).
  • New suites: 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)

  1. PR chore(deps): bump nanoid from 3.3.11 to 3.3.16 #351 is not fully unblocked: dependabot's @types/node float also reddens @upupjs/core + @upupjs/storybook-config test fixtures (Uint8Array<ArrayBufferLike> vs BlobPart) and apps/next-example (TS2305). After this merges, rebase chore(deps): bump nanoid from 3.3.11 to 3.3.16 #351 and fix the residue.
  2. Nightly's remaining red is the PostHog ingestion spec (apps/e2e-test/.../ingestion-verification.spec.ts:118, AI-thumbs ↔ $ai_generation correlation) — 3 nights running, uninvestigated. With 7ffe2891 merged, the next scheduled red will finally file its tracking issue.
  3. Deprecate upup-react-file-uploader on npm with a pointer to @upupjs/* #345 needs maintainer npm auth (exact command on the issue). Swap the base64 PNG logo assets for inline SVG across the framework packages #229 rescoped to the SVG-logo swap. Per-file error state + retry button; compact FileRow hides progress at 0% #352 (new) tracks per-file error/retry UI + the FileRow 0% progress gate.
  4. UploadErrorFromResponseArgs deliberately not exported (structural typing covers documented usage).

Issues #337#344 should be closed when this reaches master (auto-close keywords don't fire on a dev merge).

…#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.
@codesandbox

codesandbox Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant