diff --git a/.changeset/quiet-file-results.md b/.changeset/quiet-file-results.md new file mode 100644 index 0000000..7a0e828 --- /dev/null +++ b/.changeset/quiet-file-results.md @@ -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. diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml new file mode 100644 index 0000000..4d8a835 --- /dev/null +++ b/.github/workflows/client.yml @@ -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 diff --git a/apps/storybook/src/mock-client.ts b/apps/storybook/src/mock-client.ts index dfffcc5..eddcdd9 100644 --- a/apps/storybook/src/mock-client.ts +++ b/apps/storybook/src/mock-client.ts @@ -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] }, diff --git a/packages/client/FILE-ACCESS.md b/packages/client/FILE-ACCESS.md new file mode 100644 index 0000000..38cb78c --- /dev/null +++ b/packages/client/FILE-ACCESS.md @@ -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. diff --git a/packages/client/package.json b/packages/client/package.json index 219220c..5fe95f5 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -6,7 +6,10 @@ "author": "Roland Rodriguez ", "type": "module", "sideEffects": false, - "files": ["dist"], + "files": [ + "dist", + "FILE-ACCESS.md" + ], "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -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", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index e1e7056..e72bf10 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -65,6 +65,8 @@ export interface ForgeClient { describeSchema(name: string): Promise listEntities(schema: string, params?: ListEntitiesParams): Promise getEntity(schema: string, id: string): Promise + /** Resolve a presigned file field; the host owns cancellation and URL use. */ + getPresignedFileUrl(schema: string, id: string, field: string, options?: { signal?: AbortSignal }): Promise createEntity(schema: string, body: Record): Promise updateEntity(schema: string, id: string, body: Record): Promise deleteEntity(schema: string, id: string): Promise @@ -87,7 +89,7 @@ export function createForgeClient(config: ForgeClientConfig): ForgeClient { return fetch(`${base}${path}`, { ...init, headers: buildHeaders(init?.headers as Record) }) } - async function request(path: string, init?: RequestInit): Promise { + async function request(path: string, init?: RequestInit, requireJson = false): Promise { let res = await send(path, init) if (res.status === 401 && config.onUnauthorized) { const refreshed = await config.onUnauthorized() @@ -95,8 +97,16 @@ export function createForgeClient(config: ForgeClientConfig): ForgeClient { } 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 { @@ -145,6 +155,20 @@ export function createForgeClient(config: ForgeClientConfig): ForgeClient { ), ) }, + async getPresignedFileUrl(schema, id, field, options = {}) { + const value = await request( + `${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(`${FORGE_PREFIX}/schemas/${encodeURIComponent(schema)}/entities`, { diff --git a/packages/client/tests/files.test.mjs b/packages/client/tests/files.test.mjs new file mode 100644 index 0000000..f530c12 --- /dev/null +++ b/packages/client/tests/files.test.mjs @@ -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' }) +})