Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,10 @@ INDEXER_MAX_POLL_INTERVAL_MS=300000
INDEXER_BACKOFF_FACTOR=2
# INDEXER_LAG_ALERT_THRESHOLD: ledgers behind the tip before lag is alerted. Default: 100.
INDEXER_LAG_ALERT_THRESHOLD=100

# Time handling (issue #494)
# All instants are created, stored and compared in UTC, and serialized in API
# responses as ISO-8601 strings with a Z suffix. TZ pins the process default;
# the application sets it to UTC itself when this is unset, and remains
# correct even if it is overridden (see README.md#time-handling).
TZ=UTC
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# --- Base & Development Stage ---
FROM node:20-alpine AS development
# Issue #494 — all instants are UTC; pin the container default explicitly so
# log rotation filenames and any date the runtime formats are UTC too.
ENV TZ=UTC
WORKDIR /app

# Instalar dependencias completas (incluye devDependencies para nodemon/hot-reload)
Expand Down Expand Up @@ -27,6 +30,8 @@ COPY public ./public
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
# Issue #494 — UTC process default (see development stage above).
ENV TZ=UTC

COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/src ./src
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,24 @@ Each price source (`stellar_dex`, `coingecko`, `coinmarketcap`) gets one circuit
| `API_KEY_RATELIMIT_ADMIN_MAX` | Requests per window for `admin`-tier keys | 10000 | No |


---

## Time handling

All instants are created, computed, compared and stored in **UTC**, and serialized in API responses as ISO-8601 strings with a `Z` suffix (e.g. `2026-06-30T19:00:00.000Z`). Local time is a presentation concern: clients convert a UTC value to their own zone. The server does not accept or emit local times.

- Shared helpers live in `src/utils/dates.js` (`nowIso`, `toIso`, `startOfUtcDay`, `utcDayKey`).
- Day-boundary logic (daily limits, expiries, "today" queries) must use `utcDayKey()`/`startOfUtcDay()` — never a slice of a local-time string.
- PostgreSQL sessions are pinned to UTC (`SET TIME ZONE 'UTC'` on every pooled connection), and every timestamp column is `TIMESTAMPTZ`.
- Log file rotation (`winston-daily-rotate-file`) uses `utc: true`, so filenames and retention are bucketed by UTC date regardless of the host's time zone.
- `TZ` is set to `UTC` by default (in `Dockerfile`/`docker-compose`, and defensively at process start when unset). This is **not** the fix for time-zone bugs — it only provides a stable default, and the code stays correct if it is overridden. `TZ` is reported in the startup banner as `time_zone`.

Regression coverage: `test/timezone.test.js` runs the date helpers under `TZ=UTC`, `TZ=America/New_York` and `TZ=Asia/Tokyo` and asserts identical UTC results.

| Variable | Description | Default | Required |
| --- | --- | --- | --- |
| `TZ` | Process time zone. The app sets `UTC` when unset and warns if set to a non-UTC zone. | `UTC` | No |

---

## Observability
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ services:
- "4000:4000"
environment:
- PORT=4000
# Issue #494 — UTC process default; all instants are stored/serialized in UTC.
- TZ=UTC
- REDIS_URL=redis://redis:6379
- REDIS_HOST=redis
- REDIS_PORT=6379
Expand Down
11 changes: 10 additions & 1 deletion knexfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,32 @@ function connectionFor(fallback) {
return process.env.DATABASE_URL || fallback;
}

// Issue #494 — migrations and ad-hoc knex CLI queries must render timestamps
// in UTC too, not in the database server's local zone. Runs per pooled
// connection.
function afterCreateUtcConnection(conn, done) {
conn.query("SET TIME ZONE 'UTC'", (err) => done(err, conn));
}

module.exports = {
development: {
client: 'pg',
connection: connectionFor('postgres://localhost/smartdrop'),
pool: { min: 2, max: 10, afterCreate: afterCreateUtcConnection },
migrations,
},

test: {
client: 'pg',
connection: connectionFor('postgres://localhost/smartdrop_test'),
pool: { min: 2, max: 10, afterCreate: afterCreateUtcConnection },
migrations,
},

production: {
client: 'pg',
connection: connectionFor(undefined),
pool: { min: 2, max: 10 },
pool: { min: 2, max: 10, afterCreate: afterCreateUtcConnection },
migrations,
},
};
99 changes: 99 additions & 0 deletions src/auth/cookies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use strict';

