Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro rates and "Carrer Example 12" on every job. Presented to a Mexican client, all of that reads as somebody else's product. City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at 19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were Barcelona literals, so an unset env quietly seeded a different city than the app rendered — they now agree. Two db tests pinned the Barcelona centre as a hardcoded constant, which is why the deck returned zero cards on the first run here: every pro was a continent outside the radius. They read the same env as the seed now, so the trap cannot recur. Money: formatCents defaults to USD/en-US, and the nine hardcoded euro signs across the card, search rows, quote strip and forms are dollars. The rate NUMBERS are unchanged and still read high for CDMX — that is a pricing decision, not a currency one, and is left alone deliberately. Seed people are Mexican, addressed on real Roma/Condesa streets rotated by index rather than one placeholder repeated. Phones moved to +52 55, which moves the demo login to +525500000000 / 000000. Also in here, from the same session: - Sending a job now confirms. The mutation always succeeded; the sheet just closed with no receipt, which from the customer's side is indistinguishable from a dead button. Dismissing that receipt resolves as 'sent', so the card does not return to the deck. - Media moves to DigitalOcean Spaces, with the public origin derived from bucket and region instead of a second env var to keep in sync. - Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM. - The client-facing project panel beside the running app. - Two profiles removed and four renamed to match their photos. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@linkder/storage",
|
||||
"name": "@linkdr/storage",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -20,6 +20,6 @@
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^2.1.8",
|
||||
"@linkder/shared": "workspace:*"
|
||||
"@linkdr/shared": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Direct-to-R2 uploads.
|
||||
* Direct-to-Spaces uploads.
|
||||
*
|
||||
* DigitalOcean Spaces, which is S3-compatible — so this is the AWS SDK pointed
|
||||
* at a different endpoint, and nothing above this file knows the difference.
|
||||
*
|
||||
* Files never pass through the Next server: the browser asks for a presigned
|
||||
* PUT, uploads straight to R2, then tells us the key. That keeps a 10 MB licence
|
||||
* scan off the request path and out of the serverless body limit.
|
||||
* PUT, uploads straight to the bucket, then tells us the key. That keeps a 10 MB
|
||||
* licence scan off the request path and out of the serverless body limit.
|
||||
*
|
||||
* The security property that matters: the server chooses the key and pins the
|
||||
* content type and length. A client cannot upload a 2 GB file, cannot overwrite
|
||||
@@ -75,7 +78,8 @@ export interface PresignedUpload {
|
||||
export class StorageError extends Error {}
|
||||
|
||||
interface StorageConfig {
|
||||
accountId: string;
|
||||
/** Spaces datacentre, e.g. `nyc3`. Part of both the endpoint and the URL. */
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucket: string;
|
||||
@@ -83,18 +87,16 @@ interface StorageConfig {
|
||||
}
|
||||
|
||||
function readConfig(): StorageConfig {
|
||||
const accountId = process.env.R2_ACCOUNT_ID;
|
||||
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
|
||||
const bucket = process.env.R2_BUCKET;
|
||||
const publicUrl = process.env.R2_PUBLIC_URL;
|
||||
const region = process.env.SPACES_REGION;
|
||||
const accessKeyId = process.env.SPACES_KEY;
|
||||
const secretAccessKey = process.env.SPACES_SECRET;
|
||||
const bucket = process.env.SPACES_BUCKET;
|
||||
|
||||
const missing = Object.entries({
|
||||
R2_ACCOUNT_ID: accountId,
|
||||
R2_ACCESS_KEY_ID: accessKeyId,
|
||||
R2_SECRET_ACCESS_KEY: secretAccessKey,
|
||||
R2_BUCKET: bucket,
|
||||
R2_PUBLIC_URL: publicUrl,
|
||||
SPACES_REGION: region,
|
||||
SPACES_KEY: accessKeyId,
|
||||
SPACES_SECRET: secretAccessKey,
|
||||
SPACES_BUCKET: bucket,
|
||||
})
|
||||
.filter(([, v]) => !v)
|
||||
.map(([k]) => k);
|
||||
@@ -102,12 +104,27 @@ function readConfig(): StorageConfig {
|
||||
if (missing.length) {
|
||||
throw new StorageError(`Object storage is not configured. Missing: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
/*
|
||||
* The public origin is DERIVED, not configured.
|
||||
*
|
||||
* Spaces serves every bucket at `https://<bucket>.<region>.digitaloceanspaces.com`,
|
||||
* so a separate env var for it is a second place to be wrong — and the way it
|
||||
* goes wrong is that objects upload to one bucket and render from another,
|
||||
* which looks like a broken image rather than a misconfiguration.
|
||||
*
|
||||
* SPACES_CDN_URL overrides it for the case that genuinely needs one: the CDN
|
||||
* endpoint, or a custom domain in front of the bucket.
|
||||
*/
|
||||
const publicUrl =
|
||||
process.env.SPACES_CDN_URL ?? `https://${bucket!}.${region!}.digitaloceanspaces.com`;
|
||||
|
||||
return {
|
||||
accountId: accountId!,
|
||||
region: region!,
|
||||
accessKeyId: accessKeyId!,
|
||||
secretAccessKey: secretAccessKey!,
|
||||
bucket: bucket!,
|
||||
publicUrl: publicUrl!.replace(/\/$/, ''),
|
||||
publicUrl: publicUrl.replace(/\/$/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,8 +134,10 @@ function getClient() {
|
||||
if (cached) return cached;
|
||||
const config = readConfig();
|
||||
const client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
|
||||
// Spaces is S3-compatible, so the only difference from AWS is the endpoint.
|
||||
// The region is real (not `auto`) because it is part of the signature.
|
||||
region: config.region,
|
||||
endpoint: `https://${config.region}.digitaloceanspaces.com`,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
@@ -128,7 +147,7 @@ function getClient() {
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Extension for a content type. Keys carry one so R2 serves the right thing back. */
|
||||
/** Extension for a content type. Keys carry one so Spaces serves the right thing back. */
|
||||
const EXTENSIONS: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
@@ -169,7 +188,7 @@ export function validateUpload(input: UploadRequest): void {
|
||||
* Sign a one-shot PUT.
|
||||
*
|
||||
* `ContentLength` is signed too, so the client cannot request a small file and
|
||||
* then push a huge one — R2 rejects a mismatched body.
|
||||
* then push a huge one — the mismatched body is rejected at the edge.
|
||||
*/
|
||||
export async function createPresignedUpload(
|
||||
input: UploadRequest & { ownerId: string },
|
||||
|
||||
@@ -107,11 +107,11 @@ describe('configuration', () => {
|
||||
beforeEach(() => {
|
||||
resetStorageClient();
|
||||
for (const k of [
|
||||
'R2_ACCOUNT_ID',
|
||||
'R2_ACCESS_KEY_ID',
|
||||
'R2_SECRET_ACCESS_KEY',
|
||||
'R2_BUCKET',
|
||||
'R2_PUBLIC_URL',
|
||||
'SPACES_REGION',
|
||||
'SPACES_KEY',
|
||||
'SPACES_SECRET',
|
||||
'SPACES_BUCKET',
|
||||
'SPACES_CDN_URL',
|
||||
]) {
|
||||
delete process.env[k];
|
||||
}
|
||||
@@ -131,7 +131,7 @@ describe('configuration', () => {
|
||||
contentLength: 100,
|
||||
ownerId: OWNER,
|
||||
}),
|
||||
).rejects.toThrow(/R2_ACCOUNT_ID.*R2_ACCESS_KEY_ID/s);
|
||||
).rejects.toThrow(/SPACES_REGION.*SPACES_KEY/s);
|
||||
});
|
||||
|
||||
it('validates the upload before it complains about configuration', async () => {
|
||||
@@ -166,13 +166,13 @@ describe('key/URL contract with the API', () => {
|
||||
});
|
||||
|
||||
it('accepts that key against the credential schema', async () => {
|
||||
const { credentialSchema } = await import('@linkder/shared');
|
||||
const { credentialSchema } = await import('@linkdr/shared');
|
||||
const key = buildKey('credential', OWNER, 'application/pdf');
|
||||
expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: key }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an empty key rather than storing a dangling reference', async () => {
|
||||
const { credentialSchema } = await import('@linkder/shared');
|
||||
const { credentialSchema } = await import('@linkdr/shared');
|
||||
expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user