@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20

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 (168) hide show
  1. package/api/reap.ts +17 -0
  2. package/cli.js +6 -0
  3. package/client/finding-link.js +305 -0
  4. package/client/linked-findings.d.ts +1 -0
  5. package/client/linked-findings.js +111 -0
  6. package/common/bundle-metadata.d.ts +10 -0
  7. package/common/bundle-metadata.js +177 -0
  8. package/common/bundle-reasons.d.ts +2 -0
  9. package/common/bundle-reasons.js +21 -0
  10. package/common/bundle-sources.d.ts +3 -0
  11. package/common/bundle-sources.js +284 -0
  12. package/common/bundle-stats.js +41 -0
  13. package/common/bundle-tabs.js +1 -0
  14. package/common/code-language.js +36 -0
  15. package/common/default-scan-models.ts +30 -0
  16. package/common/finding-id.js +47 -0
  17. package/common/github-pr.ts +56 -0
  18. package/common/managed/comments.ts +34 -0
  19. package/common/managed/permissions.ts +35 -0
  20. package/common/managed/report-content.ts +42 -0
  21. package/common/managed/report-filter.ts +108 -0
  22. package/common/managed/roles.ts +28 -0
  23. package/common/managed/routes.d.ts +2 -0
  24. package/common/managed/routes.js +121 -0
  25. package/common/managed/scan-models.ts +6 -0
  26. package/common/managed/triage.ts +83 -0
  27. package/common/save-error-reason.ts +20 -7
  28. package/common/scan-server.ts +13 -0
  29. package/common/server-info.ts +33 -0
  30. package/common/utf8.d.ts +3 -0
  31. package/common/utf8.js +45 -0
  32. package/out/brotli-fallback.js +3 -3
  33. package/out/client-managed-import.js +81 -0
  34. package/out/client-managed.js +110 -0
  35. package/out/client-sync.js +16 -13
  36. package/out/graph.js +30 -4
  37. package/out/index.html +55 -8
  38. package/out/prism.js +2 -2
  39. package/out/stasis.svg +45 -0
  40. package/out/terminal.js +273 -39
  41. package/out/view.css +1 -1
  42. package/out/view.js +198 -62
  43. package/package.json +179 -55
  44. package/report/index.js +254 -0
  45. package/report/src/finding-id.js +80 -0
  46. package/report/src/finding.js +312 -0
  47. package/report/src/labels.js +33 -0
  48. package/report/src/md-structure.js +471 -0
  49. package/report/src/md-text.js +167 -0
  50. package/report/src/meta.js +76 -0
  51. package/report/src/parse-codex.js +147 -0
  52. package/report/src/parse-deepsec.js +197 -0
  53. package/report/src/parse-deepview-fields.js +375 -0
  54. package/report/src/parse-deepview-md.js +185 -0
  55. package/report/src/parse-md-id.js +137 -0
  56. package/report/src/parse-md.js +322 -0
  57. package/report/src/parse-piolium-id.js +79 -0
  58. package/report/src/parse-piolium-rows.js +131 -0
  59. package/report/src/parse-piolium-tokens.js +175 -0
  60. package/report/src/parse-piolium.js +400 -0
  61. package/report/src/security.js +63 -0
  62. package/report/src/utf8.js +21 -0
  63. package/report/src/write-md-finding.js +273 -0
  64. package/report/src/write-md.js +291 -0
  65. package/server-common/database-config.ts +16 -0
  66. package/server-common/initialize.ts +18 -0
  67. package/server-common/npm-advisories.ts +101 -0
  68. package/{server → server-common}/origin.ts +5 -5
  69. package/server-common/reap.ts +48 -0
  70. package/server-common/scan-config.ts +19 -0
  71. package/server-common/standalone.ts +29 -0
  72. package/server-common/storage-log.ts +34 -0
  73. package/server-common/vercel-blob.ts +110 -0
  74. package/server-e2e/app.ts +485 -0
  75. package/{server → server-e2e}/auth.ts +5 -1
  76. package/{server → server-e2e}/bus-receiver.ts +9 -8
  77. package/{server → server-e2e}/cli.js +9 -4
  78. package/{server → server-e2e}/config.ts +54 -39
  79. package/{server → server-e2e}/db-neon.ts +2 -2
  80. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  81. package/{server → server-e2e}/db-stmt.ts +2 -2
  82. package/{server → server-e2e}/db.ts +96 -135
  83. package/{server → server-e2e}/http.ts +110 -12
  84. package/{server → server-e2e}/hub.ts +44 -14
  85. package/server-e2e/index.ts +17 -0
  86. package/server-e2e/lifecycle.ts +95 -0
  87. package/{server → server-e2e}/neon-driver.ts +2 -2
  88. package/{server → server-e2e}/npm-proxy.ts +11 -144
  89. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  90. package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
  91. package/{server → server-e2e}/objstore/blob.ts +24 -9
  92. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  93. package/{server → server-e2e}/objstore/handlers.ts +19 -20
  94. package/{server → server-e2e}/objstore/init.ts +53 -27
  95. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  96. package/server-e2e/objstore/rest-deny.ts +28 -0
  97. package/server-e2e/objstore/rest-mint.ts +224 -0
  98. package/{server → server-e2e}/objstore/rest.ts +110 -93
  99. package/{server → server-e2e}/objstore/sign.ts +105 -0
  100. package/{server → server-e2e}/objstore/store-neon.ts +10 -14
  101. package/{server → server-e2e}/objstore/store.ts +98 -118
  102. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  103. package/{server → server-e2e}/peer.ts +7 -9
  104. package/{server → server-e2e}/pubsub.ts +29 -36
  105. package/{server → server-e2e}/sign.ts +12 -14
  106. package/{server → server-e2e}/sse-server.ts +105 -73
  107. package/{server → server-e2e}/sse-session.ts +30 -16
  108. package/{server → server-e2e}/static.ts +36 -29
  109. package/server-e2e/sync-handlers.ts +408 -0
  110. package/{server → server-e2e}/util.ts +9 -0
  111. package/{server → server-e2e}/ws-server.ts +29 -23
  112. package/server-managed/activity.ts +231 -0
  113. package/server-managed/avatar-store.ts +51 -0
  114. package/server-managed/blob-store.ts +66 -0
  115. package/server-managed/blob-vercel.ts +125 -0
  116. package/server-managed/brotli.ts +10 -0
  117. package/server-managed/bundle-cache.ts +185 -0
  118. package/server-managed/bundle-catalog.ts +29 -0
  119. package/server-managed/bundle-store.ts +28 -0
  120. package/server-managed/bundle-summary-cache.ts +97 -0
  121. package/server-managed/bundle.ts +39 -0
  122. package/server-managed/cache-storage.ts +40 -0
  123. package/server-managed/cli.js +13 -0
  124. package/server-managed/combined.ts +46 -0
  125. package/server-managed/comments.ts +151 -0
  126. package/server-managed/config.ts +144 -0
  127. package/server-managed/content-access.ts +15 -0
  128. package/server-managed/crypto.ts +25 -0
  129. package/server-managed/db-methods.ts +1330 -0
  130. package/server-managed/db-neon.ts +171 -0
  131. package/server-managed/db-schema.ts +203 -0
  132. package/server-managed/db-table-names.ts +22 -0
  133. package/server-managed/db.ts +109 -0
  134. package/server-managed/github-app.ts +332 -0
  135. package/server-managed/github-metadata.ts +65 -0
  136. package/server-managed/github-oauth.ts +215 -0
  137. package/server-managed/github-pulls.ts +115 -0
  138. package/server-managed/http-response.ts +18 -0
  139. package/server-managed/http.ts +2043 -0
  140. package/server-managed/import-triage.ts +48 -0
  141. package/server-managed/index.ts +135 -0
  142. package/server-managed/public-workspace.ts +150 -0
  143. package/server-managed/repo-path.ts +21 -0
  144. package/server-managed/report-migration.ts +35 -0
  145. package/server-managed/report-query.ts +4 -0
  146. package/server-managed/report-response.ts +16 -0
  147. package/server-managed/report-sources.ts +154 -0
  148. package/server-managed/repository-discovery.ts +82 -0
  149. package/server-managed/repository-policy.ts +25 -0
  150. package/server-managed/session.ts +78 -0
  151. package/server-managed/slugs.ts +38 -0
  152. package/server-managed/sql-postgres.ts +30 -0
  153. package/server-managed/sql.ts +61 -0
  154. package/server-managed/static.ts +28 -0
  155. package/server-managed/storage.ts +34 -0
  156. package/server-managed/team-catalog.ts +7 -0
  157. package/server-managed/team-feed.ts +128 -0
  158. package/server-managed/team-reports.ts +156 -0
  159. package/server-managed/triage-response.ts +16 -0
  160. package/server-managed/uploads.ts +47 -0
  161. package/server-managed/workspace-shares.ts +150 -0
  162. package/server.ts +50 -0
  163. package/server/index.ts +0 -481
  164. package/server/lifecycle.ts +0 -204
  165. package/server/sync-handlers.ts +0 -327
  166. /package/{server → server-e2e}/config.example.json +0 -0
  167. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  168. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,101 @@