// Issue #496 — Secure cookie helpers.
// No cookies are set anywhere in the codebase today. However, future
// authenticated UI/admin sessions must use these helpers so the flags are
// applied consistently:
// - Secure: only over HTTPS (production). In dev/test HTTP is allowed but
// still constrained by SameSite (and trust proxy affects req.secure).
// - HttpOnly: prevents JS access to auth/session cookies.
// - SameSite: 'Strict' by default to mitigate CSRF for same-site flows; use
// 'Lax' only when cross-site GET redirects are necessary.
// - Path: scoped to '/'.
// - Max-Age/Expires: set explicitly (no session cookies by default).
//
// The decision to *not* force Secure in non-prod is deliberate so local
// development over http://localhost works. Trust proxy must be configured
// (app.set('trust proxy', ...)) when running behind a reverse proxy/load
// balancer; without it, req.secure remains false even if the client connected
// via TLS to the proxy.

const config = require('../config');

const DEFAULT_SAME_SITE = 'Strict';
const DEFAULT_PATH = '/';

function isSecure(req) {
// req.secure is true only if the request was made over HTTPS. If the app is
// behind a proxy, trust proxy must be enabled for this to reflect the
// original protocol.
if (req && req.secure) return true;
if (req && req.headers && req.headers['x-forwarded-proto']) {
return String(req.headers['x-forwarded-proto']).toLowerCase() === 'https';
}
// Production default is to require Secure where possible; still, if no
// indicator is present, fall back to config flag (dev/test).
return config.nodeEnv === 'production';
}

function getCookieDomain(domain) {
if (domain) return domain;
return undefined;
}

function buildCookieOptions(options = {}) {
const {
req,
maxAgeMs,
expires,
sameSite = DEFAULT_SAME_SITE,
path = DEFAULT_PATH,
domain,
httpOnly = true,
secure,
} = options;

const secureFlag = typeof secure === 'boolean' ? secure : isSecure(req);
const opts = {
httpOnly,
secure: secureFlag,
sameSite,
path,
};

const resolvedDomain = getCookieDomain(domain);
if (resolvedDomain) opts.domain = resolvedDomain;

if (Number.isFinite(maxAgeMs)) {
opts.maxAge = Math.trunc(maxAgeMs);
}
if (expires instanceof Date) {
opts.expires = expires;
}
return opts;
}

function setCookie(res, name, value, options = {}) {
if (!res || typeof res.cookie !== 'function') {
throw new Error('res.cookie is not available');
}
const opts = buildCookieOptions(options);
res.cookie(name, value, opts);
}

function clearCookie(res, name, options = {}) {
if (!res || typeof res.clearCookie !== 'function') {
throw new Error('res.clearCookie is not available');
}
const opts = buildCookieOptions({ ...options, maxAgeMs: 0, expires: new Date(0) });
res.clearCookie(name, opts);
}

module.exports = {
buildCookieOptions,
setCookie,
clearCookie,
isSecure,
DEFAULT_SAME_SITE,
DEFAULT_PATH,
};
81 changes: 68 additions & 13 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ const env = cleanEnv(rawEnv, {
API_KEY_RATELIMIT_FREE_MAX: positiveInteger({ default: 100 }),
API_KEY_RATELIMIT_PRO_MAX: positiveInteger({ default: 1000 }),
API_KEY_RATELIMIT_ADMIN_MAX: positiveInteger({ default: 10000 }),
// Issue #495 — cache warming startup behavior
CACHE_WARM_ENABLED: str({ default: 'true' }),
CACHE_WARM_TIMEOUT_MS: num({ default: 30000 }),
CACHE_WARM_ENTRY_TIMEOUT_MS: num({ default: 10000 }),
CACHE_WARM_MAX_ENTRIES: num({ default: 500 }),
CACHE_WARM_CONCURRENCY: num({ default: 8 }),
CACHE_WARM_JITTER_MS: num({ default: 0 }),
CACHE_WARM_LOCK_TTL_MS: num({ default: 60000 }),
});

