Files
agentic-dev/docs/superpowers/plans/2026-05-08-realtime-layer.md

2945 lines
97 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Realtime layer (Socket.IO) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a vendor-isolated bidirectional realtime layer over Socket.IO, with cookie-session auth, four channel scope kinds, a hybrid bus-bridge / direct-broadcaster model, two scaffolding generators, and a `realtime-ping` integration smoke test. Defers the observability dashboard, DB-backed roles/permissions, and multi-instance fanout to follow-up PRs.
**Architecture:** Mirrors the ADR-015 pattern. A new `@repo/core-realtime` package holds vendor-neutral interfaces (`IRealtimeBroadcaster`, `IRealtimeServer`, `IRealtimeAuthenticator`, `RealtimeChannelDescriptor`) plus a `SocketIORealtimeServer` adapter. Feature packages use the interfaces only — `socket.io` is ESLint-blocked outside `core-realtime` and the apps' custom Node servers. Auth runs at four lifecycle gates (connect / subscribe / inbound / broadcast); broadcast skips the gate because subscribe already filters who's in the room. Bus events forward to realtime channels through an explicit per-app allowlist (empty in v1; populated when the dashboard PR ships).
**Tech Stack:** TypeScript, Node 22, `socket.io` ^4, `socket.io-client` ^4, Zod, Vitest, ESLint, Turborepo, Inversify, Next.js, Payload CMS.
**Spec:** `docs/superpowers/specs/2026-05-08-realtime-design.md` — read first, especially §3 (interfaces), §7 (auth), §13 (v1 scope).
---
## Phase 0 — Read first
- [ ] **Step 1: Read the spec end-to-end**
Open `docs/superpowers/specs/2026-05-08-realtime-design.md` in your editor. The plan below maps to its sections roughly 1:1. The four checkpoints in §7, the authorize function in §7.3, the file shapes in §5, and the v1 scope in §13 are load-bearing references.
- [ ] **Step 2: Read ADR-015 (events and jobs)**
Open `docs/decisions/adr-015-events-and-jobs.md`. The realtime layer reuses the same patterns: vendor-isolated interface in core, recording test helper in core-testing, custom ESLint rule against direct vendor imports, anchor protocol for generators, span+capture sandwich at bind time. Familiarity halves the cognitive load.
- [ ] **Step 3: Skim the navigation feature for the canonical shape**
Open `packages/navigation/src/di/bind-production.ts` and `packages/navigation/src/di/bind-dev-seed.ts`. They show the post-ADR-015 binder shape `(config, tracer, logger, bus, queue)`. The realtime layer extends this signature to `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`.
---
## File map (summary)
```
packages/core-realtime/ ← new package, tag: core
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── eslint.config.js
├── turbo.json
├── AGENTS.md
└── src/
├── index.ts
├── symbols.ts
├── realtime-channel.ts
├── realtime-broadcaster.interface.ts
├── realtime-handler.interface.ts
├── realtime-server.interface.ts
├── realtime-authenticator.interface.ts
├── realtime-handler-registry.ts
├── channel-template.ts
├── authorize.ts
├── in-memory-realtime-broadcaster.ts
├── socket-io-realtime-broadcaster.ts
├── socket-io-realtime-server.ts
└── realtime-ping.ts ← proof-of-life
packages/core-testing/src/instrumentation/
└── recording-realtime-broadcaster.ts ← test helper
packages/core-eslint/
├── rules/
│ ├── no-direct-socket-io.js ← new rule
│ └── no-realtime-handler-reexport.js ← new rule
└── anchors.test.js ← extend with new anchors
packages/<feature>/src/ ← all 5 features
├── index.ts ← + // <gen:realtime-channels>
├── di/
│ ├── symbols.ts ← + // <gen:realtime-handler-symbols>
│ ├── bind-production.ts ← signature ext + // <gen:realtime-handlers>
│ └── bind-dev-seed.ts ← same
└── (eventually) realtime/ ← optional, generator-driven
apps/web-next/
├── server.ts ← NEW: replaces `next start` / `next dev`
├── package.json ← gain socket.io + scripts change
└── src/server/bind-production.ts ← + resolveRealtime() + bindRealtimeBridge()
turbo/generators/
├── config.ts ← + realtime generator
├── lib/anchor-validate.ts ← already exists from ADR-015
└── templates/realtime/
├── channel/
│ ├── channel.ts.hbs
│ └── channel.test.ts.hbs
└── handler/
├── handler.ts.hbs
└── handler.test.ts.hbs
docs/
├── decisions/adr-016-realtime-layer.md ← NEW
├── guides/realtime.md ← NEW
├── architecture/vertical-feature-spec.md ← § 13 update
├── architecture/dependency-flow.md ← bindAll diagram update
└── superpowers/specs/2026-05-08-realtime-design.md ← already exists
```
---
## Phase 1 — Scaffold `@repo/core-realtime` package
### Task 1: Create the package skeleton
**Files:**
- Create: `packages/core-realtime/package.json`
- Create: `packages/core-realtime/tsconfig.json`
- Create: `packages/core-realtime/vitest.config.ts`
- Create: `packages/core-realtime/eslint.config.js`
- Create: `packages/core-realtime/turbo.json`
- Create: `packages/core-realtime/AGENTS.md`
- Create: `packages/core-realtime/src/index.ts` (empty for now; populated in Phase 2)
- [ ] **Step 1: Write `package.json`**
```json
{
"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": {
"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-typescript": "workspace:*",
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
}
```
(No core-testing devDep — same lesson from ADR-015 Task 56. The setup file resolves via the workspace symlinks; we don't need it in deps.)
- [ ] **Step 2: Write `tsconfig.json`**
```json
{
"extends": "@repo/core-typescript/tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*", "*.config.ts"]
}
```
- [ ] **Step 3: Write `vitest.config.ts`**
```ts
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});
```
- [ ] **Step 4: Write `eslint.config.js`**
```js
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;
```
- [ ] **Step 5: Write `turbo.json` (boundary tag)**
```json
{
"$schema": "https://turborepo.dev/schema.json",
"extends": ["//"],
"tags": ["core"]
}
```
- [ ] **Step 6: Write a stub `AGENTS.md`** (will be filled in once all the components land — keep this lean for now)
```markdown
# @repo/core-realtime
Vendor-isolated realtime abstractions over Socket.IO. Feature packages depend only on the interfaces; only this package imports `socket.io`.
See `docs/superpowers/specs/2026-05-08-realtime-design.md` for the full design and `docs/decisions/adr-016-realtime-layer.md` for the rationale (added during implementation).
## 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`.
```
- [ ] **Step 7: Write `src/index.ts`** (empty barrel — populated in Phase 2)
```ts
// Public barrel — populated as components land in Phase 2.
export {};
```
- [ ] **Step 8: Verify the package installs and lints**
Run: `pnpm install && pnpm --filter @repo/core-realtime lint typecheck`
Expected: PASS (zero TS errors, zero ESLint errors).
- [ ] **Step 9: Commit**
```bash
git add packages/core-realtime/
git commit -m "chore(core-realtime): scaffold package"
```
### Task 2: Tag `@repo/core-realtime` in the boundary map
**Files:**
- Modify: `packages/core-eslint/base.js` (or wherever the boundaries plugin tag map lives — check via `grep -n "boundaries" packages/core-eslint/*.js`)
- [ ] **Step 1: Find the tag map**
Run: `grep -n "tag\|boundaries" packages/core-eslint/base.js | head`
Expected: a configuration that lists each package and its tag (`core | core-composition | feature | tooling | app`).
- [ ] **Step 2: Add `@repo/core-realtime` as `core`**
Add to the appropriate section:
```js
{
type: "core",
pattern: "packages/core-realtime",
mode: "folder",
}
```
- [ ] **Step 3: Verify**
Run: `pnpm turbo boundaries`
Expected: PASS — no new violations.
- [ ] **Step 4: Commit**
```bash
git add packages/core-eslint/
git commit -m "chore(core-eslint,turbo): tag @repo/core-realtime as core"
```
---
## Phase 2 — Interfaces + descriptors (TDD foundation)
### Task 3: Define `RealtimeChannelDescriptor` + `defineRealtimeChannel`
**Files:**
- Create: `packages/core-realtime/src/realtime-channel.ts`
- Create: `packages/core-realtime/src/realtime-channel.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
// packages/core-realtime/src/realtime-channel.test.ts
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}" });
});
});
```
- [ ] **Step 2: Run; expect FAIL**
Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL — module not found.
- [ ] **Step 3: Implement**
```ts
// packages/core-realtime/src/realtime-channel.ts
import type { z } from "zod";
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<TName, TSchema> {
return { name, schema, scope: options.scope };
}
```
- [ ] **Step 4: Run; expect PASS**
Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS — both tests green.
- [ ] **Step 5: Commit**
```bash
git add packages/core-realtime/src/realtime-channel.ts packages/core-realtime/src/realtime-channel.test.ts
git commit -m "feat(core-realtime): RealtimeChannelDescriptor + defineRealtimeChannel"
```
### Task 4: Define `IRealtimeBroadcaster` interface + symbol
**Files:**
- Create: `packages/core-realtime/src/realtime-broadcaster.interface.ts`
- Create: `packages/core-realtime/src/symbols.ts`
- [ ] **Step 1: Write the interface**
```ts
// packages/core-realtime/src/realtime-broadcaster.interface.ts
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
export interface IRealtimeBroadcaster {
broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void>;
}
```
- [ ] **Step 2: Write the symbols registry**
```ts
// packages/core-realtime/src/symbols.ts
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;
```
- [ ] **Step 3: Verify typecheck**
Run: `pnpm --filter @repo/core-realtime typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/core-realtime/src/realtime-broadcaster.interface.ts packages/core-realtime/src/symbols.ts
git commit -m "feat(core-realtime): IRealtimeBroadcaster interface + symbol registry"
```
### Task 5: Define `IRealtimeHandler` + `IInboundDescriptor` types
**Files:**
- Create: `packages/core-realtime/src/realtime-handler.interface.ts`
- [ ] **Step 1: Write the types**
```ts
// packages/core-realtime/src/realtime-handler.interface.ts
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
export type RealtimeContext = {
userId: string | null;
roles: string[];
};
export type IRealtimeHandler<T> = (input: T, ctx: RealtimeContext) => Promise<void>;
export type IInboundDescriptor<TName extends string, TSchema extends z.ZodType> = {
readonly descriptor: RealtimeChannelDescriptor<TName, TSchema>;
readonly handler: IRealtimeHandler<z.infer<TSchema>>;
};
```
- [ ] **Step 2: Verify typecheck**
Run: `pnpm --filter @repo/core-realtime typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add packages/core-realtime/src/realtime-handler.interface.ts
git commit -m "feat(core-realtime): IRealtimeHandler + IInboundDescriptor types"
```
### Task 6: Define `IRealtimeAuthenticator` interface
**Files:**
- Create: `packages/core-realtime/src/realtime-authenticator.interface.ts`
- [ ] **Step 1: Write the interface**
```ts
// packages/core-realtime/src/realtime-authenticator.interface.ts
export interface IRealtimeAuthenticator {
authenticate(handshake: {
cookies: Record<string, string>;
headers: Record<string, string>;
}): Promise<{ userId: string; roles: string[] } | null>;
}
```
- [ ] **Step 2: Verify typecheck**
Run: `pnpm --filter @repo/core-realtime typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add packages/core-realtime/src/realtime-authenticator.interface.ts
git commit -m "feat(core-realtime): IRealtimeAuthenticator interface"
```
### Task 7: Implement channel-template matcher
**Files:**
- Create: `packages/core-realtime/src/channel-template.ts`
- Create: `packages/core-realtime/src/channel-template.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
// packages/core-realtime/src/channel-template.test.ts
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" } });
});
});
```
- [ ] **Step 2: Run; expect FAIL**
Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL.
- [ ] **Step 3: Implement**
```ts
// packages/core-realtime/src/channel-template.ts
const PLACEHOLDER = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
export function matchChannelTemplate(
template: string,
candidate: string,
): { params: Record<string, string> } | 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<string, string> = {};
names.forEach((name, i) => {
params[name] = match[i + 1]!;
});
return { params };
}
```
- [ ] **Step 4: Run; expect PASS**
Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS — all four cases.
- [ ] **Step 5: Commit**
```bash
git add packages/core-realtime/src/channel-template.ts packages/core-realtime/src/channel-template.test.ts
git commit -m "feat(core-realtime): channel-template matcher (placeholder extraction)"
```
### Task 8: Implement the `authorize` function
**Files:**
- Create: `packages/core-realtime/src/authorize.ts`
- Create: `packages/core-realtime/src/authorize.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
// packages/core-realtime/src/authorize.test.ts
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);
});
});
});
```
- [ ] **Step 2: Run; expect FAIL**
Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL.
- [ ] **Step 3: Implement**
```ts
// packages/core-realtime/src/authorize.ts
import type { z } from "zod";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
export async function authorize(
descriptor: RealtimeChannelDescriptor<string, z.ZodType>,
params: Record<string, string>,
user: { userId: string; roles: string[] } | null,
): Promise<boolean> {
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;
}
```
- [ ] **Step 4: Run; expect PASS**
Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS — all 9 cases.
- [ ] **Step 5: Commit**
```bash
git add packages/core-realtime/src/authorize.ts packages/core-realtime/src/authorize.test.ts
git commit -m "feat(core-realtime): authorize function (4 scope kinds)"
```
### Task 9: Implement `RealtimeHandlerRegistry`
**Files:**
- Create: `packages/core-realtime/src/realtime-handler-registry.ts`
- Create: `packages/core-realtime/src/realtime-handler-registry.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
// packages/core-realtime/src/realtime-handler-registry.test.ts
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);
});
});
```
- [ ] **Step 2: Run; expect FAIL**
Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL.
- [ ] **Step 3: Implement**
```ts
// packages/core-realtime/src/realtime-handler-registry.ts
import type { z } from "zod";
import type { IInboundDescriptor } from "./realtime-handler.interface";
export interface IRealtimeHandlerRegistry {
register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void;
getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null;
list(): IInboundDescriptor<string, z.ZodType>[];
}
export class RealtimeHandlerRegistry implements IRealtimeHandlerRegistry {
private readonly entries = new Map<string, IInboundDescriptor<string, z.ZodType>>();
register<T>(entry: IInboundDescriptor<string, z.ZodType<T>>): void {
this.entries.set(entry.descriptor.name, entry as IInboundDescriptor<string, z.ZodType>);
}
getInboundDescriptor(channelName: string): IInboundDescriptor<string, z.ZodType> | null {
return this.entries.get(channelName) ?? null;
}
list(): IInboundDescriptor<string, z.ZodType>[] {
return Array.from(this.entries.values());
}
}
```
- [ ] **Step 4: Run; expect PASS**
Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS — all 4 cases.
- [ ] **Step 5: Commit**
```bash
git add packages/core-realtime/src/realtime-handler-registry.ts packages/core-realtime/src/realtime-handler-registry.test.ts
git commit -m "feat(core-realtime): RealtimeHandlerRegistry"
```
### Task 10: Implement `InMemoryRealtimeBroadcaster` (test/dev impl)
**Files:**
- Create: `packages/core-realtime/src/in-memory-realtime-broadcaster.ts`
- Create: `packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
// packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts
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
});
});
```
- [ ] **Step 2: Run; expect FAIL**
Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL.
- [ ] **Step 3: Implement**
```ts
// packages/core-realtime/src/in-memory-realtime-broadcaster.ts
import type { z } from "zod";
import type { IRealtimeBroadcaster } from "./realtime-broadcaster.interface";
import type { RealtimeChannelDescriptor } from "./realtime-channel";
type Listener<T> = (payload: T) => Promise<void> | void;
export class InMemoryRealtimeBroadcaster implements IRealtimeBroadcaster {
private readonly listeners = new Map<string, Listener<unknown>[]>();
async broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
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<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
listener: Listener<T>,
): void {
const arr = this.listeners.get(descriptor.name) ?? [];
arr.push(listener as Listener<unknown>);
this.listeners.set(descriptor.name, arr);
}
}
```
- [ ] **Step 4: Run; expect PASS**
Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add packages/core-realtime/src/in-memory-realtime-broadcaster.ts packages/core-realtime/src/in-memory-realtime-broadcaster.test.ts
git commit -m "feat(core-realtime): InMemoryRealtimeBroadcaster (test/dev impl)"
```
### Task 11: Define `IRealtimeServer` interface
**Files:**
- Create: `packages/core-realtime/src/realtime-server.interface.ts`
- [ ] **Step 1: Write the interface**
```ts
// packages/core-realtime/src/realtime-server.interface.ts
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<void>;
stop(): Promise<void>;
}
```
- [ ] **Step 2: Verify typecheck**
Run: `pnpm --filter @repo/core-realtime typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add packages/core-realtime/src/realtime-server.interface.ts
git commit -m "feat(core-realtime): IRealtimeServer interface"
```
### Task 12: Implement `SocketIORealtimeBroadcaster`
**Files:**
- Create: `packages/core-realtime/src/socket-io-realtime-broadcaster.ts`
- Create: `packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts`
- [ ] **Step 1: Write the failing test (with a stub Socket.IO server)**
```ts
// packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";
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("ch: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();
});
});
```
- [ ] **Step 2: Run; expect FAIL**
Run: `pnpm --filter @repo/core-realtime test`
Expected: FAIL.
- [ ] **Step 3: Implement**
```ts
// packages/core-realtime/src/socket-io-realtime-broadcaster.ts
import type { Server as IOServer } from "socket.io";
import type { z } from "zod";
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<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
this.io.to(`ch:${descriptor.name}`).emit(descriptor.name, payload);
}
}
```
- [ ] **Step 4: Run; expect PASS**
Run: `pnpm --filter @repo/core-realtime test`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add packages/core-realtime/src/socket-io-realtime-broadcaster.ts packages/core-realtime/src/socket-io-realtime-broadcaster.test.ts
git commit -m "feat(core-realtime): SocketIORealtimeBroadcaster"
```
### Task 13: Implement `SocketIORealtimeServer`
**Files:**
- Create: `packages/core-realtime/src/socket-io-realtime-server.ts`
- Create: `packages/core-realtime/src/socket-io-realtime-server.test.ts`
This is the largest single component. The test exercises the full lifecycle (connect → subscribe → inbound) against a real `socket.io-server` + `socket.io-client` pair on a transient port.
- [ ] **Step 1: Add `socket.io-client` to devDependencies**
Edit `packages/core-realtime/package.json`:
```json
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"socket.io-client": "^4.7.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
}
```
Run: `pnpm install`
- [ ] **Step 2: Write the failing integration test**
```ts
// packages/core-realtime/src/socket-io-realtime-server.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { z } from "zod";
import { createServer, type Server as HttpServer } 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" },
);
describe("SocketIORealtimeServer", () => {
let httpServer: HttpServer;
let io: IOServer;
let server: SocketIORealtimeServer;
let port: number;
beforeEach(async () => {
httpServer = createServer();
io = new IOServer(httpServer);
const registry = new RealtimeHandlerRegistry();
let received: { input: unknown; ctx: unknown } | null = null;
registry.register({
descriptor: pingChannel,
handler: async (input, ctx) => {
received = { input, ctx };
},
});
server = new SocketIORealtimeServer({
httpServer,
io,
authenticator: {
authenticate: async ({ cookies }) => {
if (cookies.session === "valid") return { userId: "u1", roles: [] };
return null;
},
},
registry,
});
await server.start();
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
port = (httpServer.address() as AddressInfo).port;
// expose received via a closure for the assertions
(server as unknown as { received: typeof received }).received = received;
});
afterEach(async () => {
await server.stop();
httpServer.close();
});
it("rejects subscribe to authenticated channel from anonymous socket", async () => {
const client = ioClient(`http://localhost:${port}`);
await new Promise<void>((r) => client.on("connect", () => r()));
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 client = ioClient(`http://localhost:${port}`, {
extraHeaders: { Cookie: "session=valid" },
});
await new Promise<void>((r) => client.on("connect", () => r()));
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);
client.disconnect();
});
it("rejects unknown channel subscribe", async () => {
const client = ioClient(`http://localhost:${port}`);
await new Promise<void>((r) => client.on("connect", () => r()));
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 client = ioClient(`http://localhost:${port}`, {
extraHeaders: { Cookie: "session=valid" },
});
await new Promise<void>((r) => client.on("connect", () => r()));
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();
});
});
```
- [ ] **Step 3: Run; expect FAIL**
Run: `pnpm --filter @repo/core-realtime test socket-io-realtime-server`
Expected: FAIL.
- [ ] **Step 4: Implement**
```ts
// packages/core-realtime/src/socket-io-realtime-server.ts
import type { Server as HttpServer } from "node:http";
import type { Server as IOServer, Socket } from "socket.io";
import { authorize } from "./authorize";
import { matchChannelTemplate } from "./channel-template";
import type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface";
function parseCookies(header: string): Record<string, string> {
const out: Record<string, string> = {};
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 httpServer: HttpServer;
private readonly io: IOServer;
private readonly opts: IRealtimeServerOptions;
constructor(opts: IRealtimeServerOptions) {
this.opts = opts;
this.httpServer = opts.httpServer;
this.io = opts.io;
}
async start(): Promise<void> {
const { authenticator, registry } = this.opts;
// Gate 1: connect — read cookie, authenticate, attach user.
this.io.use(async (socket, next) => {
const cookies = parseCookies(socket.handshake.headers.cookie ?? "");
socket.data.user = await authenticator.authenticate({
cookies,
headers: socket.handshake.headers as Record<string, string>,
});
next();
});
this.io.on("connection", (socket: Socket) => {
// Gate 2: subscribe.
socket.on("subscribe", async (requestedName: string, ack?: (r: unknown) => void) => {
// Find a registered descriptor whose name (or template) matches requestedName.
let matched: { descriptor: { name: string; scope: unknown }; params: Record<string, string> } | null = null;
for (const entry of registry.list()) {
const m = matchChannelTemplate(entry.descriptor.name, requestedName);
if (m) {
matched = { descriptor: entry.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(`ch:${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;
}
const allowed = await authorize(entry.descriptor, {}, 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<void> {
this.io.close();
}
}
```
- [ ] **Step 5: Run; expect PASS**
Run: `pnpm --filter @repo/core-realtime test socket-io-realtime-server`
Expected: PASS — all 4 cases.
- [ ] **Step 6: Commit**
```bash
git add packages/core-realtime/src/socket-io-realtime-server.ts packages/core-realtime/src/socket-io-realtime-server.test.ts packages/core-realtime/package.json pnpm-lock.yaml
git commit -m "feat(core-realtime): SocketIORealtimeServer (4 lifecycle gates)"
```
### Task 14: Public barrel
**Files:**
- Modify: `packages/core-realtime/src/index.ts`
- [ ] **Step 1: Replace the empty barrel with the full surface**
```ts
// packages/core-realtime/src/index.ts
export type { ChannelScope, RealtimeChannelDescriptor } from "./realtime-channel";
export { defineRealtimeChannel } from "./realtime-channel";
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";
```
- [ ] **Step 2: Verify**
Run: `pnpm --filter @repo/core-realtime typecheck test`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add packages/core-realtime/src/index.ts
git commit -m "feat(core-realtime): public barrel"
```
---
## Phase 3 — Recording test helper
### Task 15: `RecordingRealtimeBroadcaster` in core-testing
**Files:**
- Create: `packages/core-testing/src/instrumentation/recording-realtime-broadcaster.ts`
- Create: `packages/core-testing/src/instrumentation/recording-realtime-broadcaster.test.ts`
- Modify: `packages/core-testing/src/instrumentation/index.ts`
Critical: use the **local-type-alias pattern** from ADR-015 — do NOT import `IRealtimeBroadcaster` from `@repo/core-realtime`, that causes a build-graph cycle (core-testing is `tooling`-tagged, core-realtime is `core`-tagged, and turbo's typecheck graph would walk the dep both ways).
- [ ] **Step 1: Write the failing test**
```ts
// packages/core-testing/src/instrumentation/recording-realtime-broadcaster.test.ts
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { RecordingRealtimeBroadcaster } from "@/instrumentation/recording-realtime-broadcaster";
const ch = {
name: "test.ch" as const,
schema: z.object({ x: z.number() }).strict(),
scope: "public" as const,
};
describe("RecordingRealtimeBroadcaster", () => {
it("records every broadcast call after schema validation", async () => {
const b = new RecordingRealtimeBroadcaster();
await b.broadcast(ch, { x: 1 });
await b.broadcast(ch, { x: 2 });
expect(b.broadcasts).toEqual([
{ channel: "test.ch", payload: { x: 1 } },
{ channel: "test.ch", payload: { x: 2 } },
]);
});
it("rejects payloads that fail schema validation", async () => {
const b = new RecordingRealtimeBroadcaster();
await expect(b.broadcast(ch, { x: "wrong" } as never)).rejects.toThrow();
expect(b.broadcasts).toHaveLength(0);
});
});
```
- [ ] **Step 2: Run; expect FAIL**
Run: `pnpm --filter @repo/core-testing test recording-realtime`
Expected: FAIL.
- [ ] **Step 3: Implement (with local type aliases — DO NOT import from @repo/core-realtime)**
```ts
// packages/core-testing/src/instrumentation/recording-realtime-broadcaster.ts
// Local type aliases mirroring @repo/core-realtime's contracts. Kept inline to
// avoid a build-graph cycle between core-testing (tooling) and core-realtime (core).
// Same pattern recording-event-bus + recording-job-queue use.
import type { z } from "zod";
type RealtimeChannelDescriptor<TName extends string, TSchema extends z.ZodType> = {
readonly name: TName;
readonly schema: TSchema;
};
interface IRealtimeBroadcaster {
broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void>;
}
export class RecordingRealtimeBroadcaster implements IRealtimeBroadcaster {
readonly broadcasts: { channel: string; payload: unknown }[] = [];
async broadcast<T>(
descriptor: RealtimeChannelDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void> {
descriptor.schema.parse(payload);
this.broadcasts.push({ channel: descriptor.name, payload });
}
}
```
- [ ] **Step 4: Add to the instrumentation barrel**
Modify `packages/core-testing/src/instrumentation/index.ts`:
```ts
export { RecordingTracer, type RecordedSpan } from "./recording-tracer";
export { RecordingLogger, type RecordedCapture } from "./recording-logger";
export { RecordingJobQueue } from "./recording-job-queue";
export { RecordingEventBus } from "./recording-event-bus";
export { RecordingRealtimeBroadcaster } from "./recording-realtime-broadcaster";
```
- [ ] **Step 5: Run; expect PASS**
Run: `pnpm --filter @repo/core-testing test`
Expected: PASS — every existing test plus the 2 new cases.
- [ ] **Step 6: Verify boundaries still pass**
Run: `pnpm turbo boundaries`
Expected: PASS — no new cycle (we're using local type aliases, no actual import of `@repo/core-realtime`).
- [ ] **Step 7: Commit**
```bash
git add packages/core-testing/src/instrumentation/
git commit -m "feat(core-testing): RecordingRealtimeBroadcaster (local-type-alias pattern)"
```
---
## Phase 4 — ESLint rules
### Task 16: Write `no-direct-socket-io` rule
**Files:**
- Create: `packages/core-eslint/rules/no-direct-socket-io.js`
- Create: `packages/core-eslint/rules/no-direct-socket-io.test.js`
Mirrors `no-direct-payload-jobs` from ADR-015. Read that rule first for pattern: `packages/core-eslint/rules/no-direct-payload-jobs.js`.
- [ ] **Step 1: Write the failing test**
```js
// 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 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" }],
},
],
});
```
- [ ] **Step 2: Implement**
```js
// packages/core-eslint/rules/no-direct-socket-io.js
const ALLOWED = [
/\/packages\/core-realtime\/src\//,
/\/apps\/[^/]+\/server\.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" });
}
},
};
},
};
```
- [ ] **Step 3: Wire into the ESLint config**
Find where `no-direct-payload-jobs` is registered (probably `packages/core-eslint/base.js`). Add the same registration for `no-direct-socket-io`. Apply the rule to all packages by default.
- [ ] **Step 4: Run rule's own tests**
Run: `pnpm --filter @repo/core-eslint test`
Expected: PASS.
- [ ] **Step 5: Run workspace lint to confirm no new violations**
Run: `pnpm lint`
Expected: PASS — no feature package imports `socket.io` yet.
- [ ] **Step 6: Commit**
```bash
git add packages/core-eslint/
git commit -m "feat(core-eslint): rule no-direct-socket-io"
```
### Task 17: Write `no-realtime-handler-reexport` rule
**Files:**
- Create: `packages/core-eslint/rules/no-realtime-handler-reexport.js`
- Create: `packages/core-eslint/rules/no-realtime-handler-reexport.test.js`
Parallel to ADR-015's `no-handler-reexport`. Read that rule first; this is the same pattern targeting `realtime/handlers/` instead of `events/handlers/`.
- [ ] **Step 1: Write the failing test**
```js
// 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" }],
},
],
});
```
- [ ] **Step 2: Implement (mirror the existing `no-handler-reexport` rule)**
Read `packages/core-eslint/rules/no-handler-reexport.js` and adapt: change the path matcher from `events/handlers/` to `realtime/handlers/`, and the message ID accordingly.
- [ ] **Step 3: Wire into ESLint config**
Add to `packages/core-eslint/base.js` next to `no-handler-reexport`.
- [ ] **Step 4: Run rule's tests + workspace lint**
Run: `pnpm --filter @repo/core-eslint test && pnpm lint`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add packages/core-eslint/
git commit -m "feat(core-eslint): rule no-realtime-handler-reexport"
```
---
## Phase 5 — Anchor retrofit + CI guard
### Task 18: Retrofit anchors into `auth`
**Files:**
- Modify: `packages/auth/src/index.ts`
- Modify: `packages/auth/src/di/symbols.ts`
- Modify: `packages/auth/src/di/bind-production.ts`
- Modify: `packages/auth/src/di/bind-dev-seed.ts`
Three new anchors. Same protocol as ADR-015. The anchors are inert until generators target them.
- [ ] **Step 1: Add `// <gen:realtime-channels>` to `src/index.ts`**
Place at the end of the file (mirror where `<gen:events>` sits — should be the last line).
- [ ] **Step 2: Add `// <gen:realtime-handler-symbols>` to `src/di/symbols.ts`**
Place inside the `AUTH_SYMBOLS` object next to the existing `<gen:event-handler-symbols>` and `<gen:job-symbols>` lines.
- [ ] **Step 3: Add `// <gen:realtime-handlers>` to both `bind-production.ts` and `bind-dev-seed.ts`**
Place at the end of the function body, just before the closing `}` and after the existing `<gen:event-handlers>` and `<gen:jobs>` anchor comments.
- [ ] **Step 4: Verify**
Run: `pnpm --filter @repo/auth typecheck lint test`
Expected: PASS — anchors are comments, no behavior change.
- [ ] **Step 5: Commit**
```bash
git add packages/auth/
git commit -m "chore(auth): add // <gen:realtime-*> anchor comments"
```
### Task 19: Retrofit anchors into `blog`
Repeat Task 18 for `packages/blog/`.
Commit: `chore(blog): add // <gen:realtime-*> anchor comments`
### Task 20: Retrofit anchors into `media`
Repeat Task 18 for `packages/media/`.
Commit: `chore(media): add // <gen:realtime-*> anchor comments`
### Task 21: Retrofit anchors into `marketing-pages`
Repeat Task 18 for `packages/marketing-pages/`.
Commit: `chore(marketing-pages): add // <gen:realtime-*> anchor comments`
### Task 22: Retrofit anchors into `navigation`
Repeat Task 18 for `packages/navigation/`.
Commit: `chore(navigation): add // <gen:realtime-*> anchor comments`
### Task 23: Extend the anchor-presence CI guard
**Files:**
- Modify: `packages/core-eslint/anchors.test.js`
- [ ] **Step 1: Add new anchors to the assertion table**
Append to the `ANCHORS` map in the test file:
```js
"src/index.ts": [
"// <gen:events>",
"// <gen:realtime-channels>", // NEW
],
"src/di/symbols.ts": [
"// <gen:event-handler-symbols>",
"// <gen:job-symbols>",
"// <gen:realtime-handler-symbols>", // NEW
],
"src/di/bind-production.ts": [
"// <gen:event-handlers>",
"// <gen:jobs>",
"// <gen:realtime-handlers>", // NEW
],
"src/di/bind-dev-seed.ts": [
"// <gen:event-handlers>",
"// <gen:jobs>",
"// <gen:realtime-handlers>", // NEW
],
```
- [ ] **Step 2: Run the guard**
Run: `pnpm --filter @repo/core-eslint test`
Expected: PASS — every feature has every anchor.
- [ ] **Step 3: Commit**
```bash
git add packages/core-eslint/anchors.test.js
git commit -m "test(core-eslint): extend anchor-presence guard for realtime anchors"
```
### Task 24: Add anchors to the `feature` generator template
**Files:**
- Modify: `turbo/generators/templates/feature/src/index.ts.hbs`
- Modify: `turbo/generators/templates/feature/src/di/symbols.ts.hbs`
- Modify: `turbo/generators/templates/feature/src/di/bind-production.ts.hbs`
- Modify: `turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs`
Add the same three new anchors to the `feature` generator's templates so newly-scaffolded features ship with them. Same shape as Task 18 — exact placement, just in `.hbs` files.
- [ ] **Step 1: Edit each template**
Add the same anchor comments at the same positions (end of file / inside symbols object / end of bind function body).
- [ ] **Step 2: Verify**
Run: `cd turbo/generators && npx tsc --noEmit`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add turbo/generators/templates/feature/
git commit -m "chore(generators): add // <gen:realtime-*> anchors to feature template"
```
---
## Phase 6 — Per-feature binder signature extension
### Task 25: Extend `bindProductionAuth` and `bindDevSeedAuth` signatures
**Files:**
- Modify: `packages/auth/src/di/bind-production.ts`
- Modify: `packages/auth/src/di/bind-dev-seed.ts`
- Modify: `packages/auth/src/di/bind-dev-seed.test.ts`
- Modify: `packages/auth/package.json`
Per the spec §5.4, every feature binder grows from 5-arg `(config, tracer, logger, bus, queue)` to 7-arg `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`. Body does NOT yet use `realtime` / `realtimeRegistry` — Phase 8 generators inject usage. Accept-and-forward, mirror the ADR-015 Phase 6 pattern.
- [ ] **Step 1: Add `@repo/core-realtime` to `packages/auth/package.json` dependencies**
```json
"dependencies": {
"@repo/core-events": "workspace:*",
"@repo/core-realtime": "workspace:*",
"@repo/core-shared": "workspace:*",
...
}
```
Run: `pnpm install`
- [ ] **Step 2: Modify `bind-production.ts`**
Add imports:
```ts
import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime";
```
Extend signature:
```ts
export function bindProductionAuth(
config: SanitizedConfig,
tracer: ITracer,
logger: ILogger,
bus: IEventBus,
queue: IJobQueue,
realtime: IRealtimeBroadcaster,
realtimeRegistry: IRealtimeHandlerRegistry,
): void {
// existing body unchanged
// before the closing brace, just below the existing void bus/queue lines:
void realtime;
void realtimeRegistry;
// <gen:event-handlers>
// <gen:jobs>
// <gen:realtime-handlers>
}
```
- [ ] **Step 3: Modify `bind-dev-seed.ts` similarly**
Same imports + signature extension. Same `void` no-ops.
- [ ] **Step 4: Update `bind-dev-seed.test.ts`**
Import `RecordingRealtimeBroadcaster` and `RealtimeHandlerRegistry`, pass them through:
```ts
import { RecordingEventBus, RecordingJobQueue, RecordingRealtimeBroadcaster } from "@repo/core-testing/instrumentation";
import { RealtimeHandlerRegistry } from "@repo/core-realtime";
// inside each test:
await bindDevSeedAuth(
noop.tracer,
noop.logger,
new RecordingEventBus(),
new RecordingJobQueue(),
new RecordingRealtimeBroadcaster(),
new RealtimeHandlerRegistry(),
);
```
- [ ] **Step 5: Verify**
Run: `pnpm --filter @repo/auth typecheck lint test`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add packages/auth/ pnpm-lock.yaml
git commit -m "feat(auth): bind binders accept (realtime, realtimeRegistry) params"
```
### Task 26: Extend `blog` binder signatures
Repeat Task 25 for `packages/blog/`.
Commit: `feat(blog): bind binders accept (realtime, realtimeRegistry) params`
### Task 27: Extend `media` binder signatures
Repeat Task 25 for `packages/media/`.
Commit: `feat(media): bind binders accept (realtime, realtimeRegistry) params`
### Task 28: Extend `marketing-pages` binder signatures
Repeat Task 25 for `packages/marketing-pages/`.
Commit: `feat(marketing-pages): bind binders accept (realtime, realtimeRegistry) params`
### Task 29: Extend `navigation` binder signatures
Repeat Task 25 for `packages/navigation/`.
Commit: `feat(navigation): bind binders accept (realtime, realtimeRegistry) params`
### Task 30: Update default DI module fallbacks
**Files:**
- Modify: `packages/<feature>/src/di/module.ts` for each feature whose `module.ts` resolves use cases via `.toDynamicValue()`.
ADR-015 added `new InMemoryEventBus()` to `signUpUseCase`'s default fallback in `packages/auth/src/di/module.ts` for the same reason. Repeat for any use case that now takes `realtime` as a factory dep — but in v1, **no use case takes `realtime` yet** (Phase 8 generators inject it). Skip this task in v1; it will land alongside the first feature that uses `realtime` directly. Confirm by running `grep -rn 'signUpUseCase\|getArticlesUseCase' packages/*/src/di/module.ts` and verifying no factory call has more than 5 arguments. If any do, add `new InMemoryRealtimeBroadcaster()` per-resolution.
This task may be a no-op; that's fine. Mark it done after the verification step.
- [ ] **Verify and confirm no-op (or fix if needed)**
Run: `grep -rn 'UseCase(' packages/*/src/di/module.ts | grep -v test`
Expected: every factory call has ≤ 5 args. If any has 6+ that include `realtime`, bind a fresh `InMemoryRealtimeBroadcaster` from `@repo/core-realtime` per resolution.
- [ ] **Commit (only if any module.ts changed)**
```bash
git add packages/*/src/di/module.ts
git commit -m "feat(auth,blog,...): default DI module supplies in-memory realtime broadcaster"
```
---
## Phase 7 — Custom Node server + bindAll wiring
### Task 31: Replace `next dev` / `next start` with a custom server in apps/web-next
**Files:**
- Create: `apps/web-next/server.ts`
- Modify: `apps/web-next/package.json` (scripts, dependencies)
- [ ] **Step 1: Add socket.io to apps/web-next dependencies**
```json
"dependencies": {
"@repo/auth": "workspace:*",
"@repo/blog": "workspace:*",
"@repo/core-api": "workspace:*",
"@repo/core-cms": "workspace:*",
"@repo/core-events": "workspace:*",
"@repo/core-realtime": "workspace:*",
"@repo/core-shared": "workspace:*",
...
"socket.io": "^4.7.0"
}
```
Update scripts:
```json
"scripts": {
"build": "echo 'Next.js build requires full environment — use pnpm dev or docker'",
"dev": "tsx server.ts",
"start": "node --import tsx server.ts",
"lint": "eslint .",
...
}
```
Add `tsx` to devDependencies if not present.
Run: `pnpm install`
- [ ] **Step 2: Write `server.ts`**
```ts
// apps/web-next/server.ts
// SERVER-ONLY entry. Boots Next.js + Socket.IO on the same Node http server.
import "reflect-metadata";
import { createServer } from "node:http";
import next from "next";
import { Server as IOServer } from "socket.io";
import {
RealtimeHandlerRegistry,
SocketIORealtimeBroadcaster,
SocketIORealtimeServer,
type IRealtimeAuthenticator,
} from "@repo/core-realtime";
import { SESSION_COOKIE } from "@repo/auth";
import { authContainer } from "@repo/auth/di/container";
import { AUTH_SYMBOLS } from "@repo/auth/di/symbols";
import type { IAuthenticationService } from "@repo/auth";
import { bindAll } from "./src/server/bind-production";
const dev = process.env.NODE_ENV !== "production";
const port = Number(process.env.PORT ?? 3000);
const app = next({ dev });
const handle = app.getRequestHandler();
await app.prepare();
const httpServer = createServer((req, res) => handle(req, res));
const io = new IOServer(httpServer);
const broadcaster = new SocketIORealtimeBroadcaster(io);
const registry = new RealtimeHandlerRegistry();
// Wire features. bindAll passes broadcaster + registry into every per-feature binder.
await bindAll({ realtime: broadcaster, realtimeRegistry: registry });
const authenticator: IRealtimeAuthenticator = {
authenticate: async ({ cookies }) => {
const sessionId = cookies[SESSION_COOKIE];
if (!sessionId) return null;
const authService = authContainer.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
);
const session = await authService.validateSession(sessionId);
return session
? { userId: session.userId, roles: (session as { roles?: string[] }).roles ?? [] }
: null;
},
};
const realtimeServer = new SocketIORealtimeServer({
httpServer,
io,
authenticator,
registry,
});
await realtimeServer.start();
httpServer.listen(port, () => {
console.log(`> Ready on http://localhost:${port}`);
});
```
> **Note:** the `IAuthenticationService.validateSession` signature today returns a session with `userId`. The cast `(session as { roles?: string[] }).roles ?? []` defensively reads roles if the session shape grows them later (per spec §14). When the DB-backed roles ship, replace this with the typed accessor.
- [ ] **Step 3: Verify the server boots**
Run: `pnpm --filter @repo/web-next dev`
Expected: `> Ready on http://localhost:3000`. `curl http://localhost:3000` returns the Next.js home page. Stop with Ctrl+C.
- [ ] **Step 4: Commit**
```bash
git add apps/web-next/server.ts apps/web-next/package.json pnpm-lock.yaml
git commit -m "feat(web-next): custom Node server hosting Next.js + Socket.IO on port 3000"
```
### Task 32: Extend `bindAll()` in apps/web-next
**Files:**
- Modify: `apps/web-next/src/server/bind-production.ts`
- Modify: `apps/web-next/src/server/bind-production.test.ts`
`bindAll` currently takes no args. The custom server constructs the broadcaster + registry first (because they need the http server), then calls `bindAll(deps)`. This is a signature change.
- [ ] **Step 1: Modify the function signatures + thread realtime through every feature binder**
```ts
// apps/web-next/src/server/bind-production.ts (excerpt)
import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime";
type BindAllDeps = {
realtime: IRealtimeBroadcaster;
realtimeRegistry: IRealtimeHandlerRegistry;
};
let bound = false;
export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
if (bound) return;
bound = true;
const { tracer, logger } = resolveInstrumentation();
const { bus, queue } = await resolveEventsAndJobsProduction();
const resolvedConfig = await config;
const { realtime, realtimeRegistry } = deps;
bindProductionAuth(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
bindProductionBlog(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
bindProductionMarketingPages(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
bindProductionNavigation(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
bindProductionMedia(resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry);
bindRealtimeBridge(bus, realtime); // empty allowlist in v1, see § next task
}
export async function bindAllDevSeed(deps: BindAllDeps): Promise<void> {
if (bound) return;
bound = true;
const { tracer, logger } = resolveInstrumentation();
const { bus, queue } = resolveEventsAndJobsDevSeed();
const { realtime, realtimeRegistry } = deps;
await bindDevSeedAuth(tracer, logger, bus, queue, realtime, realtimeRegistry);
await bindDevSeedBlog(tracer, logger, bus, queue, realtime, realtimeRegistry);
await bindDevSeedMarketingPages(tracer, logger, bus, queue, realtime, realtimeRegistry);
await bindDevSeedNavigation(tracer, logger, bus, queue, realtime, realtimeRegistry);
await bindDevSeedMedia(tracer, logger, bus, queue, realtime, realtimeRegistry);
bindRealtimeBridge(bus, realtime);
}
export async function bindAll(deps: BindAllDeps): Promise<void> {
if (process.env.USE_DEV_SEED === "true") {
await bindAllDevSeed(deps);
return;
}
if (process.env.NODE_ENV === "production") {
await bindAllProduction(deps);
return;
}
await bindAllDevSeed(deps);
}
```
- [ ] **Step 2: Add the bridge stub (empty allowlist in v1)**
```ts
// apps/web-next/src/server/bind-production.ts (continued)
function bindRealtimeBridge(_bus: IEventBus, _broadcaster: IRealtimeBroadcaster): void {
// v1 ships with an empty allowlist. The dashboard PR adds the first entries here.
// Example shape (commented out so v1 doesn't try to use it):
// bus.subscribe(userSignedUpEvent, "realtime-bridge", async (payload) =>
// broadcaster.broadcast(adminEventStreamChannel, { kind: "user.signed-up", payload }),
// );
}
```
- [ ] **Step 3: Update the test file**
The existing tests already mock the per-feature binders. Update them to:
- Pass `{ realtime, realtimeRegistry }` to `bindAll`/`bindAllProduction`/`bindAllDevSeed` calls
- Use `RecordingRealtimeBroadcaster` + `RealtimeHandlerRegistry` for the recording argument
Add new test cases asserting `realtime` and `realtimeRegistry` are forwarded as the 6th + 7th positional args to each per-feature binder.
- [ ] **Step 4: Verify**
Run: `pnpm --filter @repo/web-next typecheck lint test`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add apps/web-next/src/server/
git commit -m "feat(web-next): bindAll wires (realtime, realtimeRegistry) through every feature"
```
---
## Phase 8 — Generators
### Task 33: Generator templates for `gen realtime channel`
**Files:**
- Create: `turbo/generators/templates/realtime/channel/channel.ts.hbs`
- Create: `turbo/generators/templates/realtime/channel/channel.test.ts.hbs`
- [ ] **Step 1: Write `channel.ts.hbs`**
```hbs
// packages/{{kebabCase feature}}/src/realtime/{{kebabCase channelSlug}}.channel.ts
import { z } from "zod";
import { defineRealtimeChannel } from "@repo/core-realtime";
export const {{camelCase channelSlug}}Schema = z.object({}).strict();
export type {{pascalCase channelSlug}}Payload = z.infer<typeof {{camelCase channelSlug}}Schema>;
export const {{camelCase channelSlug}}Channel = defineRealtimeChannel(
"{{kebabCase feature}}.{{kebabCase channelSlug}}",
{{camelCase channelSlug}}Schema,
{ scope: {{{scopeLiteral}}} },
);
```
- [ ] **Step 2: Write `channel.test.ts.hbs`**
```hbs
// packages/{{kebabCase feature}}/src/realtime/{{kebabCase channelSlug}}.channel.test.ts
import { describe, it, expect } from "vitest";
import {
{{camelCase channelSlug}}Channel,
{{camelCase channelSlug}}Schema,
} from "@/realtime/{{kebabCase channelSlug}}.channel";
describe("{{camelCase channelSlug}}Channel", () => {
it("has the expected wire name", () => {
expect({{camelCase channelSlug}}Channel.name).toBe(
"{{kebabCase feature}}.{{kebabCase channelSlug}}",
);
});
it("validates an empty payload (stub schema)", () => {
expect(() => {{camelCase channelSlug}}Schema.parse({})).not.toThrow();
});
});
```
- [ ] **Step 3: Commit**
```bash
git add turbo/generators/templates/realtime/channel/
git commit -m "feat(turbo-gen): templates for gen realtime channel"
```
### Task 34: Generator templates for `gen realtime handler`
**Files:**
- Create: `turbo/generators/templates/realtime/handler/handler.ts.hbs`
- Create: `turbo/generators/templates/realtime/handler/handler.test.ts.hbs`
- [ ] **Step 1: Write `handler.ts.hbs`**
```hbs
// packages/{{kebabCase feature}}/src/realtime/handlers/on-{{kebabCase channelSlug}}.handler.ts
import type { {{pascalCase channelSlug}}Payload } from "../{{kebabCase channelSlug}}.channel";
import type { RealtimeContext } from "@repo/core-realtime";
export type IOn{{pascalCase channelSlug}}Handler = ReturnType<typeof on{{pascalCase channelSlug}}Handler>;
export const on{{pascalCase channelSlug}}Handler =
() =>
async (_input: {{pascalCase channelSlug}}Payload, _ctx: RealtimeContext): Promise<void> => {
// TODO: implement the reaction. Inject deps via the factory's constructor
// and use them here. The handler is wrapped in span+capture at bind time,
// so just throwing on failure is the right shape.
};
```
- [ ] **Step 2: Write `handler.test.ts.hbs`**
```hbs
// packages/{{kebabCase feature}}/src/realtime/handlers/on-{{kebabCase channelSlug}}.handler.test.ts
import { describe, it, expect } from "vitest";
import { on{{pascalCase channelSlug}}Handler } from "@/realtime/handlers/on-{{kebabCase channelSlug}}.handler";
describe("on{{pascalCase channelSlug}}Handler", () => {
it("returns a function (factory shape)", () => {
const handler = on{{pascalCase channelSlug}}Handler();
expect(typeof handler).toBe("function");
});
it("does not throw on a valid stub input", async () => {
const handler = on{{pascalCase channelSlug}}Handler();
await expect(
handler({} as never, { userId: "u1", roles: [] }),
).resolves.toBeUndefined();
});
});
```
- [ ] **Step 3: Commit**
```bash
git add turbo/generators/templates/realtime/handler/
git commit -m "feat(turbo-gen): templates for gen realtime handler"
```
### Task 35: Wire `gen realtime` into `turbo/generators/config.ts`
**Files:**
- Modify: `turbo/generators/config.ts`
Read the existing event/job generator code in `config.ts` for the pattern. The realtime generator follows the same shape: a single `realtime` generator with a `mode` prompt that branches between `channel` and `handler`.
- [ ] **Step 1: Add the `realtime` generator definition**
Inside the existing `generator(plop)` function, after `plop.setGenerator("job", ...)`:
```ts
plop.setGenerator("realtime", {
description: "Scaffold a realtime channel descriptor or inbound handler",
prompts: [
{
type: "input",
name: "mode",
message: "Mode: channel | handler",
validate(input: string) {
if (!["channel", "handler"].includes(input)) return "Must be 'channel' or 'handler'";
return true;
},
},
{
type: "input",
name: "feature",
message: "Feature (kebab-case, must exist):",
validate(input: string) {
if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
if (!existsSync(join(process.cwd(), "packages", input, "src"))) {
return `packages/${input}/src does not exist`;
}
return true;
},
},
{
type: "input",
name: "channelSlug",
message: "Channel slug (kebab-case, e.g. 'presence-ping'):",
validate(input: string) {
if (!/^[a-z][a-z0-9-]+$/.test(input)) return "Must be kebab-case";
return true;
},
},
{
type: "input",
name: "scope",
message: "Scope (channel mode only): public | authenticated | role:NAME | user-scoped (ignored for handler mode):",
validate(input: string, answers: { mode: string }) {
if (answers.mode === "handler") return true;
if (input === "public" || input === "authenticated" || input === "user-scoped") return true;
if (/^role:[a-z][a-z0-9_-]*$/.test(input)) return true;
return "Must be public | authenticated | role:NAME | user-scoped";
},
},
],
actions(answers) {
const a = answers as { mode: string; feature: string; channelSlug: string; scope: string };
if (a.mode === "channel") return realtimeChannelActions(a);
if (a.mode === "handler") return realtimeHandlerActions(a);
throw new Error(`Unknown mode: ${a.mode}`);
},
});
```
- [ ] **Step 2: Implement `realtimeChannelActions`**
```ts
function realtimeChannelActions(a: { feature: string; channelSlug: string; scope: string }): PlopTypes.ActionType[] {
const indexPath = `packages/${a.feature}/src/index.ts`;
const scopeLiteral = renderScopeLiteral(a.scope);
return [
() => {
assertAnchors(process.cwd(), indexPath, ["// <gen:realtime-channels>"]);
return `Anchors verified in ${indexPath}`;
},
{
type: "add",
path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`,
templateFile: "templates/realtime/channel/channel.ts.hbs",
data: { ...a, scopeLiteral },
},
{
type: "add",
path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.test.ts`,
templateFile: "templates/realtime/channel/channel.test.ts.hbs",
data: { ...a, scopeLiteral },
},
{
type: "modify",
path: indexPath,
pattern: /\/\/ <gen:realtime-channels>/,
template: `// <gen:realtime-channels>\nexport {\n {{camelCase channelSlug}}Channel,\n {{camelCase channelSlug}}Schema,\n type {{pascalCase channelSlug}}Payload,\n} from "./realtime/{{kebabCase channelSlug}}.channel";`,
data: a,
},
() => printChannelNextSteps(a),
];
}
function renderScopeLiteral(scope: string): string {
if (scope === "public") return `"public"`;
if (scope === "authenticated") return `"authenticated"`;
if (scope.startsWith("role:")) return `{ role: "${scope.slice("role:".length)}" }`;
if (scope === "user-scoped") return `{ userScoped: true, template: "TODO_TEMPLATE" }`;
throw new Error(`unknown scope: ${scope}`);
}
function printChannelNextSteps(a: { feature: string; channelSlug: string }): string {
return [
"─────────────────────────────────────────────────────────────",
`Realtime channel ${a.feature}.${a.channelSlug} scaffolded.`,
"",
"Next steps (manual):",
` 1. Fill in the Zod schema in packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`,
` 2. To broadcast: inject 'realtime: IRealtimeBroadcaster' into a use case factory and call`,
` realtime.broadcast(${a.channelSlug}Channel, payload)`,
` 3. To receive client-emitted messages on this channel: pnpm turbo gen realtime`,
` and pick "handler" mode with the same channel slug.`,
` 4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
"─────────────────────────────────────────────────────────────",
].join("\n");
}
```
- [ ] **Step 3: Implement `realtimeHandlerActions`**
```ts
function realtimeHandlerActions(a: { feature: string; channelSlug: string }): PlopTypes.ActionType[] {
const symbolFile = `packages/${a.feature}/src/di/symbols.ts`;
const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`;
const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`;
return [
() => {
assertAnchors(process.cwd(), symbolFile, ["// <gen:realtime-handler-symbols>"]);
assertAnchors(process.cwd(), bindProdFile, ["// <gen:realtime-handlers>"]);
assertAnchors(process.cwd(), bindDevFile, ["// <gen:realtime-handlers>"]);
return "All required anchors present";
},
{
type: "add",
path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`,
templateFile: "templates/realtime/handler/handler.ts.hbs",
data: a,
},
{
type: "add",
path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.test.ts`,
templateFile: "templates/realtime/handler/handler.test.ts.hbs",
data: a,
},
{
type: "modify",
path: symbolFile,
pattern: /\/\/ <gen:realtime-handler-symbols>/,
template: `// <gen:realtime-handler-symbols>\n IOn${pascalCase(a.channelSlug)}Handler: Symbol.for("@repo/${a.feature}/on${pascalCase(a.channelSlug)}Handler"),`,
},
{
type: "modify",
path: bindProdFile,
pattern: /\/\/ <gen:realtime-handlers>/,
template: realtimeHandlerBindBlock(a),
},
{
type: "modify",
path: bindDevFile,
pattern: /\/\/ <gen:realtime-handlers>/,
template: realtimeHandlerBindBlock(a),
},
() => printHandlerNextSteps(a),
];
}
function realtimeHandlerBindBlock(a: { feature: string; channelSlug: string }): string {
const handlerFn = `on${pascalCase(a.channelSlug)}Handler`;
const channelConst = `${camel(a.channelSlug)}Channel`;
return `// <gen:realtime-handlers>
// ${handlerFn} — generated by gen realtime handler. Edit the handler file (not this block).
const wrapped${pascalCase(a.channelSlug)} = withSpan(
tracer,
{ name: "${a.feature}.${handlerFn}", op: "realtime-handler" },
withCapture(
logger,
{ feature: "${a.feature}", layer: "realtime-handler", name: "${a.feature}.${handlerFn}" },
${handlerFn}(),
),
);
realtimeRegistry.register({ descriptor: ${channelConst}, handler: wrapped${pascalCase(a.channelSlug)} });`;
}
function printHandlerNextSteps(a: { feature: string; channelSlug: string }): string {
return [
"─────────────────────────────────────────────────────────────",
`Realtime handler on-${a.channelSlug} scaffolded in ${a.feature}.`,
"",
"Next steps (manual):",
` 1. Implement the handler body in packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`,
` 2. Add the imports to bind-production.ts AND bind-dev-seed.ts:`,
` import { ${camel(a.channelSlug)}Channel } from "../realtime/${a.channelSlug}.channel";`,
` import { on${pascalCase(a.channelSlug)}Handler } from "../realtime/handlers/on-${a.channelSlug}.handler";`,
` 3. If the handler needs deps, extend the factory signature and pass them in the bind block.`,
` 4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`,
"─────────────────────────────────────────────────────────────",
].join("\n");
}
```
- [ ] **Step 4: Smoke test channel mode**
Run from project root:
```bash
pnpm turbo gen realtime --args channel auth ping authenticated
```
Verify:
- `packages/auth/src/realtime/ping.channel.ts` exists
- `packages/auth/src/realtime/ping.channel.test.ts` exists
- `packages/auth/src/index.ts` re-exports the channel at the anchor
- `pnpm --filter @repo/auth typecheck lint test` PASSES
Then revert the smoke output:
```bash
rm -rf packages/auth/src/realtime/
git restore packages/auth/src/index.ts
```
- [ ] **Step 5: Smoke test handler mode**
Re-create the channel + run handler:
```bash
pnpm turbo gen realtime --args channel auth ping authenticated
pnpm turbo gen realtime --args handler auth ping
```
Verify (and add the manual imports to bind files as per the printed instructions, then revert).
- [ ] **Step 6: Commit (after both smoke tests pass + revert)**
```bash
git add turbo/generators/config.ts
git commit -m "feat(turbo-gen): wire gen realtime (channel + handler modes)"
```
---
## Phase 9 — Proof-of-life: realtime-ping
### Task 36: Implement `realtime-ping` channel pair inside core-realtime
**Files:**
- Create: `packages/core-realtime/src/realtime-ping.ts`
The proof-of-life lives inside core-realtime itself, not in a feature, so the integration test in apps/web-next can drive it without polluting any feature's surface.
- [ ] **Step 1: Implement**
```ts
// packages/core-realtime/src/realtime-ping.ts
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<typeof pingSchema>;
export type PongPayload = z.infer<typeof pongSchema>;
export const realtimePingChannel = defineRealtimeChannel(
"realtime.ping",
pingSchema,
{ scope: "authenticated" },
);
export const realtimePongChannel = defineRealtimeChannel(
"realtime.pong",
pongSchema,
{ scope: "authenticated" },
);
export function realtimePingInboundDescriptor(
broadcaster: IRealtimeBroadcaster,
): IInboundDescriptor<typeof realtimePingChannel.name, typeof pingSchema> {
return {
descriptor: realtimePingChannel,
handler: async (input: PingPayload, ctx: RealtimeContext): Promise<void> => {
await broadcaster.broadcast(realtimePongChannel, {
at: input.at,
echo: ctx.userId ?? "anonymous",
});
},
};
}
```
- [ ] **Step 2: Add to the public barrel**
```ts
// packages/core-realtime/src/index.ts (append)
export {
realtimePingChannel,
realtimePongChannel,
realtimePingInboundDescriptor,
type PingPayload,
type PongPayload,
} from "./realtime-ping";
```
- [ ] **Step 3: Verify typecheck**
Run: `pnpm --filter @repo/core-realtime typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/core-realtime/src/realtime-ping.ts packages/core-realtime/src/index.ts
git commit -m "feat(core-realtime): realtime-ping proof-of-life channel pair"
```
### Task 37: Register `realtime-ping` in apps/web-next bindAll
**Files:**
- Modify: `apps/web-next/src/server/bind-production.ts` (the bindAll variants)
- [ ] **Step 1: Register the inbound descriptor with the realtime registry**
In both `bindAllProduction` and `bindAllDevSeed`, after the per-feature binders run:
```ts
import { realtimePingInboundDescriptor } from "@repo/core-realtime";
// inside bindAllProduction and bindAllDevSeed, after feature binders, before bridge:
realtimeRegistry.register(realtimePingInboundDescriptor(realtime));
```
- [ ] **Step 2: Add an env-gate for production**
```ts
if (process.env.REALTIME_PING_DISABLED !== "true") {
realtimeRegistry.register(realtimePingInboundDescriptor(realtime));
}
```
This lets ops disable the smoke channel post-merge if they want a leaner production surface.
- [ ] **Step 3: Verify**
Run: `pnpm --filter @repo/web-next typecheck lint test`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/web-next/src/server/bind-production.ts
git commit -m "feat(web-next): register realtime-ping inbound (env-gateable)"
```
### Task 38: Integration test: realtime-ping over real Socket.IO
**Files:**
- Create: `apps/web-next/src/__tests__/realtime-ping.test.ts`
This is the v1 proof-of-life — equivalent to ADR-015's `sign-up-welcome-email.test.ts`.
- [ ] **Step 1: Add `socket.io-client` to apps/web-next devDependencies**
```json
"devDependencies": {
...
"socket.io-client": "^4.7.0",
}
```
Run: `pnpm install`
- [ ] **Step 2: Write the test**
```ts
// apps/web-next/src/__tests__/realtime-ping.test.ts
// e2e proof-of-life: connect → subscribe → emit ping → receive pong via the
// production-shaped binder + Socket.IO server, with cookie-session auth
// against a seeded test session.
import "reflect-metadata";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createServer, type Server as HttpServer } from "node:http";
import { Server as IOServer } from "socket.io";
import { io as ioClient } from "socket.io-client";
import type { AddressInfo } from "node:net";
import {
RealtimeHandlerRegistry,
SocketIORealtimeBroadcaster,
SocketIORealtimeServer,
realtimePingInboundDescriptor,
type IRealtimeAuthenticator,
} from "@repo/core-realtime";
describe("e2e: realtime-ping exercises all four checkpoints", () => {
let httpServer: HttpServer;
let realtimeServer: SocketIORealtimeServer;
let port: number;
beforeEach(async () => {
httpServer = createServer();
const io = new IOServer(httpServer);
const broadcaster = new SocketIORealtimeBroadcaster(io);
const registry = new RealtimeHandlerRegistry();
registry.register(realtimePingInboundDescriptor(broadcaster));
const authenticator: IRealtimeAuthenticator = {
authenticate: async ({ cookies }) =>
cookies.session === "valid-session"
? { userId: "user_test", roles: [] }
: null,
};
realtimeServer = new SocketIORealtimeServer({
httpServer,
io,
authenticator,
registry,
});
await realtimeServer.start();
await new Promise<void>((r) => httpServer.listen(0, r));
port = (httpServer.address() as AddressInfo).port;
});
afterEach(async () => {
await realtimeServer.stop();
await new Promise<void>((r) => httpServer.close(() => r()));
});
it("authenticated client gets pong after ping", async () => {
const client = ioClient(`http://localhost:${port}`, {
extraHeaders: { Cookie: "session=valid-session" },
});
await new Promise<void>((r) => client.on("connect", () => r()));
const subAck = await new Promise<{ ok: boolean }>((r) =>
client.emit("subscribe", "realtime.pong", r),
);
expect(subAck.ok).toBe(true);
const pongs: { at: string; echo: string }[] = [];
client.on("realtime.pong", (p) => pongs.push(p));
const sentAt = "2026-05-08T12:00:00.000Z";
const pingAck = await new Promise<{ ok: boolean }>((r) =>
client.emit("realtime.ping", { at: sentAt }, r),
);
expect(pingAck.ok).toBe(true);
// Pong arrives synchronously after handler runs.
await new Promise<void>((r) => setImmediate(r));
expect(pongs).toHaveLength(1);
expect(pongs[0]).toEqual({ at: sentAt, echo: "user_test" });
client.disconnect();
});
it("anonymous client cannot subscribe to pong (authenticated scope)", async () => {
const client = ioClient(`http://localhost:${port}`);
await new Promise<void>((r) => client.on("connect", () => r()));
const subAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
client.emit("subscribe", "realtime.pong", r),
);
expect(subAck.ok).toBe(false);
expect(subAck.error).toBe("forbidden");
client.disconnect();
});
});
```
- [ ] **Step 3: Run; expect PASS**
Run: `pnpm --filter @repo/web-next test realtime-ping`
Expected: PASS — both cases.
- [ ] **Step 4: Commit**
```bash
git add apps/web-next/src/__tests__/realtime-ping.test.ts apps/web-next/package.json pnpm-lock.yaml
git commit -m "test(web-next): e2e realtime-ping (4 checkpoints)"
```
---
## Phase 10 — Documentation
### Task 39: Write `docs/decisions/adr-016-realtime-layer.md`
**Files:**
- Create: `docs/decisions/adr-016-realtime-layer.md`
- [ ] **Step 1: Read the format of recent ADRs**
Open `docs/decisions/adr-015-events-and-jobs.md`. Same Status / Context / Decision / Alternatives / Consequences / Notes-from-execution / Out-of-scope / Related sections.
- [ ] **Step 2: Write ADR-016**
Distill the spec's §1, §2 (rules), §3 (package), §6 (topology), §7 (auth), §8 (hybrid), §13 (v1 scope). Mirror the prose density of ADR-015. Each rule (R0/R1/R2) gets one paragraph. Cross-link the spec at `docs/superpowers/specs/2026-05-08-realtime-design.md`.
- [ ] **Step 3: Commit**
```bash
git add docs/decisions/adr-016-realtime-layer.md
git commit -m "docs(adr-016): realtime layer (Socket.IO)"
```
### Task 40: Write `docs/guides/realtime.md`
**Files:**
- Create: `docs/guides/realtime.md`
- [ ] **Step 1: Read the events-and-jobs guide for the format**
Open `docs/guides/events-and-jobs.md`. The realtime guide follows the same shape: three sections (declare a channel, broadcast from a use case, receive client messages with a handler), each with full code samples.
- [ ] **Step 2: Write the guide**
Cover:
- "Declare a channel" — runs `gen realtime channel`, fills the schema, picks scope
- "Broadcast from a use case" — adds `realtime: IRealtimeBroadcaster` to the factory, calls `realtime.broadcast(channel, payload)`
- "Receive client messages" — runs `gen realtime handler`, fills handler body, adds imports to bind files
- Cross-reference: ADR-016, the design spec, and `docs/decisions/adr-015-events-and-jobs.md` for the bridge pattern
- [ ] **Step 3: Commit**
```bash
git add docs/guides/realtime.md
git commit -m "docs(guide): realtime walkthrough"
```
### Task 41: Update `AGENTS.md`
**Files:**
- Modify: `AGENTS.md`
- [ ] **Step 1: Add a new section under Per-Package Conventions**
Insert after "Cross-feature events and background jobs (Plan 10, ADR-015)" — same shape, three-rule summary:
```markdown
### Realtime layer (ADR-016)
Three rules:
- **R0:** Realtime is for state delivery, not for replacing tRPC. Persistent operations with request/response semantics belong on tRPC procedures.
- **R1:** Channel descriptors are exported; handlers are private. A feature's `realtime/<name>.channel.ts` is re-exported from the package root barrel; `realtime/handlers/*.handler.ts` is wired only in the feature's own bind-* files (ESLint-enforced via `no-realtime-handler-reexport`).
- **R2:** `socket.io` lives in one package only. Feature packages MUST NOT `import "socket.io"` or `import "socket.io-client"`. Allowlist: `packages/core-realtime/src/socket-io-*.ts` + `apps/*/server.ts`. ESLint rule `no-direct-socket-io` enforces this.
`@repo/core-realtime` provides `IRealtimeBroadcaster` (server → client), `IRealtimeHandlerRegistry` (client → server), and the `SocketIORealtimeServer` adapter. `apps/web-next/server.ts` replaces `next start`/`next dev` with a custom Node http server hosting both Next.js and Socket.IO on port 3000.
Use the generators: `pnpm turbo gen realtime channel`, `pnpm turbo gen realtime handler`. They insert at three fixed `// <gen:realtime-*>` anchor comments per feature.
See `docs/guides/realtime.md` and `docs/decisions/adr-016-realtime-layer.md`.
```
- [ ] **Step 2: Add a row to the Specification & Guides list**
```markdown
- **Realtime Guide** — `docs/guides/realtime.md` — declare channels, broadcast, receive
```
- [ ] **Step 3: Commit**
```bash
git add AGENTS.md
git commit -m "docs(agents): realtime layer section + guide reference"
```
### Task 42: Update `CLAUDE.md`
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: Update Quick Start**
Add to the command block:
```bash
pnpm turbo gen realtime # Scaffold a realtime channel or inbound handler
```
- [ ] **Step 2: Update Read First**
Add: `- docs/guides/realtime.md — Socket.IO channels, broadcasts, handlers`
- [ ] **Step 3: Update Key Conventions**
Add three short bullets matching R0/R1/R2 from the AGENTS.md section above.
- [ ] **Step 4: Commit**
```bash
git add CLAUDE.md
git commit -m "docs(claude): realtime generator + R0/R1/R2 conventions"
```
### Task 43: Update `docs/guides/scaffolding-a-feature.md`
**Files:**
- Modify: `docs/guides/scaffolding-a-feature.md`
- [ ] **Step 1: Extend the "Adding events and jobs" section to cover realtime**
Append `pnpm turbo gen realtime channel` and `pnpm turbo gen realtime handler` to the generator list. Note that newly-generated features include the three new realtime anchors.
- [ ] **Step 2: Commit**
```bash
git add docs/guides/scaffolding-a-feature.md
git commit -m "docs(scaffolding): realtime generator reference"
```
### Task 44: Update `docs/architecture/dependency-flow.md` and `docs/architecture/vertical-feature-spec.md`
**Files:**
- Modify: `docs/architecture/dependency-flow.md`
- Modify: `docs/architecture/vertical-feature-spec.md`
- [ ] **Step 1: Update the bindAll diagram**
Add a `resolveRealtime` step + extend the binder signature line in `dependency-flow.md` to `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`.
- [ ] **Step 2: Update vertical-feature-spec.md § 4 (optional folders)**
Add `realtime/` and `realtime/handlers/` to the optional-folder list, with a cross-reference to ADR-016.
- [ ] **Step 3: Commit**
```bash
git add docs/architecture/
git commit -m "docs(architecture): realtime layer in dependency-flow + vertical-feature-spec"
```
---
## Phase 11 — Final verification
### Task 45: Whole-monorepo green check
- [ ] **Step 1: Full lint**
Run: `pnpm lint`
Expected: PASS — including the two new ESLint rules, the anchor-presence guard, and all rule tests. Pre-existing warnings (NEXT_RUNTIME, etc.) unchanged.
- [ ] **Step 2: Full typecheck**
Run: `pnpm typecheck`
Expected: PASS.
- [ ] **Step 3: Full test**
Run: `pnpm test`
Expected: PASS — including `core-realtime`'s ~20 test cases, the recording-realtime-broadcaster tests, the anchor-presence guard, and the realtime-ping integration test.
- [ ] **Step 4: Boundary check**
Run: `pnpm turbo boundaries`
Expected: PASS — `core-realtime` properly tagged, no new violations.
- [ ] **Step 5: Smoke run of generators**
```bash
cp -r ~/Documents/Projects/template-vertical /tmp/realtime-final-test && cd /tmp/realtime-final-test && pnpm install
pnpm turbo gen realtime --args channel blog test-channel public
pnpm turbo gen realtime --args handler blog test-channel
# manually add the imports printed by the handler generator
pnpm --filter @repo/blog typecheck test lint
```
Expected: PASS.
```bash
rm -rf /tmp/realtime-final-test
```
- [ ] **Step 6: Manual dev-server smoke**
```bash
pnpm --filter @repo/web-next dev
# In another terminal:
curl -i http://localhost:3000
# Stop the dev server.
```
Expected: HTTP 200 from Next.js, no Socket.IO errors in the dev-server log.
- [ ] **Step 7: Final commit (only if any fixes were needed)**
If steps 16 surfaced a fix, commit it. Otherwise no-op — the plan is done.
---
## Known follow-ups — out of v1 plan scope
Tracked here so they aren't forgotten:
1. **Observability dashboard.** The first concrete consumer of the bridge. Adds an `admin-realtime` feature package with a `{ role: "admin" }` channel that streams every bus event to connected admin tabs. Includes a Next.js admin route + filter UI. Adds the first allowlist entry to `bindRealtimeBridge`.
2. **DB-backed roles + permissions in the auth feature.** Per spec § 14. Additive: `IRealtimeAuthenticator.authenticate()` widens its return type, scope union grows new kinds (`{ permission }`, `{ permissions, mode }`, `{ check }`), no changes to `core-realtime`.
3. **Generic core-package generator.** User-flagged on 2026-05-08. A `turbo gen core-package` with template variants (interface-and-adapters / utility / policy-only) so future core packages don't need to be hand-built. Saved as project memory; brainstorm separately.
4. **Production-mode e2e against real Postgres-backed Payload session.** § 10.3's test runs against a stub authenticator. A parallel test that boots Payload and uses a real `validateSession` would prove the full cookie-auth chain.
5. **Multi-instance fanout.** When `apps/web-next` scales horizontally, broadcasts in one process don't reach sockets connected to another. Solved by Socket.IO's Redis adapter (sticky sessions + Redis pub/sub).
6. **Custom Node server for `cms` and `web-tanstack`.** v1 only converts `web-next`. Lands when those apps actually need realtime.
7. **Generator for outbound broadcasts.** Currently a manual edit (add `realtime: IRealtimeBroadcaster` to a use case factory). If the pattern proliferates, a `gen realtime broadcast <feature> <channel-slug>` could automate it.
---
## Self-review check
This plan was self-reviewed against the spec on the date of writing. Key points verified:
- Every spec section maps to one or more tasks (§ 3 → Phase 1 + 2, § 5 → Phase 6 + 8, § 6 → Phase 7, § 7 → Tasks 7+8+13, § 8 → Task 32 (bridge stub), § 9 → Phase 4 (ESLint), § 10 → Phase 3 + 9, § 11 → Phase 5 + 8, § 12 → Phase 9, § 13 → covered by per-task scope, § 14 → ADR-016 only, deferred otherwise).
- All file paths are absolute or repo-relative; no "TBD".
- Tests precede implementation in every behavior-adding task (R-style TDD per ADR-011).
- Commit cadence: one commit per task, ~45 commits total.
- Anchor protocol identical to ADR-015 (3 anchors per feature, same kind of CI guard extension).
- Span+capture sandwich tags (`op: "realtime-handler"`, `layer: "realtime-handler"`) appear consistently in Task 35's generator output.
- `RecordingRealtimeBroadcaster` uses the local-type-alias pattern (lessons from ADR-015 Task 56 codified into Task 15).
- v1 scope (out of plan items) explicitly excludes the dashboard, DB-backed roles, multi-instance fanout, and the generic core-package generator.
- Type consistency: `RealtimeContext` shape matches across handler factory definition, the gate-3 dispatch site in `SocketIORealtimeServer`, and the generator template.