From 926f91f2f098385ade1d14848e08320eb3207034 Mon Sep 17 00:00:00 2001 From: akinerin Date: Tue, 30 Jun 2026 03:18:17 +0000 Subject: [PATCH 1/3] feat: per-org retention purge job for soft-deleted vaults --- docs/multi-tenancy.md | 43 ++ src/jobs/handlers.ts | 21 + src/jobs/system.ts | 42 ++ src/jobs/types.ts | 14 +- src/lib/validation.ts | 11 + src/routes/jobs.ts | 8 +- src/services/retention.ts | 99 +++ src/tests/jobs.retentionPurge.handler.test.ts | 47 ++ src/tests/vault.retentionPurge.test.ts | 651 ++++++++++++++++++ 9 files changed, 933 insertions(+), 3 deletions(-) create mode 100644 src/services/retention.ts create mode 100644 src/tests/jobs.retentionPurge.handler.test.ts create mode 100644 src/tests/vault.retentionPurge.test.ts diff --git a/docs/multi-tenancy.md b/docs/multi-tenancy.md index 9cabffb5..d7bffda1 100644 --- a/docs/multi-tenancy.md +++ b/docs/multi-tenancy.md @@ -22,6 +22,49 @@ org-scoped endpoint must uphold. `req.params`. Client-supplied `orgId` values in query strings or request bodies are never trusted for data access decisions. +### Org-scoped retention and hard purge + +Retention purge jobs are also scoped to the organization whose ID appears in the job +payload. A per-org retention pass only hard-deletes soft-deleted vaults and related +milestones for that organization. + +Retention configuration supports the following environment variables: + +- `RETENTION_PURGE_AGE_MS` — minimum age before a soft-deleted vault is eligible for hard purge. + If unset, the per-org retention window is consulted (see below). +- `RETENTION_PURGE_INTERVAL_MS` — frequency at which the retention scheduler enqueues per-org jobs. +- `RETENTION_PURGE_BATCH_SIZE` — maximum number of vaults deleted per org on each retention run. + +Every retention purge also writes an audit log entry scoped to the target organization with a +summary of deleted vault and milestone counts. + +### Per-org retention window + +Each organization can override the global retention window by setting +`retention_purge_age_ms` in its `metadata` JSONB column: + +```sql +UPDATE organizations +SET metadata = jsonb_set( + COALESCE(metadata, '{}'::jsonb), + '{retention_purge_age_ms}', + '604800000' -- 7 days in milliseconds +) +WHERE id = ''; +``` + +Resolution order (first match wins): + +1. Explicit `retentionAgeMs` argument passed to `purgeSoftDeletedVaults` (testing). +2. `RETENTION_PURGE_AGE_MS` environment variable (global override). +3. `organizations.metadata.retention_purge_age_ms` (per-org override). +4. Built-in default of 30 days (`2_592_000_000` ms). + +If the per-org value is missing, unset, or not a valid non-negative number, the system +falls through to the next level. This allows a cluster-wide default via `RETENTION_PURGE_AGE_MS` +while letting individual organizations opt into a shorter (or longer) window through their +metadata. + 5. **No cross-org leakage** — pagination, sorting, and filtering are applied *after* the org-scope filter. A filter that matches zero records in the target org returns an empty result set, not records from another org. diff --git a/src/jobs/handlers.ts b/src/jobs/handlers.ts index 020fb5e1..02b0665c 100644 --- a/src/jobs/handlers.ts +++ b/src/jobs/handlers.ts @@ -3,6 +3,8 @@ import { processJob as processExportJob } from '../services/exportQueue.js' import type { JobHandler, JobType } from './types.js' import { markVaultExpiries } from '../services/vaultExpiry.service.js' import { cleanupExpiredSessions } from '../services/session.js' +import { purgeSoftDeletedVaults } from '../services/retention.js' +import { createAuditLog } from '../lib/audit-logs.js' type JobHandlerRegistry = { [K in JobType]: JobHandler @@ -74,4 +76,23 @@ export const createDefaultJobHandlers = ( `deleted=${deleted} batchSize=${batchSize} attempt=${context.attempt}`, ) }, + 'retention.purge': async (payload, context) => { + const batchSize = payload.batchSize ?? 1000 + const result = await purgeSoftDeletedVaults(payload.organizationId, batchSize) + const message = `organization=${payload.organizationId} deletedVaults=${result.deletedVaults} deletedMilestones=${result.deletedMilestones} batchSize=${batchSize}` + logJob('retention.purge', `${message} attempt=${context.attempt}`) + + await createAuditLog({ + actor_user_id: 'system', + organization_id: payload.organizationId, + action: 'retention.purge', + target_type: 'organization', + target_id: payload.organizationId, + metadata: { + deleted_vaults: result.deletedVaults, + deleted_milestones: result.deletedMilestones, + batch_size: batchSize, + }, + }) + }, }) diff --git a/src/jobs/system.ts b/src/jobs/system.ts index 6fd782ff..a5a2b53b 100644 --- a/src/jobs/system.ts +++ b/src/jobs/system.ts @@ -2,6 +2,7 @@ import { createDefaultJobHandlers } from './handlers.js' import { InMemoryJobQueue, type QueueMetrics, type QueuedJobReceipt } from './queue.js' import { type EnqueueOptions, type JobPayloadByType, type JobType } from './types.js' import { recoverPendingExportJobs } from '../services/exportQueue.js' +import { listOrganizations } from '../services/organization.js' import { createNotificationService, type NotificationService, @@ -42,6 +43,7 @@ export class BackgroundJobSystem { this.queue.registerHandler('analytics.recompute', handlers['analytics.recompute']) this.queue.registerHandler('export.generate', handlers['export.generate']) this.queue.registerHandler('sessions.cleanup', handlers['sessions.cleanup']) + this.queue.registerHandler('retention.purge', handlers['retention.purge']) } start(): void { @@ -152,6 +154,46 @@ export class BackgroundJobSystem { this.enqueue('sessions.cleanup', {}) }, sessionsCleanupIntervalMs) + const retentionPurgeIntervalMs = parsePositiveInteger( + process.env.RETENTION_PURGE_INTERVAL_MS, + 86_400_000, + ) + const retentionPurgeBatchSize = parsePositiveInteger( + process.env.RETENTION_PURGE_BATCH_SIZE, + 500, + ) + + const enqueueRetentionPurgeJobs = async (): Promise => { + try { + const organizations = await listOrganizations() + for (const org of organizations) { + this.enqueue('retention.purge', { + organizationId: org.id, + batchSize: retentionPurgeBatchSize, + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[jobs:retention.purge] failed to enqueue jobs: ${message}`) + } + } + + if (process.env.ENABLE_RETENTION_PURGE_SCHEDULER !== 'false') { + const retentionTimer = setInterval(() => { + void enqueueRetentionPurgeJobs() + }, retentionPurgeIntervalMs) + + if (typeof retentionTimer.unref === 'function') { + retentionTimer.unref() + } + + this.scheduleTimers.push(retentionTimer) + void enqueueRetentionPurgeJobs().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + console.error(`[jobs:retention.purge] initial enqueue failed: ${message}`) + }) + } + if (typeof deadlineTimer.unref === 'function') { deadlineTimer.unref() } diff --git a/src/jobs/types.ts b/src/jobs/types.ts index f6564bce..23db7f6d 100644 --- a/src/jobs/types.ts +++ b/src/jobs/types.ts @@ -5,6 +5,7 @@ export const JOB_TYPES = [ 'analytics.recompute', 'export.generate', 'sessions.cleanup', + 'retention.purge', ] as const export type JobType = (typeof JOB_TYPES)[number] @@ -41,6 +42,11 @@ export interface SessionsCleanupJobPayload { batchSize?: number } +export interface RetentionPurgeJobPayload { + organizationId: string + batchSize?: number +} + export interface JobPayloadByType { 'notification.send': NotificationJobPayload 'deadline.check': DeadlineCheckJobPayload @@ -48,6 +54,7 @@ export interface JobPayloadByType { 'analytics.recompute': AnalyticsRecomputeJobPayload 'export.generate': ExportGenerateJobPayload 'sessions.cleanup': SessionsCleanupJobPayload + 'retention.purge': RetentionPurgeJobPayload } export interface JobContext { @@ -65,7 +72,7 @@ export interface EnqueueOptions { maxAttempts?: number } -const isRecord = (value: unknown): value is Record => { +export const isRecord = (value: unknown): value is Record => { return typeof value === 'object' && value !== null } @@ -122,6 +129,11 @@ export const isPayloadForJobType = ( return isNonEmptyString(payload.exportJobId) case 'sessions.cleanup': return payload.batchSize === undefined || (typeof payload.batchSize === 'number' && payload.batchSize > 0) + case 'retention.purge': + return ( + isNonEmptyString(payload.organizationId) && + (payload.batchSize === undefined || (typeof payload.batchSize === 'number' && payload.batchSize > 0)) + ) default: return false } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 185f88f0..55080e67 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -98,6 +98,11 @@ export const analyticsRecomputePayloadSchema = z.object({ reason: z.string().optional(), }) +export const retentionPurgePayloadSchema = z.object({ + organizationId: nonEmptyString, + batchSize: z.number().int().min(1).optional(), +}) + export const enqueueJobSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('notification.send'), @@ -123,4 +128,10 @@ export const enqueueJobSchema = z.discriminatedUnion('type', [ maxAttempts: z.number().int().min(1).max(10).optional(), delayMs: z.number().int().min(0).max(60000).optional(), }), + z.object({ + type: z.literal('retention.purge'), + payload: retentionPurgePayloadSchema, + maxAttempts: z.number().int().min(1).max(10).optional(), + delayMs: z.number().int().min(0).max(60000).optional(), + }), ]) diff --git a/src/routes/jobs.ts b/src/routes/jobs.ts index d9caaf62..dd32f8e6 100644 --- a/src/routes/jobs.ts +++ b/src/routes/jobs.ts @@ -1,11 +1,13 @@ import { Router, type RequestHandler } from 'express' -import { z } from 'zod' import { UserRole } from '../types/user.js' import type { BackgroundJobSystem } from '../jobs/system.js' import { type EnqueueOptions, type JobPayloadByType, type JobType, + isJobType, + isPayloadForJobType, + isRecord, } from '../jobs/types.js' import { parseEnqueueOptions } from '../jobs/enqueueOptions.js' import { authenticate, authorize } from '../middleware/auth.js' @@ -34,6 +36,8 @@ const enqueueTypedJob = ( return jobSystem.enqueue(type, payload, options) case 'analytics.recompute': return jobSystem.enqueue(type, payload, options) + case 'retention.purge': + return jobSystem.enqueue(type, payload, options) default: throw new Error('Unsupported job type') } @@ -161,7 +165,7 @@ export const createJobsRouter = (jobSystem: BackgroundJobSystem, options: JobsRo if (!isJobType(type)) { res.status(400).json({ error: - 'Invalid or missing job type. Supported types: notification.send, deadline.check, oracle.call, analytics.recompute', + 'Invalid or missing job type. Supported types: notification.send, deadline.check, oracle.call, analytics.recompute, retention.purge', }) return } diff --git a/src/services/retention.ts b/src/services/retention.ts new file mode 100644 index 00000000..d7434b6c --- /dev/null +++ b/src/services/retention.ts @@ -0,0 +1,99 @@ +import db from '../db/index.js' +import type { Knex } from 'knex' + +export type PurgeSoftDeletedVaultsResult = { + deletedVaults: number + deletedMilestones: number +} + +const DEFAULT_RETENTION_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 days + +const validateOrganizationId = (organizationId: string): void => { + if (typeof organizationId !== 'string' || organizationId.trim() === '') { + throw new Error('organizationId is required') + } +} + +const getGlobalRetentionAgeMs = (): number | undefined => { + const rawValue = process.env.RETENTION_PURGE_AGE_MS + if (rawValue === undefined || rawValue.trim() === '') { + return undefined + } + + const parsed = Number(rawValue) + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error('RETENTION_PURGE_AGE_MS must be a non-negative integer') + } + + return Math.floor(parsed) +} + +const resolvePerOrgRetentionAgeMs = async ( + organizationId: string, + knexInstance: typeof db | Knex, +): Promise => { + const org = await knexInstance('organizations') + .select('metadata') + .where('id', organizationId) + .first() + + const orgRetention = org?.metadata?.retention_purge_age_ms + if (typeof orgRetention === 'number' && Number.isFinite(orgRetention) && orgRetention >= 0) { + return Math.floor(orgRetention) + } + + return DEFAULT_RETENTION_AGE_MS +} + +export const purgeSoftDeletedVaults = async ( + organizationId: string, + batchSize = 500, + knexInstance: typeof db | Knex = db, + retentionAgeMs?: number, +): Promise => { + validateOrganizationId(organizationId) + + if (!Number.isFinite(batchSize) || batchSize <= 0) { + throw new Error('batchSize must be a positive integer') + } + + const effectiveRetentionAgeMs = + retentionAgeMs !== undefined + ? retentionAgeMs + : getGlobalRetentionAgeMs() ?? (await resolvePerOrgRetentionAgeMs(organizationId, knexInstance)) + + return await knexInstance.transaction(async (trx) => { + const cutoffDate = + effectiveRetentionAgeMs > 0 + ? new Date(Date.now() - effectiveRetentionAgeMs) + : undefined + + let query = trx('vaults') + .where({ organization_id: organizationId }) + .whereNotNull('deleted_at') + + if (cutoffDate) { + query = query.where('deleted_at', '<=', cutoffDate) + } + + const vaultIds = await query.limit(batchSize).pluck('id') + + if (vaultIds.length === 0) { + return { deletedVaults: 0, deletedMilestones: 0 } + } + + const milestonesCountRow = await trx('milestones') + .whereIn('vault_id', vaultIds) + .count<{ total: string }>('id as total') + .first() + + const deletedMilestones = Number(milestonesCountRow?.total ?? 0) + + const deletedVaults = await trx('vaults').whereIn('id', vaultIds).delete() + + return { + deletedVaults, + deletedMilestones, + } + }) +} diff --git a/src/tests/jobs.retentionPurge.handler.test.ts b/src/tests/jobs.retentionPurge.handler.test.ts new file mode 100644 index 00000000..ede0e4ae --- /dev/null +++ b/src/tests/jobs.retentionPurge.handler.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals' + +const mockPurgeSoftDeletedVaults = jest.fn() +const mockCreateAuditLog = jest.fn() + +jest.unstable_mockModule('../services/retention.js', () => ({ + purgeSoftDeletedVaults: mockPurgeSoftDeletedVaults, +})) + +jest.unstable_mockModule('../lib/audit-logs.js', () => ({ + createAuditLog: mockCreateAuditLog, +})) + +const { createDefaultJobHandlers } = await import('../jobs/handlers.js') + +describe('retention.purge job handler', () => { + beforeEach(() => { + jest.clearAllMocks() + mockPurgeSoftDeletedVaults.mockResolvedValue({ deletedVaults: 2, deletedMilestones: 3 }) + mockCreateAuditLog.mockResolvedValue({ id: 'audit-retention-1' }) + }) + + it('invokes retention purge and writes an audit log summary', async () => { + const handlers = createDefaultJobHandlers({} as any) + + await handlers['retention.purge']( + { organizationId: 'org-1', batchSize: 42 }, + { jobId: 'job-123', attempt: 1 }, + ) + + expect(mockPurgeSoftDeletedVaults).toHaveBeenCalledWith('org-1', 42) + expect(mockCreateAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ + actor_user_id: 'system', + organization_id: 'org-1', + action: 'retention.purge', + target_type: 'organization', + target_id: 'org-1', + metadata: { + deleted_vaults: 2, + deleted_milestones: 3, + batch_size: 42, + }, + }), + ) + }) +}) diff --git a/src/tests/vault.retentionPurge.test.ts b/src/tests/vault.retentionPurge.test.ts new file mode 100644 index 00000000..7f028cca --- /dev/null +++ b/src/tests/vault.retentionPurge.test.ts @@ -0,0 +1,651 @@ +import knex, { type Knex } from 'knex' +import { beforeAll, beforeEach, afterAll, describe, expect, it } from '@jest/globals' +import { purgeSoftDeletedVaults } from '../services/retention.js' + +let temporaryTestDbName: string | null = null + +const createRetentionTestDatabase = async (): Promise => { + const baseDatabaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5433/postgres' + const adminUrl = new URL(baseDatabaseUrl) + adminUrl.pathname = '/postgres' + + temporaryTestDbName = `disciplr_test_retention_${Date.now()}_${Math.floor(Math.random() * 10000)}` + const adminDb = knex({ client: 'pg', connection: adminUrl.toString(), pool: { min: 1, max: 1 } }) + try { + await adminDb.raw(`CREATE DATABASE "${temporaryTestDbName}"`) + } finally { + await adminDb.destroy() + } + + const testUrl = new URL(baseDatabaseUrl) + testUrl.pathname = `/${temporaryTestDbName}` + const db = knex({ client: 'pg', connection: testUrl.toString(), pool: { min: 1, max: 2 } }) + + await db.raw('select 1') + + await db.schema.createTable('organizations', (table) => { + table.string('id', 255).primary() + table.string('name', 255).notNullable() + table.string('slug', 255).notNullable() + table.jsonb('metadata').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()) + }) + + await db.schema.createTable('vaults', (table) => { + table.string('id', 255).primary() + table.string('creator', 255).notNullable() + table.decimal('amount', 32, 7).notNullable() + table.timestamp('start_timestamp', { useTz: true }).notNullable() + table.timestamp('end_timestamp', { useTz: true }).notNullable() + table.string('success_destination', 255).notNullable() + table.string('failure_destination', 255).notNullable() + table.string('status', 64).notNullable() + table.timestamp('deleted_at', { useTz: true }).nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()) + table.string('organization_id', 255).nullable() + table + .foreign('organization_id') + .references('id') + .inTable('organizations') + .onDelete('SET NULL') + }) + + await db.schema.createTable('milestones', (table) => { + table.string('id', 255).primary() + table.string('vault_id', 255).notNullable() + table.string('title', 255).notNullable() + table.string('description', 1024).notNullable() + table.decimal('target_amount', 32, 7).notNullable() + table.decimal('current_amount', 32, 7).notNullable().defaultTo('0') + table.timestamp('deadline', { useTz: true }).notNullable() + table.string('status', 64).notNullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()) + table + .foreign('vault_id') + .references('id') + .inTable('vaults') + .onDelete('CASCADE') + }) + + return db +} + +describe('Retention Service - purgeSoftDeletedVaults', () => { + let db: Knex + + beforeAll(async () => { + db = await createRetentionTestDatabase() + }) + + afterAll(async () => { + await db.destroy() + + if (temporaryTestDbName) { + const adminUrl = new URL(process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5433/postgres') + adminUrl.pathname = '/postgres' + const adminDb = knex({ client: 'pg', connection: adminUrl.toString(), pool: { min: 1, max: 1 } }) + try { + await adminDb.raw(`DROP DATABASE IF EXISTS "${temporaryTestDbName}"`) + } finally { + await adminDb.destroy() + } + } + }) + + beforeEach(async () => { + delete process.env.RETENTION_PURGE_AGE_MS + await db('milestones').delete() + await db('vaults').delete() + await db('organizations').delete() + }) + + it('deletes only soft-deleted vaults for the specified organization and counts cascade milestones', async () => { + const [organization] = await db('organizations') + .insert({ id: 'org-one', name: 'Org One', slug: 'org-one' }) + .returning('*') + + await db('vaults').insert([ + { + id: 'vault-org1-1', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + status: 'cancelled', + deleted_at: new Date('2025-01-01T00:00:00Z'), + created_at: new Date(), + organization_id: organization.id, + }, + { + id: 'vault-org1-2', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYYYYYYYY', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYYYYYYYY', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYYYYYYYY', + status: 'active', + deleted_at: null, + created_at: new Date(), + organization_id: organization.id, + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-org1-1', + vault_id: 'vault-org1-1', + title: 'Milestone One', + description: 'Soft deleted vault milestone', + target_amount: '500.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-org1-2', + vault_id: 'vault-org1-1', + title: 'Milestone Two', + description: 'Soft deleted vault milestone', + target_amount: '500.0000000', + current_amount: '0', + deadline: new Date('2024-06-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-org1-3', + vault_id: 'vault-org1-2', + title: 'Active Vault Milestone', + description: 'Active vault milestone should remain', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-07-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults(organization.id, 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 2 }) + + const remainingVaults = await db('vaults').select('id', 'deleted_at', 'organization_id') + expect(remainingVaults).toHaveLength(1) + expect(remainingVaults[0].id).toBe('vault-org1-2') + + const remainingMilestones = await db('milestones').select('id', 'vault_id') + expect(remainingMilestones).toHaveLength(1) + expect(remainingMilestones[0].vault_id).toBe('vault-org1-2') + }) + + it('honors batchSize and deletes only one soft-deleted vault per batch', async () => { + const [organization] = await db('organizations') + .insert({ id: 'org-batch', name: 'Org Batch', slug: 'org-batch' }) + .returning('*') + + await db('vaults').insert([ + { + id: 'vault-batch-1', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAA', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAA', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAA', + status: 'cancelled', + deleted_at: new Date('2025-01-01T00:00:00Z'), + created_at: new Date(), + organization_id: organization.id, + }, + { + id: 'vault-batch-2', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBB', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBB', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBB', + status: 'failed', + deleted_at: new Date('2025-01-02T00:00:00Z'), + created_at: new Date(), + organization_id: organization.id, + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-batch-1', + vault_id: 'vault-batch-1', + title: 'Batch Milestone One', + description: 'First soft deleted vault milestone', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-03-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-batch-2', + vault_id: 'vault-batch-2', + title: 'Batch Milestone Two', + description: 'Second soft deleted vault milestone', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-04-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults(organization.id, 1, db) + + expect(result.deletedVaults).toBe(1) + expect(result.deletedMilestones).toBe(1) + + const remainingDeletedVaults = await db('vaults') + .where({ organization_id: organization.id }) + .whereNotNull('deleted_at') + .select('id') + + expect(remainingDeletedVaults).toHaveLength(1) + }) + + it('does not purge soft-deleted vaults from other organizations', async () => { + await db('organizations').insert([ + { id: 'org-one', name: 'Org One', slug: 'org-one' }, + { id: 'org-two', name: 'Org Two', slug: 'org-two' }, + ]) + + await db('vaults').insert([ + { + id: 'vault-org-one', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAAA', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAAA', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXAAAAAAAA', + status: 'cancelled', + deleted_at: new Date('2025-01-01T00:00:00Z'), + created_at: new Date(), + organization_id: 'org-one', + }, + { + id: 'vault-org-two', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBBB', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBBB', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXBBBBBBBB', + status: 'cancelled', + deleted_at: new Date('2025-01-01T00:00:00Z'), + created_at: new Date(), + organization_id: 'org-two', + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-org-one', + vault_id: 'vault-org-one', + title: 'Org One Milestone', + description: 'Belongs to org one', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-org-two', + vault_id: 'vault-org-two', + title: 'Org Two Milestone', + description: 'Belongs to org two', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-06-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults('org-one', 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 1 }) + + const remainingOrgTwoVaults = await db('vaults') + .where({ organization_id: 'org-two' }) + .select('id') + + expect(remainingOrgTwoVaults).toHaveLength(1) + expect(remainingOrgTwoVaults[0].id).toBe('vault-org-two') + }) + + it('respects RETENTION_PURGE_AGE_MS and excludes recently deleted vaults', async () => { + process.env.RETENTION_PURGE_AGE_MS = String(24 * 60 * 60 * 1000) + + await db('organizations').insert({ id: 'org-window', name: 'Org Window', slug: 'org-window' }) + + await db('vaults').insert([ + { + id: 'vault-window-old', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXCCCCCCCC', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXCCCCCCCC', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXCCCCCCCC', + status: 'cancelled', + deleted_at: new Date(Date.now() - 36 * 60 * 60 * 1000), + created_at: new Date(), + organization_id: 'org-window', + }, + { + id: 'vault-window-recent', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXDDDDDDDD', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXDDDDDDDD', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXDDDDDDDD', + status: 'cancelled', + deleted_at: new Date(Date.now() - 2 * 60 * 60 * 1000), + created_at: new Date(), + organization_id: 'org-window', + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-window-old', + vault_id: 'vault-window-old', + title: 'Old Deleted Vault Milestone', + description: 'Should be removed', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-window-recent', + vault_id: 'vault-window-recent', + title: 'Recent Deleted Vault Milestone', + description: 'Should remain', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-06-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults('org-window', 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 1 }) + + const remainingVaults = await db('vaults') + .where({ organization_id: 'org-window' }) + .whereNotNull('deleted_at') + .select('id') + + expect(remainingVaults).toHaveLength(1) + expect(remainingVaults[0].id).toBe('vault-window-recent') + }) + + it('uses per-org retention_purge_age_ms from metadata when env var is unset', async () => { + delete process.env.RETENTION_PURGE_AGE_MS + + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000) + const sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000) + + await db('organizations').insert({ + id: 'org-per-org-window', + name: 'Org Per-Org', + slug: 'org-per-org', + metadata: { retention_purge_age_ms: 24 * 60 * 60 * 1000 }, + }) + + await db('vaults').insert([ + { + id: 'vault-per-org-old', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXEEEEEEEE', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXEEEEEEEE', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXEEEEEEEE', + status: 'cancelled', + deleted_at: twoDaysAgo, + created_at: new Date(), + organization_id: 'org-per-org-window', + }, + { + id: 'vault-per-org-recent', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXFFFFFFFF', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXFFFFFFFF', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXFFFFFFFF', + status: 'cancelled', + deleted_at: sixHoursAgo, + created_at: new Date(), + organization_id: 'org-per-org-window', + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-per-org-old', + vault_id: 'vault-per-org-old', + title: 'Old Milestone', + description: 'Should be removed', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-per-org-recent', + vault_id: 'vault-per-org-recent', + title: 'Recent Milestone', + description: 'Should remain', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-06-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults('org-per-org-window', 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 1 }) + + const remaining = await db('vaults') + .where({ organization_id: 'org-per-org-window' }) + .whereNotNull('deleted_at') + .select('id') + + expect(remaining).toHaveLength(1) + expect(remaining[0].id).toBe('vault-per-org-recent') + }) + + it('env var RETENTION_PURGE_AGE_MS takes precedence over per-org metadata', async () => { + process.env.RETENTION_PURGE_AGE_MS = String(2 * 60 * 60 * 1000) // 2 hours + + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000) + const oneHourAgo = new Date(Date.now() - 1 * 60 * 60 * 1000) + + await db('organizations').insert({ + id: 'org-precedence', + name: 'Org Precedence', + slug: 'org-precedence', + metadata: { retention_purge_age_ms: 7 * 24 * 60 * 60 * 1000 }, + }) + + await db('vaults').insert([ + { + id: 'vault-precedence-old', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXGGGGGGGG', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXGGGGGGGG', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXGGGGGGGG', + status: 'cancelled', + deleted_at: twoDaysAgo, + created_at: new Date(), + organization_id: 'org-precedence', + }, + { + id: 'vault-precedence-recent', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXHHHHHHHH', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXHHHHHHHH', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXHHHHHHHH', + status: 'cancelled', + deleted_at: oneHourAgo, + created_at: new Date(), + organization_id: 'org-precedence', + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-precedence-old', + vault_id: 'vault-precedence-old', + title: 'Old Milestone', + description: 'Should be removed', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 'milestone-precedence-recent', + vault_id: 'vault-precedence-recent', + title: 'Recent Milestone', + description: 'Should remain', + target_amount: '2000.0000000', + current_amount: '0', + deadline: new Date('2024-06-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults('org-precedence', 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 1 }) + + const remaining = await db('vaults') + .where({ organization_id: 'org-precedence' }) + .whereNotNull('deleted_at') + .select('id') + + expect(remaining).toHaveLength(1) + expect(remaining[0].id).toBe('vault-precedence-recent') + }) + + it('applies default 30-day retention window when no config is provided', async () => { + delete process.env.RETENTION_PURGE_AGE_MS + + await db('organizations').insert({ + id: 'org-default-window', + name: 'Org Default', + slug: 'org-default', + metadata: null, + }) + + await db('vaults').insert([ + { + id: 'vault-default-old', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXIIIIIIII', + amount: '1000.0000000', + start_timestamp: new Date('2024-01-01T00:00:00Z'), + end_timestamp: new Date('2024-06-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXIIIIIIII', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXIIIIIIII', + status: 'cancelled', + deleted_at: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + created_at: new Date(), + organization_id: 'org-default-window', + }, + { + id: 'vault-default-recent', + creator: 'GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXJJJJJJJJ', + amount: '2000.0000000', + start_timestamp: new Date('2024-02-01T00:00:00Z'), + end_timestamp: new Date('2024-07-01T00:00:00Z'), + success_destination: 'GSUCCESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXJJJJJJJJ', + failure_destination: 'GFAILUREXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXJJJJJJJJ', + status: 'cancelled', + deleted_at: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + created_at: new Date(), + organization_id: 'org-default-window', + }, + ]) + + await db('milestones').insert([ + { + id: 'milestone-default-old', + vault_id: 'vault-default-old', + title: 'Old Milestone', + description: 'Should be removed', + target_amount: '1000.0000000', + current_amount: '0', + deadline: new Date('2024-05-01T00:00:00Z'), + status: 'pending', + created_at: new Date(), + updated_at: new Date(), + }, + ]) + + const result = await purgeSoftDeletedVaults('org-default-window', 10, db) + + expect(result).toEqual({ deletedVaults: 1, deletedMilestones: 1 }) + + const remaining = await db('vaults') + .where({ organization_id: 'org-default-window' }) + .whereNotNull('deleted_at') + .select('id') + + expect(remaining).toHaveLength(1) + expect(remaining[0].id).toBe('vault-default-recent') + }) + + it('throws when RETENTION_PURGE_AGE_MS is not a valid non-negative integer', async () => { + process.env.RETENTION_PURGE_AGE_MS = 'invalid' + + await db('organizations').insert({ id: 'org-invalid', name: 'Org Invalid', slug: 'org-invalid' }) + + await expect(purgeSoftDeletedVaults('org-invalid', 10, db)).rejects.toThrow( + 'RETENTION_PURGE_AGE_MS must be a non-negative integer', + ) + }) +}) From 34373f2c2760a9fe6322d4b660ed7fddd899baca Mon Sep 17 00:00:00 2001 From: akinerin Date: Tue, 30 Jun 2026 03:25:20 +0000 Subject: [PATCH 2/3] chore: update package-lock and add test snapshot --- package-lock.json | 79 +++++++++++++++++++ .../privacy-logger.redaction.test.ts.snap | 25 ++++++ 2 files changed, 104 insertions(+) create mode 100644 src/tests/__snapshots__/privacy-logger.redaction.test.ts.snap diff --git a/package-lock.json b/package-lock.json index d442b979..d224c052 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,14 @@ "bcryptjs": "^3.0.3", "cors": "^2.8.6", "csv-stringify": "^6.6.0", + "dataloader": "^2.2.2", "date-fns": "^4.1.0", "debug": "^4.4.3", "express": "^4.21.0", "express-rate-limit": "^8.2.1", + "graphql": "^16.8.1", + "graphql-depth-limit": "^1.1.0", + "graphql-http": "^1.22.0", "helmet": "^7.2.0", "jsonwebtoken": "^9.0.3", "pg": "^8.20.0", @@ -36,6 +40,7 @@ "@types/cors": "^2.8.19", "@types/debug": "^4.1.13", "@types/express": "^4.17.21", + "@types/graphql-depth-limit": "^1.1.4", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.9.0", @@ -5350,6 +5355,28 @@ "@types/node": "*" } }, + "node_modules/@types/graphql-depth-limit": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@types/graphql-depth-limit/-/graphql-depth-limit-1.1.6.tgz", + "integrity": "sha512-WU4bjoKOzJ8CQE32Pbyq+YshTMcLJf2aJuvVtSLv1BQPwDUGa38m2Vr8GGxf0GZ0luCQcfxlhZeHKu6nmTBvrw==", + "dev": true, + "dependencies": { + "graphql": "^14.5.3" + } + }, + "node_modules/@types/graphql-depth-limit/node_modules/graphql": { + "version": "14.7.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-14.7.0.tgz", + "integrity": "sha512-l0xWZpoPKpppFzMfvVyFmp9vLN7w/ZZJPefUicMCepfJeQ8sMcztloGYY9DfjVPo6tIUDzU5Hw3MUbIjj9AVVA==", + "deprecated": "No longer supported; please update to a newer version. Details: https://github.com/graphql/graphql-js#version-support", + "dev": true, + "dependencies": { + "iterall": "^1.2.2" + }, + "engines": { + "node": ">= 6.x" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -6098,6 +6125,14 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -7082,6 +7117,11 @@ "integrity": "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==", "license": "MIT" }, + "node_modules/dataloader": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==" + }, "node_modules/date-fns": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", @@ -8587,6 +8627,39 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-depth-limit": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/graphql-depth-limit/-/graphql-depth-limit-1.1.0.tgz", + "integrity": "sha512-+3B2BaG8qQ8E18kzk9yiSdAa75i/hnnOwgSeAxVJctGQPvmeiLtqKOYF6HETCyRjiF7Xfsyal0HbLlxCQkgkrw==", + "dependencies": { + "arrify": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + }, + "peerDependencies": { + "graphql": "*" + } + }, + "node_modules/graphql-http": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/graphql-http/-/graphql-http-1.22.4.tgz", + "integrity": "sha512-OC3ucK988teMf+Ak/O+ZJ0N2ukcgrEurypp8ePyJFWq83VzwRAmHxxr+XxrMpxO/FIwI4a7m/Fzv3tWGJv0wPA==", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -9103,6 +9176,12 @@ "node": ">=8" } }, + "node_modules/iterall": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", + "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", + "dev": true + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", diff --git a/src/tests/__snapshots__/privacy-logger.redaction.test.ts.snap b/src/tests/__snapshots__/privacy-logger.redaction.test.ts.snap new file mode 100644 index 00000000..d4d28c11 --- /dev/null +++ b/src/tests/__snapshots__/privacy-logger.redaction.test.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`privacyLogger middleware snapshot: structured log line for a request with sensitive fields 1`] = ` +{ + "body": { + "email": "[REDACTED]", + "password": "[REDACTED]", + }, + "durationMs": 0, + "event": "http.request", + "headers": { + "authorization": "[REDACTED]", + "content-type": "application/json", + "x-api-key": "[REDACTED]", + }, + "ip": "10.20.x.x", + "level": "info", + "method": "POST", + "query": null, + "service": "disciplr-backend", + "status": 200, + "timestamp": "2024-01-01T00:00:00.000Z", + "url": "/api/auth/login", +} +`; From 9283dc25eedef41c5ec434caae9a486998f4da5d Mon Sep 17 00:00:00 2001 From: akinerin Date: Wed, 1 Jul 2026 15:46:31 +0000 Subject: [PATCH 3/3] chore: update package-lock --- package-lock.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d224c052..ca5c3e11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5360,6 +5360,7 @@ "resolved": "https://registry.npmjs.org/@types/graphql-depth-limit/-/graphql-depth-limit-1.1.6.tgz", "integrity": "sha512-WU4bjoKOzJ8CQE32Pbyq+YshTMcLJf2aJuvVtSLv1BQPwDUGa38m2Vr8GGxf0GZ0luCQcfxlhZeHKu6nmTBvrw==", "dev": true, + "license": "MIT", "dependencies": { "graphql": "^14.5.3" } @@ -5370,6 +5371,7 @@ "integrity": "sha512-l0xWZpoPKpppFzMfvVyFmp9vLN7w/ZZJPefUicMCepfJeQ8sMcztloGYY9DfjVPo6tIUDzU5Hw3MUbIjj9AVVA==", "deprecated": "No longer supported; please update to a newer version. Details: https://github.com/graphql/graphql-js#version-support", "dev": true, + "license": "MIT", "dependencies": { "iterall": "^1.2.2" }, @@ -6129,6 +6131,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -7120,7 +7123,8 @@ "node_modules/dataloader": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", - "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==" + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", + "license": "MIT" }, "node_modules/date-fns": { "version": "4.4.0", @@ -8631,6 +8635,7 @@ "version": "16.14.2", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -8639,6 +8644,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/graphql-depth-limit/-/graphql-depth-limit-1.1.0.tgz", "integrity": "sha512-+3B2BaG8qQ8E18kzk9yiSdAa75i/hnnOwgSeAxVJctGQPvmeiLtqKOYF6HETCyRjiF7Xfsyal0HbLlxCQkgkrw==", + "license": "MIT", "dependencies": { "arrify": "^1.0.1" }, @@ -8653,6 +8659,7 @@ "version": "1.22.4", "resolved": "https://registry.npmjs.org/graphql-http/-/graphql-http-1.22.4.tgz", "integrity": "sha512-OC3ucK988teMf+Ak/O+ZJ0N2ukcgrEurypp8ePyJFWq83VzwRAmHxxr+XxrMpxO/FIwI4a7m/Fzv3tWGJv0wPA==", + "license": "MIT", "engines": { "node": ">=12" }, @@ -9180,7 +9187,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jackspeak": { "version": "3.4.3",