const usdcIssuer = env.USDC_ISSUER;
Expand Down Expand Up @@ -306,11 +314,32 @@ const config = module.exports = {
max: parseInt(process.env.WEBHOOK_TEST_RATELIMIT_MAX, 10) || 5,
},
orderedDelivery: process.env.WEBHOOK_ORDERED_DELIVERY === 'true',
shutdownDrainMs: parseInt(process.env.WEBHOOK_RETRY_SHUTDOWN_DRAIN_MS, 10) || 2000,
leaseMs: parseInt(process.env.WEBHOOK_RETRY_LEASE_MS, 10) || 300000,
recoverBatch: parseInt(process.env.WEBHOOK_RETRY_RECOVER_BATCH, 10) || 100,
dlqMax: parseInt(process.env.WEBHOOK_DLQ_MAX, 10) || 10000,
deliveryTtlSeconds: parseInt(process.env.WEBHOOK_DELIVERY_TTL_SECONDS, 10) || 2592000,
},
ws: {
maxConnections: parseInt(process.env.WS_MAX_CONNECTIONS, 10) || 100,
maxConnectionsPerIp: parseInt(process.env.WS_MAX_CONNECTIONS_PER_IP, 10) || 5,
},
cacheWarm: {
enabled: (process.env.CACHE_WARM_ENABLED || 'true').toLowerCase() !== 'false',
timeoutMs: parseInt(process.env.CACHE_WARM_TIMEOUT_MS, 10) || 30000,
entryTimeoutMs: parseInt(process.env.CACHE_WARM_ENTRY_TIMEOUT_MS, 10) || 10000,
maxEntries: parseInt(process.env.CACHE_WARM_MAX_ENTRIES, 10) || 500,
concurrency: parseInt(process.env.CACHE_WARM_CONCURRENCY, 10) || 8,
jitterMs: parseInt(process.env.CACHE_WARM_JITTER_MS, 10) || 0,
lockTtlMs: parseInt(process.env.CACHE_WARM_LOCK_TTL_MS, 10) || 60000,
},
trustProxy: (process.env.TRUST_PROXY || 'false').toLowerCase() === 'true',
cookies: {
secure: (process.env.COOKIE_SECURE || 'auto').toLowerCase(),
sameSite: (process.env.COOKIE_SAME_SITE || 'Strict'),
path: (process.env.COOKIE_PATH || '/'),
domain: process.env.COOKIE_DOMAIN || undefined,
},
};


Expand Down Expand Up @@ -367,11 +396,19 @@ function reload() {
SLOW_REQUEST_THRESHOLD_MS: num({ default: 1000 }),
ROUTE_TIMEOUT_MS: num({ default: 30000 }),
API_KEY_RATELIMIT_WINDOW_SECONDS: positiveInteger({ default: 60 }),
API_KEY_RATELIMIT_FREE_MAX: positiveInteger({ default: 100 }),
API_KEY_RATELIMIT_PRO_MAX: positiveInteger({ default: 1000 }),
API_KEY_RATELIMIT_ADMIN_MAX: positiveInteger({ default: 10000 }),
BCRYPT_ROUNDS: positiveInteger({ default: 12 }),
});
API_KEY_RATELIMIT_FREE_MAX: positiveInteger({ default: 100 }),
API_KEY_RATELIMIT_PRO_MAX: positiveInteger({ default: 1000 }),
API_KEY_RATELIMIT_ADMIN_MAX: positiveInteger({ default: 10000 }),
BCRYPT_ROUNDS: positiveInteger({ default: 12 }),
CACHE_WARM_ENABLED: str({ default: 'true' }),
CACHE_WARM_TIMEOUT_MS: num({ default: 30000 }),
CACHE_WARM_ENTRY_TIMEOUT_MS: num({ default: 10000 }),
CACHE_WARM_MAX_ENTRIES: num({ default: 500 }),
CACHE_WARM_CONCURRENCY: num({ default: 8 }),
CACHE_WARM_JITTER_MS: num({ default: 0 }),
CACHE_WARM_LOCK_TTL_MS: num({ default: 60000 }),
});


