feat(portal-bff): redis client foundation per ADR-0010 (#109)
## Summary First step toward Redis-backed sessions (ADR-0010). Adds the shared `ioredis` connection that every downstream consumer (session storage, OBO token cache, …) injects via the new `REDIS_CLIENT` DI token. No session logic in this PR — that's the next one. ## What lands - **`ioredis@^5.10.1`** as a direct dependency. Chosen by ADR-0010 for its mature Sentinel support — single-instance URL today, Sentinel-HA configuration lands with the prod infrastructure ADR. - **[`.env.example`](apps/portal-bff/.env.example)** promotes `REDIS_URL` from its future-vars comment to an active variable, defaulting to the local Compose stack's address. The Sentinel-style keys (`REDIS_SENTINEL_HOSTS`, `REDIS_SENTINEL_NAME`, `REDIS_TLS`) stay in the future-vars comment until the prod deploy. - **[`check-redis-config.ts`](apps/portal-bff/src/config/check-redis-config.ts)** — boot-time guard mirroring the existing four: - Refuses to start on missing / non-`redis(s)://` / passwordless / placeholder URLs. - Returns a typed `RedisConfig` with parsed `host` + `port` for downstream observability. - **[`redis.token.ts`](apps/portal-bff/src/redis/redis.token.ts)** — `REDIS_CLIENT` string token + `Redis` type alias. Same shape as the existing `ENTRA_CONFIG` / `MSAL_CLIENT`. - **[`redis.module.ts`](apps/portal-bff/src/redis/redis.module.ts)** — `RedisModule` factory provider: - Caps `maxRetriesPerRequest: 3` so an unreachable Redis surfaces a clear command-time error rather than an infinite reconnect storm. - Wires `connect` / `ready` / `error` / `close` / `reconnecting` events into the Pino stream under the `redis` context — easy log isolation. - Non-global; consumers import the module to state "I depend on Redis". - **`main.ts`** calls `assertRedisConfig()` alongside the other three validators; **`AppModule`** imports `RedisModule`. ## Decisions worth flagging - **`maxRetriesPerRequest: 3`** rather than the ioredis default of 20. With the default, a Redis outage masquerades as request-level timeouts spread over minutes. Capping low surfaces the outage in the first command failure — the BFF can then return 503 and recover quickly when Redis comes back. - **Single shared client.** Pub/sub use-cases (when they appear) duplicate via `redis.duplicate()` per ioredis convention. Connect/disconnect is one socket per BFF instance. - **No explicit shutdown hook yet.** Node's process-exit handlers and ioredis's own cleanup take care of the socket on SIGTERM / Ctrl+C. If we see stuck connections in real load, we wire `OnApplicationShutdown` + `redis.quit()`. - **Sentinel-style config stays in the future-vars comment.** ioredis supports it natively, but plumbing it on top of the URL form complicates the validator and the factory for zero v1 payoff. Lands with the prod infrastructure ADR. ## Verification - `nx run-many -t lint test build --projects=portal-bff` — green. - **62 / 62 specs** (was 52; +10 — `check-redis-config` covers happy path + 6 failure modes; `redis.module` covers DI resolution against an unreachable URL plus the missing-env failure). - Boot smoke against the local Compose stack: Pino's `redis` context shows `redis.connect` → `redis.ready` on startup; killing the Redis container produces `redis.close` / `redis.reconnecting` lines. ## What this PR explicitly does NOT do - Mount `express-session` + `connect-redis` middleware. The next PR wires the session cookie (`__Host-portal_session`), the encrypted payload, and the lookup middleware that attaches `user` to every request. - Plug the callback into session creation. Auth still ends with a Pino log + redirect; the SPA still sees the user anonymous on the next request. - Sentinel / TLS configuration. Future-var keys are documented in `.env.example` for when the prod deploy lands. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #109
This commit was merged in pull request #109.
This commit is contained in:
@@ -73,6 +73,17 @@ ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
|
|||||||
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
||||||
SESSION_SECRET=replace_with_32_random_bytes_base64url
|
SESSION_SECRET=replace_with_32_random_bytes_base64url
|
||||||
|
|
||||||
|
# Redis connection (per ADR-0010). The BFF uses `ioredis` for session
|
||||||
|
# storage (today: just the connection; the express-session +
|
||||||
|
# connect-redis middleware lands in the next PR).
|
||||||
|
#
|
||||||
|
# REDIS_URL — full URL form including auth. Must match `infra/local/.env`
|
||||||
|
# (REDIS_PASSWORD + REDIS_PORT) when running against the local Compose
|
||||||
|
# stack. Production wiring uses Sentinel (REDIS_SENTINEL_HOSTS +
|
||||||
|
# REDIS_SENTINEL_NAME — future-vars block below) and TLS; the current
|
||||||
|
# variable supports the dev single-instance shape only.
|
||||||
|
REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0
|
||||||
|
|
||||||
# Future env vars introduced by upcoming phases / ADRs:
|
# Future env vars introduced by upcoming phases / ADRs:
|
||||||
#
|
#
|
||||||
# Auth flow (ADR-0009) — additional keys wired as the routes land:
|
# Auth flow (ADR-0009) — additional keys wired as the routes land:
|
||||||
@@ -81,9 +92,9 @@ SESSION_SECRET=replace_with_32_random_bytes_base64url
|
|||||||
# in the multi-tenant phase — empty means
|
# in the multi-tenant phase — empty means
|
||||||
# "only ENTRA_TENANT_ID is accepted")
|
# "only ENTRA_TENANT_ID is accepted")
|
||||||
#
|
#
|
||||||
# Sessions (ADR-0010):
|
# Sessions (ADR-0010) — additional keys wired as the layers land:
|
||||||
# REDIS_URL (or REDIS_SENTINEL_HOSTS + REDIS_SENTINEL_NAME)
|
# REDIS_SENTINEL_HOSTS (CSV `host:port,host:port,…`; prod HA)
|
||||||
# REDIS_PASSWORD
|
# REDIS_SENTINEL_NAME (master name in Sentinel; prod HA)
|
||||||
# REDIS_TLS ('true' in prod)
|
# REDIS_TLS ('true' in prod)
|
||||||
# SESSION_ENCRYPTION_KEY (32-byte base64)
|
# SESSION_ENCRYPTION_KEY (32-byte base64)
|
||||||
# SESSION_IDLE_TIMEOUT_SECONDS (default 1800)
|
# SESSION_IDLE_TIMEOUT_SECONDS (default 1800)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ObservabilityModule } from '../observability/observability.module';
|
|||||||
import { HealthModule } from '../health/health.module';
|
import { HealthModule } from '../health/health.module';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { RedisModule } from '../redis/redis.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -13,6 +14,7 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
PrismaModule.forRoot({ isGlobal: true }),
|
PrismaModule.forRoot({ isGlobal: true }),
|
||||||
AuditModule,
|
AuditModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
RedisModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { assertRedisConfig } from './check-redis-config';
|
||||||
|
|
||||||
|
const VALID = 'redis://default:s3cret@redis.example:6379/0';
|
||||||
|
|
||||||
|
describe('assertRedisConfig', () => {
|
||||||
|
const original = process.env['REDIS_URL'];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (original === undefined) {
|
||||||
|
delete process.env['REDIS_URL'];
|
||||||
|
} else {
|
||||||
|
process.env['REDIS_URL'] = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a well-formed redis URL', () => {
|
||||||
|
process.env['REDIS_URL'] = VALID;
|
||||||
|
expect(assertRedisConfig()).toEqual({
|
||||||
|
url: VALID,
|
||||||
|
host: 'redis.example',
|
||||||
|
port: 6379,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults the port to 6379 when the URL omits it', () => {
|
||||||
|
process.env['REDIS_URL'] = 'redis://default:s3cret@redis.example/0';
|
||||||
|
expect(assertRedisConfig().port).toBe(6379);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts rediss:// for TLS-wrapped connections (future prod)', () => {
|
||||||
|
process.env['REDIS_URL'] = 'rediss://default:s3cret@redis.example:6380/0';
|
||||||
|
const config = assertRedisConfig();
|
||||||
|
expect(config.host).toBe('redis.example');
|
||||||
|
expect(config.port).toBe(6380);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when REDIS_URL is unset', () => {
|
||||||
|
delete process.env['REDIS_URL'];
|
||||||
|
expect(() => assertRedisConfig()).toThrow(/REDIS_URL is not set/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when REDIS_URL is not a valid URL', () => {
|
||||||
|
process.env['REDIS_URL'] = 'not-a-url';
|
||||||
|
expect(() => assertRedisConfig()).toThrow(/not a valid URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when scheme is neither redis: nor rediss:', () => {
|
||||||
|
process.env['REDIS_URL'] = 'http://default:s3cret@redis.example:6379/0';
|
||||||
|
expect(() => assertRedisConfig()).toThrow(/must use the "redis:\/\/" or "rediss:\/\/" scheme/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when password is absent (local stack requires one)', () => {
|
||||||
|
process.env['REDIS_URL'] = 'redis://redis.example:6379/0';
|
||||||
|
expect(() => assertRedisConfig()).toThrow(/no password/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when password is still the .env.example placeholder', () => {
|
||||||
|
process.env['REDIS_URL'] = 'redis://default:redis_dev_change_me@redis.example:6379/0';
|
||||||
|
expect(() => assertRedisConfig()).toThrow(/placeholder/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Boot-time guard for the Redis connection string. Same family as
|
||||||
|
* `check-database-url.ts` / `check-entra-config.ts` /
|
||||||
|
* `check-session-secret.ts` — fails before NestFactory if the URL
|
||||||
|
* is missing or obviously malformed, so the contributor sees one
|
||||||
|
* clear message instead of a `ioredis` reconnect storm.
|
||||||
|
*
|
||||||
|
* v1 understands the single-instance form (`redis://[user]:pass@host:port/db`).
|
||||||
|
* Sentinel-style configuration (`REDIS_SENTINEL_HOSTS` +
|
||||||
|
* `REDIS_SENTINEL_NAME` per ADR-0010) lands when the production
|
||||||
|
* deploy ADR ships; until then the validator only accepts URLs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PLACEHOLDER_PASSWORD = 'redis_dev_change_me';
|
||||||
|
|
||||||
|
export interface RedisConfig {
|
||||||
|
/** Original URL as configured; consumed by `ioredis`'s URL ctor. */
|
||||||
|
readonly url: string;
|
||||||
|
/** Parsed host (for log lines and the future readiness probe). */
|
||||||
|
readonly host: string;
|
||||||
|
/** Parsed port (defaults to 6379 when the URL omits it). */
|
||||||
|
readonly port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertRedisConfig(): RedisConfig {
|
||||||
|
const raw = process.env['REDIS_URL'];
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error(
|
||||||
|
'REDIS_URL is not set. Copy apps/portal-bff/.env.example to ' +
|
||||||
|
'apps/portal-bff/.env and adjust to match infra/local/.env ' +
|
||||||
|
'(REDIS_PASSWORD + REDIS_PORT).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`REDIS_URL is not a valid URL. Expected ` +
|
||||||
|
`"redis://[user]:<password>@<host>:<port>/<db>"; got: ${truncate(raw)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'redis:' && parsed.protocol !== 'rediss:') {
|
||||||
|
throw new Error(
|
||||||
|
`REDIS_URL must use the "redis://" or "rediss://" scheme; got "${parsed.protocol}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The local Compose stack requires a non-default password (the
|
||||||
|
// compose refuses to boot without it). A REDIS_URL without one
|
||||||
|
// would only connect to a non-authenticated Redis — surface the
|
||||||
|
// mismatch up-front rather than at the first connection attempt.
|
||||||
|
if (!parsed.password) {
|
||||||
|
throw new Error(
|
||||||
|
`REDIS_URL has no password. The local dev stack requires a ` +
|
||||||
|
`password (REDIS_PASSWORD in infra/local/.env). Add it to ` +
|
||||||
|
`the URL's userinfo segment.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.password === PLACEHOLDER_PASSWORD) {
|
||||||
|
throw new Error(
|
||||||
|
`REDIS_URL still carries the .env.example placeholder password ` +
|
||||||
|
`("${PLACEHOLDER_PASSWORD}"). Set a real value in ` +
|
||||||
|
`infra/local/.env and mirror it here.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = parsed.port ? Number(parsed.port) : 6379;
|
||||||
|
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
||||||
|
throw new Error(`REDIS_URL has an invalid port: ${parsed.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: raw,
|
||||||
|
host: parsed.hostname,
|
||||||
|
port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string): string {
|
||||||
|
return s.length > 32 ? `${s.slice(0, 32)}…` : s;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { Logger } from 'nestjs-pino';
|
|||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
import { assertDatabaseUrl } from './config/check-database-url';
|
import { assertDatabaseUrl } from './config/check-database-url';
|
||||||
import { assertEntraConfig } from './config/check-entra-config';
|
import { assertEntraConfig } from './config/check-entra-config';
|
||||||
|
import { assertRedisConfig } from './config/check-redis-config';
|
||||||
import { assertSessionSecret } from './config/check-session-secret';
|
import { assertSessionSecret } from './config/check-session-secret';
|
||||||
|
|
||||||
// Fail fast on a malformed DATABASE_URL (most often a special char in
|
// Fail fast on a malformed DATABASE_URL (most often a special char in
|
||||||
@@ -26,6 +27,11 @@ assertEntraConfig();
|
|||||||
// PKCE verifier today, session cookie next).
|
// PKCE verifier today, session cookie next).
|
||||||
const sessionSecret = assertSessionSecret();
|
const sessionSecret = assertSessionSecret();
|
||||||
|
|
||||||
|
// REDIS_URL is the shared session / cache backend (ADR-0010). Boot-
|
||||||
|
// time guard so a malformed URL fails before `ioredis` enters its
|
||||||
|
// reconnect loop.
|
||||||
|
assertRedisConfig();
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
// `bufferLogs: true` holds early-bootstrap log lines until the
|
// `bufferLogs: true` holds early-bootstrap log lines until the
|
||||||
// Pino-based Logger is wired in below, so we don't lose anything
|
// Pino-based Logger is wired in below, so we don't lose anything
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import IORedis from 'ioredis';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
import { RedisModule } from './redis.module';
|
||||||
|
import { REDIS_CLIENT } from './redis.token';
|
||||||
|
|
||||||
|
const ORIGINAL_REDIS_URL = process.env['REDIS_URL'];
|
||||||
|
|
||||||
|
async function compile() {
|
||||||
|
return Test.createTestingModule({
|
||||||
|
imports: [LoggerModule.forRoot({ pinoHttp: { level: 'silent' } }), RedisModule],
|
||||||
|
}).compile();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RedisModule', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
if (ORIGINAL_REDIS_URL === undefined) {
|
||||||
|
delete process.env['REDIS_URL'];
|
||||||
|
} else {
|
||||||
|
process.env['REDIS_URL'] = ORIGINAL_REDIS_URL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides an ioredis client via the REDIS_CLIENT token', async () => {
|
||||||
|
// A well-formed but unreachable URL — `ioredis` constructs the
|
||||||
|
// client lazily so the test never opens a real socket.
|
||||||
|
process.env['REDIS_URL'] = 'redis://default:test-pass@127.0.0.1:65535/0';
|
||||||
|
const ref = await compile();
|
||||||
|
try {
|
||||||
|
const client = ref.get<IORedis>(REDIS_CLIENT);
|
||||||
|
expect(client).toBeInstanceOf(IORedis);
|
||||||
|
} finally {
|
||||||
|
ref.get<IORedis>(REDIS_CLIENT).disconnect();
|
||||||
|
await ref.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails to compile when REDIS_URL is missing', async () => {
|
||||||
|
delete process.env['REDIS_URL'];
|
||||||
|
await expect(compile()).rejects.toThrow(/REDIS_URL is not set/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import IORedis from 'ioredis';
|
||||||
|
import { Logger } from 'nestjs-pino';
|
||||||
|
import { assertRedisConfig } from '../config/check-redis-config';
|
||||||
|
import { REDIS_CLIENT, type Redis } from './redis.token';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis module — owns the single shared `ioredis` connection that
|
||||||
|
* downstream consumers (session store, OBO token cache, …) inject
|
||||||
|
* via the `REDIS_CLIENT` token.
|
||||||
|
*
|
||||||
|
* Per ADR-0010:
|
||||||
|
* - Single instance for dev (one `REDIS_URL`); Sentinel-HA for
|
||||||
|
* prod (designed-in, plumbing lands with the production
|
||||||
|
* infrastructure ADR).
|
||||||
|
* - Connection is eager — ioredis opens the socket lazily but the
|
||||||
|
* factory builds the client at module init so DI consumers
|
||||||
|
* always receive an instance.
|
||||||
|
* - Connect / error / reconnect events route into Pino with the
|
||||||
|
* `redis` context so log search isolates them from app logs.
|
||||||
|
* - Single shared client. Pub/sub use-cases (when they appear)
|
||||||
|
* duplicate the connection via `redis.duplicate()` per ioredis
|
||||||
|
* convention.
|
||||||
|
*
|
||||||
|
* Module stays non-global; consumers state "I depend on Redis" by
|
||||||
|
* importing it explicitly.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: REDIS_CLIENT,
|
||||||
|
inject: [Logger],
|
||||||
|
useFactory: (logger: Logger): Redis => {
|
||||||
|
const config = assertRedisConfig();
|
||||||
|
const client = new IORedis(config.url, {
|
||||||
|
// Cap reconnect storms during bootstrap: if Redis is
|
||||||
|
// unreachable we want the BFF to surface a clear error on
|
||||||
|
// the first command path, not a never-ending reconnect
|
||||||
|
// loop hiding the issue.
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('connect', () => {
|
||||||
|
logger.log({ event: 'redis.connect', host: config.host, port: config.port }, 'redis');
|
||||||
|
});
|
||||||
|
client.on('ready', () => {
|
||||||
|
logger.log({ event: 'redis.ready' }, 'redis');
|
||||||
|
});
|
||||||
|
client.on('error', (err) => {
|
||||||
|
logger.error({ event: 'redis.error', message: err.message }, 'redis');
|
||||||
|
});
|
||||||
|
client.on('close', () => {
|
||||||
|
logger.warn({ event: 'redis.close' }, 'redis');
|
||||||
|
});
|
||||||
|
client.on('reconnecting', (delayMs: number) => {
|
||||||
|
logger.warn({ event: 'redis.reconnecting', delayMs }, 'redis');
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [REDIS_CLIENT],
|
||||||
|
})
|
||||||
|
export class RedisModule {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type Redis from 'ioredis';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DI token for the shared `ioredis` connection used across the
|
||||||
|
* BFF: today the session store (`connect-redis` lands in the next
|
||||||
|
* PR), tomorrow the OBO token cache (ADR-0014).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* @Inject(REDIS_CLIENT) private readonly redis: Redis
|
||||||
|
*/
|
||||||
|
export const REDIS_CLIENT = 'REDIS_CLIENT';
|
||||||
|
|
||||||
|
export type { Redis };
|
||||||
@@ -147,6 +147,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
"lucide-angular": "^1.0.0",
|
"lucide-angular": "^1.0.0",
|
||||||
"nestjs-cls": "^6.2.0",
|
"nestjs-cls": "^6.2.0",
|
||||||
"nestjs-pino": "^4.6.1",
|
"nestjs-pino": "^4.6.1",
|
||||||
|
|||||||
Generated
+67
@@ -119,6 +119,9 @@ importers:
|
|||||||
cookie-parser:
|
cookie-parser:
|
||||||
specifier: ^1.4.7
|
specifier: ^1.4.7
|
||||||
version: 1.4.7
|
version: 1.4.7
|
||||||
|
ioredis:
|
||||||
|
specifier: ^5.10.1
|
||||||
|
version: 5.10.1
|
||||||
lucide-angular:
|
lucide-angular:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))
|
version: 1.0.0(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))
|
||||||
@@ -1882,6 +1885,9 @@ packages:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@ioredis/commands@1.5.1':
|
||||||
|
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -5326,6 +5332,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2:
|
||||||
|
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
co@4.6.0:
|
co@4.6.0:
|
||||||
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
||||||
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
||||||
@@ -5719,6 +5729,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
|
denque@2.1.0:
|
||||||
|
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
|
||||||
depd@1.1.2:
|
depd@1.1.2:
|
||||||
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
|
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -6658,6 +6672,10 @@ packages:
|
|||||||
intl-messageformat@10.7.18:
|
intl-messageformat@10.7.18:
|
||||||
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
|
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
|
||||||
|
|
||||||
|
ioredis@5.10.1:
|
||||||
|
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
|
||||||
|
engines: {node: '>=12.22.0'}
|
||||||
|
|
||||||
ip-address@10.2.0:
|
ip-address@10.2.0:
|
||||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
@@ -7395,9 +7413,15 @@ packages:
|
|||||||
lodash.debounce@4.0.8:
|
lodash.debounce@4.0.8:
|
||||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
|
|
||||||
|
lodash.defaults@4.2.0:
|
||||||
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
|
||||||
lodash.includes@4.3.0:
|
lodash.includes@4.3.0:
|
||||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
|
lodash.isarguments@3.1.0:
|
||||||
|
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||||
|
|
||||||
lodash.isboolean@3.0.3:
|
lodash.isboolean@3.0.3:
|
||||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||||
|
|
||||||
@@ -8594,6 +8618,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
|
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
|
||||||
engines: {node: '>= 10.13.0'}
|
engines: {node: '>= 10.13.0'}
|
||||||
|
|
||||||
|
redis-errors@1.2.0:
|
||||||
|
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
redis-parser@3.0.0:
|
||||||
|
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
reflect-metadata@0.2.2:
|
reflect-metadata@0.2.2:
|
||||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||||
|
|
||||||
@@ -9159,6 +9191,9 @@ packages:
|
|||||||
stackframe@1.3.4:
|
stackframe@1.3.4:
|
||||||
resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==}
|
resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0:
|
||||||
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
|
|
||||||
statuses@1.5.0:
|
statuses@1.5.0:
|
||||||
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
|
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -11916,6 +11951,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.12.4
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
|
'@ioredis/commands@1.5.1': {}
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
@@ -16022,6 +16059,8 @@ snapshots:
|
|||||||
|
|
||||||
clone@1.0.4: {}
|
clone@1.0.4: {}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
co@4.6.0: {}
|
co@4.6.0: {}
|
||||||
|
|
||||||
code-block-writer@12.0.0: {}
|
code-block-writer@12.0.0: {}
|
||||||
@@ -16397,6 +16436,8 @@ snapshots:
|
|||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
|
denque@2.1.0: {}
|
||||||
|
|
||||||
depd@1.1.2: {}
|
depd@1.1.2: {}
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
@@ -17467,6 +17508,20 @@ snapshots:
|
|||||||
'@formatjs/icu-messageformat-parser': 2.11.4
|
'@formatjs/icu-messageformat-parser': 2.11.4
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
ioredis@5.10.1:
|
||||||
|
dependencies:
|
||||||
|
'@ioredis/commands': 1.5.1
|
||||||
|
cluster-key-slot: 1.1.2
|
||||||
|
debug: 4.4.3
|
||||||
|
denque: 2.1.0
|
||||||
|
lodash.defaults: 4.2.0
|
||||||
|
lodash.isarguments: 3.1.0
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
redis-parser: 3.0.0
|
||||||
|
standard-as-callback: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
ip-address@10.2.0: {}
|
ip-address@10.2.0: {}
|
||||||
|
|
||||||
ip-regex@4.3.0: {}
|
ip-regex@4.3.0: {}
|
||||||
@@ -18562,8 +18617,12 @@ snapshots:
|
|||||||
|
|
||||||
lodash.debounce@4.0.8: {}
|
lodash.debounce@4.0.8: {}
|
||||||
|
|
||||||
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
lodash.includes@4.3.0: {}
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
|
lodash.isarguments@3.1.0: {}
|
||||||
|
|
||||||
lodash.isboolean@3.0.3: {}
|
lodash.isboolean@3.0.3: {}
|
||||||
|
|
||||||
lodash.isinteger@4.0.4: {}
|
lodash.isinteger@4.0.4: {}
|
||||||
@@ -19980,6 +20039,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
resolve: 1.22.12
|
resolve: 1.22.12
|
||||||
|
|
||||||
|
redis-errors@1.2.0: {}
|
||||||
|
|
||||||
|
redis-parser@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
|
||||||
reflect-metadata@0.2.2: {}
|
reflect-metadata@0.2.2: {}
|
||||||
|
|
||||||
regenerate-unicode-properties@10.2.2:
|
regenerate-unicode-properties@10.2.2:
|
||||||
@@ -20612,6 +20677,8 @@ snapshots:
|
|||||||
|
|
||||||
stackframe@1.3.4: {}
|
stackframe@1.3.4: {}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0: {}
|
||||||
|
|
||||||
statuses@1.5.0: {}
|
statuses@1.5.0: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user