From 081c133f65d72f61b75f91ead64380300a03cf3c Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:10:45 +0200 Subject: [PATCH] feat(identity): reload the identity map without a server restart The server read T3_IDENTITY_MAP_PATH once at layer construction, so adding or removing a person meant restarting t3code-server. The map is now re-checked on a 60s TTL and applies in place. Polls rather than watches: the map arrives over virtiofs from the host, where inotify propagation is not something to depend on. An ino/size/mtime fingerprint keeps an untouched file from being re-parsed every TTL. Two safety rules, because a reload can now fail in production where startup could not: - A re-read that yields no people never disables an already-enabled map. `enabled === false` turns the operate gate off entirely, so a truncated or unparseable file would have failed open. The last good map keeps serving, marked unhealthy, and the next TTL retries. - requireOperateClaim no longer deletes the persisted claim of a person who is absent from the map. A half-written file can still parse as a valid map with a subset of people, and that delete is not reversible. Refusing operate is the gate; membership is re-checked on every operate, so a stale row grants nothing. Startup behaviour is unchanged: a missing or empty map still means the feature is off, and removing T3_IDENTITY_MAP_PATH remains the way to disable the gate. Co-Authored-By: Claude Opus 5 (1M context) --- .../identity/IdentityService.reload.test.ts | 124 ++++++++++++ apps/server/src/identity/IdentityService.ts | 178 +++++++++++++++--- 2 files changed, 274 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/identity/IdentityService.reload.test.ts diff --git a/apps/server/src/identity/IdentityService.reload.test.ts b/apps/server/src/identity/IdentityService.reload.test.ts new file mode 100644 index 000000000000..682f8bcb6a39 --- /dev/null +++ b/apps/server/src/identity/IdentityService.reload.test.ts @@ -0,0 +1,124 @@ +// @effect-diagnostics nodeBuiltinImport:off +// Staging a real file on disk is the point of these tests: they cover the +// fs-backed reload path, so they use node:fs directly like IdentityService does. +import { AuthSessionId, IdentityUsername } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Duration from "effect/Duration"; +import * as TestClock from "effect/testing/TestClock"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as IdentityService from "./IdentityService.ts"; + +const TTL = IdentityService.IDENTITY_MAP_RELOAD_TTL_MS; + +const mapYaml = (usernames: ReadonlyArray) => + ["people:", ...usernames.map((u) => ` "id-${u}":\n username: ${u}\n name: ${u}`)].join( + "\n", + ); + +/** + * The source fingerprints on ino/size/mtime, so a rewrite inside the same clock + * millisecond could otherwise look unchanged. Stamp a strictly increasing mtime + * rather than reading wall-clock time (which the Effect lint rules disallow). + */ +let mtimeSeconds = 1_700_000_000; +function touch(file: string): void { + mtimeSeconds += 10; + NodeFS.utimesSync(file, mtimeSeconds, mtimeSeconds); +} + +/** Writes the map and points T3_IDENTITY_MAP_PATH at it; returns the path. */ +function stageMap(usernames: ReadonlyArray): string { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "identity-map-")); + const file = NodePath.join(dir, "identity-map.yaml"); + NodeFS.writeFileSync(file, mapYaml(usernames), "utf8"); + touch(file); + process.env.T3_IDENTITY_MAP_PATH = file; + return file; +} + +function rewrite(file: string, usernames: ReadonlyArray): void { + NodeFS.writeFileSync(file, mapYaml(usernames), "utf8"); + touch(file); +} + +describe("identity map reload", () => { + it.effect("applies an added person after the TTL, without a restart", () => + Effect.gen(function* () { + const file = stageMap(["patroza"]); + const source = IdentityService.makeFileSourceForTest(); + + const before = yield* source.current; + expect(before.people.map((p) => p.username)).toEqual(["patroza"]); + + rewrite(file, ["patroza", "micseg"]); + + // Still cached until the TTL elapses. + yield* TestClock.adjust(Duration.millis(TTL - 1)); + expect((yield* source.current).people).toHaveLength(1); + + yield* TestClock.adjust(Duration.millis(2)); + const after = yield* source.current; + expect(after.people.map((p) => p.username)).toEqual(["patroza", "micseg"]); + expect(after.enabled).toBe(true); + expect(after.healthy).toBe(true); + }), + ); + + it.effect("keeps the last good map when a re-read yields no people", () => + Effect.gen(function* () { + const file = stageMap(["patroza", "micseg"]); + const source = IdentityService.makeFileSourceForTest(); + expect((yield* source.current).people).toHaveLength(2); + + // Simulates a truncated or half-staged file. + NodeFS.writeFileSync(file, "", "utf8"); + touch(file); + + yield* TestClock.adjust(Duration.millis(TTL + 1)); + const degraded = yield* source.current; + expect(degraded.people).toHaveLength(2); + // The gate must stay on: enabled === false would turn it off entirely. + expect(degraded.enabled).toBe(true); + expect(degraded.healthy).toBe(false); + + // Recovers once the file is readable again. + rewrite(file, ["patroza", "micseg", "enricopolanski"]); + yield* TestClock.adjust(Duration.millis(TTL + 1)); + const recovered = yield* source.current; + expect(recovered.people).toHaveLength(3); + expect(recovered.healthy).toBe(true); + }), + ); + + it.effect("refuses operate for a removed person without deleting their claim", () => { + // Must be staged before the layer is built: the source loads at construction. + const file = stageMap(["patroza", "micseg"]); + return Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000cc"); + + yield* identity.claim(sessionId, { username: IdentityUsername.make("micseg") }); + expect(yield* identity.requireOperateClaim(sessionId)).not.toBeNull(); + + rewrite(file, ["patroza"]); + yield* TestClock.adjust(Duration.millis(TTL + 1)); + + // Operate is refused... + const refused = yield* identity.requireOperateClaim(sessionId).pipe(Effect.exit); + expect(Exit.isFailure(refused)).toBe(true); + + // ...but the claim was not destroyed, so re-adding the person restores it. + const stillThere = yield* identity.getSessionClaim(sessionId); + expect(stillThere.claim?.username).toBe("micseg"); + + rewrite(file, ["patroza", "micseg"]); + yield* TestClock.adjust(Duration.millis(TTL + 1)); + expect(yield* identity.requireOperateClaim(sessionId)).not.toBeNull(); + }).pipe(Effect.provide(IdentityService.layer)); + }); +}); diff --git a/apps/server/src/identity/IdentityService.ts b/apps/server/src/identity/IdentityService.ts index 8421f270d8af..46b7aa44bac0 100644 --- a/apps/server/src/identity/IdentityService.ts +++ b/apps/server/src/identity/IdentityService.ts @@ -4,7 +4,9 @@ /** * Closed-set identity map + per-session claims. * - * Map: T3_IDENTITY_MAP_PATH only (explicit). Missing/empty → feature off. + * Map: T3_IDENTITY_MAP_PATH only (explicit). Missing/empty at startup → feature + * off. Once enabled the file is re-checked on a TTL, so staged edits apply + * without a restart; a bad or empty re-read never disables an enabled map. * Claims: `layerPersisted` (server) stores them in SQLite via * SessionIdentityClaimRepository with the Ref as a read-through cache, so they * survive a restart. The residual-free `layer` (CLI / tests) is Ref-only. @@ -15,6 +17,7 @@ * v1 trust: interactive claim is map membership only (trusted-team ops). */ import * as NodeFS from "node:fs"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -147,6 +150,109 @@ function loadPeopleFromEnv(): ReadonlyArray { } } +/** How long a loaded map is served before the file is re-checked. */ +export const IDENTITY_MAP_RELOAD_TTL_MS = 60_000; + +type MapSnapshot = { + readonly people: ReadonlyArray; + readonly byUsername: ReadonlyMap; + readonly byPersonId: ReadonlyMap; + readonly enabled: boolean; + /** + * False while we are serving a stale snapshot because the last re-read failed + * (missing, truncated, or unparseable file). Callers must not take destructive + * action — notably evicting persisted claims — off an unhealthy snapshot. + */ + readonly healthy: boolean; +}; + +type IdentityMapSource = { readonly current: Effect.Effect }; + +function toSnapshot(people: ReadonlyArray, healthy: boolean): MapSnapshot { + return { + people, + byUsername: new Map(people.map((person) => [person.username, person] as const)), + byPersonId: new Map(people.map((person) => [person.personId, person] as const)), + enabled: people.length > 0, + healthy, + }; +} + +/** Cheap change detector: avoids re-parsing an untouched file every TTL. */ +function fingerprintFromEnv(): string | null { + const configured = process.env.T3_IDENTITY_MAP_PATH?.trim(); + if (configured === undefined || configured.length === 0) return null; + try { + const stat = NodeFS.statSync(configured); + return `${stat.ino}:${stat.size}:${stat.mtimeMs}`; + } catch { + return null; + } +} + +/** + * File-backed map with a TTL re-check, so operators can edit the staged map and + * have it apply without a server restart. + * + * Polls rather than watching: the map is delivered over virtiofs from the host, + * where inotify propagation is not something to depend on. + * + * Two safety rules, both about not letting a bad read do damage: + * - a reload that yields no people never disables an already-enabled map, since + * `enabled === false` turns the operate gate off entirely. Emptying the file + * is not the documented way to disable; removing T3_IDENTITY_MAP_PATH is. + * - a failed reload keeps serving the last good snapshot, marked unhealthy, and + * retries on the next TTL (the fingerprint is left unadvanced). + */ +function makeFileSource(options?: { readonly ttlMs?: number }): IdentityMapSource { + const ttlMs = options?.ttlMs ?? IDENTITY_MAP_RELOAD_TTL_MS; + + let snapshot = toSnapshot(loadPeopleFromEnv(), true); + let fingerprint = fingerprintFromEnv(); + let checkedAt: number | null = null; + let degraded = false; + + const current = Effect.gen(function* () { + const at = yield* Clock.currentTimeMillis; + if (checkedAt === null) { + checkedAt = at; + return snapshot; + } + if (at - checkedAt < ttlMs) return snapshot; + checkedAt = at; + + const nextFingerprint = fingerprintFromEnv(); + if (nextFingerprint !== null && nextFingerprint === fingerprint) return snapshot; + + const people = loadPeopleFromEnv(); + if (people.length === 0 && snapshot.enabled) { + if (!degraded) { + yield* Effect.logError( + "Identity map re-read produced no people; keeping the previous map and retrying", + ); + degraded = true; + } + snapshot = { ...snapshot, healthy: false }; + return snapshot; + } + + const changed = people.length !== snapshot.people.length || !snapshot.healthy; + fingerprint = nextFingerprint; + degraded = false; + snapshot = toSnapshot(people, true); + if (changed) { + yield* Effect.logInfo("Identity map reloaded", { people: people.length }); + } + return snapshot; + }); + + return { current }; +} + +function staticSource(people: ReadonlyArray): IdentityMapSource { + return { current: Effect.succeed(toSnapshot(people, true)) }; +} + function toPublicPeople(people: ReadonlyArray) { return people.map((person) => { const pub = toIdentityPersonPublic(person); @@ -165,14 +271,7 @@ type ClaimStore = { readonly remove: (sessionId: AuthSessionId) => Effect.Effect; }; -function makeService( - people: ReadonlyArray, - store: ClaimStore, -): IdentityService["Service"] { - const byUsername = new Map(people.map((person) => [person.username, person] as const)); - const byPersonId = new Map(people.map((person) => [person.personId, person] as const)); - const enabled = people.length > 0; - +function makeService(source: IdentityMapSource, store: ClaimStore): IdentityService["Service"] { const toPublicClaim = (record: ClaimRecord): SessionIdentityClaim => ({ sessionId: record.sessionId, personId: record.personId, @@ -183,13 +282,15 @@ function makeService( return { getSnapshot: () => - Effect.succeed({ - enabled, - claimRequired: enabled, - people: toPublicPeople(people), - }), + source.current.pipe( + Effect.map((snapshot) => ({ + enabled: snapshot.enabled, + claimRequired: snapshot.enabled, + people: toPublicPeople(snapshot.people), + })), + ), - listMapPeople: () => Effect.succeed(people), + listMapPeople: () => source.current.pipe(Effect.map((snapshot) => snapshot.people)), getSessionClaim: (sessionId) => store.get(sessionId).pipe( @@ -200,7 +301,8 @@ function makeService( claim: (sessionId, input) => Effect.gen(function* () { - if (!enabled) { + const snapshot = yield* source.current; + if (!snapshot.enabled) { return yield* new IdentityError({ code: "identity_map_disabled", message: "Identity map is not configured; claims are disabled.", @@ -209,8 +311,8 @@ function makeService( const person = "personId" in input - ? (byPersonId.get(input.personId) ?? null) - : (byUsername.get(input.username) ?? null); + ? (snapshot.byPersonId.get(input.personId) ?? null) + : (snapshot.byUsername.get(input.username) ?? null); if (person === null) { return yield* new IdentityError({ code: "identity_unknown_person", @@ -237,7 +339,8 @@ function makeService( requireOperateClaim: (sessionId, options) => Effect.gen(function* () { - if (!enabled) return null; + const snapshot = yield* source.current; + if (!snapshot.enabled) return null; // Integration bots: one auth session, many platform actors — not interactive claim. if (options?.clientDeviceType === "bot") { return null; @@ -250,8 +353,16 @@ function makeService( "Choose who you are (identity claim) before operating on this environment. Map membership only — trusted-team ops.", }); } - if (!byPersonId.has(existing.personId) || !byUsername.has(existing.username)) { - yield* store.remove(sessionId); + if ( + !snapshot.byPersonId.has(existing.personId) || + !snapshot.byUsername.has(existing.username) + ) { + // Deliberately does not delete the persisted claim. Refusing operate is + // the gate; deleting is only cleanup, and it is not reversible. Now that + // the map reloads under the server, a half-written file can parse as a + // valid map with a subset of people — deleting off that would destroy + // good claims. Membership is re-checked on every operate anyway, so a + // stale row grants nothing. return yield* new IdentityError({ code: "identity_unknown_person", message: "Your identity claim is no longer in the server map. Claim again.", @@ -261,9 +372,13 @@ function makeService( }), resolveByJiraAccountId: (accountId) => - Effect.succeed(enabled ? resolvePersonByJiraAccountId(people, accountId) : null), + source.current.pipe( + Effect.map((snapshot) => + snapshot.enabled ? resolvePersonByJiraAccountId(snapshot.people, accountId) : null, + ), + ), - isMapEnabled: () => Effect.succeed(enabled), + isMapEnabled: () => source.current.pipe(Effect.map((snapshot) => snapshot.enabled)), }; } @@ -290,14 +405,16 @@ function makeMemoryStore(claimsRef: Ref.Ref>): ClaimSto export const make: Effect.Effect = Effect.gen(function* () { const claimsRef = yield* Ref.make(new Map()); - const people = loadPeopleFromEnv(); + const source = makeFileSource(); + const people = (yield* source.current).people; if (people.length > 0) { yield* Effect.logInfo("Identity map loaded", { path: process.env.T3_IDENTITY_MAP_PATH ?? "", people: people.length, + reloadTtlMs: IDENTITY_MAP_RELOAD_TTL_MS, }); } - return makeService(people, makeMemoryStore(claimsRef)); + return makeService(source, makeMemoryStore(claimsRef)); }); /** Residual-free in-memory layer (CLI / tests without SQL). */ @@ -313,11 +430,13 @@ export const layerPersisted = Layer.effect( const claimsRef = yield* Ref.make(new Map()); const memory = makeMemoryStore(claimsRef); const repository = yield* SessionIdentityClaimRepository; - const people = loadPeopleFromEnv(); + const source = makeFileSource(); + const people = (yield* source.current).people; if (people.length > 0) { yield* Effect.logInfo("Identity map loaded", { path: process.env.T3_IDENTITY_MAP_PATH ?? "", people: people.length, + reloadTtlMs: IDENTITY_MAP_RELOAD_TTL_MS, }); } @@ -353,7 +472,7 @@ export const layerPersisted = Layer.effect( }), }; - return makeService(people, store); + return makeService(source, store); }), ).pipe(Layer.provide(sessionIdentityClaimRepositoryLayer)); @@ -363,6 +482,9 @@ export const layerWithPeople = (people: ReadonlyArray) => IdentityService, Effect.gen(function* () { const claimsRef = yield* Ref.make(new Map()); - return makeService(people, makeMemoryStore(claimsRef)); + return makeService(staticSource(people), makeMemoryStore(claimsRef)); }), ); + +/** Test seam: file-backed source with an injectable clock and TTL. */ +export const makeFileSourceForTest = makeFileSource;