From 8f6eaa0895a170d1fb503c1ac550bff483c8b65c Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 13:49:59 +0200 Subject: [PATCH 1/2] feat(snapshot): expose effective roles and the role/permission catalog The IdP now sends each subject's effective roles, with per-role expiry, and a catalog naming every role and projected permission (PoliNetworkOrg/auth#62). Both are additive to polinetwork.access-snapshot/v1 and optional in the consumer schema, so snapshots from older IdPs, including persisted ones, still parse. AccessSubject gains a roles map and AccessIndex gains hasRole(subject, role, now?), roles(subject, now?) and catalog(); the client gains hasRole(actor, role). A malformed catalog is dropped rather than failing the snapshot, since it is display data only. --- docs/contract.md | 7 +- fixtures/snapshot-v1.json | 141 ++++++++++++++++++++++++++++++++++ src/contract/index.ts | 1 + src/contract/snapshot.ts | 23 +++++- src/index.ts | 7 +- src/snapshot/access-index.ts | 69 ++++++++++++++--- src/snapshot/client.ts | 7 ++ tests/snapshot-client.test.ts | 25 ++++++ tests/snapshot.test.ts | 60 +++++++++++++++ 9 files changed, 325 insertions(+), 15 deletions(-) diff --git a/docs/contract.md b/docs/contract.md index 40e749b..9930e37 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -40,9 +40,12 @@ The executable form of this contract is the `@polinetwork/auth-kit/contract` ent ### 2.1 Content - **Permissions are fully expanded.** Roles are resolved up through their parents and permissions down through their implications, transitively, in the IdP. The SDK only ever does a key lookup and repeats no role logic. -- **Master Admin is written out, never `*`.** Its entry lists every catalog key under the projection's prefixes. Break-glass admins get `validUntil: null`; members of the Entra admin group get that group's `observedAt` + 1 h. +- **Master Admin is written out, never `*`.** Its entry lists every catalog key the projection carries. Break-glass admins get `validUntil: null`; members of the Entra admin group get that group's `observedAt` + 1 h. - **Validity per key:** an implied key inherits the validity of the path that granted it. When several paths grant a key, the latest expiry wins and `null` beats any date. -- **Keys outside the projection's prefixes never appear,** even when implied by a key that does. +- **The `backend` projection carries every permission except the IdP's own `idp:*` ones,** which never appear, even when implied by a key that does. +- **`roles` lists the effective roles** under the same expiry rules: every granted role plus every role it inherits from through its parents. A subject is still included only for its permissions. The SDK reads roles as given (`hasRole`, `roles`) and repeats no hierarchy logic. +- **`catalog` names every role and every projected permission** (`{ key, name }`, sorted by key), for pickers. It is display data only: when it is missing or malformed, `catalog()` returns empty lists and the snapshot is still accepted. +- **IdPs before roles were projected** send neither `roles` nor `catalog`. Those snapshots, including persisted ones, still parse, with no roles and an empty catalog. - **Absent means no permissions.** People without a projected permission are omitted, including people who linked Telegram. An unknown `sub` or Telegram ID is not an error. - **`telegramId` may be `null` for someone who linked Telegram,** when the link is ambiguous (one person with several IDs, or one ID shared by several people). Duplicates therefore cannot appear; the SDK still rejects them as a backstop. - **`telegramId` is a decimal string** matching `^[1-9]\d*$`. diff --git a/fixtures/snapshot-v1.json b/fixtures/snapshot-v1.json index ab01402..7f64670 100644 --- a/fixtures/snapshot-v1.json +++ b/fixtures/snapshot-v1.json @@ -25,10 +25,125 @@ "observedAt": "2026-10-09T10:15:02.123Z" } }, + "catalog": { + "roles": [ + { + "key": "auditor", + "name": "Auditor" + }, + { + "key": "direttivo", + "name": "Direttivo" + }, + { + "key": "master-admin", + "name": "Master Admin" + }, + { + "key": "moderator", + "name": "Moderator" + }, + { + "key": "socio", + "name": "Socio" + }, + { + "key": "student", + "name": "Student" + }, + { + "key": "web-editor", + "name": "Web editor" + } + ], + "permissions": [ + { + "key": "admin:access", + "name": "Admin access" + }, + { + "key": "azure:members:create", + "name": "Azure members create" + }, + { + "key": "groups:labels:write", + "name": "Groups labels write" + }, + { + "key": "tg:audit:read", + "name": "Tg audit read" + }, + { + "key": "tg:bot:add", + "name": "Tg bot add" + }, + { + "key": "tg:grants:manage", + "name": "Tg grants manage" + }, + { + "key": "tg:grants:read", + "name": "Tg grants read" + }, + { + "key": "tg:groups:manage", + "name": "Tg groups manage" + }, + { + "key": "tg:immune", + "name": "Tg immune" + }, + { + "key": "tg:messages:delete", + "name": "Tg messages delete" + }, + { + "key": "tg:messages:pin", + "name": "Tg messages pin" + }, + { + "key": "tg:messages:read", + "name": "Tg messages read" + }, + { + "key": "tg:moderate", + "name": "Tg moderate" + }, + { + "key": "tg:moderate:global", + "name": "Tg moderate global" + }, + { + "key": "tg:trusted", + "name": "Tg trusted" + }, + { + "key": "tg:users:read", + "name": "Tg users read" + }, + { + "key": "wa:groups:manage", + "name": "Wa groups manage" + }, + { + "key": "web:content:write", + "name": "Web content write" + }, + { + "key": "web:reports:manage", + "name": "Web reports manage" + } + ] + }, "subjects": [ { "sub": "usr_admin_breakglass", "telegramId": "100000001", + "roles": { + "master-admin": { + "validUntil": null + } + }, "permissions": { "admin:access": { "validUntil": null @@ -92,6 +207,11 @@ { "sub": "usr_admin_entra", "telegramId": null, + "roles": { + "master-admin": { + "validUntil": "2026-10-09T11:14:58.001Z" + } + }, "permissions": { "admin:access": { "validUntil": "2026-10-09T11:14:58.001Z" @@ -155,6 +275,17 @@ { "sub": "usr_direttivo", "telegramId": "555", + "roles": { + "auditor": { + "validUntil": null + }, + "direttivo": { + "validUntil": "2026-10-09T11:14:31.456Z" + }, + "socio": { + "validUntil": "2026-10-09T11:14:31.456Z" + } + }, "permissions": { "admin:access": { "validUntil": "2026-10-09T11:14:31.456Z" @@ -179,6 +310,11 @@ { "sub": "usr_moderator", "telegramId": "123456789", + "roles": { + "moderator": { + "validUntil": null + } + }, "permissions": { "tg:messages:delete": { "validUntil": null @@ -197,6 +333,11 @@ { "sub": "usr_web", "telegramId": null, + "roles": { + "web-editor": { + "validUntil": null + } + }, "permissions": { "admin:access": { "validUntil": null diff --git a/src/contract/index.ts b/src/contract/index.ts index 93883f0..10b55dc 100644 --- a/src/contract/index.ts +++ b/src/contract/index.ts @@ -19,6 +19,7 @@ export { } from "./constants" export { type AccessChangedEventClaims, accessChangedEventClaims, accessChangedEventSchema } from "./event" export { + type AccessCatalogV1, type AccessSnapshotBody, type AccessSnapshotV1, type AccessSourceV1, diff --git a/src/contract/snapshot.ts b/src/contract/snapshot.ts index 3478683..69b2b13 100644 --- a/src/contract/snapshot.ts +++ b/src/contract/snapshot.ts @@ -7,6 +7,14 @@ const timestamp = z.iso.datetime({ offset: true }) /** Major version 1 only; minor revisions add fields, which are ignored. */ const schemaId = z.string().regex(/^polinetwork\.access-snapshot\/v1(\.\d+)?$/, "unsupported snapshot schema") +const expiries = z.record(z.string().min(1), z.object({ validUntil: timestamp.nullable() })) + +// Display names only, so a malformed list is dropped rather than failing the snapshot. +const names = z + .array(z.object({ key: z.string().min(1), name: z.string() })) + .optional() + .catch(undefined) + /** * Consumer schema for `polinetwork.access-snapshot/v1`. Deliberately lenient: source keys, * health values and error codes are open sets, and unknown fields are ignored, so the IdP can @@ -25,6 +33,8 @@ export const accessSnapshotSchema = z.object({ error: z.string().optional(), }) ), + /** Absent from IdPs before roles were projected. */ + catalog: z.object({ roles: names, permissions: names }).optional().catch(undefined), subjects: z.array( z.object({ sub: z.string().min(1), @@ -32,7 +42,9 @@ export const accessSnapshotSchema = z.object({ .string() .regex(/^[1-9]\d*$/) .nullable(), - permissions: z.record(z.string().min(1), z.object({ validUntil: timestamp.nullable() })), + /** Absent from IdPs before roles were projected. */ + roles: expiries.optional(), + permissions: expiries, }) ), }) @@ -54,15 +66,24 @@ export type AccessSubjectV1 = { sub: string /** Decimal string; null when not linked or ambiguous. */ telegramId: string | null + /** Granted roles plus every role they inherit from; `validUntil: null` never expires. */ + roles: Record /** Fully expanded keys; `validUntil: null` never expires. */ permissions: Record } +/** Sorted by key. Every role, and every permission the projection can carry. */ +export type AccessCatalogV1 = { + roles: { key: string; name: string }[] + permissions: { key: string; name: string }[] +} + export type AccessSnapshotV1 = { schema: typeof SNAPSHOT_SCHEMA projection: string generation: number builtAt: string sources: Record + catalog: AccessCatalogV1 subjects: AccessSubjectV1[] } diff --git a/src/index.ts b/src/index.ts index 8e0af89..737a84b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,12 @@ export { type KeyStoreStatus, } from "./key-store" export { memoryPersistence, type Persistence } from "./persistence" -export { AccessIndex, type AccessSubject } from "./snapshot/access-index" +export { + type AccessCatalog, + type AccessCatalogEntry, + AccessIndex, + type AccessSubject, +} from "./snapshot/access-index" export { type AccessSnapshotClient, type AccessSnapshotOptions, diff --git a/src/snapshot/access-index.ts b/src/snapshot/access-index.ts index da51cdf..fc71597 100644 --- a/src/snapshot/access-index.ts +++ b/src/snapshot/access-index.ts @@ -4,10 +4,44 @@ import { AuthKitError } from "../errors" export type AccessSubject = Readonly<{ sub: string telegramId: string | null + /** Effective role key → expiry in epoch milliseconds; `null` never expires. */ + roles: ReadonlyMap /** Permission key → expiry in epoch milliseconds; `null` never expires. */ permissions: ReadonlyMap }> +export type AccessCatalogEntry = Readonly<{ key: string; name: string }> + +/** Names to show in a picker; empty lists when the IdP does not send them. */ +export type AccessCatalog = Readonly<{ + roles: readonly AccessCatalogEntry[] + permissions: readonly AccessCatalogEntry[] +}> + +type Expiries = Record + +function epochs(entries: Expiries = {}): Map { + return new Map( + Object.entries(entries).map(([key, { validUntil }]) => [key, validUntil === null ? null : Date.parse(validUntil)]) + ) +} + +function entries(list: readonly AccessCatalogEntry[] = []): readonly AccessCatalogEntry[] { + return Object.freeze(list.map(({ key, name }) => Object.freeze({ key, name }))) +} + +/** A key is dropped once `now ≥ validUntil`, whatever the sync state (RFC §5.3). */ +function holds(expiries: ReadonlyMap | undefined, key: string, now: number): boolean { + const validUntil = expiries?.get(key) + if (validUntil === undefined) return false + return validUntil === null || now < validUntil +} + +function valid(expiries: ReadonlyMap | undefined, now: number): string[] { + if (!expiries) return [] + return [...expiries.keys()].filter((key) => holds(expiries, key, now)).sort() +} + /** * One validated snapshot, indexed by `sub` and Telegram ID. Immutable, so a client swaps it in * with a single assignment and readers never see a half-applied snapshot. @@ -20,6 +54,7 @@ export class AccessIndex { readonly generation: number readonly builtAt: string readonly sources: AccessSnapshotBody["sources"] + readonly #catalog: AccessCatalog readonly #bySub: ReadonlyMap readonly #byTelegramId: ReadonlyMap @@ -32,6 +67,10 @@ export class AccessIndex { this.generation = body.generation this.builtAt = body.builtAt this.sources = body.sources + this.#catalog = Object.freeze({ + roles: entries(body.catalog?.roles), + permissions: entries(body.catalog?.permissions), + }) this.#bySub = bySub this.#byTelegramId = byTelegramId } @@ -44,12 +83,8 @@ export class AccessIndex { const subject: AccessSubject = Object.freeze({ sub: raw.sub, telegramId: raw.telegramId, - permissions: new Map( - Object.entries(raw.permissions).map(([key, { validUntil }]) => [ - key, - validUntil === null ? null : Date.parse(validUntil), - ]) - ), + roles: epochs(raw.roles), + permissions: epochs(raw.permissions), }) if (bySub.has(subject.sub)) throw new AuthKitError("snapshot_invalid", `Duplicate sub ${subject.sub}`) bySub.set(subject.sub, subject) @@ -78,14 +113,26 @@ export class AccessIndex { * `now ≥ validUntil`, whatever the sync state (RFC §5.3). Staleness is the client's concern. */ has(subject: AccessSubject | undefined, permission: string, now = Date.now()): boolean { - const validUntil = subject?.permissions.get(permission) - if (validUntil === undefined) return false - return validUntil === null || now < validUntil + return holds(subject?.permissions, permission, now) } /** The subject's permissions that are still valid at `now`, sorted. */ permissions(subject: AccessSubject | undefined, now = Date.now()): string[] { - if (!subject) return [] - return [...subject.permissions.keys()].filter((key) => this.has(subject, key, now)).sort() + return valid(subject?.permissions, now) + } + + /** Whether the subject holds the role, directly or by inheritance, at `now`. Same expiry rule as `has`. */ + hasRole(subject: AccessSubject | undefined, role: string, now = Date.now()): boolean { + return holds(subject?.roles, role, now) + } + + /** The subject's effective roles that are still valid at `now`, sorted. */ + roles(subject: AccessSubject | undefined, now = Date.now()): string[] { + return valid(subject?.roles, now) + } + + /** Every role, and every permission the projection can carry, with display names. */ + catalog(): AccessCatalog { + return this.#catalog } } diff --git a/src/snapshot/client.ts b/src/snapshot/client.ts index 3e5f3b0..d29bb0d 100644 --- a/src/snapshot/client.ts +++ b/src/snapshot/client.ts @@ -51,6 +51,8 @@ export interface AccessSnapshotClient { subjectByTelegramId(telegramId: string | number): AccessSubject | undefined /** False for service actors, actors without `sub`, unknown subjects and a stale snapshot. */ has(actor: Actor, permission: string): boolean + /** Like `has`, for an effective role. */ + hasRole(actor: Actor, role: string): boolean status(): SnapshotStatus } @@ -187,6 +189,11 @@ export function accessSnapshot(options: AccessSnapshotOptions): AccessSnapshotCl const index = decisionIndex() return index ? index.has(index.bySub(actor.sub), permission, now()) : false }, + hasRole(actor, role) { + if (actor.kind === "service" || actor.sub === null) return false + const index = decisionIndex() + return index ? index.hasRole(index.bySub(actor.sub), role, now()) : false + }, status() { return { lastSyncAt, diff --git a/tests/snapshot-client.test.ts b/tests/snapshot-client.test.ts index e74849e..a733faf 100644 --- a/tests/snapshot-client.test.ts +++ b/tests/snapshot-client.test.ts @@ -52,6 +52,8 @@ describe("accessSnapshot", () => { expect(client.status()).toMatchObject({ fresh: true, generation: 4813, subjects: 5 }) expect(client.has(moderator, "tg:moderate")).toBe(true) expect(client.has(moderator, "tg:moderate:global")).toBe(false) + expect(client.hasRole(moderator, "moderator")).toBe(true) + expect(client.hasRole(moderator, "direttivo")).toBe(false) expect(client.subjectByTelegramId("555")?.sub).toBe("usr_direttivo") }) @@ -61,11 +63,16 @@ describe("accessSnapshot", () => { client.stop() expect(client.has({ kind: "service", client: "telegram-bot" }, "tg:moderate")).toBe(false) expect(client.has({ ...moderator, sub: null }, "tg:moderate")).toBe(false) + expect(client.hasRole({ kind: "service", client: "telegram-bot" }, "moderator")).toBe(false) + expect(client.hasRole({ ...moderator, sub: null }, "moderator")).toBe(false) const direttivo: Actor = { kind: "user", client: "admin-dashboard", sub: "usr_direttivo", telegramId: "555" } expect(client.has(direttivo, "tg:moderate:global")).toBe(true) + expect(client.hasRole(direttivo, "direttivo")).toBe(true) time.advance(55 * 60_000) expect(client.has(direttivo, "tg:moderate:global")).toBe(false) expect(client.has(direttivo, "tg:audit:read")).toBe(true) + expect(client.hasRole(direttivo, "direttivo")).toBe(false) + expect(client.hasRole(direttivo, "auditor")).toBe(true) }) it("revalidates with the ETag and counts a 304 as a sync", async () => { @@ -162,6 +169,24 @@ describe("accessSnapshot", () => { expect(restarted.client.has(moderator, "tg:moderate")).toBe(false) }) + it("resumes from a snapshot persisted before roles and the catalog were projected", async () => { + const persistence = memoryPersistence() + const body = JSON.parse(BODY) + delete body.catalog + for (const subject of body.subjects) delete subject.roles + await persistence.save( + "snapshot:backend", + JSON.stringify({ body: JSON.stringify(body), etag: ETAG, lastSyncAt: Date.parse("2026-10-09T10:19:00.000Z") }) + ) + const { client, errors } = setup(() => new Response(null, { status: 503 }), persistence) + await client.start() + client.stop() + expect(errors).toHaveLength(1) + expect(client.has(moderator, "tg:moderate")).toBe(true) + expect(client.hasRole(moderator, "moderator")).toBe(false) + expect(client.current()?.catalog()).toEqual({ roles: [], permissions: [] }) + }) + it("ignores a persisted snapshot dated in the future", async () => { const persistence = memoryPersistence() await persistence.save( diff --git a/tests/snapshot.test.ts b/tests/snapshot.test.ts index 9e98417..076674e 100644 --- a/tests/snapshot.test.ts +++ b/tests/snapshot.test.ts @@ -5,6 +5,14 @@ import { fixture } from "./fixtures" const BUILT_AT = Date.parse("2026-10-09T10:15:02.123Z") const DIRETTIVO_EXPIRY = Date.parse("2026-10-09T11:14:31.456Z") +/** The v1 fixture as an IdP sent it before roles and the catalog were projected. */ +function withoutRoles(): string { + const body = JSON.parse(fixture("snapshot-v1.json")) + delete body.catalog + for (const subject of body.subjects) delete subject.roles + return JSON.stringify(body) +} + function expectInvalid(body: string, projection = "backend") { expect(() => parseAccessSnapshot(body, projection)).toThrowError( expect.objectContaining({ name: "AuthKitError", code: "snapshot_invalid" }) as AuthKitError @@ -41,6 +49,58 @@ describe("parseAccessSnapshot", () => { expect(index.has(index.bySub("usr_admin_breakglass"), "tg:grants:manage", Number.MAX_SAFE_INTEGER)).toBe(true) }) + it("indexes effective roles and drops each once now reaches its validUntil", () => { + const index = parseAccessSnapshot(fixture("snapshot-v1.json"), "backend") + const direttivo = index.bySub("usr_direttivo") + expect(index.roles(direttivo, DIRETTIVO_EXPIRY - 1)).toEqual(["auditor", "direttivo", "socio"]) + expect(index.hasRole(direttivo, "socio", DIRETTIVO_EXPIRY - 1)).toBe(true) + expect(index.hasRole(direttivo, "socio", DIRETTIVO_EXPIRY)).toBe(false) + expect(index.roles(direttivo, DIRETTIVO_EXPIRY)).toEqual(["auditor"]) + expect(index.hasRole(index.bySub("usr_admin_breakglass"), "master-admin", Number.MAX_SAFE_INTEGER)).toBe(true) + expect(index.hasRole(undefined, "moderator")).toBe(false) + expect(index.roles(undefined)).toEqual([]) + }) + + it("exposes the role and permission catalog, frozen", () => { + const catalog = parseAccessSnapshot(fixture("snapshot-v1.json"), "backend").catalog() + expect(catalog.roles.map(({ key }) => key)).toEqual([ + "auditor", + "direttivo", + "master-admin", + "moderator", + "socio", + "student", + "web-editor", + ]) + expect(catalog.permissions[0]).toEqual({ key: "admin:access", name: "Admin access" }) + expect(catalog.permissions).toHaveLength(19) + expect(Object.isFrozen(catalog.roles)).toBe(true) + expect(Object.isFrozen(catalog.roles[0])).toBe(true) + }) + + it("accepts snapshots from before roles and the catalog", () => { + const index = parseAccessSnapshot(withoutRoles(), "backend") + const moderator = index.bySub("usr_moderator") + expect(moderator?.roles.size).toBe(0) + expect(index.roles(moderator)).toEqual([]) + expect(index.has(moderator, "tg:moderate", BUILT_AT)).toBe(true) + expect(index.catalog()).toEqual({ roles: [], permissions: [] }) + }) + + it("drops a malformed catalog rather than rejecting the snapshot", () => { + const body = JSON.parse(fixture("snapshot-v1.json")) + body.catalog.roles = [{ key: "" }] + expect(parseAccessSnapshot(JSON.stringify(body), "backend").catalog()).toMatchObject({ roles: [] }) + body.catalog = "roles" + expect(parseAccessSnapshot(JSON.stringify(body), "backend").catalog()).toEqual({ roles: [], permissions: [] }) + }) + + it("rejects a role with an invalid validUntil", () => { + const body = JSON.parse(fixture("snapshot-v1.json")) + body.subjects[0].roles["master-admin"] = { validUntil: "tomorrow" } + expectInvalid(JSON.stringify(body)) + }) + it("accepts degraded sources, empty snapshots and additive changes", () => { expect(parseAccessSnapshot(fixture("snapshot-degraded.json"), "backend").sources["entra:soci"]?.error).toBe( "graph_unavailable" From dd13cfda2eb4fd140bd165aad91cd50b660f59f8 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 14:02:04 +0200 Subject: [PATCH 2/2] docs(contract): clarify the Master Admin entry and malformed catalog lists --- docs/contract.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contract.md b/docs/contract.md index 9930e37..675a033 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -40,11 +40,11 @@ The executable form of this contract is the `@polinetwork/auth-kit/contract` ent ### 2.1 Content - **Permissions are fully expanded.** Roles are resolved up through their parents and permissions down through their implications, transitively, in the IdP. The SDK only ever does a key lookup and repeats no role logic. -- **Master Admin is written out, never `*`.** Its entry lists every catalog key the projection carries. Break-glass admins get `validUntil: null`; members of the Entra admin group get that group's `observedAt` + 1 h. +- **Master Admin is written out, never `*`.** Its entry lists every permission key the projection carries. Break-glass admins get `validUntil: null`; members of the Entra admin group get that group's `observedAt` + 1 h. - **Validity per key:** an implied key inherits the validity of the path that granted it. When several paths grant a key, the latest expiry wins and `null` beats any date. - **The `backend` projection carries every permission except the IdP's own `idp:*` ones,** which never appear, even when implied by a key that does. - **`roles` lists the effective roles** under the same expiry rules: every granted role plus every role it inherits from through its parents. A subject is still included only for its permissions. The SDK reads roles as given (`hasRole`, `roles`) and repeats no hierarchy logic. -- **`catalog` names every role and every projected permission** (`{ key, name }`, sorted by key), for pickers. It is display data only: when it is missing or malformed, `catalog()` returns empty lists and the snapshot is still accepted. +- **`catalog` names every role and every projected permission** (`{ key, name }`, sorted by key), for pickers. It is display data only: a missing or malformed `catalog` gives two empty lists, a malformed `roles` or `permissions` list empties only that list, and the snapshot is still accepted. - **IdPs before roles were projected** send neither `roles` nor `catalog`. Those snapshots, including persisted ones, still parse, with no roles and an empty catalog. - **Absent means no permissions.** People without a projected permission are omitted, including people who linked Telegram. An unknown `sub` or Telegram ID is not an error. - **`telegramId` may be `null` for someone who linked Telegram,** when the link is ambiguous (one person with several IDs, or one ID shared by several people). Duplicates therefore cannot appear; the SDK still rejects them as a backstop.