diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5655f260bc9..b302f2d7f07 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -48,6 +48,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + [WS_METHODS.identityGetSnapshot]: AuthOrchestrationReadScope, + [WS_METHODS.identityGetSessionClaim]: AuthOrchestrationReadScope, + [WS_METHODS.identityClaim]: AuthOrchestrationOperateScope, + [WS_METHODS.identityClearClaim]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/identity/IdentityService.test.ts b/apps/server/src/identity/IdentityService.test.ts new file mode 100644 index 00000000000..88dc3e7f819 --- /dev/null +++ b/apps/server/src/identity/IdentityService.test.ts @@ -0,0 +1,109 @@ +import { AuthSessionId, IdentityError } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as IdentityService from "./IdentityService.ts"; + +const people = [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + }, + { + personId: "julius", + username: "julius", + name: "Julius", + }, +] as const; + +const TestLayer = IdentityService.layerWithPeople([...people]).pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); + +const isIdentityError = (error: unknown): error is IdentityError => + typeof error === "object" && error !== null && "_tag" in error && error._tag === "IdentityError"; + +describe("IdentityService", () => { + it.effect("snapshot is enabled when people are present", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const snapshot = yield* identity.getSnapshot(); + expect(snapshot.enabled).toBe(true); + expect(snapshot.claimRequired).toBe(true); + expect(snapshot.people.map((person) => person.username)).toEqual(["patroza", "julius"]); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("rejects unknown claim targets", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000aa"); + const result = yield* identity.claim(sessionId, { username: "nobody" }).pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = result.cause; + // Cause.fail path + const failures = + "failures" in error ? (error as { failures: ReadonlyArray }).failures : []; + const first = failures[0] ?? error; + // Prefer direct fail extraction via Cause.squash if available + void first; + } + const failed = yield* identity.claim(sessionId, { username: "nobody" }).pipe( + Effect.map(() => null as string | null), + Effect.catch((error) => Effect.succeed(isIdentityError(error) ? error.code : "other")), + ); + expect(failed).toBe("identity_unknown_person"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("claims, gates operate, and clears", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000bb"); + + const before = yield* identity.requireOperateClaim(sessionId).pipe( + Effect.map(() => null as string | null), + Effect.catch((error) => Effect.succeed(isIdentityError(error) ? error.code : "other")), + ); + expect(before).toBe("identity_claim_required"); + + const claimed = yield* identity.claim(sessionId, { + username: "patroza", + method: "typeahead", + }); + expect(claimed.claim.username).toBe("patroza"); + expect(claimed.claim.personId).toBe("patroza"); + + const allowed = yield* identity.requireOperateClaim(sessionId); + expect(allowed?.username).toBe("patroza"); + + const cleared = yield* identity.clearClaim(sessionId); + expect(cleared.cleared).toBe(true); + + const after = yield* identity.requireOperateClaim(sessionId).pipe( + Effect.map(() => null as string | null), + Effect.catch((error) => Effect.succeed(isIdentityError(error) ? error.code : "other")), + ); + expect(after).toBe("identity_claim_required"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("allows overwrite claim (settings switch)", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000cc"); + yield* identity.claim(sessionId, { username: "patroza" }); + const next = yield* identity.claim(sessionId, { + username: "julius", + method: "settings", + }); + expect(next.claim.username).toBe("julius"); + expect(next.claim.method).toBe("settings"); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/identity/IdentityService.ts b/apps/server/src/identity/IdentityService.ts new file mode 100644 index 00000000000..9a2fcb3d672 --- /dev/null +++ b/apps/server/src/identity/IdentityService.ts @@ -0,0 +1,453 @@ +/** + * Closed-set identity map + per-session claims. + * + * Map load path: T3_IDENTITY_MAP_PATH, else $stateDir/identity-map.yaml|json if present. + * Missing/empty map → feature off (no claim gate). + * + * v1 trust: interactive claim only checks map membership (trusted-team ops). + */ +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Config from "effect/Config"; +import * as Path from "effect/Path"; +import { + AuthSessionId, + IdentityClaimInput, + IdentityError, + IdentitySessionClaimResult, + IdentitySnapshot, + IdentityUsername, + PersonId, + SessionIdentityClaim, + type SessionIdentityClaimMethod, +} from "@t3tools/contracts"; +import { + parseIdentityMapDocument, + toIdentityPersonPublic, + type IdentityMapPerson, + IdentityMapParseError, +} from "@t3tools/shared/identityMap"; +import { parse as parseYamlString } from "yaml"; + +import * as ServerConfig from "../config.ts"; +import * as SessionIdentityClaims from "../persistence/SessionIdentityClaims.ts"; + +export class IdentityService extends Context.Service< + IdentityService, + { + readonly getSnapshot: () => Effect.Effect; + readonly getSessionClaim: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly claim: ( + sessionId: AuthSessionId, + input: IdentityClaimInput, + ) => Effect.Effect<{ claim: SessionIdentityClaim }, IdentityError>; + readonly clearClaim: ( + sessionId: AuthSessionId, + ) => Effect.Effect<{ cleared: boolean }, IdentityError>; + /** + * When map enabled, require a session claim for orchestration:operate paths. + * No-op when identity feature is off. + */ + readonly requireOperateClaim: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + } +>()("t3/identity/IdentityService") {} + +const loadPeopleFromPath = Effect.fn("IdentityService.loadPeopleFromPath")(function* ( + path: string, + options: { readonly required: boolean }, +) { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + if (options.required) { + return yield* Effect.fail( + new IdentityError({ + code: "identity_map_invalid", + message: `Configured identity map not found: ${path}`, + }), + ); + } + return [] as ReadonlyArray; + } + const raw = yield* fs.readFileString(path).pipe( + Effect.mapError( + (cause) => + new IdentityError({ + code: "identity_map_invalid", + message: `Failed to read identity map at ${path}: ${String(cause)}`, + }), + ), + ); + const trimmed = raw.trim(); + if (trimmed.length === 0) { + if (options.required) { + return yield* Effect.fail( + new IdentityError({ + code: "identity_map_invalid", + message: `Configured identity map is empty: ${path}`, + }), + ); + } + return []; + } + + let document: unknown; + try { + if (path.endsWith(".json")) { + document = JSON.parse(trimmed) as unknown; + } else { + try { + document = JSON.parse(trimmed) as unknown; + } catch { + document = parseYamlString(trimmed) as unknown; + } + } + } catch (cause) { + return yield* Effect.fail( + new IdentityError({ + code: "identity_map_invalid", + message: `Failed to parse identity map at ${path}: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + ); + } + + try { + return parseIdentityMapDocument(document); + } catch (cause) { + const message = + cause instanceof IdentityMapParseError + ? cause.message + : cause instanceof Error + ? cause.message + : String(cause); + return yield* Effect.fail( + new IdentityError({ + code: "identity_map_invalid", + message: `Invalid identity map at ${path}: ${message}`, + }), + ); + } +}); + +const resolveMapPath = Effect.fn("IdentityService.resolveMapPath")(function* () { + const configured = yield* Config.string("T3_IDENTITY_MAP_PATH").pipe(Config.option); + if (Option.isSome(configured) && configured.value.trim().length > 0) { + return { path: configured.value.trim(), required: true as const }; + } + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const pathApi = yield* Path.Path; + const yamlPath = pathApi.join(stateDir, "identity-map.yaml"); + const jsonPath = pathApi.join(stateDir, "identity-map.json"); + if (yield* fs.exists(yamlPath).pipe(Effect.orElseSucceed(() => false))) { + return { path: yamlPath, required: false as const }; + } + if (yield* fs.exists(jsonPath).pipe(Effect.orElseSucceed(() => false))) { + return { path: jsonPath, required: false as const }; + } + return null; +}); + +export const make = Effect.gen(function* () { + const claims = yield* SessionIdentityClaims.SessionIdentityClaimRepository; + const resolved = yield* resolveMapPath; + + let people: ReadonlyArray = []; + if (resolved !== null) { + // Explicit T3_IDENTITY_MAP_PATH is fail-closed (boot fails). Default path is soft. + people = yield* loadPeopleFromPath(resolved.path, { required: resolved.required }).pipe( + Effect.catchTag("IdentityError", (error) => { + if (resolved.required) { + return Effect.fail(error); + } + return Effect.logError("Identity map failed to load; identity feature disabled", { + path: resolved.path, + code: error.code, + message: error.message, + }).pipe(Effect.as([] as ReadonlyArray)); + }), + ); + if (people.length > 0) { + yield* Effect.logInfo("Identity map loaded", { + path: resolved.path, + people: people.length, + }); + } + } + + 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; + + const getSnapshot = (): Effect.Effect => + Effect.succeed({ + enabled, + claimRequired: enabled, + people: people.map(toIdentityPersonPublic), + }); + + const toPublicClaim = ( + record: SessionIdentityClaims.SessionIdentityClaimRecord, + ): SessionIdentityClaim => ({ + sessionId: record.sessionId, + personId: record.personId, + username: record.username, + claimedAt: record.claimedAt, + method: record.method, + }); + + const getSessionClaim = (sessionId: AuthSessionId) => + claims.getBySessionId(sessionId).pipe( + Effect.mapError( + (cause) => + new IdentityError({ + code: "identity_map_invalid", + message: `Failed to load session claim: ${cause.message}`, + }), + ), + Effect.map((option) => ({ + claim: Option.match(option, { + onNone: () => null, + onSome: toPublicClaim, + }), + })), + ); + + const claim = (sessionId: AuthSessionId, input: IdentityClaimInput) => + Effect.gen(function* () { + if (!enabled) { + return yield* Effect.fail( + new IdentityError({ + code: "identity_map_disabled", + message: "Identity map is not configured; claims are disabled.", + }), + ); + } + + const person = + "personId" in input + ? (byPersonId.get(input.personId) ?? null) + : (byUsername.get(input.username) ?? null); + if (person === null) { + return yield* Effect.fail( + new IdentityError({ + code: "identity_unknown_person", + message: "That identity is not in the server identity map.", + }), + ); + } + + const method: SessionIdentityClaimMethod = + ("method" in input && input.method !== undefined ? input.method : undefined) ?? "typeahead"; + const claimedAt = yield* DateTime.now.pipe(Effect.map((dt) => DateTime.formatIso(dt))); + const record = { + sessionId, + personId: PersonId.make(person.personId), + username: IdentityUsername.make(person.username), + claimedAt, + method, + }; + yield* claims.upsert(record).pipe( + Effect.mapError( + (cause) => + new IdentityError({ + code: "identity_map_invalid", + message: `Failed to persist claim: ${cause.message}`, + }), + ), + ); + return { claim: toPublicClaim(record) }; + }); + + const clearClaim = (sessionId: AuthSessionId) => + claims.deleteBySessionId(sessionId).pipe( + Effect.mapError( + (cause) => + new IdentityError({ + code: "identity_map_invalid", + message: `Failed to clear claim: ${cause.message}`, + }), + ), + Effect.map((cleared) => ({ cleared })), + ); + + const requireOperateClaim = (sessionId: AuthSessionId) => + Effect.gen(function* () { + if (!enabled) return null; + const { claim: existing } = yield* getSessionClaim(sessionId); + if (existing === null) { + return yield* Effect.fail( + new IdentityError({ + code: "identity_claim_required", + message: + "Choose who you are (identity claim) before operating on this environment. Map membership only — trusted-team ops.", + }), + ); + } + // Revalidate against the live map so removed people cannot keep operating. + if (!byPersonId.has(existing.personId) || !byUsername.has(existing.username)) { + yield* claims.deleteBySessionId(sessionId).pipe(Effect.ignore); + return yield* Effect.fail( + new IdentityError({ + code: "identity_unknown_person", + message: "Your identity claim is no longer in the server map. Claim again.", + }), + ); + } + return existing; + }); + + return { + getSnapshot, + getSessionClaim, + claim, + clearClaim, + requireOperateClaim, + } satisfies IdentityService["Service"]; +}); + +export const layer = Layer.effect(IdentityService, make).pipe( + Layer.provideMerge(SessionIdentityClaims.layer), +); + +/** Test helper: fixed people, real claim repository still required. */ +export const layerWithPeople = (people: ReadonlyArray) => + Layer.effect( + IdentityService, + Effect.gen(function* () { + const claims = yield* SessionIdentityClaims.SessionIdentityClaimRepository; + 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; + + const toPublicClaim = ( + record: SessionIdentityClaims.SessionIdentityClaimRecord, + ): SessionIdentityClaim => ({ + sessionId: record.sessionId, + personId: record.personId, + username: record.username, + claimedAt: record.claimedAt, + method: record.method, + }); + + return { + getSnapshot: () => + Effect.succeed({ + enabled, + claimRequired: enabled, + people: people.map(toIdentityPersonPublic), + }), + getSessionClaim: (sessionId) => + claims.getBySessionId(sessionId).pipe( + Effect.mapError( + (cause) => + new IdentityError({ + code: "identity_map_invalid", + message: cause.message, + }), + ), + Effect.map((option) => ({ + claim: Option.match(option, { + onNone: () => null, + onSome: toPublicClaim, + }), + })), + ), + claim: (sessionId, input) => + Effect.gen(function* () { + if (!enabled) { + return yield* Effect.fail( + new IdentityError({ + code: "identity_map_disabled", + message: "disabled", + }), + ); + } + const person = + "personId" in input + ? (byPersonId.get(input.personId) ?? null) + : (byUsername.get(input.username) ?? null); + if (person === null) { + return yield* Effect.fail( + new IdentityError({ + code: "identity_unknown_person", + message: "unknown", + }), + ); + } + const method = + ("method" in input && input.method !== undefined ? input.method : undefined) ?? + "typeahead"; + const claimedAt = yield* DateTime.now.pipe(Effect.map((dt) => DateTime.formatIso(dt))); + const record = { + sessionId, + personId: PersonId.make(person.personId), + username: IdentityUsername.make(person.username), + claimedAt, + method, + }; + yield* claims.upsert(record).pipe( + Effect.mapError( + (cause) => + new IdentityError({ + code: "identity_map_invalid", + message: cause.message, + }), + ), + ); + return { claim: toPublicClaim(record) }; + }), + clearClaim: (sessionId) => + claims.deleteBySessionId(sessionId).pipe( + Effect.mapError( + (cause) => + new IdentityError({ + code: "identity_map_invalid", + message: cause.message, + }), + ), + Effect.map((cleared) => ({ cleared })), + ), + requireOperateClaim: (sessionId) => + Effect.gen(function* () { + if (!enabled) return null; + const option = yield* claims.getBySessionId(sessionId).pipe( + Effect.mapError( + (cause) => + new IdentityError({ + code: "identity_map_invalid", + message: cause.message, + }), + ), + ); + if (Option.isNone(option)) { + return yield* Effect.fail( + new IdentityError({ + code: "identity_claim_required", + message: "claim required", + }), + ); + } + const existing = toPublicClaim(option.value); + if (!byPersonId.has(existing.personId) || !byUsername.has(existing.username)) { + yield* claims.deleteBySessionId(sessionId).pipe(Effect.ignore); + return yield* Effect.fail( + new IdentityError({ + code: "identity_unknown_person", + message: "stale claim", + }), + ); + } + return existing; + }), + } satisfies IdentityService["Service"]; + }), + ).pipe(Layer.provideMerge(SessionIdentityClaims.layer)); diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 659665e47b5..0312ea53366 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -2,6 +2,8 @@ import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, EnvironmentHttpApi, + type EnvironmentRequestInvalidReason, + IdentityError, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -16,15 +18,29 @@ import { failEnvironmentNotFound, requireEnvironmentScope, } from "../auth/http.ts"; +import * as IdentityService from "../identity/IdentityService.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +const identityErrorToHttpReason = (error: IdentityError): EnvironmentRequestInvalidReason => { + switch (error.code) { + case "identity_claim_required": + case "identity_claim_missing": + return "identity_claim_required"; + case "identity_unknown_person": + return "identity_unknown_person"; + default: + return "identity_map_invalid"; + } +}; + export const orchestrationHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "orchestration", Effect.fnUntraced(function* (handlers) { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; + const identity = yield* IdentityService.IdentityService; return handlers .handle( @@ -77,7 +93,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( "dispatch", Effect.fn("environment.orchestration.dispatch")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); - yield* requireEnvironmentScope(AuthOrchestrationOperateScope); + const session = yield* requireEnvironmentScope(AuthOrchestrationOperateScope); + yield* identity + .requireOperateClaim(session.sessionId) + .pipe( + Effect.catchTag("IdentityError", (error) => + failEnvironmentInvalidRequest(identityErrorToHttpReason(error)), + ), + ); const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d25895671a9..4cd845945f5 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -47,6 +47,7 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; +import Migration0035 from "./Migrations/035_SessionIdentityClaims.ts"; /** * Migration loader with all migrations defined inline. @@ -93,6 +94,7 @@ export const migrationEntries = [ [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "ProjectionThreadsSettled", Migration0033], [34, "ProjectionThreadsSnoozed", Migration0034], + [35, "SessionIdentityClaims", Migration0035], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_SessionIdentityClaims.ts b/apps/server/src/persistence/Migrations/035_SessionIdentityClaims.ts new file mode 100644 index 00000000000..b778ee3bcb3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_SessionIdentityClaims.ts @@ -0,0 +1,19 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS session_identity_claims ( + session_id TEXT PRIMARY KEY NOT NULL, + person_id TEXT NOT NULL, + username TEXT NOT NULL, + claimed_at TEXT NOT NULL, + method TEXT NOT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_session_identity_claims_person + ON session_identity_claims(person_id) + `; +}); diff --git a/apps/server/src/persistence/SessionIdentityClaims.ts b/apps/server/src/persistence/SessionIdentityClaims.ts new file mode 100644 index 00000000000..b7eafb4b4c3 --- /dev/null +++ b/apps/server/src/persistence/SessionIdentityClaims.ts @@ -0,0 +1,177 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { + AuthSessionId, + IdentityUsername, + PersonId, + SessionIdentityClaimMethod, +} from "@t3tools/contracts"; + +import { + PersistenceDecodeError, + PersistenceSqlError, + type PersistenceErrorCorrelation, +} from "./Errors.ts"; + +export const SessionIdentityClaimRecord = Schema.Struct({ + sessionId: AuthSessionId, + personId: PersonId, + username: IdentityUsername, + claimedAt: Schema.String, + method: SessionIdentityClaimMethod, +}); +export type SessionIdentityClaimRecord = typeof SessionIdentityClaimRecord.Type; + +type ClaimRepoError = PersistenceSqlError | PersistenceDecodeError; + +export class SessionIdentityClaimRepository extends Context.Service< + SessionIdentityClaimRepository, + { + readonly getBySessionId: ( + sessionId: AuthSessionId, + ) => Effect.Effect, ClaimRepoError>; + readonly upsert: (record: SessionIdentityClaimRecord) => Effect.Effect; + readonly deleteBySessionId: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + } +>()("t3/persistence/SessionIdentityClaims/SessionIdentityClaimRepository") {} + +const toError = + (sqlOp: string, decodeOp: string, correlation?: PersistenceErrorCorrelation) => + (cause: unknown): ClaimRepoError => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError(decodeOp, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOp, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); + +const ClaimDbRow = Schema.Struct({ + sessionId: AuthSessionId, + personId: PersonId, + username: IdentityUsername, + claimedAt: Schema.String, + method: SessionIdentityClaimMethod, +}); + +const decodeClaim = Schema.decodeUnknownEffect(ClaimDbRow); + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const getRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ sessionId: AuthSessionId }), + Result: Schema.Struct({ + sessionId: Schema.String, + personId: Schema.Unknown, + username: Schema.Unknown, + claimedAt: Schema.Unknown, + method: Schema.Unknown, + }), + execute: ({ sessionId }) => + sql` + SELECT + session_id AS "sessionId", + person_id AS "personId", + username AS "username", + claimed_at AS "claimedAt", + method AS "method" + FROM session_identity_claims + WHERE session_id = ${sessionId} + `, + }); + + const upsertRow = SqlSchema.void({ + Request: SessionIdentityClaimRecord, + execute: (input) => + sql` + INSERT INTO session_identity_claims ( + session_id, + person_id, + username, + claimed_at, + method + ) + VALUES ( + ${input.sessionId}, + ${input.personId}, + ${input.username}, + ${input.claimedAt}, + ${input.method} + ) + ON CONFLICT(session_id) DO UPDATE SET + person_id = excluded.person_id, + username = excluded.username, + claimed_at = excluded.claimed_at, + method = excluded.method + `, + }); + + const deleteRow = SqlSchema.void({ + Request: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ sessionId }) => + sql` + DELETE FROM session_identity_claims + WHERE session_id = ${sessionId} + `, + }); + + return { + getBySessionId: (sessionId) => + getRow({ sessionId }).pipe( + Effect.mapError( + toError( + "SessionIdentityClaimRepository.getBySessionId:query", + "SessionIdentityClaimRepository.getBySessionId:decode", + { sessionId }, + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeClaim(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "SessionIdentityClaimRepository.getBySessionId:decode", + cause, + { sessionId }, + ), + ), + Effect.map((decoded) => Option.some(decoded)), + ), + }), + ), + ), + upsert: (record) => + upsertRow(record).pipe( + Effect.mapError( + toError( + "SessionIdentityClaimRepository.upsert:query", + "SessionIdentityClaimRepository.upsert:encode", + { sessionId: record.sessionId }, + ), + ), + ), + deleteBySessionId: (sessionId) => + deleteRow({ sessionId }).pipe( + Effect.mapError( + toError( + "SessionIdentityClaimRepository.deleteBySessionId:query", + "SessionIdentityClaimRepository.deleteBySessionId:encode", + { sessionId }, + ), + ), + Effect.as(true), + ), + } satisfies SessionIdentityClaimRepository["Service"]; +}); + +export const layer = Layer.effect(SessionIdentityClaimRepository, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e0d36e99bc9..ff10e07acd2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -78,6 +78,7 @@ import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { connectHttpApiLayer, @@ -391,6 +392,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(ServerLifecycleEvents.layer), + Layer.provideMerge(IdentityService.layer), Layer.provide(NetService.layer), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6c021c9af80..5f13d3d2e86 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -97,6 +97,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; @@ -397,6 +398,7 @@ const makeWsRpcLayer = ( yield* SourceControlRepositoryService.SourceControlRepositoryService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; + const identity = yield* IdentityService.IdentityService; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; @@ -1007,10 +1009,30 @@ const makeWsRpcLayer = ( .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); return WsRpcGroup.of({ + [WS_METHODS.identityGetSnapshot]: () => + observeRpcEffect(WS_METHODS.identityGetSnapshot, identity.getSnapshot()), + [WS_METHODS.identityGetSessionClaim]: () => + observeRpcEffect( + WS_METHODS.identityGetSessionClaim, + identity.getSessionClaim(currentSessionId), + ), + [WS_METHODS.identityClaim]: (payload) => + observeRpcEffect(WS_METHODS.identityClaim, identity.claim(currentSessionId, payload)), + [WS_METHODS.identityClearClaim]: () => + observeRpcEffect(WS_METHODS.identityClearClaim, identity.clearClaim(currentSessionId)), [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { + yield* identity.requireOperateClaim(currentSessionId).pipe( + Effect.mapError( + (error) => + new OrchestrationDispatchCommandError({ + message: error.message, + code: error.code, + }), + ), + ); const normalizedCommand = yield* normalizeDispatchCommand(command); const shouldStopSessionAfterArchive = normalizedCommand.type === "thread.archive" diff --git a/docs/architecture/source-and-identity.md b/docs/architecture/source-and-identity.md new file mode 100644 index 00000000000..8575be1db52 --- /dev/null +++ b/docs/architecture/source-and-identity.md @@ -0,0 +1,466 @@ +# Source attribution & session identity + +**Status:** draft design · **Area:** contracts, server auth/orchestration, web/desktop/mobile, integrations +**Audience:** shared single-environment servers with multiple people and multiple clients + +## Problem + +Threads and messages have no durable notion of **who** started or participated, or **which surface** they came from. A shared T3 server is used by several humans (and bots) over desktop, web, mobile, Discord, Jira, and eventually GitHub/Slack/Teams. We need: + +1. Compact **source** display (channel icons; hover expands location). +2. **Person@channel** handles (`patroza@desktop`, `patroza@discord`). +3. Filters: **mine vs theirs**, **starter vs participant**, **by channel**. +4. Identities that are **not free-form** — only people listed in a **server-side identity map file**. + +## Goals + +- One environment, many people, many sessions. +- Username is **chosen from the map**, never typed arbitrarily. +- Session-bound claim: “this connection is person X.” +- Source stamped on user turns; thread origin derived from first user message. +- Integrations resolve platform actors via the same map (no second identity system). +- Old events remain valid (`source` optional / null). + +## Non-goals (for this design / early PRs) + +- Multi-tenant auth with external IdP accounts. +- Free-form custom usernames or per-device nicknames outside the map. +- Full Slack/Teams adapters (channel enum reserved only). +- Backfilling invented provenance for historical threads. +- Client-only identity that the server cannot verify. + +## Key decisions + +| Decision | Choice | Rationale | +| -------------------- | ----------------------------------------------------------------- | -------------------------------------------- | +| Scope of username | **Per auth session** (claim), not one env-global string | Shared server; multiple humans | +| Allowed identities | **Closed set** from server identity map file | Operator-controlled; matches Discord bot map | +| Free-form entry | **Rejected** | Only map members; no invent-a-handle | +| Username charset | Handle pattern `^[a-z0-9][a-z0-9._-]*$` (no min length product) | Safe `user@channel`; membership still rules | +| Claim trust (v1) | **Map membership only** — any paired peer can claim any person | Trusted-team shared server; not anti-spoof | +| Client person fields | **Never trusted** — `ClientSourceHint` only; server stamps claim | Blocks naive SourceRef spoof on wire | +| Claim UI | **Typeahead after 3 chars**, not a full dropdown | Fewer wrong-person misclicks on large maps | +| Avatars | **Generated initials + stable color** first; photos later | Zero deps, works offline, distinct enough | +| Claim mutability | Overwrite via claim (settings method) or clearClaim | One claim row per sessionId | +| Operate gate | **orchestration dispatch only** (WS + HTTP) when map enabled | Attribution for turns; not full ACL | +| Map location | Server file path (env), not client settings | Same host of truth as secrets/aliases | +| Stamp site | User-originated orchestration events (`message-sent`, turn start) | Source of truth is event log | +| Thread origin | First user message’s `SourceRef` | Simple; no separate origin command for v1 | +| Display | Channel icon + creator avatar; `+N` expands extras on hover | Compact list; multi-person without noise | +| Mine | Session’s claimed `personId` equals message/thread person | Cross-surface via map links | + +## Concepts + +### Person + +A human (or bot operator) listed in the identity map. + +- Stable **`personId`** (slug or uuid; prefer explicit `id` in map, fallback slug of `username`). +- Required **`username`**: non-empty operator-chosen handle; **normalized lowercase** on the wire. **No min/max product length** — validity is **membership in the map**, not free-form shape rules. +- Optional display **`name`** (for git trailers / tooltips). +- Optional platform links: discord, github, jira, (later slack/teams). + +### Channel (client / surface) + +How the action entered T3: + +```text +desktop | web | mobile | discord | github | jira | slack | teams | bot | unknown +``` + +T3 UI clients map from existing `ClientKind` / `AuthClientMetadata.deviceType`: + +| Runtime signal | Channel | +| ------------------------------------- | ---------------------------------------- | +| desktop-renderer / deviceType desktop | `desktop` | +| web | `web` | +| mobile / tablet | `mobile` | +| bot deviceType / integration | `discord` / `jira` / … as set by adapter | + +### Handle + +Display only: `{username}@{channel}` → `patroza@desktop`. + +### SourceRef + +Compact provenance attached to user messages (and denormalized onto thread shell for lists): + +```ts +type SourceRef = { + channel: SourceChannel; + personId: string; // required for new writes when identity is enabled + username: string; // denormalized from map at write time (stable display if map changes later) + // optional location (channel-specific, all fields optional) + location?: { + // discord + guildId?: string; + channelId?: string; + threadId?: string; + // github + owner?: string; + repo?: string; + number?: number; + kind?: "pr" | "issue"; + // jira + projectKey?: string; + issueKey?: string; + }; + actor?: { + platformId?: string; // snowflake, login, accountId as observed + displayName?: string; + }; +}; +``` + +**Thread origin** = first user message with a `SourceRef`, else null. +**Participants** = distinct `personId`s on user messages (denormalized on shell). + +## Identity map file (closed set) + +### Load path + +Server config (mirrors Discord bot ops): + +```bash +# preferred: under T3 home / secrets +export T3_IDENTITY_MAP_PATH=/run/secrets/identity-map.yaml +# default fallback when unset: $T3CODE_HOME/userdata/identity-map.yaml if present +``` + +- Missing file → **identity feature off**: no claim gate, no source stamping requirement, filters degraded. +- Present but empty people → treat as off (or misconfig warning). +- Present with people → **identity required** for interactive clients. + +Reload: process restart is fine for v1; optional file watch later (bot already has reload patterns to copy). + +### Document shape + +Compatible with existing Discord bot map, **plus required `username`** per person: + +```yaml +# identity-map.yaml +people: + patroza: + username: patroza # required; closed-set handle (lowercase on wire) + name: Patrick Roza # display / Co-authored-by name + discord: + id: "95218063095377920" + username: patroza + github: + login: patroza + id: "42661" + # email optional; noreply derived when id+login present + jira: + accountId: "…" + email: patrick@example.com + julius: + username: julius + name: Julius + github: + login: juliusmarminge +``` + +Also accept array form and flat keys used by the bot (`discordId`, `githubLogin`, …). +**Validation rules:** + +- Every person must have unique `username` (case-insensitive). +- At least one of: `username` only (T3-only person), or any platform link. +- `username` non-empty after trim; stored/compared case-insensitively. +- Unknown fields ignored (forward compatible). + +Promote parsing into **`packages/shared` or `packages/contracts` + server loader** so Discord bot and server share one parser over time (bot can keep a thin re-export). First PR may vendor a copy if package boundaries are awkward; converge in a follow-up. + +### Not free-form + +Interactive claim API accepts **only** a `personId` or `username` that exists in the loaded map. +Any other value → `identity_unknown_person`. + +## Session claim (who is this connection) + +### Model + +Extend auth session (or a side table keyed by `sessionId`) with: + +```ts +type SessionIdentityClaim = { + sessionId: AuthSessionId; + personId: string; + username: string; // snapshot from map at claim time + claimedAt: DateTime; + // optional: how they claimed + method: "typeahead" | "auto-discord" | "auto-jira" | "bootstrap"; +}; +``` + +- **One claim per session.** Re-claim allowed only to the same person, or via explicit “switch person” that requires re-claim (admin/debug); default: immutable after set. +- **Not** stored in client settings blob as source of truth (client may cache for UI). +- Pairing links remain capability grants; claim is an extra session attribute after auth. + +### Bootstrap / bots + +| Client | Claim path | +| ------------------------------ | ----------------------------------------------------------------------------------- | +| Web / desktop / mobile | After pairing: **typeahead claim** against map usernames; must claim before operate | +| Discord bot | Auto-resolve sender snowflake → person; stamp on turn; no UI | +| Jira bot | Auto-resolve accountId/email → person | +| Headless CLI / admin bootstrap | Optional claim; if identity-on and operate without claim → reject operate RPCs | + +### Gate + +When identity map is **enabled** (non-empty people): + +- RPCs that need `orchestration:operate` (and thread create/send) require a claimed session. +- Allow without claim: auth/pairing, access admin, identity list + claim endpoints, health, shell **read** optional (prefer read allowed so UI can show claim gate over empty shell). +- UI: full chrome locked behind claim screen (“Who are you?”). See typeahead below — **not** a full dropdown of everyone. + +When map **disabled**: behavior unchanged from today (no gate, no source required). + +## Stamping turns + +On `thread.turn.start` / user `thread.message-sent`: + +1. Resolve `SourceRef` **on the server** from session claim + client channel metadata. + Clients **must not** be trusted to send arbitrary `personId`/`username`. They may send channel hints already present (device type); server overwrites person fields from claim. +2. Integrations attach platform `actor` + `location`; server resolves person via map; if unresolved → message still sent with `personId` omitted or `unknown` policy: + + **v1 policy:** unresolved external actor → stamp channel + actor only, `personId` null; does not count as mine for anyone. Log once per turn. + +3. Projector copies `source` onto `OrchestrationMessage`. +4. Shell projector maintains `originSource` and `participantPersonIds`. + +### Shell fields (list/filter) + +```ts +// OrchestrationThreadShell additions (all optional for decode) +originSource: SourceRef | null; +/** Distinct people on user messages, origin first, then first-participation order. */ +participantSummaries: ReadonlyArray<{ + personId: string; + username: string; + name?: string; + firstChannel?: SourceChannel; + firstParticipatedAt: string; // IsoDateTime +}>; +``` + +Filters (client-side over shell): + +- Ownership: mine | theirs | any (`personId === session.claim.personId` against origin or summaries) +- Role: starter (origin person) | participant (in summaries) +- Channel: multi-select on `originSource.channel` (v1); optional “any message channel” later + +## UI + +### Compact display + +- Thread row: origin **channel icon** + **participant stack** (below). +- Message: single micro avatar (author) + tiny channel glyph; tooltip full handle. +- Icons / chips only; no continuous animation (hover expand is CSS/static popover, not a looping animation). + +### Participant stack (thread list) + +When more than one person has user messages in a thread: + +| Collapsed (default) | Expanded (hover / focus / long-press on mobile) | +| ----------------------------------------------------- | ------------------------------------------------------------------- | +| **Creator first** (thread origin person micro avatar) | Same first chip + full row of the remaining participants | +| Then a **`+N`** chip for the other distinct people | e.g. creator + 3 others → show creator, then expand reveals those 3 | + +Rules: + +1. **Order:** origin/`personId` from `originSource` first; then other participants by **first participation time** (first user message by that person), ascending. Stable ties by `personId`. +2. **Count:** distinct resolved `personId`s only (unmapped actors without person do not get a person chip; optional later: anonymous channel-only count). +3. **Collapsed:** always show **exactly one** face (creator) when participants ≥ 1. If `participants.length > 1`, append **`+N`** where `N = participants.length - 1` (e.g. 4 people → creator face + `+3`). +4. **Hover / keyboard focus** on the stack (or `+N`): popover/tooltip lists **all extras** (or full set including creator) with micro avatar + `username@channel` of _their first contribution channel_ when cheap, else just `username` / display name. Prefer a short vertical list, not a second dense icon strip that reflows the sidebar. +5. **Single participant:** only the creator avatar; no `+0`. +6. **Unknown origin** (legacy threads): if participants known but origin null, show first participant as lead; if none, no stack. +7. **Density:** stack sits after the channel glyph; total width capped so long titles still ellipsize. `+N` uses the same micro size as avatars (muted chip, not a second color-hash face). +8. **a11y:** stack has `aria-label` like “Started by patroza, 3 other participants”; expand content is in a focusable disclosure on keyboard (not hover-only). + +```text +[discord] [PR] +3 Fix the flaky gate … + └ hover → PR patroza + JU julius + TH theo +``` + +Shell should carry enough denormalized data for the stack without loading full message history: + +```ts +// Prefer ordered summaries over bare ids once UI lands +participantSummaries: Array<{ + personId: string; + username: string; // snapshot at first sighting + name?: string; + firstChannel?: SourceChannel; + firstParticipatedAt: IsoDateTime; +}>; +// origin person is participantSummaries[0] when originSource.personId is set +// and present; otherwise derive lead from originSource alone +``` + +Keep `participantPersonIds` only if useful for filters; UI should prefer `participantSummaries` ordered as above. + +### Micro avatars (generated first) + +v1 does **not** load GitHub/Discord profile photos. Generate chips on the fly: + +- Pure helper: `@t3tools/shared/identityAvatar` → `{ initials, backgroundColor, color, label }`. +- **Initials**: display `name` when present (`Patrick Roza` → `PR`), else first two letters of `username` (`patroza` → `PA`). +- **Color**: stable hash of `personId` (fallback username) into a fixed muted palette — same person ⇒ same chip on every client. +- **Sizes**: micro ~14–16px in thread lists / message rows; ~24–28px in typeahead suggestions and settings. +- **Later**: optional real image URL from map/platform, falling back to the generated chip. +- No new dependency (avoids pulling in full avatar libs); logic matches the usual initials+hue pattern. + +### Identity claim UI (typeahead, not a full dropdown) + +Goal: reduce mis-clicks on the wrong person when the map is large; still **closed-set only**. + +- Full-screen / modal after connect when unclaimed and map enabled. +- Single text field: user types their username (and/or display name). +- **No full-list dropdown** of all people by default. +- After **`IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS` (3)** characters, show matching suggestions from the map (prefix/substring on `username` and `name`, case-insensitive). +- User must **select a suggestion** or submit an **exact** map username match. Free-form values that are not in the map are rejected (client validation + server `identity_unknown_person`). +- Suggestion rows: **micro avatar** + `username` primary, `name` secondary, optional platform badges. +- Selecting / exact confirm calls `identity.claim` with `personId` or `username`. +- Change identity: Settings → rare; clears claim and re-runs typeahead (or admin-only). + +### Filters entry points + +Sidebar / command palette: Mine | Theirs; Starter | Participant; source chips. + +## API sketch (contracts) + +New module `packages/contracts/src/identity.ts` (see initial draft in repo): + +- `IdentityUsername`, `PersonId`, `SourceChannel`, `SourceRef` +- `IdentityPersonPublic` (safe for clients: no emails required in list; emails may be omitted from public DTO) +- `IdentitySnapshot` — `{ enabled, people: IdentityPersonPublic[] }` +- RPC: + - `identity.getSnapshot` → map public view + whether claim required + - `identity.getSessionClaim` → current claim or null + - `identity.claim` → `{ username | personId }` validated against map +- Auth session list may show claimed username next to device label + +Orchestration: + +- Optional `source` on message payloads and shell (decode defaults null/[]). + +## Server layout + +```text +apps/server/src/identity/ + IdentityMap.ts # load/parse/validate file + IdentityMapStore.ts # Effect service, path from config + SessionIdentity.ts # claim persistence (sqlite) + IdentityRpc.ts # handlers + gate helper +``` + +Persistence: session claim columns on existing auth session table **or** `session_identity(session_id PK, person_id, username, claimed_at)`. Prefer side table to avoid heavy SessionStore churn. + +Gate helper used by orchestration command dispatch: + +```ts +requireSessionIdentity(session): Effect +``` + +## Discord / Jira bots + +- Prefer **same file** path already used (`T3_IDENTITY_MAP_PATH`) so ops do not maintain two maps. +- When calling T3 turn APIs, bots either: + - use a bot session that passes platform actor in a trusted integration path, and server maps to person, or + - claim is not used; integration reactor stamps `SourceRef` server-side when ingesting. + +Until server owns the map, bots keep co-author resolution as today; **P2** unifies parser + stamp. + +## Migration / compatibility + +| Artifact | Behavior | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Old messages | `source: null` | +| Old shells | `originSource: null`, empty participants | +| Map without `username` | Reject load or derive from github.login / key if valid slug; **prefer fail load with clear error** so operators add usernames explicitly | +| Feature flag | Implicit: map present + people ⇒ on | + +## Phased PR plan + +### PR1 — Contracts + map schema + design doc + +- Land this design doc. +- Add `identity.ts` schemas + tests (username wire form, SourceRef decode, typeahead constant). +- No runtime behavior. + +### PR2 — Server identity map load + claim RPC + session persistence + +- Load `T3_IDENTITY_MAP_PATH` / default path. +- `identity.getSnapshot` / `claim` / `getSessionClaim`. +- Gate `orchestration:operate` when enabled. +- Unit tests with temp map files. + +### PR3 — Stamp SourceRef on T3 client turns + +- Derive channel from session client metadata. +- Attach source on message-sent / projector / shell origin + participants. +- Focused orchestration tests (receipts, no sleeps). + +### PR4 — Web/desktop/mobile: typeahead claim gate + compact icons + +- Claim gate UI (all entry points: first paint after pair, not only settings). +- Typeahead after 3 chars; no full-people dropdown; reject non-map values. +- Micro avatars via `@t3tools/shared/identityAvatar` (initials + palette); thin React/RN chip wrappers. +- Thread list **participant stack**: creator face + `+N`; hover/focus expands remaining people. +- Thread/message source icons + tooltips. +- Client settings may cache last username for prefill only if still in map. + +### PR5 — Filters (mine / theirs / starter / participant / channel) + +- Shell-driven client filters + command palette. +- Mobile parity for filter entry (simpler sheet). + +### PR6 — Integrations stamp external sources + +- Discord bot passes location + actor; server resolves person. +- Jira similarly. +- Shared map parser extraction if not done in PR2. + +## Testing strategy + +- Map parse: valid/invalid username, duplicates, missing username. +- Claim: accepts map member; rejects unknown; rejects operate without claim when enabled. +- Stamp: desktop session → `channel: desktop`, correct personId. +- Shell: first message sets origin; second person adds participant. +- Gate off when map absent. +- Decode old events without `source`. + +## Open questions (resolved defaults) + +| Question | Default for implementation | +| ----------------------------------- | ------------------------------------------------------ | +| Map empty vs missing | Both = feature off | +| Can two sessions claim same person? | **Yes** (same human, phone + desktop) | +| Switch person mid-session | Settings action; new claim overwrites | +| Emails in client snapshot | **Omit** by default (github login / discord id ok) | +| Unmapped Discord user | Stamp actor only; not mine | +| Username rename in map | Old events keep denormalized username; personId stable | + +## Surfaces checklist + +| Surface | Notes | +| ----------------- | ----------------------------------------------------------------- | +| Contracts | SourceRef, identity RPC, message/shell fields | +| Server | Map, claim, gate, stamp, project | +| Web | Gate, icons, filters | +| Desktop | Same web UI + deviceType desktop | +| Mobile | Gate + icons + simplified filters | +| Discord/Jira bots | External SourceRef (PR6) | +| Docs | This file; user-facing note under `docs/user/` when shipping gate | + +## Appendix: example handle matrix + +| Actor | Channel | Handle | +| --------------------------------- | ------- | ----------------- | +| Patrick on Mac app | desktop | `patroza@desktop` | +| Patrick in browser | web | `patroza@web` | +| Patrick on phone | mobile | `patroza@mobile` | +| Patrick via Discord | discord | `patroza@discord` | +| Julius via GitHub-originated flow | github | `julius@github` | diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc..f95fb82808a 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -56,6 +56,9 @@ export const EnvironmentRequestInvalidReason = Schema.Literals([ "invalid_scope", "scope_not_granted", "invalid_command", + "identity_claim_required", + "identity_unknown_person", + "identity_map_invalid", ]); export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type; diff --git a/packages/contracts/src/identity.test.ts b/packages/contracts/src/identity.test.ts new file mode 100644 index 00000000000..746497e420c --- /dev/null +++ b/packages/contracts/src/identity.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { + IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS, + IDENTITY_HANDLE_SOFT_MAX_LENGTH, + ClientSourceHint, + IdentityClaimInput, + IdentityError, + IdentityPersonPublic, + IdentitySnapshot, + IdentityUsername, + PersonId, + SessionIdentityClaim, + SourceRef, + ThreadParticipantSummary, +} from "./identity.ts"; +import { AuthSessionId } from "./baseSchemas.ts"; + +const decodeUsername = Schema.decodeUnknownSync(IdentityUsername); +const decodePersonId = Schema.decodeUnknownSync(PersonId); +const decodeSourceRef = Schema.decodeUnknownSync(SourceRef); +const decodeClientHint = Schema.decodeUnknownSync(ClientSourceHint); +const decodeSnapshot = Schema.decodeUnknownSync(IdentitySnapshot); +const decodeClaimInput = Schema.decodeUnknownSync(IdentityClaimInput); +const decodeClaim = Schema.decodeUnknownSync(SessionIdentityClaim); +const decodePerson = Schema.decodeUnknownSync(IdentityPersonPublic); +const decodeParticipant = Schema.decodeUnknownSync(ThreadParticipantSummary); + +describe("IdentityUsername / PersonId handles", () => { + it.each(["a", "pat", "patroza", "a_b-c", "julius", "user.name", "x1"])("accepts %s", (value) => { + expect(decodeUsername(value)).toBe(value.toLowerCase()); + expect(decodePersonId(value)).toBe(value.toLowerCase()); + }); + + it("normalizes case to lowercase", () => { + expect(decodeUsername("PatRoza")).toBe("patroza"); + expect(decodePersonId("PatRoza")).toBe("patroza"); + }); + + it("accepts usernames longer than 16 chars within soft max", () => { + const long = `a${"b".repeat(40)}`; + expect(decodeUsername(long)).toBe(long); + }); + + it.each([ + ["empty", ""], + ["spaces", "pat roza"], + ["control char", "foo\nbar"], + ["leading dash", "-pat"], + ["leading underscore", "_pat"], + ["at-sign", "pat@roza"], + ["leading dot", ".pat"], + ])("rejects %s", (_label, value) => { + expect(() => decodeUsername(value)).toThrow(); + expect(() => decodePersonId(value)).toThrow(); + }); + + it("rejects past soft max", () => { + expect(() => decodeUsername("a".repeat(IDENTITY_HANDLE_SOFT_MAX_LENGTH + 1))).toThrow(); + }); + + it("exports typeahead threshold of 3 characters", () => { + expect(IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS).toBe(3); + }); +}); + +describe("SourceRef vs ClientSourceHint", () => { + it("decodes a server stamp with person", () => { + const parsed = decodeSourceRef({ + channel: "desktop", + personId: "patroza", + username: "patroza", + }); + expect(parsed.channel).toBe("desktop"); + expect(parsed.personId).toBe("patroza"); + }); + + it("client hint has no person fields", () => { + const hint = decodeClientHint({ + channel: "discord", + location: { guildId: "1", channelId: "2" }, + actor: { platformId: "9", displayName: "Patrick" }, + }); + expect(hint.channel).toBe("discord"); + expect("personId" in hint).toBe(false); + }); + + it("rejects unknown channel", () => { + expect(() => decodeSourceRef({ channel: "irc" })).toThrow(); + }); +}); + +describe("IdentitySnapshot + claim", () => { + it("decodes an enabled map snapshot", () => { + const parsed = decodeSnapshot({ + enabled: true, + claimRequired: true, + people: [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + links: { + discordId: "95218063095377920", + githubLogin: "patroza", + }, + }, + ], + }); + expect(parsed.enabled).toBe(true); + expect(parsed.people[0]?.username).toBe("patroza"); + }); + + it("defaults empty links on person", () => { + const person = decodePerson({ + personId: PersonId.make("julius"), + username: "julius", + }); + expect(person.links).toEqual({}); + }); + + it("accepts claim by username or personId with optional method", () => { + expect(decodeClaimInput({ username: "patroza" })).toEqual({ username: "patroza" }); + expect(decodeClaimInput({ personId: "patroza", method: "settings" })).toEqual({ + personId: "patroza", + method: "settings", + }); + }); + + it("decodes a session claim", () => { + const claim = decodeClaim({ + sessionId: AuthSessionId.make("00000000-0000-4000-8000-000000000001"), + personId: "patroza", + username: "patroza", + claimedAt: "2026-07-30T12:00:00.000Z", + method: "typeahead", + }); + expect(claim.method).toBe("typeahead"); + }); + + it("decodes participant summary", () => { + const row = decodeParticipant({ + personId: "patroza", + username: "patroza", + firstChannel: "discord", + firstParticipatedAt: "2026-07-30T12:00:00.000Z", + }); + expect(row.firstChannel).toBe("discord"); + }); + + it("constructs IdentityError codes", () => { + const err = new IdentityError({ + code: "identity_unknown_person", + message: "not in map", + }); + expect(err.code).toBe("identity_unknown_person"); + }); +}); diff --git a/packages/contracts/src/identity.ts b/packages/contracts/src/identity.ts new file mode 100644 index 00000000000..ccb1104d0f4 --- /dev/null +++ b/packages/contracts/src/identity.ts @@ -0,0 +1,208 @@ +/** + * Session identity + message/thread source attribution. + * + * Closed-set people come from a server identity map file. Interactive clients + * claim a map person on their auth session; free-form usernames are rejected. + * + * Trust note (v1): interactive claim is **map membership only** — any paired + * session can claim any listed person. That is intentional for trusted-team + * shared environments, not anti-impersonation. “Mine” is claim-based and + * spoofable by peers with a session. Discord/Jira auto-claim binds via platform id. + * + * See docs/architecture/source-and-identity.md + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; +import { AuthSessionId, TrimmedNonEmptyString, IsoDateTime } from "./baseSchemas.ts"; + +// ── Username / person ────────────────────────────────────────── + +/** + * Soft max for wire abuse only — not a product length rule. + * Charset keeps handles safe for `user@channel` display and logs. + */ +export const IDENTITY_HANDLE_SOFT_MAX_LENGTH = 128; + +/** Minimum typed characters before the claim UI shows map suggestions. */ +export const IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS = 3; + +/** + * Handle charset: leading alnum, then alnum / `.` / `_` / `-`. + * No spaces or control chars. No minimum length product rule (single char OK). + */ +export const IDENTITY_HANDLE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; + +const normalizeHandle = (value: string) => value.trim().toLowerCase(); + +const IdentityHandleString = TrimmedNonEmptyString.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(normalizeHandle(value)), + encode: (value) => Effect.succeed(value), + }), + ), +).check( + Schema.isMaxLength(IDENTITY_HANDLE_SOFT_MAX_LENGTH), + Schema.isPattern(IDENTITY_HANDLE_PATTERN), +); + +export const IdentityUsername = IdentityHandleString.pipe(Schema.brand("IdentityUsername")); +export type IdentityUsername = typeof IdentityUsername.Type; + +/** Same normalization as username so mine/theirs compares stay case-stable. */ +export const PersonId = IdentityHandleString.pipe(Schema.brand("PersonId")); +export type PersonId = typeof PersonId.Type; + +// ── Channels / SourceRef ─────────────────────────────────────── + +export const SourceChannel = Schema.Literals([ + "desktop", + "web", + "mobile", + "discord", + "github", + "jira", + "slack", + "teams", + "bot", + "unknown", +]); +export type SourceChannel = typeof SourceChannel.Type; + +export const SourceLocation = Schema.Struct({ + guildId: Schema.optionalKey(TrimmedNonEmptyString), + channelId: Schema.optionalKey(TrimmedNonEmptyString), + threadId: Schema.optionalKey(TrimmedNonEmptyString), + owner: Schema.optionalKey(TrimmedNonEmptyString), + repo: Schema.optionalKey(TrimmedNonEmptyString), + number: Schema.optionalKey(Schema.Int), + kind: Schema.optionalKey(Schema.Literals(["pr", "issue"])), + projectKey: Schema.optionalKey(TrimmedNonEmptyString), + issueKey: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type SourceLocation = typeof SourceLocation.Type; + +export const SourceActor = Schema.Struct({ + platformId: Schema.optionalKey(TrimmedNonEmptyString), + displayName: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type SourceActor = typeof SourceActor.Type; + +/** + * Client may only hint non-person fields. Server stamps person from the + * session claim (or platform map for bots). Never trust client personId/username. + */ +export const ClientSourceHint = Schema.Struct({ + channel: Schema.optionalKey(SourceChannel), + location: Schema.optionalKey(SourceLocation), + actor: Schema.optionalKey(SourceActor), +}); +export type ClientSourceHint = typeof ClientSourceHint.Type; + +/** + * Server-authored provenance for a user-originated message / thread origin. + * personId/username absent only when an external actor is unmapped. + */ +export const SourceRef = Schema.Struct({ + channel: SourceChannel, + personId: Schema.optionalKey(PersonId), + username: Schema.optionalKey(IdentityUsername), + location: Schema.optionalKey(SourceLocation), + actor: Schema.optionalKey(SourceActor), +}); +export type SourceRef = typeof SourceRef.Type; + +/** Ordered participant on a thread shell (origin first when known). */ +export const ThreadParticipantSummary = Schema.Struct({ + personId: PersonId, + username: IdentityUsername, + name: Schema.optionalKey(TrimmedNonEmptyString), + firstChannel: Schema.optionalKey(SourceChannel), + firstParticipatedAt: IsoDateTime, +}); +export type ThreadParticipantSummary = typeof ThreadParticipantSummary.Type; + +// ── Public identity map (client-safe) ────────────────────────── + +export const IdentityPlatformLinkPublic = Schema.Struct({ + discordId: Schema.optionalKey(TrimmedNonEmptyString), + discordUsername: Schema.optionalKey(TrimmedNonEmptyString), + githubLogin: Schema.optionalKey(TrimmedNonEmptyString), + jiraAccountId: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type IdentityPlatformLinkPublic = typeof IdentityPlatformLinkPublic.Type; + +export const IdentityPersonPublic = Schema.Struct({ + personId: PersonId, + username: IdentityUsername, + name: Schema.optionalKey(TrimmedNonEmptyString), + links: IdentityPlatformLinkPublic.pipe(Schema.withDecodingDefault(Effect.succeed({}))), +}); +export type IdentityPersonPublic = typeof IdentityPersonPublic.Type; + +/** + * Snapshot of the closed identity set. + * v1: `claimRequired === enabled` (both true when map has people). + * Full people[] is intentional roster share for typeahead (not privacy isolation). + */ +export const IdentitySnapshot = Schema.Struct({ + /** False when map file missing/empty — no claim gate. */ + enabled: Schema.Boolean, + people: Schema.Array(IdentityPersonPublic), + /** v1 always equals `enabled`. */ + claimRequired: Schema.Boolean, +}); +export type IdentitySnapshot = typeof IdentitySnapshot.Type; + +export const SessionIdentityClaimMethod = Schema.Literals([ + "typeahead", + "settings", + "auto-discord", + "auto-jira", + "bootstrap", +]); +export type SessionIdentityClaimMethod = typeof SessionIdentityClaimMethod.Type; + +export const SessionIdentityClaim = Schema.Struct({ + sessionId: AuthSessionId, + personId: PersonId, + username: IdentityUsername, + claimedAt: IsoDateTime, + method: SessionIdentityClaimMethod, +}); +export type SessionIdentityClaim = typeof SessionIdentityClaim.Type; + +export const IdentityClaimInput = Schema.Union([ + Schema.Struct({ + personId: PersonId, + method: Schema.optionalKey(Schema.Literals(["typeahead", "settings", "bootstrap"])), + }), + Schema.Struct({ + username: IdentityUsername, + method: Schema.optionalKey(Schema.Literals(["typeahead", "settings", "bootstrap"])), + }), +]); +export type IdentityClaimInput = typeof IdentityClaimInput.Type; + +export const IdentityClaimResult = Schema.Struct({ + claim: SessionIdentityClaim, +}); +export type IdentityClaimResult = typeof IdentityClaimResult.Type; + +export const IdentitySessionClaimResult = Schema.Struct({ + claim: Schema.NullOr(SessionIdentityClaim), +}); +export type IdentitySessionClaimResult = typeof IdentitySessionClaimResult.Type; + +export class IdentityError extends Schema.TaggedErrorClass()("IdentityError", { + code: Schema.Literals([ + "identity_map_disabled", + "identity_unknown_person", + "identity_claim_required", + "identity_claim_missing", + "identity_map_invalid", + ]), + message: Schema.String, +}) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f0ee1889177..b032aaee197 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export * from "./baseSchemas.ts"; export * from "./background.ts"; export * from "./auth.ts"; +export * from "./identity.ts"; export * from "./environment.ts"; export * from "./environmentHttp.ts"; export * from "./relayClient.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b947bd63e4c..144738914dc 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -21,6 +21,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { SourceRef, ThreadParticipantSummary } from "./identity.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -231,6 +232,8 @@ export const OrchestrationMessage = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + /** Server-authored provenance; absent on legacy / assistant messages. */ + source: Schema.optional(SourceRef), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -374,6 +377,10 @@ export const OrchestrationThread = Schema.Struct({ activities: Schema.Array(OrchestrationThreadActivity), checkpoints: Schema.Array(OrchestrationCheckpointSummary), session: Schema.NullOr(OrchestrationSession), + originSource: Schema.optional(Schema.NullOr(SourceRef)), + participantSummaries: Schema.optional(Schema.Array(ThreadParticipantSummary)).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), }); export type OrchestrationThread = typeof OrchestrationThread.Type; @@ -423,6 +430,15 @@ export const OrchestrationThreadShell = Schema.Struct({ hasPendingApprovals: Schema.Boolean, hasPendingUserInput: Schema.Boolean, hasActionableProposedPlan: Schema.Boolean, + /** First user message SourceRef; null/absent on legacy threads. */ + originSource: Schema.optional(Schema.NullOr(SourceRef)), + /** + * Distinct people on user messages: origin person first, then first-participation order. + * Used for creator + +N participant stack. + */ + participantSummaries: Schema.optional(Schema.Array(ThreadParticipantSummary)).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; @@ -1027,6 +1043,8 @@ export const ThreadMessageSentPayload = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + /** Server-authored only; clients must not invent person fields. */ + source: Schema.optional(SourceRef), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -1401,6 +1419,8 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass { + it("uses two words from display name", () => { + expect(identityInitials({ username: "patroza", name: "Patrick Roza" })).toBe("PR"); + }); + + it("uses first two letters of a single name token", () => { + expect(identityInitials({ name: "Julius" })).toBe("JU"); + }); + + it("falls back to username", () => { + expect(identityInitials({ username: "patroza" })).toBe("PA"); + }); + + it("handles short username", () => { + expect(identityInitials({ username: "ab" })).toBe("AB"); + expect(identityInitials({ username: "x" })).toBe("X"); + }); + + it("returns ? when empty", () => { + expect(identityInitials({})).toBe("?"); + expect(identityInitials({ username: " ", name: "" })).toBe("?"); + }); + + it("handles CJK name without surrogate splits", () => { + expect(identityInitials({ name: "田中 太郎" })).toBe("田太"); + }); + + it("handles CJK username", () => { + expect(identityInitials({ username: "田中" })).toBe("田中"); + }); + + it("skips emoji-only name to username when possible", () => { + // emoji has no L/N letters — falls through to code points of name + const initials = identityInitials({ name: "😀😀", username: "pat" }); + expect(initials.length).toBeGreaterThan(0); + expect(initials).not.toMatch(/[\uD800-\uDFFF]/u); + }); +}); + +describe("identityAvatarColors", () => { + it("is deterministic for the same seed", () => { + expect(identityAvatarColors("patroza")).toEqual(identityAvatarColors("patroza")); + }); + + it("varies across different seeds when possible", () => { + const a = identityAvatarColors("patroza"); + const b = identityAvatarColors("julius"); + expect(IDENTITY_AVATAR_PALETTE).toContainEqual(a); + expect(IDENTITY_AVATAR_PALETTE).toContainEqual(b); + }); +}); + +describe("identityAvatar", () => { + it("combines initials, label, and colors", () => { + const avatar = identityAvatar({ + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + }); + expect(avatar.initials).toBe("PR"); + expect(avatar.label).toBe("Patrick Roza"); + expect(avatar.backgroundColor).toMatch(/^#/); + expect(avatar.color).toBe("#FFFFFF"); + }); + + it("keeps color seed on personId when username changes", () => { + const a = identityAvatar({ personId: "p1", username: "old" }); + const b = identityAvatar({ personId: "p1", username: "new" }); + expect(a.backgroundColor).toBe(b.backgroundColor); + }); +}); diff --git a/packages/shared/src/identityAvatar.ts b/packages/shared/src/identityAvatar.ts new file mode 100644 index 00000000000..ab817efe3b0 --- /dev/null +++ b/packages/shared/src/identityAvatar.ts @@ -0,0 +1,122 @@ +/** + * Deterministic micro-avatars from identity usernames (initials + color). + * + * Pure presentation helpers for web/mobile — no network, no assets. + * Real photo URLs can replace these later; seed stays `personId` / `username`. + * + * See docs/architecture/source-and-identity.md + */ + +export type IdentityAvatarColors = { + readonly backgroundColor: string; + readonly color: string; +}; + +export type IdentityAvatarModel = IdentityAvatarColors & { + /** 1–2 uppercase letters for the chip. */ + readonly initials: string; + /** Accessible label, usually the username or display name. */ + readonly label: string; +}; + +/** + * Fixed palette (background + readable foreground). Indexed by a stable hash of + * the person key so the same user always gets the same chip across clients. + * Colors are slightly muted so dense lists stay calm on dark/light UIs. + */ +export const IDENTITY_AVATAR_PALETTE: ReadonlyArray = [ + { backgroundColor: "#3B5BDB", color: "#FFFFFF" }, + { backgroundColor: "#0CA678", color: "#FFFFFF" }, + { backgroundColor: "#E67700", color: "#FFFFFF" }, + { backgroundColor: "#9C36B5", color: "#FFFFFF" }, + { backgroundColor: "#0B7285", color: "#FFFFFF" }, + { backgroundColor: "#C2255C", color: "#FFFFFF" }, + { backgroundColor: "#2F9E44", color: "#FFFFFF" }, + { backgroundColor: "#364FC7", color: "#FFFFFF" }, + { backgroundColor: "#D9480F", color: "#FFFFFF" }, + { backgroundColor: "#5F3DC4", color: "#FFFFFF" }, + { backgroundColor: "#087F5B", color: "#FFFFFF" }, + { backgroundColor: "#A61E4D", color: "#FFFFFF" }, +] as const; + +/** FNV-1a 32-bit — fast, stable, no deps. Not part of the public chip API. */ +function hashIdentitySeed(seed: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < seed.length; i++) { + hash ^= seed.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +export function identityAvatarColors(seed: string): IdentityAvatarColors { + const index = hashIdentitySeed(seed) % IDENTITY_AVATAR_PALETTE.length; + return IDENTITY_AVATAR_PALETTE[index]!; +} + +/** First up to `count` Unicode code points (not UTF-16 units). */ +function takeCodePoints(value: string, count: number): string { + const points: Array = []; + for (const point of value) { + if (point.trim().length === 0) continue; + points.push(point); + if (points.length >= count) break; + } + return points.join(""); +} + +/** + * Initials from display name when present, otherwise username. + * Uses code points so non-BMP / CJK handles do not split surrogates. + * - "Patrick Roza" → "PR" + * - "patroza" → "PA" + * - "田中" → "田中" + * - empty → "?" + */ +export function identityInitials(input: { + readonly username?: string | null | undefined; + readonly name?: string | null | undefined; +}): string { + const name = input.name?.trim() ?? ""; + if (name.length > 0) { + const words = name.replace(/[_-]+/gu, " ").split(/\s+/u).filter(Boolean); + if (words.length >= 2) { + const a = takeCodePoints(words[0]!, 1); + const b = takeCodePoints(words[1]!, 1); + const pair = `${a}${b}`; + if (pair.length > 0) return pair.toLocaleUpperCase(); + } + if (words.length === 1) { + const two = takeCodePoints(words[0]!, 2); + if (two.length > 0) return two.toLocaleUpperCase(); + } + } + + const username = input.username?.trim() ?? ""; + if (username.length === 0) return "?"; + // Prefer letter/number-like code points; fall back to raw username points. + const alnumLike = [...username].filter((ch) => /[\p{L}\p{N}]/u.test(ch)).join(""); + const source = alnumLike.length > 0 ? alnumLike : username; + const two = takeCodePoints(source, 2); + return two.length > 0 ? two.toLocaleUpperCase() : "?"; +} + +/** + * Build a micro-avatar model. Prefer `personId` as color seed when available so + * renames keep the same chip; fall back to username. + */ +export function identityAvatar(input: { + readonly personId?: string | null | undefined; + readonly username?: string | null | undefined; + readonly name?: string | null | undefined; +}): IdentityAvatarModel { + const username = input.username?.trim() ?? ""; + const name = input.name?.trim() ?? ""; + const seed = (input.personId?.trim() || username || name || "?").toLowerCase(); + const colors = identityAvatarColors(seed); + return { + initials: identityInitials({ username, name }), + label: name.length > 0 ? name : username.length > 0 ? username : "Unknown", + ...colors, + }; +} diff --git a/packages/shared/src/identityMap.test.ts b/packages/shared/src/identityMap.test.ts new file mode 100644 index 00000000000..93e0d0d3574 --- /dev/null +++ b/packages/shared/src/identityMap.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseIdentityMapDocument, IdentityMapParseError } from "./identityMap.ts"; + +describe("parseIdentityMapDocument", () => { + it("parses people map with usernames", () => { + const people = parseIdentityMapDocument({ + people: { + patroza: { + username: "patroza", + name: "Patrick Roza", + discord: { id: "95218063095377920" }, + github: { login: "patroza", id: "42661" }, + }, + julius: { + username: "Julius", + name: "Julius", + }, + }, + }); + expect(people).toHaveLength(2); + expect(people[0]?.username).toBe("patroza"); + expect(people[1]?.username).toBe("julius"); + expect(people[1]?.personId).toBe("julius"); + }); + + it("rejects free-form invalid usernames", () => { + expect(() => + parseIdentityMapDocument({ + people: [{ username: "pat roza", name: "Bad" }], + }), + ).toThrow(IdentityMapParseError); + }); + + it("rejects duplicate usernames", () => { + expect(() => + parseIdentityMapDocument({ + people: [ + { username: "a", personId: "a" }, + { username: "a", personId: "b" }, + ], + }), + ).toThrow(/duplicate username/); + }); + + it("returns empty for empty document", () => { + expect(parseIdentityMapDocument({})).toEqual([]); + expect(parseIdentityMapDocument({ people: [] })).toEqual([]); + }); +}); diff --git a/packages/shared/src/identityMap.ts b/packages/shared/src/identityMap.ts new file mode 100644 index 00000000000..786ebbe012b --- /dev/null +++ b/packages/shared/src/identityMap.ts @@ -0,0 +1,248 @@ +/** + * Parse closed-set identity map documents (YAML/JSON). + * Shared by server (and later Discord bot) so ops keep one file format. + * + * See docs/architecture/source-and-identity.md + */ +import * as Schema from "effect/Schema"; + +export type IdentityMapDiscordRef = { + readonly id: string; + readonly username?: string | undefined; +}; + +export type IdentityMapGitHubRef = { + readonly login: string; + readonly id?: string | undefined; + readonly email?: string | undefined; + readonly name?: string | undefined; +}; + +export type IdentityMapJiraRef = { + readonly accountId?: string | undefined; + readonly email?: string | undefined; + readonly displayName?: string | undefined; +}; + +export type IdentityMapPerson = { + readonly personId: string; + readonly username: string; + readonly name?: string | undefined; + readonly discord?: IdentityMapDiscordRef | undefined; + readonly github?: IdentityMapGitHubRef | undefined; + readonly jira?: IdentityMapJiraRef | undefined; +}; + +export class IdentityMapParseError extends Error { + readonly _tag = "IdentityMapParseError"; + constructor( + readonly pathLabel: string, + message: string, + ) { + super(message); + this.name = "IdentityMapParseError"; + } +} + +const HANDLE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; +const HANDLE_MAX = 128; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeHandle(value: string, field: string, label: string): string { + const normalized = value.trim().toLowerCase(); + if ( + normalized.length === 0 || + normalized.length > HANDLE_MAX || + !HANDLE_PATTERN.test(normalized) + ) { + throw new IdentityMapParseError( + label, + `${field} must be a non-empty handle (max ${HANDLE_MAX}, pattern ${HANDLE_PATTERN}): got ${JSON.stringify(value)}`, + ); + } + return normalized; +} + +function asDiscordSnowflake(value: unknown): string | undefined { + const raw = asNonEmptyString(value); + if (raw === undefined) return undefined; + if (!/^\d{1,32}$/u.test(raw)) return undefined; + return raw; +} + +function normalizeLogin(value: unknown): string | undefined { + const raw = asNonEmptyString(value); + if (raw === undefined) return undefined; + const login = raw.replace(/^@/u, "").trim(); + if (login.length === 0) return undefined; + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u.test(login)) return undefined; + return login; +} + +function parsePerson(raw: unknown, indexLabel: string, keyHint?: string): IdentityMapPerson { + if (!isRecord(raw)) { + throw new IdentityMapParseError(indexLabel, "person entry must be an object"); + } + + const discordNested = isRecord(raw.discord) ? raw.discord : undefined; + const githubNested = isRecord(raw.github) ? raw.github : undefined; + const jiraNested = isRecord(raw.jira) ? raw.jira : undefined; + + const usernameRaw = + asNonEmptyString(raw.username) ?? + asNonEmptyString(raw.userName) ?? + (keyHint !== undefined && !/^\d+$/u.test(keyHint) ? keyHint : undefined); + if (usernameRaw === undefined) { + throw new IdentityMapParseError(indexLabel, 'missing required "username"'); + } + const username = normalizeHandle(usernameRaw, "username", indexLabel); + + const personIdRaw = asNonEmptyString(raw.personId) ?? asNonEmptyString(raw.id) ?? username; + const personId = normalizeHandle(personIdRaw, "personId", indexLabel); + + const name = asNonEmptyString(raw.name); + + const discordId = + asDiscordSnowflake(discordNested?.id) ?? + asDiscordSnowflake(raw.discordId) ?? + asDiscordSnowflake(raw.discord_id) ?? + (keyHint !== undefined ? asDiscordSnowflake(keyHint) : undefined); + const discordUsername = + asNonEmptyString(discordNested?.username) ?? + asNonEmptyString(raw.discordUsername) ?? + asNonEmptyString(raw.discord_username); + + const githubLogin = + normalizeLogin(githubNested?.login) ?? + normalizeLogin(raw.githubLogin) ?? + normalizeLogin(raw.github_login) ?? + normalizeLogin(raw.github); + const githubId = + asNonEmptyString(githubNested?.id)?.replace(/\D/gu, "") || + asNonEmptyString(raw.githubId)?.replace(/\D/gu, "") || + asNonEmptyString(raw.github_id)?.replace(/\D/gu, "") || + undefined; + const githubEmail = + asNonEmptyString(githubNested?.email) ?? + asNonEmptyString(raw.githubEmail) ?? + asNonEmptyString(raw.github_email); + const githubName = + asNonEmptyString(githubNested?.name) ?? + asNonEmptyString(raw.githubName) ?? + asNonEmptyString(raw.github_name); + + const jiraAccountId = + asNonEmptyString(jiraNested?.accountId) ?? + asNonEmptyString(raw.jiraAccountId) ?? + asNonEmptyString(raw.jira_account_id); + const jiraEmail = + asNonEmptyString(jiraNested?.email) ?? + asNonEmptyString(raw.jiraEmail) ?? + asNonEmptyString(raw.jira_email); + const jiraDisplayName = + asNonEmptyString(jiraNested?.displayName) ?? + asNonEmptyString(raw.jiraDisplayName) ?? + asNonEmptyString(raw.jira_display_name); + + return { + personId, + username, + ...(name !== undefined ? { name } : {}), + ...(discordId !== undefined + ? { + discord: { + id: discordId, + ...(discordUsername !== undefined ? { username: discordUsername } : {}), + }, + } + : {}), + ...(githubLogin !== undefined + ? { + github: { + login: githubLogin, + ...(githubId !== undefined && githubId.length > 0 ? { id: githubId } : {}), + ...(githubEmail !== undefined ? { email: githubEmail } : {}), + ...(githubName !== undefined ? { name: githubName } : {}), + }, + } + : {}), + ...(jiraAccountId !== undefined || jiraEmail !== undefined + ? { + jira: { + ...(jiraAccountId !== undefined ? { accountId: jiraAccountId } : {}), + ...(jiraEmail !== undefined ? { email: jiraEmail } : {}), + ...(jiraDisplayName !== undefined ? { displayName: jiraDisplayName } : {}), + }, + } + : {}), + }; +} + +/** + * Parse identity map document object (already JSON/YAML-parsed). + */ +export function parseIdentityMapDocument(document: unknown): ReadonlyArray { + if (document === null || document === undefined) return []; + if (!isRecord(document)) { + throw new IdentityMapParseError("root", "Identity map root must be an object."); + } + + const peopleNode = document.people; + let people: ReadonlyArray; + + if (Array.isArray(peopleNode)) { + people = peopleNode.map((entry, index) => parsePerson(entry, `[${index}]`)); + } else if (isRecord(peopleNode)) { + people = Object.entries(peopleNode).map(([key, value]) => + parsePerson(value, `people["${key}"]`, key), + ); + } else { + const reserved = new Set(["version", "schema", "$schema"]); + const entries = Object.entries(document).filter(([key]) => !reserved.has(key)); + if (entries.length === 0) return []; + people = entries.map(([key, value]) => parsePerson(value, `["${key}"]`, key)); + } + + const usernames = new Set(); + const personIds = new Set(); + for (const person of people) { + if (usernames.has(person.username)) { + throw new IdentityMapParseError(person.username, `duplicate username "${person.username}"`); + } + if (personIds.has(person.personId)) { + throw new IdentityMapParseError(person.personId, `duplicate personId "${person.personId}"`); + } + usernames.add(person.username); + personIds.add(person.personId); + } + + return people; +} + +export function toIdentityPersonPublic(person: IdentityMapPerson) { + return { + personId: person.personId, + username: person.username, + ...(person.name !== undefined ? { name: person.name } : {}), + links: { + ...(person.discord?.id !== undefined ? { discordId: person.discord.id } : {}), + ...(person.discord?.username !== undefined + ? { discordUsername: person.discord.username } + : {}), + ...(person.github?.login !== undefined ? { githubLogin: person.github.login } : {}), + ...(person.jira?.accountId !== undefined ? { jiraAccountId: person.jira.accountId } : {}), + }, + }; +} + +/** Schema re-export helper for tests that want branded contracts after parse. */ +export const IdentityMapPersonCount = Schema.Number;