Skip to content

Commit 1b74526

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(oci): expose streaming and object storage response headers
1 parent 6b6d43f commit 1b74526

2 files changed

Lines changed: 265 additions & 11 deletions

File tree

apps/sim/lib/internal/oci/client.server.test.ts

Lines changed: 212 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -708,26 +708,233 @@ describe('credential-bound OCI client', () => {
708708
expect(JSON.stringify(failure)).not.toContain('authorization')
709709
})
710710

711-
it('returns only selected safe headers and bounded Uint8Array bodies', async () => {
711+
it.each([true, false])('keeps additional response headers opt-in: %s', async (requested) => {
712+
const additionalHeaders = {
713+
'opc-next-cursor': 'opaque/next+cursor==%2F',
714+
'content-length': '3',
715+
'last-modified': 'Sat, 05 Sep 2026 12:00:00 GMT',
716+
'content-md5': 'content-md5==',
717+
'opc-content-md5': 'opc-content-md5==',
718+
'opc-multipart-md5': 'multipart-md5==',
719+
'content-encoding': 'identity',
720+
'content-language': 'en',
721+
'content-disposition': 'attachment; filename="report.csv"',
722+
'cache-control': 'private, max-age=60',
723+
'storage-tier': 'Archive',
724+
'archival-state': 'Restored',
725+
'time-of-archival': '2026-09-06T12:00:00Z',
726+
'version-id': 'opaque-version-id',
727+
'is-delete-marker': 'false',
728+
}
729+
const defaultHeaders = {
730+
'content-type': 'application/octet-stream',
731+
etag: 'etag-1',
732+
'opc-request-id': 'request-1',
733+
}
712734
mocks.secureFetch.mockResolvedValueOnce(
713735
secureResponse({
714736
status: 200,
715737
body: new Uint8Array([1, 2, 3]),
716-
headers: { etag: 'etag-1', 'x-provider-secret': 'hidden' },
738+
headers: { ...additionalHeaders, ...defaultHeaders, 'x-provider-secret': 'hidden' },
717739
})
718740
)
719741
const { client, endpoint } = await createPreparedClient()
720742
const result = await client.request({
721743
endpoint,
722744
method: 'GET',
723745
encodedPath: '/v1/test',
724-
responseHeaders: ['etag'],
746+
responseHeaders: requested
747+
? ['ETAG', ...Object.keys(additionalHeaders).map((name) => name.toUpperCase())]
748+
: undefined,
725749
timeoutMs: 10_000,
726750
maxResponseBytes: 3,
727751
})
728752
expect([...result.body]).toEqual([1, 2, 3])
729-
expect(result.headers.etag).toBe('etag-1')
730-
expect(result.headers).not.toHaveProperty('x-provider-secret')
753+
expect(result.headers).toEqual({
754+
...defaultHeaders,
755+
...(requested ? additionalHeaders : {}),
756+
})
757+
expect(Object.isFrozen(result.headers)).toBe(true)
758+
})
759+
760+
it('retains an opaque next cursor when the message batch is empty', async () => {
761+
const cursor = 'opaque/next+cursor==%2F'
762+
mocks.secureFetch.mockResolvedValueOnce(
763+
secureResponse({
764+
body: '[]',
765+
headers: { 'opc-next-cursor': cursor, 'opc-next-page': 'not-a-message-cursor' },
766+
})
767+
)
768+
const { client, endpoint } = await createPreparedClient()
769+
const result = await client.request({
770+
endpoint,
771+
method: 'GET',
772+
encodedPath: '/v1/messages',
773+
responseHeaders: ['opc-next-cursor'],
774+
timeoutMs: 10_000,
775+
maxResponseBytes: 1024,
776+
})
777+
expect(new TextDecoder().decode(result.body)).toBe('[]')
778+
expect(result.headers).toEqual({ 'opc-next-cursor': cursor })
779+
})
780+
781+
it('projects HEAD metadata without applying the body limit to the object size', async () => {
782+
mocks.secureFetch.mockResolvedValueOnce(
783+
secureResponse({
784+
headers: { 'content-length': '1099511627776', 'opc-meta-source': 'head metadata' },
785+
})
786+
)
787+
const { client, endpoint } = await createPreparedClient()
788+
const result = await client.request({
789+
endpoint,
790+
method: 'HEAD',
791+
encodedPath: '/v1/object',
792+
responseHeaders: ['content-length', 'opc-meta-*'],
793+
timeoutMs: 10_000,
794+
maxResponseBytes: 1,
795+
})
796+
expect(result.body.byteLength).toBe(0)
797+
expect(result.headers).toEqual({
798+
'content-length': '1099511627776',
799+
'opc-meta-source': 'head metadata',
800+
})
801+
})
802+
803+
it.each([true, false])(
804+
'projects only explicitly requested object metadata: %s',
805+
async (requested) => {
806+
mocks.secureFetch.mockResolvedValueOnce(
807+
secureResponse({
808+
headers: {
809+
'OPC-Meta-Source': 'Mixed CASE / café',
810+
'opc-meta-empty': '',
811+
'opc-meta-': 'missing suffix',
812+
'x-opc-meta-secret': 'excluded',
813+
'set-cookie': 'excluded=secret',
814+
authorization: 'Bearer excluded-secret',
815+
},
816+
})
817+
)
818+
const { client, endpoint } = await createPreparedClient()
819+
const result = await client.request({
820+
endpoint,
821+
method: 'GET',
822+
encodedPath: '/v1/object',
823+
responseHeaders: requested ? ['OPC-META-*', 'opc-meta-*'] : [],
824+
timeoutMs: 10_000,
825+
maxResponseBytes: 1024,
826+
})
827+
expect(result.headers).toEqual(
828+
requested
829+
? {
830+
'opc-meta-source': 'Mixed CASE / café',
831+
'opc-meta-empty': '',
832+
}
833+
: {}
834+
)
835+
expect(Object.isFrozen(result.headers)).toBe(true)
836+
}
837+
)
838+
839+
it.each([4096, 4097])('bounds projected object metadata entries: %i', async (count) => {
840+
const headers: Record<string, string> = {}
841+
for (let index = 0; index < count; index += 1) {
842+
headers[`opc-meta-${index}`] = ''
843+
}
844+
mocks.secureFetch.mockResolvedValueOnce(secureResponse({ headers }))
845+
const { client, endpoint } = await createPreparedClient()
846+
const pending = client.request({
847+
endpoint,
848+
method: 'GET',
849+
encodedPath: '/v1/object',
850+
responseHeaders: ['opc-meta-*'],
851+
timeoutMs: 10_000,
852+
maxResponseBytes: 1024,
853+
})
854+
if (count === 4096) {
855+
const result = await pending
856+
expect(result.headers).toEqual(headers)
857+
expect(Object.isFrozen(result.headers)).toBe(true)
858+
} else {
859+
await expect(pending).rejects.toMatchObject({
860+
code: 'response_too_large',
861+
message: 'OCI response exceeded the configured limit',
862+
})
863+
}
864+
expect(mocks.secureFetch).toHaveBeenCalledOnce()
865+
})
866+
867+
it.each([65536, 65537])('bounds metadata names and values by UTF-8 bytes: %i', async (bytes) => {
868+
const name = 'opc-meta-test'
869+
const valueBytes = bytes - Buffer.byteLength(name, 'utf8')
870+
const value = 'é'.repeat(Math.floor(valueBytes / 2)) + 'x'.repeat(valueBytes % 2)
871+
expect(Buffer.byteLength(name + value, 'utf8')).toBe(bytes)
872+
mocks.secureFetch.mockResolvedValueOnce(secureResponse({ headers: { [name]: value } }))
873+
const { client, endpoint } = await createPreparedClient()
874+
const pending = client.request({
875+
endpoint,
876+
method: 'GET',
877+
encodedPath: '/v1/object',
878+
responseHeaders: ['opc-meta-*'],
879+
timeoutMs: 10_000,
880+
maxResponseBytes: 1024,
881+
})
882+
if (bytes === 65536) {
883+
expect((await pending).headers).toEqual({ [name]: value })
884+
} else {
885+
await expect(pending).rejects.toMatchObject({
886+
code: 'response_too_large',
887+
message: 'OCI response exceeded the configured limit',
888+
})
889+
}
890+
})
891+
892+
it('applies the metadata byte limit across entries only when requested', async () => {
893+
const headers = {
894+
'opc-meta-first': 'a'.repeat(40_000),
895+
'opc-meta-second': 'b'.repeat(40_000),
896+
}
897+
mocks.secureFetch.mockResolvedValue(secureResponse({ headers }))
898+
const { client, endpoint } = await createPreparedClient()
899+
const request = {
900+
endpoint,
901+
method: 'GET' as const,
902+
encodedPath: '/v1/object',
903+
timeoutMs: 10_000,
904+
maxResponseBytes: 1024,
905+
}
906+
expect((await client.request(request)).headers).toEqual({})
907+
mocks.secureFetch.mockResolvedValueOnce(secureResponse({ headers }))
908+
await expect(
909+
client.request({ ...request, responseHeaders: ['opc-meta-*'] })
910+
).rejects.toMatchObject({
911+
code: 'response_too_large',
912+
message: 'OCI response exceeded the configured limit',
913+
})
914+
})
915+
916+
it.each([
917+
'*',
918+
'opc-*',
919+
'opc-meta-*suffix',
920+
'opc-meta-',
921+
'opc-meta-name',
922+
'set-cookie',
923+
'x-provider-secret',
924+
])('rejects unsupported header selectors before DNS or transport: %s', async (name) => {
925+
const { client, endpoint } = await createPreparedClient()
926+
await expect(
927+
client.request({
928+
endpoint,
929+
method: 'GET',
930+
encodedPath: '/v1/object',
931+
responseHeaders: [name],
932+
timeoutMs: 10_000,
933+
maxResponseBytes: 1024,
934+
})
935+
).rejects.toMatchObject({ code: 'invalid_request' })
936+
expect(mocks.validateUrl).not.toHaveBeenCalled()
937+
expect(mocks.secureFetch).not.toHaveBeenCalled()
731938
})
732939

733940
it('cancels and classifies a success body beyond the operation limit', async () => {

apps/sim/lib/internal/oci/client.server.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ interface OciRequestBase {
6767
readonly headers?: Readonly<Record<string, string>>
6868
readonly timeoutMs: number
6969
readonly maxResponseBytes: number
70+
/** Additional allowlisted headers; `opc-meta-*` selects bounded object metadata. */
7071
readonly responseHeaders?: readonly string[]
7172
readonly signal?: AbortSignal
7273
}
@@ -165,14 +166,33 @@ const SIGNING_CONTROLLED_HEADERS: ReadonlySet<string> = new Set([
165166
'x-content-sha256',
166167
])
167168
const RESPONSE_HEADER_ALLOWLIST: ReadonlySet<string> = new Set([
169+
'archival-state',
170+
'cache-control',
171+
'content-disposition',
172+
'content-encoding',
173+
'content-language',
174+
'content-length',
175+
'content-md5',
168176
'content-type',
169177
'etag',
178+
'is-delete-marker',
179+
'last-modified',
170180
'location',
181+
'opc-content-md5',
182+
'opc-multipart-md5',
183+
'opc-next-cursor',
171184
'opc-next-page',
172185
'opc-request-id',
173186
'opc-work-request-id',
174187
'retry-after',
188+
'storage-tier',
189+
'time-of-archival',
190+
'version-id',
175191
])
192+
const OBJECT_METADATA_HEADER_PREFIX = 'opc-meta-'
193+
const OBJECT_METADATA_HEADER_SELECTOR = 'opc-meta-*'
194+
const MAX_OBJECT_METADATA_HEADERS = 4096
195+
const MAX_OBJECT_METADATA_HEADER_BYTES = 64 * 1024
176196
const RETRYABLE_STATUSES: ReadonlySet<number> = new Set([429, 500, 502, 503, 504])
177197
const RETRYABLE_TRANSPORT_CODES: ReadonlySet<string> = new Set([
178198
'ECONNRESET',
@@ -526,7 +546,11 @@ function validateRequest(request: OciRequest): {
526546
throw new OciClientError('invalid_request')
527547
}
528548
for (const name of request.responseHeaders ?? []) {
529-
if (typeof name !== 'string' || !RESPONSE_HEADER_ALLOWLIST.has(name.toLowerCase())) {
549+
if (
550+
typeof name !== 'string' ||
551+
(!RESPONSE_HEADER_ALLOWLIST.has(name.toLowerCase()) &&
552+
name.toLowerCase() !== OBJECT_METADATA_HEADER_SELECTOR)
553+
) {
530554
throw new OciClientError('invalid_request')
531555
}
532556
}
@@ -586,13 +610,36 @@ function selectedResponseHeaders(
586610
response: SecureFetchResponse,
587611
requested: readonly string[]
588612
): Readonly<Record<string, string>> {
589-
const selected = new Set(['content-type', 'etag', 'opc-request-id', ...requested.map(String)])
613+
const selected = new Set([
614+
'content-type',
615+
'etag',
616+
'opc-request-id',
617+
...requested.map((name) => name.toLowerCase()),
618+
])
590619
const result: Record<string, string> = {}
591620
for (const name of selected) {
592-
const normalized = name.toLowerCase()
593-
if (!RESPONSE_HEADER_ALLOWLIST.has(normalized)) continue
594-
const value = response.headers.get(normalized)
595-
if (value !== null) result[normalized] = value
621+
if (!RESPONSE_HEADER_ALLOWLIST.has(name)) continue
622+
const value = response.headers.get(name)
623+
if (value !== null) result[name] = value
624+
}
625+
if (selected.has(OBJECT_METADATA_HEADER_SELECTOR)) {
626+
let count = 0
627+
let bytes = 0
628+
for (const [name, value] of response.headers) {
629+
const normalized = name.toLowerCase()
630+
if (
631+
!normalized.startsWith(OBJECT_METADATA_HEADER_PREFIX) ||
632+
normalized.length === OBJECT_METADATA_HEADER_PREFIX.length
633+
) {
634+
continue
635+
}
636+
count += 1
637+
bytes += Buffer.byteLength(normalized, 'utf8') + Buffer.byteLength(value, 'utf8')
638+
if (count > MAX_OBJECT_METADATA_HEADERS || bytes > MAX_OBJECT_METADATA_HEADER_BYTES) {
639+
throw new OciClientError('response_too_large')
640+
}
641+
result[normalized] = value
642+
}
596643
}
597644
return Object.freeze(result)
598645
}

0 commit comments

Comments
 (0)