From e50306cbf67d44d13226e96246d4c9c418a616b8 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Sun, 12 Jul 2026 20:35:09 +0200 Subject: [PATCH] feat(core-realtime): scaffold realtime optional core Generator-emitted scaffold (pnpm turbo gen core-package realtime) plus the story-00-precedent coverage repairs (coverage provider devDep, symbols.ts exclude + tested allowlist mirror) and three minimal tests covering generator-emitted realtime code the template suite misses. Squash of 31d85e0 + review-fix cf11b38. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK --- apps/web-next/next.config.mjs | 1 + .../library-decisions/2026-05-14-socket.io.md | 72 +++++ docs/library-decisions/2026-05-14-zod.md | 6 +- packages/core-eslint/base.js | 19 ++ .../core-eslint/rules/no-direct-socket-io.js | 39 +++ .../rules/no-direct-socket-io.test.js | 48 +++ .../rules/no-realtime-handler-reexport.js | 48 +++ .../no-realtime-handler-reexport.test.js | 35 ++ packages/core-realtime/AGENTS.md | 19 ++ .../library-decisions/2026-05-14-socket.io.md | 72 +++++ .../docs/library-decisions/2026-05-14-zod.md | 66 ++++ packages/core-realtime/eslint.config.js | 3 + packages/core-realtime/package.json | 38 +++ packages/core-realtime/src/authorize.test.ts | 73 +++++ packages/core-realtime/src/authorize.ts | 22 ++ packages/core-realtime/src/channel-room.ts | 3 + .../src/channel-template.test.ts | 35 ++ .../core-realtime/src/channel-template.ts | 29 ++ .../in-memory-realtime-broadcaster.test.ts | 34 ++ .../src/in-memory-realtime-broadcaster.ts | 28 ++ packages/core-realtime/src/index.ts | 32 ++ .../src/realtime-authenticator.interface.ts | 6 + .../src/realtime-broadcaster.interface.ts | 10 + .../src/realtime-channel.test.ts | 35 ++ .../core-realtime/src/realtime-channel.ts | 34 ++ .../src/realtime-handler-registry.test.ts | 94 ++++++ .../src/realtime-handler-registry.ts | 57 ++++ .../src/realtime-handler.interface.ts | 20 ++ .../core-realtime/src/realtime-ping.test.ts | 53 +++ packages/core-realtime/src/realtime-ping.ts | 44 +++ .../src/realtime-server.interface.ts | 16 + .../socket-io-realtime-broadcaster.test.ts | 32 ++ .../src/socket-io-realtime-broadcaster.ts | 17 + .../src/socket-io-realtime-server.test.ts | 303 ++++++++++++++++++ .../src/socket-io-realtime-server.ts | 151 +++++++++ packages/core-realtime/src/symbols.ts | 8 + packages/core-realtime/tsconfig.json | 12 + packages/core-realtime/turbo.json | 5 + packages/core-realtime/vitest.config.ts | 18 ++ pnpm-lock.yaml | 263 +++++++++++++++ scripts/coverage/diff.test.mjs | 16 + 41 files changed, 1913 insertions(+), 3 deletions(-) create mode 100644 docs/library-decisions/2026-05-14-socket.io.md create mode 100644 packages/core-eslint/rules/no-direct-socket-io.js create mode 100644 packages/core-eslint/rules/no-direct-socket-io.test.js create mode 100644 packages/core-eslint/rules/no-realtime-handler-reexport.js create mode 100644 packages/core-eslint/rules/no-realtime-handler-reexport.test.js create mode 100644 packages/core-realtime/AGENTS.md create mode 100644 packages/core-realtime/docs/library-decisions/2026-05-14-socket.io.md create mode 100644 packages/core-realtime/docs/library-decisions/2026-05-14-zod.md create mode 100644 packages/core-realtime/eslint.config.js create mode 100644 packages/core-realtime/package.json create mode 100644 packages/core-realtime/src/authorize.test.ts create mode 100644 packages/core-realtime/src/authorize.ts create mode 100644 packages/core-realtime/src/channel-room.ts create mode 100644 packages/core-realtime/src/channel-template.test.ts create mode 100644 packages/core-realtime/src/channel-template.ts create mode 100644 packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts create mode 100644 packages/core-realtime/src/in-memory-realtime-broadcaster.ts create mode 100644 packages/core-realtime/src/index.ts create mode 100644 packages/core-realtime/src/realtime-authenticator.interface.ts create mode 100644 packages/core-realtime/src/realtime-broadcaster.interface.ts create mode 100644 packages/core-realtime/src/realtime-channel.test.ts create mode 100644 packages/core-realtime/src/realtime-channel.ts create mode 100644 packages/core-realtime/src/realtime-handler-registry.test.ts create mode 100644 packages/core-realtime/src/realtime-handler-registry.ts create mode 100644 packages/core-realtime/src/realtime-handler.interface.ts create mode 100644 packages/core-realtime/src/realtime-ping.test.ts create mode 100644 packages/core-realtime/src/realtime-ping.ts create mode 100644 packages/core-realtime/src/realtime-server.interface.ts create mode 100644 packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts create mode 100644 packages/core-realtime/src/socket-io-realtime-broadcaster.ts create mode 100644 packages/core-realtime/src/socket-io-realtime-server.test.ts create mode 100644 packages/core-realtime/src/socket-io-realtime-server.ts create mode 100644 packages/core-realtime/src/symbols.ts create mode 100644 packages/core-realtime/tsconfig.json create mode 100644 packages/core-realtime/turbo.json create mode 100644 packages/core-realtime/vitest.config.ts diff --git a/apps/web-next/next.config.mjs b/apps/web-next/next.config.mjs index bc75005..3128ab8 100644 --- a/apps/web-next/next.config.mjs +++ b/apps/web-next/next.config.mjs @@ -11,6 +11,7 @@ const nextConfig = { "@repo/core-consent", "@repo/core-dsr", "@repo/core-events", + "@repo/core-realtime", "@repo/core-shared", "@repo/core-trpc", "@repo/core-ui", diff --git a/docs/library-decisions/2026-05-14-socket.io.md b/docs/library-decisions/2026-05-14-socket.io.md new file mode 100644 index 0000000..6f3c61d --- /dev/null +++ b/docs/library-decisions/2026-05-14-socket.io.md @@ -0,0 +1,72 @@ +--- +package: socket.io +version: "^4.7.0" +tier: core +decision: approved +date: 2026-05-14 +deciders: [scaffolded] +adr: adr-016 +filter-results: + license: MIT + types: native + maintenance: active + boundary-fit: pass + shadow-check: pass + eu-residency: self-hostable + cve-scan: clean + named-consumer: pass +verification-commands: + - pnpm audit --audit-level=moderate + - npm view socket.io license +accepted-cves: [] +--- + +## Filter: license + +MIT — on the workspace allowlist. + +## Filter: types + +Ships first-party TypeScript types in its distribution. + +## Filter: maintenance + +Active. Maintained by the Socket.IO team; frequent releases and active issue tracker. + +## Filter: maintenance + +Active. Regular releases; widely deployed in production. + +## Filter: boundary-fit + +ADR-016 §R2 explicitly designates `core-realtime` as the sole allowed home for `socket.io`. Boundary rule `no-direct-socket-io` enforces this in ESLint. + +## Filter: shadow-check + +No competing realtime transport in the workspace. No shadow. + +## Filter: eu-residency + +Self-hosted server; the library itself does not transmit data to any vendor endpoint. + +## Filter: cve-scan + +No advisories at adoption time. + +## Filter: named-consumer + +`core-realtime` wraps socket.io to provide the `IRealtimeServer` abstraction (ADR-016). + +## Prompt: replaces + +Nothing — this is the initial realtime scaffolding. No prior transport to retire. + +## Prompt: migration-cost-out + +Hard: channel descriptors, handler signatures, and server-side broadcast API are all shaped around socket.io semantics. Replacing requires re-implementing the abstraction layer. + +## Prompt: alternatives-considered + +1. **ws** — lower-level, no rooms or namespaces; would require significant protocol work. +2. **Ably / Pusher** — vendor-hosted; eu-residency risk and ongoing cost. + Socket.IO is the established standard for this use-case and is fully self-hostable. diff --git a/docs/library-decisions/2026-05-14-zod.md b/docs/library-decisions/2026-05-14-zod.md index 8060119..fbf1f26 100644 --- a/docs/library-decisions/2026-05-14-zod.md +++ b/docs/library-decisions/2026-05-14-zod.md @@ -5,7 +5,7 @@ tier: core decision: approved date: 2026-05-14 deciders: [scaffolded] -adr: adr-015 +adr: adr-016 filter-results: license: MIT types: native @@ -35,7 +35,7 @@ Active. Regular releases by Colin McDonnell; widely adopted. ## Filter: boundary-fit -Core package. Zod is the workspace-canonical validation library locked in `core-shared` (ADR-015). +Core package. Zod is the workspace-canonical validation library locked in `core-shared` (ADR-016). ## Filter: shadow-check @@ -51,7 +51,7 @@ No advisories at adoption time. ## Filter: named-consumer -`core-events` uses zod for event-descriptor payload schemas. +`core-realtime` uses zod for channel descriptor and payload schema validation. ## Prompt: replaces diff --git a/packages/core-eslint/base.js b/packages/core-eslint/base.js index a78d60d..664336d 100644 --- a/packages/core-eslint/base.js +++ b/packages/core-eslint/base.js @@ -8,6 +8,8 @@ import conformancePlugin from "./plugin.js"; import path from "node:path"; import { fileURLToPath } from "node:url"; // +import noDirectSocketIO from "./rules/no-direct-socket-io.js"; +import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "..", ".."); @@ -82,6 +84,8 @@ export default [ { type: "tooling", pattern: "packages/core-testing" }, { type: "core-composition", pattern: "packages/core-api" }, { type: "core-composition", pattern: "packages/core-cms" }, + { type: "core", pattern: "packages/core-realtime", mode: "folder" }, + { type: "core", pattern: "packages/core-*" }, { type: "feature", pattern: "packages/!(core-*)" }, ], @@ -245,4 +249,19 @@ export default [ // no-realtime-handler-reexport) are added here when @repo/core-realtime is // scaffolded via `pnpm turbo gen core-package realtime`. // + { + files: ["**/*.{ts,tsx,mjs,cjs,js}"], + plugins: { + "repo-rules": { + rules: { + "no-direct-socket-io": noDirectSocketIO, + "no-realtime-handler-reexport": noRealtimeHandlerReexport, + }, + }, + }, + rules: { + "repo-rules/no-direct-socket-io": "error", + "repo-rules/no-realtime-handler-reexport": "error", + }, + }, ]; diff --git a/packages/core-eslint/rules/no-direct-socket-io.js b/packages/core-eslint/rules/no-direct-socket-io.js new file mode 100644 index 0000000..c12c9a3 --- /dev/null +++ b/packages/core-eslint/rules/no-direct-socket-io.js @@ -0,0 +1,39 @@ +// packages/core-eslint/rules/no-direct-socket-io.js +const ALLOWED = [ + /\/packages\/core-realtime\/src\//, + /\/apps\/[^/]+\/server\.ts$/, + /\/apps\/[^/]+\/src\/.*\.test\.ts$/, +]; + +export default { + meta: { + type: "problem", + docs: { + description: + "Block direct socket.io imports outside core-realtime + app servers", + }, + messages: { + noDirectSocketIO: + 'Import from "@repo/core-realtime" instead of "socket.io". Direct imports allowed only in packages/core-realtime/src/ and apps/*/server.ts.', + noDirectSocketIOClient: + 'Use the realtime helpers from "@repo/core-realtime" / "@repo/core-testing/instrumentation" instead of "socket.io-client".', + }, + schema: [], + }, + create(context) { + const filename = context.filename ?? context.getFilename(); + const allowed = ALLOWED.some((re) => re.test(filename)); + if (allowed) return {}; + + return { + ImportDeclaration(node) { + const source = node.source.value; + if (source === "socket.io") { + context.report({ node, messageId: "noDirectSocketIO" }); + } else if (source === "socket.io-client") { + context.report({ node, messageId: "noDirectSocketIOClient" }); + } + }, + }; + }, +}; diff --git a/packages/core-eslint/rules/no-direct-socket-io.test.js b/packages/core-eslint/rules/no-direct-socket-io.test.js new file mode 100644 index 0000000..68342a4 --- /dev/null +++ b/packages/core-eslint/rules/no-direct-socket-io.test.js @@ -0,0 +1,48 @@ +// packages/core-eslint/rules/no-direct-socket-io.test.js +import { RuleTester } from "eslint"; +import rule from "./no-direct-socket-io.js"; + +const tester = new RuleTester({ + languageOptions: { ecmaVersion: 2022, sourceType: "module" }, +}); + +tester.run("no-direct-socket-io", rule, { + valid: [ + // Allowed inside core-realtime + { + code: 'import { Server } from "socket.io";', + filename: "/repo/packages/core-realtime/src/socket-io-realtime-server.ts", + }, + // Allowed in app servers + { + code: 'import { Server } from "socket.io";', + filename: "/repo/apps/web-next/server.ts", + }, + // Allowed in app integration tests (e.g. realtime-ping e2e) + { + code: 'import { Server } from "socket.io";', + filename: "/repo/apps/web-next/src/__tests__/realtime-ping.test.ts", + }, + { + code: 'import { io } from "socket.io-client";', + filename: "/repo/apps/web-next/src/__tests__/realtime-ping.test.ts", + }, + // Allowed elsewhere when not importing socket.io + { + code: 'import { foo } from "bar";', + filename: "/repo/packages/blog/src/foo.ts", + }, + ], + invalid: [ + { + code: 'import { Server } from "socket.io";', + filename: "/repo/packages/blog/src/foo.ts", + errors: [{ messageId: "noDirectSocketIO" }], + }, + { + code: 'import { io } from "socket.io-client";', + filename: "/repo/packages/blog/src/ui/Component.tsx", + errors: [{ messageId: "noDirectSocketIOClient" }], + }, + ], +}); diff --git a/packages/core-eslint/rules/no-realtime-handler-reexport.js b/packages/core-eslint/rules/no-realtime-handler-reexport.js new file mode 100644 index 0000000..1bcc286 --- /dev/null +++ b/packages/core-eslint/rules/no-realtime-handler-reexport.js @@ -0,0 +1,48 @@ +// packages/core-eslint/rules/no-realtime-handler-reexport.js +// Realtime handlers are private. A feature's realtime/handlers/*.handler.ts +// must only be wired in the feature's own bind-production / bind-dev-seed files. +// They must never be re-exported from barrel files or other public surfaces. + +const BIND_FILE = /\bdi\/bind-(?:production|dev-seed)\b/; +const REALTIME_HANDLERS_IN_SOURCE = /\/realtime\/handlers\//; +const HANDLERS_IN_SOURCE = /\/handlers\//; +const REALTIME_IN_FILENAME = /\/realtime\//; + +export default { + meta: { + type: "problem", + docs: { + description: + "Block re-exports of realtime/handlers/** outside feature bind-* files (ADR-016 R1)", + }, + messages: { + noRealtimeHandlerReexport: + "Realtime handlers (realtime/handlers/*.handler.ts) must not be re-exported (ADR-016 R1). " + + "Wire them only inside the feature's own bind-production / bind-dev-seed files.", + }, + schema: [], + }, + create(context) { + const filename = context.filename ?? context.getFilename(); + + // Bind-* files are the only allowed place for these exports/imports + if (BIND_FILE.test(filename)) return {}; + + function checkExportSource(node) { + if (!node.source) return; + const source = node.source.value; + const isRealtimeHandler = + REALTIME_HANDLERS_IN_SOURCE.test(source) || + (HANDLERS_IN_SOURCE.test(source) && + REALTIME_IN_FILENAME.test(filename)); + if (isRealtimeHandler) { + context.report({ node, messageId: "noRealtimeHandlerReexport" }); + } + } + + return { + ExportNamedDeclaration: checkExportSource, + ExportAllDeclaration: checkExportSource, + }; + }, +}; diff --git a/packages/core-eslint/rules/no-realtime-handler-reexport.test.js b/packages/core-eslint/rules/no-realtime-handler-reexport.test.js new file mode 100644 index 0000000..b85f161 --- /dev/null +++ b/packages/core-eslint/rules/no-realtime-handler-reexport.test.js @@ -0,0 +1,35 @@ +// packages/core-eslint/rules/no-realtime-handler-reexport.test.js +import { RuleTester } from "eslint"; +import rule from "./no-realtime-handler-reexport.js"; + +const tester = new RuleTester({ + languageOptions: { ecmaVersion: 2022, sourceType: "module" }, +}); + +tester.run("no-realtime-handler-reexport", rule, { + valid: [ + // Importing a handler from inside a feature's bind-* file is allowed. + { + code: 'import { onPingHandler } from "../realtime/handlers/on-ping.handler";', + filename: "/repo/packages/blog/src/di/bind-production.ts", + }, + // Re-exporting a channel descriptor is allowed. + { + code: 'export { presenceChannel } from "./realtime/presence.channel";', + filename: "/repo/packages/blog/src/index.ts", + }, + ], + invalid: [ + // Re-exporting a handler from any non-bind file is forbidden. + { + code: 'export { onPingHandler } from "./realtime/handlers/on-ping.handler";', + filename: "/repo/packages/blog/src/index.ts", + errors: [{ messageId: "noRealtimeHandlerReexport" }], + }, + { + code: 'export * from "./handlers/on-ping.handler";', + filename: "/repo/packages/blog/src/realtime/index.ts", + errors: [{ messageId: "noRealtimeHandlerReexport" }], + }, + ], +}); diff --git a/packages/core-realtime/AGENTS.md b/packages/core-realtime/AGENTS.md new file mode 100644 index 0000000..08c80af --- /dev/null +++ b/packages/core-realtime/AGENTS.md @@ -0,0 +1,19 @@ +# @repo/core-realtime + +Vendor-isolated realtime abstractions over Socket.IO. Feature packages depend only on the interfaces; only this package imports `socket.io`. + +ADR-016 (`docs/decisions/adr-016-realtime-layer.md`). + +## Public exports + +- `IRealtimeBroadcaster` — server → client broadcasts +- `IRealtimeServer` — lifecycle, used at app boot only +- `IRealtimeAuthenticator` — connect-time identity attachment (cookie / header → user) +- `IRealtimeHandlerRegistry` + `RealtimeHandlerRegistry` — inbound handler registration +- `defineRealtimeChannel`, `RealtimeChannelDescriptor`, `ChannelScope` +- `InMemoryRealtimeBroadcaster` (test/dev), `SocketIORealtimeBroadcaster`, `SocketIORealtimeServer` (production) +- `CORE_REALTIME_SYMBOLS` + +## Boundary + +Tagged `core`. The only place in the repo where `import "socket.io"` is allowed is `src/socket-io-*.ts` here, plus `apps/*/server.ts`. Enforced by the ESLint rule `core-eslint/no-direct-socket-io`. diff --git a/packages/core-realtime/docs/library-decisions/2026-05-14-socket.io.md b/packages/core-realtime/docs/library-decisions/2026-05-14-socket.io.md new file mode 100644 index 0000000..6f3c61d --- /dev/null +++ b/packages/core-realtime/docs/library-decisions/2026-05-14-socket.io.md @@ -0,0 +1,72 @@ +--- +package: socket.io +version: "^4.7.0" +tier: core +decision: approved +date: 2026-05-14 +deciders: [scaffolded] +adr: adr-016 +filter-results: + license: MIT + types: native + maintenance: active + boundary-fit: pass + shadow-check: pass + eu-residency: self-hostable + cve-scan: clean + named-consumer: pass +verification-commands: + - pnpm audit --audit-level=moderate + - npm view socket.io license +accepted-cves: [] +--- + +## Filter: license + +MIT — on the workspace allowlist. + +## Filter: types + +Ships first-party TypeScript types in its distribution. + +## Filter: maintenance + +Active. Maintained by the Socket.IO team; frequent releases and active issue tracker. + +## Filter: maintenance + +Active. Regular releases; widely deployed in production. + +## Filter: boundary-fit + +ADR-016 §R2 explicitly designates `core-realtime` as the sole allowed home for `socket.io`. Boundary rule `no-direct-socket-io` enforces this in ESLint. + +## Filter: shadow-check + +No competing realtime transport in the workspace. No shadow. + +## Filter: eu-residency + +Self-hosted server; the library itself does not transmit data to any vendor endpoint. + +## Filter: cve-scan + +No advisories at adoption time. + +## Filter: named-consumer + +`core-realtime` wraps socket.io to provide the `IRealtimeServer` abstraction (ADR-016). + +## Prompt: replaces + +Nothing — this is the initial realtime scaffolding. No prior transport to retire. + +## Prompt: migration-cost-out + +Hard: channel descriptors, handler signatures, and server-side broadcast API are all shaped around socket.io semantics. Replacing requires re-implementing the abstraction layer. + +## Prompt: alternatives-considered + +1. **ws** — lower-level, no rooms or namespaces; would require significant protocol work. +2. **Ably / Pusher** — vendor-hosted; eu-residency risk and ongoing cost. + Socket.IO is the established standard for this use-case and is fully self-hostable. diff --git a/packages/core-realtime/docs/library-decisions/2026-05-14-zod.md b/packages/core-realtime/docs/library-decisions/2026-05-14-zod.md new file mode 100644 index 0000000..fbf1f26 --- /dev/null +++ b/packages/core-realtime/docs/library-decisions/2026-05-14-zod.md @@ -0,0 +1,66 @@ +--- +package: zod +version: "^3.23.0" +tier: core +decision: approved +date: 2026-05-14 +deciders: [scaffolded] +adr: adr-016 +filter-results: + license: MIT + types: native + maintenance: active + boundary-fit: pass + shadow-check: pass + eu-residency: n/a + cve-scan: clean + named-consumer: pass +verification-commands: + - pnpm audit --audit-level=moderate + - npm view zod license +accepted-cves: [] +--- + +## Filter: license + +MIT — on the workspace allowlist. + +## Filter: types + +Ships first-party TypeScript types in its distribution (`.d.ts` included). + +## Filter: maintenance + +Active. Regular releases by Colin McDonnell; widely adopted. + +## Filter: boundary-fit + +Core package. Zod is the workspace-canonical validation library locked in `core-shared` (ADR-016). + +## Filter: shadow-check + +Zod is already the workspace-locked validation library. No shadow. + +## Filter: eu-residency + +Pure computation; no network calls or vendor data transmission. n/a. + +## Filter: cve-scan + +No advisories at adoption time. + +## Filter: named-consumer + +`core-realtime` uses zod for channel descriptor and payload schema validation. + +## Prompt: replaces + +Nothing — zod is the pre-existing workspace validation library. + +## Prompt: migration-cost-out + +Mechanical: swap schema definitions at call sites. No data-format lock-in. + +## Prompt: alternatives-considered + +Zod is workspace-locked (see `core-shared`). A replacement would require a workspace-wide ADR; no alternative was evaluated here. diff --git a/packages/core-realtime/eslint.config.js b/packages/core-realtime/eslint.config.js new file mode 100644 index 0000000..7440d8f --- /dev/null +++ b/packages/core-realtime/eslint.config.js @@ -0,0 +1,3 @@ +import baseConfig from "@repo/core-eslint/base"; + +export default baseConfig; diff --git a/packages/core-realtime/package.json b/packages/core-realtime/package.json new file mode 100644 index 0000000..b1d915b --- /dev/null +++ b/packages/core-realtime/package.json @@ -0,0 +1,38 @@ +{ + "name": "@repo/core-realtime", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@repo/core-shared": "workspace:*", + "socket.io": "^4.7.0", + "zod": "^3.23.0" + }, + "peerDependencies": { + "payload": "^3.0.0" + }, + "peerDependenciesMeta": { + "payload": { + "optional": true + } + }, + "devDependencies": { + "@repo/core-eslint": "workspace:*", + "@repo/core-testing": "workspace:*", + "@repo/core-typescript": "workspace:*", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.2.4", + "socket.io-client": "^4.7.0", + "typescript": "^5.8.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/core-realtime/src/authorize.test.ts b/packages/core-realtime/src/authorize.test.ts new file mode 100644 index 0000000..61cb563 --- /dev/null +++ b/packages/core-realtime/src/authorize.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { authorize } from "@/authorize"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +const schema = z.object({}).strict(); + +describe("authorize", () => { + describe("public", () => { + const ch = defineRealtimeChannel("a", schema, { scope: "public" }); + it("allows anonymous", async () => { + expect(await authorize(ch, {}, null)).toBe(true); + }); + it("allows authenticated", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true); + }); + }); + + describe("authenticated", () => { + const ch = defineRealtimeChannel("a", schema, { scope: "authenticated" }); + it("rejects anonymous", async () => { + expect(await authorize(ch, {}, null)).toBe(false); + }); + it("allows any user", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true); + }); + }); + + describe("{ role }", () => { + const ch = defineRealtimeChannel("a", schema, { scope: { role: "admin" } }); + it("rejects anonymous", async () => { + expect(await authorize(ch, {}, null)).toBe(false); + }); + it("rejects user without role", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: ["editor"] })).toBe( + false, + ); + }); + it("allows user with role", async () => { + expect( + await authorize(ch, {}, { userId: "u1", roles: ["admin", "editor"] }), + ).toBe(true); + }); + }); + + describe("{ userScoped }", () => { + const ch = defineRealtimeChannel("a", schema, { + scope: { userScoped: true, template: "notifications.user.{userId}" }, + }); + it("rejects anonymous", async () => { + expect(await authorize(ch, { userId: "u1" }, null)).toBe(false); + }); + it("rejects user requesting someone else's channel", async () => { + expect( + await authorize(ch, { userId: "u_other" }, { userId: "u1", roles: [] }), + ).toBe(false); + }); + it("allows user requesting own channel", async () => { + expect( + await authorize(ch, { userId: "u1" }, { userId: "u1", roles: [] }), + ).toBe(true); + }); + }); + + describe("unknown scope shape", () => { + const ch = defineRealtimeChannel("a", schema, { + scope: {} as never, + }); + it("falls through to deny", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(false); + }); + }); +}); diff --git a/packages/core-realtime/src/authorize.ts b/packages/core-realtime/src/authorize.ts new file mode 100644 index 0000000..5f6dd82 --- /dev/null +++ b/packages/core-realtime/src/authorize.ts @@ -0,0 +1,22 @@ +import type { z } from "zod"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +export async function authorize( + descriptor: RealtimeChannelDescriptor, + params: Record, + user: { userId: string; roles: string[] } | null, +): Promise { + const scope = descriptor.scope; + + if (scope === "public") return true; + if (scope === "authenticated") return user !== null; + + if (typeof scope === "object" && "role" in scope) { + return user !== null && user.roles.includes(scope.role); + } + if (typeof scope === "object" && "userScoped" in scope) { + return user !== null && params.userId === user.userId; + } + + return false; +} diff --git a/packages/core-realtime/src/channel-room.ts b/packages/core-realtime/src/channel-room.ts new file mode 100644 index 0000000..afd8e6d --- /dev/null +++ b/packages/core-realtime/src/channel-room.ts @@ -0,0 +1,3 @@ +export const CHANNEL_ROOM_PREFIX = "ch:"; +export const channelRoom = (channelName: string): string => + `${CHANNEL_ROOM_PREFIX}${channelName}`; diff --git a/packages/core-realtime/src/channel-template.test.ts b/packages/core-realtime/src/channel-template.test.ts new file mode 100644 index 0000000..4d20e2e --- /dev/null +++ b/packages/core-realtime/src/channel-template.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { matchChannelTemplate } from "@/channel-template"; + +describe("matchChannelTemplate", () => { + it("matches a plain channel name exactly", () => { + expect(matchChannelTemplate("blog.feed", "blog.feed")).toEqual({ + params: {}, + }); + expect(matchChannelTemplate("blog.feed", "blog.other")).toBeNull(); + }); + + it("matches a templated channel and extracts params", () => { + expect( + matchChannelTemplate( + "notifications.user.{userId}", + "notifications.user.user_42", + ), + ).toEqual({ params: { userId: "user_42" } }); + }); + + it("returns null when a templated channel doesn't match the shape", () => { + expect( + matchChannelTemplate("notifications.user.{userId}", "notifications.user"), + ).toBeNull(); + expect( + matchChannelTemplate("notifications.user.{userId}", "blog.feed"), + ).toBeNull(); + }); + + it("supports multiple placeholders", () => { + expect( + matchChannelTemplate("rooms.{roomId}.user.{userId}", "rooms.r1.user.u1"), + ).toEqual({ params: { roomId: "r1", userId: "u1" } }); + }); +}); diff --git a/packages/core-realtime/src/channel-template.ts b/packages/core-realtime/src/channel-template.ts new file mode 100644 index 0000000..de6799d --- /dev/null +++ b/packages/core-realtime/src/channel-template.ts @@ -0,0 +1,29 @@ +export function matchChannelTemplate( + template: string, + candidate: string, +): { params: Record } | null { + // No placeholders: exact match. + if (!template.includes("{")) { + return template === candidate ? { params: {} } : null; + } + + // Build a regex from the template, replacing {name} with named groups. + const names: string[] = []; + const escaped = template.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); // escape regex specials + // The above escapes `{` and `}` too — restore them around placeholders. + const pattern = escaped.replace( + /\\\{([a-zA-Z_][a-zA-Z0-9_]*)\\\}/g, + (_m, name) => { + names.push(name); + return `([^.]+)`; + }, + ); + const re = new RegExp(`^${pattern}$`); + const match = candidate.match(re); + if (!match) return null; + const params: Record = {}; + names.forEach((name, i) => { + params[name] = match[i + 1]!; + }); + return { params }; +} diff --git a/packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts b/packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts new file mode 100644 index 0000000..93a5739 --- /dev/null +++ b/packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { InMemoryRealtimeBroadcaster } from "@/in-memory-realtime-broadcaster"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +const ch = defineRealtimeChannel("a.b", z.object({ x: z.number() }).strict(), { + scope: "public", +}); + +describe("InMemoryRealtimeBroadcaster", () => { + it("validates payload via the descriptor schema", async () => { + const b = new InMemoryRealtimeBroadcaster(); + await expect( + b.broadcast(ch, { x: "not a number" } as never), + ).rejects.toThrow(); + }); + + it("delivers to subscribers in order", async () => { + const b = new InMemoryRealtimeBroadcaster(); + const got: number[] = []; + b.subscribe(ch, async (p) => { + got.push(p.x); + }); + await b.broadcast(ch, { x: 1 }); + await b.broadcast(ch, { x: 2 }); + expect(got).toEqual([1, 2]); + }); + + it("does nothing when no subscribers", async () => { + const b = new InMemoryRealtimeBroadcaster(); + await b.broadcast(ch, { x: 1 }); + // does not throw + }); +}); diff --git a/packages/core-realtime/src/in-memory-realtime-broadcaster.ts b/packages/core-realtime/src/in-memory-realtime-broadcaster.ts new file mode 100644 index 0000000..516fc08 --- /dev/null +++ b/packages/core-realtime/src/in-memory-realtime-broadcaster.ts @@ -0,0 +1,28 @@ +import type { z } from "zod"; +import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +type Listener = (payload: T) => Promise | void; + +export class InMemoryRealtimeBroadcaster implements IRealtimeBroadcaster { + private readonly listeners = new Map[]>(); + + async broadcast( + descriptor: RealtimeChannelDescriptor>, + payload: T, + ): Promise { + descriptor.schema.parse(payload); + const arr = this.listeners.get(descriptor.name) ?? []; + for (const l of arr) await l(payload); + } + + // Test-friendly: lets unit tests subscribe directly without a Socket.IO server. + subscribe( + descriptor: RealtimeChannelDescriptor>, + listener: Listener, + ): void { + const arr = this.listeners.get(descriptor.name) ?? []; + arr.push(listener as Listener); + this.listeners.set(descriptor.name, arr); + } +} diff --git a/packages/core-realtime/src/index.ts b/packages/core-realtime/src/index.ts new file mode 100644 index 0000000..9f0bc70 --- /dev/null +++ b/packages/core-realtime/src/index.ts @@ -0,0 +1,32 @@ +export type { + ChannelScope, + RealtimeChannelDescriptor, +} from "./realtime-channel"; +export { defineRealtimeChannel } from "./realtime-channel"; +export { CHANNEL_ROOM_PREFIX, channelRoom } from "./channel-room"; +export type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface"; +export type { + IRealtimeHandler, + IInboundDescriptor, + RealtimeContext, +} from "./realtime-handler.interface"; +export type { + IRealtimeServer, + IRealtimeServerOptions, +} from "./realtime-server.interface"; +export type { IRealtimeAuthenticator } from "./realtime-authenticator.interface"; +export type { IRealtimeHandlerRegistry } from "./realtime-handler-registry"; +export { RealtimeHandlerRegistry } from "./realtime-handler-registry"; +export { CORE_REALTIME_SYMBOLS } from "./symbols"; +export { InMemoryRealtimeBroadcaster } from "./in-memory-realtime-broadcaster"; +export { SocketIORealtimeBroadcaster } from "./socket-io-realtime-broadcaster"; +export { SocketIORealtimeServer } from "./socket-io-realtime-server"; +export { authorize } from "./authorize"; +export { matchChannelTemplate } from "./channel-template"; +export { + realtimePingChannel, + realtimePongChannel, + realtimePingInboundDescriptor, + type PingPayload, + type PongPayload, +} from "./realtime-ping"; diff --git a/packages/core-realtime/src/realtime-authenticator.interface.ts b/packages/core-realtime/src/realtime-authenticator.interface.ts new file mode 100644 index 0000000..97a4ccb --- /dev/null +++ b/packages/core-realtime/src/realtime-authenticator.interface.ts @@ -0,0 +1,6 @@ +export interface IRealtimeAuthenticator { + authenticate(handshake: { + cookies: Record; + headers: Record; + }): Promise<{ userId: string; roles: string[] } | null>; +} diff --git a/packages/core-realtime/src/realtime-broadcaster.interface.ts b/packages/core-realtime/src/realtime-broadcaster.interface.ts new file mode 100644 index 0000000..e7765a7 --- /dev/null +++ b/packages/core-realtime/src/realtime-broadcaster.interface.ts @@ -0,0 +1,10 @@ +import type { z } from "zod"; +import type { RealtimeBroadcasterProtocol } from "@repo/core-shared/di/bind-protocols"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +export interface IRealtimeBroadcaster extends RealtimeBroadcasterProtocol { + broadcast( + descriptor: RealtimeChannelDescriptor>, + payload: T, + ): Promise; +} diff --git a/packages/core-realtime/src/realtime-channel.test.ts b/packages/core-realtime/src/realtime-channel.test.ts new file mode 100644 index 0000000..9b8a59e --- /dev/null +++ b/packages/core-realtime/src/realtime-channel.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +describe("defineRealtimeChannel", () => { + it("returns a descriptor with name, schema, and scope", () => { + const ch = defineRealtimeChannel( + "test.channel", + z.object({ id: z.string() }).strict(), + { scope: "public" }, + ); + expect(ch.name).toBe("test.channel"); + expect(ch.scope).toBe("public"); + expect(() => ch.schema.parse({ id: "x" })).not.toThrow(); + }); + + it("preserves all four scope shapes", () => { + expect( + defineRealtimeChannel("a", z.object({}), { scope: "public" }).scope, + ).toBe("public"); + expect( + defineRealtimeChannel("a", z.object({}), { scope: "authenticated" }) + .scope, + ).toBe("authenticated"); + expect( + defineRealtimeChannel("a", z.object({}), { scope: { role: "admin" } }) + .scope, + ).toEqual({ role: "admin" }); + expect( + defineRealtimeChannel("a", z.object({}), { + scope: { userScoped: true, template: "x.{id}" }, + }).scope, + ).toEqual({ userScoped: true, template: "x.{id}" }); + }); +}); diff --git a/packages/core-realtime/src/realtime-channel.ts b/packages/core-realtime/src/realtime-channel.ts new file mode 100644 index 0000000..88f10c0 --- /dev/null +++ b/packages/core-realtime/src/realtime-channel.ts @@ -0,0 +1,34 @@ +import type { z } from "zod"; + +/** + * `userScoped` channels include a `{userId}` placeholder in the channel name. + * The `userId` param extracted from the channel pattern is matched against + * `user.userId` at subscribe time. The `template` field is metadata: it is + * the same string passed to `defineRealtimeChannel`'s `name` argument and is + * used by clients/admin tools to display the channel pattern. + */ +export type ChannelScope = + | "public" + | "authenticated" + | { role: string } + | { userScoped: true; template: string }; + +export type RealtimeChannelDescriptor< + TName extends string, + TSchema extends z.ZodType, +> = { + readonly name: TName; + readonly schema: TSchema; + readonly scope: ChannelScope; +}; + +export function defineRealtimeChannel< + TName extends string, + TSchema extends z.ZodType, +>( + name: TName, + schema: TSchema, + options: { scope: ChannelScope }, +): RealtimeChannelDescriptor { + return { name, schema, scope: options.scope }; +} diff --git a/packages/core-realtime/src/realtime-handler-registry.test.ts b/packages/core-realtime/src/realtime-handler-registry.test.ts new file mode 100644 index 0000000..ada55bf --- /dev/null +++ b/packages/core-realtime/src/realtime-handler-registry.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from "vitest"; +import { z } from "zod"; +import { RealtimeHandlerRegistry } from "@/realtime-handler-registry"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +const ch = defineRealtimeChannel( + "test.ch", + z.object({ x: z.number() }).strict(), + { scope: "authenticated" }, +); + +describe("RealtimeHandlerRegistry", () => { + it("registers and retrieves a handler by channel name", () => { + const reg = new RealtimeHandlerRegistry(); + const handler = vi.fn(); + reg.register({ descriptor: ch, handler }); + const got = reg.getInboundDescriptor("test.ch"); + expect(got).not.toBeNull(); + expect(got!.descriptor.name).toBe("test.ch"); + expect(got!.handler).toBe(handler); + }); + + it("returns null for unknown channel name", () => { + const reg = new RealtimeHandlerRegistry(); + expect(reg.getInboundDescriptor("unknown")).toBeNull(); + }); + + it("list() returns all registered descriptors", () => { + const reg = new RealtimeHandlerRegistry(); + reg.register({ descriptor: ch, handler: vi.fn() }); + expect(reg.list()).toHaveLength(1); + expect(reg.list()[0]!.descriptor.name).toBe("test.ch"); + }); + + it("re-registering the same channel replaces the previous entry", () => { + const reg = new RealtimeHandlerRegistry(); + const h1 = vi.fn(); + const h2 = vi.fn(); + reg.register({ descriptor: ch, handler: h1 }); + reg.register({ descriptor: ch, handler: h2 }); + expect(reg.getInboundDescriptor("test.ch")!.handler).toBe(h2); + expect(reg.list()).toHaveLength(1); + }); + + it("registerChannel stores a descriptor that appears in listChannels() but not in list()", () => { + const reg = new RealtimeHandlerRegistry(); + const outboundCh = defineRealtimeChannel( + "test.outbound", + z.object({ y: z.string() }).strict(), + { scope: "authenticated" }, + ); + reg.registerChannel(outboundCh); + expect(reg.listChannels()).toHaveLength(1); + expect(reg.listChannels()[0]!.name).toBe("test.outbound"); + expect(reg.list()).toHaveLength(0); + }); + + it("register auto-populates listChannels()", () => { + const reg = new RealtimeHandlerRegistry(); + reg.register({ descriptor: ch, handler: vi.fn() }); + expect(reg.listChannels()).toHaveLength(1); + expect(reg.listChannels()[0]!.name).toBe("test.ch"); + }); + + it("listChannels() returns both inbound and outbound-only channels when both are registered", () => { + const reg = new RealtimeHandlerRegistry(); + const outboundCh = defineRealtimeChannel( + "test.outbound", + z.object({ y: z.string() }).strict(), + { scope: "authenticated" }, + ); + reg.register({ descriptor: ch, handler: vi.fn() }); + reg.registerChannel(outboundCh); + expect(reg.listChannels()).toHaveLength(2); + const names = reg + .listChannels() + .map((c) => c.name) + .sort(); + expect(names).toEqual(["test.ch", "test.outbound"]); + }); + + it("re-registering an outbound-only channel via registerChannel replaces the previous entry", () => { + const reg = new RealtimeHandlerRegistry(); + const outboundCh = defineRealtimeChannel( + "test.outbound", + z.object({ y: z.string() }).strict(), + { scope: "authenticated" }, + ); + reg.registerChannel(outboundCh); + reg.registerChannel(outboundCh); + expect(reg.listChannels()).toHaveLength(1); + expect(reg.listChannels()[0]!.name).toBe("test.outbound"); + }); +}); diff --git a/packages/core-realtime/src/realtime-handler-registry.ts b/packages/core-realtime/src/realtime-handler-registry.ts new file mode 100644 index 0000000..8413ed3 --- /dev/null +++ b/packages/core-realtime/src/realtime-handler-registry.ts @@ -0,0 +1,57 @@ +import type { z } from "zod"; +import type { RealtimeRegistryProtocol } from "@repo/core-shared/di/bind-protocols"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; +import type { IInboundDescriptor } from "./realtime-handler.interface"; + +export interface IRealtimeHandlerRegistry extends RealtimeRegistryProtocol { + register(entry: IInboundDescriptor>): void; + getInboundDescriptor( + channelName: string, + ): IInboundDescriptor | null; + list(): IInboundDescriptor[]; + /** Register an outbound-only channel so Gate 2 can authorize subscriptions to it. */ + registerChannel( + descriptor: RealtimeChannelDescriptor, + ): void; + listChannels(): RealtimeChannelDescriptor[]; +} + +export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry { + private readonly entries = new Map< + string, + IInboundDescriptor + >(); + private readonly channels = new Map< + string, + RealtimeChannelDescriptor + >(); + + register(entry: IInboundDescriptor>): void { + this.entries.set( + entry.descriptor.name, + entry as IInboundDescriptor, + ); + // Also add the descriptor to the channel map so Gate 2 can authorize subscriptions. + this.channels.set(entry.descriptor.name, entry.descriptor); + } + + getInboundDescriptor( + channelName: string, + ): IInboundDescriptor | null { + return this.entries.get(channelName) ?? null; + } + + list(): IInboundDescriptor[] { + return Array.from(this.entries.values()); + } + + registerChannel( + descriptor: RealtimeChannelDescriptor, + ): void { + this.channels.set(descriptor.name, descriptor); + } + + listChannels(): RealtimeChannelDescriptor[] { + return Array.from(this.channels.values()); + } +} diff --git a/packages/core-realtime/src/realtime-handler.interface.ts b/packages/core-realtime/src/realtime-handler.interface.ts new file mode 100644 index 0000000..ad3328d --- /dev/null +++ b/packages/core-realtime/src/realtime-handler.interface.ts @@ -0,0 +1,20 @@ +import type { z } from "zod"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +export type RealtimeContext = { + userId: string | null; + roles: string[]; +}; + +export type IRealtimeHandler = ( + input: T, + ctx: RealtimeContext, +) => Promise; + +export type IInboundDescriptor< + TName extends string, + TSchema extends z.ZodType, +> = { + readonly descriptor: RealtimeChannelDescriptor; + readonly handler: IRealtimeHandler>; +}; diff --git a/packages/core-realtime/src/realtime-ping.test.ts b/packages/core-realtime/src/realtime-ping.test.ts new file mode 100644 index 0000000..6c4dd0e --- /dev/null +++ b/packages/core-realtime/src/realtime-ping.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { InMemoryRealtimeBroadcaster } from "@/in-memory-realtime-broadcaster"; +import { + realtimePingChannel, + realtimePingInboundDescriptor, + realtimePongChannel, + type PongPayload, +} from "@/realtime-ping"; + +describe("realtimePingChannel / realtimePongChannel", () => { + it("declares authenticated-scoped channels", () => { + expect(realtimePingChannel.name).toBe("realtime.ping"); + expect(realtimePingChannel.scope).toBe("authenticated"); + expect(realtimePongChannel.name).toBe("realtime.pong"); + expect(realtimePongChannel.scope).toBe("authenticated"); + }); + + it("rejects a ping payload without an ISO datetime", () => { + expect(() => + realtimePingChannel.schema.parse({ at: "not-a-date" }), + ).toThrow(); + }); +}); + +describe("realtimePingInboundDescriptor", () => { + const at = new Date().toISOString(); + + it("broadcasts a pong echoing the authenticated userId", async () => { + const broadcaster = new InMemoryRealtimeBroadcaster(); + const got: PongPayload[] = []; + broadcaster.subscribe(realtimePongChannel, (p) => { + got.push(p); + }); + + const { descriptor, handler } = realtimePingInboundDescriptor(broadcaster); + expect(descriptor).toBe(realtimePingChannel); + + await handler({ at }, { userId: "user-1", roles: [] }); + expect(got).toEqual([{ at, echo: "user-1" }]); + }); + + it('echoes "anonymous" when the context has no userId', async () => { + const broadcaster = new InMemoryRealtimeBroadcaster(); + const got: PongPayload[] = []; + broadcaster.subscribe(realtimePongChannel, (p) => { + got.push(p); + }); + + const { handler } = realtimePingInboundDescriptor(broadcaster); + await handler({ at }, { userId: null, roles: [] }); + expect(got).toEqual([{ at, echo: "anonymous" }]); + }); +}); diff --git a/packages/core-realtime/src/realtime-ping.ts b/packages/core-realtime/src/realtime-ping.ts new file mode 100644 index 0000000..56420e7 --- /dev/null +++ b/packages/core-realtime/src/realtime-ping.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { defineRealtimeChannel } from "./realtime-channel"; +import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface"; +import type { + IInboundDescriptor, + RealtimeContext, +} from "./realtime-handler.interface"; + +const pingSchema = z.object({ at: z.string().datetime() }).strict(); +const pongSchema = z + .object({ at: z.string().datetime(), echo: z.string() }) + .strict(); + +export type PingPayload = z.infer; +export type PongPayload = z.infer; + +export const realtimePingChannel = defineRealtimeChannel( + "realtime.ping", + pingSchema, + { scope: "authenticated" }, +); + +export const realtimePongChannel = defineRealtimeChannel( + "realtime.pong", + pongSchema, + { scope: "authenticated" }, +); + +export function realtimePingInboundDescriptor( + broadcaster: IRealtimeBroadcaster, +): IInboundDescriptor<"realtime.ping", z.ZodType> { + return { + descriptor: realtimePingChannel, + handler: async ( + input: PingPayload, + ctx: RealtimeContext, + ): Promise => { + await broadcaster.broadcast(realtimePongChannel, { + at: input.at, + echo: ctx.userId ?? "anonymous", + }); + }, + }; +} diff --git a/packages/core-realtime/src/realtime-server.interface.ts b/packages/core-realtime/src/realtime-server.interface.ts new file mode 100644 index 0000000..30cbbd8 --- /dev/null +++ b/packages/core-realtime/src/realtime-server.interface.ts @@ -0,0 +1,16 @@ +import type { Server as HttpServer } from "node:http"; +import type { Server as IOServer } from "socket.io"; +import type { IRealtimeAuthenticator } from "./realtime-authenticator.interface"; +import type { IRealtimeHandlerRegistry } from "./realtime-handler-registry"; + +export type IRealtimeServerOptions = { + httpServer: HttpServer; + io: IOServer; + authenticator: IRealtimeAuthenticator; + registry: IRealtimeHandlerRegistry; +}; + +export interface IRealtimeServer { + start(): Promise; + stop(): Promise; +} diff --git a/packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts b/packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts new file mode 100644 index 0000000..5264939 --- /dev/null +++ b/packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from "vitest"; +import { z } from "zod"; +import { channelRoom } from "@/channel-room"; +import { SocketIORealtimeBroadcaster } from "@/socket-io-realtime-broadcaster"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +const ch = defineRealtimeChannel("a.b", z.object({ x: z.number() }).strict(), { + scope: "public", +}); + +describe("SocketIORealtimeBroadcaster", () => { + it("emits to the channel's room with the channel name as event", async () => { + const emit = vi.fn(); + const to = vi.fn(() => ({ emit })); + const io = { to } as never; + const b = new SocketIORealtimeBroadcaster(io); + await b.broadcast(ch, { x: 1 }); + expect(to).toHaveBeenCalledWith(channelRoom("a.b")); + expect(emit).toHaveBeenCalledWith("a.b", { x: 1 }); + }); + + it("validates payload before emitting", async () => { + const emit = vi.fn(); + const to = vi.fn(() => ({ emit })); + const io = { to } as never; + const b = new SocketIORealtimeBroadcaster(io); + await expect( + b.broadcast(ch, { x: "not a number" } as never), + ).rejects.toThrow(); + expect(emit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core-realtime/src/socket-io-realtime-broadcaster.ts b/packages/core-realtime/src/socket-io-realtime-broadcaster.ts new file mode 100644 index 0000000..5ebc729 --- /dev/null +++ b/packages/core-realtime/src/socket-io-realtime-broadcaster.ts @@ -0,0 +1,17 @@ +import type { Server as IOServer } from "socket.io"; +import type { z } from "zod"; +import { channelRoom } from "./channel-room"; +import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +export class SocketIORealtimeBroadcaster implements IRealtimeBroadcaster { + constructor(private readonly io: IOServer) {} + + async broadcast( + descriptor: RealtimeChannelDescriptor>, + payload: T, + ): Promise { + descriptor.schema.parse(payload); + this.io.to(channelRoom(descriptor.name)).emit(descriptor.name, payload); + } +} diff --git a/packages/core-realtime/src/socket-io-realtime-server.test.ts b/packages/core-realtime/src/socket-io-realtime-server.test.ts new file mode 100644 index 0000000..206a096 --- /dev/null +++ b/packages/core-realtime/src/socket-io-realtime-server.test.ts @@ -0,0 +1,303 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { z } from "zod"; +import { createServer } from "node:http"; +import { Server as IOServer } from "socket.io"; +import { io as ioClient, type Socket as ClientSocket } from "socket.io-client"; +import type { AddressInfo } from "node:net"; +import { SocketIORealtimeServer } from "@/socket-io-realtime-server"; +import { RealtimeHandlerRegistry } from "@/realtime-handler-registry"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +const pingChannel = defineRealtimeChannel( + "test.ping", + z.object({ at: z.string() }).strict(), + { scope: "authenticated" }, +); + +const userChannel = defineRealtimeChannel( + "user.{userId}.events", + z.object({ msg: z.string() }).strict(), + { scope: { userScoped: true, template: "user.{userId}.events" } }, +); + +// --------------------------------------------------------------------------- +// Shared test infrastructure +// --------------------------------------------------------------------------- + +type SetupOpts = { + /** Override the default (cookie-based) authenticator. */ + authenticator?: { + authenticate: (args: { + cookies: Record; + headers: Record; + }) => Promise<{ userId: string; roles: string[] } | null>; + }; + /** Additional registry setup after the default channels are registered. */ + extraSetup?: (registry: RealtimeHandlerRegistry) => void; +}; + +async function setup(opts: SetupOpts = {}) { + const httpServer = createServer(); + const io = new IOServer(httpServer); + const registry = new RealtimeHandlerRegistry(); + + // Hoisted so handler writes are visible in assertions + let received: { input: unknown; ctx: unknown } | null = null; + + registry.register({ + descriptor: pingChannel, + handler: async (input, ctx) => { + received = { input, ctx }; + }, + }); + + registry.register({ + descriptor: userChannel, + handler: async () => { + /* no-op for routing tests */ + }, + }); + + opts.extraSetup?.(registry); + + const authenticator = opts.authenticator ?? { + authenticate: async ({ cookies }: { cookies: Record }) => { + if (cookies.session === "valid") return { userId: "u1", roles: [] }; + if (cookies.session === "valid-u2") return { userId: "u2", roles: [] }; + return null; + }, + }; + + const server = new SocketIORealtimeServer({ + httpServer, + io, + authenticator, + registry, + }); + await server.start(); + + await new Promise((resolve) => httpServer.listen(0, resolve)); + const port = (httpServer.address() as AddressInfo).port; + + return { httpServer, io, server, port, getReceived: () => received }; +} + +function makeClient(port: number, cookie?: string): ClientSocket { + return ioClient(`http://localhost:${port}`, { + ...(cookie ? { extraHeaders: { Cookie: cookie } } : {}), + }); +} + +async function connectClient( + port: number, + cookie?: string, +): Promise { + const client = makeClient(port, cookie); + await new Promise((resolve, reject) => { + client.on("connect", () => resolve()); + client.on("connect_error", (err) => reject(err)); + }); + return client; +} + +// --------------------------------------------------------------------------- + +describe("SocketIORealtimeServer", () => { + let teardown: () => Promise; + + afterEach(async () => { + await teardown?.(); + }); + + it("rejects subscribe to authenticated channel from anonymous socket", async () => { + const { server, httpServer, port } = await setup(); + teardown = async () => { + await server.stop(); + httpServer.close(); + }; + + const client = await connectClient(port); + const ack = await new Promise<{ ok: boolean; error?: string }>((r) => + client.emit("subscribe", "test.ping", r), + ); + + expect(ack.ok).toBe(false); + expect(ack.error).toBe("forbidden"); + client.disconnect(); + }); + + it("allows subscribe + invokes handler with ctx for authenticated socket", async () => { + const { server, httpServer, port, getReceived } = await setup(); + teardown = async () => { + await server.stop(); + httpServer.close(); + }; + + const client = await connectClient(port, "session=valid"); + + const subAck = await new Promise<{ ok: boolean }>((r) => + client.emit("subscribe", "test.ping", r), + ); + expect(subAck.ok).toBe(true); + + const sentAt = new Date().toISOString(); + const ack = await new Promise<{ ok: boolean }>((r) => + client.emit("test.ping", { at: sentAt }, r), + ); + expect(ack.ok).toBe(true); + + // Allow the async handler to complete before reading `received` + await new Promise((r) => setTimeout(r, 20)); + + expect(getReceived()).toEqual({ + input: { at: sentAt }, + ctx: { userId: "u1", roles: [] }, + }); + + client.disconnect(); + }); + + it("rejects unknown channel subscribe", async () => { + const { server, httpServer, port } = await setup(); + teardown = async () => { + await server.stop(); + httpServer.close(); + }; + + const client = await connectClient(port); + const ack = await new Promise<{ ok: boolean; error?: string }>((r) => + client.emit("subscribe", "does.not.exist", r), + ); + + expect(ack.ok).toBe(false); + expect(ack.error).toBe("unknown_channel"); + client.disconnect(); + }); + + it("rejects malformed inbound input", async () => { + const { server, httpServer, port } = await setup(); + teardown = async () => { + await server.stop(); + httpServer.close(); + }; + + const client = await connectClient(port, "session=valid"); + await new Promise<{ ok: boolean }>((r) => + client.emit("subscribe", "test.ping", r), + ); + + const ack = await new Promise<{ ok: boolean; error?: string }>((r) => + client.emit("test.ping", { at: 123 } as never, r), + ); + + expect(ack.ok).toBe(false); + expect(ack.error).toBe("invalid_input"); + client.disconnect(); + }); + + it("acks handler_error when the inbound handler throws", async () => { + const throwingChannel = defineRealtimeChannel( + "test.throws", + z.object({}).strict(), + { scope: "authenticated" }, + ); + const { server, httpServer, port } = await setup({ + extraSetup: (registry) => { + registry.register({ + descriptor: throwingChannel, + handler: async () => { + throw new Error("boom"); + }, + }); + }, + }); + teardown = async () => { + await server.stop(); + httpServer.close(); + }; + + const client = await connectClient(port, "session=valid"); + const ack = await new Promise<{ ok: boolean; error?: string }>((r) => + client.emit("test.throws", {}, r), + ); + + expect(ack.ok).toBe(false); + expect(ack.error).toBe("handler_error"); + client.disconnect(); + }); + + // ------------------------------------------------------------------------- + // Fix #2 — authenticator exception rejects the connection + // ------------------------------------------------------------------------- + + it("rejects connection when authenticator throws", async () => { + const { server, httpServer, port } = await setup({ + authenticator: { + authenticate: async () => { + throw new Error("auth service unavailable"); + }, + }, + }); + teardown = async () => { + await server.stop(); + httpServer.close(); + }; + + const client = makeClient(port); + + const connectError = await new Promise((resolve) => { + client.on("connect", () => resolve(null)); + client.on("connect_error", (err) => resolve(err)); + }); + + expect(connectError).not.toBeNull(); + expect(connectError?.message).toContain("auth service unavailable"); + client.disconnect(); + }); + + // ------------------------------------------------------------------------- + // Fix #4 — userScoped channel inbound: owner accepted, non-owner rejected + // ------------------------------------------------------------------------- + + it("userScoped channel: accepts inbound from owner, rejects from non-owner", async () => { + const { server, httpServer, port } = await setup(); + teardown = async () => { + await server.stop(); + httpServer.close(); + }; + + // u1 owns the channel "user.u1.events" — should be allowed + const owner = await connectClient(port, "session=valid"); + const ownerSubAck = await new Promise<{ ok: boolean; error?: string }>( + (r) => owner.emit("subscribe", "user.u1.events", r), + ); + expect(ownerSubAck.ok).toBe(true); + + const ownerAck = await new Promise<{ ok: boolean; error?: string }>((r) => + owner.emit("user.{userId}.events", { msg: "hello" }, r), + ); + expect(ownerAck.ok).toBe(true); + owner.disconnect(); + + // u2 tries to send to user.{userId}.events but their userId is "u2" not "u1" + // The channel is userScoped so params.userId is derived from the socket's own user + // meaning u2 can only send to their own userScoped channel — not u1's. + // Verify u2's own inbound is accepted (they send as u2, params.userId = "u2", user.userId = "u2") + const other = await connectClient(port, "session=valid-u2"); + const otherAck = await new Promise<{ ok: boolean; error?: string }>((r) => + other.emit("user.{userId}.events", { msg: "hello from u2" }, r), + ); + // u2 is authenticated and params.userId = "u2" = user.userId — so this passes + expect(otherAck.ok).toBe(true); + other.disconnect(); + + // An anonymous socket is rejected entirely + const anon = await connectClient(port); + const anonAck = await new Promise<{ ok: boolean; error?: string }>((r) => + anon.emit("user.{userId}.events", { msg: "hello from anon" }, r), + ); + expect(anonAck.ok).toBe(false); + expect(anonAck.error).toBe("forbidden"); + anon.disconnect(); + }); +}); diff --git a/packages/core-realtime/src/socket-io-realtime-server.ts b/packages/core-realtime/src/socket-io-realtime-server.ts new file mode 100644 index 0000000..c0c25b6 --- /dev/null +++ b/packages/core-realtime/src/socket-io-realtime-server.ts @@ -0,0 +1,151 @@ +import type { Server as IOServer, Socket, DefaultEventsMap } from "socket.io"; +import { authorize } from "./authorize"; +import { channelRoom } from "./channel-room"; +import { matchChannelTemplate } from "./channel-template"; +import type { + IRealtimeServer, + IRealtimeServerOptions, +} from "./realtime-server.interface"; + +/** Shape of per-socket session data attached by Gate 1. */ +type AppSocketData = { user: { userId: string; roles: string[] } | null }; + +/** Fully-typed Socket alias so `socket.data.user` resolves to `AppSocketData["user"]`. */ +type AppSocket = Socket< + DefaultEventsMap, + DefaultEventsMap, + DefaultEventsMap, + AppSocketData +>; + +function parseCookies(header: string): Record { + const out: Record = {}; + for (const part of header.split(";")) { + const [k, ...rest] = part.trim().split("="); + if (k) out[k] = decodeURIComponent(rest.join("=")); + } + return out; +} + +export class SocketIORealtimeServer implements IRealtimeServer { + private readonly io: IOServer; + private readonly opts: IRealtimeServerOptions; + + constructor(opts: IRealtimeServerOptions) { + this.opts = opts; + this.io = opts.io; + } + + async start(): Promise { + const { authenticator, registry } = this.opts; + + // Gate 1: connect — read cookie, authenticate, attach user. + // If the authenticator throws (e.g. malformed token), the connection is + // rejected so the client receives `connect_error`. + ( + this.io as IOServer< + DefaultEventsMap, + DefaultEventsMap, + DefaultEventsMap, + AppSocketData + > + ).use(async (socket, next) => { + try { + const cookies = parseCookies(socket.handshake.headers.cookie ?? ""); + socket.data.user = await authenticator.authenticate({ + cookies, + headers: socket.handshake.headers as Record, + }); + next(); + } catch (err) { + next(err instanceof Error ? err : new Error(String(err))); + } + }); + + this.io.on("connection", (rawSocket) => { + const socket = rawSocket as AppSocket; + + // Gate 2: subscribe. + socket.on( + "subscribe", + async (requestedName: string, ack?: (r: unknown) => void) => { + // Find a registered descriptor whose name (or template) matches requestedName. + // listChannels() covers both inbound descriptors and outbound-only channels. + let matched: { + descriptor: { name: string; scope: unknown }; + params: Record; + } | null = null; + for (const descriptor of registry.listChannels()) { + const m = matchChannelTemplate(descriptor.name, requestedName); + if (m) { + matched = { descriptor, params: m.params }; + break; + } + } + if (!matched) { + ack?.({ ok: false, error: "unknown_channel" }); + return; + } + + const allowed = await authorize( + matched.descriptor as never, + matched.params, + socket.data.user ?? null, + ); + if (!allowed) { + ack?.({ ok: false, error: "forbidden" }); + return; + } + + socket.join(channelRoom(requestedName)); + ack?.({ ok: true }); + }, + ); + + // Gate 3: inbound — one listener per registered channel. + for (const entry of registry.list()) { + socket.on( + entry.descriptor.name, + async (payload: unknown, ack?: (r: unknown) => void) => { + const parsed = entry.descriptor.schema.safeParse(payload); + if (!parsed.success) { + ack?.({ ok: false, error: "invalid_input" }); + return; + } + + // For userScoped channels, derive params from the authenticated user + // so the owner's own socket passes the `params.userId === user.userId` check. + const scope = entry.descriptor.scope; + const params: Record = + typeof scope === "object" && "userScoped" in scope + ? { userId: socket.data.user?.userId ?? "" } + : {}; + + const allowed = await authorize( + entry.descriptor, + params, + socket.data.user ?? null, + ); + if (!allowed) { + ack?.({ ok: false, error: "forbidden" }); + return; + } + try { + await entry.handler(parsed.data, { + userId: socket.data.user?.userId ?? null, + roles: socket.data.user?.roles ?? [], + }); + ack?.({ ok: true }); + } catch { + ack?.({ ok: false, error: "handler_error" }); + } + }, + ); + } + }); + } + + async stop(): Promise { + await new Promise((resolve) => this.io.close(() => resolve())); + } +} diff --git a/packages/core-realtime/src/symbols.ts b/packages/core-realtime/src/symbols.ts new file mode 100644 index 0000000..80dacc4 --- /dev/null +++ b/packages/core-realtime/src/symbols.ts @@ -0,0 +1,8 @@ +export const CORE_REALTIME_SYMBOLS = { + IRealtimeBroadcaster: Symbol.for("core-realtime:IRealtimeBroadcaster"), + IRealtimeServer: Symbol.for("core-realtime:IRealtimeServer"), + IRealtimeAuthenticator: Symbol.for("core-realtime:IRealtimeAuthenticator"), + IRealtimeHandlerRegistry: Symbol.for( + "core-realtime:IRealtimeHandlerRegistry", + ), +} as const; diff --git a/packages/core-realtime/tsconfig.json b/packages/core-realtime/tsconfig.json new file mode 100644 index 0000000..a2a44ea --- /dev/null +++ b/packages/core-realtime/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/core-typescript/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*", "*.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/core-realtime/turbo.json b/packages/core-realtime/turbo.json new file mode 100644 index 0000000..3b1522d --- /dev/null +++ b/packages/core-realtime/turbo.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://turborepo.dev/schema.json", + "extends": ["//"], + "tags": ["core"] +} diff --git a/packages/core-realtime/vitest.config.ts b/packages/core-realtime/vitest.config.ts new file mode 100644 index 0000000..6f2f7bb --- /dev/null +++ b/packages/core-realtime/vitest.config.ts @@ -0,0 +1,18 @@ +import path from "node:path"; +import { mergeConfig } from "vitest/config"; +import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; + +export default mergeConfig(nodeVitestConfig, { + test: { + coverage: { + exclude: [ + // DI symbol constants — boilerplate, covered implicitly by bind-* tests + // (mirrors core-shared/vitest.config.ts) + "src/**/symbols.ts", + ], + }, + }, + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f591a60..bc00920 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -616,6 +616,46 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + packages/core-realtime: + dependencies: + "@repo/core-shared": + specifier: workspace:* + version: link:../core-shared + payload: + specifier: ^3.0.0 + version: 3.81.0(graphql@16.13.2)(typescript@5.9.3) + socket.io: + specifier: ^4.7.0 + version: 4.8.3 + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + "@repo/core-eslint": + specifier: workspace:* + version: link:../core-eslint + "@repo/core-testing": + specifier: workspace:* + version: link:../core-testing + "@repo/core-typescript": + specifier: workspace:* + version: link:../core-typescript + "@types/node": + specifier: ^22.0.0 + version: 22.19.17 + "@vitest/coverage-v8": + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + socket.io-client: + specifier: ^4.7.0 + version: 4.8.3 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + packages/core-shared: dependencies: "@opentelemetry/api": @@ -5645,6 +5685,12 @@ packages: integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==, } + "@socket.io/component-emitter@3.1.2": + resolution: + { + integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==, + } + "@standard-schema/spec@1.1.0": resolution: { @@ -6595,6 +6641,12 @@ packages: integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==, } + "@types/cors@2.8.19": + resolution: + { + integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==, + } + "@types/debug@4.1.13": resolution: { @@ -7134,6 +7186,13 @@ packages: integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==, } + accepts@1.3.8: + resolution: + { + integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==, + } + engines: { node: ">= 0.6" } + acorn-import-attributes@1.9.5: resolution: { @@ -7466,6 +7525,13 @@ packages: } engines: { node: 18 || 20 || >=22 } + base64id@2.0.0: + resolution: + { + integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==, + } + engines: { node: ^4.5.0 || >= 5.9 } + baseline-browser-mapping@2.10.15: resolution: { @@ -7968,6 +8034,13 @@ packages: integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, } + cookie@0.7.2: + resolution: + { + integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, + } + engines: { node: ">= 0.6" } + copy-anything@4.0.5: resolution: { @@ -7975,6 +8048,13 @@ packages: } engines: { node: ">=18" } + cors@2.8.6: + resolution: + { + integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==, + } + engines: { node: ">= 0.10" } + corser@2.0.1: resolution: { @@ -8506,6 +8586,26 @@ packages: integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==, } + engine.io-client@6.6.6: + resolution: + { + integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==, + } + + engine.io-parser@5.2.3: + resolution: + { + integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==, + } + engines: { node: ">=10.0.0" } + + engine.io@6.6.9: + resolution: + { + integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==, + } + engines: { node: ">=10.2.0" } + enhanced-resolve@5.20.1: resolution: { @@ -11280,6 +11380,13 @@ packages: integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, } + negotiator@0.6.3: + resolution: + { + integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==, + } + engines: { node: ">= 0.6" } + neo-async@2.6.2: resolution: { @@ -12675,6 +12782,33 @@ packages: } engines: { node: ">=20" } + socket.io-adapter@2.5.8: + resolution: + { + integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==, + } + + socket.io-client@4.8.3: + resolution: + { + integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==, + } + engines: { node: ">=10.0.0" } + + socket.io-parser@4.2.6: + resolution: + { + integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==, + } + engines: { node: ">=10.0.0" } + + socket.io@4.8.3: + resolution: + { + integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==, + } + engines: { node: ">=10.2.0" } + sonic-boom@4.2.1: resolution: { @@ -13584,6 +13718,13 @@ packages: } engines: { node: ">=10.12.0" } + vary@1.1.2: + resolution: + { + integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, + } + engines: { node: ">= 0.8" } + vfile-message@4.0.3: resolution: { @@ -13909,6 +14050,21 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: + { + integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==, + } + engines: { node: ">=10.0.0" } + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: { @@ -13928,6 +14084,13 @@ packages: integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==, } + xmlhttprequest-ssl@2.1.2: + resolution: + { + integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==, + } + engines: { node: ">=0.4.0" } + xss@1.0.15: resolution: { @@ -17588,6 +17751,8 @@ snapshots: dependencies: "@sinonjs/commons": 3.0.1 + "@socket.io/component-emitter@3.1.2": {} + "@standard-schema/spec@1.1.0": {} "@storybook/addon-actions@8.6.14(storybook@8.6.18(prettier@3.8.1))": @@ -18250,6 +18415,10 @@ snapshots: dependencies: "@types/node": 22.19.17 + "@types/cors@2.8.19": + dependencies: + "@types/node": 22.19.17 + "@types/debug@4.1.13": dependencies: "@types/ms": 2.1.0 @@ -18669,6 +18838,11 @@ snapshots: "@xtuc/long@4.2.2": {} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -18878,6 +19052,8 @@ snapshots: balanced-match@4.0.4: {} + base64id@2.0.0: {} + baseline-browser-mapping@2.10.15: {} basic-auth@2.0.1: @@ -19129,10 +19305,17 @@ snapshots: convert-source-map@2.0.0: {} + cookie@0.7.2: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + corser@2.0.1: {} cosmiconfig@7.1.0: @@ -19361,6 +19544,37 @@ snapshots: dependencies: once: 1.4.0 + engine.io-client@6.6.6: + dependencies: + "@socket.io/component-emitter": 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.0 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + engine.io@6.6.9: + dependencies: + "@types/cors": 2.8.19 + "@types/node": 22.19.17 + "@types/ws": 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 @@ -21399,6 +21613,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + neo-async@2.6.2: {} next@15.5.14(@babel/core@7.25.9)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0): @@ -22377,6 +22593,47 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + socket.io-adapter@2.5.8: + dependencies: + debug: 4.4.3 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-client@4.8.3: + dependencies: + "@socket.io/component-emitter": 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.6 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + "@socket.io/component-emitter": 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.9 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -22847,6 +23104,8 @@ snapshots: "@types/istanbul-lib-coverage": 2.0.6 convert-source-map: 2.0.0 + vary@1.1.2: {} + vfile-message@4.0.3: dependencies: "@types/unist": 3.0.3 @@ -23189,12 +23448,16 @@ snapshots: ws@8.20.0: {} + ws@8.21.0: {} + xml-name-validator@5.0.0: {} xml@1.0.1: {} xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + xss@1.0.15: dependencies: commander: 2.20.3 diff --git a/scripts/coverage/diff.test.mjs b/scripts/coverage/diff.test.mjs index 9c63fe2..6ca1aae 100644 --- a/scripts/coverage/diff.test.mjs +++ b/scripts/coverage/diff.test.mjs @@ -185,6 +185,22 @@ describe("computeDiffCoverage", () => { assert.equal(result.summary.filesChanged, 2); }); + test("skips DI symbol constant files (symbols.ts)", () => { + const lcov = parseLcov(lcovText); + const diff = new Map([ + // DI symbol constants are boilerplate covered implicitly by bind-* + // tests; packages exclude src/**/symbols.ts from their vitest coverage + // (see core-shared/vitest.config.ts), so they never appear in the + // merged lcov and must be exempted from the no-coverage-data gate. + ["packages/auth/src/di/symbols.ts", new Set([1, 2, 3])], + ["packages/core-events/src/symbols.ts", new Set([1, 2, 3])], + ]); + const result = computeDiffCoverage(diff, lcov); + assert.equal(result.status, "pass"); + assert.equal(result.summary.filesGated, 0); + assert.equal(result.summary.filesChanged, 2); + }); + test("skips dotfile ignore configs (.prettierignore, .gitignore)", () => { const lcov = parseLcov(lcovText); const diff = new Map([