Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { GraphileConfig } from 'graphile-config';
import { withRequestPgClient } from 'graphile-plugin-utils';
import { DOWNLOAD_URL_FIELD } from 'graphile-storage-registry';

import { isLiveFileRow } from './file-lifecycle';
import { resolveS3ForDatabase } from './physical-bucket';
import { generatePresignedGetUrl } from './s3-signer';
import { loadAllStorageModules, readBucketPhysicalName, resolveStorageConfigFromCodec } from './storage-module-cache';
Expand Down Expand Up @@ -86,13 +87,15 @@ export function createDownloadUrlPlugin(
{
description:
'URL to download this file. For public files, returns the public URL. ' +
'For private files, returns a time-limited presigned URL.',
'For private files, returns a time-limited presigned URL. ' +
'Null until the upload is confirmed (status uploaded or processed), and for a rejected file.',
type: GraphQLString,
plan($parent: any) {
const $key = $parent.get('key');
const $isPublic = $parent.get('is_public');
const $filename = $parent.get('filename');
const $bucketId = $parent.get('bucket_id');
const $status = $parent.get('status');

const $withPgClient = (grafastContext() as any).get('withPgClient');
const $pgSettings = (grafastContext() as any).get('pgSettings');
Expand All @@ -102,12 +105,14 @@ export function createDownloadUrlPlugin(
isPublic: $isPublic,
filename: $filename,
bucketId: $bucketId,
status: $status,
withPgClient: $withPgClient,
pgSettings: $pgSettings,
});

return lambda($combined, async ({ key, isPublic, filename, bucketId, withPgClient, pgSettings }: any) => {
return lambda($combined, async ({ key, isPublic, filename, bucketId, status, withPgClient, pgSettings }: any) => {
if (!key) return null;
if (!isLiveFileRow({ status })) return null;
Comment on lines 98 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

status column assumed on every @storageFiles table

The downloadUrl plan adds $parent.get('status') and gates on isLiveFileRow({ status }), but the field is attached to every codec carrying the @storageFiles smart tag, not only tables with a lifecycle column (download-url-field.ts:98). A tagged table whose schema lacks status fails at plan time (grafast cannot get a nonexistent attribute), turning the graceful null promised in the field description into a broken query. Additionally, isLiveFileRow matches only the exact strings uploaded/processed, so a tagged table with a differently-valued lifecycle column would silently return null for every row.

📋 Prompt for AI Agents

In graphile/graphile-presigned-url-plugin/src/download-url-field.ts around lines 68-98, before adding the downloadUrl field, verify the codec actually has a status attribute (e.g. if (!pgCodec.attributes?.status) return fields; alongside the existing tags?.storageFiles check, or log a warning naming the codec when the tag is present but status is missing). If you keep the field for tables without status, treat an absent status as live so isLiveFileRow gating applies only where the schema declares the column. This prevents a plan-time error or a permanently-null downloadUrl on @storageFiles-tagged tables that do not carry the lifecycle status column.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked both parts.

Missing status column: not reachable. @storageFiles is set in one place, constructive-db storage_module.sql:1038-1041, and the same generator always creates status (:768-777, is_required := true). The one commit that replaced status with a processed boolean (4b9078bf847) landed on main together with the commit that restored it as the file_status enum (e8d4e51d885), both in constructive-db #996, so no main version tagged a table without status. Every @storageFiles table on a local cluster (11) has status of type file_status.

Different lifecycle values: real for pre-#996 tenants. Files tables provisioned before 2026-05-04 have status text with pending/ready/processing/failed; their ready files would get null. Dedup already treats them the same way since #1952. Added an "Existing tenants (R7)" section to the PR description with a question for the owner rather than adding a compatibility mapping here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed analysis. The two-part response is helpful, but neither part addresses the original finding in the diff itself.

Missing status column: Your argument is that the tag is always co-generated with the column in constructive-db, so the case cannot arise in practice. That is a valid operational argument, but the plugin code still has no guard — if the tag is ever applied manually or by a different generator, the plan-time error will occur with no warning. The finding asked for a codec-level check (e.g. if (!pgCodec.attributes?.status) return fields;) or at minimum a warning, and the diff does not show that added.

Different lifecycle values (pre-#996 tenants): You confirm this is a real issue — ready files on older tenants will return null from downloadUrl. Deferring to a PR description question does not fix the silent data-correctness bug that the finding flagged.

The diff adds $parent.get('status') and isLiveFileRow gating unconditionally with no codec attribute guard and no compatibility mapping for legacy status values. The finding is not resolved.


if (!withPgClient || !pgSettings) return null;

Expand Down
5 changes: 3 additions & 2 deletions graphile/graphile-presigned-url-plugin/src/file-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
export const LIVE_FILE_STATUSES = ['uploaded', 'processed'];

/**
* Whether an existing row may be handed back as a dedup hit: only once the bytes
* it names are in S3.
* Whether a row is live (constructive-db file lifecycle invariant S2): a dedup
* hit, and a row `downloadUrl` signs a URL for, only once the bytes it names
* are in S3.
*
* The row is written before the PUT; the confirm-upload job moves it out of
* `requested` only after inspecting the object, so its status is the answer.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,21 @@ BEGIN
EXECUTE format(
'GRANT EXECUTE ON FUNCTION %I.app_files_record_file(uuid, text, text, text, bigint, text, jsonb, text, uuid) TO administrator, authenticated, anonymous',
schema_name || '-private');

-- The generator's bucket physical-name reader, which downloadUrl calls.
EXECUTE format($sql$
CREATE FUNCTION %I.app_buckets_physical_name(bucket_id uuid) RETURNS text
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $function$
SELECT b.physical_name FROM %I.app_buckets b WHERE b.id = app_buckets_physical_name.bucket_id
$function$;
$sql$, schema_name || '-private', schema_name);

EXECUTE format(
'GRANT EXECUTE ON FUNCTION %I.app_buckets_physical_name(uuid) TO administrator, authenticated, anonymous',
schema_name || '-private');
END;
$$;

Expand Down
118 changes: 118 additions & 0 deletions graphql/server-test/__tests__/download-url-status.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Integration tests: `downloadUrl` is handed out only for a file whose bytes a
* reader can GET.
*
* A files row is written before its presigned PUT runs, and the confirm-upload
* job settles its `status` after inspecting the object. A `requested` row may
* name an object that never arrives, and a `rejected` row is a settled
* failure, so neither may carry a download URL.
*
* pnpm test -- --testPathPattern=download-url-status
*/

import path from 'path';
import type { PgTestClient } from 'pgsql-test';
import type supertest from 'supertest';

import { getConnections, seed } from '../src';

jest.setTimeout(120000);

const localSeedRoot = path.join(__dirname, '..', '__fixtures__', 'seed');
const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed');
const sql = (seedDir: string, file: string) => path.join(localSeedRoot, seedDir, file);
const pgpmWorkspace = path.join(sharedSeedRoot, '..', '..');

const aliceDatabaseId = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9';
const aliceSchema = 'simple-storage-public';
const alicePrivateBucketId = 'd0000001-0000-0000-0000-000000000002';

const metaSchemas = [
'catalog_private',
'routing_public',
'apps_public',
'metaschema_public',
'metaschema_modules_public'
];

const STATUSES = ['requested', 'uploaded', 'processed', 'rejected'];

const APP_FILES = `
query AppFiles {
appFiles {
nodes {
key
downloadUrl
}
}
}
`;

describe('downloadUrl by file status', () => {
let request: supertest.Agent;
let pg: PgTestClient;
let teardown: () => Promise<void>;
let urlByKey: Record<string, string | null>;

beforeAll(async () => {
({ request, pg, teardown } = await getConnections(
{
schemas: [aliceSchema],
authRole: 'anonymous',
server: {
useRouting: true,
api: {
isPublic: false,
metaSchemas
}
}
},
[
seed.pgpm(pgpmWorkspace),
seed.sqlfile([
sql('simple-seed-storage', 'schema.sql'),
sql('simple-seed-storage', 'test-data.sql'),
sql('simple-seed-storage', 'auth-provision.sql')
])
]
));

for (const status of STATUSES) {
await pg.query(
`INSERT INTO "${aliceSchema}".app_files
(bucket_id, key, content_hash, mime_type, filename, status)
VALUES ($1, $2, $2, 'text/plain', $3, $4)`,
[alicePrivateBucketId, `status-${status}`, `${status}.txt`, status]
);
}

const res = await request
.post('/graphql')
.set('X-Database-Id', aliceDatabaseId)
.set('X-Schemata', aliceSchema)
.send({ query: APP_FILES });
expect(res.status).toBe(200);
expect(res.body.errors).toBeUndefined();
urlByKey = Object.fromEntries(
res.body.data.appFiles.nodes.map((n: { key: string; downloadUrl: string | null }) => [
n.key,
n.downloadUrl
])
);
});

afterAll(async () => {
if (teardown) await teardown();
});

it.each(['uploaded', 'processed'])('signs a download URL for a %s file', (status) => {
const url = urlByKey[`status-${status}`];
expect(url).toEqual(expect.any(String));
expect(new URL(url).pathname).toContain(`/status-${status}`);
});

it.each(['requested', 'rejected'])('returns no download URL for a %s file', (status) => {
expect(urlByKey).toHaveProperty(`status-${status}`);
expect(urlByKey[`status-${status}`]).toBeNull();
});
});
Loading