All files / src/s3 attach.ts

100% Statements 30/30
97.36% Branches 37/38
100% Functions 2/2
100% Lines 30/30

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150                                                                            2x                                                                     175x 175x 175x 175x 175x 175x 9x 9x 175x 175x 175x 175x                                                                 2x 482x 482x 482x 169x   169x 169x 482x         482x 482x 482x   7x 7x 482x 482x 482x                   2x  
/**
 * The one place a credential — of either kind — is attached to an outbound
 * worker message.
 *
 * Remote sources are constructed all over the main thread — the URL box, the
 * compare card, a session config being resolved, a track row being restored
 * from storage, the examples list. Making each of those look up a key would be
 * a dozen call sites that have to remember to, and the one that forgot would
 * present as a single private track failing while the rest of the session
 * worked.
 *
 * So none of them do. They build a plain `{ kind: 'remote', url }` exactly as
 * before, and the credential is stamped on at the boundary the message crosses
 * — `worker/client.ts` and `annotations/worker/client.ts`, one `postMessage`
 * each. That is also the last moment the answer can be right: a key edited
 * after a track was constructed is picked up, because the lookup happens when
 * the request goes out rather than when the object was made.
 *
 * The two worker protocols disagree about almost everything and agree about
 * this: the field is called `source`, it is a discriminated union, and the
 * remote arm has a `url`. That is all this needs to know, which is why one
 * function serves both.
 *
 * ## Two credentials, stamped independently
 *
 * A bucket key **signs** and everything else on this road is **a header**
 * (SPECS §24.7): a Google API key for the Drive and GCS JSON APIs, a bearer
 * token for a signed-in account, a token from an institutional portal. They come
 * from different stores, they are matched to a URL by different rules, and a URL
 * can want one, both or neither — a `gs://` object under no bucket scope takes a
 * header and no signature, and a private prefix on S3 takes the reverse. So they
 * are looked up separately and stamped separately, and neither one being present
 * is a reason to skip the other. That is the bug this shape exists to make
 * impossible: an earlier draft returned as soon as it found a credential, which
 * would have sent the API key on the files that did not need it and left it off
 * the ones that did.
 */
 
import { accessHeadersFor, credentialFor } from './credentials'
import type { S3Credential } from './sigv4'
 
/** The shape both protocols' remote arm shares. */
interface Remoteish {
  kind: string
  url?: unknown
  credential?: S3Credential
  /** Static headers for this URL: an API key, a bearer token. */
  headers?: Record<string, string>
  /**
   * The sidecar half of a two-file format, when the source has one.
   *
   * Only a HiC-Pro matrix carries this today: the counts are in one file and
   * the bin coordinates in another, and `#bins=` names the second (§3.9). It is
   * a source in its own right, under its own key, and that is the whole reason
   * it needs its own line below.
   */
  bins?: Remoteish
}
 
/**
 * That source with whatever its URL needs on it, or the same object when it
 * needs nothing.
 *
 * Split out of {@link withAccess} because a source can carry a second source,
 * and both have to be asked separately rather than the outer one standing for
 * the pair.
 *
 * A source that already carries a field is left holding it: a caller that knows
 * its own — the bucket browser opening a file it just listed — must not have it
 * replaced by a lookup that could, in principle, resolve differently. Asked per
 * field, so a source built with a credential still picks up the headers its host
 * wants.
 */
function stamped(source: Remoteish): Remoteish {
  if (source.kind !== 'remote' || typeof source.url !== 'string') return source
  const credential = source.credential === undefined ? credentialFor(source.url) : null
  const found = source.headers === undefined ? accessHeadersFor(source.url) : {}
  const headers = Object.keys(found).length === 0 ? null : found
  if (credential === null && headers === null) return source
  return {
    ...source,
    ...(credential === null ? {} : { credential }),
    ...(headers === null ? {} : { headers }),
  }
}
 
/**
 * `message`, with a credential and a header bag on its source — and on that
 * source's sidecar.
 *
 * Returns the message untouched — the same object, not a copy — when there is
 * nothing to add, which is every request in a session that has configured no
 * buckets. The common path allocates nothing and reads `localStorage` only when
 * the message actually carries a remote URL.
 *
 * ## Why the sidecar is asked separately
 *
 * This used to stamp `message.source` and stop, and it returned early the
 * moment the outer source was already signed or was not remote. A HiC-Pro pair
 * is one source with another inside it, so both guards were wrong for it: the
 * matrix went out signed and **the bin file went out bare**, and the half
 * carrying every coordinate came back 403 while the half carrying the counts
 * arrived. What that looks like from the outside is a private matrix that opens
 * and then fails to be a matrix, which names neither file.
 *
 * The two halves are looked up **independently**, on their own URLs, because
 * `#bins=` exists precisely so the two can live under unrelated keys — a
 * matrix in one prefix and a shared bin grid in another is the arrangement the
 * fragment was added for. Signing the second with the first's key would be a
 * request scoped to a bucket it is not for.
 *
 * `app/Session.ts` also stamps the bins as it builds them, which is not
 * redundant: this is the last moment the answer can be right, so a key added
 * *after* a pair was opened is picked up here on the next request. The two
 * compose safely — {@link stamped} declines to replace a field a source already
 * carries, so whichever runs first wins and neither overwrites the other.
 */
export function withAccess<T>(message: T): T {
  if (typeof message !== 'object' || message === null) return message
  const source = (message as { source?: unknown }).source
  if (typeof source !== 'object' || source === null) return message
  const remote = source as Remoteish
 
  const outer = stamped(remote)
  const sidecar =
    typeof remote.bins === 'object' && remote.bins !== null ? stamped(remote.bins) : undefined
 
  // Identity, not deep equality: `stamped` returns its argument unchanged when
  // it has nothing to add, so an untouched pair is two reference comparisons
  // and no allocation at all.
  const outerSame = outer === remote
  const sidecarSame = sidecar === undefined || sidecar === remote.bins
  if (outerSame && sidecarSame) return message
 
  return {
    ...message,
    source: sidecarSame ? outer : { ...outer, bins: sidecar },
  }
}
 
/**
 * The name this had when a bucket key was the only credential there was.
 *
 * Kept because it reads correctly at the call sites that predate the header bag
 * and in the tests written against them, and because renaming a function is not
 * a reason to touch a file. New code says {@link withAccess}, which is what it
 * does.
 */
export const withCredential = withAccess