diff --git a/.claude/rules/devcontainer.md b/.claude/rules/devcontainer.md index 4d0332e8..8cb1c829 100644 --- a/.claude/rules/devcontainer.md +++ b/.claude/rules/devcontainer.md @@ -21,7 +21,7 @@ This project includes a fully configured DevContainer for VS Code and Cursor. ## Pre-installed Tools - **Bun** - JavaScript runtime and package manager -- **Node.js 20** - For npm packages requiring Node +- **Node.js 24** - For npm packages requiring Node - **Claude Code CLI** - AI-powered coding assistant - **mkcert** - Local HTTPS certificate generation diff --git a/.devcontainer/compose.yml b/.devcontainer/compose.yml index fcf61df4..2aa586bd 100644 --- a/.devcontainer/compose.yml +++ b/.devcontainer/compose.yml @@ -83,7 +83,6 @@ services: dragonfly --maxmemory=128mb --proactor_threads=1 - --cache_mode=true --default_lua_flags=allow-undeclared-keys ports: - "6379:6379" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e2a05f67..d7fbc1bd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -32,7 +32,7 @@ "features": { "ghcr.io/shyim/devcontainers-features/bun:0": {}, "ghcr.io/devcontainers/features/node:1": { - "version": "20" + "version": "24" } }, "containerEnv": { diff --git a/.env.example b/.env.example index b5bc00bb..205d814b 100644 --- a/.env.example +++ b/.env.example @@ -219,8 +219,10 @@ ENABLE_REGISTRATION=true # Redis URL for caching and job queues # Required for ActivityPub delivery queue in production -# (DRAGONFLY_URL is also accepted as a fallback) -REDIS_URL=redis://localhost:6379 +# Defaults to redis://localhost:6379. DRAGONFLY_URL is also accepted as a +# fallback; docker/compose.yml and the devcontainer set DRAGONFLY_URL, so leave +# this commented out there (REDIS_URL takes precedence over DRAGONFLY_URL) +# REDIS_URL=redis://localhost:6379 # Disable queue and use synchronous delivery (default: false) # Set to "true" to disable BullMQ queue (useful when Redis/Dragonfly has issues) diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index b79beacf..036d5adc 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -55,7 +55,9 @@ jobs: echo "Creating tag: $TAG" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "$TAG" -m "Release $TAG" + # The "[auto-tag]" marker in the tag message tells release.yml to skip + # this tag (#278, #282). Keep it in sync with the guard job there. + git tag -a "$TAG" -m "Release $TAG [auto-tag]" git push origin "$TAG" echo "Successfully created and pushed tag: $TAG" echo "created=true" >> $GITHUB_OUTPUT diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c952335..8b2693b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Manual Release # Manual release workflow for: # - Hotfix releases (bypassing normal dev -> main flow) # - Re-creating failed releases -# - Creating releases from manually pushed tags +# - Creating releases from manually pushed tags (tags pushed by auto-tag.yml are skipped) # # For normal releases, use auto-tag.yml (triggered by version bump in package.json) @@ -29,15 +29,52 @@ permissions: contents: read jobs: + # 0. Skip tags pushed by auto-tag.yml. auto-tag pushes with RELEASE_PAT, which + # (unlike GITHUB_TOKEN) fires this workflow too; without this guard both + # workflows create the same release and race on the SBOM upload (#278). + # auto-tag puts an explicit "[auto-tag]" marker in the tag message (#282). + # The github-actions[bot] tagger check covers tags created before the + # marker was introduced. + guard: + runs-on: ubuntu-latest + outputs: + skip: ${{ steps.check.outputs.skip }} + steps: + - name: Check tagger + id: check + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + if [ "${{ github.event_name }}" != "push" ]; then + echo "skip=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + read -r TYPE SHA < <(gh api "repos/${{ github.repository }}/git/ref/tags/${TAG}" --jq '.object.type + " " + .object.sha') + TAGGER="" + MESSAGE="" + if [ "$TYPE" = "tag" ]; then + TAGGER=$(gh api "repos/${{ github.repository }}/git/tags/${SHA}" --jq '.tagger.name') + MESSAGE=$(gh api "repos/${{ github.repository }}/git/tags/${SHA}" --jq '.message') + fi + echo "Tag ${TAG}: type=${TYPE}, tagger=${TAGGER:-}, message=${MESSAGE:-}" + if [[ "$MESSAGE" == *"[auto-tag]"* ]] || [ "$TAGGER" = "github-actions[bot]" ]; then + echo "Tag was created by auto-tag.yml, which handles the release. Skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + # 1. Run CI workflow first (unless skipped) call-ci: - if: ${{ github.event_name == 'push' || !inputs.skip_ci }} + needs: guard + if: ${{ needs.guard.outputs.skip != 'true' && (github.event_name == 'push' || !inputs.skip_ci) }} uses: ./.github/workflows/ci.yml # 2. Create release after CI passes create-release: - needs: call-ci - if: ${{ always() && (needs.call-ci.result == 'success' || needs.call-ci.result == 'skipped') }} + needs: [guard, call-ci] + if: ${{ always() && needs.guard.result == 'success' && needs.guard.outputs.skip != 'true' && (needs.call-ci.result == 'success' || needs.call-ci.result == 'skipped') }} runs-on: ubuntu-latest permissions: contents: write diff --git a/bun.lock b/bun.lock index 2ade59e4..bccd0c8e 100644 --- a/bun.lock +++ b/bun.lock @@ -18,14 +18,13 @@ }, "packages/backend": { "name": "hono_rox", - "version": "1.10.0", + "version": "1.10.1", "dependencies": { "@aws-sdk/client-s3": "^3.1150.0", "@hono/zod-validator": "^0.9.1", "@sentry/bun": "^11.7.0", "@sentry/core": "11.7.0", "@simplewebauthn/server": "^14.0.3", - "@simplewebauthn/types": "^12.0.0", "blurhash": "^2.0.5", "bullmq": "^6.3.14", "drizzle-orm": "^0.45.4", @@ -56,7 +55,7 @@ }, "packages/frontend": { "name": "waku_rox", - "version": "1.10.0", + "version": "1.10.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -106,7 +105,7 @@ }, "packages/shared": { "name": "shared", - "version": "1.10.0", + "version": "1.10.1", "dependencies": { "vite-plus": "^1.1.0", }, @@ -691,8 +690,6 @@ "@simplewebauthn/server": ["@simplewebauthn/server@14.0.3", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-post-quantum": "^2.9.4", "@peculiar/x509": "^2.1.0", "reflect-metadata": "^0.2.2" } }, "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q=="], - "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], - "@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], "@smithy/core": ["@smithy/core@3.35.2", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ=="], diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index 2cea1697..7417334c 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -51,7 +51,6 @@ services: dragonfly --maxmemory=128mb --proactor_threads=1 - --cache_mode=true --default_lua_flags=allow-undeclared-keys ports: - "6379:6379" diff --git a/docker/compose.yml b/docker/compose.yml index c663e584..826a3b38 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -41,7 +41,7 @@ services: postgres: condition: service_healthy dragonfly: - condition: service_started + condition: service_healthy environment: - NODE_ENV=production - PORT=3000 @@ -195,7 +195,6 @@ services: dragonfly --maxmemory=256mb --proactor_threads=2 - --cache_mode=true --default_lua_flags=allow-undeclared-keys volumes: - dragonfly-data:/data diff --git a/package.json b/package.json index 8495a205..fcac0a26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rox", - "version": "2026.10.0", + "version": "2026.10.1", "license": "AGPL-3.0-only", "devDependencies": { "@playwright/test": "^1.64.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index bc2a8620..ef8ea4c0 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "hono_rox", - "version": "1.10.0", + "version": "1.10.1", "private": true, "license": "AGPL-3.0-only", "type": "module", @@ -25,7 +25,6 @@ "@sentry/bun": "^11.7.0", "@sentry/core": "11.7.0", "@simplewebauthn/server": "^14.0.3", - "@simplewebauthn/types": "^12.0.0", "blurhash": "^2.0.5", "bullmq": "^6.3.14", "drizzle-orm": "^0.45.4", diff --git a/packages/backend/src/adapters/cache/DragonflyCacheAdapter.ts b/packages/backend/src/adapters/cache/DragonflyCacheAdapter.ts index c1746103..3135b67d 100644 --- a/packages/backend/src/adapters/cache/DragonflyCacheAdapter.ts +++ b/packages/backend/src/adapters/cache/DragonflyCacheAdapter.ts @@ -2,14 +2,15 @@ * Dragonfly Cache Adapter * * Implements ICacheService using Dragonfly (Redis-compatible) as the backend. - * Uses ioredis for connection management. + * Uses ioredis for connection management (see lib/redis.ts). * * @module adapters/cache/DragonflyCacheAdapter */ -import { Redis } from "ioredis"; +import type { Redis } from "ioredis"; import type { ICacheService, CacheSetOptions } from "../../interfaces/ICacheService.js"; import { logger } from "../../lib/logger.js"; +import { createRedisClient, waitForRedisReady } from "../../lib/redis.js"; import { recordCacheOperation } from "../../lib/metrics.js"; /** @@ -61,31 +62,22 @@ export class DragonflyCacheAdapter implements ICacheService { * Initialize Redis connection */ private async initialize(): Promise { - // docker/compose.yml と K8s secret は DRAGONFLY_URL を設定しているため両方を受け付ける - const redisUrl = process.env.REDIS_URL || process.env.DRAGONFLY_URL || "redis://localhost:6379"; - try { - this.redis = new Redis(redisUrl, { + this.redis = createRedisClient("cache", { maxRetriesPerRequest: 3, - // ioredis v6 は既定で RESP3 になったため、v5 と同じ RESP2 を明示(Dragonfly/BullMQ 互換性のため) - protocol: 2, - retryStrategy: (times) => { - if (times > 3) return null; - return Math.min(times * 100, 1000); - }, - connectTimeout: 5000, - enableOfflineQueue: false, - lazyConnect: true, + enableOfflineQueue: false, // Cache misses while disconnected instead of queueing commands }); - await this.redis.connect(); - await this.redis.ping(); + // 起動直後は Dragonfly がまだ接続を受け付けていないことがあるため、一定時間は再試行を待つ + await waitForRedisReady(this.redis); this.available = true; - logger.debug("Dragonfly cache connected"); + logger.info("Dragonfly cache connected"); } catch (error) { logger.warn({ err: error }, "Dragonfly cache not available, caching disabled"); this.available = false; + // 失敗した接続が裏で再接続を続けないように切断する + this.redis?.disconnect(); this.redis = null; } } diff --git a/packages/backend/src/lib/redis.ts b/packages/backend/src/lib/redis.ts new file mode 100644 index 00000000..aca69a63 --- /dev/null +++ b/packages/backend/src/lib/redis.ts @@ -0,0 +1,140 @@ +/** + * Redis / Dragonfly Connection Helpers + * + * Shared connection settings for the Dragonfly cache adapter and the + * ActivityPub delivery queue, so both always talk to the same server with + * the same protocol and reconnect behavior. + * + * @module lib/redis + */ + +import { Redis, type RedisOptions } from "ioredis"; +import { logger } from "./logger.js"; + +/** Default Redis URL when neither `REDIS_URL` nor `DRAGONFLY_URL` is set. */ +const DEFAULT_REDIS_URL = "redis://localhost:6379"; + +/** Upper bound for the delay between reconnect attempts. */ +const MAX_RECONNECT_DELAY_MS = 5000; + +/** How long to wait for the first connection at startup. */ +export const DEFAULT_STARTUP_TIMEOUT_MS = 15000; + +/** + * Resolve the Redis connection URL. + * + * `REDIS_URL` takes precedence; `DRAGONFLY_URL` is accepted as a fallback + * because docker/compose.yml and the K8s secret set that name. + * + * @returns Redis connection URL + */ +export function getRedisUrl(): string { + return process.env.REDIS_URL || process.env.DRAGONFLY_URL || DEFAULT_REDIS_URL; +} + +/** + * Strip credentials from a Redis URL so it can be logged safely. + * + * @param url - Redis connection URL (may contain `user:password@`) + * @returns URL with username and password replaced by `***` + */ +export function redactRedisUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.username) parsed.username = "***"; + if (parsed.password) parsed.password = "***"; + return parsed.toString(); + } catch { + return ""; + } +} + +/** + * Create an ioredis client with the shared defaults. + * + * Defaults: + * - `protocol: 2`: ioredis v6 defaults to RESP3; RESP2 keeps v5 behavior + * for Dragonfly/BullMQ compatibility. + * - Never gives up reconnecting (capped backoff), so a Dragonfly restart + * does not permanently disable the cache or the delivery queue. + * - Logs a warning when an established connection drops and an info + * message when it comes back. + * + * @param name - Label used in log messages (e.g. "cache", "queue") + * @param options - ioredis options merged over the defaults + * @returns ioredis client (connecting unless `lazyConnect` is set) + */ +export function createRedisClient(name: string, options: RedisOptions = {}): Redis { + const redis = new Redis(getRedisUrl(), { + protocol: 2, + connectTimeout: 5000, + retryStrategy: (times) => Math.min(times * 200, MAX_RECONNECT_DELAY_MS), + ...options, + }); + + // Log once per outage. "reconnecting" (unlike "close") does not fire on a + // deliberate quit()/disconnect(), so graceful shutdown stays quiet. + let everReady = false; + let connected = false; + redis.on("ready", () => { + if (everReady) { + logger.info({ client: name }, "Redis connection restored"); + } + everReady = true; + connected = true; + }); + redis.on("reconnecting", () => { + if (connected) { + logger.warn({ client: name }, "Redis connection lost, reconnecting"); + connected = false; + } + }); + // Without an error listener ioredis prints "Unhandled error event" on + // every reconnect attempt. Connection state is reported via ready/close. + redis.on("error", (err) => { + logger.debug({ err, client: name }, "Redis connection error"); + }); + + return redis; +} + +/** + * Wait until a client is ready, tolerating transient connection errors. + * + * Unlike listening for the first `error` event, this keeps waiting while + * ioredis retries, so a backend that starts before Dragonfly accepts + * connections still picks it up. + * + * @param redis - Client to wait for + * @param timeoutMs - Maximum time to wait + * @throws Error if the client is not ready within `timeoutMs` + */ +export async function waitForRedisReady( + redis: Redis, + timeoutMs: number = DEFAULT_STARTUP_TIMEOUT_MS, +): Promise { + if (redis.status === "ready") return; + + await new Promise((resolve, reject) => { + const onReady = () => { + cleanup(); + resolve(); + }; + const onEnd = () => { + cleanup(); + reject(new Error("Redis connection ended before becoming ready")); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Redis not ready within ${timeoutMs}ms (${redactRedisUrl(getRedisUrl())})`)); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timer); + redis.off("ready", onReady); + redis.off("end", onEnd); + }; + + redis.once("ready", onReady); + redis.once("end", onEnd); + }); +} diff --git a/packages/backend/src/services/PasskeyService.ts b/packages/backend/src/services/PasskeyService.ts index c6799b27..56ac751b 100644 --- a/packages/backend/src/services/PasskeyService.ts +++ b/packages/backend/src/services/PasskeyService.ts @@ -14,9 +14,9 @@ import { verifyAuthenticationResponse, type VerifiedRegistrationResponse, type VerifiedAuthenticationResponse, + type AuthenticatorTransport, } from "@simplewebauthn/server"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; -import type { AuthenticatorTransportFuture } from "@simplewebauthn/types"; import type { IPasskeyCredentialRepository } from "../interfaces/repositories/IPasskeyCredentialRepository.js"; import type { IPasskeyChallengeRepository } from "../interfaces/repositories/IPasskeyChallengeRepository.js"; import type { IUserRepository } from "../interfaces/repositories/IUserRepository.js"; @@ -144,7 +144,7 @@ export class PasskeyService { attestationType: "none", // Don't require attestation excludeCredentials: existingPasskeys.map((passkey) => ({ id: passkey.credentialId, - transports: (passkey.transports as AuthenticatorTransportFuture[]) || undefined, + transports: (passkey.transports as AuthenticatorTransport[]) || undefined, })), authenticatorSelection: { residentKey: "required", // Required for passkeys @@ -283,9 +283,7 @@ export class PasskeyService { * @returns Authentication options to pass to browser */ async generateAuthenticationOptions(username?: string): Promise { - let allowCredentials: - | Array<{ id: string; transports?: AuthenticatorTransportFuture[] }> - | undefined; + let allowCredentials: Array<{ id: string; transports?: AuthenticatorTransport[] }> | undefined; if (username) { const user = await this.userRepository.findByUsername(username); @@ -294,7 +292,7 @@ export class PasskeyService { if (passkeys.length > 0) { allowCredentials = passkeys.map((passkey) => ({ id: passkey.credentialId, - transports: (passkey.transports as AuthenticatorTransportFuture[]) || undefined, + transports: (passkey.transports as AuthenticatorTransport[]) || undefined, })); } } @@ -395,7 +393,7 @@ export class PasskeyService { id: passkey.credentialId, publicKey: isoBase64URL.toBuffer(passkey.publicKey), counter: passkey.counter, - transports: passkey.transports as AuthenticatorTransportFuture[] | undefined, + transports: passkey.transports as AuthenticatorTransport[] | undefined, }, requireUserVerification: false, }); diff --git a/packages/backend/src/services/ap/ActivityDeliveryQueue.ts b/packages/backend/src/services/ap/ActivityDeliveryQueue.ts index efd704f6..f6541dbe 100644 --- a/packages/backend/src/services/ap/ActivityDeliveryQueue.ts +++ b/packages/backend/src/services/ap/ActivityDeliveryQueue.ts @@ -7,10 +7,12 @@ * @module services/ap/ActivityDeliveryQueue */ +import { createHash } from "node:crypto"; import { Queue, Worker, type Job } from "bullmq"; -import { Redis } from "ioredis"; +import type { Redis } from "ioredis"; import { ActivityDeliveryService } from "./ActivityDeliveryService.js"; import { logger } from "../../lib/logger.js"; +import { createRedisClient, waitForRedisReady } from "../../lib/redis.js"; /** * Job priority levels @@ -149,32 +151,18 @@ export class ActivityDeliveryQueue { return; } - // docker/compose.yml と K8s secret は DRAGONFLY_URL を設定しているため両方を受け付ける - const redisUrl = process.env.REDIS_URL || process.env.DRAGONFLY_URL || "redis://localhost:6379"; - try { - // Attempt to connect to Redis // Note: maxRetriesPerRequest must be null for BullMQ compatibility // BullMQ handles its own retry logic and requires this setting - this.redis = new Redis(redisUrl, { + this.redis = createRedisClient("queue", { maxRetriesPerRequest: null, // Required for BullMQ - // ioredis v6 は既定で RESP3 になったため、v5 と同じ RESP2 を明示(Dragonfly/BullMQ 互換性のため) - protocol: 2, - retryStrategy: (times) => { - if (times > 3) return null; // Give up after 3 attempts - return Math.min(times * 100, 1000); // Exponential backoff up to 1 second - }, - connectTimeout: 5000, - enableOfflineQueue: false, // Fail fast if connection is not ready + enableOfflineQueue: false, // Fail fast (and fall back to sync) while disconnected }); - // Wait for connection to be ready - await new Promise((resolve, reject) => { - this.redis!.once("ready", () => resolve()); - this.redis!.once("error", (err) => reject(err)); - }); + // 起動直後は Dragonfly がまだ接続を受け付けていないことがあるため、一定時間は再試行を待つ + await waitForRedisReady(this.redis); - logger.debug("Redis connected, using BullMQ for delivery queue"); + logger.info("Redis connected, using BullMQ for delivery queue"); // Initialize queue // Note: Using simple prefix without hash tags for Dragonfly compatibility @@ -356,7 +344,7 @@ export class ActivityDeliveryQueue { removeOnFail: { age: 24 * 3600, // Keep failed jobs for 24 hours }, - // Deduplication: if same activity to same inbox within 5 seconds, skip + // Deduplication: skipped while a job for the same activity and inbox exists jobId: this.generateJobId(data), }); @@ -364,7 +352,7 @@ export class ActivityDeliveryQueue { } catch (error) { // Queue operation failed (e.g., Dragonfly cluster mode issue) // Fall back to synchronous delivery - logger.debug({ err: error }, "Queue operation failed, falling back to sync delivery"); + logger.warn({ err: error }, "Queue operation failed, falling back to sync delivery"); await this.deliverSync(data); } } else { @@ -386,10 +374,12 @@ export class ActivityDeliveryQueue { /** * Generate unique job ID for deduplication * - * Creates a job ID based on activity ID and inbox URL to prevent - * duplicate deliveries within a short time window. - * Uses a simple numeric hash to avoid special characters that may - * cause issues with Dragonfly's Lua scripts. + * Creates a job ID based on activity ID and inbox URL so the same activity + * is not queued twice for the same inbox while a job with that ID exists. + * Uses a truncated SHA-256 hex digest (128 bits) to make collisions between + * different deliveries practically impossible, with a `d-` prefix so the ID + * is never all digits (BullMQ rejects integer-like custom IDs). Hex + * characters only, which is safe for Dragonfly's Lua scripts. * * @param data - Delivery job data * @returns Job ID for deduplication @@ -398,16 +388,8 @@ export class ActivityDeliveryQueue { */ private generateJobId(data: DeliveryJobData): string { const activityId = data.activity.id || JSON.stringify(data.activity); - // Use a simple hash that avoids special characters - // Create a numeric hash from the string for cluster-safe job ID - const input = `${activityId}-${data.inboxUrl}`; - let hash = 0; - for (let i = 0; i < input.length; i++) { - const char = input.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer - } - return `${Math.abs(hash).toString(36)}`; + const digest = createHash("sha256").update(`${activityId}\n${data.inboxUrl}`).digest("hex"); + return `d-${digest.slice(0, 32)}`; } /** diff --git a/packages/backend/src/tests/unit/ActivityDeliveryQueue.test.ts b/packages/backend/src/tests/unit/ActivityDeliveryQueue.test.ts index 22361bb2..ab7b7a5f 100644 --- a/packages/backend/src/tests/unit/ActivityDeliveryQueue.test.ts +++ b/packages/backend/src/tests/unit/ActivityDeliveryQueue.test.ts @@ -16,6 +16,10 @@ import { type DeliveryJobData, } from "../../services/ap/ActivityDeliveryQueue"; +// These tests cover synchronous delivery mode. Disable the queue explicitly so +// the queue does not wait for a Redis connection during initialization. +process.env.USE_QUEUE = "false"; + // Mock the ActivityDeliveryService mock.module("../../services/ap/ActivityDeliveryService", () => ({ ActivityDeliveryService: class { @@ -329,11 +333,24 @@ describe("ActivityDeliveryQueue job ID generation", () => { privateKey: "secret", }; - // Should not throw and return a valid base36 string + // Should not throw and return a hex digest with the "d-" prefix const id = generateJobId(jobData); - expect(typeof id).toBe("string"); - expect(id.length).toBeGreaterThan(0); - // Verify it's a valid base36 string (alphanumeric, no special characters) - expect(/^[0-9a-z]+$/.test(id)).toBe(true); + expect(id).toMatch(/^d-[0-9a-f]{32}$/); + }); + + test("should never generate an integer-like job ID", () => { + const queue = new ActivityDeliveryQueue(false); + const generateJobId = (queue as any).generateJobId.bind(queue); + + // BullMQ rejects custom job IDs that parse as integers + for (let i = 0; i < 1000; i++) { + const id: string = generateJobId({ + activity: { id: `https://local.example/activities/${i}`, type: "Create" }, + inboxUrl: "https://remote.example/inbox", + keyId: "key", + privateKey: "secret", + }); + expect(String(Number.parseInt(id, 10))).not.toBe(id); + } }); }); diff --git a/packages/backend/src/tests/unit/redis.test.ts b/packages/backend/src/tests/unit/redis.test.ts new file mode 100644 index 00000000..d968c391 --- /dev/null +++ b/packages/backend/src/tests/unit/redis.test.ts @@ -0,0 +1,24 @@ +/** + * Redis helper unit tests + */ + +import { describe, test, expect } from "bun:test"; +import { redactRedisUrl } from "../../lib/redis"; + +describe("redactRedisUrl", () => { + test("should mask password-only credentials", () => { + expect(redactRedisUrl("redis://:s3cret@dragonfly:6379")).toBe("redis://:***@dragonfly:6379"); + }); + + test("should mask username and password", () => { + expect(redactRedisUrl("rediss://user:pw@host:6380/0")).toBe("rediss://***:***@host:6380/0"); + }); + + test("should leave URLs without credentials unchanged", () => { + expect(redactRedisUrl("redis://localhost:6379")).toBe("redis://localhost:6379"); + }); + + test("should not echo invalid input", () => { + expect(redactRedisUrl("not a url :secret")).toBe(""); + }); +}); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index ee99cf36..a7e7dcf8 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,6 +1,6 @@ { "name": "waku_rox", - "version": "1.10.0", + "version": "1.10.1", "private": true, "license": "AGPL-3.0-only", "type": "module", diff --git a/packages/shared/package.json b/packages/shared/package.json index 15213768..ec43ec02 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "shared", - "version": "1.10.0", + "version": "1.10.1", "private": true, "license": "AGPL-3.0-only", "type": "module",