1
+ import { Buffer } from 'node:buffer'
2
+
3
+ const UPSTREAM_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'
4
+ const RESPONSE_BODY_LIMIT = 4 * 1024 * 1024
5
+ export const NPM_ADVISORIES_TIMEOUT_MS = 30_000
6
+
7
+ export async function fetchNpmAdvisories(body: Buffer, signal: AbortSignal, debug = false): Promise<{ status: number; body: unknown }> {
8
+ let upstream: Response
9
+ try {
10
+ upstream = await fetch(UPSTREAM_URL, {
11
+ method: 'POST',
12
+ // Force JSON — the bulk endpoint requires it. Drop every
13
+ // client-supplied header to keep an upstream fingerprint from
14
+ // leaking through (cookies, auth, custom UA, ...). The
15
+ // registry's bulk endpoint doesn't need any of them for a
16
+ // public lookup.
17
+ headers: { 'content-type': 'application/json', 'accept': 'application/json' },
18
+ // Re-wrap as a plain Uint8Array — Buffer's underlying
19
+ // ArrayBufferLike type doesn't satisfy fetch's BodyInit
20
+ // narrowing (it can't statically rule out SharedArrayBuffer),
21
+ // but a copy through Uint8Array is zero-cost in practice and
22
+ // unambiguously typed.
23
+ body: new Uint8Array(body),
24
+ signal,
25
+ })
26
+ } catch (err: unknown) {
27
+ if (debug) console.warn('npm-advisories upstream error:', err)
28
+ return { status: 502, body: { error: 'upstream-unreachable' } }
29
+ }
30
+ // Assert JSON on the upstream body. The Content-Type header is
31
+ // unreliable (Cloudflare in front of registry.npmjs.org strips it
32
+ // from some responses; a captive portal / WAF can declare HTML on
33
+ // a body that's actually JSON or vice-versa), so we don't lean on
34
+ // it — instead we buffer the body and parse. A successful
35
+ // JSON.parse is the strongest guarantee we can hand the UI's
36
+ // `await res.json()`. Buffering is bounded by
37
+ // `RESPONSE_BODY_LIMIT`; the advisories endpoint's payloads sit
38
+ // well under that.
39
+ const upstreamContentType = upstream.headers.get('content-type') ?? ''
40
+ let buffered: Buffer | null
41
+ try {
42
+ buffered = await readUpstreamBody(upstream)
43
+ } catch (err: unknown) {
44
+ if (debug) console.warn('npm-advisories upstream body error:', err)
45
+ return { status: 502, body: { error: 'upstream-unreachable' } }
46
+ }
47
+ if (buffered === null) {
48
+ if (debug) console.warn(`npm-advisories upstream too large: status=${upstream.status}`)
49
+ return { status: 502, body: { error: 'upstream-too-large', upstreamStatus: upstream.status } }
50
+ }
51
+ // Treat the body as UTF-8 — `JSON.parse` operates on a string and
52
+ // the registry's responses are always UTF-8 in practice. A
53
+ // non-UTF-8 byte sequence still decodes (with U+FFFD
54
+ // substitution); the subsequent JSON.parse fails and routes
55
+ // through the error branch.
56
+ const text = buffered.toString('utf8')
57
+ let parsed: unknown
58
+ try {
59
+ parsed = JSON.parse(text)
60
+ } catch {
61
+ if (debug) console.warn(`npm-advisories upstream non-JSON: status=${upstream.status} ct=${upstreamContentType || '<none>'} bytes=${buffered.byteLength}`)
62
+ return { status: 502, body: {
63
+ error: 'upstream-not-json',
64
+ upstreamStatus: upstream.status,
65
+ upstreamContentType: upstreamContentType || null,
66
+ } }
67
+ }
68
+ return { status: upstream.status, body: parsed }
69
+ }
70
+
71
+ // Buffer the upstream response body up to RESPONSE_BODY_LIMIT.
72
+ // Returns null if the cap is exceeded (caller maps to a 502
73
+ // `upstream-too-large`), or the cumulative Buffer otherwise. A
74
+ // transport error mid-read (e.g. AbortSignal fired by the deadline
75
+ // timer or by `req` close) throws — the caller catches it.
76
+ //
77
+ // `finally { reader.cancel() }` is load-bearing on the error and
78
+ // cap-exceeded paths: leaving the reader locked to the body holds
79
+ // the underlying undici TCP socket out of the connection pool until
80
+ // GC, and the cap-exceeded path explicitly needs to tear the
81
+ // transfer down so we don't keep buffering bytes we'll never use.
82
+ // On the clean-drain path (done:true), cancel() is a no-op.
83
+ async function readUpstreamBody(upstream: Response): Promise<Buffer | null> {
84
+ if (!upstream.body) return Buffer.alloc(0)
85
+ const reader = upstream.body.getReader()
86
+ const chunks: Uint8Array[] = []
87
+ let received = 0
88
+ try {
89
+ for (;;) {
90
+ const { done, value } = await reader.read()
91
+ if (done) break
92
+ if (!value) continue
93
+ received += value.byteLength
94
+ if (received > RESPONSE_BODY_LIMIT) return null
95
+ chunks.push(value)
96
+ }
97
+ return Buffer.concat(chunks)
98
+ } finally {
99
+ try { await reader.cancel() } catch {}
100
+ }
101
+ }
@@ -1,8 +1,8 @@
1
- // Same-origin gate for the WS upgrade and REST data plane. We don't
2
- // support cross-origin browser clients, so any Origin header present
3
- // on an incoming request MUST match the server's own host (derived
4
- // from `req.headers.host`, or from `X-Forwarded-Host` /
5
- // `X-Forwarded-Proto` when a trusted reverse proxy is in front).
1
+ // Same-origin gate, shared by the e2e and managed servers (server-common).
2
+ // We don't support cross-origin browser clients, so any Origin header present
3
+ // on an incoming request MUST match the server's own host (derived from
4
+ // `req.headers.host`, or from `X-Forwarded-Host` / `X-Forwarded-Proto` when a
5
+ // trusted reverse proxy is in front).
6
6
  //
