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
13 changes: 6 additions & 7 deletions .agents/skills/pgpm/references/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,13 @@ env:
PGPASSWORD: password
```

For RustFS/S3 testing (uploads, storage):
For RustFS/S3 testing (uploads, storage) — credentials only; endpoint,
provider and region are `storage_module` rows (fixtures set them):

```yaml
env:
OBJECT_STORE_ENDPOINT: http://localhost:9000
AWS_ACCESS_KEY: constructive
AWS_SECRET_KEY: constructive-dev-secret
AWS_REGION: us-east-1
BUCKET_NAME: test-bucket
STORAGE_ACCESS_KEY_ID: constructive
STORAGE_SECRET_ACCESS_KEY: constructive-dev-secret
```

## PGPM CLI Caching
Expand Down Expand Up @@ -421,7 +419,8 @@ strategy:
TEST_DATABASE_URL: postgres://postgres:password@localhost:5432/postgres
- package: uploads/s3-streamer
env:
BUCKET_NAME: test-bucket
STORAGE_ACCESS_KEY_ID: constructive
STORAGE_SECRET_ACCESS_KEY: constructive-dev-secret

steps:
- name: Test ${{ matrix.package }}
Expand Down
15 changes: 8 additions & 7 deletions .agents/skills/pgpm/references/environment-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,17 @@ const deployOptions = getDeploymentEnvOptions();
| `SERVER_ORIGIN` | Server origin URL |
| `SERVER_STRICT_AUTH` | Strict authentication mode |

### CDN/Storage
### Storage

Endpoint, provider, region, bucket and public URL prefix are never env: they
are `metaschema_modules_public.storage_module` rows (NULL inherits the platform
database's `platform` plane). Only the credentials are env, and both are
required wherever storage is used:

| Variable | Description |
|----------|-------------|
| `BUCKET_PROVIDER` | Storage provider (s3, minio, rustfs, gcs) — `minio` is path-style S3-compatible storage (RustFS, MinIO) |
| `BUCKET_NAME` | Bucket name |
| `AWS_REGION` | AWS region |
| `AWS_ACCESS_KEY_ID` | AWS access key |
| `AWS_SECRET_ACCESS_KEY` | AWS secret key |
| `OBJECT_STORE_ENDPOINT` | S3-compatible endpoint URL (RustFS or MinIO; both listen on 9000) |
| `STORAGE_ACCESS_KEY_ID` | Object-store access key |
| `STORAGE_SECRET_ACCESS_KEY` | Object-store secret key |

### Jobs Configuration

Expand Down
9 changes: 3 additions & 6 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -355,12 +355,9 @@ jobs:
PGPORT: 5432
PGUSER: postgres
PGPASSWORD: password
CDN_ENDPOINT: http://localhost:9000
AWS_ACCESS_KEY: constructive
AWS_SECRET_KEY: constructive-dev-secret
AWS_REGION: us-east-1
# uploads/s3-streamer reads BUCKET_NAME; harmless for the others.
BUCKET_NAME: test-bucket
# Object-store credentials only; endpoint/provider/region are storage_module rows.
STORAGE_ACCESS_KEY_ID: constructive
STORAGE_SECRET_ACCESS_KEY: constructive-dev-secret
# Pin an explicit heap cap: on smaller runners Node's memory-derived
# default can land near ~2GB and OOM Jest.
NODE_OPTIONS: '--max-old-space-size=4096'
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Tests require PostgreSQL. Standard PG env vars:
- `PGHOST` (default: localhost), `PGPORT` (default: 5432)
- `PGUSER` (default: postgres), `PGPASSWORD` (default: password)

For S3/RustFS tests: `OBJECT_STORE_ENDPOINT`, `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, `AWS_REGION`
For S3/RustFS tests: `STORAGE_ACCESS_KEY_ID`, `STORAGE_SECRET_ACCESS_KEY` (endpoint/provider/region are `storage_module` rows, not env)

