Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-file-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@schemaforge/client": minor
---

Add getPresignedFileUrl for authorized presigned file fields. It forwards current token and tenant providers, accepts a caller AbortSignal, prevents redirects and caching, and validates the returned HTTP(S) URL. Proxied file bytes and malformed or unsafe metadata are rejected. Custom implementations of the ForgeClient interface must provide the new method.
24 changes: 24 additions & 0 deletions .github/workflows/client.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Client checks
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
client:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @schemaforge/client test
- run: pnpm build:packages
- run: pnpm typecheck
3 changes: 3 additions & 0 deletions apps/storybook/src/mock-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ export function createMockClient(): ForgeClient {
async getEntity() {
return widgetRows[0]
},
async getPresignedFileUrl() {
throw new Error("File links are not configured in this story")
},
async createEntity() {
return widgetRows[0]
},
Expand Down
15 changes: 15 additions & 0 deletions packages/client/FILE-ACCESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Resolve a presigned file field

`getPresignedFileUrl(schema, entityId, fieldName, { signal })` reads the runtime's existing file-field endpoint with `redirect=false`. Use it only for a field configured with presigned access. It returns the validated absolute HTTP(S) URL and leaves opening or downloading it to the host.

```ts
const url = await client.getPresignedFileUrl('MarketReport', report.id, 'pdf', {
signal: AbortSignal.timeout(15_000),
})
```

The client reads token and active-tenant providers on each request and uses the existing single unauthorized retry callback. A 403 never triggers renewal. File responses must have JSON content type and contain a bounded absolute HTTP(S) URL without credentials or control characters. Redirects are rejected so the metadata request does not follow a file-store redirect with account authorization. Proxied file access requires a separate byte-stream integration.

The host must verify that the account, tenant and selected record still match before using a returned URL. Use an AbortSignal to bound loading and cancel on context changes. The method does not poll, cache or persist URLs, infer output permission from job status, or cancel a worker. Presigned URLs can remain usable until their backend-defined expiry; clearing a browser view does not revoke them.

The additive method is a minor pre-1.0 package change. Applications providing their own full ForgeClient implementation or typed mocks must add this method. Tests operate on the built package with synthetic fetch responses and do not contact an object store.
8 changes: 6 additions & 2 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
"author": "Roland Rodriguez <roland@govcraft.ai>",
"type": "module",
"sideEffects": false,
"files": ["dist"],
"files": [
"dist",
"FILE-ACCESS.md"
],
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand All @@ -20,7 +23,8 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "pnpm build && node --test tests/*.test.mjs"
},
"devDependencies": {
"tsup": "^8.3.0",
Expand Down
28 changes: 26 additions & 2 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface ForgeClient {
describeSchema(name: string): Promise<SchemaView>
listEntities(schema: string, params?: ListEntitiesParams): Promise<ListEntitiesResult>
getEntity(schema: string, id: string): Promise<EntityRow>
/** Resolve a presigned file field; the host owns cancellation and URL use. */
getPresignedFileUrl(schema: string, id: string, field: string, options?: { signal?: AbortSignal }): Promise<string>
createEntity(schema: string, body: Record<string, unknown>): Promise<EntityRow>
updateEntity(schema: string, id: string, body: Record<string, unknown>): Promise<EntityRow>
deleteEntity(schema: string, id: string): Promise<void>
Expand All @@ -87,16 +89,24 @@ export function createForgeClient(config: ForgeClientConfig): ForgeClient {
return fetch(`${base}${path}`, { ...init, headers: buildHeaders(init?.headers as Record<string, string>) })
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
async function request<T>(path: string, init?: RequestInit, requireJson = false): Promise<T> {
let res = await send(path, init)
if (res.status === 401 && config.onUnauthorized) {
const refreshed = await config.onUnauthorized()
if (refreshed) res = await send(path, init)
}
if (res.status === 401) throw new ForgeUnauthorizedError()
if (!res.ok) throw new ForgeApiError(res.status, await res.text())
if (requireJson && res.headers.get("content-type")?.split(";")[0].trim().toLowerCase() !== "application/json") {
await res.body?.cancel()
throw new Error("Expected a presigned file URL response")
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
try { return (await res.json()) as T }
catch (error) {
if (requireJson && error instanceof SyntaxError) throw new Error("Invalid presigned file URL response")
throw error
}
}

function flatten(env: EntityEnvelope): EntityRow {
Expand Down Expand Up @@ -145,6 +155,20 @@ export function createForgeClient(config: ForgeClientConfig): ForgeClient {
),
)
},
async getPresignedFileUrl(schema, id, field, options = {}) {
const value = await request<unknown>(
`${FORGE_PREFIX}/schemas/${encodeURIComponent(schema)}/entities/${encodeURIComponent(id)}/fields/${encodeURIComponent(field)}?redirect=false`,
{ headers: { Accept: "application/json" }, signal: options.signal, redirect: "error", cache: "no-store" },
true,
)
if (!value || typeof value !== "object" || !("url" in value) || typeof value.url !== "string" || value.url.length > 8192 || /[\u0000-\u0020\u007f]/.test(value.url)) {
throw new Error("Invalid presigned file URL response")
}
let url: URL
try { url = new URL(value.url) } catch { throw new Error("Invalid presigned file URL response") }
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("Invalid presigned file URL response")
return url.href
},
async createEntity(schema, body) {
return flatten(
await request<EntityEnvelope>(`${FORGE_PREFIX}/schemas/${encodeURIComponent(schema)}/entities`, {
Expand Down
76 changes: 76 additions & 0 deletions packages/client/tests/files.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createForgeClient, ForgeApiError } from '../dist/index.js'

const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })

test('encodes file identity and uses current token and tenant for each resolution', async t => {
let token = 'synthetic-a', tenant = 'Organization:alpha'
const requests = []
t.mock.method(globalThis, 'fetch', async (url, init) => { requests.push({ url, init }); return json({ url: 'https://files.example.test/report.pdf?signature=synthetic', key: 'not-returned' }) })
const client = createForgeClient({ baseUrl: 'https://api.example.test', getToken: () => token, getActiveTenant: () => tenant })
const signal = new AbortController().signal
assert.equal(await client.getPresignedFileUrl('Report/Archive', 'id?#', 'pdf/file', { signal }), 'https://files.example.test/report.pdf?signature=synthetic')
assert.equal(requests[0].url, 'https://api.example.test/api/v1/forge/schemas/Report%2FArchive/entities/id%3F%23/fields/pdf%2Ffile?redirect=false')
assert.equal(requests[0].init.headers.Authorization, 'Bearer synthetic-a')
assert.equal(requests[0].init.headers['X-Active-Tenant'], tenant)
assert.equal(requests[0].init.headers.Accept, 'application/json')
assert.equal(requests[0].init.signal, signal)
assert.equal(requests[0].init.redirect, 'error')
assert.equal(requests[0].init.cache, 'no-store')
token = 'synthetic-b'; tenant = 'Organization:beta'
await client.getPresignedFileUrl('Report', 'id', 'pdf')
assert.equal(requests.length, 2)
assert.equal(requests[1].init.headers.Authorization, 'Bearer synthetic-b')
assert.equal(requests[1].init.headers['X-Active-Tenant'], tenant)
})

test('refreshes once through the existing host callback and rereads providers', async t => {
let token = 'expired', refreshes = 0
const tokens = []
t.mock.method(globalThis, 'fetch', async (_url, init) => { tokens.push(init.headers.Authorization); return tokens.length === 1 ? json({}, 401) : json({ url: 'https://files.example.test/ready.pdf' }) })
const client = createForgeClient({ getToken: () => token, onUnauthorized: async () => { refreshes++; token = 'renewed'; return token } })
await client.getPresignedFileUrl('Report', 'id', 'pdf')
assert.deepEqual(tokens, ['Bearer expired', 'Bearer renewed']); assert.equal(refreshes, 1)
})

test('denied result access rejects without renewal', async t => {
let refreshes = 0
t.mock.method(globalThis, 'fetch', async () => json({ url: 'https://files.example.test/denied.pdf' }, 403))
const client = createForgeClient({ getToken: () => 'synthetic', onUnauthorized: async () => { refreshes++; return 'renewed' } })
await assert.rejects(client.getPresignedFileUrl('Report', 'id', 'pdf'), error => error instanceof ForgeApiError && error.status === 403)
assert.equal(refreshes, 0)
})

test('rejects malformed or unsafe metadata without echoing its contents', async t => {
let body
t.mock.method(globalThis, 'fetch', async () => json(body))
const client = createForgeClient({ getToken: () => null })
for (body of [null, {}, { url: 1 }, { url: '/relative.pdf' }, { url: 'javascript:alert(1)' }, { url: 'data:text/plain,private' }, { url: 'https://user:password@files.example.test/a' }, { url: 'https://files.example.test/a\nprivate' }, { url: 'https://files.example.test/' + 'a'.repeat(8192) }]) {
await assert.rejects(client.getPresignedFileUrl('Report', 'id', 'pdf'), { message: 'Invalid presigned file URL response' })
}
})

test('refuses a proxied file response before buffering it as metadata', async t => {
let cancelled = false
t.mock.method(globalThis, 'fetch', async () => new Response(new ReadableStream({ cancel() { cancelled = true } }), { headers: { 'Content-Type': 'application/pdf' } }))
const client = createForgeClient({ getToken: () => 'synthetic' })
await assert.rejects(client.getPresignedFileUrl('Report', 'id', 'pdf'), { message: 'Expected a presigned file URL response' })
assert.equal(cancelled, true)
})

test('passes cancellation to transport without returning a late result', async t => {
const controller = new AbortController()
t.mock.method(globalThis, 'fetch', (_url, init) => new Promise((_resolve, reject) => { init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true }) }))
const client = createForgeClient({ getToken: () => 'synthetic' })
const pending = client.getPresignedFileUrl('Report', 'id', 'pdf', { signal: controller.signal })
controller.abort(new Error('Synthetic cancellation'))
await assert.rejects(pending, { message: 'Synthetic cancellation' })
})


test('malformed JSON does not echo server content', async t => {
t.mock.method(globalThis, 'fetch', async () => new Response('private malformed metadata', { headers: { 'Content-Type': 'application/json' } }))
const client = createForgeClient({ getToken: () => 'synthetic' })
await assert.rejects(client.getPresignedFileUrl('Report', 'id', 'pdf'), { message: 'Invalid presigned file URL response' })
})
Loading