const reloadedUsdcIssuer = reloaded.USDC_ISSUER;
const reloadedWatchedAssets = Array.isArray(reloaded.WATCHED_ASSETS)
Expand Down Expand Up @@ -417,14 +454,32 @@ function reload() {
corsMaxAgeSeconds: reloaded.CORS_MAX_AGE_SECONDS,
watchedAssets: reloadedWatchedAssets,
webhooks: {
...config.webhooks,
maxAttempts: parseInt(process.env.WEBHOOK_MAX_ATTEMPTS, 10) || 3,
retryBaseMs: parseInt(process.env.WEBHOOK_RETRY_BASE_MS, 10) || 30000,
retryFactor: parseFloat(process.env.WEBHOOK_RETRY_FACTOR) || 2,
timeoutMs: parseInt(process.env.WEBHOOK_TIMEOUT_MS, 10) || 5000,
},
LOG_LEVEL: reloaded.LOG_LEVEL,
});
...config.webhooks,
maxAttempts: parseInt(process.env.WEBHOOK_MAX_ATTEMPTS, 10) || 3,
retryBaseMs: parseInt(process.env.WEBHOOK_RETRY_BASE_MS, 10) || 30000,
retryFactor: parseFloat(process.env.WEBHOOK_RETRY_FACTOR) || 2,
timeoutMs: parseInt(process.env.WEBHOOK_TIMEOUT_MS, 10) || 5000,
},
cacheWarm: {
...config.cacheWarm,
enabled: (process.env.CACHE_WARM_ENABLED || 'true').toLowerCase() !== 'false',
timeoutMs: parseInt(process.env.CACHE_WARM_TIMEOUT_MS, 10) || 30000,
entryTimeoutMs: parseInt(process.env.CACHE_WARM_ENTRY_TIMEOUT_MS, 10) || 10000,
maxEntries: parseInt(process.env.CACHE_WARM_MAX_ENTRIES, 10) || 500,
concurrency: parseInt(process.env.CACHE_WARM_CONCURRENCY, 10) || 8,
jitterMs: parseInt(process.env.CACHE_WARM_JITTER_MS, 10) || 0,
lockTtlMs: parseInt(process.env.CACHE_WARM_LOCK_TTL_MS, 10) || 60000,
},
trustProxy: (process.env.TRUST_PROXY || 'false').toLowerCase() === 'true',
cookies: {
secure: (process.env.COOKIE_SECURE || 'auto').toLowerCase(),
sameSite: (process.env.COOKIE_SAME_SITE || 'Strict'),
path: (process.env.COOKIE_PATH || '/'),
domain: process.env.COOKIE_DOMAIN || undefined,
},
LOG_LEVEL: reloaded.LOG_LEVEL,
});


return config;
}
Expand Down
13 changes: 12 additions & 1 deletion src/database/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,24 @@ let db = null;
* @param {Object} [overrideConfig] - Optional configuration overrides (useful for testing)
* @returns {import('knex').Knex}
*/
// Issue #494 — every timestamp column is TIMESTAMPTZ, and all application
// code stores/compares UTC. Pinning the session TimeZone to UTC as well means
// anything the database renders itself in a text form (to_char, ::date,
// CURRENT_TIMESTAMP without a zone, a query typed by hand) is UTC too, rather
// than following the server's local zone. A pool `afterCreate` hook runs for
// every pooled connection, not just the first.
function afterCreateUtcConnection(conn, done) {
conn.query("SET TIME ZONE 'UTC'", (err) => done(err, conn));
}

function getDb(overrideConfig = {}) {
if (!db) {
const pool = { min: 2, max: 10, afterCreate: afterCreateUtcConnection, ...(overrideConfig.pool || {}) };
db = knexFactory({
client: 'pg',
connection: overrideConfig.connection || config.databaseUrl,
pool: overrideConfig.pool || { min: 2, max: 10 },
...overrideConfig,
pool,
});
defaultPoolMonitor.attach(db);
}
Expand Down
Loading