## Build System

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,6 @@ function createMockPgClient({
entity_table_id: entityField === 'owner_id' ? 'entity-table-uuid' : null,
buckets_schema: 'app_public',
buckets_table: 'buckets',
endpoint: null,
public_url_prefix: null,
provider: null,
allowed_origins: null,
entity_schema: entityField === 'owner_id' ? 'app_public' : null,
entity_table: entityField === 'owner_id' ? 'accounts' : null,
Expand Down
6 changes: 0 additions & 6 deletions graphile/graphile-bucket-provisioner-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,6 @@ const ALL_STORAGE_MODULES_QUERY = `
sm.entity_table_id,
bs.schema_name AS buckets_schema,
bt.name AS buckets_table,
sm.endpoint,
sm.public_url_prefix,
sm.provider,
sm.allowed_origins,
es.schema_name AS entity_schema,
et.name AS entity_table
Expand All @@ -57,9 +54,6 @@ interface StorageModuleRow {
entity_table_id: string | null;
buckets_schema: string;
buckets_table: string;
endpoint: string | null;
public_url_prefix: string | null;
provider: string | null;
allowed_origins: string[] | null;
entity_schema?: string | null;
entity_table?: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const bucket = {
} as unknown as BucketConfig;

const s3 = { client: { send: jest.fn() }, bucket: 'site-bucket', region: 'us-east-1' } as unknown as S3Config;
const options = { s3 } as unknown as PresignedUrlPluginOptions;
const options: PresignedUrlPluginOptions = { credentials: { accessKeyId: 'test', secretAccessKey: 'test' } };

function fakeTx(existingHash: string, deletable = true) {
const queries: Array<{ text: string; values: unknown[] }> = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
* without a server or S3.
*/

const mockS3Send = jest.fn();
jest.mock('@constructive-io/s3-utils', () => ({
createS3Client: jest.fn(() => ({ send: mockS3Send })),
}));

import { clearFileRefFieldCache } from '../src/file-ref-registry';
import { clearBucketCache, clearStorageModuleCache } from '../src/storage-module-cache';
import type { BucketConfig, PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from '../src/types';
Expand Down Expand Up @@ -65,9 +70,11 @@ function storageModuleRow(overrides: Record<string, unknown> = {}): Record<strin
files_schema: 'storage_public',
files_table: 'app_files',
private_schema: 'storage_private',
endpoint: null,
endpoint: 'http://localhost:9000',
public_url_prefix: 'https://cdn.example.com',
provider: 'minio',
region: 'us-east-1',
connection_overrides: [],
allowed_origins: null,
upload_url_expiry_seconds: null,
download_url_expiry_seconds: null,
Expand Down Expand Up @@ -111,14 +118,7 @@ const NO_REGISTRY_ROW: QueryHandler = {
};

function options(): PresignedUrlPluginOptions {
return {
s3: {
client: { send: jest.fn() } as any,
bucket: 'connection-default',
region: 'us-east-1',
publicUrlPrefix: 'https://cdn.example.com',
},
};
return { credentials: { accessKeyId: 'test', secretAccessKey: 'test' } };
}

function storageConfig(): StorageModuleConfig {
Expand Down Expand Up @@ -201,7 +201,8 @@ describe('resolveManagedUploadTarget', () => {
expect(target.binding).toBeNull();
expect(target.physicalName).toBe('myapp-default-public-db');
expect(target.s3.bucket).toBe('myapp-default-public-db');
expect(target.s3.bucket).not.toBe('connection-default');
expect(target.s3.endpoint).toBe('http://localhost:9000');
expect(target.s3.region).toBe('us-east-1');

const resolveCall = db.queries.find((q) => /resolve_default_bucket/.test(q.text));
// scope, entity, public_access, and no explicit key: the reserved default tag.
Expand Down Expand Up @@ -267,8 +268,7 @@ describe('resolveManagedUploadTarget', () => {

it('rejects an unreconciled bucket without calling S3 or provisioning', async () => {
const { resolveManagedUploadTarget } = await import('../src/managed-upload');
const send = jest.fn();
const baseS3 = options().s3 as S3Config;
mockS3Send.mockClear();
const db = fakeDb([
SET_CONFIG,
NO_REGISTRY_ROW,
Expand All @@ -278,18 +278,15 @@ describe('resolveManagedUploadTarget', () => {
]);

await expect(resolveManagedUploadTarget({
options: {
...options(),
s3: { ...baseS3, client: { send } as any },
},
options: options(),
withPgClient: db.withPgClient,
pgSettings: null,
databaseId: DATABASE_ID,
field: FIELD,
defaultPublicAccess: true,
})).rejects.toThrow('STORAGE_BUCKET_NOT_RECONCILED');

expect(send).not.toHaveBeenCalled();
expect(mockS3Send).not.toHaveBeenCalled();
expect(db.queries.some((q) => /UPDATE/.test(q.text))).toBe(false);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { resolveS3ForDatabase } from '../src/physical-bucket';
import type { PresignedUrlPluginOptions, StorageModuleConfig } from '../src/types';

const options = {
credentials: { accessKeyId: 'platform-key', secretAccessKey: 'platform-secret' },
} as PresignedUrlPluginOptions;

const config = (overrides: Partial<StorageModuleConfig> = {}): StorageModuleConfig =>
({
id: 'sm-1',
scope: 'app',
endpoint: 'https://objects.example.com',
publicUrlPrefix: null,
provider: 'minio',
region: 'us-east-1',
connectionOverrides: [],
...overrides,
}) as StorageModuleConfig;

describe('resolveS3ForDatabase', () => {
it('signs against the platform plane connection', () => {
const s3 = resolveS3ForDatabase(options, config(), 'physical-bucket');
expect(s3).toMatchObject({
bucket: 'physical-bucket',
region: 'us-east-1',
endpoint: 'https://objects.example.com',
forcePathStyle: true,
});
});

it('refuses a module row that names its own endpoint for the platform credentials', () => {
expect(() =>
resolveS3ForDatabase(options, config({ connectionOverrides: ['endpoint', 'region'] }), 'physical-bucket'),
).toThrow('STORAGE_CONNECTION_OVERRIDE_REFUSED: storage module sm-1 (scope app) sets its own endpoint, region');
});

it('refuses a connection the platform plane has not configured', () => {
expect(() => resolveS3ForDatabase(options, config({ provider: null }), 'physical-bucket')).toThrow(
'STORAGE_CONNECTION_NOT_CONFIGURED',
);
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* The presigned lane's diagnosis of a failed S3 call.
*
* The case that motivated this: a server whose CDN_ENDPOINT is unset signs
* against the library default (its own loopback), and the transport failure
* The case that motivated this: a server signing against an endpoint it cannot
* reach (its own loopback), and the transport failure
* arrives as an `AggregateError` with an empty `message` — so reporting
* `err.message` gave the client a blank reason. These assert that the reason is
* never blank, that it names the coordinates, and that the original error stays
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ import type { S3Config } from '../src/types';

// --- RustFS config (matches docker-compose.yml + CI env) ---

const OBJECT_STORE_ENDPOINT = process.env.CDN_ENDPOINT || 'http://localhost:9000';
const AWS_REGION = process.env.AWS_REGION || 'us-east-1';
const AWS_ACCESS_KEY = process.env.AWS_ACCESS_KEY || 'constructive';
const AWS_SECRET_KEY = process.env.AWS_SECRET_KEY || 'constructive-dev-secret';
// The local object store (docker RustFS/MinIO); credentials from the env.
const OBJECT_STORE_ENDPOINT = 'http://localhost:9000';
const AWS_REGION = 'us-east-1';
const AWS_ACCESS_KEY = process.env.STORAGE_ACCESS_KEY_ID!;
const AWS_SECRET_KEY = process.env.STORAGE_SECRET_ACCESS_KEY!;
const TEST_BUCKET = 'presigned-url-test-bucket';

// --- S3 client + config ---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ function storageConfig(
endpoint: null,
publicUrlPrefix: null,
provider: 'minio',
region: 'us-east-1',
connectionOverrides: [],
allowedOrigins: null,
uploadUrlExpirySeconds: 900,
downloadUrlExpirySeconds: 3600,
Expand Down
4 changes: 2 additions & 2 deletions graphile/graphile-presigned-url-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "graphile-presigned-url-plugin",
"version": "1.21.1",
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
"description": "Presigned URL upload plugin for PostGraphile v5 \u2014 requestUploadUrl mutation and downloadUrl computed field",
"author": "Constructive <developers@constructive.io>",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
Expand Down Expand Up @@ -42,6 +42,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1052.0",
"@aws-sdk/s3-request-presigner": "^3.1052.0",
"@constructive-io/s3-utils": "workspace:^",
"@pgpmjs/logger": "workspace:^",
"@pgsql/quotes": "^18.2.4",
"graphile-plugin-utils": "workspace:^",
Expand All @@ -59,7 +60,6 @@
"postgraphile": "^5.1.3"
},
"devDependencies": {
"@constructive-io/s3-utils": "workspace:^",
"@types/node": "^22.19.11",
"makage": "^0.8.0"
}
Expand Down
Loading
Loading