From 24214296bfd64982493e4c7983b7f1078ae57496 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 1 Apr 2026 13:45:20 +0200 Subject: [PATCH 1/7] feat: add env parsing --- infra/ENV.md | 94 +++++++++++++++++ server/env.ts | 32 ++++++ server/envSchema.ts | 212 +++++++++++++++++++++++++++++++++++++++ tools/generateEnvDocs.ts | 146 +++++++++++++++++++++++++++ 4 files changed, 484 insertions(+) create mode 100644 infra/ENV.md create mode 100644 server/env.ts create mode 100644 server/envSchema.ts create mode 100644 tools/generateEnvDocs.ts diff --git a/infra/ENV.md b/infra/ENV.md new file mode 100644 index 000000000..f632c694f --- /dev/null +++ b/infra/ENV.md @@ -0,0 +1,94 @@ +# Environment Variables + +All environment variables used by PubPub, with types, defaults, and descriptions. + +> Auto-generated from `server/env.ts` — do not edit manually. +> Run `npx tsx tools/generateEnvDocs.ts` to regenerate. + +| Variable | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `NODE_ENV` | `"production"` \| `"development"` \| `"test"` | No | development | Node environment | +| `PORT` | number | No | 9876 | HTTP server port | +| `PUBPUB_PRODUCTION` | boolean | No | `false` | Treat this instance as the production PubPub deployment | +| `IS_DUQDUQ` | boolean | No | `false` | Treat this instance as the DuqDuq staging deployment | +| `IS_QUBQUB` | boolean | No | `false` | Treat this instance as the QubQub deployment | +| `HEROKU_SLUG_COMMIT` | string | No | — | Git commit hash set by Heroku | +| `PUBPUB_LOCAL_COMMUNITY` | string | No | — | Slug of the community to proxy in local dev (e.g. "stanford-jblp") | +| `FORCE_BASE_PUBPUB` | boolean | No | `false` | Force the base PubPub site in development/QubQub mode | +| `PUBPUB_READ_ONLY` | boolean | No | `false` | Enable read-only mode, disabling all mutations | +| `DISABLE_SSL_REDIRECT` | boolean | No | `false` | Disable automatic HTTP → HTTPS redirect | +| `DATABASE_URL` | string | **Yes** | — | Primary PostgreSQL connection URL | +| `DATABASE_READ_REPLICA_1_URL` | string | No | — | PostgreSQL read-replica 1 URL | +| `DATABASE_READ_REPLICA_2_URL` | string | No | — | PostgreSQL read-replica 2 URL | +| `SEQUELIZE_MAX_CONNECTIONS` | number | No | — | Max DB pool connections (default: 20 for server, 5 for workers) | +| `SEQUELIZE_IDLE_TIMEOUT` | number | No | — | DB pool idle timeout in ms (default: 60000) | +| `SEQUELIZE_ACQUIRE_TIMEOUT` | number | No | — | DB pool acquire timeout in ms (default: 10000) | +| `SEQUELIZE_MAX_USES` | number | No | — | Max times a DB connection may be reused (default: Infinity) | +| `REQUEST_TIMEOUT_MS` | number | No | 30000 | Request abort timeout in ms | +| `JWT_SIGNING_SECRET` | string | **Yes** | — | Secret used to sign JWT tokens | +| `FIREBASE_SERVICE_ACCOUNT_BASE64` | string | **Yes** | — | Base64-encoded Firebase service-account JSON | +| `FIREBASE_TEST_DB_URL` | string | No | — | Firebase Realtime Database URL for test env | +| `AWS_ACCESS_KEY_ID` | string | **Yes** | — | AWS access key for S3 uploads | +| `AWS_SECRET_ACCESS_KEY` | string | **Yes** | — | AWS secret key for S3 uploads | +| `AWS_BACKUP_ACCESS_KEY_ID` | string | No | — | AWS access key for backup operations | +| `AWS_BACKUP_SECRET_ACCESS_KEY` | string | No | — | AWS secret key for backup operations | +| `S3_BACKUP_ENDPOINT` | string | No | — | S3-compatible endpoint for backups | +| `S3_BACKUP_ACCESS_KEY` | string | No | — | S3 backup access key (if different from AWS) | +| `S3_BACKUP_SECRET_KEY` | string | No | — | S3 backup secret key (if different from AWS) | +| `S3_BACKUP_BUCKET` | string | No | — | S3 bucket name for backups | +| `S3_BACKUP_KEY_PREFIX` | string | No | pg-backups | Key prefix for backup objects | +| `MAILGUN_API_KEY` | string | **Yes** | — | Mailgun API key for transactional emails | +| `MAILCHIMP_API_KEY` | string | No | — | Mailchimp API key for mailing lists | +| `ALGOLIA_ID` | string | **Yes** | — | Algolia application ID | +| `ALGOLIA_KEY` | string | **Yes** | — | Algolia admin API key | +| `ALGOLIA_SEARCH_KEY` | string | **Yes** | — | Algolia public search-only key | +| `ALTCHA_HMAC_KEY` | string | **Yes** | — | HMAC key for ALTCHA proof-of-work captcha | +| `BYPASS_CAPTCHA` | boolean | No | `false` | Bypass captcha checks (dev/test only) | +| `AES_ENCRYPTION_KEY` | string | **Yes** | — | AES-256 key for encrypting deposit credentials | +| `DOI_LOGIN_ID` | string | No | — | CrossRef DOI deposit login ID | +| `DOI_LOGIN_PASSWORD` | string | No | — | CrossRef DOI deposit login password | +| `DOI_SUBMISSION_URL` | string | No | — | CrossRef DOI deposit endpoint URL | +| `DATACITE_DEPOSIT_URL` | string | No | — | DataCite DOI deposit endpoint URL | +| `CLOUDAMQP_URL` | string | No | — | CloudAMQP (RabbitMQ) connection URL | +| `ZOTERO_CLIENT_KEY` | string | No | — | Zotero OAuth1 consumer key | +| `ZOTERO_CLIENT_SECRET` | string | No | — | Zotero OAuth1 consumer secret | +| `FASTLY_SERVICE_ID_PROD` | string | No | — | Fastly service ID for production | +| `FASTLY_PURGE_TOKEN_PROD` | string | No | — | Fastly purge token for production | +| `FASTLY_SERVICE_ID_DUQDUQ` | string | No | — | Fastly service ID for DuqDuq | +| `FASTLY_PURGE_TOKEN_DUQDUQ` | string | No | — | Fastly purge token for DuqDuq | +| `PURGE_TOKEN` | string | No | — | Legacy Fastly purge token | +| `PUBSTASH_URL` | string | No | http://pubstash:8080 | PubStash service URL for paged exports | +| `PUBSTASH_ACCESS_KEY` | string | No | — | PubStash access key | +| `SLACK_WEBHOOK_URL` | string | No | — | Slack incoming webhook URL for notifications | +| `STITCH_WEBHOOK_URL` | string | No | — | MongoDB Stitch webhook URL for analytics | +| `METABASE_SECRET_KEY` | string | No | — | Metabase embedding secret key | +| `SENTRY_AUTH_TOKEN` | string | No | — | Sentry auth token (build-time only) | +| `SENTRY_ORG` | string | No | — | Sentry organization slug | +| `BLOCKLIST_IP_ADDRESSES` | string | No | — | Comma-separated list of IP addresses to block | +| `LARGE_COMMUNITY_SLUGS` | string | No | — | Comma-separated list of large community slugs for optimized queries | +| `NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES` | number | No | — | Minutes after account creation during which link-comments are flagged | +| `EXTRA_SUSPICIOUS_KEYWORDS` | string | No | — | Comma-separated extra keywords to flag uploads as suspicious | +| `BACKUPS_SECRET` | string | No | — | GPG passphrase for encrypting backups | +| `DEBUG_LOG` | string | No | — | Enable verbose debug logging | +| `WORKER` | boolean | No | `false` | Set to true when running as a standalone worker process | +| `DEFAULT_QUEUE_TASK_PRIORITY` | number | No | — | Default priority for worker queue tasks | +| `PUBPUB_LOCAL_TASK_QUEUE` | string | No | — | Custom task queue name for local development | +| `INTEGRATION_TESTING` | boolean | No | `false` | Signals that integration tests are running | +| `TEST_FASTLY_PURGE` | boolean | No | `false` | Enable Fastly purge calls during tests | +| `USE_LOCAL_DB` | boolean | No | `false` | Force use of local PostgreSQL in development | + +## Required Variables Checklist + +These must be set for the server to start: + +- [ ] `DATABASE_URL` — Primary PostgreSQL connection URL +- [ ] `JWT_SIGNING_SECRET` — Secret used to sign JWT tokens +- [ ] `FIREBASE_SERVICE_ACCOUNT_BASE64` — Base64-encoded Firebase service-account JSON +- [ ] `AWS_ACCESS_KEY_ID` — AWS access key for S3 uploads +- [ ] `AWS_SECRET_ACCESS_KEY` — AWS secret key for S3 uploads +- [ ] `MAILGUN_API_KEY` — Mailgun API key for transactional emails +- [ ] `ALGOLIA_ID` — Algolia application ID +- [ ] `ALGOLIA_KEY` — Algolia admin API key +- [ ] `ALGOLIA_SEARCH_KEY` — Algolia public search-only key +- [ ] `ALTCHA_HMAC_KEY` — HMAC key for ALTCHA proof-of-work captcha +- [ ] `AES_ENCRYPTION_KEY` — AES-256 key for encrypting deposit credentials diff --git a/server/env.ts b/server/env.ts new file mode 100644 index 000000000..d12cd0603 --- /dev/null +++ b/server/env.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +import { envSchema } from './envSchema'; + +export type { Env } from './envSchema'; +export { envSchema } from './envSchema'; + +function parseEnv() { + try { + return envSchema.parse(process.env); + } catch (e) { + if (e instanceof z.ZodError) { + console.error('❌ Invalid environment variables:'); + for (const issue of e.issues) { + console.error(` ${issue.path.join('.')}: ${issue.message}`); + } + throw new Error('Environment validation failed. See above for details.'); + } + throw e; + } +} + +/** + * Validated, typed environment variables. + * + * Import this instead of accessing `process.env` directly: + * ```ts + * import { env } from 'server/env'; + * const port = env.PORT; // number, typed + * ``` + */ +export const env = parseEnv(); diff --git a/server/envSchema.ts b/server/envSchema.ts new file mode 100644 index 000000000..27f3dbd8e --- /dev/null +++ b/server/envSchema.ts @@ -0,0 +1,212 @@ +import { z } from 'zod'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** Coerce "true"/"false"/undefined → boolean */ +const booleanish = z + .union([z.boolean(), z.string()]) + .optional() + .transform((v) => v === true || v === 'true'); + +/** Coerce string → number (or leave undefined) */ +const optionalInt = z.coerce.number().int().optional(); + +// ─── Schema ───────────────────────────────────────────────────────────────── + +export const envSchema = z.object({ + // ── Node / Runtime ─────────────────────────────────────────────────── + NODE_ENV: z + .enum(['production', 'development', 'test']) + .default('development') + .describe('Node environment'), + PORT: z.coerce.number().int().default(9876).describe('HTTP server port'), + + // ── PubPub Environment ────────────────────────────────────────────── + PUBPUB_PRODUCTION: booleanish.describe( + 'Treat this instance as the production PubPub deployment', + ), + IS_DUQDUQ: booleanish.describe('Treat this instance as the DuqDuq staging deployment'), + IS_QUBQUB: booleanish.describe('Treat this instance as the QubQub deployment'), + HEROKU_SLUG_COMMIT: z.string().optional().describe('Git commit hash set by Heroku'), + PUBPUB_LOCAL_COMMUNITY: z + .string() + .optional() + .describe('Slug of the community to proxy in local dev (e.g. "stanford-jblp")'), + FORCE_BASE_PUBPUB: booleanish.describe( + 'Force the base PubPub site in development/QubQub mode', + ), + PUBPUB_READ_ONLY: booleanish.describe('Enable read-only mode, disabling all mutations'), + DISABLE_SSL_REDIRECT: booleanish.describe('Disable automatic HTTP → HTTPS redirect'), + + // ── Database ───────────────────────────────────────────────────────── + DATABASE_URL: z.string().url().describe('Primary PostgreSQL connection URL'), + DATABASE_READ_REPLICA_1_URL: z + .string() + .url() + .optional() + .describe('PostgreSQL read-replica 1 URL'), + DATABASE_READ_REPLICA_2_URL: z + .string() + .url() + .optional() + .describe('PostgreSQL read-replica 2 URL'), + + // ── Sequelize Pool ────────────────────────────────────────────────── + SEQUELIZE_MAX_CONNECTIONS: optionalInt.describe( + 'Max DB pool connections (default: 20 for server, 5 for workers)', + ), + SEQUELIZE_IDLE_TIMEOUT: optionalInt.describe( + 'DB pool idle timeout in ms (default: 60000)', + ), + SEQUELIZE_ACQUIRE_TIMEOUT: optionalInt.describe( + 'DB pool acquire timeout in ms (default: 10000)', + ), + SEQUELIZE_MAX_USES: optionalInt.describe( + 'Max times a DB connection may be reused (default: Infinity)', + ), + + // ── Server ─────────────────────────────────────────────────────────── + REQUEST_TIMEOUT_MS: z.coerce + .number() + .int() + .default(30_000) + .describe('Request abort timeout in ms'), + + // ── Auth / Signing ────────────────────────────────────────────────── + JWT_SIGNING_SECRET: z.string().min(1).describe('Secret used to sign JWT tokens'), + + // ── Firebase ───────────────────────────────────────────────────────── + FIREBASE_SERVICE_ACCOUNT_BASE64: z + .string() + .min(1) + .describe('Base64-encoded Firebase service-account JSON'), + FIREBASE_TEST_DB_URL: z + .string() + .url() + .optional() + .describe('Firebase Realtime Database URL for test env'), + + // ── AWS / S3 ───────────────────────────────────────────────────────── + AWS_ACCESS_KEY_ID: z.string().min(1).describe('AWS access key for S3 uploads'), + AWS_SECRET_ACCESS_KEY: z.string().min(1).describe('AWS secret key for S3 uploads'), + AWS_BACKUP_ACCESS_KEY_ID: z + .string() + .optional() + .describe('AWS access key for backup operations'), + AWS_BACKUP_SECRET_ACCESS_KEY: z + .string() + .optional() + .describe('AWS secret key for backup operations'), + + // ── S3 Backup ──────────────────────────────────────────────────────── + S3_BACKUP_ENDPOINT: z.string().optional().describe('S3-compatible endpoint for backups'), + S3_BACKUP_ACCESS_KEY: z + .string() + .optional() + .describe('S3 backup access key (if different from AWS)'), + S3_BACKUP_SECRET_KEY: z + .string() + .optional() + .describe('S3 backup secret key (if different from AWS)'), + S3_BACKUP_BUCKET: z.string().optional().describe('S3 bucket name for backups'), + S3_BACKUP_KEY_PREFIX: z + .string() + .default('pg-backups') + .describe('Key prefix for backup objects'), + + // ── Email ──────────────────────────────────────────────────────────── + MAILGUN_API_KEY: z.string().min(1).describe('Mailgun API key for transactional emails'), + MAILCHIMP_API_KEY: z.string().optional().describe('Mailchimp API key for mailing lists'), + + // ── Search ─────────────────────────────────────────────────────────── + ALGOLIA_ID: z.string().min(1).describe('Algolia application ID'), + ALGOLIA_KEY: z.string().min(1).describe('Algolia admin API key'), + ALGOLIA_SEARCH_KEY: z.string().min(1).describe('Algolia public search-only key'), + + // ── Captcha (ALTCHA) ──────────────────────────────────────────────── + ALTCHA_HMAC_KEY: z.string().min(1).describe('HMAC key for ALTCHA proof-of-work captcha'), + BYPASS_CAPTCHA: booleanish.describe('Bypass captcha checks (dev/test only)'), + + // ── Encryption ────────────────────────────────────────────────────── + AES_ENCRYPTION_KEY: z.string().min(1).describe('AES-256 key for encrypting deposit credentials'), + + // ── DOI / CrossRef ────────────────────────────────────────────────── + DOI_LOGIN_ID: z.string().optional().describe('CrossRef DOI deposit login ID'), + DOI_LOGIN_PASSWORD: z.string().optional().describe('CrossRef DOI deposit login password'), + DOI_SUBMISSION_URL: z.string().url().optional().describe('CrossRef DOI deposit endpoint URL'), + + // ── DataCite ───────────────────────────────────────────────────────── + DATACITE_DEPOSIT_URL: z.string().url().optional().describe('DataCite DOI deposit endpoint URL'), + + // ── Message Queues ────────────────────────────────────────────────── + CLOUDAMQP_URL: z.string().optional().describe('CloudAMQP (RabbitMQ) connection URL'), + + // ── Zotero ────────────────────────────────────────────────────────── + ZOTERO_CLIENT_KEY: z.string().optional().describe('Zotero OAuth1 consumer key'), + ZOTERO_CLIENT_SECRET: z.string().optional().describe('Zotero OAuth1 consumer secret'), + + // ── CDN / Fastly ──────────────────────────────────────────────────── + FASTLY_SERVICE_ID_PROD: z.string().optional().describe('Fastly service ID for production'), + FASTLY_PURGE_TOKEN_PROD: z.string().optional().describe('Fastly purge token for production'), + FASTLY_SERVICE_ID_DUQDUQ: z.string().optional().describe('Fastly service ID for DuqDuq'), + FASTLY_PURGE_TOKEN_DUQDUQ: z.string().optional().describe('Fastly purge token for DuqDuq'), + PURGE_TOKEN: z.string().optional().describe('Legacy Fastly purge token'), + + // ── PubStash (Export) ─────────────────────────────────────────────── + PUBSTASH_URL: z + .string() + .default('http://pubstash:8080') + .describe('PubStash service URL for paged exports'), + PUBSTASH_ACCESS_KEY: z.string().optional().describe('PubStash access key'), + + // ── Webhooks / Integrations ───────────────────────────────────────── + SLACK_WEBHOOK_URL: z.string().optional().describe('Slack incoming webhook URL for notifications'), + STITCH_WEBHOOK_URL: z.string().optional().describe('MongoDB Stitch webhook URL for analytics'), + + // ── Observability ─────────────────────────────────────────────────── + METABASE_SECRET_KEY: z.string().optional().describe('Metabase embedding secret key'), + SENTRY_AUTH_TOKEN: z.string().optional().describe('Sentry auth token (build-time only)'), + SENTRY_ORG: z.string().optional().describe('Sentry organization slug'), + + // ── Spam / Security ───────────────────────────────────────────────── + BLOCKLIST_IP_ADDRESSES: z + .string() + .optional() + .describe('Comma-separated list of IP addresses to block'), + LARGE_COMMUNITY_SLUGS: z + .string() + .optional() + .describe('Comma-separated list of large community slugs for optimized queries'), + NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES: z.coerce + .number() + .int() + .optional() + .describe('Minutes after account creation during which link-comments are flagged'), + EXTRA_SUSPICIOUS_KEYWORDS: z + .string() + .optional() + .describe('Comma-separated extra keywords to flag uploads as suspicious'), + + // ── Backup / Misc ─────────────────────────────────────────────────── + BACKUPS_SECRET: z.string().optional().describe('GPG passphrase for encrypting backups'), + DEBUG_LOG: z.string().optional().describe('Enable verbose debug logging'), + + // ── Worker ─────────────────────────────────────────────────────────── + WORKER: booleanish.describe('Set to true when running as a standalone worker process'), + DEFAULT_QUEUE_TASK_PRIORITY: z.coerce + .number() + .int() + .optional() + .describe('Default priority for worker queue tasks'), + PUBPUB_LOCAL_TASK_QUEUE: z + .string() + .optional() + .describe('Custom task queue name for local development'), + + // ── Testing ────────────────────────────────────────────────────────── + INTEGRATION_TESTING: booleanish.describe('Signals that integration tests are running'), + TEST_FASTLY_PURGE: booleanish.describe('Enable Fastly purge calls during tests'), + USE_LOCAL_DB: booleanish.describe('Force use of local PostgreSQL in development'), +}); + +export type Env = z.infer; diff --git a/tools/generateEnvDocs.ts b/tools/generateEnvDocs.ts new file mode 100644 index 000000000..2cc714c37 --- /dev/null +++ b/tools/generateEnvDocs.ts @@ -0,0 +1,146 @@ +/** + * Generates infra/ENV.md from the Zod env schema defined in server/env.ts. + * + * Usage: + * npx tsx tools/generateEnvDocs.ts + */ + +import fs from 'fs'; +import path from 'path'; +import type { z } from 'zod'; + +import { envSchema } from '../server/envSchema'; + +type ZodShape = Record; + +function getInnerType(schema: z.ZodTypeAny): z.ZodTypeAny { + // Unwrap ZodOptional, ZodDefault, ZodEffects (transform/refine) + if ('_def' in schema) { + const def = (schema as any)._def; + if (def.typeName === 'ZodOptional' || def.typeName === 'ZodDefault') { + return getInnerType(def.innerType); + } + if (def.typeName === 'ZodEffects') { + return getInnerType(def.schema); + } + if (def.typeName === 'ZodUnion') { + // For our booleanish helper, just report "boolean" + return schema; + } + } + return schema; +} + +function getTypeName(schema: z.ZodTypeAny): string { + const inner = getInnerType(schema); + const def = (inner as any)._def; + + switch (def.typeName) { + case 'ZodString': + return 'string'; + case 'ZodNumber': + return 'number'; + case 'ZodBoolean': + return 'boolean'; + case 'ZodEnum': + return def.values.map((v: string) => `\`"${v}"\``).join(' \\| '); + case 'ZodUnion': + return 'boolean'; + default: + return 'string'; + } +} + +function isRequired(schema: z.ZodTypeAny): boolean { + const def = (schema as any)._def; + if (def.typeName === 'ZodOptional') return false; + if (def.typeName === 'ZodDefault') return false; + // booleanish is optional().transform() → ZodEffects wrapping ZodOptional + if (def.typeName === 'ZodEffects') return isRequired(def.schema); + return true; +} + +function getDefault(schema: z.ZodTypeAny): string | undefined { + const def = (schema as any)._def; + if (def.typeName === 'ZodDefault') { + const val = def.defaultValue(); + if (val === undefined || val === '') return undefined; + return String(val); + } + // booleanish defaults to false via transform + if (def.typeName === 'ZodEffects') { + const innerDefault = getDefault(def.schema); + if (innerDefault !== undefined) return innerDefault; + // booleanish with no explicit default → false + const innerDef = (def.schema as any)?._def; + if (innerDef?.typeName === 'ZodOptional') return '`false`'; + } + return undefined; +} + +function getDescription(schema: z.ZodTypeAny): string { + if (schema.description) return schema.description; + const def = (schema as any)._def; + if (def.innerType?.description) return def.innerType.description; + if (def.schema?.description) return def.schema.description; + return ''; +} + +function main() { + const shape = (envSchema as any).shape as ZodShape; + + const lines: string[] = [ + '# Environment Variables', + '', + 'All environment variables used by PubPub, with types, defaults, and descriptions.', + '', + '> Auto-generated from `server/env.ts` — do not edit manually.', + '> Run `npx tsx tools/generateEnvDocs.ts` to regenerate.', + '', + ]; + + // Group by section using the comments in the schema + // We'll detect groups by looking at consecutive keys and inferring from descriptions + const entries = Object.entries(shape); + + // Build a simple table + lines.push( + '| Variable | Type | Required | Default | Description |', + '|----------|------|----------|---------|-------------|', + ); + + for (const [key, schema] of entries) { + const type = getTypeName(schema); + const required = isRequired(schema); + const defaultVal = getDefault(schema); + const description = getDescription(schema); + + lines.push( + `| \`${key}\` | ${type} | ${required ? '**Yes**' : 'No'} | ${defaultVal ?? '—'} | ${description} |`, + ); + } + + lines.push(''); + + // Also generate a "required variables checklist" section + lines.push('## Required Variables Checklist'); + lines.push(''); + lines.push('These must be set for the server to start:'); + lines.push(''); + + for (const [key, schema] of entries) { + if (isRequired(schema)) { + const description = getDescription(schema); + lines.push(`- [ ] \`${key}\` — ${description}`); + } + } + + lines.push(''); + + const output = lines.join('\n'); + const outPath = path.join(__dirname, '..', 'infra', 'ENV.md'); + fs.writeFileSync(outPath, output); + console.log(`Written to ${outPath}`); +} + +main(); From 11a45bde71f0eff63acde09132c2a76e86d1d75a Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 1 Apr 2026 14:36:46 +0200 Subject: [PATCH 2/7] feat: use env --- localDatabase.js | 5 +- server/analytics/api.ts | 5 +- server/apiRoutes.ts | 3 +- server/debug/api.ts | 4 +- server/dev/api.ts | 25 ++++-- server/doi/submit.ts | 13 ++- server/doi/validate.ts | 3 +- server/env.ts | 104 +++++++++++++++++++--- server/envSchema.ts | 89 ++++++++---------- server/routes/pubDocument.tsx | 3 +- server/routes/search.tsx | 7 +- server/routes/sitemap.ts | 5 +- server/sequelize.ts | 26 ++---- server/server.ts | 43 ++++----- server/spamTag/commentSpam.ts | 10 +-- server/spamTag/notifications/slack.ts | 13 +-- server/spamTag/uploadScamKeywords.ts | 4 +- server/upload/api.ts | 11 +-- server/uploadPolicy/queries.ts | 7 +- server/userNotification/hooks.ts | 3 +- server/utils/blocklist.ts | 4 +- server/utils/captcha.ts | 5 +- server/utils/email/communityServices.ts | 4 +- server/utils/email/reset.ts | 4 +- server/utils/email/signup.ts | 4 +- server/utils/errors.ts | 4 +- server/utils/firebaseAdmin.ts | 9 +- server/utils/initData.ts | 3 +- server/utils/layouts/layoutPubs.ts | 5 +- server/utils/mailchimp.ts | 4 +- server/utils/metabase.ts | 4 +- server/utils/queryHelpers/communityGet.ts | 5 +- server/utils/s3.ts | 6 +- server/utils/slack.ts | 7 +- server/utils/tokens.ts | 12 +-- server/utils/workers.ts | 10 +-- server/zoteroIntegration/utils/auth.ts | 5 +- stubstub/global/setup.ts | 9 +- tools/generateEnvDocs.ts | 3 +- utils/caching/purgeSurrogateTag.ts | 5 +- utils/caching/readOnlyMiddleware.ts | 4 +- utils/caching/skipPurgeConditions.ts | 6 +- utils/workers/constants.js | 3 +- workers/environment.ts | 5 +- workers/queue.ts | 15 ++-- workers/tasks/archive.tsx | 3 +- workers/tasks/export/paged.ts | 6 +- workers/tasks/import/bulk/cli.ts | 4 +- workers/tasks/import/import.ts | 3 +- workers/tasks/search.ts | 5 +- 50 files changed, 319 insertions(+), 230 deletions(-) diff --git a/localDatabase.js b/localDatabase.js index 1c403cf56..371fa1ba5 100644 --- a/localDatabase.js +++ b/localDatabase.js @@ -2,21 +2,20 @@ const path = require('path'); const { createAndRunPostgresDatabase } = require('server/database'); +const { env } = require('server/env'); const { isProd } = require('utils/environment'); const setupLocalDatabase = async (definitely) => { if (isProd()) { throw new Error('Refusing to set up local database in production environment.'); } else if (definitely || process.env.USE_LOCAL_DB) { - process.env.DATABASE_URL = await createAndRunPostgresDatabase({ + env.DATABASE_URL = await createAndRunPostgresDatabase({ username: 'pubpubdbadmin', password: 'pubpub-db-password', dbName: 'pubpub-localdb', dbPath: path.join(process.cwd(), 'pubpub-localdb'), drop: false, }); - process.env.DATABASE_READ_REPLICA_1_URL = process.env.DATABASE_URL; - process.env.DATABASE_READ_REPLICA_2_URL = process.env.DATABASE_URL; } }; diff --git a/server/analytics/api.ts b/server/analytics/api.ts index 78f660f9a..68b561ba7 100644 --- a/server/analytics/api.ts +++ b/server/analytics/api.ts @@ -6,6 +6,7 @@ import { initServer } from '@ts-rest/express'; import { getCountryForTimezone } from 'countries-and-timezones'; import express from 'express'; +import { env } from 'server/env'; import { contract } from 'utils/api/contract'; const s = initServer(); @@ -13,12 +14,12 @@ const s = initServer(); const sendToStitch = async ( payload: AnalyticsEvent & { country: string | null; countryCode: string | null }, ) => { - if (!process.env.STITCH_WEBHOOK_URL) { + if (!env.STITCH_WEBHOOK_URL) { // throw new Error('Missing STITCH_WEBHOOK_URL'); return null; } - const response = await fetch(process.env.STITCH_WEBHOOK_URL, { + const response = await fetch(env.STITCH_WEBHOOK_URL, { method: 'POST', body: JSON.stringify(payload), headers: { diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts index 772b4cec0..014ca0ba9 100644 --- a/server/apiRoutes.ts +++ b/server/apiRoutes.ts @@ -13,6 +13,7 @@ import { router as devApiRouter } from './dev/api'; import { router as discussionRouter } from './discussion/api'; import { router as doiRouter } from './doi/api'; import { router as editorRouter } from './editor/api'; +import { env } from './env'; import { router as integrationDataOAuth1Router } from './integrationDataOAuth1/api'; import { router as landingPageFeatureRouter } from './landingPageFeature/api'; import { router as layoutRouter } from './layout/api'; @@ -74,7 +75,7 @@ const apiRouter = Router() .use(zoteroIntegrationRouter) .use(apiDocsRouter); -if (!isProd() && process.env.NODE_ENV !== 'test') { +if (!isProd() && env.NODE_ENV !== 'test') { apiRouter.use(devApiRouter); } diff --git a/server/debug/api.ts b/server/debug/api.ts index 76c22ba23..324deba95 100644 --- a/server/debug/api.ts +++ b/server/debug/api.ts @@ -3,12 +3,14 @@ import { type Request, type Response, Router } from 'express'; +import { env } from 'server/env'; + import { poolOptions, sequelize } from '../sequelize'; export const router = Router(); // adjust path as needed export const poolStatsHandler = (req: Request, res: Response) => { - if (process.env.NODE_ENV === 'production') { + if (env.NODE_ENV === 'production') { return res.status(404).json({ error: 'Not available in production' }); } diff --git a/server/dev/api.ts b/server/dev/api.ts index 74f31533f..e84d42301 100644 --- a/server/dev/api.ts +++ b/server/dev/api.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; +import { env } from 'server/env'; import { Community } from 'server/models'; import { BadRequestError, ForbiddenError, NotFoundError } from 'server/utils/errors'; import { wrap } from 'server/wrap'; @@ -8,14 +9,24 @@ import { canSelectCommunityForDevelopment } from 'utils/environment'; export const router = Router(); export const setSubdomain = async (subdomain: string | null) => { - process.env.FORCE_BASE_PUBPUB = subdomain === null ? 'true' : ''; - if (subdomain) { - const exists = await Community.findOne({ where: { subdomain } }); - if (!exists) { - throw new NotFoundError(); - } - process.env.PUBPUB_LOCAL_COMMUNITY = subdomain; + const isBasePubPub = subdomain === null; + env.FORCE_BASE_PUBPUB = isBasePubPub; + + if (isBasePubPub) { + env.PUBPUB_LOCAL_COMMUNITY = undefined; + return; + } + + if (!subdomain) { + return; } + + const exists = await Community.findOne({ where: { subdomain } }); + if (!exists) { + throw new NotFoundError(); + } + + env.PUBPUB_LOCAL_COMMUNITY = subdomain; }; router.post( diff --git a/server/doi/submit.ts b/server/doi/submit.ts index 8fdb1dcb2..2202577b1 100644 --- a/server/doi/submit.ts +++ b/server/doi/submit.ts @@ -3,6 +3,7 @@ import { Readable } from 'stream'; import xmlbuilder from 'xmlbuilder'; import { getCommunityDepositTarget } from 'server/depositTarget/queries'; +import { env } from 'server/env'; import { expect } from 'utils/assert'; import { aes256Decrypt } from 'utils/crypto'; @@ -13,17 +14,13 @@ const getDoiLogin = async (communityId: string) => { if (username && password && passwordInitVec) { return { login: username, - password: aes256Decrypt( - password, - expect(process.env.AES_ENCRYPTION_KEY), - passwordInitVec, - ), + password: aes256Decrypt(password, expect(env.AES_ENCRYPTION_KEY), passwordInitVec), }; } } return { - login: process.env.DOI_LOGIN_ID, - password: process.env.DOI_LOGIN_PASSWORD, + login: env.DOI_LOGIN_ID, + password: env.DOI_LOGIN_PASSWORD, }; }; @@ -32,7 +29,7 @@ export const submitDoiData = async ( timestamp: number, communityId: string, ) => { - const { DOI_SUBMISSION_URL } = process.env; + const DOI_SUBMISSION_URL = env.DOI_SUBMISSION_URL; if (!DOI_SUBMISSION_URL) { throw new Error('DOI_SUBMISSION_URL environment variable not set'); diff --git a/server/doi/validate.ts b/server/doi/validate.ts index 275605b2a..3609041d7 100644 --- a/server/doi/validate.ts +++ b/server/doi/validate.ts @@ -3,6 +3,7 @@ import type { RelationTypeName } from 'utils/pubEdge/relations'; import cheerio from 'cheerio'; import fetch from 'node-fetch'; +import { env } from 'server/env'; import { type ExternalPublication, type Pub, PubEdge } from 'server/models'; import { getPubEdgeIncludes } from 'server/utils/queryHelpers/pubEdgeOptions'; import { pubEdgeQueries, runQueries } from 'server/utils/scrape'; @@ -39,7 +40,7 @@ const isReviewRelationType = (relationType: RelationTypeName) => REVIEW_RELATION_TYPES.includes(relationType); const isTestEnvironment = () => { - const submissionUrl = process.env.DOI_SUBMISSION_URL || ''; + const submissionUrl = env.DOI_SUBMISSION_URL ?? ''; return submissionUrl.includes('test') || submissionUrl.includes('sandbox'); }; diff --git a/server/env.ts b/server/env.ts index d12cd0603..6873e6ac2 100644 --- a/server/env.ts +++ b/server/env.ts @@ -1,8 +1,12 @@ import { z } from 'zod'; -import { envSchema } from './envSchema'; +import { type Env, envSchema } from './envSchema'; + +type EnvKey = keyof Env; +type RawEnvSnapshot = Partial>; export type { Env } from './envSchema'; + export { envSchema } from './envSchema'; function parseEnv() { @@ -10,7 +14,7 @@ function parseEnv() { return envSchema.parse(process.env); } catch (e) { if (e instanceof z.ZodError) { - console.error('❌ Invalid environment variables:'); + console.error('Invalid environment variables:'); for (const issue of e.issues) { console.error(` ${issue.path.join('.')}: ${issue.message}`); } @@ -20,13 +24,89 @@ function parseEnv() { } } -/** - * Validated, typed environment variables. - * - * Import this instead of accessing `process.env` directly: - * ```ts - * import { env } from 'server/env'; - * const port = env.PORT; // number, typed - * ``` - */ -export const env = parseEnv(); +const envKeys = Object.keys(envSchema.shape) as EnvKey[]; +const envKeySet = new Set(envKeys); + +let parsedEnvCache: Env | null = null; +let rawEnvSnapshot: RawEnvSnapshot | null = null; + +const createRawSnapshot = (): RawEnvSnapshot => { + return envKeys.reduce((acc, key) => { + acc[key] = process.env[key]; + return acc; + }, {}); +}; + +const hasRawEnvChanged = (): boolean => { + if (!rawEnvSnapshot) { + return true; + } + + return envKeys.some((key) => process.env[key] !== rawEnvSnapshot?.[key]); +}; + +const parseAndCacheEnv = (): Env => { + const parsedEnv = parseEnv(); + parsedEnvCache = parsedEnv; + rawEnvSnapshot = createRawSnapshot(); + return parsedEnv; +}; + +const getParsedEnv = (): Env => { + if (!parsedEnvCache || hasRawEnvChanged()) { + return parseAndCacheEnv(); + } + + return parsedEnvCache; +}; + +const setProcessEnvValue = (key: K, value: Env[K]) => { + if (value === undefined || value === null) { + delete process.env[key]; + return; + } + + process.env[key] = String(value); +}; + +export const refreshEnv = () => parseAndCacheEnv(); + +export const setEnv = (key: K, value: Env[K]) => { + setProcessEnvValue(key, value); + parsedEnvCache = null; + rawEnvSnapshot = null; +}; + +export const env: Env = new Proxy({} as Env, { + get: (_, property) => { + if (typeof property !== 'string' || !envKeySet.has(property)) { + return undefined; + } + + return getParsedEnv()[property as EnvKey]; + }, + set: (_, property, value) => { + if (typeof property !== 'string' || !envKeySet.has(property)) { + return false; + } + + setEnv(property as EnvKey, value as Env[EnvKey]); + return true; + }, + has: (_, property) => { + return typeof property === 'string' && envKeySet.has(property); + }, + ownKeys: () => envKeys as string[], + getOwnPropertyDescriptor: (_, property) => { + if (typeof property !== 'string' || !envKeySet.has(property)) { + return undefined; + } + + return { + enumerable: true, + configurable: true, + writable: true, + value: getParsedEnv()[property as EnvKey], + }; + }, +}); diff --git a/server/envSchema.ts b/server/envSchema.ts index 27f3dbd8e..114bc206d 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -32,32 +32,18 @@ export const envSchema = z.object({ .string() .optional() .describe('Slug of the community to proxy in local dev (e.g. "stanford-jblp")'), - FORCE_BASE_PUBPUB: booleanish.describe( - 'Force the base PubPub site in development/QubQub mode', - ), + FORCE_BASE_PUBPUB: booleanish.describe('Force the base PubPub site in development/QubQub mode'), PUBPUB_READ_ONLY: booleanish.describe('Enable read-only mode, disabling all mutations'), DISABLE_SSL_REDIRECT: booleanish.describe('Disable automatic HTTP → HTTPS redirect'), // ── Database ───────────────────────────────────────────────────────── DATABASE_URL: z.string().url().describe('Primary PostgreSQL connection URL'), - DATABASE_READ_REPLICA_1_URL: z - .string() - .url() - .optional() - .describe('PostgreSQL read-replica 1 URL'), - DATABASE_READ_REPLICA_2_URL: z - .string() - .url() - .optional() - .describe('PostgreSQL read-replica 2 URL'), // ── Sequelize Pool ────────────────────────────────────────────────── SEQUELIZE_MAX_CONNECTIONS: optionalInt.describe( 'Max DB pool connections (default: 20 for server, 5 for workers)', ), - SEQUELIZE_IDLE_TIMEOUT: optionalInt.describe( - 'DB pool idle timeout in ms (default: 60000)', - ), + SEQUELIZE_IDLE_TIMEOUT: optionalInt.describe('DB pool idle timeout in ms (default: 60000)'), SEQUELIZE_ACQUIRE_TIMEOUT: optionalInt.describe( 'DB pool acquire timeout in ms (default: 10000)', ), @@ -86,28 +72,18 @@ export const envSchema = z.object({ .optional() .describe('Firebase Realtime Database URL for test env'), - // ── AWS / S3 ───────────────────────────────────────────────────────── + // ── S3 ───────────────────────────────────────────────────────── AWS_ACCESS_KEY_ID: z.string().min(1).describe('AWS access key for S3 uploads'), AWS_SECRET_ACCESS_KEY: z.string().min(1).describe('AWS secret key for S3 uploads'), - AWS_BACKUP_ACCESS_KEY_ID: z - .string() - .optional() - .describe('AWS access key for backup operations'), - AWS_BACKUP_SECRET_ACCESS_KEY: z - .string() - .optional() - .describe('AWS secret key for backup operations'), - // ── S3 Backup ──────────────────────────────────────────────────────── - S3_BACKUP_ENDPOINT: z.string().optional().describe('S3-compatible endpoint for backups'), - S3_BACKUP_ACCESS_KEY: z - .string() - .optional() - .describe('S3 backup access key (if different from AWS)'), - S3_BACKUP_SECRET_KEY: z - .string() - .optional() - .describe('S3 backup secret key (if different from AWS)'), + // ── Backup ───────────────────────────────────────────────────────── + AWS_BACKUP_ACCESS_KEY_ID: z.string().describe('AWS access key for backup operations'), + AWS_BACKUP_SECRET_ACCESS_KEY: z.string().describe('AWS secret key for backup operations'), + + // ── S3 Backup, again? ─────────────────────────────────────────────── + S3_BACKUP_ENDPOINT: z.string().describe('S3-compatible endpoint for backups'), + S3_BACKUP_ACCESS_KEY: z.string().describe('S3 backup access key (if different from AWS)'), + S3_BACKUP_SECRET_KEY: z.string().describe('S3 backup secret key (if different from AWS)'), S3_BACKUP_BUCKET: z.string().optional().describe('S3 bucket name for backups'), S3_BACKUP_KEY_PREFIX: z .string() @@ -128,45 +104,50 @@ export const envSchema = z.object({ BYPASS_CAPTCHA: booleanish.describe('Bypass captcha checks (dev/test only)'), // ── Encryption ────────────────────────────────────────────────────── - AES_ENCRYPTION_KEY: z.string().min(1).describe('AES-256 key for encrypting deposit credentials'), + AES_ENCRYPTION_KEY: z + .string() + .min(1) + .describe('AES-256 key for encrypting deposit credentials'), // ── DOI / CrossRef ────────────────────────────────────────────────── - DOI_LOGIN_ID: z.string().optional().describe('CrossRef DOI deposit login ID'), - DOI_LOGIN_PASSWORD: z.string().optional().describe('CrossRef DOI deposit login password'), - DOI_SUBMISSION_URL: z.string().url().optional().describe('CrossRef DOI deposit endpoint URL'), + DOI_LOGIN_ID: z.string().describe('CrossRef DOI deposit login ID'), + DOI_LOGIN_PASSWORD: z.string().describe('CrossRef DOI deposit login password'), + DOI_SUBMISSION_URL: z.string().url().describe('CrossRef DOI deposit endpoint URL'), // ── DataCite ───────────────────────────────────────────────────────── - DATACITE_DEPOSIT_URL: z.string().url().optional().describe('DataCite DOI deposit endpoint URL'), + DATACITE_DEPOSIT_URL: z.string().url().describe('DataCite DOI deposit endpoint URL'), // ── Message Queues ────────────────────────────────────────────────── - CLOUDAMQP_URL: z.string().optional().describe('CloudAMQP (RabbitMQ) connection URL'), + CLOUDAMQP_URL: z.string().describe('CloudAMQP (RabbitMQ) connection URL'), // ── Zotero ────────────────────────────────────────────────────────── - ZOTERO_CLIENT_KEY: z.string().optional().describe('Zotero OAuth1 consumer key'), - ZOTERO_CLIENT_SECRET: z.string().optional().describe('Zotero OAuth1 consumer secret'), + ZOTERO_CLIENT_KEY: z.string().describe('Zotero OAuth1 consumer key'), + ZOTERO_CLIENT_SECRET: z.string().describe('Zotero OAuth1 consumer secret'), // ── CDN / Fastly ──────────────────────────────────────────────────── - FASTLY_SERVICE_ID_PROD: z.string().optional().describe('Fastly service ID for production'), - FASTLY_PURGE_TOKEN_PROD: z.string().optional().describe('Fastly purge token for production'), - FASTLY_SERVICE_ID_DUQDUQ: z.string().optional().describe('Fastly service ID for DuqDuq'), - FASTLY_PURGE_TOKEN_DUQDUQ: z.string().optional().describe('Fastly purge token for DuqDuq'), - PURGE_TOKEN: z.string().optional().describe('Legacy Fastly purge token'), + FASTLY_SERVICE_ID_PROD: z.string().describe('Fastly service ID for production'), + FASTLY_PURGE_TOKEN_PROD: z.string().describe('Fastly purge token for production'), + FASTLY_SERVICE_ID_DUQDUQ: z.string().describe('Fastly service ID for DuqDuq'), + FASTLY_PURGE_TOKEN_DUQDUQ: z.string().describe('Fastly purge token for DuqDuq'), + PURGE_TOKEN: z.string().describe('Legacy Fastly purge token'), // ── PubStash (Export) ─────────────────────────────────────────────── PUBSTASH_URL: z .string() .default('http://pubstash:8080') .describe('PubStash service URL for paged exports'), - PUBSTASH_ACCESS_KEY: z.string().optional().describe('PubStash access key'), + PUBSTASH_ACCESS_KEY: z.string().describe('PubStash access key'), // ── Webhooks / Integrations ───────────────────────────────────────── - SLACK_WEBHOOK_URL: z.string().optional().describe('Slack incoming webhook URL for notifications'), - STITCH_WEBHOOK_URL: z.string().optional().describe('MongoDB Stitch webhook URL for analytics'), + SLACK_WEBHOOK_URL: z.string().describe('Slack incoming webhook URL for notifications'), // ── Observability ─────────────────────────────────────────────────── - METABASE_SECRET_KEY: z.string().optional().describe('Metabase embedding secret key'), - SENTRY_AUTH_TOKEN: z.string().optional().describe('Sentry auth token (build-time only)'), - SENTRY_ORG: z.string().optional().describe('Sentry organization slug'), + SENTRY_AUTH_TOKEN: z.string().describe('Sentry auth token (build-time only)'), + SENTRY_ORG: z.string().describe('Sentry organization slug'), + + // ── Analytics ─────────────────────────────────────────────────────── + METABASE_SECRET_KEY: z.string().describe('Metabase embedding secret key'), + STITCH_WEBHOOK_URL: z.string().describe('MongoDB Stitch webhook URL for analytics'), // ── Spam / Security ───────────────────────────────────────────────── BLOCKLIST_IP_ADDRESSES: z diff --git a/server/routes/pubDocument.tsx b/server/routes/pubDocument.tsx index 802a7a235..c7bf8e866 100644 --- a/server/routes/pubDocument.tsx +++ b/server/routes/pubDocument.tsx @@ -9,6 +9,7 @@ import slowDown from 'express-slow-down'; import { chooseCollectionForPub } from 'client/utils/collections'; import { getCustomScriptsForCommunity } from 'server/customScript/queries'; +import { env } from 'server/env'; import Html from 'server/Html'; import { createUserScopeVisit } from 'server/userScopeVisit/queries'; import { findUserSubscription } from 'server/userSubscription/shared/queries'; @@ -182,7 +183,7 @@ const getEnrichedPubData = async (options: EnrichedPubOptions) => { * useful anyway. */ const speedLimiter: RequestHandler = - process.env.NODE_ENV === 'test' + env.NODE_ENV === 'test' ? (req, res, next) => next() : slowDown({ windowMs: 60000, // 1 minute for requests to be kept in memory. value of 60000ms is default but expressed here for clarity diff --git a/server/routes/search.tsx b/server/routes/search.tsx index d074f3250..9fbd1dd58 100644 --- a/server/routes/search.tsx +++ b/server/routes/search.tsx @@ -6,6 +6,7 @@ import algoliasearch from 'algoliasearch'; import { Router } from 'express'; import { getCustomScriptsForCommunity } from 'server/customScript/queries'; +import { env } from 'server/env'; import Html from 'server/Html'; import { handleErrors } from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; @@ -13,9 +14,9 @@ import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; export const router = Router(); -const client = algoliasearch(process.env.ALGOLIA_ID!, process.env.ALGOLIA_KEY!); -const searchId = process.env.ALGOLIA_ID!; -const searchKey = process.env.ALGOLIA_SEARCH_KEY!; +const client = algoliasearch(env.ALGOLIA_ID, env.ALGOLIA_KEY); +const searchId = env.ALGOLIA_ID; +const searchKey = env.ALGOLIA_SEARCH_KEY; const filterValueAgainstKeys = (keys: string[], value: string) => { const keyValuePairs = keys.map((key) => `${key}:${value}`); diff --git a/server/routes/sitemap.ts b/server/routes/sitemap.ts index 1d19a2cce..cff29598b 100644 --- a/server/routes/sitemap.ts +++ b/server/routes/sitemap.ts @@ -6,6 +6,7 @@ import * as stream from 'stream'; import { promisify } from 'util'; import { createGzip } from 'zlib'; +import { env } from 'server/env'; import { Page, Pub, Release } from 'server/models'; import { getInitialData } from 'server/utils/initData'; import { hostIsValid } from 'server/utils/routes'; @@ -17,8 +18,8 @@ import { isProd } from 'utils/environment'; export const router = Router(); const s3 = createPubPubS3Client({ - accessKeyId: process.env.AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, bucket: 'sitemaps.pubpub.org', ACL: 'public-read', }); diff --git a/server/sequelize.ts b/server/sequelize.ts index d57a887ed..bd607a608 100644 --- a/server/sequelize.ts +++ b/server/sequelize.ts @@ -11,6 +11,7 @@ import { Sequelize } from 'sequelize-typescript'; /* eslint-enable */ import { abortStorage } from './abort'; +import { env } from './env'; import { DatabaseRequestAbortedError } from './utils/errors'; // cls-hooked-compatible namespace backed by AsyncLocalStorage. @@ -41,7 +42,7 @@ export const clsNamespace = { // biome-ignore lint/correctness/useHookAtTopLevel: not a react hook Sequelize.useCLS(clsNamespace as any); -const database_url = process.env.DATABASE_URL; +const database_url = env.DATABASE_URL; class SequelizeWithId extends Sequelize { /* Create standard id type for our database */ @@ -56,28 +57,15 @@ class SequelizeWithId extends Sequelize { }; } -if (!database_url) { - console.log('Process.env:', process.env); - throw new Error('DATABASE_URL environment variable not set'); -} - const useSSL = database_url.includes('.'); export const poolOptions = { - max: process.env.SEQUELIZE_MAX_CONNECTIONS - ? parseInt(process.env.SEQUELIZE_MAX_CONNECTIONS, 10) - : 20, + max: env.SEQUELIZE_MAX_CONNECTIONS ?? 20, evict: 10_000, min: 2, - idle: process.env.SEQUELIZE_IDLE_TIMEOUT - ? parseInt(process.env.SEQUELIZE_IDLE_TIMEOUT, 10) - : 60_000, - acquire: process.env.SEQUELIZE_ACQUIRE_TIMEOUT - ? parseInt(process.env.SEQUELIZE_ACQUIRE_TIMEOUT, 10) - : 10_000, - maxUses: process.env.SEQUELIZE_MAX_USES - ? parseInt(process.env.SEQUELIZE_MAX_USES, 10) - : Infinity, + idle: env.SEQUELIZE_IDLE_TIMEOUT ?? 60_000, + acquire: env.SEQUELIZE_ACQUIRE_TIMEOUT ?? 10_000, + maxUses: env.SEQUELIZE_MAX_USES ?? Infinity, } satisfies PoolOptions; // this is to avoid thundering herd @@ -119,7 +107,7 @@ export const knexInstance = knex({ client: 'pg' }); /* Change to true to update the model in the database. */ /* NOTE: This being set to true will erase your data. */ -if (process.env.NODE_ENV !== 'test') { +if (env.NODE_ENV !== 'test') { sequelize.sync({ force: false }).then(async () => { // Install search triggers and backfill tsvector columns const { installSearchTriggers, backfillPubSearchVectors, backfillCommunitySearchVectors } = diff --git a/server/server.ts b/server/server.ts index 5df9cd7be..7890e71b0 100755 --- a/server/server.ts +++ b/server/server.ts @@ -10,6 +10,8 @@ import noSlash from 'no-slash'; import passport from 'passport'; import path from 'path'; +import { env } from './env'; + const app = express(); const appRouter = Router(); @@ -18,20 +20,20 @@ import { getAppCommit, isProd, isQubQub, setAppCommit, setEnvironment } from 'ut // ACHTUNG: These calls must appear before we import any more of our own code to ensure that // the environment, and in particular the choice of dev vs. prod, is configured correctly! -setEnvironment(process.env.PUBPUB_PRODUCTION, process.env.IS_DUQDUQ, process.env.IS_QUBQUB); -if (isQubQub() && !process.env.HEROKU_SLUG_COMMIT) { +setEnvironment(env.PUBPUB_PRODUCTION, env.IS_DUQDUQ, env.IS_QUBQUB); +if (isQubQub() && !env.HEROKU_SLUG_COMMIT) { try { setAppCommit(fs.readFileSync('.app-commit').toString()); } catch (err) { console.error('Unable to read app commit from .app-commit file: ', err); } } else { - setAppCommit(process.env.HEROKU_SLUG_COMMIT); + setAppCommit(env.HEROKU_SLUG_COMMIT); } import { errorMiddleware, HTTPStatusError } from 'server/utils/errors'; -if (process.env.NODE_ENV !== 'test') { +if (env.NODE_ENV !== 'test') { require('server/utils/serverModuleOverwrite'); } @@ -56,7 +58,7 @@ const errorHandler: ErrorRequestHandler = (err, req, res, next) => { } // Log the error if we're testing. Normally this is handled in the error middleware, but // that isn't active while handling individual requests in a test environment. - if (process.env.NODE_ENV === 'test' && !(err instanceof HTTPStatusError)) { + if (env.NODE_ENV === 'test' && !(err instanceof HTTPStatusError)) { // biome-ignore lint/suspicious/noConsole: shhhhhh console.log('Got an error in an API route while testing:', err); } @@ -69,10 +71,10 @@ import { contract } from 'utils/api/contract'; import { server } from 'utils/api/server'; // just hardcoded blocking, very bad, but we really need it -// set process.env.BLOCKLIST_IP_ADDRESSES to comma separated list of ips (or partial ips) to block +// set BLOCKLIST_IP_ADDRESSES to comma separated list of ips (or partial ips) to block appRouter.use(blocklistMiddleware); -if (process.env.NODE_ENV === 'production') { +if (env.NODE_ENV === 'production') { Sentry.init({ dsn: 'https://abe1c84bbb3045bd982f9fea7407efaa@sentry.io/1505439', environment: isProd() ? 'prod' : 'dev', @@ -87,7 +89,7 @@ if (process.env.NODE_ENV === 'production') { // The Sentry request handler must be the first middleware on the app appRouter.use(Sentry.Handlers.requestHandler({ user: ['id', 'slug'] })); appRouter.use(Sentry.Handlers.tracingHandler()); - if (process.env.DISABLE_SSL_REDIRECT !== 'true') { + if (!env.DISABLE_SSL_REDIRECT) { appRouter.use(enforce.HTTPS({ trustProtoHeader: true })); } } @@ -123,7 +125,7 @@ appRouter.use( secret: 'sessionsecret', resave: false, saveUninitialized: false, - store: process.env.NODE_ENV !== 'test' ? new SequelizeStore({ db: sequelize }) : undefined, + store: env.NODE_ENV !== 'test' ? new SequelizeStore({ db: sequelize }) : undefined, cookie: { path: '/', /* These are necessary for */ @@ -185,13 +187,11 @@ process.on('uncaughtException', (err) => { }); /** Same as Heroku's default timeout */ -const TIMEOUT_MS = process.env.REQUEST_TIMEOUT_MS - ? parseInt(process.env.REQUEST_TIMEOUT_MS, 10) - : 30_000; +const TIMEOUT_MS = env.REQUEST_TIMEOUT_MS; appRouter.use((req, res, next) => { // don't abort requests in test environment - if (process.env.NODE_ENV === 'test') { + if (env.NODE_ENV === 'test') { return next(); } @@ -226,15 +226,16 @@ appRouter.use((req, res, next) => { // @ts-expect-error req.headers.host = req.headers.communityhostname; } + + const localCommunity = env.PUBPUB_LOCAL_COMMUNITY; if ( - process.env.PUBPUB_LOCAL_COMMUNITY || + localCommunity || req.hostname.includes('localhost') || req.hostname.includes('127.0.0.1') ) { req.headers.localhost = req.headers.host; - if (process.env.PUBPUB_LOCAL_COMMUNITY) { - const subdomain = process.env.PUBPUB_LOCAL_COMMUNITY; - req.headers.host = `${subdomain}.duqduq.org`; + if (localCommunity) { + req.headers.host = `${localCommunity}.duqduq.org`; } else { req.headers.host = 'demo.pubpub.org'; } @@ -298,7 +299,7 @@ createExpressEndpoints(contractWithoutCustomScript, server, appRouter, { } const prettifiedError = fromZodError(error); - if (process.env.NODE_ENV !== 'production') { + if (env.NODE_ENV !== 'production') { console.error(prettifiedError); } @@ -322,7 +323,7 @@ appRouter.use(rootRouter); /* ------------- */ /* Error Handlers */ /* ------------- */ -if (process.env.NODE_ENV === 'production') { +if (env.NODE_ENV === 'production') { // The Sentry error handler must be before any other error middleware appRouter.use(Sentry.Handlers.errorHandler()); } @@ -334,7 +335,7 @@ app.use(appRouter); /* ------------ */ /* Start Server */ /* ------------ */ -const port = process.env.PORT || 9876; +const port = env.PORT; export const startServer = () => { return app.listen( port, @@ -345,7 +346,7 @@ export const startServer = () => { } console.info( `==> Sequelize Max Connections:, - ${process.env.SEQUELIZE_MAX_CONNECTIONS ? parseInt(process.env.SEQUELIZE_MAX_CONNECTIONS, 10) : 5}`, + ${env.SEQUELIZE_MAX_CONNECTIONS ?? 5}`, ); console.info('----\n==> 🌎 API is running on port %s', port); console.info('==> 💻 Send requests to http://localhost:%s', port); diff --git a/server/spamTag/commentSpam.ts b/server/spamTag/commentSpam.ts index 8e4c5f370..6c7437a0f 100644 --- a/server/spamTag/commentSpam.ts +++ b/server/spamTag/commentSpam.ts @@ -3,17 +3,15 @@ import type { DocJson, NewAccountLinkCommentTriggerSource, UserSpamTagFields } f import { type Mark, Node } from 'prosemirror-model'; import { editorSchema } from 'client/components/Editor'; +import { env } from 'server/env'; import { SpamTag, User } from 'server/models'; import { contextFromUser, notify } from 'server/spamTag/notifications'; import { upsertSpamTag } from 'server/spamTag/userQueries'; const DEFAULT_NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES = 10; -const parsedWindowMinutes = parseInt( - process.env.NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES || - DEFAULT_NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES.toString(), - 10, -); +const parsedWindowMinutes = + env.NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES ?? DEFAULT_NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES; const IS_WINDOW_MINUTES_VALID = Number.isFinite(parsedWindowMinutes) && parsedWindowMinutes > 0; @@ -156,7 +154,7 @@ export const autoBanForNewAccountLinkComment = async ( fields, }); - const shouldNotify = previousStatus !== 'confirmed-spam' && process.env.NODE_ENV !== 'test'; + const shouldNotify = previousStatus !== 'confirmed-spam' && env.NODE_ENV !== 'test'; if (shouldNotify) { await notify( diff --git a/server/spamTag/notifications/slack.ts b/server/spamTag/notifications/slack.ts index cb4cd5606..45a60332f 100644 --- a/server/spamTag/notifications/slack.ts +++ b/server/spamTag/notifications/slack.ts @@ -1,5 +1,6 @@ import type { BanReason, UserSpamTagFields } from 'types'; +import { env } from 'server/env'; import { postToSlack } from 'server/utils/slack'; import { @@ -15,7 +16,7 @@ type SuspiciousUploadSlackOptions = { }; export const postToSlackAboutSuspiciousUpload = async (opts: SuspiciousUploadSlackOptions) => { - if (process.env.NODE_ENV === 'test') return; + if (env.NODE_ENV === 'test') return; const { userName, uploadKey } = opts; const dashUrl = getSpamDashUrl(userName); await postToSlack({ @@ -51,7 +52,7 @@ type UserBanSlackOptions = { }; export const postToSlackAboutUserBan = async (opts: UserBanSlackOptions) => { - if (process.env.NODE_ENV === 'test') { + if (env.NODE_ENV === 'test') { return; } @@ -109,7 +110,7 @@ type UserLiftedSlackOptions = { }; export const postToSlackAboutUserLifted = async (opts: UserLiftedSlackOptions) => { - if (process.env.NODE_ENV === 'test') return; + if (env.NODE_ENV === 'test') return; const { userName, userSlug, actorName } = opts; const profileUrl = getUserProfileUrl(userSlug); const byText = actorName ? ` by ${actorName}` : ''; @@ -148,7 +149,7 @@ type NewSpamTagSlackOptions = { }; export const postToSlackAboutNewUserSpamTag = async (opts: NewSpamTagSlackOptions) => { - if (process.env.NODE_ENV === 'test') return; + if (env.NODE_ENV === 'test') return; const { userName, userSlug, spamScore } = opts; const profileUrl = getUserProfileUrl(userSlug); const dashUrl = getSpamDashUrl(userName); @@ -195,7 +196,7 @@ type CommunityFlagSlackOptions = { }; export const postToSlackAboutCommunityFlag = async (opts: CommunityFlagSlackOptions) => { - if (process.env.NODE_ENV === 'test') return; + if (env.NODE_ENV === 'test') return; const { userName, userSlug, actorName, communitySubdomain, reason, reasonText } = opts; const profileUrl = getUserProfileUrl(userSlug); const dashUrl = getSpamDashUrl(userName); @@ -239,7 +240,7 @@ export const postToSlackAboutCommunityFlag = async (opts: CommunityFlagSlackOpti }; export const postToSlackAboutCommunityFlagRetracted = async (opts: CommunityFlagSlackOptions) => { - if (process.env.NODE_ENV === 'test') return; + if (env.NODE_ENV === 'test') return; const { userName, userSlug, actorName, communitySubdomain, reason, reasonText } = opts; const profileUrl = getUserProfileUrl(userSlug); const dashUrl = getSpamDashUrl(userName); diff --git a/server/spamTag/uploadScamKeywords.ts b/server/spamTag/uploadScamKeywords.ts index 2daca61d2..adde187a6 100644 --- a/server/spamTag/uploadScamKeywords.ts +++ b/server/spamTag/uploadScamKeywords.ts @@ -1,3 +1,5 @@ +import { env } from 'server/env'; + export const uploadScamKeywords = [ 'robux', 'roblox', @@ -13,7 +15,7 @@ export const uploadScamKeywords = [ ] as const; export const isSuspiciousUploadKey = (keyOrFilename: string): boolean => { - const extraSuspiciousKeywords = process.env.EXTRA_SUSPICIOUS_KEYWORDS?.split(',') ?? []; + const extraSuspiciousKeywords = env.EXTRA_SUSPICIOUS_KEYWORDS?.split(',') ?? []; const lower = keyOrFilename.toLowerCase(); return [...uploadScamKeywords, ...extraSuspiciousKeywords].some((kw) => lower.includes(kw)); }; diff --git a/server/upload/api.ts b/server/upload/api.ts index ed22bf222..459331307 100644 --- a/server/upload/api.ts +++ b/server/upload/api.ts @@ -6,6 +6,7 @@ import bb from 'busboy'; import mime from 'mime-types'; import uuid from 'uuid'; +import { env } from 'server/env'; import { contextFromUser, notify } from 'server/spamTag/notifications'; import { isSuspiciousUploadKey } from 'server/spamTag/uploadScamKeywords'; import { upsertSpamTag } from 'server/spamTag/userQueries'; @@ -28,16 +29,16 @@ export const generateFileNameForUpload = (file: string) => { * are integration testing */ if ( - process.env.NODE_ENV === 'test' && - process.env.INTEGRATION_TESTING && - (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) + env.NODE_ENV === 'test' && + env.INTEGRATION_TESTING && + (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) ) { require('../../config'); } const s3Client = createPubPubS3Client({ - accessKeyId: process.env.AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, bucket: 'assets.pubpub.org', ACL: 'public-read', }); diff --git a/server/uploadPolicy/queries.ts b/server/uploadPolicy/queries.ts index 9cd22aaca..f24d320d2 100644 --- a/server/uploadPolicy/queries.ts +++ b/server/uploadPolicy/queries.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; -import { expect } from 'utils/assert'; +import { env } from 'server/env'; type GetUploadPolicyParams = { contentType: string; @@ -11,8 +11,8 @@ type GetUploadPolicyParams = { export const getUploadPolicy = ({ contentType }: GetUploadPolicyParams) => { const acl = 'public-read'; const bucket = 'assets.pubpub.org'; - const awsAccessKeyId = expect(process.env.AWS_ACCESS_KEY_ID); - const awsAccessKeySecret = process.env.AWS_SECRET_ACCESS_KEY; + const awsAccessKeyId = env.AWS_ACCESS_KEY_ID; + const awsAccessKeySecret = env.AWS_SECRET_ACCESS_KEY; const expirationDate = new Date(Date.now() + 60000); const policyObject = { @@ -33,7 +33,6 @@ export const getUploadPolicy = ({ contentType }: GetUploadPolicyParams) => { .toString('base64') .replace(/\n|\r/, ''); - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | undefined' is not assig... Remove this comment to see the full error message const hmac = crypto.createHmac('sha1', awsAccessKeySecret); hmac.update(policy); const signature = hmac.digest('base64'); diff --git a/server/userNotification/hooks.ts b/server/userNotification/hooks.ts index a4988d902..335661b85 100644 --- a/server/userNotification/hooks.ts +++ b/server/userNotification/hooks.ts @@ -4,6 +4,7 @@ import type * as types from 'types'; import mailgun from 'mailgun.js'; import stripIndent from 'strip-indent'; +import { env } from 'server/env'; import { ActivityItem, Community, @@ -19,7 +20,7 @@ import * as urls from 'utils/canonicalUrls'; export const mg = mailgun.client({ username: 'api', - key: process.env.MAILGUN_API_KEY!, + key: env.MAILGUN_API_KEY, }); const template = async (activityItem: types.ActivityItemOfKind<'pub-discussion-comment-added'>) => { diff --git a/server/utils/blocklist.ts b/server/utils/blocklist.ts index 4f85e31b7..1b1db936d 100644 --- a/server/utils/blocklist.ts +++ b/server/utils/blocklist.ts @@ -1,8 +1,10 @@ import type { RequestHandler } from 'express'; +import { env } from 'server/env'; + export const blocklistMiddleware: RequestHandler = async (req, res, next) => { /** You are only allowed to access API */ - const maybeBlockList = process.env.BLOCKLIST_IP_ADDRESSES?.split(',') || []; + const maybeBlockList = env.BLOCKLIST_IP_ADDRESSES?.split(',') || []; if (!maybeBlockList.length) { return next(); diff --git a/server/utils/captcha.ts b/server/utils/captcha.ts index 0947e190d..028e2beec 100644 --- a/server/utils/captcha.ts +++ b/server/utils/captcha.ts @@ -1,5 +1,6 @@ import { verifySolution } from 'altcha-lib'; +import { env } from 'server/env'; import { isProd } from 'utils/environment'; /** @@ -22,14 +23,14 @@ import { isProd } from 'utils/environment'; const DEV_HMAC_KEY = 'dev-altcha-hmac-key-do-not-use-in-production'; export const getAltchaHmacKey = (): string => { - const key = process.env.ALTCHA_HMAC_KEY; + const key = env.ALTCHA_HMAC_KEY; if (isProd() && !key) { throw new Error('ALTCHA_HMAC_KEY must be set in production'); } return key ?? DEV_HMAC_KEY; }; -export const isCaptchaBypassed = (): boolean => process.env.BYPASS_CAPTCHA === 'true'; +export const isCaptchaBypassed = (): boolean => env.BYPASS_CAPTCHA; export const verifyCaptchaPayload = async (payload: unknown): Promise => { if (isCaptchaBypassed()) return true; diff --git a/server/utils/email/communityServices.ts b/server/utils/email/communityServices.ts index 5b51640aa..de3ab1ce6 100644 --- a/server/utils/email/communityServices.ts +++ b/server/utils/email/communityServices.ts @@ -1,9 +1,11 @@ import mailgun from 'mailgun.js'; import stripIndent from 'strip-indent'; +import { env } from 'server/env'; + const mg = mailgun.client({ username: 'api', - key: process.env.MAILGUN_API_KEY!, + key: env.MAILGUN_API_KEY, }); export const sendServicesInquiryEmail = ({ contactEmail, additionalDetails, selections }) => { diff --git a/server/utils/email/reset.ts b/server/utils/email/reset.ts index 8c3d0b2ac..76dd9983f 100644 --- a/server/utils/email/reset.ts +++ b/server/utils/email/reset.ts @@ -1,9 +1,11 @@ import mailgun from 'mailgun.js'; import stripIndent from 'strip-indent'; +import { env } from 'server/env'; + export const mg = mailgun.client({ username: 'api', - key: process.env.MAILGUN_API_KEY!, + key: env.MAILGUN_API_KEY, }); type From = { name: string; address: string }; diff --git a/server/utils/email/signup.ts b/server/utils/email/signup.ts index 981f40704..e5096320d 100644 --- a/server/utils/email/signup.ts +++ b/server/utils/email/signup.ts @@ -1,9 +1,11 @@ import mailgun from 'mailgun.js'; import stripIndent from 'strip-indent'; +import { env } from 'server/env'; + const mg = mailgun.client({ username: 'api', - key: process.env.MAILGUN_API_KEY!, + key: env.MAILGUN_API_KEY, }); export const sendSignupEmail = ({ toEmail, signupUrl }) => { diff --git a/server/utils/errors.ts b/server/utils/errors.ts index f4556c1fd..56f377d68 100644 --- a/server/utils/errors.ts +++ b/server/utils/errors.ts @@ -5,6 +5,8 @@ import type { ForbiddenSlugStatus } from 'types'; import * as Sentry from '@sentry/node'; import { resolve } from 'path'; +import { env } from 'server/env'; + import { isRequestAborted } from '../abort'; export enum PubPubApplicationError { @@ -119,7 +121,7 @@ export const handleErrors = (req: Request, res: Response, next: NextFunction) => return next(); } console.error('Err', err); - if (process.env.NODE_ENV === 'production') { + if (env.NODE_ENV === 'production') { Sentry.configureScope((scope) => { scope.setTag('error_source', 'server_error_handler'); }); diff --git a/server/utils/firebaseAdmin.ts b/server/utils/firebaseAdmin.ts index 3265d737b..7adf1555c 100644 --- a/server/utils/firebaseAdmin.ts +++ b/server/utils/firebaseAdmin.ts @@ -13,6 +13,7 @@ import { getLatestKeyAndTimestamp, } from 'components/Editor'; import { createFirebaseChange, storeCheckpoint } from 'components/Editor/utils'; +import { env } from 'server/env'; import { Draft, Pub } from 'server/models'; import { expect } from 'utils/assert'; import { getFirebaseConfig } from 'utils/editor/firebaseConfig'; @@ -21,14 +22,14 @@ const getFirebaseApp = () => { if (firebaseAdmin.apps.length > 0) { return firebaseAdmin.apps[0]; } - if (process.env.NODE_ENV === 'test') { - if (process.env.FIREBASE_TEST_DB_URL) { - return firebaseAdmin.initializeApp({ databaseURL: process.env.FIREBASE_TEST_DB_URL }); + if (env.NODE_ENV === 'test') { + if (env.FIREBASE_TEST_DB_URL) { + return firebaseAdmin.initializeApp({ databaseURL: env.FIREBASE_TEST_DB_URL }); } return null; } const serviceAccount = JSON.parse( - Buffer.from(process.env.FIREBASE_SERVICE_ACCOUNT_BASE64 as string, 'base64').toString(), + Buffer.from(env.FIREBASE_SERVICE_ACCOUNT_BASE64, 'base64').toString(), ); // biome-ignore lint/suspicious/noConsole: shhhhhh console.log(`Firebase App will use: ${getFirebaseConfig().databaseURL}`); diff --git a/server/utils/initData.ts b/server/utils/initData.ts index c7b3cc2f9..e6349dab4 100644 --- a/server/utils/initData.ts +++ b/server/utils/initData.ts @@ -2,6 +2,7 @@ import type * as types from 'types'; import queryString from 'query-string'; +import { env } from 'server/env'; import { getFeatureFlagsForUserAndCommunity } from 'server/featureFlag/queries'; import { isUserMemberOfScope } from 'server/member/queries'; import { UserNotification } from 'server/models'; @@ -144,7 +145,7 @@ export const getInitialData = async ( if ( (communityData.domain && whereQuery.subdomain && - process.env.NODE_ENV === 'production' && + env.NODE_ENV === 'production' && isProd()) || (communityData.domain && communityData.domain === 'duqduqdomaintest.underlay.org' && diff --git a/server/utils/layouts/layoutPubs.ts b/server/utils/layouts/layoutPubs.ts index 3f3bcf68e..d5f74014c 100644 --- a/server/utils/layouts/layoutPubs.ts +++ b/server/utils/layouts/layoutPubs.ts @@ -2,6 +2,7 @@ import type { InitialData, Maybe, PubsQueryOrdering, SanitizedPubData } from 'ty import { QueryTypes } from 'sequelize'; +import { env } from 'server/env'; import { sequelize } from 'server/models'; import { getPubsById, queryPubIds } from 'server/pub/queryMany'; import { @@ -367,9 +368,7 @@ const shouldUsePerBlockFetching = (blocks: LayoutBlock[], communitySlug: string) return true; } - const largeCommunitySlugs = process.env.LARGE_COMMUNITY_SLUGS - ? process.env.LARGE_COMMUNITY_SLUGS.split(',').map((s) => s.trim()) - : []; + const largeCommunitySlugs = env.LARGE_COMMUNITY_SLUGS?.split(',').map((s) => s.trim()) ?? []; return largeCommunitySlugs.includes(communitySlug); }; diff --git a/server/utils/mailchimp.ts b/server/utils/mailchimp.ts index cb6f304fb..4463f0e1d 100644 --- a/server/utils/mailchimp.ts +++ b/server/utils/mailchimp.ts @@ -1,7 +1,9 @@ import md5 from 'crypto-js/md5'; import request from 'request-promise'; -const key = process.env.MAILCHIMP_API_KEY; +import { env } from 'server/env'; + +const key = env.MAILCHIMP_API_KEY; const base = 'https://us5.api.mailchimp.com/3.0/lists'; diff --git a/server/utils/metabase.ts b/server/utils/metabase.ts index a20903a0e..2bc41b79f 100644 --- a/server/utils/metabase.ts +++ b/server/utils/metabase.ts @@ -1,5 +1,7 @@ import jwt from 'jsonwebtoken'; +import { env } from 'server/env'; + const dashboardNums = { community: { type: 'community', @@ -60,7 +62,7 @@ export const generateMetabaseToken = ( exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration }; - const metabaseSecretKey = process.env.METABASE_SECRET_KEY; + const metabaseSecretKey = env.METABASE_SECRET_KEY; if (!metabaseSecretKey) { throw new Error('METABASE_SECRET_KEY environment variable not set'); diff --git a/server/utils/queryHelpers/communityGet.ts b/server/utils/queryHelpers/communityGet.ts index 23f747586..36a06594c 100644 --- a/server/utils/queryHelpers/communityGet.ts +++ b/server/utils/queryHelpers/communityGet.ts @@ -1,3 +1,4 @@ +import { env } from 'server/env'; import { Collection, Community, Member, Page, ScopeSummary, SpamTag } from 'server/models'; import { isProd } from 'utils/environment'; @@ -20,7 +21,7 @@ export function createLogger(initialId: string) { const logger = logg.bind(this as any, acc); const log = (id: string, q: Promise) => { - if (!process.env.DEBUG_LOG) { + if (!env.DEBUG_LOG) { return q; } @@ -32,7 +33,7 @@ export function createLogger(initialId: string) { return { log, end: () => { - if (!process.env.DEBUG_LOG) { + if (!env.DEBUG_LOG) { return; } diff --git a/server/utils/s3.ts b/server/utils/s3.ts index 12a39a92d..beb09b0c2 100644 --- a/server/utils/s3.ts +++ b/server/utils/s3.ts @@ -12,6 +12,8 @@ import { } from '@aws-sdk/client-s3'; import { type Progress, Upload } from '@aws-sdk/lib-storage'; +import { env } from 'server/env'; + type UploadInput = PutObjectCommandInput['Body']; type UploadResult = { @@ -169,8 +171,8 @@ export const createPubPubS3Client = (config: PubPubS3ClientConfig): PubPubS3Clie }; export const assetsClient = createPubPubS3Client({ - accessKeyId: process.env.AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, bucket: 'assets.pubpub.org', ACL: 'public-read', }); diff --git a/server/utils/slack.ts b/server/utils/slack.ts index aeadbd2a9..abdf2fc8f 100644 --- a/server/utils/slack.ts +++ b/server/utils/slack.ts @@ -1,5 +1,6 @@ import type { SpamStatus } from 'types'; +import { env } from 'server/env'; import { isDangerousSpamScore } from 'server/spamTag/communityScore'; import { isProd } from 'utils/environment'; import { getSuperAdminTabUrl } from 'utils/superAdmin'; @@ -7,7 +8,7 @@ import { getSuperAdminTabUrl } from 'utils/superAdmin'; const defaultBody = { username: 'PubPub', unfurl_links: true }; export const postToSlack = async (body: Record) => { - const slackUrl = process.env.SLACK_WEBHOOK_URL!; + const slackUrl = env.SLACK_WEBHOOK_URL!; if (slackUrl) { try { const res = await fetch(slackUrl, { @@ -112,7 +113,7 @@ type CommunityStatusSlackOptions = { }; export const postToSlackAboutCommunityStatusChange = async (opts: CommunityStatusSlackOptions) => { - if (process.env.NODE_ENV === 'test') { + if (env.NODE_ENV === 'test') { return; } @@ -163,7 +164,7 @@ export const postToSlackAboutCommunityStatusChange = async (opts: CommunityStatu }; export const postToSlackAboutNewCommunity = async (opts: NewCommunitySlackOptions) => { - if (process.env.NODE_ENV === 'test') { + if (env.NODE_ENV === 'test') { return; } diff --git a/server/utils/tokens.ts b/server/utils/tokens.ts index 84443b02b..7c25289b7 100644 --- a/server/utils/tokens.ts +++ b/server/utils/tokens.ts @@ -1,10 +1,9 @@ import jwt from 'jsonwebtoken'; +import { env } from 'server/env'; + export const issueToken = ({ userId, communityId, type, payload, expiresIn }) => { - const signingSecret = process.env.JWT_SIGNING_SECRET; - if (!signingSecret) { - throw new Error('JWT_SIGNING_SECRET environment variable not set'); - } + const signingSecret = env.JWT_SIGNING_SECRET; if (userId && communityId && type && expiresIn) { return jwt.sign({ userId, communityId, type, payload }, signingSecret, { @@ -15,10 +14,7 @@ export const issueToken = ({ userId, communityId, type, payload, expiresIn }) => }; export const verifyAndDecodeToken = (token, { userId, communityId, type }) => { - const signingSecret = process.env.JWT_SIGNING_SECRET; - if (!signingSecret) { - return null; - } + const signingSecret = env.JWT_SIGNING_SECRET; try { jwt.verify(token, signingSecret); diff --git a/server/utils/workers.ts b/server/utils/workers.ts index fc3c5413e..e3ce9a145 100644 --- a/server/utils/workers.ts +++ b/server/utils/workers.ts @@ -1,5 +1,6 @@ import amqplib from 'amqplib'; +import { env } from 'server/env'; import { createWorkerTask } from 'server/workerTask/queries'; import { TaskPriority, taskQueueName } from 'utils/workers'; @@ -60,8 +61,7 @@ const invalidateChannel = () => { }; const createChannel = async (): Promise => { - const amqpUrl = process.env.CLOUDAMQP_URL; - if (!amqpUrl) throw new Error('CLOUDAMQP_URL environment variable not set'); + const amqpUrl = env.CLOUDAMQP_URL; return withRetry(async () => { const connection = await amqplib.connect(amqpUrl, { @@ -103,15 +103,15 @@ const createChannel = async (): Promise => { }; const getDefaultTaskPriority = () => { - const processPriority = process.env.DEFAULT_QUEUE_TASK_PRIORITY; + const processPriority = env.DEFAULT_QUEUE_TASK_PRIORITY; if (processPriority) { - return parseInt(processPriority, 10); + return processPriority; } return TaskPriority.Normal; }; const getOrCreateOpenChannel = async (): Promise => { - if (process.env.NODE_ENV === 'test') { + if (env.NODE_ENV === 'test') { return { sendToQueue: () => {}, waitForConfirms: () => {}, diff --git a/server/zoteroIntegration/utils/auth.ts b/server/zoteroIntegration/utils/auth.ts index 3f616e2b9..4f442d7c0 100644 --- a/server/zoteroIntegration/utils/auth.ts +++ b/server/zoteroIntegration/utils/auth.ts @@ -1,5 +1,6 @@ import passportOAuth1 from 'passport-oauth1'; +import { env } from 'server/env'; import { IntegrationDataOAuth1, User, ZoteroIntegration } from 'server/models'; import { expect } from 'utils/assert'; import { isDevelopment, isDuqDuq } from 'utils/environment'; @@ -16,8 +17,8 @@ export const zoteroAuthStrategy = () => requestTokenURL: 'https://www.zotero.org/oauth/request', accessTokenURL: 'https://www.zotero.org/oauth/access', userAuthorizationURL: 'https://www.zotero.org/oauth/authorize', - consumerKey: process.env.ZOTERO_CLIENT_KEY, - consumerSecret: process.env.ZOTERO_CLIENT_SECRET, + consumerKey: env.ZOTERO_CLIENT_KEY, + consumerSecret: env.ZOTERO_CLIENT_SECRET, callbackURL: `${baseRedirectUrl}/auth/zotero/redirect`, signatureMethod: 'HMAC-SHA1', passReqToCallback: true, diff --git a/stubstub/global/setup.ts b/stubstub/global/setup.ts index 2891dbcf8..f2c365de8 100644 --- a/stubstub/global/setup.ts +++ b/stubstub/global/setup.ts @@ -1,5 +1,7 @@ import type { ChildProcessWithoutNullStreams } from 'child_process'; +import { env } from 'server/env'; + import { initTestDatabase, setupTestDatabase, startTestDatabaseServer } from '../testDatabase'; // HACK(ian): The PUBPUB_SYNCING_MODELS_FOR_TEST_DB flag tells the code that we're only going to use @@ -26,14 +28,9 @@ export default async () => { console.log('\nSit tight while a local test database is created...'); await initTestDatabase(); global.testDbServerProcess = await startTestDatabaseServer(); - process.env.DATABASE_URL = await setupTestDatabase(); + env.DATABASE_URL = await setupTestDatabase(); } - // only use one database for the test db - process.env.DATABASE_READ_REPLICA_1_URL = process.env.DATABASE_URL; - process.env.DATABASE_READ_REPLICA_2_URL = process.env.DATABASE_URL; - // see hack comment above - // process.env.PUBPUB_SYNCING_MODELS_FOR_TEST_DB = 'true'; /** * Two things of note * diff --git a/tools/generateEnvDocs.ts b/tools/generateEnvDocs.ts index 2cc714c37..626ac19b0 100644 --- a/tools/generateEnvDocs.ts +++ b/tools/generateEnvDocs.ts @@ -5,9 +5,10 @@ * npx tsx tools/generateEnvDocs.ts */ +import type { z } from 'zod'; + import fs from 'fs'; import path from 'path'; -import type { z } from 'zod'; import { envSchema } from '../server/envSchema'; diff --git a/utils/caching/purgeSurrogateTag.ts b/utils/caching/purgeSurrogateTag.ts index 9b388d9f8..1f6d31813 100644 --- a/utils/caching/purgeSurrogateTag.ts +++ b/utils/caching/purgeSurrogateTag.ts @@ -1,3 +1,4 @@ +import { env } from 'server/env'; import { isDuqDuq } from 'utils/environment'; import { shouldntPurge } from './skipPurgeConditions'; @@ -24,8 +25,8 @@ export const purgeSurrogateTag = async (tag: string, soft = false) => { } const [serviceId, token] = duqduq - ? [process.env.FASTLY_SERVICE_ID_DUQDUQ, process.env.FASTLY_PURGE_TOKEN_DUQDUQ] - : [process.env.FASTLY_SERVICE_ID_PROD, process.env.FASTLY_PURGE_TOKEN_PROD]; + ? [env.FASTLY_SERVICE_ID_DUQDUQ, env.FASTLY_PURGE_TOKEN_DUQDUQ] + : [env.FASTLY_SERVICE_ID_PROD, env.FASTLY_PURGE_TOKEN_PROD]; if (!token) { throw new Error(`No Fastly purge token found for ${duqduq ? 'DuqDuq' : 'prod'}'} diff --git a/utils/caching/readOnlyMiddleware.ts b/utils/caching/readOnlyMiddleware.ts index 4f049d5de..ea3972e16 100644 --- a/utils/caching/readOnlyMiddleware.ts +++ b/utils/caching/readOnlyMiddleware.ts @@ -1,5 +1,7 @@ import type { NextFunction, Request, Response } from 'express'; +import { env } from 'server/env'; + const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'] as const; type MutatingMethod = (typeof MUTATING_METHODS)[number]; @@ -23,7 +25,7 @@ function isAllowedRoute(req: Request): boolean { export const readOnlyMiddleware = () => { return (req: Request, res: Response, next: NextFunction) => { - const isReadOnly = process.env.PUBPUB_READ_ONLY === 'true'; + const isReadOnly = env.PUBPUB_READ_ONLY; if (!isReadOnly) { return next(); diff --git a/utils/caching/skipPurgeConditions.ts b/utils/caching/skipPurgeConditions.ts index 1fa9bf392..532eae6dc 100644 --- a/utils/caching/skipPurgeConditions.ts +++ b/utils/caching/skipPurgeConditions.ts @@ -1,7 +1,7 @@ +import { env } from 'server/env'; import { isQubQub } from 'utils/environment'; -export const isntProdOrTest = () => - process.env.NODE_ENV !== 'production' && !process.env.TEST_FASTLY_PURGE; +export const isntProdOrTest = () => env.NODE_ENV !== 'production' && !env.TEST_FASTLY_PURGE; export const shouldntPurge = (tag?: string) => { const qubqub = isQubQub(); @@ -20,6 +20,6 @@ export const shouldntPurge = (tag?: string) => { } return `Skipping Fastly purge for ${tag} in ${ - process.env.NODE_ENV ?? 'dev' + env.NODE_ENV ?? 'dev' } because NODE_ENV is not production and TEST_FASTLY_PURGE is not set`; }; diff --git a/utils/workers/constants.js b/utils/workers/constants.js index 0c2b368dd..ff345764c 100644 --- a/utils/workers/constants.js +++ b/utils/workers/constants.js @@ -1,3 +1,4 @@ +import { env } from 'server/env'; import { isDevelopment } from 'utils/environment'; export const TaskPriority = { @@ -13,6 +14,6 @@ export const TaskPriority = { // a queue with this name will be created automatically. You'll need to visit the RabbitMQ // control panel, move all unfinished tasks into the new queue, and then delete the old one. const prodTaskQueueName = 'pubpubTaskQueue-2020-07-20'; -const localTaskQueueName = isDevelopment() && process.env.PUBPUB_LOCAL_TASK_QUEUE; +const localTaskQueueName = isDevelopment() && env.PUBPUB_LOCAL_TASK_QUEUE; export const taskQueueName = localTaskQueueName || prodTaskQueueName; diff --git a/workers/environment.ts b/workers/environment.ts index c63d743b5..af082e613 100644 --- a/workers/environment.ts +++ b/workers/environment.ts @@ -1,6 +1,7 @@ require('server/utils/serverModuleOverwrite'); +const { env } = require('server/env'); const { setEnvironment, setAppCommit } = require('utils/environment'); -setEnvironment(process.env.PUBPUB_PRODUCTION, process.env.IS_DUQDUQ, process.env.IS_QUBQUB); -setAppCommit(process.env.HEROKU_SLUG_COMMIT); +setEnvironment(env.PUBPUB_PRODUCTION, env.IS_DUQDUQ, env.IS_QUBQUB); +setAppCommit(env.HEROKU_SLUG_COMMIT); diff --git a/workers/queue.ts b/workers/queue.ts index 63834d665..08fce82f6 100644 --- a/workers/queue.ts +++ b/workers/queue.ts @@ -5,6 +5,7 @@ import amqplib from 'amqplib'; import path from 'path'; import { Worker } from 'worker_threads'; +import { env } from 'server/env'; import { WorkerTask } from 'server/models'; import { expect } from 'utils/assert'; import { createCachePurgeDebouncer } from 'utils/caching/createCachePurgeDebouncer'; @@ -20,7 +21,7 @@ const customTimeouts = { archive: 14_400, // 4 hours } satisfies Partial>; -if (process.env.NODE_ENV === 'production') { +if (env.NODE_ENV === 'production') { Sentry.init({ dsn: 'https://abe1c84bbb3045bd982f9fea7407efaa@sentry.io/1505439', environment: isProd() ? 'prod' : 'dev', @@ -31,7 +32,7 @@ if (process.env.NODE_ENV === 'production') { } const { schedulePurge: schedulePurgeWorker } = createCachePurgeDebouncer({ - errorHandler: process.env.NODE_ENV === 'production' ? Sentry.captureException : undefined, + errorHandler: env.NODE_ENV === 'production' ? Sentry.captureException : undefined, debounceTime: 5000, throttleTime: 1000, }); @@ -96,7 +97,7 @@ const processTask = (channel) => async (message) => { const onWorkerError = async (error) => { console.error('In task:', error); - if (process.env.NODE_ENV === 'production') { + if (env.NODE_ENV === 'production') { Sentry.captureException(error); } await onWorkerFinished({ @@ -141,7 +142,7 @@ const processTask = (channel) => async (message) => { }, maxWorkerTime * 1000); }; -const cloudAmqpUrl = process.env.CLOUDAMQP_URL; +const cloudAmqpUrl = env.CLOUDAMQP_URL; if (!cloudAmqpUrl) { throw new Error('CLOUDAMQP_URL environment variable not set'); } @@ -212,11 +213,7 @@ async function connectAndConsumeWithRetry({ console.log( ` ==> Sequelize Max Connections: ${ - process.env.WORKER - ? 2 - : process.env.SEQUELIZE_MAX_CONNECTIONS - ? parseInt(process.env.SEQUELIZE_MAX_CONNECTIONS, 10) - : 5 + env.WORKER ? 2 : (env.SEQUELIZE_MAX_CONNECTIONS ?? 5) }`, ); console.log(` [*] Waiting for messages on ${taskQueueName}. To exit press CTRL+C`); diff --git a/workers/tasks/archive.tsx b/workers/tasks/archive.tsx index 0c789dc7d..37a2bea43 100644 --- a/workers/tasks/archive.tsx +++ b/workers/tasks/archive.tsx @@ -11,6 +11,7 @@ import { PassThrough, Readable, Transform } from 'stream'; import { renderStatic } from 'client/components/Editor/utils/renderStatic'; import { editorSchema } from 'client/components/Editor/utils/schema'; +import { env } from 'server/env'; import { fetchFacetsForScopeIds } from 'server/facets'; import { Collection, @@ -526,7 +527,7 @@ const getPubs = async (communityId: string) => { // create multiple readable streams that generate URLs for public pages, collections, and all pub releases const createUrlStreams = (communityData: any, pubs: Pub[], numStreams: number) => { const baseUrl = - process.env.NODE_ENV === 'production' + env.NODE_ENV === 'production' ? communityUrl(communityData.community) : 'http://localhost:9876'; diff --git a/workers/tasks/export/paged.ts b/workers/tasks/export/paged.ts index 7ef2bb5bb..c280b76ed 100644 --- a/workers/tasks/export/paged.ts +++ b/workers/tasks/export/paged.ts @@ -1,10 +1,12 @@ +import { env } from 'server/env'; + export const exportWithPaged = async ( html: string, opts: { communityId: string; pubId: string }, ) => { // Default to the in-swarm pubstash service; falls back to PUBSTASH_URL env var // for backwards-compat with the old Fly.io deployment. - const baseUrl = process.env.PUBSTASH_URL ?? 'http://pubstash:8080'; + const baseUrl = env.PUBSTASH_URL ?? 'http://pubstash:8080'; const params = new URLSearchParams({ format: 'pdf', communityId: opts.communityId, @@ -14,7 +16,7 @@ export const exportWithPaged = async ( method: 'POST', body: html, headers: { - Authorization: process.env.PUBSTASH_ACCESS_KEY ?? '', + Authorization: env.PUBSTASH_ACCESS_KEY ?? '', 'Content-Type': 'text/plain', }, }); diff --git a/workers/tasks/import/bulk/cli.ts b/workers/tasks/import/bulk/cli.ts index 2b1f25a25..48e98a174 100644 --- a/workers/tasks/import/bulk/cli.ts +++ b/workers/tasks/import/bulk/cli.ts @@ -1,5 +1,6 @@ import fs from 'fs-extra'; +import { env } from 'server/env'; import { User } from 'server/models'; import { TaskPriority } from 'utils/workers'; @@ -78,8 +79,7 @@ const writePlanToFile = async (path, plan) => { }; const setLowWorkerPriority = () => { - // @ts-expect-error ts-migrate(2322) FIXME: Type 'number' is not assignable to type 'string | ... Remove this comment to see the full error message - process.env.DEFAULT_QUEUE_TASK_PRIORITY = TaskPriority.Low; + env.DEFAULT_QUEUE_TASK_PRIORITY = TaskPriority.Low; }; const main = async () => { diff --git a/workers/tasks/import/import.ts b/workers/tasks/import/import.ts index 32e600ab6..e5579ae09 100644 --- a/workers/tasks/import/import.ts +++ b/workers/tasks/import/import.ts @@ -4,6 +4,7 @@ import { fromPandoc, parsePandocJson, setPandocApiVersion } from '@pubpub/prosem import { spawnSync } from 'child_process'; import path from 'path'; +import { env } from 'server/env'; import { extensionToPandocFormat, type PandocFormat } from 'utils/import/formats'; import { extractBibliographyItems } from './bibliography'; @@ -18,7 +19,7 @@ import { extensionFor } from './util'; setPandocApiVersion([1, 22]); -const dataRoot = process.env.NODE_ENV === 'production' ? '/app/.apt/usr/share/pandoc/data ' : ''; +const dataRoot = env.NODE_ENV === 'production' ? '/app/.apt/usr/share/pandoc/data ' : ''; const createPandocArgs = ( pandocFormat: PandocFormat, diff --git a/workers/tasks/search.ts b/workers/tasks/search.ts index 652911ce5..8c0e3f55c 100644 --- a/workers/tasks/search.ts +++ b/workers/tasks/search.ts @@ -1,10 +1,11 @@ import algoliasearch from 'algoliasearch'; +import { env } from 'server/env'; + import { Community, PubAttribution } from '../../server/models'; import { getPageSearchData, getPubSearchData } from '../utils/searchUtils'; -// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | undefined' is not assig... Remove this comment to see the full error message -const client = algoliasearch(process.env.ALGOLIA_ID, process.env.ALGOLIA_KEY); +const client = algoliasearch(env.ALGOLIA_ID, env.ALGOLIA_KEY); const pubsIndex = client.initIndex('pubs'); const pagesIndex = client.initIndex('pages'); From c668837381309c84b65ee3e99f6893892f2d47e5 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 1 Apr 2026 15:11:58 +0200 Subject: [PATCH 3/7] fix: preserve output on watch --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e6c41898f..4f29ef10d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dev": "./infra/dev.sh", "dev:prod": "./infra/dev.sh prod", "docker:build": "docker build -t pubpub:test-build .", - "api-dev": "concurrently \"pnpm run watch-server\" \"NODE_PATH=./dist/server/client:./dist/server:./dist node --enable-source-maps init.js --watch ./dist/server --watch ./node_modules/@pubpub\"", + "api-dev": "concurrently \"pnpm run watch-server\" \"NODE_PATH=./dist/server/client:./dist/server:./dist node --watch-preserve-output --enable-source-maps init.js --watch ./dist/server --watch ./node_modules/@pubpub\"", "api-prod": "NODE_PATH=./dist/server/client:./dist/server:./dist node --enable-source-maps init.js", "build-client-dev": "webpack --config ./client/webpack/webpackConfig.dev.js", "build-dev": "webpack --config ./client/webpack/webpackConfig.dev.js --watch", From b0fd31087516e89ea12a8a453d86a61e4812a15c Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 1 Apr 2026 16:00:21 +0200 Subject: [PATCH 4/7] fix: standardize surrogate tag config --- server/envSchema.ts | 9 +++------ utils/caching/purgeSurrogateTag.ts | 14 ++------------ 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/server/envSchema.ts b/server/envSchema.ts index 114bc206d..ed9900e04 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -125,18 +125,15 @@ export const envSchema = z.object({ ZOTERO_CLIENT_SECRET: z.string().describe('Zotero OAuth1 consumer secret'), // ── CDN / Fastly ──────────────────────────────────────────────────── - FASTLY_SERVICE_ID_PROD: z.string().describe('Fastly service ID for production'), - FASTLY_PURGE_TOKEN_PROD: z.string().describe('Fastly purge token for production'), - FASTLY_SERVICE_ID_DUQDUQ: z.string().describe('Fastly service ID for DuqDuq'), - FASTLY_PURGE_TOKEN_DUQDUQ: z.string().describe('Fastly purge token for DuqDuq'), - PURGE_TOKEN: z.string().describe('Legacy Fastly purge token'), + FASTLY_SERVICE_ID: z.string().describe('Fastly service ID'), + FASTLY_PURGE_TOKEN: z.string().describe('Fastly purge token'), // ── PubStash (Export) ─────────────────────────────────────────────── PUBSTASH_URL: z .string() .default('http://pubstash:8080') .describe('PubStash service URL for paged exports'), - PUBSTASH_ACCESS_KEY: z.string().describe('PubStash access key'), + PUBSTASH_ACCESS_KEY: z.string().optional().describe('PubStash access key'), // ── Webhooks / Integrations ───────────────────────────────────────── SLACK_WEBHOOK_URL: z.string().describe('Slack incoming webhook URL for notifications'), diff --git a/utils/caching/purgeSurrogateTag.ts b/utils/caching/purgeSurrogateTag.ts index 1f6d31813..3e7aa6374 100644 --- a/utils/caching/purgeSurrogateTag.ts +++ b/utils/caching/purgeSurrogateTag.ts @@ -24,18 +24,8 @@ export const purgeSurrogateTag = async (tag: string, soft = false) => { return ''; } - const [serviceId, token] = duqduq - ? [env.FASTLY_SERVICE_ID_DUQDUQ, env.FASTLY_PURGE_TOKEN_DUQDUQ] - : [env.FASTLY_SERVICE_ID_PROD, env.FASTLY_PURGE_TOKEN_PROD]; - - if (!token) { - throw new Error(`No Fastly purge token found for ${duqduq ? 'DuqDuq' : 'prod'}'} - Did you forget to set FASTLY_PURGE_TOKEN_${duqduq ? 'DUQDUQ' : 'PROD'}?`); - } - if (!serviceId) { - throw new Error(`No Fastly service ID found for ${duqduq ? 'DuqDuq' : 'prod'}'} - Did you forget to set FASTLY_SERVICE_ID_${duqduq ? 'DUQDUQ' : 'PROD'}?`); - } + // ? [env.FASTLY_SERVICE_ID_DUQDUQ, env.FASTLY_PURGE_TOKEN_DUQDUQ] + const [serviceId, token] = [env.FASTLY_SERVICE_ID, env.FASTLY_PURGE_TOKEN]; try { const purge = await fetch( From 957e0e2817a027536c982cb413bccdcb6e7cfb08 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 1 Apr 2026 16:01:32 +0200 Subject: [PATCH 5/7] fix: reintroduce appcommit --- .github/workflows/deploy.yml | 9 +++- .test/setup-env.js | 7 +-- infra/.env.dev.enc | 90 ++++++++++++++++----------------- infra/.env.enc | 96 ++++++++++++++++++------------------ infra/stack.yml | 2 + package.json | 3 +- server/envSchema.ts | 14 +++++- server/server.ts | 17 +++---- server/utils/appCommit.ts | 19 +++++++ workers/environment.ts | 8 ++- 10 files changed, 151 insertions(+), 114 deletions(-) create mode 100644 server/utils/appCommit.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1f3c53296..ac9de2b1e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -167,6 +167,12 @@ jobs: cd "${APP_DIR}" git fetch --prune --tags origin git checkout --detach "${DEPLOY_REF}" + APP_COMMIT="$(git rev-parse HEAD)" + + if [[ -z "$APP_COMMIT" ]]; then + echo "Unable to resolve APP_COMMIT from DEPLOY_REF='${DEPLOY_REF}'" + exit 1 + fi cd infra umask 077 @@ -183,12 +189,13 @@ jobs: echo "$GHCR_TOKEN" | sudo docker login ghcr.io -u "$GHCR_USER" --password-stdin echo "IMAGE_TAG in shell: [$IMAGE_TAG]" + echo "APP_COMMIT in shell: [$APP_COMMIT]" # For some reason, not pulling explicitly makes the docker stack deploy throw an error that it can't find the package. sudo docker pull ghcr.io/knowledgefutures/pubpub:"$IMAGE_TAG" # deploy/update stack - sudo env IMAGE_TAG="$IMAGE_TAG" docker stack deploy -c stack.yml --with-registry-auth --resolve-image always --prune pubpub + sudo env IMAGE_TAG="$IMAGE_TAG" APP_COMMIT="$APP_COMMIT" docker stack deploy -c stack.yml --with-registry-auth --resolve-image always --prune pubpub # show progress and cleanup sudo docker stack services pubpub diff --git a/.test/setup-env.js b/.test/setup-env.js index 69c2b8ebd..81f845c82 100644 --- a/.test/setup-env.js +++ b/.test/setup-env.js @@ -22,11 +22,8 @@ process.env.FIREBASE_TEST_DB_URL = 'http://localhost:9875?ns=pubpub-v6'; process.env.ZOTERO_CLIENT_KEY = 'abc'; process.env.ZOTERO_CLIENT_SECRET = 'def'; -process.env.FASTLY_PURGE_TOKEN_PROD = 'token'; -process.env.FASTLY_SERVICE_ID_PROD = 'prod'; - -process.env.FASTLY_PURGE_TOKEN_DUQDUQ = 'token_duqduq'; -process.env.FASTLY_SERVICE_ID_DUQDUQ = 'duqduq'; +process.env.FASTLY_PURGE_TOKEN = 'token'; +process.env.FASTLY_SERVICE_ID = 'prod'; if (process.env.INTEGRATION) { try { diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index 3ed84c1ee..0ef00e6ca 100644 --- a/infra/.env.dev.enc +++ b/infra/.env.dev.enc @@ -1,52 +1,52 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:Kz4/KiHal4JIP5kmXBSVg+VI/nvNGfPtRv8Rrf9mUdY7bDuc5k1In1oSPHsjjNQLDN8bs6YuzCSBzWyyouDUAg==,iv:QC65R/DVjioNsTb6EnChdSaugtlOqvEx+m4C57eZStM=,tag:kc9NcW6faLAdJIy17wcjtQ==,type:str] -ALGOLIA_ID=ENC[AES256_GCM,data:Pu/lBy/Vo/9p1g==,iv:4HhG6IjEyheW+Ug1mI9m+6xzxRuVf2xvJ+dPtONp130=,tag:9LNs+U1klSpxqVgAnQJaug==,type:str] -ALGOLIA_KEY=ENC[AES256_GCM,data:bY2wVEofNkVMlvwhEhGr4punAQ/JMShYYPpKfR/kyK8=,iv:NIrux9ntBLGphMQ0yem+puX9gjnvlGb3jUiDupiyths=,tag:8DkKbPwvQtPPWHGmH0zRWw==,type:str] -ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:MWIU9p3KmmsDjdxk+IRUJgzB7SQw479ZUG9WdlXLXk8=,iv:ujK8AHJPvvbCi7HxhL7cGvxQ/HYFJRriCFbsNkCAHXk=,tag:KaU7v4CrQJRKmH+QfJwGlA==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:nnIlGdEBlwi268NlCFi8d5Ugjmg=,iv:uP5wFBOWZJrSH50uYNzJ4kJdv3jYUcEFlMRVrDMlTng=,tag:Uv0D0mKNKebrNwuySxIM7g==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:aWJiiVfgnam1My2J/J3k1p/8j4g=,iv:XD+OHujRRMUyyZ8cMGJ2mw1TCtwELrR16txekGpwMi8=,tag:HEsGvUYMNEiv1mk2mb4GYw==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:fYAtsyYDbTp3EBvm6vtabfmBupk9ryfpcu4P3ROoyTEOBnZyPrhPnQ==,iv:L/VZB/DccoB55GFWPfsVbQ9uzdIk3nwadkKZ15YzFGE=,tag:WEbCfpCYN0jHrfzWsc5TNg==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:DGbljDUXr1ehACzOOWFFN2fCIrzq5aChbZg++7IXzLwBxWNldm1vqQ==,iv:G4lKHMPP4BL0kaZz4oH1CulYBiRh7SyZAu3pKhGoUys=,tag:LIVhg5BYgGuH7B7Eyk82Dw==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:eQLNXNHy2Uv6TJt0nn2aZFCa/cyUFQFp490E+sv4+oMY2c0zZ1VpYSuIQCg=,iv:oFQ6xrc6v0z9K0t9m9EU3nXCwsW0lYQNoRbXclnAIFU=,tag:nO+JnRZKD5wBlIoMtyqnuw==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:+uZ8Kkhyxa6KersbX7p6hnqSpp9KcjCEttWRnxDWatf8jr3h+MhyP1SW/6c=,iv:3HQ97+1Gj+7+UjE798q3B4tF9CH7Oi8T4217dgiSygE=,tag:SXFgzJRgDXECYqx1yW6h1w==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:Q+H2KthVe4xO5RChejW0YU1TgGtJqXW4I7kc+5WXpM40GQ==,iv:jZjvyIZf3p/a4hC2LhqW6eo4wDGPLFgTn4YTWPVSrxI=,tag:WVguZkbwTPQ5kot//hkSew==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:MnV5UPz2,iv:WILeu6evYxrmctdiCKU9xuuW1E4u7RdFSjG5abdBZZs=,tag:u5gWtVGfeuXrJeqRREMPBg==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:gNfWFreLj6v6dXP/hxKvD7M7jr0=,iv:VAVh9dBRQP/ggQmKHOLepqDN8+pCmN5kIm+Xe7fpYis=,tag:w4dAIBjRMKUBnoX3FqjtXw==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:BjI1Ebz5t00X3n8PSmjPUk6IaIFgWOqEDDA+SheOHLDNotjabDjUE2k=,iv:FB5IXGUm4c/rdVfzD6S8WnmA7CW8y91YsvDr+EUf1NA=,tag:Q+OwRyQPNaBfNLb21AQVfg==,type:str] -FASTLY_PURGE_TOKEN_DUQDUQ=ENC[AES256_GCM,data:MN2kB/F4ztrOXpuDVV3qDBotky/1suF4NsZbtTmE9gY=,iv:VzvRIdYjX4Tw2op5qTsdLg2l3ABeGQnTRp2EOf4F1S0=,tag:3Ua3WcAWJHVHxyRIfNTDCw==,type:str] -FASTLY_SERVICE_ID_DUQDUQ=ENC[AES256_GCM,data:BpF94SQhZj9+gwlWtGT23VFFQaZBMw==,iv:HRVbeeO7QCYi9wO841TCwS8BZYTvb//3diy9u07RL7c=,tag:EqtpAL5cWLYNulhMupkYOw==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:gApN9Y2dF+3z6KbQcR5TmRIBO0DK5B907WaPhD9LIuE=,tag:Hl5fty+5t8g221MjMyfCqA==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:NQOkmw==,iv:0tqLjo3HE/XZnR6UgZZBFZtn2vq6mj01XgO9j5B0r9s=,tag:dX45cwa5tWLgIzjxVe1Log==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:PjgE+THClW+wcA1gvnT5Ej4sOmIjYJCXtSw7//wpxOaeuKs5Ic9Jt45qFErknu4lL6RtHEHXvQuuxgYEKkVqzpYuH2ckxFXRW/UChWMj/xw1ye8ZHjca1CqY8kHyN5wMGgxAAaNZAuK9IvRGV8DEWdepRR9JKM8f3RwsZSE4mw0/U/ClBbc9NywFFAGotouVPxexqWZgb8K1qA7i59FXYMP8HqBu0Z9XZzSPJ1Jj9lWe0HbRyE5IC5DX4sqCDuC2UdboDRfp5RwpJhMYWkKMf3q+3xccTm/jMuMX1BgybG26r4xgmko13g7aTcznLj0qzzY3OWilk0jZWJ5zIXp7C8282BxnFZmWfE224zi+TEaJBu8aAn/WsZznqp6dHv1PqqhXEKf109/fHKOlxFjQKx4qL4/AwS9rUBWWQZ34Ldm9Y/7F17/Hmo+DMztL9xfRyu3NkzwjOCWKXL+PX5A9AszKELOPkaJxT4MIdtNZp1CLKl3RQtpZ33l7gqb3kJuYA9+WGgya7HN0QX9IbkX9D1YdAV/cA2V9dMd7tb3pvnxybbEODig2eDwxEp0fVAsULwxTF72PRgczGTiza13zCE31NEwqEYEBRE6Nq1UIJSus2aiBocrETDtfvELzvGSTOtlJUOM+ARoZiGyUvg/BpTuNoqEkCB2lYmSiX4b1jrxiE70qgMdbG85Bi+POqcfrg68cI5rk/zIbj08mBw5QTteELNI0EFeTzd013nxYXWkAlpC+OtlaZk/Pi1n8qhqHmkPfSlZVjxL/hqTwSviybwIJDh/cZCPanSpZ+Sn7fgEG8KzaQQHXRVqxXJp/2FRt80dY2v+nX54K664DPMFWgMHlqVQYIDoWn8Yv8NVVlP2lAxAajuOAC0sRRVgbZ6IOM6Gw/MD/u7mG+cxm/cvNZZfhT2kjzotEPqsGOGJk1tBbNuxcosi1sCmFlhZsFm1gjy09kwbRTVF68U/dpfjC+bl2+e7uMWeFcJOOJfI+AczUNu8MI+Perzs1C+P8bCne+27yvNAnPnerXUdpnJyfaRsbD4kOqm89681iMEhJNA2XToK7DTBslgXlLa2YS+Cp2jQAttsoTDKsEfqZIDN11JxXYgRoBjb3usglxGZRSp3pqGv3rrqXyqx4umL77mreb2xdil4kOBySFSKG4l1hHaK/NRJqsiaHX36F1ELFy3P0V+z0hW9QujyWDlt13gymBZalCgiLfawSDplhgULcFFoEQGc/NvFQvuvlo8ZvFycsBlZaam52WXM0BsoDvfGGyunTg27KCjx0+eB2hqsIABTU4qKNIxFFdWCeT325NAO82oO65TgfSglkXRDujjAh/nj3VgSeLU+SGGobM30+Bg==,iv:JHBkoIafd0nzqnzZNYhBx5Y12q94fDdQktC6iW43cyc=,tag:wV01DXrn1ypimNFrzzJLcQ==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:6Co8J9KOFq1Q4qIVk3h8gayLsmP/kJ8Unczd56XjT/lC4Hkl,iv:2sNxyJmFZMigRKzqsaiDM09N/Dank783OE9SxvpUZfU=,tag:n8AzKw/BKoXciipBZkb6VA==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:+JBNQ4LxSOkZWtB2AQPSDA4juXF5jUKZrTAQo8xGB0boQEW7,iv:AxtIwHYoNnSN9QjgRQNDK85WK+sCyYB+Rlzks106eyg=,tag:G70UlOuspR+BDsx/5r697w==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:GW/O7+RQWlC+rBb2A/xIHKyYyrEcmUMy7dxqDC5nQ/UpkzOHK1SOpRvandIa62YivcNNvxwEzAFHu7BASjZK6A==,iv:GPPmRbHHys6I5FGddg/AZsnysFQ3PVqPquupbhiAtng=,tag:XoN4puLeTMwVB4zQ72Qpqw==,type:str] -NODE_ENV=ENC[AES256_GCM,data:7zwIoxkdMtNzBg==,iv:b8n1uKjI71k1dc1kMYJphEQnnefqS6+C8VzGgOldUm0=,tag:rN6nFQjBY4cghk/n0aeBuw==,type:str] -#ENC[AES256_GCM,data:AQh6rlnCeWROveEJsICN/veNdoKD22C++/M=,iv:Sw5zGhSVxTzs0m5aRWoGiPRARwNnCujwdlEN1i9ZMQg=,tag:oSE0CuDZoN3hJNhykICfbA==,type:comment] -#ENC[AES256_GCM,data:189wvfDMP4Wv/4CUSOi/ls4jIZpU7PBTa8mcDYxqnU5N9NhSjq8=,iv:ohTmqGFpcn4cbIos8TTemUwNGzTXFRZHAEo3qf8TqEc=,tag:NrWA7r7wP4MVSwJU8A42Dg==,type:comment] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:kinsYfgL36qIqGou0TaalR1HGiWvX/6nCcNe/gYPp9ypSGo=,iv:Nz5Bub7iBiXlo5S5vpiv1v7OrX/2dx3VOcXNSNtigaY=,tag:rpAu5dDTZnveOMMjWCBI+A==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:4/yI1dtcnuRx+wzb3F+ygBDsKxs=,iv:ybu3IGbI8K98byov4TR9iy3FcG59Yr7EZC5oVHbUpUk=,tag:+pDV21jYmde6KlGa9HfV1w==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:wEFLWIh6UL+eI9eMsgqad5lI5dppWIzac/E7B8Fa3PUSLQejPQgpeA==,iv:1r3DFRl7i0QPZ7ITr0jhKV08b1ZbnDmnmIw7FCpOkzo=,tag:rIeDFnAM4M4VaoagzalxOQ==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:aJ9K549KzJSlgNs=,iv:Ca9+3SxNXvyd7pvX9ShtGnjPUn8LARnMhOfSaauUAEg=,tag:0sRvNSahQTOsw3Xk9YrOPw==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:OTK6lJ6IK46n8wXKVLTH5WwKXn47iLIZjEPQasLPW29RfBjOfihuZN2kEf4T8pBwDQh0fGaY6crs1dewF4ama4hXvmg13kWqO9MwSU+NHiuroX4gUZ+Fg8xkBOjnarEe7NMiRMP0BrlntD1LwwcTDV3p/Df6SphgyYfqeXDiJP1O4AosPuv+4egBKqqY2KCy3czc47PcMWapSxWJfMU7KlWggYoSiU3BR3EhbP8cRVZ1tYUttrb0brI28A==,iv:c+h68p57wTshNPaAzd0EiEBVXcAPvBUvgozWJEpZ7n4=,tag:aV3vlkzWJCAp1n6kLXCFNA==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:m8Qr,iv:RdVMbGg1oRNC6t+Ly27+wbjR7oI6uGYmPgGagNFE3cs=,tag:/xqPyr8e2SZgMw4dM9eRrQ==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:ius=,iv:SgH1AcWawWQRDHcB0E5aWCfp3UXDSOvCXocgsV1eeKo=,tag:1QfV5Jl9bQvV64Ty+IcNwA==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:eLJUhmgQ+zOYRHnYKJrp60DQpKR6Li3iOCeJ1VaHWv9IwItuakAEIRbVrC8hp8Q0c02M76a6nlObomuRFSqK/zOMc3aqomg9mCpObYsgQw==,iv:vCCjOU36LdEwEHaA1Rqzqfs0Po3uodY4qJSbbih2BlQ=,tag:er56v92xiIMel1MjqRL/cQ==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:FFBgY/8j+ZkVDJlx20UsSc7OiR7V4yg38AC6jJWKZXZ/KnRhCjMgsqwgGHwxUBbZ1YgB/ZKLscAyhlGWeF9An9BsFueUJk1v9W+w8IOBpiczjftfC4VEqVWTqpiWT09iGbiiHYZTJKUvdxeNnsStoNAmcN7R,iv:GUY0HRWcMHKlYhVDo/LsXrS0UXMdIuak8bn0gdtAVFs=,tag:Vo5Qvjt9JHpRVbG01s01kw==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:whlgZOo3vAFqntlSdO+vvrBIws8=,iv:yiBuRlqWtTD7VZuylXFs8c+rDCZbce3N0c3kG8i50Ss=,tag:V7mbPzU7QGJcv98IUc/Mig==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:oFi5Jd45an5EUeIfbNL2cmcMa2U=,iv:Qda3XG/A/3ilj4Ak0AL2LBSq+N5TClAtIZ15w6//U1I=,tag:5tx1ESB4JOQNGM18L4Y0xA==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzZWZlN0NjRG9aZHlKWHVR\nOElRZlVscVFseGRORHZoSEpHS1ViUHpnWFVNCkR5NUU0ZUJlR2VOZHV5aHRJTi9G\nK29CVHhvOUt6OUs2aWNOSG1RNHdzMlUKLS0tIDVHcUF3Z3dzKzhXeHNERUlUb0Na\nUXBaS1doRkx0aVdFWGJVUGFpNlN4aG8K0cjHGDgqdu4DnvrU1QIZAkaMIoZA02aE\nVlURBU9Y4MInhk3xs/9MSxNLaqlOPDu5sCXRI9ATO02fkiWNDIiDRg==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:DtpsuxsEJeHJzJWC0OeCWJpI1VKAys1xWItsDJoS4TvgmDP36JTZ54jGcnSlogqiI0cUCHCsZS+UCrSR+B9RAQ==,iv:kXivkkbNgQL1fdHT+WIn5+EgNY3pQWEU7YZBdKHP8iw=,tag:iazFd6bSweEWQcgB7cbl+Q==,type:str] +ALGOLIA_ID=ENC[AES256_GCM,data:d90VJCUuTdL6Jg==,iv:OP8XU6SwExzlGYFvWkWvJFbCXRGPhymx+iF76cVkdTc=,tag:uAoi7HszkpVSrlfHRu4LJw==,type:str] +ALGOLIA_KEY=ENC[AES256_GCM,data:b5Ta653alhclBscWiDLK6CfCwqPZjeRaqZb22PcPV3A=,iv://OrZ+0Vl6IIf6s/zkhRXsmZCfQz+FiYgNzhZiQYMjo=,tag:B8a/mr7ku1tFiq981uvyVg==,type:str] +ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:Hep8FyG7keL/b01yx7PMIl1XwiPKJdxL+xSxoCcJKGo=,iv:RxKK3i/jAy+cxpTK8L5r7EbNZhQv8waozxWIRV0J3Nw=,tag:IyPeD+rcwJ0gKVYhONsSoA==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:q4jq0J0AvOsIsf+1Wp48CSFopKY=,iv:NllPbdbo35yMEQGT2hxMP9AgTWz8Vh+Z9C4d2CCMhhU=,tag:nJ9lzpnRfe4MgV1ND38XGQ==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:0aHyELZ3rJZHKY/N07g4krAlBBc=,iv:Sj8XtqvFpYuok/uivSZXQsFBGrIGh9dCqjfiIbWbkTo=,tag:lGKf7l/ywxWV3PZFvkP6zQ==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:YrhXUaHuOsAVow017s33O4UC6CARLj9wcYY/14G9+P1UQoxUsidbIA==,iv:aNKzOARrktJTk+YhLh+6fvDZSWf3y0c2BMDSfWCkLP4=,tag:V9wtkn7ff4Dx/ouWxy6k4Q==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:eY1BRa9+az7+cHX8QuYPhdetRO1xJNqkrpd50b8pFoeytYCfv3WOBg==,iv:BJaYCiHeu194VDN6m19gtSL3PA+oMMtv9hELdg8+Bww=,tag:J7tEqv0jRmTaxrnjcF7WbA==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:xD7bJVBS4yjOA2+4+cDX9mQ4LM9lEzUr9vbv1HdOyAvkLEltrvzjqf5i4Os=,iv:u8JststeV/tGt4N4ZjT7tGT4LX25QjMHHnu6OC+kneo=,tag:3jTpRRtq5eu2sWgncPVXzg==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:KMwqXqokK9vVnTOEXYF/Xvc7C6qYKqmBi5H/1HOeAr2/Q4C+Q1W9rCI0Dbk=,iv:V83V+/Ier2WNvVJKTJhlZKL96DoGoKigivCNcqQLUO4=,tag:4V27qxaO7u7c8QM/y8EAMg==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:axFvPlko/81sHa0U0Old4RBMzyIu/Yr7d5r2M1h+YEN+sg==,iv:lwJSHf1lOSvXNtZzf7etkWLyPUMngkT9REtzn7koVN8=,tag:UnhYuc2/coUU+ffpiM92TQ==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:N9Egal5T,iv:cr1wWeJWv+yU38KdiVc9GLS+VVphUv8swzTxFrWXuRU=,tag:wdsSDTaslXNwlKZow2tHEQ==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:G86UKk5xpr4FBzdxu6ZuC2hVyD4=,iv:FT9spJOBgU11KvQWPPeCDOLt9P1w2T7OKfSeZawUxMg=,tag:QKQw9cjVUFgcvNrv4NtAFQ==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:oKVUfj9WnPEFILmakVZsqEBNcnohreWtUu3BCAhdqIlIU3GcIfTJq/U=,iv:t68ifDa1seH0ru+yr14U8MwfNae/XLcPPcpyxVEMy5U=,tag:aHQsVtmVXklYpyM/Nuze6A==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:3HfsT3gWW5KbuH08ii4zwvPSl+oTpYINJ2tXLKtxifU=,iv:S5znbRLo9tp8zlYm+QLVLMMJvbgPeeV2yBei55AJrho=,tag:y5B4/7DiCgr0+RnbRSH40g==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:0i92H/FdJzuVb3b+gP/la+7JGRBF/A==,iv:qp0andxBhTT355LVjhzurniricpcPtzR9CVIoRqO3y4=,tag:U0d1e0F2QdzrMACw1eppLQ==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:09YePHh+Uddvtn9fZJN7mHLLTvjDqguboaKy8GX8cCI=,tag:sTMIx3imnbFkHXiOzKxnrg==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:KH8iHg==,iv:epOFsO01DQvxbj1Qq73SbjAJSZLkbQu8cnLvwj8qgwo=,tag:FOLM3tmoCpfR1WygVt9XgQ==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:WdcM5uXutFp4OcQ3WDDF5tiB5mBQYM9mpPBiJQcLHDOCF9hfeNsw9QrHBxsgz50lZxaYX5ZLjbx2m0XZ/wj1xLdhhu5eYBCcpq5xj6QxhzZcKqbqBa4EwfgUTPW88izFlzyR10Q7a5fZ8oGhVnWabnrgDty4DRWMdfRR7v4SjroTfrYosgkZxk8oSZu+Lw9Fi16rshLJBpg3WGur/qDj/tySEWswkbFHCTazbzwOstIJr8YPdq2+LxiPQZ82sNthAAKOggMpHx2ZwnfWpUZ99nil+b1Uk/x0Tne+G3ahvej3LTFOz4hIpDOxevE512CqhTw1D9k1iDFNgHQ5eXWxkovCYbq0dLkQv+gHj8l5Rn9QMubghHNe79UoALmA7d21Gj9Ivq4kcFbarPmsvceGGa22ZhlY6SKfK/aapykfbhNgZrg1sf5W/1ri/cS7uhFoobRJLzjMmFMe0Q0srijHHDvnW08YW+sx0mjD4PcJ65OKhAmey890hxbdRFDw3ZQbw8V9XNuykUacqrLCIpoHqkNX0br0ZGZfXcmBvAM9yM8n1OIvBH3dU7ecoqzZ1SA9kAHiRJdxSLgPYRmsQoVORS7RwMFArTLSUznJkVIDANkkJTtObBMjwFzVJK6HqkScb3Vr9qH+5qQOHtv4D5cMEVslOHlZJ6nt8OwoAHhh3QIrQG0NuZgQqnpc6+uwVBBxcONrnLh8iERMuxiCXYMGlH33JoHiDWGarLbbdNdxJIVIKhlbSTIlN08WhNm+pDZj5lkEf5w+1aVUM5aaXFD1ef+PfFuzUTTeAWC5nnPC6GtO7Ds5/UpK9tyXqk1YUmO3Ks/0LAFY7BTFSUpjpv40+/zA9AEbxmBuW8XeC7CXcpVKLk8My+rJ2pghpe8C7xI0dBum+JfYUaE1v7A2Y9bxE1LCa+ompjkv5KdiKWD6KCqpbRKDWP5TXERTOLDFhRF8wKv3/3Q97qFJidpiZnLcWTGGu4NFEqTEY0Onj3FmOQ1uFnLdX0bHuRyuVUQ0ScibKwQGOvmXPzePo1IAuVfv48xcuHpfVSqV9QCha8FiFNuF6c8stmLwJAEAV7mprfNUuWjPmkmO0LYdnGPhsYktWushOVnSzXWp7uV5I2r5+KHPeQ6/yVHXEDiAN6cDo40+vlbHYkVfPbHcxkADXEvzjZ4YAG8zW/IZWnNPprjQr5y13r0E1C/RYndnABYtp6eIDjjEalLvjecFSJJ9uEI5cl8uKSubnDfl/Z5Qnm2rkmhl+3fZpPVBjooJM/LMKuhiaThJArvZqjsW55ETOphb5dGPJgkBX/u7hQi4ud9+OeTP3RVXSZLB7K+R8xGEit9vS5Gmlqm+sdUSB+OZbKfEgg==,iv:m4W4EJyX4Oq9Oh8zBcIgV/IqxBCGwNdaCOFLCsUTMZM=,tag:X0Pk+iOJP7QFV8ObZfSu2Q==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:hCcjgaFYVZTgTxffyGlFYDXJNySF0UET9eTOUe62hjeXylsp,iv:r2EYHa1ltBEjqejgsQL6sBZN4Qou7EY7jHBeKK2YzHc=,tag:/O1IJs1tiUgz2QKm1SbW4w==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:bWBOy07Y4qqnLDKljViLhWb8WBgL7QZEjk1JnSo8tStrX95G,iv:TuNRGEq2kS1FDcAPMqQlD9ztIEXjIEfwLva+1hILDWE=,tag:tpk166GRcA/TCHy2Lw/DnA==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:R2ur6UQiOW84W/re4uJyxfQ2PqrE0oZbCum/fp8Ao9C+u0CchpJjyacYgLxSmzYrsdvnXvYJ5nvnfAC85aZIcg==,iv:QXP9B9JKTzP9DHT1bOo/IfnjD2je4Uyh+YCCNanrk6I=,tag:GBm01LDa5huCtfEYnI8/VA==,type:str] +NODE_ENV=ENC[AES256_GCM,data:u6BSUiP5ZvfG1g==,iv:L10+Zbs9GORSJSMpaJmwhZNydwD+SV3zbnzbNL4KiEU=,tag:qNQo/tiLp4FxlrtBy2MoGw==,type:str] +#ENC[AES256_GCM,data:UjxZbONkOYQjVBBBugjPAzYuifD3Wv/EOMQ=,iv:UeZFMj7Z+bSe2cu69506HKheX/qT2vY8Y8qfXHLaMjE=,tag:RRO1tyIrMH2tl15zO6INkQ==,type:comment] +#ENC[AES256_GCM,data:fvkPk1sD9ThePH+ZTQjyDh1cxcDW89CqwU5xbtMptjjC+DjzpGk=,iv:nWWs5fov+h5U6cmYR/ZSvmGP0RNjjst6AEtixAXiwgo=,tag:nRx1txKrvUfoFl7BPASSEQ==,type:comment] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:C/aHl2R6CewvSrlYtQa8QyAG5HsKD1YpINaYkfd4sf1CaYc=,iv:OMZyV5YwRC7ZqiVlIpMWxAfq+N8PwDcxacvDj7QLPSo=,tag:3Xv9dXuxWJU2Upjrgc4Fuw==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:D3UNZjPyV0W3F0JA9zMqXK/IGmo=,iv:UMkFy+UaMOHbPhCqegMLLi4WwRWpeFxMyVhwojadlns=,tag:bHPn4q1geXe61OowqBCC8w==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:O2E5TF/YhCphP/yawhIItqwt+mF0g43HT1fX93s8gDLXKN/xYpRxbA==,iv:Aac0X/bRVQ8qLlEO5qbFY2+r4sDFbaC0Df+CQcxj9gE=,tag:cEVEZlUsPkBjvqGiuidUtg==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:73SMvd2rRnDjKSU=,iv:AFByOK7HY/7PK/k+dhN4uSeqeOUhshhiKBU3aiNCAQY=,tag:rMLyj60eOvl/OB6RFKfIJw==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:hkfl+8E6uaONsGxJyfNo2T6hr0jZoUKHJy22geQgdBNNUNlWPkm/F+nUAQIH8iGpxufO82PPTGgiyEjO2sR+UzTCPbCv0+pyRYXtlqZUuvZnWV02cNq2irDs9f6fsg3dVx5SiqfxQGSbcy6VeaihEZWmlKqjdpqe+8ni44FuuOnDJysFYtgoXBqsxJaw8I96X21FMarpn0RbD4Ngp51PcaZzrfrrzIvX7ZBX51YLRriES3X1hBTBduwxeA==,iv:Ajzx1XiwR0SEoPJF5lO5OjOrNIS9FWzMmd8Qnuhh26M=,tag:+IFF29hGx01ebhm5cMP3PQ==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:cyFx,iv:7SI1ozims+AjN4vRlknEyxw9/9/i7P0pvdoWK7psa4g=,tag:7Yu7G7LLFEJVW6UmKLymfQ==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:dEU=,iv:fbyirmbAvSN07EEvFY6IbKdUy2aC37SxSJtFepZYiLk=,tag:OHrN2DeEwTx89n/A2rRZKA==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:eNqOrvSN5O78zygj94hYTJMEydvtTvKdcT0wiCiBUZAsB/vO/WkGzNAQTJLChhCcjUAMKy+8Jsb0F2BNuRTNRuoDwcbtP6HmfuYz3ouXMg==,iv:Y+ipnSapPwgUWujH30k2XUO5BtlprpTKky09P+D4sQI=,tag:qKPX3QP+Ir+NYGr1b2W2cQ==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:Xg+zZj3fpWU+9WJaeA3TfQ3GZwYZmeMwmGJ5deuBpMof5l1OEWx73wG/DCyMpCFY+9+oxUPyIUAdek1QPw71xmwbk3dCuSRj8WvUrEs4htoQJGtw1Pwn4cBmbtFXfgbS1TKUKPJch1OsWtsyEDdyO9Wh6M/5,iv:Q3aLPdzY6HeVunB7F6c1w5PZ5US1q4EH0wtg+1UPNV8=,tag:3ZxHwpPUEl45CT0ve0bn+Q==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:wzvVmpcnzH4vHC2SwviJWveQ7Rw=,iv:aq8rXzO8c2Fo9STSnpp7FRd7se4gRbRwxA5NhhJywMY=,tag:E3N4PofwKRNtpQ27sMQx+g==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:wcOBQROKyzJOf29t/rrJmPQcoRs=,iv:jxZ4wZJP0qtjlxqLwe6tGF7+Wv5K4D1kjhGPBlRy27Q=,tag:a9alJCqEvAZg99VzEIeXtA==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2aXJpKzhKbTBNSzl0c0Y0\nME9NWGFydi9qc1cvd2FJYmU0ZGxXemxod3pvCnZ2YndsN2NraC9HcC9pRjNzaHF2\nQTk2UndMUXdFRS83TDJFeUZ1LzdFemsKLS0tIFFUcml0dFpDQUtSbG1GQzNpbnJX\nckp2bXZiMkVhK1krS2ZHZDJJQ3pMV3MKnZi43XByoDk3NyUPl7E64pX1wq+BH6SI\n1AYHHq8PwCTWd2tKA/IyxSXgxwxBJQtcbd20rcLksM4nUw4cTkLVUw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvT3RxWDBqTS9LZ2NDWnND\nZ0p1dkxTNHZ3S1dLaTNPK0Jsd3h3L2xmcDFjCmpoNlgzWU50Tk5DWDc5NWtnTW5X\nZ0VMVzEremEwdWFDL1FDUjBKeUdnRjgKLS0tIFJEeXFqMnlMNzk4cjN3bHBiVm8z\nbVgzdmtaVTdzelRJWXRWQ3VnUllvVEkK7+wFnHcWlQ578ZBdYdEfbstSSUHyftzm\no6E9oYEqwH+oxftQN5E2nVQ2QxwdsXKlnThkMJgVxH3ncgfMSUt5zg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEemJBUElBUi8veVFhdTFu\neDFaeTZtSUhLdVZURUhZL0lVUHFwZDRuKzNFCmFxUEc2c2d5Zk5DY3pEM1ZjR25F\nWU9pbUVGcFlldjRnNkFNWEs3SEFLbEkKLS0tIFVreDFxTXhnZmlPM1ZSbGprTXgw\nd1VLRnB6SU0xazMyYUN6Tk5qTzlvV0kKmU4G4B3Z5fMYXzjnH2X82TQ8YlP36o/V\nw+kgkjrfbjoAt4R9pbggC7hfwH/L8t8QffuNMADXGevpPb/A7cVl1Q==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4eFhnQkJVaVFiWmdzYTd6\ncXduWCtZdG42dTU3VVJ4cEhlOEhpRndSN0JBCkdTWWhFQnFCTzBOalM1S3RTbDFo\naHBGdDdUcFB2M3o1R25VcTJ5YzZ1YnMKLS0tIDFnVnhzcVRtZmJZSmdxSE5JSGFK\nVkRRZWRPMi92VFpUNldYZ0MxU0lTSlEKh6gcbf04oOIrmLzfoK/0wagfzxDh/DSb\nQCRvyhkY4cFQgO1fn6fU4UXdOq8Lp0rXPEuaK15L7hq3q3hEo74O3A==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBicENuVDlMQ0YrVGhtbzVV\nZ2h3Wmgwcks5N2tNd1NjZDlsWlpIRldMb0g4CkpnM2FubFY3bldmNUdOWjdwZzJ2\nYTkxeHA0RldtbDZOYU5zZmpPdXdrWE0KLS0tIDVVTGJyOFNSL0psOG5OQ0pWRDN0\ndDJxQUc2eDd1TnQyZEdLU2ZMNGQrUmMKRrnqHwB7PwgpbxgLFDFpIZoNsWaBByN7\noHH2vSUCDQ7WdvGo6UxGJMcCA65XBNOPjq/TCgXWEbmrlA+zD8o00Q==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2T1gvVzZKQkx0NkJmek9i\nUmtWUXN6bGt0WVM4V0NIN0pZdzMxNXpUZGtjCk4zRVNRMzlraFNyQjU4Qkl5WkpQ\nbDcxQ3c4cXFtYnBFd3hmM2owNVZaVjgKLS0tIHpVbGlLbDFzZFhHVGlFRU12c2tC\nYktGSm9JRVlCUmxFOURHeENVU1UzMDAKbUeckn/3XgXyPFn/W4Ha0ayo2v5wVMQb\nNrsjhYQFn9cdG8H8hqeGh/yE1KLfIwzI8U/HSXlYs/NtsvH3h5qUPQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5QU5lUC9TWUIxQzVPOFVq\naG5QSWw5dVBxNGRsY2JZd1VwZGJoR01EcFM4CmFMbStEckh2UlhKMnR2ZytST0ow\nY1JHemRkMVliRzRiOUVBaklXSFZ0S2cKLS0tIEQwUGVka1RaenN1emlOSG04OWRC\nTWxTK1JFUVl3UnFZYUx2cUJHMk83d1EK4SCBaUJAISOnrN04W+5umnS2dp48z5Kw\nhrw7MxWQpANCfsABuFCP7qapMmz1QHE/gMOky7RZK+thzjCTkXYTXw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQWkY5YkJiTUVxQXFES1Q4\nYkY5UXp6N0YxaVZILzFkZGMvaEZ3TU1XVkVBCmV1dVhCQU4yZU10MnFhUGxVTEQ3\nNjFOeGlHdGRBQ003WHZGMHJzbk9zZU0KLS0tIFBCb0NRY3UrazRzM3g0TEw3MUhn\nTThUbzdFZkJONGNYVTF5QitLaTV6cW8KR/S0wl3+auYy9Ag0tLckJ2Xhy92e+s47\nm0lLrUGvjLYSGdA9Ox3KS2nmem+RQp0RCjTzErDlsY7X5Ai7duCJRA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBdDdUR1FUM2xjUEJLeXpt\ndWJpNVJCVGdONldKbW5Udk1panRXRzFBaVNRCjlUeVdNaTFqWW9MbFkvRlQwN3Zs\nSFhpc0pITVdvYm9WL01temhaZHdkdzQKLS0tIERGMmpIVU9xakRMeWxxREcvWnIz\nc0kyLzErZ2pURVFuMElPN0ZNZWhKMXcKzUjci5iRgZaxk6EKNSP2DyvojeRE1izg\nmJE8C4956S+S2sBb13p3ZsuqpKhIWCQR/E5fbqYsSC8vXoWkbEcLbA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1RWdBT2pqd0w3UHhtMzU3\nYlU2Z2ZsWUlCd3lIZkNMQmV3aElYTHBLNURVCkloRmZHRTM5T0MxVWxjQklRVDJx\ncDN3SnM3ZFBYWk9LdXFEQmQ2ZGFTd3cKLS0tIEs0SHhEQTdRdWp3MkdNa25mK1Zz\ncEU4ME9sMFVXT0NtMFNoVnZnOURmd3cKii8ocexy9c0xfxPaV5FtBWlWy9KsaIEh\nMpH3eJTuAK0ElMjFrrI2AvjuW3OYp3WQU7ZnqI6ubZvi8mW7iZH51Q==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRUFNlcVFPZE1VaEpHb2JX\nenRMV3BUU3V6VEdybkRDUUZOeGhFUHFnZ2s4Cm0rQUMzZnJxUlNmRWI5dDVwQThw\nTjdrcmRaZmZJTUJiVmV3REd6Z0YrQnMKLS0tIE1VVHdlaEJ3UHBLbVJNMnpyc001\nTDQ4aDlpbE9WK0ZLZHk0dWRIWUZLZm8Kq9NNCEXuD9sQRgEPEh9CJ3ZIsS5HLsVE\nefDbk0+JAuDJ5YONA20WDUAS1V7vpJsm3X6KUPuZky7nsEvTZNbLNw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-03-31T02:48:03Z -sops_mac=ENC[AES256_GCM,data:PtfB+K2c3gueL/a8MnWPsaO5XL/+uk9vgilMCAGHRW53R0ilHzWujyuNTtjsFGbReRP+SXbU0hllj3RkxCQtwZKouci2Qn9PmAKEonhRLjdYl5owSJsgSaXTL97wUS0hAZrCeYYAlLLPNYgP3XE7DqtFx9mY41vcn0ViyK0MPx8=,iv:F09inZuqJ1oRn8IUQE1noenfjdsyMNBkqtW37Z4qogs=,tag:M4Tc4+MD3zl/YZ+VcUxP2w==,type:str] +sops_lastmodified=2026-04-01T13:33:51Z +sops_mac=ENC[AES256_GCM,data:a90wHG1QU/i7txQAVbZTNg+in8lFYAUM3hJVSa3Y0WPU0BwO2bubusX0iw9CWwSo9cDrHY1gDlIpM6ZYXYtyl99MqzIqT5qaFLwIap/y0I0wjJDtgsHsCaAw1blxtLYbJTLsHlXg91B1Dc5P8P49isBsbL9a+cee/DcVVYT6ekE=,iv:cvZhYEY3tDNsRcfGGFE7vbJQdNFf4gvBLuRALjgdabA=,tag:xC3/58YprZgrs8h+eghgzg==,type:str] sops_unencrypted_suffix=_unencrypted -sops_version=3.11.0 +sops_version=3.12.1 diff --git a/infra/.env.enc b/infra/.env.enc index 1be76f841..c56d332bf 100644 --- a/infra/.env.enc +++ b/infra/.env.enc @@ -1,55 +1,55 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:vbymPb9pIX3Ar73KrjW62lYM/8A8fWoh7MnJA3IUAusQ6+MpOXNpL5k8eTu7ieGwiV7gY90MnQAra5upymG3Jg==,iv:xY8Lh00p9lcIT9B9LCARxQihN+GrivS7C2QlVKt1J2w=,tag:RDwybIdDYRk7Ecx/5L5vDA==,type:str] -ALGOLIA_ID=ENC[AES256_GCM,data:T9BnAyvLkom50Q==,iv:hntMtsqHiA+1V6D26KA756wRBm1BkFl5AuopGhLhUT8=,tag:GblUMhdJJG+VrGCniCq3zQ==,type:str] -ALGOLIA_KEY=ENC[AES256_GCM,data:72FxZOZkqzMIdbvYpO1yEZFyG0YZY7rVxJjuDPgcGD4=,iv:v5QGHSSljTYWL0/7djTGtPDP/VPGQeq0zPzDHrIfE7k=,tag:QmtvW6czoCHf0QuqZzyOug==,type:str] -ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:PhxMicSMXQSqsFVb1K2Z3ByLwpZROrob50lICyVokto=,iv:+BjJtiUur6YEiP3BxNi34wyPw7D5hVZoXYZVs6CtL8g=,tag:78je9z1AKBKwbJbEs7aKbQ==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:T+llsxSjDyEyDPzQ5ocRshwFLTeqmG/BsQd1P/aTYxd+3IsWbKuDJzuyikAAQozpahKkJZefeN2nmdFhNJlrPPE7xXXvTn2Mm+gnMRJFb5X2H6/OGua+4Ds/wcREkKhCXJJ8PrUgwR6UNagqfvfa5id34ULX3Q4ZyIUCUnE3EtQ=,iv:/Zt+E7wtlGwiMqwIVOgNT8AH74JciranTzaaPi+5esY=,tag:E6LPkAGz+odAlMMsmRbHIg==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:lZO5lg0B0KwZZqcNsVkLYkQN+xc=,iv:gZiZvft7AAcfl1NEXbrkB0FSvb7ncYZ9BQ7TWK2IyLw=,tag:XdtmjbCznNYvh1UfJrgbdg==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:4VovW11aEAmALfR0GJzQTUqhPoM=,iv:alQBBxvedCD10OPIf8/J/VRriY38LCQDtHkkzHfdxvc=,tag:RK5YxQOPb0A1MVbLK3ho/g==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:FZn5un+lcsDlePnkGlMTH6lUjncFMYjzl7aV4xyJMtCHhGHx9OOrMg==,iv:zQR6Z1HxiNJAv/sN3/A3UjRr33wbdNZtCIB6EWj0yNM=,tag:Uk7gvphLvDeXACoCzmryjQ==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:YZb27G49IssZyhiQJIc596Obf2H1g96Nyh4mXr5rNhHypTUSMYCH8w==,iv:r7aMjD/RZBUjTP5q2rsNOlSt6+ncKgfDuBL4OX39Q7A=,tag:t0Q+Wb9Zq8aDiXRTFdS+SQ==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:4BDHdFZ4+N1bdc2Ae/BO8gUIqcBozcAAW8btH6zbkcTViLq0FVa7jqdfK48=,iv:3f9W35UnnR8KDxTQ1nZAuhMX+9ByCIKJctjjsJ5HvDg=,tag:1xWFZOBVZnK1RQxK3BQ4zA==,type:str] -BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:hyphmo+BecobINiBmCOtn1uiM91/,iv:5Mz9NiC3siH/ne5a0oomvFdJKsXODXuP9C6gx4kyHIs=,tag:oGRKbNCP+/8S6mEzyccF6Q==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:VlGdDtXbuz4Z03la2CGzYPgaWd9jNjbnZL0Hp7nntg/wsThrxsI5gJcxjNw=,iv:1neCIDqq0sdaaP/5YGi9CDh5bppHCqMeTtMcUwbQ8kg=,tag:8oNLRkD2QccR8a2EBNntjQ==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:Ffm/UaNqtwglqDY4pJbrVSvbxoV8AS2hpL5/0NI=,iv:rnCV7miKSbOwpDlKpOr076gMhnas/MhZz+YqOSNrzls=,tag:UR3UiN3wZDv3dKyE/A8sig==,type:str] -NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:KiA=,iv:JyFPxlkDeW6mbwFKmIb1V1RmQ79L1NrvJwg8F+ZoDjc=,tag:X7a/MrnxM6Rx41Gj0fxaQg==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:rnyWq5KO,iv:ES5zyrlIYq+YiM76fbQkQWOkRlHCfCJfvJKFkkvbe6k=,tag:SbakzlP6bPGata6MxQPN6Q==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:LQKtRZYGCzbIYHTXou+Ajpk8VMk=,iv:GgF8FClckpq+ZzfzxQKxTz9LXXblxIie5JeBM3d9kk0=,tag:I1gRtcQevT63dUQrWeCtUg==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:FIeG9pi8ABy0OOS15akayMmbyVDu5yS0WHZk18Zf/QQ2In5bsC52nQ==,iv:XPADr6CQ4Wq1SOlDoGgrbVxrVM941/jtS49uNW7rQfs=,tag:amR+BxRepiiTrjPxIvpZFQ==,type:str] -FASTLY_PURGE_TOKEN_PROD=ENC[AES256_GCM,data:tEeV4a6MslOGAHCO8O9exE2OR+FWI7/P/DEZr1JQEtI=,iv:rs2G/2JHcjYej7XubvvazBuysfGvcR3JqIT1KHYdaqo=,tag:i5vbABs45IDWLZA6stIZKw==,type:str] -FASTLY_SERVICE_ID_PROD=ENC[AES256_GCM,data:r7i2ROh2K0SPmvjdg7S9fRLewlZmiw==,iv:tyvLY7zHaHM7g/7RJt0WP/k/iNeFIu8wCaZeGkWDFak=,tag:+S9pjgNt1xhmxujbKIEOUA==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:hscfsdG8NtRkfYYmGDxtiAlkbac2RTyBsKTkl2jr/m8=,tag:bcuZAcwY7JRrysMC9uFu/A==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:7IwVrNGrGdp2uECsH3QOtZ/OvskoO/LGCs/XHVbwMRVtPPo4ydbHxvmxbQiDzk+Q0GHv0sKMSbsd8B3ybYYzEU5jWp4qL8uQnqYqT8UaPVoPqgfXiaugUYLlWN+GVqTyfgOKQFTdyfcr4EGJ3e6njmaZ1rMF66AJyhJPAKHSqi7bgNWqOECjNISV1SZL9vVgo7L3xuwu9t2tUqTE95y5wWlz7XQvOjDCBJB0nuLd3ztfzI6qwMkzkg2nb18tHDTcPDItrA4t+rEcpC7ZeAhjE38536k3WcU60kCXeXnlzn4AP/2boGLGPfYC/qxVJ4fAXVf9WFmmjuhjGNMP4NqcsqufgpwgmxEVJpLC6ZFb0QFNjtPZK+SnLd/qJUzyJVpuIRUdCjK/V587dVBMh53V0Kuc2q88fZKA2cWQtjihJUUyyGayFoBIkgEi5aVmYX04w7szcaEFaCPDphKbV57UZstfrO8oYXAIDLVH4pGBSD97RiIvJkM7u0gItYzk/n3C/8L28g8/Fm1MVTvF+PsEnpb3F1q49fch9/y7ffyqb+i1JdlGdlDKUyVKScs9Ead8BuVe6VyDHxSEUJnNhmEAftTPyLXf/VLUlEtAxNAfsLDZ3tsb8/kZhp27elwvpCLmYZez+zCBnF6m2iCfqDWrextk+Iu4yghzt86g6Qkimqo=,iv:YcUxqeIcGA1lk45lVLThieXDtcfL71tJnmxfGUyut2k=,tag:1sxWXaALcISDV6zhXfafzA==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:KQhcMqowStnITI9nwlMYy4tdRb4HfyEOf429vgMCvh4DNtza,iv:ANcTeRiOVQRPWYADhSuSr4Iec0wD7LPmR2uJkfSaEV4=,tag:Ys18nnsHrPzeFmXirt4/QQ==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:qZ/mvlCYsb09CUd6oAxausNEYiWsrxvQgFyAWuqmuvnHDZHn,iv:1n4ndvLhR/ZsZG5id4Zc8rThI87gN8NpK+zdguLJ2Pw=,tag:Q5gZsNDyMqjclOv+xWfP9A==,type:str] -METABASE_SECRET_KEY=ENC[AES256_GCM,data:NYstQY07BV9I8vS7bNQ/Cc0MKh0IasFWGldiG+PIzLpg1sicBORDLZlJjD2/9d8v+pZl7d9f8ETjMSJDK2JTQg==,iv:V665xBdAyDoJISCKCGlr3fHz0ukkGPeIFciu4l0/ykI=,tag:10lk2XHC502P2zmOhIMzqw==,type:str] -NODE_ENV=ENC[AES256_GCM,data:d8aLeE89dNwCcw==,iv:O0Hiqq/LRysBJpT5a598UJq4VW37OHf4OBcB7vYWga8=,tag:nlRerWMVGSrBO6ir8SxD5w==,type:str] -PUBPUB_PRODUCTION=ENC[AES256_GCM,data:+YMqvw==,iv:8BX5vv/f5+joMxH5IbRmps+jy1a/PGlWhdocif+LACU=,tag:jPQwqLcBtVrzxgtuX7KZFQ==,type:str] -#ENC[AES256_GCM,data:a1UATWoyeGcPNbqZDI/5t4ZeGHJ4lQGxIw==,iv:gfIL58YSIUuJ2Dd35HWTKqdKHDWlHFxb22etNoqbXzE=,tag:eQeJq8FzglAtgcxTw6GJHw==,type:comment] -#ENC[AES256_GCM,data:2xM1MTnAs4v2RlHq5XcEe1UNIIhV+JCTvdBAGDzH6EUvZEIh0w==,iv:sr9KzmZsbhzXZ2VEA6jeP9LP23JVxv+EwrQQcwtG7Wc=,tag:hB8YG7IuE/syrIylMkDq+Q==,type:comment] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:vtIbmGq13DPp6xmmPnoLZ9yNa08X4mGaa+10yG8xfvXzgKc=,iv:8AG4A52LWFlJ0ENL/iusIM5I6kBBTnYQjpPZrvKWPQY=,tag:tw0Fm3VItc0SduiVfZS2OQ==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:2Th8rzoPAvzCYE9Wuqb8dT7x8Uk=,iv:bBspSn1lZyl9fXwni8kHBXF3HNqT4N24Q0qWCJJrfkI=,tag:aL3JNfqt3z9EtS4e7a6Kuw==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:Kn+bvOY060HrZ5iKt8LX9FAxUTqE3lsmFQhlX0CV9HOHL1h8JN+i+A==,iv:1EBNgDsy/kAvUJqxFwTVmH3YLlVWb9yyVwhNqaIYpBM=,tag:d1+up1HVO2YbM5UhFRtn3w==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:Q6+2yr3yNTDohG8=,iv:HO7haOuCSn6wqL2n9PZd+wV+yGue1GtZZlu32zvIzKc=,tag:XgrLlDcIfENqw4ILP9pSbQ==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:foNoUlRH4NSmcZb8wcJQWLGFYT+M4WhBOxcuKrMO3yk3M9ffyXASYEfcQuMX0JtyUHODCjIcZBEenxAjorXLPxRRA/86BvxrmUaQW3Ytzyr1oG7MMbx7C/0eomzahMONHFLqUitEiv+IzAm28/qwEvT7C0JoVjX5Inq1MXVJBlXNtn4eIvFVwFoIZD0DjhDPhNAbb9J/pZ7QP7/kXLd7O0ekWZAuyUeor0xSkvHu3RusGhkZp5E9k2idkw==,iv:P4dLkW6qYWo3V7Z828gjA5c/2HDO6g2ACA5QDY0YsdQ=,tag:PVzxgpN9V8nrvlsT2OWVRg==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:uD4Y,iv:xCwzDtvUsME6rkXD9ZjnWEpWlkkQeCHBTWO/KQ3d1tk=,tag:arH2UqCWDEnx63u0hYfGrA==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:xZc=,iv:+VnTLa4E9ebKgzkL6ZXmJ77/z1Wg4IPchiLomjucoKw=,tag:FxcZKE2WP24MtmMqysZcaQ==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:14eklwo2DK7XzFCl4B0zKorH2mjDJRPdLOQGlILCrrnEf9R7lNE9tAg6Jcw27UrNArwmHA4OoEUKEV2dyCVO8b3MPjsUNtm4X4lhFeQQKA==,iv:GMQNtmbaZf3fr9Lz1lhOyx3WQpEGt7HY0NhFqogT2Ys=,tag:P7Cmgkz8Fp797vDIIz3oDw==,type:str] -STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:0DTdF0yYSzaR7kjhJlKAUI69vlizcQvVNKutdsgkJXEM7j7r7+Aa//kxWzs2RC4iybgqh0cd9SYNyMGgvcP/5ilOoV01LGhMEV4BpEzbC/y71U4PVpzJCRPrUDNbnW6tHcqsMA5neXXZUK9a0iae5NHV4PST,iv:kDMMDSMSYs2f+vd+rzEhAUYWhtO0ihNIF7W7eJWh4Z4=,tag:r7B+miNzxPb2MrANLj9GSQ==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:3qkmTVWiUpP1D7vYahpLdQh1sG4=,iv:trEME/o+ZXXizoRX3zgRm7d00GGDuhIhPXWO/Ck/5WQ=,tag:WsIG8PSLsA5LIOeOZkTCfA==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:tVHxu0XEMCKOai0fZcCiddozBKw=,iv:WALv9GYJDI5eDo4YiOZhkhGt+I6RFN7V7GpF/P3MVFk=,tag:0XG3bv9CpS72tC07qDYeyg==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1dmQrZ1ZWZ1ozdU96RWs3\nRDU3MEhPbDNXLzRJZ1pKbUl2Y1E4U2Qyd1c4CkQ4TlBCNHM5L28yRDJIZ1N2ZE5D\ndWJQQ3RXQmJjcnZhc0hxYnFhYXpua00KLS0tIEZyeTJMT243cnliVW1TWEFPTVlh\nZS9wTjUybzJ2cFh5YVdsbE1TUmE2K0kK1k8ivaJttK0pzp5UijicG/NT9BaQ8Xog\n0TQpR54x4vtLBgfEMi8vy/V6jBYtCFHoefIUfKw9psNxrYx6NhJz8A==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:FzrQWyJl65/yO+oyUBjiV1nGGdYA0XfPNFIGaQsjLpYrNpx+IiXjsDZfIo0ytduVQPp/iUVp1GPn57BpupdCgw==,iv:7/A+NHFCCU6ljCvC37m6Hhxr+P9BuY6nmd6eNQKQooE=,tag:mF8ZBkKVU0ATMHd+PbBJpw==,type:str] +ALGOLIA_ID=ENC[AES256_GCM,data:fB2TWtyk0WJVyQ==,iv:uxpJBLEXbUGeuI678/t8qAdi5Vb/Wacyn5UjwzTajYg=,tag:XUtGIfSaYQkFom0JQfIWuw==,type:str] +ALGOLIA_KEY=ENC[AES256_GCM,data:4o31SXcLs+fHBkx98AYUmFTy+m0RccBnERACY7yoTAM=,iv:ufVpTfOsJJ2tQV2tscKE8PpACIV3drgADBmXjSL8v0o=,tag:voBD6qofZ9xEa+Hi4kuYWQ==,type:str] +ALGOLIA_SEARCH_KEY=ENC[AES256_GCM,data:6urNLZR5+tI5xX6+Y13gcWyvkKY2Vv70u1u1j06Hpho=,iv:LR+98cOtVyX2zWHMABjZ63MVtM317jyhdQQDe5wa1Uo=,tag:n8OLPze5CylQO1s/U8dtqQ==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:N+MNORx3e85hQR264OrrPUE4OC5zHwraPsLBFXc4Ub9LcIHtHuwBrJJ3thamSRB75lVrBBChiaDqKkFB6q1GWrROX2bjsVM14yUUYz8Fitr8P0rdjeonFLpdAJa2Q8cgcFpVVLHhsRGbYt3UtyHF2kXN//5VIsg4/C56cRC1vC0=,iv:lm8rksvXs3g9Alz6PVezBLX2/R39YuWsrQ7FdGMXk0w=,tag:bZBHlcdSTGXqXhtlH0wifw==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:rKrqrg+8OrGjLS+SUHEKziAEuW8=,iv:olGPry0h8DgfYLbZuEJStAmc8FO0uuKh90v9Bulxtjk=,tag:uscX02w9U648Sekv/WSy3Q==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:o1kBlDvsmsZL9C9aarvdQMzUKiI=,iv:6ha79DAj1ta5WM1B3AgcBSyvcxY+ut6xoRRsbjOoULA=,tag:wWNHv+40BsyZwuulvYZv/g==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:VWYNjlDCznMOsu7PqCe719qPKRyRL+nO5EwbWlT+hpm4vuBPw6KKiQ==,iv:oNdCgvnIQVngYzNICPK7Gr6D2hrhzr0k49JLodG6Z50=,tag:LxGdVxnD0S2B4/X3/m6kxg==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:wz6Y5Nsp//nZf+ojOsrbsyzEQWO7RPIv58Gmh2r7iqL5Xd7zMYgFnA==,iv:ik8CEdJmi1cLWOG3KAw6+tF5T7xkLiHyraY1bW6Qe6s=,tag:DEMK05VNIbvZgQa9FD1ykA==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:Nfc0PtjXrWpiYNzC0+bLd+qXNJoQ9jmf9KukzeY4Qewm49uCI+AGpmXjwhU=,iv:nP0/z3ALZPWs3EcwenrdWnoNtaUcrDpd2hDz/mJ4pJA=,tag:DqDYoCECWjygkQ0CXW6rBA==,type:str] +BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:Or5+vQuShF8aucfro2czFoEIG0e4,iv:IokJIv+x84YJF7EiHfTPZfgbC4GBC51HQy7vCRD1HHc=,tag:r/fRovygc/oCrxiOnSWQiQ==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:u2VBjM/f+JsWzaVv/M3+6qk/seqVk+HNSUcuF2ORdglAlmrq+TCAHjUXQDg=,iv:wT5JRttNwZoSOnlFk5z8LSbllh+njDB+O/AfyOXZiCY=,tag:SIfGbD1BjwZnWbSmoy9jqg==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:IBHnYPBLIKGfajiX4IxhQd/cVYWIsl4mgY6uI/E=,iv:4HUql7C/6M6mK8+RCoTdCJpOotwn1q+NtTDgxirECJY=,tag:hidBxjoMMzPTiT0zh1n2Rg==,type:str] +NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:hjA=,iv:y8NCtCgEPeXepPfb98wqNKbBC/J1U4X7EAUOOXiDMU4=,tag:R5mBZ7P46QywAuwozw1mcQ==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:xgoue93p,iv:kp3vcPyQOJKHrl6r+hTWW9Tl3bSFehe2GIb9UgbtuOs=,tag:mfdMztiJX43mRJXnkBZdnQ==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:1HbaEKfSltOr0K0M1zKo7ZpxQyI=,iv:0cEM2iEbuUiCgcVwYhcUdSI6PRxuWXbs7iBBzrNofjs=,tag:Blf07EjoZFRKaLze1UBoTw==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:P7PK/pvpJs8V57kwwYvVUsdHRkqrO2YPmxVI/IfGXQpJfcH8gOh3lQ==,iv:zaBPd4JrSdmJtpDotl6si0e9skRXWdCoQt/DD++QAV4=,tag:oUOxQb3qfM9y8ew8VtMGdw==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:yyd+/BTLbqHckJoeyJ3CaAT6qjToSopnr9QocsQFfXI=,iv:h0QF1rnL2t+XlFZfq4+dY3cj5Dx8g+RUE1dZadoWANI=,tag:z1G5mEMrp16ac8I7Jt4dOg==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:9JynM0FkRgV82hHMqUTn2s7bD+YfVQ==,iv:J+e1GCBhKy3dZ0FrsM0WeNY2FUrHi/FE4xeyRpKhCJo=,tag:AIHsLMCzoh18QPHKBSehSQ==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:I8Q8aBKYXan8D/XzmHkdd/U9kbZ5GsN1Oa+OqKJUjBE=,tag:jtd4uhBZSbVEtMi2zxNHqQ==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:Bg0PwOmMpF1EzPqQ/wu5woARhTV4V6bKxza0AT5SI9mfMF3zxuNMF2emVAsQFAu3oyJxdLdFvGED4YjJH7nWD1uLZMYaaguB8iWZUhMuZAQKYkP+aiNAiDn0Iz2GWJuhS7rvaWZsaIfaRp8cLV+8i+jVib3GjgfbC/eQAm3YLH1jETgbajHE5vwX7tl9NJzik9et5cFce2/+rM5iOfcM+3vmYUyrnd7n7ZuYSsPBLSDwhNFu112e8/KXs659Wq1F4Siy+WHKE7NyA0IotjTfRVnzzA4VIWM/0ARLDZAB5WqgvARu9cN8C/farFM0ve1LoHD4rgzl2aXdw7yWu9eJRXY3KOubzD06oquZrnYJTXsll/KiXCPg0OijaZjPt+BwzH1dWsydKBlkiqHWdZZjsu5NxOqu3TDmSE85T8iIVE9ZpIgKwAwV5sPqKPR1qcHZBQcLIO8VbNh0/XzQTeOcLsPnzrjhKquHe6IxvpI3u5qLH0TLnUeoOQtj6Fo8Gt4bAWehJSYJyZpgVnpqjQnFBxfVGGDay2du7VVt4pK7NJqStHsh16nqewjC3jF+mMAFSN6Jz52fIsv49nmTXObtKHs16ldm4fHkPPvjIaavoYAjhVO0h/yDkXVU2kLCojDRBVOSPhVA16Q1x0sVCygfOUZx8NTh1g8QWEalWphfSzg=,iv:jz/l9gBsVEWv/xwqhOkujOQfM9k/njJe+CkqBDQYNmM=,tag:fKOHI3yzSRrvaJjdS74orQ==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:BqWxiPEWdOlFQpZxoHQhYRNALy+mICkLHAedLdYICGxKCby3,iv:4ufSooSgj4qMBcsfTLyvWanE/hf5mf/k7LLkUj2iZCc=,tag:JraE4se4+aJQxFcFpOPpgQ==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:GGjA6d8vmNnH8ZRUR1J9M+R0mF14sUhTlcqX6xXweAA99rpA,iv:v9I0Q+86scRlIBhgBKtupTmrYDIg8ECUDqn+4q7xnik=,tag:qvTM9rw1uTZBNv3phYimnA==,type:str] +METABASE_SECRET_KEY=ENC[AES256_GCM,data:ZeFKoY5/DuQcHajeBzoHt4s2zuPmQfck5cWXiC1heSZmnxPvdWcp1svxnlTFp+Ph81jmOGWSKo6PDYq/+g+hyQ==,iv:oBmsG76duMxcQI0HCxqrgatXoeyyFiNMgpGhF3engRk=,tag:6h39+RIMhsLGKQQp25Ej8w==,type:str] +NODE_ENV=ENC[AES256_GCM,data:izLFAHzaZEh9kg==,iv:vNjqaC42NkM/IJFxgnreFMgAosmSDBPVg5Gltn4130I=,tag:h5wgD9zcK+PxfkDJQPXnKw==,type:str] +PUBPUB_PRODUCTION=ENC[AES256_GCM,data:hFd5Kw==,iv:lgXcSXBNZlP8v6Fin/SblVOyqjq/cYnmQETCSVKroII=,tag:2YMNcrzNeIBhH5Dx1dlZJw==,type:str] +#ENC[AES256_GCM,data:5Lc33H4hD8+Xj+B7PFa/yF3XeN3B/F0Kdg==,iv:g5cY/+dfO6/pFpLto+x2BWK+zigYNaGkX8gZ3QvK4B8=,tag:fvnfrAf6voF7IYIbDwCowQ==,type:comment] +#ENC[AES256_GCM,data:nvOJM0Hcg/amQjsvLg9Icf+5i/0PeCvNsyEF7OwN999OsEp3Zw==,iv:nXePC0fqoXyPcW29Gx8ZBDQ9/CxLruhJstpeUN15kVw=,tag:CvO5LsiHfCtMChTq7k+Nig==,type:comment] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:xzCtH1OI1sLjZvR6qtro2HFA9LYkebr2ukw5wyR1q4c9jng=,iv:k7b+WKHokihnvby2vxqTP4ch4fIut2byikmpY89Zndo=,tag:8dLesiwz8BbvMYkm6ih8Wg==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:U45a/exQ7bew48ayU/BINfhclnU=,iv:jF5EdmOWq6eBcl7aHpZjwULf0ERBXUli4qlQzxy9qQ8=,tag:oKATOloKHvk12dlbaogiBw==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:HSXDCRyZ54mQjH9xzPYeBYn2AbQ1Ikb473RonwhQu/JQQFjjxqyZTw==,iv:3gEN28BZiWHKxBWM0003wmI1JKl7pPjmahUk6m8bhWo=,tag:24LDZ4yVd3SvXdiBSQ3xqw==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:mTdxNcT7bxGGN8Y=,iv:DgrPacnr9KJbHiAbYAqa6QDVdDb+7p1Q+HaEK2o5xW4=,tag:FzLs7p7G0JmpVOXMLDM++A==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:OdMmFZqG2feiQEKlNOzJLcLcnlKFzwxYWpze25uHlAeoBfbp8eg8K5FriQGuDPWwuG61P+ZL86VR7dBxhotWKJsjruwI52JyhYvPFkCrnQfKDrXl3FN97wXe5sh9ZIUAKmatB+QOina5TCjSQu2PaVMA2Y3rLTFjhZjT6c7NbuY5uM9WPuHz/UG+xhqbJXls7cgfET2HhyAZmL0TJM9ytJXmk+jHkV3sqUbF+IAy7wx63nklzK3fgxoOXw==,iv:4FA6mZMc1zSCLJYdXpj38+2/IEscq9qkZmZUJU2A+IU=,tag:RtQf/I/f20VJ8aMLGRtTXg==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:xO+G,iv:Bj5seL8b/Y7IsWV4JBTC6qP+z81/ry6UrL+kdLJj8rk=,tag:7OH5qogKXt7b4AuJ9CCPYg==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:bWE=,iv:ojxFjPgmjugTUeJeOazO9RPilFrc0p7r7rAe4HPhQvQ=,tag:dluXFeHo2jWAyl1WCG6ixQ==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:eXyN0jLy81aUlTg4xEuKdRB3vGHexe985UsTa3CUj2gplKVjqmWb7DxD7/us+52O0oKRHVfWnmt46WESlzw9TLVM9HQPLgvuDKsJ3PDjdQ==,iv:/uMvDKDO2LcBI5sINc4RrpoHYJ51It1hvyTLK+ZcLLU=,tag:iqpDL/EFo9yHvdyawcW+XQ==,type:str] +STITCH_WEBHOOK_URL=ENC[AES256_GCM,data:+2uiQZc29PFt3ugxv+Xscqw2CySpDZwXS4m3Usx+Zb7BOxlLtu7ov1/4qz9zECWEVPWUqGDEATs3C8/1ZYa7erk7LXJh73rRkmKmtjEWKJ8YvANw8NWFIyMFY0OCrqx9As5O9joWxZZsyEssFMNwFmBFIlLs,iv:mTIx5ZZbngr47Wxc0GcIkLLZt9r7cpQ4bqIoeC61d1o=,tag:iEvvNqdJo/xDs65a6vQ1SQ==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:4bOh4osMRuzQ7WSpb/eRlKE28nE=,iv:UJdEMFYxAYxhK7IMWzWj/X2i+mUkP9RPhi22xbW3YUI=,tag:o78ysd6fpA8gTTj/M7dx7A==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:W7aXa/yv3cJ3fMnJJbvfqAoNJUs=,iv:Ep0SW0kEQB0GDeHbmCPiTN4sDNCPCStb51tQHDpe0Rc=,tag:mPUp3hUz55cSzOc2nXBMyg==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHTyt2d1ZRU05wU3hRcjRL\nd0lndTVNY1V2RDlvMVIzV1dzaFYydUJ0SVhFCkJRMXAzS2Z6dlJ1MjNwMGtRUFYr\nYTl1RDUxZm5LTU00VmZaUUp4MjdER1EKLS0tIFIxbit2VEpPa3FnRWdQeFVnUUd5\nRngwMjFrZUhEK0QrOGo5NndONFNYZEEK0TCEFubEjXVlPI2otclTYGzSSv1XtkkG\nIXwwvM2KKlxlLtjrE6PSuBXirLUKqayk5larPnhdIOYaokpYqGutGw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqMGFITjNJazkzNGtwQUF2\ndURyRktTTE9VY3l4TWVlam9zc2psQzBSMUNBClpIcU81VitObWtnM2U2TVhGaHo5\nK25Cb01CQjdwSlRjVGNmdGlUY2xHa1kKLS0tIFMwOUtuVTB6WmNrTDhXd09tRGU0\nLzM1bi8wVmpnazMyLzhvOFYxVmNtUmsKSjl6gGc/oBnkd7rGz5HsXVlRtSY6PopE\nOoGXRVElkLdhBzC9LY6HbgkWSJyu+v56mIbYro7euczYX+6dzrrerw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuWnJCU0dXWUtVdnpSZXBn\nckZYbmFTVmhLQnFIY0ZrdGtTY2cyQkJ0SjBFClhEMi9WM3lpek5SMjZFaUY3bXB2\nMTA2YVdDNmlXQnZyUENQMTRGOGp3NGcKLS0tIHRKV2VKMHpYMk9Ta01yNzJ0dDVu\nSUdvUjJpOGxEc3hhcEpDTFBzQUx0NWMKSe2zCnQ5LCrcs65nHVLjH7uwhxHEBHLu\nvwdgsPA8uJo/1lVkmOOIT2innBN1lNRGbBFX6CZDe7hUmNjyS17oig==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2V2c3dkxMQnZwTDRrZWZI\nL3A0ck9CaEo1WGx3UE96bEZnWmVBUXJaazJRCkRLYVN4aFMwN3gvaThkUUE2dGpn\nS3ZGMjlTUW1NL1Radk4xOXIvYm42dHcKLS0tIEJxbHRIQUo3ZlU4QUR5K1RCL2RK\nNmc0bllPUmNDUkVuRFdUaGJYZU5yVmcKZKktxJzujZ/C8VogEoRE4l6RrYEUf41q\nObBXtOgawAax/R0gdCfoTkoC5yfNT27yu5ngIz0wlDpusNF4L+fMpQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBM09XU3JMUnRyR3ZpWW9B\nWXJvcmJva2E3Y2ZuR2F5SUFPbW95TWZtMmgwCnBoWmNKcHpyV3VDZGQyY05zNTZP\nb0t4bXJWeG92TnFZeFB5NlRRdWUvTXcKLS0tIDZzejdPZ2lKejVyemoxYkkxcis1\nUXQwRzc5Sks3SjRMUUlpcko3elRpRzQKsG9vHJOM22VispYg6MG6vH3JNkpbiOxK\nmEuJo372CNR+20xdhxYphiDrFDVcjSX9q29tMJ5CmLmEZNp3u1US7Q==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHc2RSV0trMW1qZ2dRaFlS\ndjZ6ZHliM3hVS2FKa2R2R3FiYklwQllrQTFFCjJwNk1ZZEo1UkNDMTBPQy9vcVM2\nbE1MSHg0WXZ3NGd0MmUzY0pmTWRkSlEKLS0tIGxKTThVR016enMvZi8rSjBHNC9V\nL2MwNWhBdlplYnJ2Q3lLMzNDZGJQZVEKkNanfMXN+vxtDXaUSK/w4q4/bc0NwVQw\nVyKvEmwT2IagUVqD7ey+4upLgiGdRuhkooAGfiBtJHyPVsKvCPeMgg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuRnM0YVd6UlpJbThHMkpC\naHUwelY0WEdpZTJFUk9ySzg2RVBSQ0lBOEg0Cis1UUFRb1RiK1ZsNzJBd3FFYVRK\nc1JOZ3htaTBFTW9vUzEyRktyNTQva3MKLS0tIFZCeC9ORVd4ZVhoMCtGOGVpd0lH\nYngvQWZhVWUzdHFvci82K2FTVFRoRkUKppE3ZbBRtU9pznG/LCDyeDbxDpWeeLeW\nXInx5UcTX4rbuv46FVy5DiGcrDVKGieZa6PZLKxuV+IHW7NW1SCIDA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1N0JjWkt1cGxwbzhPVUVk\nVE9MS1R3MHRIL1VLRHlGNGxpSUdGZWNVc21NCnhMamUyd0RwYWw1SHhQQXBzbzh1\nRVlvMXpaUTNId1BwYU1LZFVONExhNzQKLS0tIEZpZ3dhVHJtRHBidU1OZ1NyZFUw\nT04reTVzR0tuK3QxZ1ZzdTJqUThCTzQK3vTB9CQLDcOJShwvYOkmOcLQfJ9QCkZF\n9SNn1wPd1MNGSfxdSOYWl9t1o0z3gz65YSL71KJC8xZ90TaV3Esthw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsbHhaOGFDTmYxZWR4TTFC\neEZLNHhIZGhRazJCMUlGMjZaU1h4MVphdVJjCmxlY3doWmk2ZDVjcVg1ZjZQRVls\nV3RUVERlcVhER3IzOWJNUWNaclpBTzAKLS0tIGFlaW12Q1JCQjVWR0Q1UnVYOWpY\nS2ZzdzkxVG5NZm1yVnU3eXM4VGdZWWcKWTlk0f21oY9WJdZhFxPuxsTpH5ww0Kw4\nMLPDWnyAQLP4LTOMK1pbAK6h0WSAGFSLiZfZYIWmW+m66gD2W9YQ8Q==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyeXlpa0xmWkMzSGVNQ2V1\nOXMwWi9ibitBeXk1b0lsdUVYVlZieEVZS25nCkVUcHdkTlpuUjdkTTlwV3BmOW5i\nbEQ0cEY4TEJubGV1YjlnQjdjckRKbjgKLS0tIHRHa252aXVxT3Bwd1JaWW5Ld0xr\nTW84ZHlWNXF0K2dDMEJ2NmxWWndYckUKEsCbco1+C6mhFUxFj9zGQuo1Xs5U2HMb\nFq/OLyclKqJryJbkNRPQTfD0J/vzLKk1TLjiyn6fE74vvHVp0qUOCQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJdW1aaFRuZVc3dHZUTXMy\nSVB0RWxYWWxidUxqZitVNStER0Z1Q1pCYTNjCkxOVjZ1V0cvV29vZnh3engrV2Jw\nSlJXUFVqMXZvZXNYUHc1WFhPb3VoTm8KLS0tIDZqWFhYRDVxZVBXQWF3K2VjVnVX\ncEd1YjA1aTVJbmZ6c0hmVjZtdVlFWWsK2eL8X17F56k+xUFQKKtDCAyaehjocCsS\na4WckbCoi6Po0p5d6xvlInrPqOrAT0wHMtUCmc8tqtrrk1MP1M/Olw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-04-01T03:03:08Z -sops_mac=ENC[AES256_GCM,data:hQlfCmb8jk2U5+U1cRI0W662mzTMEfaHXhmX2nHLnTSn/n3I3rVnl/tl16XikJjrO6LQpoELhs0Espu+1PYF7VRknDI0vmoP4XYciV/W2LiXZ8Q9au7NSfNrOmkvNEGdXgOedzxO5cCl96ARmmXsz1Yi5SzWWaxDW3mFb6RPSm4=,iv:TLr9e4RhdNm6Yc/YKPXBxQRzb9Q1RqYeW+0dQodcKDo=,tag:Z2TIN79C44dWJWmgzfdd8g==,type:str] +sops_lastmodified=2026-04-01T13:33:48Z +sops_mac=ENC[AES256_GCM,data:LGl+b6V3pJbK/EMTqQBknlX+7MbtsJPjUXXsoLPwPm8jIiTzqahoaN7+qwwhe+S4CijuolBRHYtxT8dmiH7cxFwJ136PajxiNgPgccOec9PVp3GUuxfqg5EZiQALzNT6vj+Kv3e07NTC8GvsQIs3yKQ2ow1R9wLq5lVwdGseiLE=,iv:9yG1E/ZLKoYG9TiU+xGbB0UIMUENMww6tnR8oyhyPBE=,tag:1m7hsbRiwWth7g5jRXAdTA==,type:str] sops_unencrypted_suffix=_unencrypted -sops_version=3.11.0 +sops_version=3.12.1 diff --git a/infra/stack.yml b/infra/stack.yml index 14ca48d79..d60131340 100644 --- a/infra/stack.yml +++ b/infra/stack.yml @@ -27,6 +27,7 @@ services: environment: NODE_ENV: production PORT: '3000' + APP_COMMIT: '${APP_COMMIT:-}' # Reuse the existing env var name, but point it at the in-swarm broker: CLOUDAMQP_URL: 'amqp://appuser:apppassword@rabbitmq:5672/appvhost' networks: [appnet] @@ -60,6 +61,7 @@ services: env_file: [.env] environment: NODE_ENV: production + APP_COMMIT: '${APP_COMMIT:-}' CLOUDAMQP_URL: 'amqp://appuser:apppassword@rabbitmq:5672/appvhost' CLOUDAMQP_APIKEY: '' command: ['pnpm', 'run', 'workers-prod'] diff --git a/package.json b/package.json index 4f29ef10d..6d276e8d1 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,7 @@ "workers-dev": "NODE_PATH=./dist/server/client:./dist/server WORKER=true nodemon dist/server/workers/init --watch dist/server -e js,ts", "workers-prod": "NODE_PATH=./dist/server/client:./dist/server WORKER=true node --enable-source-maps dist/server/workers/init", "pubstash-prod": "NODE_PATH=./dist/server/client:./dist/server:./dist node --enable-source-maps dist/server/pubstash/server.js", - "upload-sentry-sourcemaps": "sentry-cli sourcemaps inject -p pubpub-v6 dist/server && sentry-cli releases files -p pubpub-v6 $SOURCE_VERSION upload-sourcemaps --url-prefix /app --strip-common-prefix --wait dist/server", - "write-commit-version": "echo $SOURCE_VERSION > .app-commit" + "upload-sentry-sourcemaps": "sentry-cli sourcemaps inject -p pubpub-v6 dist/server && sentry-cli releases files -p pubpub-v6 $SOURCE_VERSION upload-sourcemaps --url-prefix /app --strip-common-prefix --wait dist/server" }, "dependencies": { "@analytics/core": "^0.12.7", diff --git a/server/envSchema.ts b/server/envSchema.ts index ed9900e04..2da416d14 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -20,6 +20,7 @@ export const envSchema = z.object({ .default('development') .describe('Node environment'), PORT: z.coerce.number().int().default(9876).describe('HTTP server port'), + APP_COMMIT: z.string().optional().describe('Git commit hash for this deployed build'), // ── PubPub Environment ────────────────────────────────────────────── PUBPUB_PRODUCTION: booleanish.describe( @@ -27,11 +28,20 @@ export const envSchema = z.object({ ), IS_DUQDUQ: booleanish.describe('Treat this instance as the DuqDuq staging deployment'), IS_QUBQUB: booleanish.describe('Treat this instance as the QubQub deployment'), - HEROKU_SLUG_COMMIT: z.string().optional().describe('Git commit hash set by Heroku'), PUBPUB_LOCAL_COMMUNITY: z .string() .optional() - .describe('Slug of the community to proxy in local dev (e.g. "stanford-jblp")'), + .describe('Slug of the community to proxy in local dev (e.g. "stanford-jblp")') + .transform((arg) => { + if (arg) { + return arg; + } + + if (process.env.NODE_ENV !== 'production') { + return 'demo'; + } + return undefined; + }), FORCE_BASE_PUBPUB: booleanish.describe('Force the base PubPub site in development/QubQub mode'), PUBPUB_READ_ONLY: booleanish.describe('Enable read-only mode, disabling all mutations'), DISABLE_SSL_REDIRECT: booleanish.describe('Disable automatic HTTP → HTTPS redirect'), diff --git a/server/server.ts b/server/server.ts index 7890e71b0..49ac0142d 100755 --- a/server/server.ts +++ b/server/server.ts @@ -5,30 +5,27 @@ import cookieParser from 'cookie-parser'; import cors from 'cors'; import express, { type ErrorRequestHandler, Router } from 'express'; import enforce from 'express-sslify'; -import fs from 'fs'; import noSlash from 'no-slash'; import passport from 'passport'; import path from 'path'; import { env } from './env'; +import { resolveAppCommit } from './utils/appCommit'; const app = express(); const appRouter = Router(); -import { getAppCommit, isProd, isQubQub, setAppCommit, setEnvironment } from 'utils/environment'; +import { getAppCommit, isProd, setAppCommit, setEnvironment } from 'utils/environment'; // ACHTUNG: These calls must appear before we import any more of our own code to ensure that // the environment, and in particular the choice of dev vs. prod, is configured correctly! setEnvironment(env.PUBPUB_PRODUCTION, env.IS_DUQDUQ, env.IS_QUBQUB); -if (isQubQub() && !env.HEROKU_SLUG_COMMIT) { - try { - setAppCommit(fs.readFileSync('.app-commit').toString()); - } catch (err) { - console.error('Unable to read app commit from .app-commit file: ', err); - } -} else { - setAppCommit(env.HEROKU_SLUG_COMMIT); + +const appCommit = resolveAppCommit(); + +if (appCommit) { + setAppCommit(appCommit); } import { errorMiddleware, HTTPStatusError } from 'server/utils/errors'; diff --git a/server/utils/appCommit.ts b/server/utils/appCommit.ts new file mode 100644 index 000000000..134e9cd4d --- /dev/null +++ b/server/utils/appCommit.ts @@ -0,0 +1,19 @@ +import { env } from 'server/env'; + +const normalizeAppCommit = (appCommit?: string | null) => { + const normalizedAppCommit = appCommit?.trim(); + + if (!normalizedAppCommit) { + return undefined; + } + + return normalizedAppCommit; +}; + +export const resolveAppCommit = () => { + const appCommitFromEnvironment = normalizeAppCommit( + env.APP_COMMIT ?? process.env.HEROKU_SLUG_COMMIT, + ); + + return appCommitFromEnvironment; +}; diff --git a/workers/environment.ts b/workers/environment.ts index af082e613..c831169e5 100644 --- a/workers/environment.ts +++ b/workers/environment.ts @@ -1,7 +1,13 @@ require('server/utils/serverModuleOverwrite'); const { env } = require('server/env'); +const { resolveAppCommit } = require('server/utils/appCommit'); const { setEnvironment, setAppCommit } = require('utils/environment'); setEnvironment(env.PUBPUB_PRODUCTION, env.IS_DUQDUQ, env.IS_QUBQUB); -setAppCommit(env.HEROKU_SLUG_COMMIT); + +const appCommit = resolveAppCommit(); + +if (appCommit) { + setAppCommit(appCommit); +} From 5f8a4734ac2cdaca38f775ae467f46c1339bc68d Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 1 Apr 2026 16:46:23 +0200 Subject: [PATCH 6/7] fix: solve testing env problems --- .gitignore | 1 + .test/setup-env.js | 30 ---------- infra/.env.test | 63 ++++++++++++++++++++ server/envSchema.ts | 22 +++---- server/routes/__tests__/pubRedirects.test.ts | 1 + stubstub/global/setup.ts | 12 +++- utils/caching/__tests__/purge.test.ts | 53 ++++------------ vitest.config.ts | 1 + 8 files changed, 99 insertions(+), 84 deletions(-) create mode 100644 infra/.env.test diff --git a/.gitignore b/.gitignore index 86680b3bf..4ec6c220a 100755 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ yarn-error.log* .env.* !.env.enc !.env.dev.enc +!infra/.env.test # vercel diff --git a/.test/setup-env.js b/.test/setup-env.js index 81f845c82..3abfb1a16 100644 --- a/.test/setup-env.js +++ b/.test/setup-env.js @@ -4,37 +4,7 @@ * handled by JSDom, but there are some edge cases. */ -process.env.DOI_SUBMISSION_URL = ''; -process.env.DOI_LOGIN_ID = ''; -process.env.DOI_LOGIN_PASSWORD = ''; -process.env.MATOMO_TOKEN_AUTH = ''; -process.env.MAILGUN_API_KEY = 'some-nonsense'; -process.env.MAILCHIMP_API_KEY = ''; -process.env.FIREBASE_SERVICE_ACCOUNT_BASE64 = ''; -process.env.CLOUDAMQP_APIKEY = ''; -process.env.CLOUDAMQP_URL = ''; -process.env.ALGOLIA_ID = 'ooo'; -process.env.ALGOLIA_KEY = 'ooo'; -process.env.ALGOLIA_SEARCH_KEY = 'ooo'; -process.env.JWT_SIGNING_SECRET = 'shhhhhh'; -process.env.BYPASS_CAPTCHA = 'true'; -process.env.FIREBASE_TEST_DB_URL = 'http://localhost:9875?ns=pubpub-v6'; -process.env.ZOTERO_CLIENT_KEY = 'abc'; -process.env.ZOTERO_CLIENT_SECRET = 'def'; -process.env.FASTLY_PURGE_TOKEN = 'token'; -process.env.FASTLY_SERVICE_ID = 'prod'; - -if (process.env.INTEGRATION) { - try { - require('../config.js'); - } catch (e) { - console.log('No config.js found'); - } -} else { - process.env.AWS_ACCESS_KEY_ID = ''; - process.env.AWS_SECRET_ACCESS_KEY = ''; -} if (typeof document !== 'undefined') { require('mutationobserver-shim'); diff --git a/infra/.env.test b/infra/.env.test new file mode 100644 index 000000000..8deb7c88f --- /dev/null +++ b/infra/.env.test @@ -0,0 +1,63 @@ +DOI_SUBMISSION_URL=https://fakeurl.org +DOI_LOGIN_ID=xxx +DOI_LOGIN_PASSWORD=xxx +MATOMO_TOKEN_AUTH=xxx +MAILGUN_API_KEY=some-nonsense +MAILCHIMP_API_KEY=xxx +FIREBASE_SERVICE_ACCOUNT_BASE64=xxx +CLOUDAMQP_APIKEY=xxx +CLOUDAMQP_URL=xxx +ALGOLIA_ID=ooo +ALGOLIA_KEY=ooo +ALGOLIA_SEARCH_KEY=ooo +JWT_SIGNING_SECRET=shhhhhh +BYPASS_CAPTCHA=true +FIREBASE_TEST_DB_URL=http://localhost:9875?ns=pubpub-v6 +ZOTERO_CLIENT_KEY=abc +ZOTERO_CLIENT_SECRET=def + +FASTLY_PURGE_TOKEN=token +FASTLY_SERVICE_ID=prod + +AWS_ACCESS_KEY_ID=xxx; +AWS_SECRET_ACCESS_KEY=xxx +JWT_SIGNING_SECRET=xxxxxx +FIREBASE_SERVICE_ACCOUNT_BASE64=xxxxxxxxx + +AWS_BACKUP_ACCESS_KEY_ID=xxx +AWS_BACKUP_SECRET_ACCESS_KEY=xxx +S3_BACKUP_ENDPOINT=https://s3.aws.com +S3_BACKUP_ACCESS_KEY=xxx +S3_BACKUP_SECRET_KEY=xxx +ALTCHA_HMAC_KEY=xxx +AES_ENCRYPTION_KEY=xxx +DATACITE_DEPOSIT_URL=https://deposit.com +SLACK_WEBHOOK_URL=https://slack.com +SENTRY_AUTH_TOKEN=xxx +SENTRY_ORG=xxx +METABASE_SECRET_KEY=xxx +STITCH_WEBHOOK_URL=https://xxxxxxxxxxxxxxxxxxxx.com +# AWS_ACCESS_KEY_ID: Required +# AWS_SECRET_ACCESS_KEY: Required +# AWS_BACKUP_ACCESS_KEY_ID: Required +# AWS_BACKUP_SECRET_ACCESS_KEY: Required +# MAILGUN_API_KEY: Required +# ALGOLIA_ID: Required +# ALGOLIA_KEY: Required +# ALGOLIA_SEARCH_KEY: Required +# ALTCHA_HMAC_KEY: Required +# AES_ENCRYPTION_KEY: Required +# DOI_LOGIN_ID: Required +# DOI_LOGIN_PASSWORD: Required +# DOI_SUBMISSION_URL: Required +# DATACITE_DEPOSIT_URL: Required +# CLOUDAMQP_URL: Required +# ZOTERO_CLIENT_KEY: Required +# ZOTERO_CLIENT_SECRET: Required +# FASTLY_SERVICE_ID: Required +# FASTLY_PURGE_TOKEN: Required +# SLACK_WEBHOOK_URL: Required +# SENTRY_AUTH_TOKEN: Required +# SENTRY_ORG: Required +# METABASE_SECRET_KEY: Required +# STITCH_WEBHOOK_URL: Required \ No newline at end of file diff --git a/server/envSchema.ts b/server/envSchema.ts index 2da416d14..ff3edd8ec 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -31,17 +31,17 @@ export const envSchema = z.object({ PUBPUB_LOCAL_COMMUNITY: z .string() .optional() - .describe('Slug of the community to proxy in local dev (e.g. "stanford-jblp")') - .transform((arg) => { - if (arg) { - return arg; - } - - if (process.env.NODE_ENV !== 'production') { - return 'demo'; - } - return undefined; - }), + .describe('Slug of the community to proxy in local dev (e.g. "stanford-jblp")'), + // .transform((arg) => { + // if (arg) { + // return arg; + // } + + // if (process.env.NODE_ENV === 'development') { + // return 'demo'; + // } + // return undefined; + // }), FORCE_BASE_PUBPUB: booleanish.describe('Force the base PubPub site in development/QubQub mode'), PUBPUB_READ_ONLY: booleanish.describe('Enable read-only mode, disabling all mutations'), DISABLE_SSL_REDIRECT: booleanish.describe('Disable automatic HTTP → HTTPS redirect'), diff --git a/server/routes/__tests__/pubRedirects.test.ts b/server/routes/__tests__/pubRedirects.test.ts index 7cf8c6f8b..3e068fd78 100644 --- a/server/routes/__tests__/pubRedirects.test.ts +++ b/server/routes/__tests__/pubRedirects.test.ts @@ -35,6 +35,7 @@ describe('/pub', () => { const { draftPub, draftPubEditor, community } = models; const agent = await login(draftPubEditor); const host = getHost(community); + console.log(host); const { headers } = await agent.get(`/pub/${draftPub.slug}`).set('Host', host).expect(302); expect(headers.location).toEqual(`https://${host}/pub/${draftPub.slug}/draft`); }); diff --git a/stubstub/global/setup.ts b/stubstub/global/setup.ts index f2c365de8..e981c876e 100644 --- a/stubstub/global/setup.ts +++ b/stubstub/global/setup.ts @@ -1,7 +1,5 @@ import type { ChildProcessWithoutNullStreams } from 'child_process'; -import { env } from 'server/env'; - import { initTestDatabase, setupTestDatabase, startTestDatabaseServer } from '../testDatabase'; // HACK(ian): The PUBPUB_SYNCING_MODELS_FOR_TEST_DB flag tells the code that we're only going to use @@ -24,12 +22,22 @@ export default async () => { if (process.env.NODE_ENV !== 'test') { throw new Error('Something has gone terribly wrong and I refuse to proceed.'); } + + const path = require('path'); + const dotenv = require('dotenv'); + dotenv.config({ path: path.join(__dirname, '..', '..', 'infra', '.env.test') }); + + console.log(process.env); + const { env, refreshEnv } = await import('server/env'); + console.log(env); + if (!process.env.DATABASE_URL) { console.log('\nSit tight while a local test database is created...'); await initTestDatabase(); global.testDbServerProcess = await startTestDatabaseServer(); env.DATABASE_URL = await setupTestDatabase(); } + refreshEnv(); /** * Two things of note diff --git a/utils/caching/__tests__/purge.test.ts b/utils/caching/__tests__/purge.test.ts index e56caeb91..2f6bf6dde 100644 --- a/utils/caching/__tests__/purge.test.ts +++ b/utils/caching/__tests__/purge.test.ts @@ -143,10 +143,17 @@ const models = modelize` } `; +let token: string; +let serviceId: string; + setup(beforeAll, async () => { await models.resolve(); - process.env.TEST_FASTLY_PURGE = '1'; + const { env } = await import('server/env'); + token = env.FASTLY_PURGE_TOKEN; + serviceId = env.FASTLY_SERVICE_ID; + + env.TEST_FASTLY_PURGE = true; // mock fetch, we don't actually want to send api calls vi.spyOn(global, 'fetch').mockImplementation( @@ -157,22 +164,15 @@ setup(beforeAll, async () => { ); }); -teardown(afterAll, () => { - delete process.env.TEST_FASTLY_PURGE; +teardown(afterAll, async () => { + const { env } = await import('server/env'); + env.TEST_FASTLY_PURGE = false; setEnvironment(false, false, false); vi.restoreAllMocks(); }); -const expectFastlyPurge = ({ - key, - token = process.env.FASTLY_PURGE_TOKEN_PROD, - serviceId = process.env.FASTLY_SERVICE_ID_PROD, -}: { - key: string; - token?: string; - serviceId?: string; -}) => { +const expectFastlyPurge = ({ key }: { key: string }) => { expect(global.fetch).toHaveBeenCalledWith( `https://api.fastly.com/service/${serviceId}/purge/${key}`, { @@ -361,35 +361,6 @@ describe('purging', () => { expect(global.fetch).toHaveBeenCalledTimes(1); }); - - it('should purge the appropriate host depending on env', async () => { - const { community, admin } = models; - - setEnvironment(false, true, false); - - const agent = await login(admin); - - const host = `something.pubpub.org`; - - await agent - .post('/api/collections') - .send({ - communityId: community.id, - title: 'test', - kind: 'tag', - }) - .set('Host', host) - .expect(201); - - await finishDeferredTasks(); - - expect(global.fetch).toHaveBeenCalledTimes(1); - expectFastlyPurge({ - key: 'something.duqduq.org', - token: process.env.FASTLY_PURGE_TOKEN_DUQDUQ, - serviceId: process.env.FASTLY_SERVICE_ID_DUQDUQ, - }); - }); }); describe('surrogate keys', () => { diff --git a/vitest.config.ts b/vitest.config.ts index 1fb8a6ce7..de1a3067f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [tsconfigPaths()], + test: { globals: true, globalSetup: path.resolve(__dirname, 'stubstub/global/setup.ts'), From 683689d45f9106a07f8b7596c21b164abf4ee344 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 1 Apr 2026 16:48:24 +0200 Subject: [PATCH 7/7] fix: upload tests --- server/pub/__tests__/api.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/pub/__tests__/api.test.ts b/server/pub/__tests__/api.test.ts index bfd2538d3..639142750 100644 --- a/server/pub/__tests__/api.test.ts +++ b/server/pub/__tests__/api.test.ts @@ -585,7 +585,7 @@ describe('GET /api/pubs', () => { }); vi.mock('utils/import/uploadAndConvertImages', async () => { - if (process.env.AWS_ACCESS_KEY_ID) { + if (process.env.INTEGRATION) { return import('utils/import/uploadAndConvertImages'); } return { @@ -594,7 +594,6 @@ vi.mock('utils/import/uploadAndConvertImages', async () => { }); describe('/api/pubs/text', () => { - const isAWSAccessKeySet = !!process.env.AWS_ACCESS_KEY_ID; let adminAgent: Awaited>; beforeAll(async () => { @@ -772,7 +771,7 @@ describe('/api/pubs/text', () => { expect(pub.attributions?.[0]?.name).toEqual('Testy McTestface'); expect(pub.customPublishedAt).toMatch(/^1337-01-01/); - if (isAWSAccessKeySet) { + if (process.env.INTEGRATION) { const response = await fetch(url, { method: 'HEAD' }); expect(response.ok).toEqual(true);