7
7
  // Why "present-must-match" rather than "always required":
8
8
  // - Browser WebSocket handshakes always carry Origin (RFC 6455), so a
@@ -0,0 +1,48 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import type { IncomingMessage, ServerResponse } from 'node:http'
3
+
4
+ type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
5
+ type Reapers = Record<string, () => Promise<unknown>>
6
+ type Options = { secret?: string; isShuttingDown?: () => boolean }
7
+
8
+ // Start every cleanup and wait for all of them, including when one fails.
9
+ // The caller must not close storage or return success with work still running.
10
+ export async function runReapers(reapers: Reapers): Promise<void> {
11
+ const results = await Promise.allSettled(Object.values(reapers).map(reap => Promise.resolve().then(reap)))
12
+ const errors = results.filter(result => result.status === 'rejected').map(result => result.reason)
13
+ if (errors.length > 0) throw new AggregateError(errors, 'Cleanup failed')
14
+ }
15
+
16
+ export function createReapHandler(reapers: Reapers, { secret = process.env['CRON_SECRET'], isShuttingDown = () => false }: Options = {}) {
17
+ const key = randomBytes(32)
18
+ const hash = (value: string) => createHmac('sha256', key).update(value).digest()
19
+ const expected = secret ? hash(`Bearer ${secret}`) : null
20
+ return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
21
+ const send = (status: number, body: object, headers = {}) => {
22
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers })
23
+ res.end(JSON.stringify(body))
24
+ }
25
+ const authorization = req.headers.authorization
26
+ if (req.headers['x-deepview-share'] !== undefined) { send(403, { error: 'share-scope-required' }); return }
27
+ if (!expected || typeof authorization !== 'string' || !timingSafeEqual(hash(authorization), expected)) {
28
+ send(401, { error: 'unauthorized' }); return
29
+ }
30
+ if (req.method !== 'GET') { send(405, { error: 'method-not-allowed' }, { allow: 'GET' }); return }
31
+ if (isShuttingDown()) { send(503, { error: 'shutting-down' }); return }
32
+ const startedAt = Date.now()
33
+ try {
34
+ await runReapers(reapers)
35
+ send(200, { ok: true, reaped: Object.keys(reapers), ms: Date.now() - startedAt })
36
+ } catch (err) {
37
+ console.error('reap failed:', err)
38
+ send(500, { error: 'reap-failed' })
39
+ }
40
+ }
41
+ }
42
+
43
+ // Only top-level servers compose this route. Domain routers stay unaware of
44
+ // other modes, and cleanup uses the stores those servers have already opened.
45
+ export function withReap(next: Handler, reapers: Reapers, options: Options = {}): Handler {
46
+ const reap = createReapHandler(reapers, options)
47
+ return (req, res) => req.url?.split('?', 1)[0] === '/api/reap' ? reap(req, res) : next(req, res)
48
+ }
@@ -0,0 +1,19 @@
1
+ import { normalizeScanServer } from '../common/scan-server.ts'
2
+
3
+ export function configuredScanServer(value: string | undefined): string | null {
4
+ if (!value?.trim()) return null
5
+ const server = normalizeScanServer(value)
6
+ if (!server) throw new Error('DEEPVIEW_SCAN_SERVER must be an absolute HTTP(S) URL without credentials, query, or fragment')
7
+ return server
8
+ }
9
+
10
+ // Apply at serve time, before compression and ETag generation. Packaged HTML
11
+ // stays strict and does not bake in the build machine's configuration.
12
+ export function scanServerHtml(html: string, server: string | null, { advertise = false } = {}): string {
13
+ if (!server) return html
14
+ const origin = new URL(server).origin
15
+ const result = html.replace(/(connect-src 'self')(?=[;"])/u, `$1 ${origin}`)
16
+ if (!advertise) return result
17
+ const content = server.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
18
+ return result.replace('</head>', `<meta name="deepview-scan-server" content="${content}">\n</head>`)
19
+ }
@@ -0,0 +1,29 @@
1
+ import type { Server } from 'node:http'
2
+
3
+ // Only CLI entry points own process signals and termination. Embedded servers
4
+ // expose async disposal and error events to their host instead.
5
+ export function startServer(server: Server, config: { port: number; host: string }): void {
6
+ let shuttingDown = false
7
+ let exitCode = 0
8
+ function shutdown(code: number): void {
9
+ if (code !== 0) exitCode = code
10
+ if (shuttingDown) return
11
+ shuttingDown = true
12
+ console.log('Shutting down…')
13
+ void server[Symbol.asyncDispose]().catch(err => {
14
+ console.error('Shutdown failed:', err)
15
+ exitCode = 1
16
+ }).finally(() => process.exit(exitCode))
17
+ }
18
+ function onSignal(): void { shutdown(0) }
19
+ function onError(err: unknown): void {
20
+ console.error('Server error:', err)
21
+ shutdown(1)
22
+ }
23
+ server.on('error', onError)
24
+ process.on('SIGINT', onSignal)
25
+ process.on('SIGTERM', onSignal)
26
+ process.on('unhandledRejection', onError)
27
+ process.on('uncaughtException', onError)
28
+ server.listen(config.port, config.host)
29
+ }
@@ -0,0 +1,34 @@
1
+ import { dirname, resolve } from 'node:path'
2
+
3
+ type DatabaseConfig = { dbPath: string; neonUrl?: string | null }
4
+
5
+ function databaseLocation({ dbPath, neonUrl }: DatabaseConfig): string {
6
+ if (!neonUrl) return `SQLite ${dbPath === ':memory:' ? dbPath : resolve(dbPath)}`
7
+ // Report the destination, never URL credentials or query-string secrets.
8
+ try {
9
+ const url = new URL(neonUrl)
10
+ return `Neon Postgres ${url.host}${url.pathname}`
11
+ } catch { return 'Neon Postgres' }
12
+ }
13
+
14
+ export function e2eStorageLines(config: DatabaseConfig & { objstoreDir: string }): string[] {
15
+ return [
16
+ ` E2E database: ${databaseLocation(config)}`,
17
+ ` E2E objects: ${config.neonUrl ? 'Vercel Blob (private), {workspaceTag}/' : resolve(config.objstoreDir)}`,
18
+ ` E2E staging: ${config.neonUrl ? 'Vercel Blob (private), {workspaceTag}/.staging/' : resolve(config.objstoreDir, '{workspaceTag}', '.staging')}`,
19
+ ]
20
+ }
21
+
22
+ export function managedStorageLines(config: DatabaseConfig): string[] {
23
+ const dir = dirname(config.dbPath)
24
+ const location = (path: string) => config.neonUrl ? `Vercel Blob (private), .managed/${path}/` : resolve(dir, path)
25
+ return [
26
+ ` Managed database: ${databaseLocation(config)}`,
27
+ ` Managed reports: ${location('reports')}`,
28
+ ` Managed bundles: ${location('bundles')}`,
29
+ ` Managed avatars: ${location('avatars')}`,
30
+ ` Managed bundle cache: ${location('cache/bundles')}`,
31
+ ` Managed report sources cache: ${location('cache/report-sources')}`,
32
+ ...(config.neonUrl ? [` Managed upload parts: ${location('uploads')}`] : []),
33
+ ]
34
+ }
@@ -0,0 +1,110 @@
1
+ // Shared optional Vercel Blob SDK boundary for both server modes.
2
+ import type { Readable } from 'node:stream'
3
+ import type { Buffer } from 'node:buffer'
4
+
5
+ // Minimal structural shape of the bits of `@vercel/blob` we use.
6
+ // Shared so the optional peer dep doesn't have to type-resolve
7
+ // for SQLite-only deployments. Real type details live in the
8
+ // installed package; the fields/parameters we touch here are stable
9
+ // per the SDK v2 public surface.
10
+ //
11
+ // `put` accepts the SDK's full `PutBody` union (string | Readable |
12
+ // Buffer | Blob | ArrayBuffer | ReadableStream | File). We only ever
13
+ // pass a Node `PassThrough` (a Readable), but the wider type lets
14
+ // callers reuse this signature for future buffer/blob bodies without
15
+ // type gymnastics. `copy` accepts `allowOverwrite` — REQUIRED for
16
+ // version bumps since the live pathname is reused on re-upload.
17
+
18
+ type VercelBlobBody = Readable | Buffer | string | Blob | ArrayBuffer | ReadableStream<Uint8Array>
19
+ export type VercelBlobSdk = {
20
+ put: (
21
+ pathname: string,
22
+ body: VercelBlobBody,
23
+ options: {
24
+ access: 'private' | 'public'
25
+ addRandomSuffix?: boolean
26
+ allowOverwrite?: boolean
27
+ contentType?: string
28
+ token?: string
29
+ multipart?: boolean
30
+ abortSignal?: AbortSignal
31
+ cacheControlMaxAge?: number
32
+ },
33
+ ) => Promise<{ url: string; pathname: string }>
34
+ head: (
35
+ pathname: string,
36
+ options?: { token?: string; abortSignal?: AbortSignal },
37
+ ) => Promise<{ size: number; pathname: string; url: string }>
38
+ get: (
39
+ pathname: string,
40
+ options: {
41
+ access: 'private' | 'public'
42
+ token?: string
43
+ useCache?: boolean
44
+ abortSignal?: AbortSignal
45
+ },
46
+ ) => Promise<{
47
+ statusCode: 200 | 304
48
+ stream: ReadableStream<Uint8Array> | null
49
+ blob: { size: number | null }
50
+ } | null>
51
+ copy: (
52
+ fromPathname: string,
53
+ toPathname: string,
54
+ options: {
55
+ access: 'private' | 'public'
56
+ addRandomSuffix?: boolean
57
+ allowOverwrite?: boolean
58
+ token?: string
59
+ contentType?: string
60
+ cacheControlMaxAge?: number
61
+ },
62
+ ) => Promise<{ url: string; pathname: string }>
63
+ del: (
64
+ urlOrPathname: string | string[],
65
+ options?: { token?: string; abortSignal?: AbortSignal },
66
+ ) => Promise<void>
67
+ list: (options: {
68
+ prefix?: string
69
+ cursor?: string
70
+ limit?: number
71
+ mode?: 'expanded' | 'folded'
72
+ token?: string
73
+ }) => Promise<{
74
+ // `uploadedAt` (a Date per the SDK v2 surface) is the blob's
75
+ // creation time — used by the reaper's GC grace window. Optional
76
+ // in the type because the `folded` listing path ignores it (only
77
+ // `listLiveBlobs` reads it, and it uses the default expanded mode).
78
+ blobs: Array<{ pathname: string; size: number; uploadedAt?: Date | string | number }>
79
+ folders?: string[]
80
+ cursor?: string
81
+ hasMore: boolean
82
+ }>
83
+ }
84
+
85
+ // Recognise "blob is gone" errors uniformly across read/write/delete
86
+ // paths so callers can treat them as success (delete) or
87
+ // not-found (read). The SDK exposes BlobNotFoundError as a class with
88
+ // `.name === 'BlobNotFoundError'`; checking the name string avoids
89
+ // importing the class at the top level (which would force the optional
90
+ // peer dep to resolve).
91
+ //
92
+ // Class-name check ONLY — the SDK's internal mapper turns every API
93
+ // `not_found` into BlobNotFoundError-by-name, so a bare-404 transport
94
+ // leak doesn't reach here. A broader `/does not exist|\b404\b/` match
95
+ // is DANGEROUS: it also matches BlobStoreNotFoundError's "This store
96
+ // does not exist.", so a config fault (revoked token, deleted store)
97
+ // would silently surface as every-blob-missing across reads/unlinks,
98
+ // masking the fatal misconfiguration. The tight name check lets
99
+ // BlobStoreNotFoundError / other classes propagate as real exceptions.
100
+ export function isNotFound(err: unknown): boolean {
101
+ if (err == null || typeof err !== 'object') return false
102
+ const name = (err as { name?: unknown }).name
103
+ return typeof name === 'string' && name === 'BlobNotFoundError'
104
+ }
105
+
106
+ export async function loadVercelBlobSdk(): Promise<VercelBlobSdk> {
107
+ // @ts-ignore optional peer dep: '@vercel/blob'
108
+ const mod = (await import('@vercel/blob')) as VercelBlobSdk
109
+ return mod
110
+ }