From 1185478b3fae5ad490e2fbdbd12a29a8b9208d5e Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 09:19:48 +0530 Subject: [PATCH 01/13] Canonicalise chat entity references on eNames in the adapter and Pictique Chat participants/admins and a message's sender used to be named by the id of the referent's User profile MetaEnvelope. That only worked because two implementations agreed on it, and it has no answer for the bootstrap case: a user whose eVault holds no profile envelope yet has no envelope id to emit, so a producer had to either block on provisioning one or quietly emit something else. An eName exists from the moment the eVault does. Adds an __ename() mapping directive so the shape of an entity reference lives in one place instead of being hand-parsed per platform, and a TTL profile cache to keep display-name hydration from becoming an N+1 now that a reference no longer dereferences to a full User record. Pictique's webhook previously ran ref.split("(")[1].split(")")[0] on every participant, which throws a TypeError on a bare eName and took ingest down for the whole envelope. Participants that cannot be resolved are now skipped and logged: members may legitimately live on a platform this instance knows nothing about, and one such member must not cost the room its other members. --- infrastructure/web3-adapter/src/index.ts | 9 + .../src/mapper/ename-directive.test.ts | 226 ++++++++++++++++++ .../web3-adapter/src/mapper/mapper.ts | 50 ++++ .../src/w3ds/ename-profile-cache.test.ts | 121 ++++++++++ .../src/w3ds/ename-profile-cache.ts | 107 +++++++++ infrastructure/web3-adapter/src/w3ds/ename.ts | 93 +++++++ platforms/pictique/api/package.json | 7 +- .../api/src/controllers/WebhookController.ts | 73 +++--- .../api/src/web3adapter/entity-refs.test.ts | 117 +++++++++ .../api/src/web3adapter/entity-refs.ts | 85 +++++++ .../web3adapter/mappings/chat.mapping.json | 6 +- .../web3adapter/mappings/message.mapping.json | 2 +- pnpm-lock.yaml | 122 +++------- 13 files changed, 881 insertions(+), 137 deletions(-) create mode 100644 infrastructure/web3-adapter/src/mapper/ename-directive.test.ts create mode 100644 infrastructure/web3-adapter/src/w3ds/ename-profile-cache.test.ts create mode 100644 infrastructure/web3-adapter/src/w3ds/ename-profile-cache.ts create mode 100644 infrastructure/web3-adapter/src/w3ds/ename.ts create mode 100644 platforms/pictique/api/src/web3adapter/entity-refs.test.ts create mode 100644 platforms/pictique/api/src/web3adapter/entity-refs.ts diff --git a/infrastructure/web3-adapter/src/index.ts b/infrastructure/web3-adapter/src/index.ts index eb15d8c64..b76a08de0 100644 --- a/infrastructure/web3-adapter/src/index.ts +++ b/infrastructure/web3-adapter/src/index.ts @@ -14,6 +14,15 @@ export type { UploadFileInput, UploadFileResult, } from "./evault/evault"; +export type { EName } from "./w3ds/ename"; +export { + isEName, + normaliseEName, + normaliseENameList, + toEName, +} from "./w3ds/ename"; +export type { ENameProfileCacheOptions } from "./w3ds/ename-profile-cache"; +export { ENameProfileCache } from "./w3ds/ename-profile-cache"; /** * Standalone function to spin up an eVault diff --git a/infrastructure/web3-adapter/src/mapper/ename-directive.test.ts b/infrastructure/web3-adapter/src/mapper/ename-directive.test.ts new file mode 100644 index 000000000..cc900bcd8 --- /dev/null +++ b/infrastructure/web3-adapter/src/mapper/ename-directive.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; +import type { MappingDatabase } from "../db"; +import { fromGlobal, toGlobal } from "./mapper"; +import type { IMapping } from "./mapper.types"; + +/** + * The `__ename()` paths never consult the mapping store — that is the whole + * point of them, since an eName is resolvable without a local id mapping — so a + * store that throws on use doubles as an assertion that it is never touched. + */ +const mappingStore = new Proxy({} as MappingDatabase, { + get(_target, prop) { + throw new Error( + `__ename() must not consult the mapping store (called ${String(prop)})`, + ); + }, +}); + +const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; +const BOB = "@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60"; + +const chatMapping: IMapping = { + tableName: "chats", + schemaId: "550e8400-e29b-41d4-a716-446655440003", + ownerEnamePath: "ename", + localToUniversalMap: { + name: "name", + ename: "ename", + participants: "__ename(participants[].ename),participantIds", + admins: "__ename(admins[].ename),admins", + }, +}; + +const messageMapping: IMapping = { + tableName: "messages", + schemaId: "550e8400-e29b-41d4-a716-446655440004", + ownerEnamePath: "ename", + localToUniversalMap: { + text: "content", + sender: "__ename(sender.ename),senderId", + }, +}; + +describe("__ename mapping directive", () => { + describe("toGlobal — producers emit eNames only", () => { + it("emits participants and admins as @-prefixed eNames", async () => { + const global = await toGlobal({ + data: { + ename: "@group", + name: "Standup", + participants: [{ ename: ALICE }, { ename: BOB }], + admins: [{ ename: ALICE }], + }, + mapping: chatMapping, + mappingStore, + }); + + expect(global.data.participantIds).toEqual([ALICE, BOB]); + expect(global.data.admins).toEqual([ALICE]); + }); + + it("adds the @ prefix to a bare W3ID so the wire format is uniform", async () => { + const global = await toGlobal({ + data: { + ename: "@group", + participants: [{ ename: ALICE.slice(1) }], + admins: [], + }, + mapping: chatMapping, + mappingStore, + }); + + expect(global.data.participantIds).toEqual([ALICE]); + }); + + it("never emits a legacy table(uuid) reference", async () => { + const global = await toGlobal({ + data: { + ename: "@group", + participants: [ + { ename: "users(3f8c1e2d-0000-4444-8888-aaaabbbbcccc)" }, + { ename: ALICE }, + ], + admins: [], + }, + mapping: chatMapping, + mappingStore, + }); + + // The envelope-id form is dropped rather than passed through: emitting + // it would put a reference on the wire that no consumer accepts. + expect(global.data.participantIds).toEqual([ALICE]); + }); + + it("drops unusable participant entries instead of emitting holes", async () => { + const global = await toGlobal({ + data: { + ename: "@group", + participants: [ + { ename: ALICE }, + { ename: null }, + { ename: "" }, + { ename: 42 }, + {}, + { ename: BOB }, + ], + admins: [], + }, + mapping: chatMapping, + mappingStore, + }); + + expect(global.data.participantIds).toEqual([ALICE, BOB]); + }); + + it("emits a message sender as a scalar eName", async () => { + const global = await toGlobal({ + data: { ename: "@group", text: "hi", sender: { ename: ALICE } }, + mapping: messageMapping, + mappingStore, + }); + + expect(global.data.senderId).toBe(ALICE); + }); + }); + + describe("fromGlobal — consumers accept eNames only", () => { + it("hands back an eName participant list unchanged", async () => { + const local = await fromGlobal({ + data: { ename: "@group", participantIds: [ALICE, BOB], admins: [ALICE] }, + mapping: chatMapping, + mappingStore, + }); + + expect(local.data.participants).toEqual([ALICE, BOB]); + expect(local.data.admins).toEqual([ALICE]); + }); + + it("survives a malformed participant list without throwing", async () => { + // Every entry here crashed the old `ref.split("(")[1].split(")")[0]` + // parsing, which took down the whole room with it. + const local = await fromGlobal({ + data: { + ename: "@group", + participantIds: [ALICE, null, 42, "", { nested: true }, [], BOB], + admins: [], + }, + mapping: chatMapping, + mappingStore, + }); + + expect(local.data.participants).toEqual([ALICE, BOB]); + }); + + it("rejects a legacy envelope-id reference rather than mangling it", async () => { + const local = await fromGlobal({ + data: { + ename: "@group", + participantIds: [ + "users(3f8c1e2d-0000-4444-8888-aaaabbbbcccc)", + "3f8c1e2d-0000-4444-8888-aaaabbbbcccc", + ALICE, + ], + admins: [], + }, + mapping: chatMapping, + mappingStore, + }); + + expect(local.data.participants).toEqual([ALICE]); + }); + + it("yields an empty list, not a throw, when participants is absent or scalar", async () => { + for (const participantIds of [undefined, null, "", 7, { a: 1 }]) { + const local = await fromGlobal({ + data: { ename: "@group", participantIds, admins: [] }, + mapping: chatMapping, + mappingStore, + }); + expect(local.data.participants).toEqual([]); + } + }); + + it("resolves a message senderId to a single eName", async () => { + const local = await fromGlobal({ + data: { ename: "@group", content: "hi", senderId: ALICE }, + mapping: messageMapping, + mappingStore, + }); + + expect(local.data.sender).toBe(ALICE); + }); + + it("yields null for an unusable senderId so the caller can tell it apart", async () => { + const local = await fromGlobal({ + data: { ename: "@group", content: "hi", senderId: "user(abc)" }, + mapping: messageMapping, + mappingStore, + }); + + expect(local.data.sender).toBeNull(); + }); + }); + + it("round-trips a chat through toGlobal and back", async () => { + const global = await toGlobal({ + data: { + ename: "@group", + name: "Standup", + participants: [{ ename: ALICE }, { ename: BOB }], + admins: [{ ename: ALICE }], + }, + mapping: chatMapping, + mappingStore, + }); + + const local = await fromGlobal({ + data: global.data, + mapping: chatMapping, + mappingStore, + }); + + expect(local.data.participants).toEqual([ALICE, BOB]); + expect(local.data.admins).toEqual([ALICE]); + }); +}); diff --git a/infrastructure/web3-adapter/src/mapper/mapper.ts b/infrastructure/web3-adapter/src/mapper/mapper.ts index e69b10b84..3c9230010 100644 --- a/infrastructure/web3-adapter/src/mapper/mapper.ts +++ b/infrastructure/web3-adapter/src/mapper/mapper.ts @@ -1,4 +1,5 @@ import type { EVaultClient } from "../evault/evault"; +import { normaliseENameList, toEName } from "../w3ds/ename"; import { dereferenceFileUri, referenceFileValue } from "../w3ds/resolver"; import { isFileUri } from "../w3ds/uri"; import type { @@ -14,6 +15,22 @@ import type { */ const FILE_DIRECTIVE_RE = /^__file\((.+?)\)(?:,(.+))?$/; +/** + * Matches the `__ename()` directive with an optional `,` suffix. + * + * `__ename()` marks a field as carrying entity references — the people in a + * chat, or the sender of a message. On `toGlobal` each value is rendered as an + * `@`-prefixed eName; on `fromGlobal` each is handed back as an eName with + * unusable entries dropped. + * + * This exists as a directive rather than as per-platform parsing because every + * platform previously hand-rolled `ref.split("(")[1].split(")")[0]`, which + * throws on any reference that is not the legacy `table(uuid)` form. Stating + * the intent in the mapping keeps the shape of an entity reference in one + * place, so it can only change in one place. + */ +const ENAME_DIRECTIVE_RE = /^__ename\((.+?)\)(?:,(.+))?$/; + /** * Dereferences a single file value: a `w3ds://file` URI becomes its public * object-storage URL; any other value is passed through unchanged. @@ -186,6 +203,20 @@ export async function fromGlobal({ continue; } + const enameMatch = globalPathRaw.match(ENAME_DIRECTIVE_RE); + if (enameMatch) { + const [, localPath, alias] = enameMatch; + const raw = getValueByPath(data, alias ?? localPath); + // Whether the field is a list is a property of the mapping, not of + // whatever happened to arrive. A participant list that shows up as + // `null` is still a list — an empty one — and must not collapse into + // a scalar that downstream code then iterates. + result[localKey] = localPath.includes("[]") + ? normaliseENameList(raw) + : (normaliseENameList(raw)[0] ?? null); + continue; + } + const internalFnMatch = globalPathRaw.match(/^__(\w+)\((.+)\)$/); if (internalFnMatch) { const [, outerFn, innerExpr] = internalFnMatch; @@ -329,6 +360,25 @@ export async function toGlobal({ continue; } + const enameMatch = globalPathRaw.match(ENAME_DIRECTIVE_RE); + if (enameMatch) { + const [, localPath, alias] = enameMatch; + const enameTargetKey = alias ?? localPath; + const rawVal = getValueByPath(data, localPath); + + if (localPath.includes("[]")) { + // Unrenderable entries are dropped rather than emitted as null: + // a participant list is a set of people, and a hole in it is not + // a person. + result[enameTargetKey] = Array.isArray(rawVal) + ? rawVal.map(toEName).filter((v): v is string => v !== null) + : []; + } else { + result[enameTargetKey] = toEName(rawVal) ?? undefined; + } + continue; + } + if (globalPathRaw.includes(",")) { const [_, alias] = globalPathRaw.split(","); targetKey = alias; diff --git a/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.test.ts b/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.test.ts new file mode 100644 index 000000000..2a4605272 --- /dev/null +++ b/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ENameProfileCache } from "./ename-profile-cache"; + +const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; +const BOB = "@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60"; + +describe("ENameProfileCache", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("loads once and serves repeats from cache", async () => { + const load = vi.fn(async (ename: string) => ({ name: ename })); + const cache = new ENameProfileCache({ load }); + + expect(await cache.get(ALICE)).toEqual({ name: ALICE }); + expect(await cache.get(ALICE)).toEqual({ name: ALICE }); + expect(await cache.get(ALICE)).toEqual({ name: ALICE }); + + expect(load).toHaveBeenCalledTimes(1); + }); + + it("collapses a concurrent burst into a single load", async () => { + // The room-render case: every participant tile asks at once. + const load = vi.fn( + async (ename: string) => + new Promise<{ name: string }>((resolve) => + setTimeout(() => resolve({ name: ename }), 10), + ), + ); + const cache = new ENameProfileCache({ load }); + + const results = await Promise.all([ + cache.get(ALICE), + cache.get(ALICE), + cache.get(ALICE), + ]); + + expect(results).toEqual([ + { name: ALICE }, + { name: ALICE }, + { name: ALICE }, + ]); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("caches a miss so unknown participants are not re-queried", async () => { + // A member on a platform this instance knows nothing about is a normal, + // permanent condition — not something to retry on every render. + const load = vi.fn(async () => null); + const cache = new ENameProfileCache({ load }); + + expect(await cache.get(ALICE)).toBeNull(); + expect(await cache.get(ALICE)).toBeNull(); + + expect(load).toHaveBeenCalledTimes(1); + }); + + it("reloads after the TTL expires", async () => { + vi.useFakeTimers(); + const load = vi.fn(async (ename: string) => ({ name: ename })); + const cache = new ENameProfileCache({ load, ttlMs: 1000 }); + + await cache.get(ALICE); + vi.advanceTimersByTime(1500); + await cache.get(ALICE); + + expect(load).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it("does not cache a failed lookup", async () => { + // A transient failure must not be remembered for the whole TTL. + const load = vi + .fn<(ename: string) => Promise<{ name: string } | null>>() + .mockRejectedValueOnce(new Error("registry down")) + .mockResolvedValueOnce({ name: ALICE }); + const cache = new ENameProfileCache({ load }); + + expect(await cache.get(ALICE)).toBeNull(); + expect(await cache.get(ALICE)).toEqual({ name: ALICE }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("resolves many eNames and omits the unknown ones", async () => { + const load = vi.fn(async (ename: string) => + ename === ALICE ? { name: "Alice" } : null, + ); + const cache = new ENameProfileCache({ load }); + + const found = await cache.getMany([ALICE, BOB, ALICE]); + + expect(found.get(ALICE)).toEqual({ name: "Alice" }); + expect(found.has(BOB)).toBe(false); + // ALICE appears twice in the input but is loaded once. + expect(load).toHaveBeenCalledTimes(2); + }); + + it("evicts oldest entries past the cap", async () => { + const load = vi.fn(async (ename: string) => ({ name: ename })); + const cache = new ENameProfileCache({ load, maxEntries: 2 }); + + await cache.get("@a"); + await cache.get("@b"); + await cache.get("@c"); // evicts @a + await cache.get("@a"); // reloads + + expect(load).toHaveBeenCalledTimes(4); + }); + + it("invalidates a single entry on demand", async () => { + const load = vi.fn(async (ename: string) => ({ name: ename })); + const cache = new ENameProfileCache({ load }); + + await cache.get(ALICE); + cache.invalidate(ALICE); + await cache.get(ALICE); + + expect(load).toHaveBeenCalledTimes(2); + }); +}); diff --git a/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.ts b/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.ts new file mode 100644 index 000000000..6e1bb9168 --- /dev/null +++ b/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.ts @@ -0,0 +1,107 @@ +/** + * A small TTL cache for eName → profile lookups. + * + * Under the old envelope-id scheme a participant reference dereferenced + * straight to a full User record, so display names and avatars arrived free as + * part of the mapping. An eName carries identity but no profile data, so + * hydrating a room's members is now a separate lookup per member — an N+1 on + * every render if left alone. This caches those lookups. + * + * Misses are cached too. A participant who lives on a platform this instance + * knows nothing about is a normal, permanent condition, and re-querying for + * them on every render is exactly the cost this exists to avoid. + */ +export interface ENameProfileCacheOptions { + /** Resolves one eName to a profile, or `null` when nobody is known by it. */ + load: (ename: string) => Promise; + /** How long an entry stays fresh. Defaults to five minutes. */ + ttlMs?: number; + /** Maximum entries retained. Defaults to 1000. */ + maxEntries?: number; +} + +interface CacheEntry { + value: T | null; + expiresAt: number; +} + +const DEFAULT_TTL_MS = 5 * 60 * 1000; +const DEFAULT_MAX_ENTRIES = 1000; + +export class ENameProfileCache { + private entries = new Map>(); + /** In-flight loads, so a burst for one eName makes a single query. */ + private inflight = new Map>(); + private readonly load: (ename: string) => Promise; + private readonly ttlMs: number; + private readonly maxEntries: number; + + constructor(options: ENameProfileCacheOptions) { + this.load = options.load; + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + } + + async get(ename: string): Promise { + const cached = this.entries.get(ename); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + this.entries.delete(ename); + + const existing = this.inflight.get(ename); + if (existing) return existing; + + const pending = this.load(ename) + .then((value) => { + this.set(ename, value); + return value; + }) + .catch((error) => { + // A failed lookup is not cached: unlike "nobody is known by this + // eName", a transient failure should not be remembered for the + // whole TTL. + console.warn(`[ename-cache] failed to load profile ${ename}:`, error); + return null; + }) + .finally(() => { + this.inflight.delete(ename); + }); + + this.inflight.set(ename, pending); + return pending; + } + + /** Resolves many eNames at once, returning only those that are known. */ + async getMany(enames: readonly string[]): Promise> { + const unique = [...new Set(enames)]; + const resolved = await Promise.all( + unique.map(async (ename) => [ename, await this.get(ename)] as const), + ); + + const found = new Map(); + for (const [ename, value] of resolved) { + if (value !== null && value !== undefined) found.set(ename, value); + } + return found; + } + + /** Drops an entry, for when a profile is known to have changed. */ + invalidate(ename: string): void { + this.entries.delete(ename); + } + + clear(): void { + this.entries.clear(); + } + + private set(ename: string, value: T | null): void { + // Oldest-first eviction. Insertion order is Map's iteration order, and a + // refreshed entry is deleted before being re-set, so it moves to the back. + if (this.entries.size >= this.maxEntries) { + const oldest = this.entries.keys().next(); + if (!oldest.done) this.entries.delete(oldest.value); + } + this.entries.set(ename, { value, expiresAt: Date.now() + this.ttlMs }); + } +} diff --git a/infrastructure/web3-adapter/src/w3ds/ename.ts b/infrastructure/web3-adapter/src/w3ds/ename.ts new file mode 100644 index 000000000..c75bba7c5 --- /dev/null +++ b/infrastructure/web3-adapter/src/w3ds/ename.ts @@ -0,0 +1,93 @@ +/** + * eNames are the canonical way a chat names the people in it. + * + * Chat `participantIds`/`admins`/`owner` and Message `senderId` all carry an + * entity reference. That reference used to be the id of the referent's User + * profile MetaEnvelope, which only worked because two implementations happened + * to agree on it. An eName is stable, self-describing, and resolvable without a + * profile envelope, so it is what those fields carry now. + * + * The bootstrap case is the reason this matters rather than merely being + * tidier: a user whose eVault holds no profile envelope yet has no envelope id + * to emit, so a producer had to either block on provisioning one or quietly + * emit something else. An eName exists from the moment the eVault does. + */ + +/** An `@`-prefixed W3ID, e.g. `@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0`. */ +export type EName = string; + +/** + * True for a non-empty `@`-prefixed string. + * + * Deliberately shape-only. Whether the eName resolves to anyone this platform + * knows about is a separate question, answered by the caller, because a chat + * may legitimately include members who live on a platform this instance has + * never heard of. + */ +export function isEName(value: unknown): value is EName { + return typeof value === "string" && value.startsWith("@") && value.length > 1; +} + +/** + * Coerces a bare W3ID to its `@`-prefixed form, and returns `null` for anything + * that is not a usable reference — `null`, numbers, `""`, nested objects, and + * the legacy `table(uuid)` form all land here. + * + * Returning `null` rather than throwing is the point. Inbound envelopes are + * written by other platforms on their own release schedules, so a single + * malformed or unrecognised entry must never take down the room it appears in. + */ +export function normaliseEName(value: unknown): EName | null { + if (typeof value !== "string") return null; + + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + // The legacy `users()` / `user()` reference form. It is no longer + // produced or accepted: an envelope id is not resolvable on its own, and + // silently treating one as an eName would reintroduce the mismatch where a + // chat replicates correctly and is then dropped on ingest with no error. + if (trimmed.includes("(") && trimmed.includes(")")) return null; + + if (trimmed.startsWith("@")) { + return trimmed.length > 1 ? trimmed : null; + } + + return null; +} + +/** + * Normalises a value that may be a single reference or a list of them, dropping + * every entry that is not a usable eName. + * + * A non-array, non-string input yields an empty list rather than throwing, so a + * participant field that arrives as `null` or an object degrades to "no + * participants named here" instead of failing the whole ingest. + */ +export function normaliseENameList(value: unknown): EName[] { + const entries = Array.isArray(value) ? value : [value]; + + const seen = new Set(); + for (const entry of entries) { + const ename = normaliseEName(entry); + if (ename) seen.add(ename); + } + return [...seen]; +} + +/** + * Renders a value as an eName for an outbound envelope. + * + * Producers hold a local user record whose `ename` column may or may not carry + * the `@`. Both are accepted here and the `@`-prefixed form is what goes on the + * wire, so consumers only ever have to recognise one shape. + */ +export function toEName(value: unknown): EName | null { + if (typeof value !== "string") return null; + + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + if (trimmed.includes("(") && trimmed.includes(")")) return null; + + return trimmed.startsWith("@") ? trimmed : `@${trimmed}`; +} diff --git a/platforms/pictique/api/package.json b/platforms/pictique/api/package.json index 593dec804..e7f3002c5 100644 --- a/platforms/pictique/api/package.json +++ b/platforms/pictique/api/package.json @@ -10,7 +10,9 @@ "typeorm": "typeorm-ts-node-commonjs", "migration:generate": "npm run typeorm migration:generate -- -d src/database/data-source.ts", "migration:run": "npm run typeorm migration:run -- -d src/database/data-source.ts", - "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts" + "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "axios": "^1.6.7", @@ -39,6 +41,7 @@ "eslint": "^8.56.0", "nodemon": "^3.0.3", "ts-node": "^10.9.2", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "vitest": "^3.1.2" } } diff --git a/platforms/pictique/api/src/controllers/WebhookController.ts b/platforms/pictique/api/src/controllers/WebhookController.ts index e8f67226f..f34c07354 100644 --- a/platforms/pictique/api/src/controllers/WebhookController.ts +++ b/platforms/pictique/api/src/controllers/WebhookController.ts @@ -9,6 +9,10 @@ import { Chat } from "database/entities/Chat"; import { Message } from "database/entities/Message"; import { MessageService } from "../services/MessageService"; import { Post } from "database/entities/Post"; +import { + resolveEntityRef, + resolveParticipants, +} from "../web3adapter/entity-refs"; import axios from "axios"; export class WebhookController { @@ -227,41 +231,21 @@ export class WebhookController { } let participants: User[] = []; - if ( - local.data.participants && - Array.isArray(local.data.participants) - ) { - const participantPromises = local.data.participants.map( - async (ref: string) => { - if (ref && typeof ref === "string") { - const userId = ref.split("(")[1].split(")")[0]; - return await this.userService.findById(userId); - } - return null; - } + if (local.data.participants !== undefined) { + participants = await resolveParticipants( + local.data.participants, + this.userService, + `chat ${globalId} participants` ); - participants = ( - await Promise.all(participantPromises) - ).filter((user): user is User => user !== null); } let admins: User[] = []; - if ( - local.data.admins && - Array.isArray(local.data.admins) - ) { - const adminPromises = local.data.admins.map( - async (ref: string) => { - if (ref && typeof ref === "string") { - const userId = ref.split("(")[1].split(")")[0]; - return await this.userService.findById(userId); - } - return null; - } + if (local.data.admins !== undefined) { + admins = await resolveParticipants( + local.data.admins, + this.userService, + `chat ${globalId} admins` ); - admins = ( - await Promise.all(adminPromises) - ).filter((user): user is User => user !== null); } if (localId) { @@ -388,20 +372,29 @@ export class WebhookController { const isSystemMessage = !local.data.sender || (typeof local.data.text === 'string' && local.data.text.startsWith('$$system-message$$')); let sender: User | null = null; - if ( - local.data.sender && - typeof local.data.sender === "string" - ) { - const senderId = local.data.sender - .split("(")[1] - .split(")")[0]; - sender = await this.userService.findById(senderId); + if (local.data.sender) { + sender = await resolveEntityRef( + local.data.sender, + this.userService, + `message ${globalId} sender` + ); } let chat: Chat | null = null; if (local.data.chat && typeof local.data.chat === "string") { - const chatId = local.data.chat.split("(")[1].split(")")[0]; - chat = await this.chatService.findById(chatId); + // Unlike a participant, the chat reference stays a local + // relation: it is resolved through the mapping store, not + // by eName. Guard the parse anyway so a malformed value + // logs and drops the message rather than throwing. + const chatId = local.data.chat.split("(")[1]?.split(")")[0]; + if (chatId) { + chat = await this.chatService.findById(chatId); + } else { + console.warn( + `[chat] message ${globalId}: unusable chat reference`, + local.data.chat + ); + } } // For system messages, we only need the chat, not the sender diff --git a/platforms/pictique/api/src/web3adapter/entity-refs.test.ts b/platforms/pictique/api/src/web3adapter/entity-refs.test.ts new file mode 100644 index 000000000..15f9fecc3 --- /dev/null +++ b/platforms/pictique/api/src/web3adapter/entity-refs.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveEntityRef, resolveParticipants } from "./entity-refs"; +import type { UserService } from "../services/UserService"; +import type { User } from "../database/entities/User"; + +const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; +const BOB = "@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60"; +/** Well-formed, but nobody on this instance answers to it. */ +const STRANGER = "@0c0ffee0-dead-4bee-8fee-000000000000"; + +function user(ename: string, name: string): User { + return { id: `local-${name}`, ename, name } as User; +} + +/** A UserService that knows Alice and Bob and nobody else. */ +function userService(): UserService { + const known = new Map([ + [ALICE, user(ALICE, "alice")], + [BOB, user(BOB, "bob")], + ]); + return { + findByEname: vi.fn(async (ename: string) => known.get(ename) ?? null), + findById: vi.fn(async () => { + throw new Error( + "entity references must resolve by eName, never by envelope id", + ); + }), + } as unknown as UserService; +} + +describe("chat entity references", () => { + describe("resolveParticipants", () => { + it("resolves a participant list of eNames", async () => { + const svc = userService(); + const resolved = await resolveParticipants([ALICE, BOB], svc, "test"); + + expect(resolved.map((u) => u.ename)).toEqual([ALICE, BOB]); + expect(svc.findById).not.toHaveBeenCalled(); + }); + + it("keeps the room when one participant is unresolvable", async () => { + // The core of the bug: a member on a platform this instance knows + // nothing about must not cost the room its other members. + const resolved = await resolveParticipants( + [ALICE, STRANGER, BOB], + userService(), + "test", + ); + + expect(resolved.map((u) => u.ename)).toEqual([ALICE, BOB]); + }); + + it("does not throw on malformed entries", async () => { + // Each of these crashed `ref.split("(")[1].split(")")[0]`. + const resolved = await resolveParticipants( + [ALICE, null, 42, "", { nested: true }, [], undefined, BOB], + userService(), + "test", + ); + + expect(resolved.map((u) => u.ename)).toEqual([ALICE, BOB]); + }); + + it("rejects legacy envelope-id references instead of resolving them", async () => { + const svc = userService(); + const resolved = await resolveParticipants( + ["users(local-alice)", "local-alice", ALICE], + svc, + "test", + ); + + expect(resolved.map((u) => u.ename)).toEqual([ALICE]); + expect(svc.findById).not.toHaveBeenCalled(); + }); + + it("returns an empty list for a non-array participants field", async () => { + for (const refs of [undefined, null, "", 7, { a: 1 }]) { + await expect( + resolveParticipants(refs, userService(), "test"), + ).resolves.toEqual([]); + } + }); + + it("survives a lookup that throws", async () => { + const svc = { + findByEname: vi.fn(async (ename: string) => { + if (ename === ALICE) throw new Error("db down"); + return user(BOB, "bob"); + }), + } as unknown as UserService; + + const resolved = await resolveParticipants([ALICE, BOB], svc, "test"); + expect(resolved.map((u) => u.ename)).toEqual([BOB]); + }); + }); + + describe("resolveEntityRef", () => { + it("attributes a message to the sender named by eName", async () => { + const sender = await resolveEntityRef(BOB, userService(), "test"); + expect(sender?.ename).toBe(BOB); + }); + + it("returns null rather than throwing for an unusable sender", async () => { + for (const ref of [null, undefined, "", 42, {}, "users(local-alice)"]) { + await expect( + resolveEntityRef(ref, userService(), "test"), + ).resolves.toBeNull(); + } + }); + + it("returns null for an eName nobody local answers to", async () => { + await expect( + resolveEntityRef(STRANGER, userService(), "test"), + ).resolves.toBeNull(); + }); + }); +}); diff --git a/platforms/pictique/api/src/web3adapter/entity-refs.ts b/platforms/pictique/api/src/web3adapter/entity-refs.ts new file mode 100644 index 000000000..6e5c5fd75 --- /dev/null +++ b/platforms/pictique/api/src/web3adapter/entity-refs.ts @@ -0,0 +1,85 @@ +import { normaliseEName, normaliseENameList } from "web3-adapter"; +import type { User } from "../database/entities/User"; +import type { UserService } from "../services/UserService"; + +/** + * Resolves the entity references in an inbound chat envelope to local users. + * + * Chat participants, admins, and a message's sender are named by eName. This + * turns those names into the local `User` rows that represent them, and is the + * only place that decides what happens when one of them cannot be resolved. + * + * Two rules, both of which used to be violated in ways that lost whole rooms: + * + * - A reference that is not a usable eName is skipped, not fatal. The old + * `ref.split("(")[1].split(")")[0]` threw a TypeError on any bare eName, + * which took down ingest for the entire envelope. + * - A well-formed eName that names nobody locally is also skipped, and logged. + * Members may legitimately live on a platform this instance knows nothing + * about, and one such member must not cost the room its other members. + */ +export async function resolveParticipants( + refs: unknown, + userService: UserService, + context: string, +): Promise { + const enames = normaliseENameList(refs); + + const skipped = countSkipped(refs, enames.length); + if (skipped > 0) { + console.warn( + `[chat] ${context}: skipped ${skipped} malformed entity reference(s)`, + ); + } + + const resolved = await Promise.all( + enames.map(async (ename) => { + const user = await userService.findByEname(ename).catch((error) => { + console.warn(`[chat] ${context}: lookup failed for ${ename}:`, error); + return null; + }); + if (!user) { + console.warn( + `[chat] ${context}: no local user for ${ename}, skipping participant`, + ); + } + return user; + }), + ); + + return resolved.filter((user): user is User => user !== null); +} + +/** + * Resolves a single entity reference, such as a message's sender. + * + * Returns `null` both for an unusable reference and for an eName nobody local + * answers to; the caller decides whether that is fatal for the record at hand. + */ +export async function resolveEntityRef( + ref: unknown, + userService: UserService, + context: string, +): Promise { + const ename = normaliseEName(ref); + if (!ename) { + if (ref !== null && ref !== undefined) { + console.warn(`[chat] ${context}: unusable entity reference`, ref); + } + return null; + } + + const user = await userService.findByEname(ename).catch((error) => { + console.warn(`[chat] ${context}: lookup failed for ${ename}:`, error); + return null; + }); + if (!user) { + console.warn(`[chat] ${context}: no local user for ${ename}`); + } + return user; +} + +function countSkipped(refs: unknown, kept: number): number { + const total = Array.isArray(refs) ? refs.length : refs == null ? 0 : 1; + return Math.max(0, total - kept); +} diff --git a/platforms/pictique/api/src/web3adapter/mappings/chat.mapping.json b/platforms/pictique/api/src/web3adapter/mappings/chat.mapping.json index f86b124fe..1758f0167 100644 --- a/platforms/pictique/api/src/web3adapter/mappings/chat.mapping.json +++ b/platforms/pictique/api/src/web3adapter/mappings/chat.mapping.json @@ -6,10 +6,10 @@ "localToUniversalMap": { "name": "name", "type": "type", - "participants": "users(participants[].id),participantIds", - "admins": "users(admins[].id),admins", + "participants": "__ename(participants[].ename),participantIds", + "admins": "__ename(admins[].ename),admins", "createdAt": "createdAt", "updatedAt": "updatedAt", "ename": "ename" } -} \ No newline at end of file +} diff --git a/platforms/pictique/api/src/web3adapter/mappings/message.mapping.json b/platforms/pictique/api/src/web3adapter/mappings/message.mapping.json index ad3e08514..2646f9b1b 100644 --- a/platforms/pictique/api/src/web3adapter/mappings/message.mapping.json +++ b/platforms/pictique/api/src/web3adapter/mappings/message.mapping.json @@ -5,7 +5,7 @@ "localToUniversalMap": { "chat": "chats(chat.id),chatId", "text": "content", - "sender": "users(sender.id),senderId", + "sender": "__ename(sender.ename),senderId", "createdAt": "createdAt", "updatedAt": "updatedAt" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7e15de67..2ca2a27f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3666,6 +3666,9 @@ importers: typescript: specifier: ^5.3.3 version: 5.8.2 + vitest: + specifier: ^3.1.2 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) platforms/pictique/client: dependencies: @@ -30226,6 +30229,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/utils': 3.2.4 + magic-string: 0.30.21 + sirv: 3.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + ws: 8.19.0(bufferutil@4.1.0) + optionalDependencies: + playwright: 1.58.2 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 @@ -30245,16 +30268,16 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/utils': 3.2.4 magic-string: 0.30.21 sirv: 3.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) ws: 8.19.0(bufferutil@4.1.0) optionalDependencies: playwright: 1.58.2 @@ -30374,15 +30397,6 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - optional: true - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -33317,8 +33331,8 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@2.6.1)) @@ -33381,21 +33395,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@5.5.0) - eslint: 9.39.4(jiti@2.6.1) - get-tsconfig: 4.13.6 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -33438,17 +33437,6 @@ snapshots: - supports-color eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -33488,35 +33476,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -33528,7 +33487,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -42539,25 +42498,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.8 - rollup: 4.59.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 20.19.26 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.31.1 - sass: 1.98.0 - terser: 5.46.0 - tsx: 4.21.0 - yaml: 2.8.2 - optional: true - vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.4 @@ -42749,7 +42689,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 20.19.26 - '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) + '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) jsdom: 19.0.0(bufferutil@4.1.0) transitivePeerDependencies: - jiti @@ -42793,7 +42733,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.19.15 - '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) + '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) jsdom: 19.0.0(bufferutil@4.1.0) transitivePeerDependencies: - jiti From cc74e2f2185cd3629ce8ce38032724463075ea8a Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 09:23:07 +0530 Subject: [PATCH 02/13] Accept and emit eNames for chat entity references in Blabsy mapChatData had no guard at all: it ran p.split("(")[1].split(")")[0] over every participant, so one bare eName threw a TypeError and lost the whole room. That is the likely root cause of Blabsy chats not syncing. A Blabsy user document is already keyed by the user's eName, so a participant reference needs no lookup once it is an eName; it is the local document id already. The mapping now emits eNames directly instead of round-tripping doc ids through the mapping store into envelope ids. The chat reference on a message stays a local relation in table(id) form, since it resolves through the mapping store rather than by eName, but its parse is guarded too. A message with no resolvable chat is skipped rather than written to a path built from the string "null". Chat and message envelope mapping moves out of the controller so it can be tested without a Firestore connection. --- platforms/blabsy/api/package.json | 11 +- .../api/src/controllers/WebhookController.ts | 106 +++---------- .../api/src/web3adapter/chat-mapping.test.ts | 139 ++++++++++++++++++ .../api/src/web3adapter/chat-mapping.ts | 134 +++++++++++++++++ .../web3adapter/mappings/chat.mapping.json | 4 +- .../web3adapter/mappings/message.mapping.json | 2 +- pnpm-lock.yaml | 12 +- 7 files changed, 305 insertions(+), 103 deletions(-) create mode 100644 platforms/blabsy/api/src/web3adapter/chat-mapping.test.ts create mode 100644 platforms/blabsy/api/src/web3adapter/chat-mapping.ts diff --git a/platforms/blabsy/api/package.json b/platforms/blabsy/api/package.json index a76507bd9..57fb7e667 100644 --- a/platforms/blabsy/api/package.json +++ b/platforms/blabsy/api/package.json @@ -11,8 +11,9 @@ "migration:generate": "npm run typeorm migration:generate -- -d src/database/data-source.ts", "migration:run": "npm run typeorm migration:run -- -d src/database/data-source.ts", "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts", - "test": "jest", - "sync:users-and-groups": "ts-node src/scripts/syncUsersAndGroups.ts" + "test": "vitest run", + "sync:users-and-groups": "ts-node src/scripts/syncUsersAndGroups.ts", + "test:watch": "vitest" }, "dependencies": { "axios": "^1.6.7", @@ -43,10 +44,8 @@ "eslint": "^8.56.0", "nodemon": "^3.0.3", "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", "typescript": "^5.3.3", - "@types/jest": "^29.5.12", - "jest": "^29.7.0", - "ts-jest": "^29.1.2", - "ts-node-dev": "^2.0.0" + "vitest": "^3.1.2" } } diff --git a/platforms/blabsy/api/src/controllers/WebhookController.ts b/platforms/blabsy/api/src/controllers/WebhookController.ts index 904beb348..d8ee01d58 100644 --- a/platforms/blabsy/api/src/controllers/WebhookController.ts +++ b/platforms/blabsy/api/src/controllers/WebhookController.ts @@ -1,5 +1,10 @@ import { Request, Response } from "express"; import { Web3Adapter } from "web3-adapter"; +import { + mapChatData as mapChatEnvelope, + mapMessageData as mapMessageEnvelope, + parseLocalRef, +} from "../web3adapter/chat-mapping"; import path from "path"; import dotenv from "dotenv"; import { getFirestore } from "firebase-admin/firestore"; @@ -59,7 +64,8 @@ type Chat = { type Message = { id: string; - chatId: string; + /** null when the inbound reference was unusable; the record is skipped. */ + chatId: string | null; senderId: string | null; // null for system messages text: string; createdAt: Timestamp; @@ -153,12 +159,20 @@ export class WebhookController { } private async createRecord(tableName: string, data: any, globalId: string) { - const chatId = data.chatId - ? data.chatId.split("(")[1].split(")")[0] - : null; + const chatId = parseLocalRef(data.chatId); let collection; - if (tableName === "messages" && data.chatId) { + if (tableName === "messages") { + // A message lives in a subcollection of its chat, so with no + // resolvable chat there is nowhere to put it. Skip it rather than + // writing it under a path built from "null". + if (!chatId) { + console.warn( + `Skipping message ${globalId}: unusable chat reference`, + data.chatId, + ); + return; + } collection = this.db.collection(`chats/${chatId}/messages`); } else { collection = this.db.collection(tableName); @@ -387,88 +401,10 @@ export class WebhookController { } private mapChatData(data: any, now: Timestamp): Partial { - const participants = data.participants.map( - (p: string) => p.split("(")[1].split(")")[0], - ) || []; - const admins = (data.admins ?? []).map( - (p: string) => p.split("(")[1].split(")")[0], - ) || []; - - - // Derive type from participant count - const type = participants.length > 2 ? "group" : "direct"; - - // Log ename processing for debugging - if (data.ename) { - console.log(`Processing chat with ename: ${data.ename}`); - } - - return { - type, - name: data.name, - participants, - ename: data.ename || null, // Include eVault identifier if available - admins: admins, - createdAt: data.createdAt - ? Timestamp.fromDate(new Date(data.createdAt)) - : now, - updatedAt: now, - lastMessage: data.lastMessage - ? { - ...data.lastMessage, - timestamp: Timestamp.fromDate( - new Date(data.lastMessage.timestamp), - ), - } - : null, - }; + return mapChatEnvelope(data, now) as Partial; } private mapMessageData(data: any, now: Timestamp): Partial { - // Check if this is a system message - const isSystemMessage = !data.senderId || data.text?.startsWith('$$system-message$$'); - - // For system messages, we don't need a sender - if (isSystemMessage) { - return { - chatId: data.chatId.split("(")[1].split(")")[0], - senderId: null, // System messages have no sender - text: data.text, - createdAt: data.createdAt - ? Timestamp.fromDate(new Date(data.createdAt)) - : now, - updatedAt: now, - readBy: data.readBy || [], - isSystemMessage: true, - }; - } - - // Regular user messages - ensure senderId exists before splitting - if (!data.senderId) { - console.warn("Message has no senderId but is not a system message:", data); - return { - chatId: data.chatId.split("(")[1].split(")")[0], - senderId: null, - text: data.text, - createdAt: data.createdAt - ? Timestamp.fromDate(new Date(data.createdAt)) - : now, - updatedAt: now, - readBy: data.readBy || [], - isSystemMessage: true, // Treat as system message if no sender - }; - } - - return { - chatId: data.chatId.split("(")[1].split(")")[0], - senderId: data.senderId.split("(")[1].split(")")[0], - text: data.text, - createdAt: data.createdAt - ? Timestamp.fromDate(new Date(data.createdAt)) - : now, - updatedAt: now, - readBy: data.readBy || [], - isSystemMessage: false, - }; + return mapMessageEnvelope(data, now) as Partial; } } diff --git a/platforms/blabsy/api/src/web3adapter/chat-mapping.test.ts b/platforms/blabsy/api/src/web3adapter/chat-mapping.test.ts new file mode 100644 index 000000000..0188a85de --- /dev/null +++ b/platforms/blabsy/api/src/web3adapter/chat-mapping.test.ts @@ -0,0 +1,139 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { describe, expect, it } from "vitest"; +import { mapChatData, mapMessageData, parseLocalRef } from "./chat-mapping"; + +const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; +const BOB = "@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60"; +const CAROL = "@0c0ffee0-dead-4bee-8fee-000000000000"; + +const now = Timestamp.fromDate(new Date("2026-01-01T00:00:00Z")); + +describe("blabsy chat envelope mapping", () => { + describe("mapChatData", () => { + it("keeps eName participants as-is, since user docs are keyed by eName", async () => { + const chat = mapChatData( + { ename: "@group", participants: [ALICE, BOB], admins: [ALICE] }, + now, + ); + + expect(chat.participants).toEqual([ALICE, BOB]); + expect(chat.admins).toEqual([ALICE]); + }); + + it("ingests a chat whose participants are all eNames", () => { + const chat = mapChatData( + { ename: "@group", name: "Standup", participants: [ALICE, BOB] }, + now, + ); + + expect(chat.type).toBe("direct"); + expect(chat.name).toBe("Standup"); + expect(chat.ename).toBe("@group"); + }); + + it("does not throw on malformed entries, and still ingests the room", () => { + // Every one of these crashed the old unguarded + // `p.split("(")[1].split(")")[0]`. + const chat = mapChatData( + { + ename: "@group", + participants: [ALICE, null, 42, "", { nested: true }, [], BOB], + admins: null, + }, + now, + ); + + expect(chat.participants).toEqual([ALICE, BOB]); + expect(chat.admins).toEqual([]); + }); + + it("survives participants being absent entirely", () => { + expect(() => mapChatData({ ename: "@group" }, now)).not.toThrow(); + expect(mapChatData({ ename: "@group" }, now).participants).toEqual([]); + }); + + it("drops legacy envelope-id references rather than accepting them", () => { + const chat = mapChatData( + { + ename: "@group", + participants: ["user(3f8c1e2d-0000-4444-8888-aaaabbbbcccc)", ALICE], + }, + now, + ); + + expect(chat.participants).toEqual([ALICE]); + }); + + it("derives group type from the surviving participant count", () => { + expect( + mapChatData({ participants: [ALICE, BOB, CAROL] }, now).type, + ).toBe("group"); + expect(mapChatData({ participants: [ALICE, BOB] }, now).type).toBe( + "direct", + ); + }); + }); + + describe("mapMessageData", () => { + it("attributes a message to the eName that sent it", () => { + const message = mapMessageData( + { chatId: "chat(local-chat-1)", senderId: BOB, text: "hi" }, + now, + ); + + expect(message.senderId).toBe(BOB); + expect(message.chatId).toBe("local-chat-1"); + expect(message.isSystemMessage).toBe(false); + }); + + it("treats an unusable sender as a system message instead of throwing", () => { + for (const senderId of [null, undefined, "", 42, "user(abc)"]) { + const message = mapMessageData( + { chatId: "chat(local-chat-1)", senderId, text: "hi" }, + now, + ); + expect(message.senderId).toBeNull(); + expect(message.isSystemMessage).toBe(true); + // The text survives; only the attribution is lost. + expect(message.text).toBe("hi"); + } + }); + + it("reports an unusable chat reference as null rather than throwing", () => { + for (const chatId of [null, undefined, "", 42, "not-a-ref"]) { + expect(() => + mapMessageData({ chatId, senderId: BOB, text: "hi" }, now), + ).not.toThrow(); + expect( + mapMessageData({ chatId, senderId: BOB, text: "hi" }, now).chatId, + ).toBeNull(); + } + }); + + it("keeps explicit system messages unattributed", () => { + const message = mapMessageData( + { + chatId: "chat(local-chat-1)", + senderId: BOB, + text: "$$system-message$$ joined", + }, + now, + ); + + expect(message.isSystemMessage).toBe(true); + expect(message.senderId).toBeNull(); + }); + }); + + describe("parseLocalRef", () => { + it("reads the id out of a table(id) reference", () => { + expect(parseLocalRef("chat(abc-123)")).toBe("abc-123"); + }); + + it("returns null for anything else", () => { + for (const value of [null, undefined, "", 42, {}, [], "plain", ALICE]) { + expect(parseLocalRef(value)).toBeNull(); + } + }); + }); +}); diff --git a/platforms/blabsy/api/src/web3adapter/chat-mapping.ts b/platforms/blabsy/api/src/web3adapter/chat-mapping.ts new file mode 100644 index 000000000..0eafa571e --- /dev/null +++ b/platforms/blabsy/api/src/web3adapter/chat-mapping.ts @@ -0,0 +1,134 @@ +import { Timestamp } from "firebase-admin/firestore"; +import { normaliseEName, normaliseENameList } from "web3-adapter"; + +/** + * Turns an inbound chat or message envelope into the shape Firestore stores. + * + * Kept apart from the webhook controller so the reference handling can be + * tested without a Firestore connection — this is the code path that used to + * throw a TypeError on a bare eName and lose an entire room, so it is worth + * being able to exercise directly. + */ + +export type MappedChat = { + type: "direct" | "group"; + name?: string; + participants: string[]; + admins: string[]; + ename?: string | null; + createdAt: Timestamp; + updatedAt: Timestamp; + lastMessage?: { + text: string; + senderId: string; + timestamp: Timestamp; + } | null; +}; + +export type MappedMessage = { + chatId: string | null; + senderId: string | null; + text: string; + createdAt: Timestamp; + updatedAt: Timestamp; + readBy: string[]; + isSystemMessage: boolean; +}; + +/** + * Reads the id out of a `table(id)` reference. + * + * A chat reference is a local relation resolved through the mapping store, so + * it keeps this form; entity references (people) are eNames and do not. + * Returns `null` for anything unparseable, so one malformed reference drops one + * record instead of throwing partway through an ingest. + */ +export function parseLocalRef(value: unknown): string | null { + if (typeof value !== "string") return null; + const id = value.split("(")[1]?.split(")")[0]; + return id && id.length > 0 ? id : null; +} + +/** + * Maps an inbound chat envelope. + * + * Participants arrive as eNames, and a Blabsy user document is keyed by the + * user's eName, so a participant reference is already the local document id and + * needs no lookup. Entries that are not usable eNames are dropped and counted + * rather than being allowed to throw: a chat may name members who live on a + * platform this instance knows nothing about, and one such member must not cost + * the room its other members. + */ +export function mapChatData( + // biome-ignore lint/suspicious/noExplicitAny: inbound envelope payload + data: any, + now: Timestamp, +): MappedChat { + const participants = normaliseENameList(data.participants); + const admins = normaliseENameList(data.admins); + + const supplied = Array.isArray(data.participants) + ? data.participants.length + : 0; + if (supplied > participants.length) { + console.warn( + `Skipped ${supplied - participants.length} unusable participant reference(s) on chat ${data.ename ?? "?"}`, + ); + } + + return { + type: participants.length > 2 ? "group" : "direct", + name: data.name, + participants, + admins, + ename: data.ename || null, + createdAt: data.createdAt + ? Timestamp.fromDate(new Date(data.createdAt)) + : now, + updatedAt: now, + lastMessage: data.lastMessage + ? { + ...data.lastMessage, + timestamp: Timestamp.fromDate(new Date(data.lastMessage.timestamp)), + } + : null, + }; +} + +/** + * Maps an inbound message envelope. + * + * A message whose sender cannot be resolved becomes a system message rather + * than being dropped: the text is still worth showing, and an unattributed line + * reads better than a hole in the conversation. + */ +export function mapMessageData( + // biome-ignore lint/suspicious/noExplicitAny: inbound envelope payload + data: any, + now: Timestamp, +): MappedMessage { + const chatId = parseLocalRef(data.chatId); + const senderId = normaliseEName(data.senderId); + + const isSystemMessage = + !senderId || Boolean(data.text?.startsWith("$$system-message$$")); + + if (data.senderId && !senderId) { + console.warn( + "Message sender is not a usable eName, treating as system message:", + data.senderId, + ); + } + + return { + chatId, + senderId: isSystemMessage ? null : senderId, + text: data.text, + createdAt: data.createdAt + ? Timestamp.fromDate(new Date(data.createdAt)) + : now, + updatedAt: now, + readBy: data.readBy || [], + isSystemMessage, + }; +} diff --git a/platforms/blabsy/api/src/web3adapter/mappings/chat.mapping.json b/platforms/blabsy/api/src/web3adapter/mappings/chat.mapping.json index 5a13ad638..b7f869562 100644 --- a/platforms/blabsy/api/src/web3adapter/mappings/chat.mapping.json +++ b/platforms/blabsy/api/src/web3adapter/mappings/chat.mapping.json @@ -7,8 +7,8 @@ "name": "name", "type": "type", "ename": "ename", - "admins": "user(admins),admins", - "participants": "user(participants[]),participantIds", + "admins": "__ename(admins[]),admins", + "participants": "__ename(participants[]),participantIds", "lastMessage": "lastMessageId", "createdAt": "__date(__calc(createdAt._seconds * 1000)),createdAt", "updatedAt": "__date(__calc(updatedAt._seconds * 1000)),updatedAt", diff --git a/platforms/blabsy/api/src/web3adapter/mappings/message.mapping.json b/platforms/blabsy/api/src/web3adapter/mappings/message.mapping.json index 86b656c90..a9ec70b4b 100644 --- a/platforms/blabsy/api/src/web3adapter/mappings/message.mapping.json +++ b/platforms/blabsy/api/src/web3adapter/mappings/message.mapping.json @@ -4,7 +4,7 @@ "ownerEnamePath": "chat(chat.ename)||user(senderId)", "localToUniversalMap": { "chatId": "chat(chatId),chatId", - "senderId": "user(senderId),senderId", + "senderId": "__ename(senderId),senderId", "text": "content", "type": "type", "mediaUrl": "__file(mediaUrl)", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ca2a27f7..feffb0a48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1011,9 +1011,6 @@ importers: '@types/express': specifier: ^4.17.21 version: 4.17.25 - '@types/jest': - specifier: ^29.5.12 - version: 29.5.14 '@types/jsonwebtoken': specifier: ^9.0.5 version: 9.0.10 @@ -1035,15 +1032,9 @@ importers: eslint: specifier: ^8.56.0 version: 8.57.1 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) nodemon: specifier: ^3.0.3 version: 3.1.14 - ts-jest: - specifier: ^29.1.2 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)))(typescript@5.8.2) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) @@ -1053,6 +1044,9 @@ importers: typescript: specifier: ^5.3.3 version: 5.8.2 + vitest: + specifier: ^3.1.2 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) platforms/blabsy/client: dependencies: From 3bcba70c0dbcf34910f44bee101b0b0c8ca4dfb9 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 09:31:05 +0530 Subject: [PATCH 03/13] Accept and emit eNames across the remaining eight chat platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chat/Group schema (…440003) and Message schema (…440004) are shared by far more than the three platforms the change started with. ereputation, esigner, file-manager, ecurrency, dreamsync, evoting, cerberus, and group-charter-manager all carried the same hand-rolled ref.split("(")[1].split(")")[0], and so all had the same crash on a bare eName. Participants, admins, members, a group's owner, and a message's sender are now resolved through one shared helper that skips and logs what it cannot resolve. Only entity references changed: a chat, group, file, or signature reference is a local relation resolved through the mapping store, and keeps the table(id) form. owner and admins needed producer-side work. Unlike participants they are stored as bare local user ids with no relation for the mapping to follow, so they are rewritten to eNames just before a group reaches the mapper, and resolved back to local ids on the way in. Cerberus keeps its per-lookup timeout, which now expresses itself as one skipped participant rather than as a hung webhook. --- infrastructure/web3-adapter/src/index.ts | 3 + .../web3-adapter/src/w3ds/entity-refs.ts | 96 +++++++++++++ .../src/w3ds/group-ownership.test.ts | 90 +++++++++++++ .../web3-adapter/src/w3ds/group-ownership.ts | 63 +++++++++ .../src/controllers/WebhookController.ts | 127 ++++++++---------- .../web3adapter/mappings/group.mapping.json | 6 +- .../web3adapter/mappings/message.mapping.json | 2 +- .../src/web3adapter/watchers/subscriber.ts | 17 ++- .../api/src/controllers/WebhookController.ts | 60 +++++---- .../web3adapter/mappings/group.mapping.json | 12 +- .../web3adapter/mappings/message.mapping.json | 2 +- .../src/web3adapter/watchers/subscriber.ts | 17 ++- .../api/src/controllers/WebhookController.ts | 57 ++++---- .../web3adapter/mappings/group.mapping.json | 13 +- .../web3adapter/mappings/message.mapping.json | 3 +- .../src/web3adapter/watchers/subscriber.ts | 17 ++- .../api/src/controllers/WebhookController.ts | 48 ++++--- .../web3adapter/mappings/group.mapping.json | 12 +- .../web3adapter/mappings/message.mapping.json | 3 +- .../src/web3adapter/watchers/subscriber.ts | 17 ++- .../api/src/controllers/WebhookController.ts | 58 ++++---- .../web3adapter/mappings/group.mapping.json | 10 +- .../web3adapter/mappings/message.mapping.json | 4 +- .../src/web3adapter/watchers/subscriber.ts | 17 ++- .../api/src/controllers/WebhookController.ts | 51 +++---- .../web3adapter/mappings/group.mapping.json | 8 +- .../web3adapter/mappings/message.mapping.json | 2 +- .../src/web3adapter/watchers/subscriber.ts | 17 ++- .../api/src/controllers/WebhookController.ts | 58 ++++---- .../web3adapter/mappings/group.mapping.json | 9 +- .../web3adapter/mappings/message.mapping.json | 3 +- .../src/web3adapter/watchers/subscriber.ts | 17 ++- .../api/src/controllers/WebhookController.ts | 47 ++++--- .../web3adapter/mappings/group.mapping.json | 6 +- .../web3adapter/mappings/message.mapping.json | 2 +- .../src/web3adapter/watchers/subscriber.ts | 17 ++- .../api/src/web3adapter/entity-refs.ts | 77 ++--------- 37 files changed, 700 insertions(+), 368 deletions(-) create mode 100644 infrastructure/web3-adapter/src/w3ds/entity-refs.ts create mode 100644 infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts create mode 100644 infrastructure/web3-adapter/src/w3ds/group-ownership.ts diff --git a/infrastructure/web3-adapter/src/index.ts b/infrastructure/web3-adapter/src/index.ts index b76a08de0..0952b308b 100644 --- a/infrastructure/web3-adapter/src/index.ts +++ b/infrastructure/web3-adapter/src/index.ts @@ -23,6 +23,9 @@ export { } from "./w3ds/ename"; export type { ENameProfileCacheOptions } from "./w3ds/ename-profile-cache"; export { ENameProfileCache } from "./w3ds/ename-profile-cache"; +export type { ENameLookup, ResolveOptions } from "./w3ds/entity-refs"; +export { resolveENameRef, resolveENameRefs } from "./w3ds/entity-refs"; +export { enrichGroupOwnership } from "./w3ds/group-ownership"; /** * Standalone function to spin up an eVault diff --git a/infrastructure/web3-adapter/src/w3ds/entity-refs.ts b/infrastructure/web3-adapter/src/w3ds/entity-refs.ts new file mode 100644 index 000000000..5495e687a --- /dev/null +++ b/infrastructure/web3-adapter/src/w3ds/entity-refs.ts @@ -0,0 +1,96 @@ +import { normaliseEName, normaliseENameList } from "./ename"; + +/** + * Resolving inbound chat entity references to local records. + * + * Every platform in this repo hand-rolled the same loop over a chat's + * participants, and every one of them made the same two mistakes: it parsed the + * reference with `ref.split("(")[1].split(")")[0]`, which throws on anything + * that is not the legacy `table(uuid)` form, and it had no answer for a + * reference that is well-formed but names nobody locally. + * + * Both are fixed here once, so a platform supplies only its own lookup. + */ + +/** Looks up whatever local record represents the holder of an eName. */ +export type ENameLookup = (ename: string) => Promise; + +export interface ResolveOptions { + /** + * Identifies the envelope in log lines, e.g. `chat participants`. A + * skipped participant is only actionable if you can tell which room and + * which field it came from. + */ + context: string; +} + +/** + * Resolves a list of entity references to local records. + * + * References that are not usable eNames are skipped. eNames that resolve to + * nothing locally are also skipped, and logged. Neither is fatal: a chat may + * legitimately name members who live on a platform this instance has never + * heard of, and losing the whole room over one of them is the bug this exists + * to prevent. + */ +export async function resolveENameRefs( + refs: unknown, + lookup: ENameLookup, + { context }: ResolveOptions, +): Promise { + const enames = normaliseENameList(refs); + + const supplied = Array.isArray(refs) ? refs.length : refs == null ? 0 : 1; + if (supplied > enames.length) { + console.warn( + `[chat] ${context}: skipped ${supplied - enames.length} malformed entity reference(s)`, + ); + } + + const resolved: T[] = []; + for (const settled of await Promise.all( + enames.map((ename) => resolveOne(ename, lookup, context)), + )) { + if (settled !== null) resolved.push(settled as T); + } + return resolved; +} + +/** + * Resolves a single entity reference, such as a message's sender. + * + * `null` covers both an unusable reference and an eName nobody local answers + * to; the caller decides whether that is fatal for the record at hand. + */ +export async function resolveENameRef( + ref: unknown, + lookup: ENameLookup, + { context }: ResolveOptions, +): Promise { + const ename = normaliseEName(ref); + if (!ename) { + if (ref !== null && ref !== undefined && ref !== "") { + console.warn(`[chat] ${context}: unusable entity reference`, ref); + } + return null; + } + return resolveOne(ename, lookup, context); +} + +async function resolveOne( + ename: string, + lookup: ENameLookup, + context: string, +): Promise { + try { + const found = await lookup(ename); + if (!found) { + console.warn(`[chat] ${context}: no local record for ${ename}, skipping`); + } + return found; + } catch (error) { + // A lookup failure is per-participant, not per-room. + console.warn(`[chat] ${context}: lookup failed for ${ename}:`, error); + return null; + } +} diff --git a/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts b/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts new file mode 100644 index 000000000..a3b823973 --- /dev/null +++ b/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { enrichGroupOwnership } from "./group-ownership"; + +const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; +const BOB = "@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60"; + +/** Maps local ids to eNames for alice and bob, and knows nobody else. */ +const lookup = vi.fn(async (id: string) => + ({ "local-alice": ALICE, "local-bob": BOB })[id] ?? null, +); + +describe("enrichGroupOwnership", () => { + it("rewrites a local owner id to an eName", async () => { + const group = await enrichGroupOwnership( + { owner: "local-alice", admins: [] }, + lookup, + ); + expect(group.owner).toBe(ALICE); + }); + + it("rewrites local admin ids to eNames", async () => { + const group = await enrichGroupOwnership( + { owner: "local-alice", admins: ["local-alice", "local-bob"] }, + lookup, + ); + expect(group.admins).toEqual([ALICE, BOB]); + }); + + it("leaves values that are already eNames alone, without a lookup", async () => { + const spy = vi.fn(async () => null); + const group = await enrichGroupOwnership( + { owner: ALICE, admins: [BOB] }, + spy, + ); + + expect(group.owner).toBe(ALICE); + expect(group.admins).toEqual([BOB]); + expect(spy).not.toHaveBeenCalled(); + }); + + it("reads an ename off an admin that arrives as a relation object", async () => { + const group = await enrichGroupOwnership( + { owner: "local-alice", admins: [{ id: "local-bob", ename: BOB }] }, + lookup, + ); + expect(group.admins).toEqual([BOB]); + }); + + it("drops admins that cannot be resolved rather than emitting an id", async () => { + // Emitting a raw local id would put a reference on the wire that no + // consumer accepts, which is the failure this whole change removes. + const group = await enrichGroupOwnership( + { owner: "local-alice", admins: ["local-alice", "who-is-this", null, 42] }, + lookup, + ); + expect(group.admins).toEqual([ALICE]); + }); + + it("yields a null owner when the owner cannot be resolved", async () => { + const group = await enrichGroupOwnership( + { owner: "who-is-this", admins: [] }, + lookup, + ); + expect(group.owner).toBeNull(); + }); + + it("survives a lookup that throws", async () => { + const group = await enrichGroupOwnership( + { owner: "local-alice", admins: [] }, + async () => { + throw new Error("db down"); + }, + ); + expect(group.owner).toBeNull(); + }); + + it("leaves absent fields absent and does not invent them", async () => { + const group = await enrichGroupOwnership({ name: "Standup" }, lookup); + expect(group).toEqual({ name: "Standup" }); + }); + + it("is idempotent", async () => { + const once = await enrichGroupOwnership( + { owner: "local-alice", admins: ["local-bob"] }, + lookup, + ); + const twice = await enrichGroupOwnership(once, lookup); + expect(twice).toEqual(once); + }); +}); diff --git a/infrastructure/web3-adapter/src/w3ds/group-ownership.ts b/infrastructure/web3-adapter/src/w3ds/group-ownership.ts new file mode 100644 index 000000000..b352effce --- /dev/null +++ b/infrastructure/web3-adapter/src/w3ds/group-ownership.ts @@ -0,0 +1,63 @@ +import { toEName } from "./ename"; + +/** + * Rewrites a group's `owner` and `admins` from local user ids to eNames. + * + * Participants and members are TypeORM relations, so the mapping can reach + * their `ename` directly. `owner` and `admins` are not: they are stored as bare + * local user ids, with no relation to follow. They still name people, so they + * are entity references and must go on the wire as eNames like every other one. + * + * This runs on the producer side, just before a group is handed to the mapper, + * and is a no-op for values that are already eNames so it is safe to apply + * more than once. + */ +export async function enrichGroupOwnership( + // biome-ignore lint/suspicious/noExplicitAny: TypeORM entity snapshot + group: any, + lookupEnameById: (id: string) => Promise, + // biome-ignore lint/suspicious/noExplicitAny: TypeORM entity snapshot +): Promise { + if (!group || typeof group !== "object") return group; + + const enriched = { ...group }; + + if (group.owner !== undefined) { + enriched.owner = await idToEName(group.owner, lookupEnameById); + } + + if (Array.isArray(group.admins)) { + const admins = await Promise.all( + group.admins.map((admin: unknown) => + // An admin may already be a relation object once a platform loads + // it as one; prefer its ename before falling back to a lookup. + typeof admin === "object" && admin !== null + ? Promise.resolve( + toEName((admin as { ename?: unknown }).ename ?? null), + ) + : idToEName(admin, lookupEnameById), + ), + ); + enriched.admins = admins.filter((a): a is string => a !== null); + } + + return enriched; +} + +async function idToEName( + value: unknown, + lookupEnameById: (id: string) => Promise, +): Promise { + if (typeof value !== "string" || value.length === 0) return null; + + // Already an eName: nothing to look up. + const asEName = value.startsWith("@") ? toEName(value) : null; + if (asEName) return asEName; + + try { + return toEName(await lookupEnameById(value)); + } catch (error) { + console.warn(`[chat] could not resolve eName for user ${value}:`, error); + return null; + } +} diff --git a/platforms/cerberus/client/src/controllers/WebhookController.ts b/platforms/cerberus/client/src/controllers/WebhookController.ts index 1b5653113..61142b38f 100644 --- a/platforms/cerberus/client/src/controllers/WebhookController.ts +++ b/platforms/cerberus/client/src/controllers/WebhookController.ts @@ -4,7 +4,7 @@ import { GroupService } from "../services/GroupService"; import { MessageService } from "../services/MessageService"; import { CerberusTriggerService } from "../services/CerberusTriggerService"; import { CharterSignatureService } from "../services/CharterSignatureService"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, resolveENameRef, resolveENameRefs } from "web3-adapter"; import { User } from "../database/entities/User"; import { Group } from "../database/entities/Group"; import { Message } from "../database/entities/Message"; @@ -110,71 +110,37 @@ export class WebhookController { console.log("Local ID from mapping:", localId); let participants: User[] = []; - if ( - local.data.participants && - Array.isArray(local.data.participants) - ) { - console.log("Processing participants:", local.data.participants); - - // Use Promise.allSettled with timeout to prevent webhook hang - const participantPromises = local.data.participants.map( - async (ref: string, index: number) => { - if (!ref || typeof ref !== "string") { - return null; - } - - try { - const userId = ref.split("(")[1]?.split(")")[0]; - if (!userId) { - console.warn(`⚠️ Could not extract userId from ref: ${ref}`); - return null; - } - - console.log(`Extracted userId [${index}]: ${userId}`); - - // Add 5-second timeout to prevent indefinite hang - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error(`Timeout loading user ${userId}`)), 5000) - ); - - const userPromise = this.userService.userRepository.findOne({ - where: { id: userId }, - // Skip heavy relations in webhook context - only need basic user data - }); - - const user = await Promise.race([userPromise, timeoutPromise]); - - if (user) { - console.log(`✅ Loaded user [${index}]: ${userId}`); - } else { - console.warn(`⚠️ User not found [${index}]: ${userId}`); - } - - return user; - } catch (error) { - console.error(`❌ Error loading participant [${index}]:`, error instanceof Error ? error.message : error); - return null; - } - } + if (local.data.participants !== undefined) { + // A slow or missing user must not hang the webhook, so each + // lookup keeps its own timeout; the shared resolver turns a + // rejection into a skipped participant rather than a lost room. + participants = await resolveENameRefs( + local.data.participants, + (ename) => withTimeout( + this.userService.getUserByEname(ename), + 5000, + `loading user ${ename}` + ), + { context: `group ${globalId} participants` } ); - - // Use allSettled to handle failures gracefully without blocking - const settledResults = await Promise.allSettled(participantPromises); - - participants = settledResults - .filter((result): result is PromiseFulfilledResult => - result.status === 'fulfilled' && result.value !== null - ) - .map(result => result.value as User); - - console.log(`Found ${participants.length} participants (${settledResults.filter(r => r.status === 'rejected').length} failed)`); + console.log(`Found ${participants.length} participants`); } - // Process admins - filter out nulls and extract IDs - let admins = local?.data?.admins as string[] ?? [] - admins = admins - .filter(a => a !== null && a !== undefined) - .map((a) => a.includes("(") ? a.split("(")[1].split(")")[0] : a) + // `admins` and `owner` are eNames on the wire but local user ids + // in the columns, so they are resolved back. Anyone this instance + // does not know is skipped rather than stored as a dangling id. + const adminUsers = await resolveENameRefs( + local?.data?.admins, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} admins` } + ); + const admins = adminUsers.map((a) => a.id); + + const ownerUser = await resolveENameRef( + local?.data?.owner, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} owner` } + ); if (localId) { const group = await this.groupService.getGroupById(localId); @@ -194,8 +160,8 @@ export class WebhookController { if (local.data.description !== undefined) { group.description = local.data.description as string; } - if (local.data.owner !== undefined) { - group.owner = local.data.owner as string; + if (ownerUser) { + group.owner = ownerUser.id; } if (admins.length > 0) { group.admins = admins; @@ -247,7 +213,7 @@ export class WebhookController { group = await this.groupService.createGroup({ name: local.data.name as string, description: local.data.description as string, - owner: local.data.owner as string, + owner: ownerUser?.id as string, admins, participants: participants, charter: local.data.charter as string, @@ -281,10 +247,11 @@ export class WebhookController { let sender: User | null = null; let group: Group | null = null; - if (local.data.sender && typeof local.data.sender === "string") { - const senderId = local.data.sender.split("(")[1].split(")")[0]; - sender = await this.userService.getUserById(senderId); - } + sender = await resolveENameRef( + local.data.sender, + (ename) => this.userService.getUserByEname(ename), + { context: `message ${globalId} sender` } + ); if (local.data.group && typeof local.data.group === "string") { const groupId = local.data.group.split("(")[1].split(")")[0]; @@ -449,3 +416,23 @@ export class WebhookController { } }; } + +/** + * Rejects if a lookup takes too long. + * + * Cerberus resolves participants during webhook handling, where a slow user + * query would otherwise hold the request open; the caller treats a rejection as + * one skipped participant. + */ +function withTimeout( + promise: Promise, + ms: number, + description: string +): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timeout ${description}`)), ms) + ), + ]); +} diff --git a/platforms/cerberus/client/src/web3adapter/mappings/group.mapping.json b/platforms/cerberus/client/src/web3adapter/mappings/group.mapping.json index 0d36ea47f..3cfbc741e 100644 --- a/platforms/cerberus/client/src/web3adapter/mappings/group.mapping.json +++ b/platforms/cerberus/client/src/web3adapter/mappings/group.mapping.json @@ -6,10 +6,10 @@ "localToUniversalMap": { "name": "name", "description": "description", - "owner": "owner", - "admins": "users(admins),admins", + "owner": "__ename(owner),owner", + "admins": "__ename(admins[]),admins", "charter": "charter", - "participants": "users(participants[].id),participantIds", + "participants": "__ename(participants[].ename),participantIds", "charterSignatures": "charter_signature(charterSignatures[].id),signatureIds", "createdAt": "createdAt", "updatedAt": "updatedAt", diff --git a/platforms/cerberus/client/src/web3adapter/mappings/message.mapping.json b/platforms/cerberus/client/src/web3adapter/mappings/message.mapping.json index ec014f922..31c779776 100644 --- a/platforms/cerberus/client/src/web3adapter/mappings/message.mapping.json +++ b/platforms/cerberus/client/src/web3adapter/mappings/message.mapping.json @@ -5,7 +5,7 @@ "ownedJunctionTables": [], "localToUniversalMap": { "text": "content", - "sender": "users(sender.id),senderId", + "sender": "__ename(sender.ename),senderId", "group": "groups(group.id),chatId", "isSystemMessage": "isSystemMessage", "createdAt": "createdAt", diff --git a/platforms/cerberus/client/src/web3adapter/watchers/subscriber.ts b/platforms/cerberus/client/src/web3adapter/watchers/subscriber.ts index b54edab60..70618268c 100644 --- a/platforms/cerberus/client/src/web3adapter/watchers/subscriber.ts +++ b/platforms/cerberus/client/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -71,7 +71,20 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - return this.entityToPlain(enrichedEntity); + // `owner` and `admins` are stored as bare local user ids with no + // relation to follow, but they name people, so they must go on the + // wire as eNames like every other entity reference. + const plain = this.entityToPlain(enrichedEntity); + if (tableName === "groups" || tableName === "group") { + return await enrichGroupOwnership(plain, async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }); + } + return plain; } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/dreamsync/api/src/controllers/WebhookController.ts b/platforms/dreamsync/api/src/controllers/WebhookController.ts index dad0f01c8..f75f1e801 100644 --- a/platforms/dreamsync/api/src/controllers/WebhookController.ts +++ b/platforms/dreamsync/api/src/controllers/WebhookController.ts @@ -1,3 +1,4 @@ +import { resolveENameRef, resolveENameRefs } from "web3-adapter"; import { Request, Response } from "express"; import { UserService } from "../services/UserService"; import { GroupService } from "../services/GroupService"; @@ -177,30 +178,34 @@ export class WebhookController { } let participants: User[] = []; - if ( - local.data.participants && - Array.isArray(local.data.participants) - ) { - console.log("Processing participants:", local.data.participants); - const participantPromises = local.data.participants.map( - async (ref: string) => { - if (ref && typeof ref === "string") { - const userId = ref.split("(")[1].split(")")[0]; - console.log("Extracted userId:", userId); - return await this.userService.getUserById(userId); - } - return null; - } + if (local.data.participants !== undefined) { + participants = await resolveENameRefs( + local.data.participants, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} participants` } ); - - participants = ( - await Promise.all(participantPromises) - ).filter((user: User | null): user is User => user !== null); - console.log("Found participants:", participants.length); } - let adminIds = local?.data?.admins as string[] ?? [] - adminIds = adminIds.map((a) => a.includes("(") ? a.split("(")[1].split(")")[0]: a) + // Admins are eNames on the wire but a User relation locally, so + // they are resolved the same way participants are; an admin this + // instance does not know is skipped rather than being stored as a + // dangling id. + const admins = await resolveENameRefs( + local?.data?.admins, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} admins` } + ); + const adminIds = admins.map((a) => a.id); + + // `owner` is an eName on the wire and a local user id in the + // column, so it is resolved back. An owner this instance does + // not know leaves the column untouched rather than storing an + // eName where an id belongs. + const ownerUser = await resolveENameRef( + local?.data?.owner, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} owner` } + ); if (localId) { console.log("Updating existing group with localId:", localId); @@ -212,7 +217,7 @@ export class WebhookController { group.name = local.data.name as string; group.description = local.data.description as string; - group.owner = local.data.owner as string; + if (ownerUser) group.owner = ownerUser.id; group.admins = adminIds.map(id => ({ id } as User)); group.participants = participants; group.charter = local.data.charter as string; @@ -246,7 +251,7 @@ export class WebhookController { const group = await this.groupService.createGroup( local.data.name as string, local.data.description as string, - local.data.owner as string, + ownerUser?.id as string, adminIds, participants.map(p => p.id), local.data.charter as string | undefined, @@ -269,10 +274,11 @@ export class WebhookController { let sender: User | null = null; let group: Group | null = null; - if (local.data.sender && typeof local.data.sender === "string") { - const senderId = local.data.sender.split("(")[1].split(")")[0]; - sender = await this.userService.getUserById(senderId); - } + sender = await resolveENameRef( + local.data.sender, + (ename) => this.userService.getUserByEname(ename), + { context: `message ${globalId} sender` } + ); if (local.data.group && typeof local.data.group === "string") { const groupId = local.data.group.split("(")[1].split(")")[0]; diff --git a/platforms/dreamsync/api/src/web3adapter/mappings/group.mapping.json b/platforms/dreamsync/api/src/web3adapter/mappings/group.mapping.json index fb5088e54..9bf781385 100644 --- a/platforms/dreamsync/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/dreamsync/api/src/web3adapter/mappings/group.mapping.json @@ -2,16 +2,18 @@ "tableName": "groups", "schemaId": "550e8400-e29b-41d4-a716-446655440003", "ownerEnamePath": "users(participants[].ename)", - "ownedJunctionTables": ["group_participants"], + "ownedJunctionTables": [ + "group_participants" + ], "localToUniversalMap": { "name": "name", "description": "description", - "owner": "owner", - "admins": "users(admins[].id),admins", + "owner": "__ename(owner),owner", + "admins": "__ename(admins[].ename),admins", "charter": "charter", "ename": "ename", - "participants": "users(participants[].id),participantIds", - "members": "users(members[].id),memberIds", + "participants": "__ename(participants[].ename),participantIds", + "members": "__ename(members[].ename),memberIds", "originalMatchParticipants": "originalMatchParticipants", "isPrivate": "isPrivate", "visibility": "visibility", diff --git a/platforms/dreamsync/api/src/web3adapter/mappings/message.mapping.json b/platforms/dreamsync/api/src/web3adapter/mappings/message.mapping.json index 88a37b376..5424a1d83 100644 --- a/platforms/dreamsync/api/src/web3adapter/mappings/message.mapping.json +++ b/platforms/dreamsync/api/src/web3adapter/mappings/message.mapping.json @@ -5,7 +5,7 @@ "ownedJunctionTables": [], "localToUniversalMap": { "text": "content", - "sender": "users(sender.id),senderId", + "sender": "__ename(sender.ename),senderId", "group": "groups(group.id),chatId", "isSystemMessage": "isSystemMessage", "createdAt": "createdAt", diff --git a/platforms/dreamsync/api/src/web3adapter/watchers/subscriber.ts b/platforms/dreamsync/api/src/web3adapter/watchers/subscriber.ts index 635479ff3..3c0049da7 100644 --- a/platforms/dreamsync/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/dreamsync/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -95,7 +95,20 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - return this.entityToPlain(enrichedEntity); + // `owner` and `admins` are stored as bare local user ids with no + // relation to follow, but they name people, so they must go on the + // wire as eNames like every other entity reference. + const plain = this.entityToPlain(enrichedEntity); + if (tableName === "groups" || tableName === "group") { + return await enrichGroupOwnership(plain, async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }); + } + return plain; } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/ecurrency/api/src/controllers/WebhookController.ts b/platforms/ecurrency/api/src/controllers/WebhookController.ts index 9303aba80..be931a5a6 100644 --- a/platforms/ecurrency/api/src/controllers/WebhookController.ts +++ b/platforms/ecurrency/api/src/controllers/WebhookController.ts @@ -1,3 +1,4 @@ +import { resolveENameRef, resolveENameRefs } from "web3-adapter"; import { Request, Response } from "express"; import { UserService } from "../services/UserService"; import { GroupService } from "../services/GroupService"; @@ -114,27 +115,34 @@ export class WebhookController { } } else if (mapping.tableName === "groups") { let participants: User[] = []; - if ( - local.data.participants && - Array.isArray(local.data.participants) - ) { - const participantPromises = local.data.participants.map( - async (ref: string) => { - if (ref && typeof ref === "string") { - const userId = ref.split("(")[1].split(")")[0]; - return await this.userService.getUserById(userId); - } - return null; - } + if (local.data.participants !== undefined) { + participants = await resolveENameRefs( + local.data.participants, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} participants` } ); - - participants = ( - await Promise.all(participantPromises) - ).filter((user: User | null): user is User => user !== null); } - let adminIds = local?.data?.admins as string[] ?? [] - adminIds = adminIds.map((a) => a.includes("(") ? a.split("(")[1].split(")")[0]: a) + // Admins are eNames on the wire but a User relation locally, so + // they are resolved the same way participants are; an admin this + // instance does not know is skipped rather than being stored as a + // dangling id. + const admins = await resolveENameRefs( + local?.data?.admins, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} admins` } + ); + const adminIds = admins.map((a) => a.id); + + // `owner` is an eName on the wire and a local user id in the + // column, so it is resolved back. An owner this instance does + // not know leaves the column untouched rather than storing an + // eName where an id belongs. + const ownerUser = await resolveENameRef( + local?.data?.owner, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} owner` } + ); if (localId) { const group = await this.groupService.getGroupById(localId); @@ -144,7 +152,7 @@ export class WebhookController { group.name = local.data.name as string; group.description = local.data.description as string; - group.owner = local.data.owner as string; + if (ownerUser) group.owner = ownerUser.id; group.admins = adminIds.map(id => ({ id } as User)); group.participants = participants; group.charter = local.data.charter as string; @@ -174,7 +182,7 @@ export class WebhookController { const group = await this.groupService.createGroup( local.data.name as string, local.data.description as string, - local.data.owner as string, + ownerUser?.id as string, adminIds, participants.map(p => p.id), local.data.charter as string | undefined, @@ -194,10 +202,11 @@ export class WebhookController { let sender: User | null = null; let group: Group | null = null; - if (local.data.sender && typeof local.data.sender === "string") { - const senderId = local.data.sender.split("(")[1].split(")")[0]; - sender = await this.userService.getUserById(senderId); - } + sender = await resolveENameRef( + local.data.sender, + (ename) => this.userService.getUserByEname(ename), + { context: `message ${globalId} sender` } + ); if (local.data.group && typeof local.data.group === "string") { const groupId = local.data.group.split("(")[1].split(")")[0]; diff --git a/platforms/ecurrency/api/src/web3adapter/mappings/group.mapping.json b/platforms/ecurrency/api/src/web3adapter/mappings/group.mapping.json index cb9049307..9bf781385 100644 --- a/platforms/ecurrency/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/ecurrency/api/src/web3adapter/mappings/group.mapping.json @@ -2,16 +2,18 @@ "tableName": "groups", "schemaId": "550e8400-e29b-41d4-a716-446655440003", "ownerEnamePath": "users(participants[].ename)", - "ownedJunctionTables": ["group_participants"], + "ownedJunctionTables": [ + "group_participants" + ], "localToUniversalMap": { "name": "name", "description": "description", - "owner": "owner", - "admins": "users(admins[].id),admins", + "owner": "__ename(owner),owner", + "admins": "__ename(admins[].ename),admins", "charter": "charter", "ename": "ename", - "participants": "users(participants[].id),participantIds", - "members": "users(members[].id),memberIds", + "participants": "__ename(participants[].ename),participantIds", + "members": "__ename(members[].ename),memberIds", "originalMatchParticipants": "originalMatchParticipants", "isPrivate": "isPrivate", "visibility": "visibility", @@ -22,4 +24,3 @@ }, "readOnly": false } - diff --git a/platforms/ecurrency/api/src/web3adapter/mappings/message.mapping.json b/platforms/ecurrency/api/src/web3adapter/mappings/message.mapping.json index ac51a0215..5424a1d83 100644 --- a/platforms/ecurrency/api/src/web3adapter/mappings/message.mapping.json +++ b/platforms/ecurrency/api/src/web3adapter/mappings/message.mapping.json @@ -5,7 +5,7 @@ "ownedJunctionTables": [], "localToUniversalMap": { "text": "content", - "sender": "users(sender.id),senderId", + "sender": "__ename(sender.ename),senderId", "group": "groups(group.id),chatId", "isSystemMessage": "isSystemMessage", "createdAt": "createdAt", @@ -13,4 +13,3 @@ "isArchived": "isArchived" } } - diff --git a/platforms/ecurrency/api/src/web3adapter/watchers/subscriber.ts b/platforms/ecurrency/api/src/web3adapter/watchers/subscriber.ts index 8d8749323..862c56f17 100644 --- a/platforms/ecurrency/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/ecurrency/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -78,7 +78,20 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } const enrichedEntity = { ...entity }; - return this.entityToPlain(enrichedEntity); + // `owner` and `admins` are stored as bare local user ids with no + // relation to follow, but they name people, so they must go on the + // wire as eNames like every other entity reference. + const plain = this.entityToPlain(enrichedEntity); + if (tableName === "groups" || tableName === "group") { + return await enrichGroupOwnership(plain, async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }); + } + return plain; } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/ereputation/api/src/controllers/WebhookController.ts b/platforms/ereputation/api/src/controllers/WebhookController.ts index 05ad4b8c2..298e68af5 100644 --- a/platforms/ereputation/api/src/controllers/WebhookController.ts +++ b/platforms/ereputation/api/src/controllers/WebhookController.ts @@ -1,3 +1,4 @@ +import { resolveENameRef, resolveENameRefs } from "web3-adapter"; import { Request, Response } from "express"; import { UserService } from "../services/UserService"; import { GroupService } from "../services/GroupService"; @@ -127,27 +128,34 @@ export class WebhookController { } } else if (mapping.tableName === "groups") { let participants: User[] = []; - if ( - local.data.participants && - Array.isArray(local.data.participants) - ) { - const participantPromises = local.data.participants.map( - async (ref: string) => { - if (ref && typeof ref === "string") { - const userId = ref.split("(")[1].split(")")[0]; - return await this.userService.getUserById(userId); - } - return null; - } + if (local.data.participants !== undefined) { + participants = await resolveENameRefs( + local.data.participants, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} participants` } ); - - participants = ( - await Promise.all(participantPromises) - ).filter((user: User | null): user is User => user !== null); } - let adminIds = local?.data?.admins as string[] ?? [] - adminIds = adminIds.map((a) => a.includes("(") ? a.split("(")[1].split(")")[0]: a) + // Admins are eNames on the wire but a User relation locally, so + // they are resolved the same way participants are; an admin this + // instance does not know is skipped rather than being stored as a + // dangling id. + const admins = await resolveENameRefs( + local?.data?.admins, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} admins` } + ); + const adminIds = admins.map((a) => a.id); + + // `owner` is an eName on the wire and a local user id in the + // column, so it is resolved back. An owner this instance does + // not know leaves the column untouched rather than storing an + // eName where an id belongs. + const ownerUser = await resolveENameRef( + local?.data?.owner, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} owner` } + ); if (localId) { const group = await this.groupService.getGroupById(localId); @@ -157,7 +165,7 @@ export class WebhookController { group.name = local.data.name as string; group.description = local.data.description as string; - group.owner = local.data.owner as string; + if (ownerUser) group.owner = ownerUser.id; group.admins = adminIds.map(id => ({ id } as User)); group.participants = participants; group.charter = local.data.charter as string; @@ -187,7 +195,7 @@ export class WebhookController { const group = await this.groupService.createGroup( local.data.name as string, local.data.description as string, - local.data.owner as string, + ownerUser?.id as string, adminIds, participants.map(p => p.id), local.data.charter as string | undefined, diff --git a/platforms/ereputation/api/src/web3adapter/mappings/group.mapping.json b/platforms/ereputation/api/src/web3adapter/mappings/group.mapping.json index fb5088e54..9bf781385 100644 --- a/platforms/ereputation/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/ereputation/api/src/web3adapter/mappings/group.mapping.json @@ -2,16 +2,18 @@ "tableName": "groups", "schemaId": "550e8400-e29b-41d4-a716-446655440003", "ownerEnamePath": "users(participants[].ename)", - "ownedJunctionTables": ["group_participants"], + "ownedJunctionTables": [ + "group_participants" + ], "localToUniversalMap": { "name": "name", "description": "description", - "owner": "owner", - "admins": "users(admins[].id),admins", + "owner": "__ename(owner),owner", + "admins": "__ename(admins[].ename),admins", "charter": "charter", "ename": "ename", - "participants": "users(participants[].id),participantIds", - "members": "users(members[].id),memberIds", + "participants": "__ename(participants[].ename),participantIds", + "members": "__ename(members[].ename),memberIds", "originalMatchParticipants": "originalMatchParticipants", "isPrivate": "isPrivate", "visibility": "visibility", diff --git a/platforms/ereputation/api/src/web3adapter/mappings/message.mapping.json b/platforms/ereputation/api/src/web3adapter/mappings/message.mapping.json index ac51a0215..5424a1d83 100644 --- a/platforms/ereputation/api/src/web3adapter/mappings/message.mapping.json +++ b/platforms/ereputation/api/src/web3adapter/mappings/message.mapping.json @@ -5,7 +5,7 @@ "ownedJunctionTables": [], "localToUniversalMap": { "text": "content", - "sender": "users(sender.id),senderId", + "sender": "__ename(sender.ename),senderId", "group": "groups(group.id),chatId", "isSystemMessage": "isSystemMessage", "createdAt": "createdAt", @@ -13,4 +13,3 @@ "isArchived": "isArchived" } } - diff --git a/platforms/ereputation/api/src/web3adapter/watchers/subscriber.ts b/platforms/ereputation/api/src/web3adapter/watchers/subscriber.ts index 4d228fb40..525179bb0 100644 --- a/platforms/ereputation/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/ereputation/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -59,7 +59,20 @@ export class PostgresSubscriber implements EntitySubscriberInterface { enrichedEntity.author = author; } - return this.entityToPlain(enrichedEntity); + // `owner` and `admins` are stored as bare local user ids with no + // relation to follow, but they name people, so they must go on the + // wire as eNames like every other entity reference. + const plain = this.entityToPlain(enrichedEntity); + if (tableName === "groups" || tableName === "group") { + return await enrichGroupOwnership(plain, async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }); + } + return plain; } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/esigner/api/src/controllers/WebhookController.ts b/platforms/esigner/api/src/controllers/WebhookController.ts index c89c35c5c..3bca0a652 100644 --- a/platforms/esigner/api/src/controllers/WebhookController.ts +++ b/platforms/esigner/api/src/controllers/WebhookController.ts @@ -3,7 +3,7 @@ import { UserService } from "../services/UserService"; import { GroupService } from "../services/GroupService"; import { MessageService } from "../services/MessageService"; import { FileService } from "../services/FileService"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, resolveENameRef, resolveENameRefs } from "web3-adapter"; import { User } from "../database/entities/User"; import { Group } from "../database/entities/Group"; import { Message } from "../database/entities/Message"; @@ -94,27 +94,34 @@ export class WebhookController { } } else if (mapping.tableName === "groups") { let participants: User[] = []; - if ( - local.data.participants && - Array.isArray(local.data.participants) - ) { - const participantPromises = local.data.participants.map( - async (ref: string) => { - if (ref && typeof ref === "string") { - const userId = ref.split("(")[1].split(")")[0]; - return await this.userService.getUserById(userId); - } - return null; - } + if (local.data.participants !== undefined) { + participants = await resolveENameRefs( + local.data.participants, + (ename) => this.userService.findByEname(ename), + { context: `group ${globalId} participants` } ); - - participants = ( - await Promise.all(participantPromises) - ).filter((user: User | null): user is User => user !== null); } - let adminIds = local?.data?.admins as string[] ?? [] - adminIds = adminIds.map((a) => a.includes("(") ? a.split("(")[1].split(")")[0]: a) + // Admins are eNames on the wire but a User relation locally, so + // they are resolved the same way participants are; an admin this + // instance does not know is skipped rather than being stored as a + // dangling id. + const admins = await resolveENameRefs( + local?.data?.admins, + (ename) => this.userService.findByEname(ename), + { context: `group ${globalId} admins` } + ); + const adminIds = admins.map((a) => a.id); + + // `owner` is an eName on the wire and a local user id in the + // column, so it is resolved back. An owner this instance does + // not know leaves the column untouched rather than storing an + // eName where an id belongs. + const ownerUser = await resolveENameRef( + local?.data?.owner, + (ename) => this.userService.findByEname(ename), + { context: `group ${globalId} owner` } + ); if (localId) { const group = await this.groupService.getGroupById(localId); @@ -124,7 +131,7 @@ export class WebhookController { group.name = local.data.name as string; group.description = local.data.description as string; - group.owner = local.data.owner as string; + if (ownerUser) group.owner = ownerUser.id; group.admins = adminIds.map(id => ({ id } as User)); group.participants = participants; group.charter = local.data.charter as string; @@ -157,7 +164,7 @@ export class WebhookController { const group = await this.groupService.createGroup( local.data.name as string, local.data.description as string, - local.data.owner as string, + ownerUser?.id as string, adminIds, participants.map(p => p.id), local.data.charter as string | undefined, @@ -182,10 +189,11 @@ export class WebhookController { let sender: User | null = null; let group: Group | null = null; - if (local.data.sender && typeof local.data.sender === "string") { - const senderId = local.data.sender.split("(")[1].split(")")[0]; - sender = await this.userService.getUserById(senderId); - } + sender = await resolveENameRef( + local.data.sender, + (ename) => this.userService.findByEname(ename), + { context: `message ${globalId} sender` } + ); if (local.data.group && typeof local.data.group === "string") { const groupId = local.data.group.split("(")[1].split(")")[0]; diff --git a/platforms/esigner/api/src/web3adapter/mappings/group.mapping.json b/platforms/esigner/api/src/web3adapter/mappings/group.mapping.json index 92cf4a1af..f6175dee8 100644 --- a/platforms/esigner/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/esigner/api/src/web3adapter/mappings/group.mapping.json @@ -8,12 +8,12 @@ "localToUniversalMap": { "name": "name", "description": "description", - "owner": "owner", - "admins": "users(admins[].id),adminIds", + "owner": "__ename(owner),owner", + "admins": "__ename(admins[].ename),adminIds", "charter": "charter", "ename": "ename", - "participants": "users(participants[].id),participantIds", - "members": "users(members[].id),memberIds", + "participants": "__ename(participants[].ename),participantIds", + "members": "__ename(members[].ename),memberIds", "originalMatchParticipants": "originalMatchParticipants", "isPrivate": "isPrivate", "visibility": "visibility", @@ -23,4 +23,4 @@ "updatedAt": "updatedAt" }, "readOnly": false -} \ No newline at end of file +} diff --git a/platforms/esigner/api/src/web3adapter/mappings/message.mapping.json b/platforms/esigner/api/src/web3adapter/mappings/message.mapping.json index 6c5705475..5424a1d83 100644 --- a/platforms/esigner/api/src/web3adapter/mappings/message.mapping.json +++ b/platforms/esigner/api/src/web3adapter/mappings/message.mapping.json @@ -5,11 +5,11 @@ "ownedJunctionTables": [], "localToUniversalMap": { "text": "content", - "sender": "users(sender.id),senderId", + "sender": "__ename(sender.ename),senderId", "group": "groups(group.id),chatId", "isSystemMessage": "isSystemMessage", "createdAt": "createdAt", "updatedAt": "updatedAt", "isArchived": "isArchived" } -} \ No newline at end of file +} diff --git a/platforms/esigner/api/src/web3adapter/watchers/subscriber.ts b/platforms/esigner/api/src/web3adapter/watchers/subscriber.ts index e23acf464..2be2e02bb 100644 --- a/platforms/esigner/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/esigner/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -85,7 +85,20 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - return this.entityToPlain(enrichedEntity); + // `owner` and `admins` are stored as bare local user ids with no + // relation to follow, but they name people, so they must go on the + // wire as eNames like every other entity reference. + const plain = this.entityToPlain(enrichedEntity); + if (tableName === "groups" || tableName === "group") { + return await enrichGroupOwnership(plain, async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }); + } + return plain; } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/evoting/api/src/controllers/WebhookController.ts b/platforms/evoting/api/src/controllers/WebhookController.ts index b420176c3..08060e554 100644 --- a/platforms/evoting/api/src/controllers/WebhookController.ts +++ b/platforms/evoting/api/src/controllers/WebhookController.ts @@ -1,3 +1,4 @@ +import { resolveENameRef, resolveENameRefs } from "web3-adapter"; import { Request, Response } from "express"; import { UserService } from "../services/UserService"; import { GroupService } from "../services/GroupService"; @@ -124,30 +125,34 @@ export class WebhookController { console.log("Processing group with data:", local.data); let participants: User[] = []; - if ( - local.data.participants && - Array.isArray(local.data.participants) - ) { - console.log("Processing participants:", local.data.participants); - const participantPromises = local.data.participants.map( - async (ref: string) => { - if (ref && typeof ref === "string") { - const userId = ref.split("(")[1].split(")")[0]; - console.log("Extracted userId:", userId); - return await this.userService.getUserById(userId); - } - return null; - } + if (local.data.participants !== undefined) { + participants = await resolveENameRefs( + local.data.participants, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} participants` } ); - - participants = ( - await Promise.all(participantPromises) - ).filter((user: User | null): user is User => user !== null); - console.log("Found participants:", participants.length); } - let adminIds = local?.data?.admins as string[] ?? [] - adminIds = adminIds.map((a) => a.includes("(") ? a.split("(")[1].split(")")[0]: a) + // Admins are eNames on the wire but a User relation locally, so + // they are resolved the same way participants are; an admin this + // instance does not know is skipped rather than being stored as a + // dangling id. + const admins = await resolveENameRefs( + local?.data?.admins, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} admins` } + ); + const adminIds = admins.map((a) => a.id); + + // `owner` is an eName on the wire and a local user id in the + // column, so it is resolved back. An owner this instance does + // not know leaves the column untouched rather than storing an + // eName where an id belongs. + const ownerUser = await resolveENameRef( + local?.data?.owner, + (ename) => this.userService.getUserByEname(ename), + { context: `group ${globalId} owner` } + ); if (localId) { console.log("Updating existing group with localId:", localId); @@ -159,7 +164,7 @@ export class WebhookController { group.name = local.data.name as string; group.description = local.data.description as string; - group.owner = local.data.owner as string; + if (ownerUser) group.owner = ownerUser.id; group.admins = adminIds.map(id => ({ id } as User)); group.participants = participants; group.charter = local.data.charter as string; @@ -173,7 +178,7 @@ export class WebhookController { const group = await this.groupService.createGroup( local.data.name as string, local.data.description as string, - local.data.owner as string, + ownerUser?.id as string, adminIds, participants.map(p => p.id), local.data.charter as string | undefined, diff --git a/platforms/evoting/api/src/web3adapter/mappings/group.mapping.json b/platforms/evoting/api/src/web3adapter/mappings/group.mapping.json index f8b60e204..37fb844b0 100644 --- a/platforms/evoting/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/evoting/api/src/web3adapter/mappings/group.mapping.json @@ -6,12 +6,12 @@ "localToUniversalMap": { "name": "name", "description": "description", - "owner": "owner", - "admins": "users(admins[].id),adminIds", + "owner": "__ename(owner),owner", + "admins": "__ename(admins[].ename),adminIds", "charter": "charter", "ename": "ename", - "participants": "users(participants[].id),participantIds", - "members": "users(members[].id),memberIds", + "participants": "__ename(participants[].ename),participantIds", + "members": "__ename(members[].ename),memberIds", "isPrivate": "isPrivate", "visibility": "visibility", "avatarUrl": "__file(avatarUrl)", diff --git a/platforms/evoting/api/src/web3adapter/mappings/message.mapping.json b/platforms/evoting/api/src/web3adapter/mappings/message.mapping.json index 88a37b376..5424a1d83 100644 --- a/platforms/evoting/api/src/web3adapter/mappings/message.mapping.json +++ b/platforms/evoting/api/src/web3adapter/mappings/message.mapping.json @@ -5,7 +5,7 @@ "ownedJunctionTables": [], "localToUniversalMap": { "text": "content", - "sender": "users(sender.id),senderId", + "sender": "__ename(sender.ename),senderId", "group": "groups(group.id),chatId", "isSystemMessage": "isSystemMessage", "createdAt": "createdAt", diff --git a/platforms/evoting/api/src/web3adapter/watchers/subscriber.ts b/platforms/evoting/api/src/web3adapter/watchers/subscriber.ts index 288e5e994..861e5690c 100644 --- a/platforms/evoting/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/evoting/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -116,7 +116,20 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - return this.entityToPlain(enrichedEntity); + // `owner` and `admins` are stored as bare local user ids with no + // relation to follow, but they name people, so they must go on the + // wire as eNames like every other entity reference. + const plain = this.entityToPlain(enrichedEntity); + if (tableName === "groups" || tableName === "group") { + return await enrichGroupOwnership(plain, async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }); + } + return plain; } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/file-manager/api/src/controllers/WebhookController.ts b/platforms/file-manager/api/src/controllers/WebhookController.ts index e85d81e92..4ffaedf76 100644 --- a/platforms/file-manager/api/src/controllers/WebhookController.ts +++ b/platforms/file-manager/api/src/controllers/WebhookController.ts @@ -3,7 +3,7 @@ import { UserService } from "../services/UserService"; import { GroupService } from "../services/GroupService"; import { MessageService } from "../services/MessageService"; import { FileService } from "../services/FileService"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, resolveENameRef, resolveENameRefs } from "web3-adapter"; import { User } from "../database/entities/User"; import { Group } from "../database/entities/Group"; import { Message } from "../database/entities/Message"; @@ -95,27 +95,34 @@ export class WebhookController { } } else if (mapping.tableName === "groups") { let participants: User[] = []; - if ( - local.data.participants && - Array.isArray(local.data.participants) - ) { - const participantPromises = local.data.participants.map( - async (ref: string) => { - if (ref && typeof ref === "string") { - const userId = ref.split("(")[1].split(")")[0]; - return await this.userService.getUserById(userId); - } - return null; - } + if (local.data.participants !== undefined) { + participants = await resolveENameRefs( + local.data.participants, + (ename) => this.userService.findByEname(ename), + { context: `group ${globalId} participants` } ); - - participants = ( - await Promise.all(participantPromises) - ).filter((user: User | null): user is User => user !== null); } - let adminIds = local?.data?.admins as string[] ?? [] - adminIds = adminIds.map((a) => a.includes("(") ? a.split("(")[1].split(")")[0]: a) + // Admins are eNames on the wire but a User relation locally, so + // they are resolved the same way participants are; an admin this + // instance does not know is skipped rather than being stored as a + // dangling id. + const admins = await resolveENameRefs( + local?.data?.admins, + (ename) => this.userService.findByEname(ename), + { context: `group ${globalId} admins` } + ); + const adminIds = admins.map((a) => a.id); + + // `owner` is an eName on the wire and a local user id in the + // column, so it is resolved back. An owner this instance does + // not know leaves the column untouched rather than storing an + // eName where an id belongs. + const ownerUser = await resolveENameRef( + local?.data?.owner, + (ename) => this.userService.findByEname(ename), + { context: `group ${globalId} owner` } + ); if (localId) { const group = await this.groupService.getGroupById(localId); @@ -125,7 +132,7 @@ export class WebhookController { group.name = local.data.name as string; group.description = local.data.description as string; - group.owner = local.data.owner as string; + if (ownerUser) group.owner = ownerUser.id; group.admins = adminIds.map(id => ({ id } as User)); group.participants = participants; group.charter = local.data.charter as string; @@ -158,7 +165,7 @@ export class WebhookController { const group = await this.groupService.createGroup( local.data.name as string, local.data.description as string, - local.data.owner as string, + ownerUser?.id as string, adminIds, participants.map(p => p.id), local.data.charter as string | undefined, @@ -183,10 +190,11 @@ export class WebhookController { let sender: User | null = null; let group: Group | null = null; - if (local.data.sender && typeof local.data.sender === "string") { - const senderId = local.data.sender.split("(")[1].split(")")[0]; - sender = await this.userService.getUserById(senderId); - } + sender = await resolveENameRef( + local.data.sender, + (ename) => this.userService.findByEname(ename), + { context: `message ${globalId} sender` } + ); if (local.data.group && typeof local.data.group === "string") { const groupId = local.data.group.split("(")[1].split(")")[0]; diff --git a/platforms/file-manager/api/src/web3adapter/mappings/group.mapping.json b/platforms/file-manager/api/src/web3adapter/mappings/group.mapping.json index 10221ecae..f6175dee8 100644 --- a/platforms/file-manager/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/file-manager/api/src/web3adapter/mappings/group.mapping.json @@ -8,12 +8,12 @@ "localToUniversalMap": { "name": "name", "description": "description", - "owner": "owner", - "admins": "users(admins[].id),adminIds", + "owner": "__ename(owner),owner", + "admins": "__ename(admins[].ename),adminIds", "charter": "charter", "ename": "ename", - "participants": "users(participants[].id),participantIds", - "members": "users(members[].id),memberIds", + "participants": "__ename(participants[].ename),participantIds", + "members": "__ename(members[].ename),memberIds", "originalMatchParticipants": "originalMatchParticipants", "isPrivate": "isPrivate", "visibility": "visibility", @@ -24,4 +24,3 @@ }, "readOnly": false } - diff --git a/platforms/file-manager/api/src/web3adapter/mappings/message.mapping.json b/platforms/file-manager/api/src/web3adapter/mappings/message.mapping.json index ac51a0215..5424a1d83 100644 --- a/platforms/file-manager/api/src/web3adapter/mappings/message.mapping.json +++ b/platforms/file-manager/api/src/web3adapter/mappings/message.mapping.json @@ -5,7 +5,7 @@ "ownedJunctionTables": [], "localToUniversalMap": { "text": "content", - "sender": "users(sender.id),senderId", + "sender": "__ename(sender.ename),senderId", "group": "groups(group.id),chatId", "isSystemMessage": "isSystemMessage", "createdAt": "createdAt", @@ -13,4 +13,3 @@ "isArchived": "isArchived" } } - diff --git a/platforms/file-manager/api/src/web3adapter/watchers/subscriber.ts b/platforms/file-manager/api/src/web3adapter/watchers/subscriber.ts index 05daa9ed3..c7b01d897 100644 --- a/platforms/file-manager/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/file-manager/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -85,7 +85,20 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - return this.entityToPlain(enrichedEntity); + // `owner` and `admins` are stored as bare local user ids with no + // relation to follow, but they name people, so they must go on the + // wire as eNames like every other entity reference. + const plain = this.entityToPlain(enrichedEntity); + if (tableName === "groups" || tableName === "group") { + return await enrichGroupOwnership(plain, async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }); + } + return plain; } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/group-charter-manager/api/src/controllers/WebhookController.ts b/platforms/group-charter-manager/api/src/controllers/WebhookController.ts index c7892f9d2..4b4b59671 100644 --- a/platforms/group-charter-manager/api/src/controllers/WebhookController.ts +++ b/platforms/group-charter-manager/api/src/controllers/WebhookController.ts @@ -1,7 +1,7 @@ import { Request, Response } from "express"; import { UserService } from "../services/UserService"; import { GroupService } from "../services/GroupService"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, resolveENameRef, resolveENameRefs } from "web3-adapter"; import { User } from "../database/entities/User"; import { Group } from "../database/entities/Group"; import axios from "axios"; @@ -107,30 +107,29 @@ export class WebhookController { console.log("Processing group with data:", local.data); let participants: User[] = []; - if ( - local.data.participants && - Array.isArray(local.data.participants) - ) { - console.log("Processing participants:", local.data.participants); - const participantPromises = local.data.participants.map( - async (ref: string) => { - if (ref && typeof ref === "string") { - const userId = ref.split("(")[1].split(")")[0]; - console.log("Extracted userId:", userId); - return await this.userService.getUserById(userId); - } - return null; - } + if (local.data.participants !== undefined) { + participants = await resolveENameRefs( + local.data.participants, + (ename) => this.userService.findByEname(ename), + { context: `group ${globalId} participants` } ); - - participants = ( - await Promise.all(participantPromises) - ).filter((user): user is User => user !== null); - console.log("Found participants:", participants.length); } - let admins = local?.data?.admins as string[] ?? [] - admins = admins.map((a) => a.includes("(") ? a.split("(")[1].split(")")[0]: a) + // `admins` and `owner` are eNames on the wire but local user ids + // in the columns, so they are resolved back. Anyone this instance + // does not know is skipped rather than stored as a dangling id. + const adminUsers = await resolveENameRefs( + local?.data?.admins, + (ename) => this.userService.findByEname(ename), + { context: `group ${globalId} admins` } + ); + const admins = adminUsers.map((a) => a.id); + + const ownerUser = await resolveENameRef( + local?.data?.owner, + (ename) => this.userService.findByEname(ename), + { context: `group ${globalId} owner` } + ); if (localId) { console.log("Updating existing group with localId:", localId); @@ -142,7 +141,7 @@ export class WebhookController { group.name = local.data.name as string; group.description = local.data.description as string; - group.owner = local.data.owner as string; + if (ownerUser) group.owner = ownerUser.id; group.admins = admins; group.participants = participants; @@ -154,7 +153,7 @@ export class WebhookController { const group = await this.groupService.createGroup({ name: local.data.name as string, description: local.data.description as string, - owner: local.data.owner as string, + owner: ownerUser?.id as string, admins, participants: participants, }); diff --git a/platforms/group-charter-manager/api/src/web3adapter/mappings/group.mapping.json b/platforms/group-charter-manager/api/src/web3adapter/mappings/group.mapping.json index d4e4dea53..b23072492 100644 --- a/platforms/group-charter-manager/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/group-charter-manager/api/src/web3adapter/mappings/group.mapping.json @@ -6,10 +6,10 @@ "localToUniversalMap": { "name": "name", "description": "description", - "owner": "owner", - "admins": "users(admins),admins", + "owner": "__ename(owner),owner", + "admins": "__ename(admins[]),admins", "charter": "charter", - "participants": "users(participants[].id),participantIds", + "participants": "__ename(participants[].ename),participantIds", "charterSignatures": "charter_signatures(charterSignatures[].id),signatureIds", "createdAt": "createdAt", "updatedAt": "updatedAt", diff --git a/platforms/group-charter-manager/api/src/web3adapter/mappings/message.mapping.json b/platforms/group-charter-manager/api/src/web3adapter/mappings/message.mapping.json index ad52c47c5..725cd57ad 100644 --- a/platforms/group-charter-manager/api/src/web3adapter/mappings/message.mapping.json +++ b/platforms/group-charter-manager/api/src/web3adapter/mappings/message.mapping.json @@ -5,7 +5,7 @@ "ownedJunctionTables": [], "localToUniversalMap": { "text": "content", - "sender": "users(sender.id),senderId", + "sender": "__ename(sender.ename),senderId", "group": "groups(group.id),chatId", "isSystemMessage": "isSystemMessage", "createdAt": "createdAt", diff --git a/platforms/group-charter-manager/api/src/web3adapter/watchers/subscriber.ts b/platforms/group-charter-manager/api/src/web3adapter/watchers/subscriber.ts index 41c1772e7..facc60385 100644 --- a/platforms/group-charter-manager/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/group-charter-manager/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter } from "web3-adapter"; +import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -69,7 +69,20 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - return this.entityToPlain(enrichedEntity); + // `owner` and `admins` are stored as bare local user ids with no + // relation to follow, but they name people, so they must go on the + // wire as eNames like every other entity reference. + const plain = this.entityToPlain(enrichedEntity); + if (tableName === "groups" || tableName === "group") { + return await enrichGroupOwnership(plain, async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }); + } + return plain; } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/pictique/api/src/web3adapter/entity-refs.ts b/platforms/pictique/api/src/web3adapter/entity-refs.ts index 6e5c5fd75..c03b60545 100644 --- a/platforms/pictique/api/src/web3adapter/entity-refs.ts +++ b/platforms/pictique/api/src/web3adapter/entity-refs.ts @@ -1,85 +1,30 @@ -import { normaliseEName, normaliseENameList } from "web3-adapter"; +import { resolveENameRef, resolveENameRefs } from "web3-adapter"; import type { User } from "../database/entities/User"; import type { UserService } from "../services/UserService"; /** - * Resolves the entity references in an inbound chat envelope to local users. - * - * Chat participants, admins, and a message's sender are named by eName. This - * turns those names into the local `User` rows that represent them, and is the - * only place that decides what happens when one of them cannot be resolved. - * - * Two rules, both of which used to be violated in ways that lost whole rooms: - * - * - A reference that is not a usable eName is skipped, not fatal. The old - * `ref.split("(")[1].split(")")[0]` threw a TypeError on any bare eName, - * which took down ingest for the entire envelope. - * - A well-formed eName that names nobody locally is also skipped, and logged. - * Members may legitimately live on a platform this instance knows nothing - * about, and one such member must not cost the room its other members. + * Pictique's binding of the shared entity-reference resolver to its own user + * lookup. Chat participants, admins, and a message's sender are all named by + * eName; see `web3-adapter`'s `entity-refs` for what happens to references that + * cannot be resolved. */ + export async function resolveParticipants( refs: unknown, userService: UserService, context: string, ): Promise { - const enames = normaliseENameList(refs); - - const skipped = countSkipped(refs, enames.length); - if (skipped > 0) { - console.warn( - `[chat] ${context}: skipped ${skipped} malformed entity reference(s)`, - ); - } - - const resolved = await Promise.all( - enames.map(async (ename) => { - const user = await userService.findByEname(ename).catch((error) => { - console.warn(`[chat] ${context}: lookup failed for ${ename}:`, error); - return null; - }); - if (!user) { - console.warn( - `[chat] ${context}: no local user for ${ename}, skipping participant`, - ); - } - return user; - }), - ); - - return resolved.filter((user): user is User => user !== null); + return resolveENameRefs(refs, (ename) => userService.findByEname(ename), { + context, + }); } -/** - * Resolves a single entity reference, such as a message's sender. - * - * Returns `null` both for an unusable reference and for an eName nobody local - * answers to; the caller decides whether that is fatal for the record at hand. - */ export async function resolveEntityRef( ref: unknown, userService: UserService, context: string, ): Promise { - const ename = normaliseEName(ref); - if (!ename) { - if (ref !== null && ref !== undefined) { - console.warn(`[chat] ${context}: unusable entity reference`, ref); - } - return null; - } - - const user = await userService.findByEname(ename).catch((error) => { - console.warn(`[chat] ${context}: lookup failed for ${ename}:`, error); - return null; + return resolveENameRef(ref, (ename) => userService.findByEname(ename), { + context, }); - if (!user) { - console.warn(`[chat] ${context}: no local user for ${ename}`); - } - return user; -} - -function countSkipped(refs: unknown, kept: number): number { - const total = Array.isArray(refs) ? refs.length : refs == null ? 0 : 1; - return Math.max(0, total - kept); } From 1b32b790cf9d1187154b69bd9a18032382059837 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 09:34:33 +0530 Subject: [PATCH 04/13] Pin the eName convention in the ontology and docs, and test the shipped mappings There was no written spec saying what a chat entity reference contains. The envelope-id convention was two implementations agreeing, which is how the two sides drifted apart without anything failing loudly. The Chat and Message schemas now say eName, with a pattern, and the Chat schema declares the admins, owner, and ename fields that platforms were already emitting. The mapping guide taught the envelope-id form by example; it now documents __ename() and the difference between a reference to a record and a reference to a person. The access-control docs marked both reference shapes as equally current, which is no longer true: eNames are canonical, and profile ids are read only because records written before that convention are still at rest. The new test loads the mapping files the platforms actually ship rather than fixtures, so a producer and a consumer cannot drift apart again without a test failing. Reverting any single mapping to the envelope-id form fails it. --- .../Post Platform Guide/access-control.md | 2 +- .../docs/Post Platform Guide/mapping-rules.md | 49 ++++- docs/docs/W3DS Protocol/Access-Control.md | 12 +- .../src/mapper/shipped-mappings.test.ts | 170 ++++++++++++++++++ services/ontology/schemas/chat.json | 21 ++- services/ontology/schemas/message.json | 8 +- 6 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts diff --git a/docs/docs/Post Platform Guide/access-control.md b/docs/docs/Post Platform Guide/access-control.md index 3c87c1f94..a435f8c79 100644 --- a/docs/docs/Post Platform Guide/access-control.md +++ b/docs/docs/Post Platform Guide/access-control.md @@ -163,7 +163,7 @@ A grant or denial can name a group eName, and it resolves to the group's members "require": [] } ``` -You do not have to normalise your group records first. Participants are read from `members`, `memberIds`, `participants`, `participantIds`, `admins` and `owner`, and each entry may be **either an eName or the id of that member's profile record** — the two shapes platforms actually write. A profile id resolves through the record's own `ename` field, falling back to the vault it lives in. +You do not have to normalise your group records first. Participants are read from `members`, `memberIds`, `participants`, `participantIds`, `admins` and `owner`. Each entry should be an **eName**, which is what platforms write today; the id of a member's profile record is still resolved, for records written before that convention was settled, through the record's own `ename` field falling back to the vault it lives in. Worth knowing: diff --git a/docs/docs/Post Platform Guide/mapping-rules.md b/docs/docs/Post Platform Guide/mapping-rules.md index c70fc6349..295b02b32 100644 --- a/docs/docs/Post Platform Guide/mapping-rules.md +++ b/docs/docs/Post Platform Guide/mapping-rules.md @@ -48,14 +48,49 @@ Maps a local relation to a global field, where: ### Array Relation Mapping ```json -"participants": "users(participants[].id),participantIds" +"charterSignatures": "charter_signatures(charterSignatures[].id),signatureIds" ``` Maps an array of relations: -- `participants[].id` extracts the `id` field from each item in the `participants` array -- `users()` resolves each ID to a global user reference -- `participantIds` is the target global field name +- `charterSignatures[].id` extracts the `id` field from each item in the array +- `charter_signatures()` resolves each id to a global reference +- `signatureIds` is the target global field name + +Use this for references to *records*. References to *people* use `__ename()` +instead — see below. + +### Entity References (`__ename`) + +```json +"participants": "__ename(participants[].ename),participantIds", +"sender": "__ename(sender.ename),senderId" +``` + +Marks a field as naming people rather than records. Chat participants, admins, +members, a group's owner, and a message's sender are all entity references. + +They carry an **eName** — an `@`-prefixed W3ID such as +`@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0` — and not the id of the referent's User +profile MetaEnvelope. An eName is stable, self-describing, and resolvable +without a profile envelope; an envelope id is none of those, and a user whose +eVault has no profile envelope yet does not have one at all. + +Behaviour: + +- On `toGlobal` each value is emitted as an `@`-prefixed eName. A bare W3ID + gains the `@`, so the wire format is uniform. +- On `fromGlobal` each value comes back as an eName. Entries that are not usable + eNames are dropped rather than throwing, because one bad entry in a + participant list must not cost the room its other participants. +- Whether the field is a list is decided by the mapping (`[]` in the path), not + by whatever happened to arrive, so a participant list that arrives as `null` + is an empty list rather than a scalar. + +Resolving an eName to a local user is the consumer's job. `resolveENameRefs` and +`resolveENameRef` in `web3-adapter` do it, skipping and logging anyone this +platform does not know — members may legitimately live on a platform this +instance has never heard of. ## Special Functions @@ -175,9 +210,9 @@ When junction table data changes, it triggers updates to the parent entity. "localToUniversalMap": { "name": "name", "description": "description", - "owner": "owner", - "admins": "users(admins),admins", - "participants": "users(participants[].id),participantIds", + "owner": "__ename(owner),owner", + "admins": "__ename(admins[].ename),admins", + "participants": "__ename(participants[].ename),participantIds", "createdAt": "__date(createdAt)", "updatedAt": "__date(updatedAt)" } diff --git a/docs/docs/W3DS Protocol/Access-Control.md b/docs/docs/W3DS Protocol/Access-Control.md index e298200ba..90b4d84e9 100644 --- a/docs/docs/W3DS Protocol/Access-Control.md +++ b/docs/docs/W3DS Protocol/Access-Control.md @@ -64,14 +64,18 @@ A group eName is not a party in its own right — it stands for the people in it The group's record is found either in the group's own vault or by its `ename` field naming the group, and its participants are read from whichever fields it carries — `members`, `memberIds`, `participants`, `participantIds`, `admins`, `owner`. A group's members are the union of all of them, so an admin is a member. -A participant may be named two ways, and both are accepted: +**An eName is the canonical way to name a participant**, and the only shape +platforms write today. A profile record's id is also accepted, but only because +records written before that convention was settled are still at rest in eVaults. | Written as | Example | Resolved by | |---|---|---| -| An eName | `@7b9c2e1a-…` | Taken as-is. | -| A profile record's id | `4f1a8c30-…` | Following the record to the eName behind it. | +| An eName — canonical | `@7b9c2e1a-…` | Taken as-is. | +| A profile record's id — legacy, read-only | `4f1a8c30-…` | Following the record to the eName behind it. | -Both occur in practice — `GroupManifest.members` holds eNames while `Group.participantIds` holds profile ids — so a policy naming a group works regardless of which shape the group was written with. +Reading both keeps a policy working against groups written at any point in time. +Do not write the legacy shape: an envelope id is not resolvable on its own, and +a user whose eVault has no profile envelope yet does not have one to write. When a participant is given as a profile id, the eName is taken from the record's own `ename` field where it has one, and otherwise from the vault the record lives in. The record's own statement wins because the same profile syncs into several vaults, so the vault it happens to sit in does not reliably identify its subject. diff --git a/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts b/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts new file mode 100644 index 000000000..334327a45 --- /dev/null +++ b/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts @@ -0,0 +1,170 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { MappingDatabase } from "../db"; +import { fromGlobal, toGlobal } from "./mapper"; +import type { IMapping } from "./mapper.types"; + +/** + * Exercises the mapping files the platforms actually ship, rather than fixtures + * written to match the implementation. + * + * The bug this guards against is a producer and a consumer disagreeing about + * the shape of an entity reference: chats replicated correctly and were then + * dropped on ingest with no error. That disagreement is invisible to a test + * that only ever looks at one side, or at a mapping written for the test. + */ + +const REPO = join(__dirname, "../../../.."); + +const CHAT_SCHEMA = "550e8400-e29b-41d4-a716-446655440003"; +const MESSAGE_SCHEMA = "550e8400-e29b-41d4-a716-446655440004"; + +const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; +const BOB = "@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60"; + +/** Every shipped chat/group and message mapping, by platform. */ +const MAPPINGS: { platform: string; path: string }[] = [ + { platform: "pictique", path: "platforms/pictique/api" }, + { platform: "blabsy", path: "platforms/blabsy/api" }, + { platform: "ereputation", path: "platforms/ereputation/api" }, + { platform: "esigner", path: "platforms/esigner/api" }, + { platform: "file-manager", path: "platforms/file-manager/api" }, + { platform: "ecurrency", path: "platforms/ecurrency/api" }, + { platform: "dreamsync", path: "platforms/dreamsync/api" }, + { platform: "evoting", path: "platforms/evoting/api" }, + { platform: "group-charter-manager", path: "platforms/group-charter-manager/api" }, + { platform: "cerberus", path: "platforms/cerberus/client" }, +]; + +function loadMappings(dir: string): IMapping[] { + const base = join(REPO, dir, "src/web3adapter/mappings"); + const out: IMapping[] = []; + for (const file of ["chat.mapping.json", "group.mapping.json", "message.mapping.json"]) { + try { + out.push(JSON.parse(readFileSync(join(base, file), "utf8"))); + } catch { + // Not every platform ships every mapping. + } + } + return out; +} + +/** Fields that name people, and so must be eNames on the wire. */ +const ENTITY_FIELDS = new Set([ + "participants", + "admins", + "members", + "sender", + "owner", +]); + +/** + * A store that returns nothing. + * + * Record relations (`charterSignatures`, `lastMessage`) legitimately consult + * it, so it cannot simply throw. Entity references not needing it is asserted + * separately: with a store that resolves nothing, a participant list that still + * comes back intact cannot have been resolved through it. + */ +const emptyStore = { + getLocalId: async () => null, + getGlobalId: async () => null, +} as unknown as MappingDatabase; + +describe("shipped chat mappings", () => { + const chatLike = MAPPINGS.flatMap(({ platform, path }) => + loadMappings(path) + .filter((m) => m.schemaId === CHAT_SCHEMA || m.schemaId === MESSAGE_SCHEMA) + .map((mapping) => ({ platform, mapping })), + ); + + it("finds a chat or message mapping for every platform", () => { + expect(chatLike.length).toBeGreaterThanOrEqual(MAPPINGS.length); + }); + + it.each(chatLike)( + "$platform/$mapping.tableName declares every entity reference with __ename()", + ({ mapping }) => { + for (const [local, global] of Object.entries( + mapping.localToUniversalMap, + )) { + if (!ENTITY_FIELDS.has(local)) continue; + + // A `table(path)` reference here would be an envelope-id + // reference, which is exactly what this change removes. + expect( + global.startsWith("__ename("), + `${mapping.tableName}.${local} is "${global}", expected __ename(...)`, + ).toBe(true); + } + }, + ); + + it.each(chatLike.filter((c) => c.mapping.schemaId === CHAT_SCHEMA))( + "$platform round-trips an eName participant list without resolving ids", + async ({ mapping }) => { + const participantsPath = mapping.localToUniversalMap.participants; + // Build a local record shaped the way this platform's path expects. + const usesRelation = participantsPath.includes("[].ename"); + const local = { + ename: "@group", + participants: usesRelation + ? [{ ename: ALICE }, { ename: BOB }] + : [ALICE, BOB], + }; + + const global = await toGlobal({ + data: local, + mapping, + mappingStore: emptyStore, + }); + + expect(global.data.participantIds).toEqual([ALICE, BOB]); + + const back = await fromGlobal({ + data: global.data as Record, + mapping, + mappingStore: emptyStore, + }); + + expect(back.data.participants).toEqual([ALICE, BOB]); + }, + ); + + it.each(chatLike.filter((c) => c.mapping.schemaId === CHAT_SCHEMA))( + "$platform ingests a malformed participant list without throwing", + async ({ mapping }) => { + const back = await fromGlobal({ + data: { + ename: "@group", + participantIds: [ALICE, null, 42, "", { nested: true }, [], BOB], + }, + mapping, + mappingStore: emptyStore, + }); + + expect(back.data.participants).toEqual([ALICE, BOB]); + }, + ); + + it.each(chatLike.filter((c) => c.mapping.schemaId === MESSAGE_SCHEMA))( + "$platform attributes a message to the eName in senderId", + async ({ mapping }) => { + const senderKey = Object.entries(mapping.localToUniversalMap).find( + ([local]) => local === "sender" || local === "senderId", + )?.[0]; + if (!senderKey) return; + + const back = await fromGlobal({ + data: { senderId: ALICE, content: "hi" }, + mapping, + mappingStore: { + getLocalId: async () => null, + } as unknown as MappingDatabase, + }); + + expect(back.data[senderKey]).toBe(ALICE); + }, + ); +}); diff --git a/services/ontology/schemas/chat.json b/services/ontology/schemas/chat.json index 33e8dfcce..c4ecd8397 100644 --- a/services/ontology/schemas/chat.json +++ b/services/ontology/schemas/chat.json @@ -26,9 +26,9 @@ "type": "array", "items": { "type": "string", - "format": "uuid" + "pattern": "^@.+" }, - "description": "Array of user IDs participating in the chat" + "description": "Array of eNames of the users participating in the chat. Entity references are eNames: an @-prefixed W3ID such as @48468c9a-dc1b-5663-92fb-5e46e3d2a7f0. eNames are used rather than User profile MetaEnvelope ids because they are stable, self-describing, and resolvable without a profile envelope, which a newly provisioned eVault may not have yet." }, "lastMessageId": { "type": "string", @@ -48,6 +48,23 @@ "isArchived": { "type": "boolean", "description": "Whether the chat is archived" + }, + "admins": { + "type": "array", + "items": { + "type": "string", + "pattern": "^@.+" + }, + "description": "Array of eNames of the chat's administrators." + }, + "owner": { + "type": "string", + "pattern": "^@.+", + "description": "eName of the chat's owner." + }, + "ename": { + "type": "string", + "description": "The chat's own eName, when the chat is backed by a group eVault." } }, "required": [ diff --git a/services/ontology/schemas/message.json b/services/ontology/schemas/message.json index b3bcaa904..14e79b69d 100644 --- a/services/ontology/schemas/message.json +++ b/services/ontology/schemas/message.json @@ -17,8 +17,8 @@ }, "senderId": { "type": "string", - "format": "uuid", - "description": "The ID of the user who sent the message" + "pattern": "^@.+", + "description": "The eName of the user who sent the message. Entity references are eNames: an @-prefixed W3ID such as @48468c9a-dc1b-5663-92fb-5e46e3d2a7f0. eNames are used rather than User profile MetaEnvelope ids because they are stable, self-describing, and resolvable without a profile envelope, which a newly provisioned eVault may not have yet." }, "content": { "type": "string", @@ -43,9 +43,9 @@ "type": "array", "items": { "type": "string", - "format": "uuid" + "pattern": "^@.+" }, - "description": "Array of user IDs who have read the message" + "description": "Array of eNames of users who have read the message." }, "createdAt": { "type": "string", From dfed245f36f5f1e489e36aab452a6c804d644c5f Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 09:38:36 +0530 Subject: [PATCH 05/13] Hydrate Blabsy chat participants through a cached eName lookup Under the envelope-id scheme a participant reference dereferenced straight to a full User record, so display names and avatars arrived free as part of the mapping. An eName carries identity but no profile data, so each one is now a separate read. Blabsy's chat surfaces fetched those profiles one at a time in a sequential loop, re-running on every render, across the chat list, the chat window, the member list, the settings pane, and add-members. That was already wasteful and becomes the change's real cost if left alone. Profiles are now loaded through one cache: reads run in parallel, a concurrent burst for the same eName collapses into a single read, and the current user is served from what the caller already holds. Misses are cached too, since a participant on a platform this instance knows nothing about is a stable condition rather than something to retry on every render. A failed read is not cached, so an offline blip does not persist for the whole TTL. Adds the @types/jest the client was already missing. --- platforms/blabsy/client/package.json | 1 + .../src/components/chat/add-members.tsx | 35 ++---- .../client/src/components/chat/chat-list.tsx | 12 +- .../src/components/chat/chat-window.tsx | 27 +---- .../src/components/chat/group-settings.tsx | 12 +- .../src/components/chat/member-list.tsx | 36 ++---- .../src/lib/firebase/participants.test.ts | 104 ++++++++++++++++++ .../client/src/lib/firebase/participants.ts | 95 ++++++++++++++++ pnpm-lock.yaml | 87 ++++++++------- 9 files changed, 277 insertions(+), 132 deletions(-) create mode 100644 platforms/blabsy/client/src/lib/firebase/participants.test.ts create mode 100644 platforms/blabsy/client/src/lib/firebase/participants.ts diff --git a/platforms/blabsy/client/package.json b/platforms/blabsy/client/package.json index 742189622..000b27948 100644 --- a/platforms/blabsy/client/package.json +++ b/platforms/blabsy/client/package.json @@ -36,6 +36,7 @@ "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^13.5.0", + "@types/jest": "^29.5.12", "@types/node": "18.19.130", "@types/react": "18.3.27", "@types/react-dom": "18.3.7", diff --git a/platforms/blabsy/client/src/components/chat/add-members.tsx b/platforms/blabsy/client/src/components/chat/add-members.tsx index b1534607e..a3181e5bc 100644 --- a/platforms/blabsy/client/src/components/chat/add-members.tsx +++ b/platforms/blabsy/client/src/components/chat/add-members.tsx @@ -10,7 +10,6 @@ import { import Image from 'next/image'; import { doc, - getDoc, Timestamp, collection, getDocs, @@ -19,6 +18,7 @@ import { limit } from 'firebase/firestore'; import { db } from '@lib/firebase/app'; +import { getParticipant, getParticipants } from '@lib/firebase/participants'; import type { User } from '@lib/types/user'; import { Loading } from '@components/ui/loading'; import { Dialog } from '@headlessui/react'; @@ -73,12 +73,9 @@ export function AddMembers({ const fetchUserData = async (): Promise => { try { - const userDoc = await getDoc( - doc(db, 'users', otherParticipant) - ); - if (userDoc.exists()) { - setOtherUser(userDoc.data() as User); - } else { + const other = await getParticipant(otherParticipant); + if (other) { + setOtherUser(other); } } catch (error) {} }; @@ -127,27 +124,9 @@ export function AddMembers({ const fetchParticipantData = async (): Promise => { try { - const newParticipantData: Record = {}; - - for (const participantId of currentChat.participants) { - if (participantId === user?.id) { - // Use current user data - if (user) { - newParticipantData[participantId] = user; - } - } else { - // Fetch other participants' data - const userDoc = await getDoc( - doc(db, 'users', participantId) - ); - if (userDoc.exists()) { - newParticipantData[participantId] = - userDoc.data() as User; - } - } - } - - setParticipantData(newParticipantData); + setParticipantData( + await getParticipants(currentChat.participants, user) + ); } catch (error) { console.error('Error fetching participants data:', error); } diff --git a/platforms/blabsy/client/src/components/chat/chat-list.tsx b/platforms/blabsy/client/src/components/chat/chat-list.tsx index 11839d777..eb4c16dd7 100644 --- a/platforms/blabsy/client/src/components/chat/chat-list.tsx +++ b/platforms/blabsy/client/src/components/chat/chat-list.tsx @@ -2,10 +2,9 @@ import Image from 'next/image'; import { formatDistanceToNow } from 'date-fns'; import { UserIcon } from '@heroicons/react/24/outline'; import { useEffect, useState } from 'react'; -import { doc, getDoc } from 'firebase/firestore'; import { useAuth } from '@lib/context/auth-context'; import { useChat } from '@lib/context/chat-context'; -import { db } from '@lib/firebase/app'; +import { getParticipant } from '@lib/firebase/participants'; import { Loading } from '@components/ui/loading'; import type { Chat } from '@lib/types/chat'; import { getChatType } from '@lib/types/chat'; @@ -36,12 +35,9 @@ export function ChatList(): JSX.Element { otherParticipantId && !participantData[otherParticipantId] ) { - const userDoc = await getDoc( - doc(db, 'users', otherParticipantId) - ); - if (userDoc.exists()) { - newParticipantData[otherParticipantId] = - userDoc.data() as User; + const other = await getParticipant(otherParticipantId); + if (other) { + newParticipantData[otherParticipantId] = other; } } } diff --git a/platforms/blabsy/client/src/components/chat/chat-window.tsx b/platforms/blabsy/client/src/components/chat/chat-window.tsx index d01e1332d..120f2dec2 100644 --- a/platforms/blabsy/client/src/components/chat/chat-window.tsx +++ b/platforms/blabsy/client/src/components/chat/chat-window.tsx @@ -12,8 +12,7 @@ import { ArrowLeftIcon } from '@heroicons/react/24/outline'; import Image from 'next/image'; -import { doc, getDoc } from 'firebase/firestore'; -import { db } from '@lib/firebase/app'; +import { getParticipants } from '@lib/firebase/participants'; import { Loading } from '@components/ui/loading'; import { MemberList } from './member-list'; import { GroupSettings } from './group-settings'; @@ -181,26 +180,10 @@ export function ChatWindow(): JSX.Element { const fetchParticipantsData = async (): Promise => { try { - const newParticipantsData: Record = {}; - - // Fetch data for all participants - for (const participantId of currentChat.participants) { - if (participantId === user?.id) { - // Use current user data - if (user) { - newParticipantsData[participantId] = user; - } - } else { - // Fetch other participants' data - const userDoc = await getDoc( - doc(db, 'users', participantId) - ); - if (userDoc.exists()) { - newParticipantsData[participantId] = - userDoc.data() as User; - } - } - } + const newParticipantsData = await getParticipants( + currentChat.participants, + user + ); setParticipantsData(newParticipantsData); diff --git a/platforms/blabsy/client/src/components/chat/group-settings.tsx b/platforms/blabsy/client/src/components/chat/group-settings.tsx index 2106d5cc2..df9b20f1c 100644 --- a/platforms/blabsy/client/src/components/chat/group-settings.tsx +++ b/platforms/blabsy/client/src/components/chat/group-settings.tsx @@ -2,8 +2,9 @@ import { useEffect, useState, useRef, ChangeEvent } from 'react'; import { useChat } from '@lib/context/chat-context'; import { useAuth } from '@lib/context/auth-context'; import Image from 'next/image'; -import { doc, getDoc, updateDoc, serverTimestamp } from 'firebase/firestore'; +import { doc, updateDoc, serverTimestamp } from 'firebase/firestore'; import { db } from '@lib/firebase/app'; +import { getParticipant } from '@lib/firebase/participants'; import type { User } from '@lib/types/user'; import { Dialog } from '@headlessui/react'; import { UserIcon, XMarkIcon } from '@heroicons/react/24/outline'; @@ -52,12 +53,9 @@ export function GroupSettings({ const fetchUserData = async (): Promise => { try { - const userDoc = await getDoc( - doc(db, 'users', otherParticipant) - ); - if (userDoc.exists()) { - setOtherUser(userDoc.data() as User); - } else { + const other = await getParticipant(otherParticipant); + if (other) { + setOtherUser(other); } } catch (error) {} }; diff --git a/platforms/blabsy/client/src/components/chat/member-list.tsx b/platforms/blabsy/client/src/components/chat/member-list.tsx index 2110ba582..bb90d2d6c 100644 --- a/platforms/blabsy/client/src/components/chat/member-list.tsx +++ b/platforms/blabsy/client/src/components/chat/member-list.tsx @@ -8,8 +8,7 @@ import { XMarkIcon } from '@heroicons/react/24/outline'; import Image from 'next/image'; -import { doc, getDoc } from 'firebase/firestore'; -import { db } from '@lib/firebase/app'; +import { getParticipant, getParticipants } from '@lib/firebase/participants'; import type { User } from '@lib/types/user'; import { Loading } from '@components/ui/loading'; import { Dialog } from '@headlessui/react'; @@ -56,12 +55,9 @@ export function MemberList({ const fetchUserData = async (): Promise => { try { - const userDoc = await getDoc( - doc(db, 'users', otherParticipant) - ); - if (userDoc.exists()) { - setOtherUser(userDoc.data() as User); - } else { + const other = await getParticipant(otherParticipant); + if (other) { + setOtherUser(other); } } catch (error) {} }; @@ -110,27 +106,9 @@ export function MemberList({ const fetchParticipantData = async (): Promise => { try { - const newParticipantData: Record = {}; - - for (const participantId of currentChat.participants) { - if (participantId === user?.id) { - // Use current user data - if (user) { - newParticipantData[participantId] = user; - } - } else { - // Fetch other participants' data - const userDoc = await getDoc( - doc(db, 'users', participantId) - ); - if (userDoc.exists()) { - newParticipantData[participantId] = - userDoc.data() as User; - } - } - } - - setParticipantData(newParticipantData); + setParticipantData( + await getParticipants(currentChat.participants, user) + ); } catch (error) { console.error('Error fetching participants data:', error); } diff --git a/platforms/blabsy/client/src/lib/firebase/participants.test.ts b/platforms/blabsy/client/src/lib/firebase/participants.test.ts new file mode 100644 index 000000000..fbd0c9f0d --- /dev/null +++ b/platforms/blabsy/client/src/lib/firebase/participants.test.ts @@ -0,0 +1,104 @@ +/** + * @jest-environment jsdom + */ + +import { + getParticipant, + getParticipants, + invalidateParticipant +} from './participants'; + +const ALICE = '@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0'; +const BOB = '@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60'; +const STRANGER = '@0c0ffee0-dead-4bee-8fee-000000000000'; + +/** Profiles this fake Firestore knows about, keyed by document id. */ +const profiles: Record = { + [ALICE]: { id: ALICE, name: 'Alice' }, + [BOB]: { id: BOB, name: 'Bob' } +}; + +const getDoc = jest.fn(async (ref: { id: string }) => ({ + exists: () => ref.id in profiles, + data: () => profiles[ref.id] +})); + +jest.mock('firebase/firestore', () => ({ + doc: (_collection: unknown, id: string) => ({ id }), + getDoc: (ref: { id: string }) => getDoc(ref) +})); + +jest.mock('./collections', () => ({ usersCollection: {} })); + +describe('participant profile cache', () => { + beforeEach(() => { + getDoc.mockClear(); + for (const ename of [ALICE, BOB, STRANGER]) invalidateParticipant(ename); + }); + + it('resolves a display name for an eName-only participant', async () => { + // The cost of dropping the envelope dereference: the profile is a + // separate read, and it still has to work. + await expect(getParticipant(ALICE)).resolves.toEqual({ + id: ALICE, + name: 'Alice' + }); + }); + + it('reads a given participant once, then serves from cache', async () => { + await getParticipant(ALICE); + await getParticipant(ALICE); + await getParticipant(ALICE); + + expect(getDoc).toHaveBeenCalledTimes(1); + }); + + it('collapses a concurrent burst into one read', async () => { + // Every participant tile on a room render asks at the same moment. + await Promise.all([ + getParticipant(ALICE), + getParticipant(ALICE), + getParticipant(ALICE) + ]); + + expect(getDoc).toHaveBeenCalledTimes(1); + }); + + it('caches a miss so unknown participants are not re-read', async () => { + await expect(getParticipant(STRANGER)).resolves.toBeNull(); + await getParticipant(STRANGER); + + expect(getDoc).toHaveBeenCalledTimes(1); + }); + + it('resolves a whole participant list and omits the unknown', async () => { + const found = await getParticipants([ALICE, STRANGER, BOB]); + + expect(found[ALICE]).toEqual({ id: ALICE, name: 'Alice' }); + expect(found[BOB]).toEqual({ id: BOB, name: 'Bob' }); + expect(found[STRANGER]).toBeUndefined(); + }); + + it('does not read the current user, who is already in hand', async () => { + const self = { id: ALICE, name: 'Alice' } as never; + const found = await getParticipants([ALICE, BOB], self); + + expect(found[ALICE]).toBe(self); + expect(getDoc).toHaveBeenCalledTimes(1); + }); + + it('reads each distinct participant once for a list with duplicates', async () => { + await getParticipants([ALICE, BOB, ALICE, BOB]); + expect(getDoc).toHaveBeenCalledTimes(2); + }); + + it('does not cache a failed read', async () => { + getDoc.mockRejectedValueOnce(new Error('offline')); + + await expect(getParticipant(ALICE)).resolves.toBeNull(); + await expect(getParticipant(ALICE)).resolves.toEqual({ + id: ALICE, + name: 'Alice' + }); + }); +}); diff --git a/platforms/blabsy/client/src/lib/firebase/participants.ts b/platforms/blabsy/client/src/lib/firebase/participants.ts new file mode 100644 index 000000000..c21d9bda3 --- /dev/null +++ b/platforms/blabsy/client/src/lib/firebase/participants.ts @@ -0,0 +1,95 @@ +import { doc, getDoc } from 'firebase/firestore'; +import { usersCollection } from './collections'; +import type { User } from '@lib/types/user'; + +/** + * Loads participant profiles by eName, with a short-lived cache. + * + * A chat names its participants by eName. An eName carries identity but no + * profile data, so a display name or avatar is a separate read per participant, + * and every chat surface renders a list of them. Without a cache that is an + * N+1 on every render, repeated across the chat list, the window, the member + * list, and the settings panes, all of which show the same handful of people. + * + * A Blabsy user document is keyed by the user's eName, so the eName is the + * document id and no lookup table is needed. + * + * Misses are cached too: a participant on a platform this instance knows + * nothing about is a normal and stable condition, not something to retry on + * every render. + */ + +const TTL_MS = 5 * 60 * 1000; + +type CacheEntry = { + value: User | null; + expiresAt: number; +}; + +const cache = new Map(); +const inflight = new Map>(); + +async function loadProfile(ename: string): Promise { + const snapshot = await getDoc(doc(usersCollection, ename)); + return snapshot.exists() ? snapshot.data() : null; +} + +/** Resolves one participant, from cache when it is fresh. */ +export async function getParticipant(ename: string): Promise { + const cached = cache.get(ename); + if (cached && cached.expiresAt > Date.now()) return cached.value; + cache.delete(ename); + + const existing = inflight.get(ename); + if (existing) return existing; + + const pending = loadProfile(ename) + .then((value) => { + cache.set(ename, { value, expiresAt: Date.now() + TTL_MS }); + return value; + }) + .catch((error) => { + // Not cached: a transient failure should not be remembered for the + // whole TTL the way a genuine miss is. + console.warn(`Failed to load profile ${ename}:`, error); + return null; + }) + .finally(() => { + inflight.delete(ename); + }); + + inflight.set(ename, pending); + return pending; +} + +/** + * Resolves a participant list in parallel, keyed by eName. + * + * `self` short-circuits the current user, who is already in hand and is in + * almost every list. Participants that resolve to nothing are omitted rather + * than left as holes for the caller to guard. + */ +export async function getParticipants( + enames: readonly string[], + self?: User | null +): Promise> { + const unique = Array.from(new Set(enames)); + + const entries = await Promise.all( + unique.map(async (ename) => { + if (self && ename === self.id) return [ename, self] as const; + return [ename, await getParticipant(ename)] as const; + }) + ); + + const out: Record = {}; + for (const [ename, user] of entries) { + if (user) out[ename] = user; + } + return out; +} + +/** Drops a cached profile, for when it is known to have changed. */ +export function invalidateParticipant(ename: string): void { + cache.delete(ename); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index feffb0a48..b1f9a2ed2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1111,6 +1111,9 @@ importers: '@testing-library/user-event': specifier: ^13.5.0 version: 13.5.0(@testing-library/dom@10.4.1) + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: 18.19.130 version: 18.19.130 @@ -3272,7 +3275,7 @@ importers: version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) draft-js: specifier: ^0.11.7 - version: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@18.3.1) @@ -3293,7 +3296,7 @@ importers: version: 18.3.1(react@18.3.1) react-draft-wysiwyg: specifier: ^1.15.0 - version: 1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-hook-form: specifier: ^7.55.0 version: 7.71.2(react@18.3.1) @@ -30223,26 +30226,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': - dependencies: - '@testing-library/dom': 10.4.1 - '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/utils': 3.2.4 - magic-string: 0.30.21 - sirv: 3.0.2 - tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - ws: 8.19.0(bufferutil@4.1.0) - optionalDependencies: - playwright: 1.58.2 - transitivePeerDependencies: - - bufferutil - - msw - - utf-8-validate - - vite - optional: true - '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 @@ -30262,16 +30245,16 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/utils': 3.2.4 magic-string: 0.30.21 sirv: 3.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) ws: 8.19.0(bufferutil@4.1.0) optionalDependencies: playwright: 1.58.2 @@ -30391,6 +30374,15 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + optional: true + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -32862,9 +32854,9 @@ snapshots: dotenv@17.3.1: {} - draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - fbjs: 2.0.0(encoding@0.1.13) + fbjs: 2.0.0 immutable: 3.7.6 object-assign: 4.1.1 react: 18.3.1 @@ -32872,9 +32864,9 @@ snapshots: transitivePeerDependencies: - encoding - draftjs-utils@0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + draftjs-utils@0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 drizzle-kit@0.31.9: @@ -34221,7 +34213,7 @@ snapshots: fbjs-css-vars@1.0.2: {} - fbjs@2.0.0(encoding@0.1.13): + fbjs@2.0.0: dependencies: core-js: 3.48.0 cross-fetch: 3.2.0(encoding@0.1.13) @@ -35117,9 +35109,9 @@ snapshots: html-tags@3.3.1: {} - html-to-draftjs@1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + html-to-draftjs@1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 html-url-attributes@3.0.1: {} @@ -39491,12 +39483,12 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-draft-wysiwyg@1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-draft-wysiwyg@1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: classnames: 2.5.1 - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - draftjs-utils: 0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) - html-to-draftjs: 1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draftjs-utils: 0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + html-to-draftjs: 1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) immutable: 5.1.5 linkify-it: 2.2.0 prop-types: 15.8.1 @@ -42492,6 +42484,25 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.26 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + sass: 1.98.0 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + optional: true + vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.4 @@ -42683,7 +42694,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 20.19.26 - '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) + '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) jsdom: 19.0.0(bufferutil@4.1.0) transitivePeerDependencies: - jiti @@ -42727,7 +42738,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.19.15 - '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) + '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) jsdom: 19.0.0(bufferutil@4.1.0) transitivePeerDependencies: - jiti From ccd1d6a9fa02dd4b08f4b0ae9494ca53fcfb293b Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 09:39:55 +0530 Subject: [PATCH 06/13] Format the new adapter sources with biome --- .../src/mapper/ename-directive.test.ts | 6 +++++- .../src/mapper/shipped-mappings.test.ts | 15 ++++++++++++--- .../web3-adapter/src/w3ds/group-ownership.test.ts | 10 +++++++--- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/infrastructure/web3-adapter/src/mapper/ename-directive.test.ts b/infrastructure/web3-adapter/src/mapper/ename-directive.test.ts index cc900bcd8..7410a2138 100644 --- a/infrastructure/web3-adapter/src/mapper/ename-directive.test.ts +++ b/infrastructure/web3-adapter/src/mapper/ename-directive.test.ts @@ -127,7 +127,11 @@ describe("__ename mapping directive", () => { describe("fromGlobal — consumers accept eNames only", () => { it("hands back an eName participant list unchanged", async () => { const local = await fromGlobal({ - data: { ename: "@group", participantIds: [ALICE, BOB], admins: [ALICE] }, + data: { + ename: "@group", + participantIds: [ALICE, BOB], + admins: [ALICE], + }, mapping: chatMapping, mappingStore, }); diff --git a/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts b/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts index 334327a45..0c9b271f4 100644 --- a/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts +++ b/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts @@ -33,14 +33,21 @@ const MAPPINGS: { platform: string; path: string }[] = [ { platform: "ecurrency", path: "platforms/ecurrency/api" }, { platform: "dreamsync", path: "platforms/dreamsync/api" }, { platform: "evoting", path: "platforms/evoting/api" }, - { platform: "group-charter-manager", path: "platforms/group-charter-manager/api" }, + { + platform: "group-charter-manager", + path: "platforms/group-charter-manager/api", + }, { platform: "cerberus", path: "platforms/cerberus/client" }, ]; function loadMappings(dir: string): IMapping[] { const base = join(REPO, dir, "src/web3adapter/mappings"); const out: IMapping[] = []; - for (const file of ["chat.mapping.json", "group.mapping.json", "message.mapping.json"]) { + for (const file of [ + "chat.mapping.json", + "group.mapping.json", + "message.mapping.json", + ]) { try { out.push(JSON.parse(readFileSync(join(base, file), "utf8"))); } catch { @@ -75,7 +82,9 @@ const emptyStore = { describe("shipped chat mappings", () => { const chatLike = MAPPINGS.flatMap(({ platform, path }) => loadMappings(path) - .filter((m) => m.schemaId === CHAT_SCHEMA || m.schemaId === MESSAGE_SCHEMA) + .filter( + (m) => m.schemaId === CHAT_SCHEMA || m.schemaId === MESSAGE_SCHEMA, + ) .map((mapping) => ({ platform, mapping })), ); diff --git a/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts b/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts index a3b823973..a0f5b7071 100644 --- a/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts +++ b/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts @@ -5,8 +5,9 @@ const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; const BOB = "@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60"; /** Maps local ids to eNames for alice and bob, and knows nobody else. */ -const lookup = vi.fn(async (id: string) => - ({ "local-alice": ALICE, "local-bob": BOB })[id] ?? null, +const lookup = vi.fn( + async (id: string) => + ({ "local-alice": ALICE, "local-bob": BOB })[id] ?? null, ); describe("enrichGroupOwnership", () => { @@ -50,7 +51,10 @@ describe("enrichGroupOwnership", () => { // Emitting a raw local id would put a reference on the wire that no // consumer accepts, which is the failure this whole change removes. const group = await enrichGroupOwnership( - { owner: "local-alice", admins: ["local-alice", "who-is-this", null, 42] }, + { + owner: "local-alice", + admins: ["local-alice", "who-is-this", null, 42], + }, lookup, ); expect(group.admins).toEqual([ALICE]); From 909e5c61184dd09f531860825251ba6bc8fcb698 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 09:48:25 +0530 Subject: [PATCH 07/13] Emit group ownership eNames from the adapter, not the watchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two producer-side bugs, both silent, both found by probing the emit path directly rather than trusting the consumer tests. First: enrichGroupOwnership flattened admins to strings. Six platforms store admins as a User[] relation and their mappings read admins[].ename, so flattening left the mapping asking for .ename on a string and emitting an empty list. Every group would have replicated with no admins. A relation is now left untouched, and only a list of bare local ids is rewritten. Second: the enrichment ran in each watcher's enrichEntity, which several producer paths never call — junction-table changes, debounced group webhooks, and backfill scripts all reach handleChange directly. Those paths emitted a bare local id as the owner. The enrichment now lives in handleChange, behind an optional resolveEnameByUserId hook, because that is the one point every producer path passes through; enriching anywhere else means each new call site is another chance to emit a local id unnoticed. Also stops idToEName falling back to the input when a lookup finds nothing. That turned an unresolved local id into "@": syntactically a valid eName and semantically nobody, which is the same class of silently-wrong reference this change exists to remove. An unresolved owner is now null and skipped. The shipped-mappings suite only exercised participants, which is why it missed the admins bug. It now builds every entity field in each platform's own local shape and runs the full producer path; reintroducing the flattening fails it on seven platforms. A new handleChange test covers the junction and backfill paths end to end. --- .../__tests__/handle-change-enames.test.ts | 66 ++++++++++++ infrastructure/web3-adapter/src/index.ts | 26 ++++- .../src/mapper/shipped-mappings.test.ts | 100 ++++++++++++++++++ .../src/w3ds/group-ownership.test.ts | 24 ++++- .../web3-adapter/src/w3ds/group-ownership.ts | 54 ++++++---- .../src/web3adapter/watchers/subscriber.ts | 28 +++-- .../src/web3adapter/watchers/subscriber.ts | 28 +++-- .../src/web3adapter/watchers/subscriber.ts | 28 +++-- .../src/web3adapter/watchers/subscriber.ts | 28 +++-- .../src/web3adapter/watchers/subscriber.ts | 28 +++-- .../src/web3adapter/watchers/subscriber.ts | 28 +++-- .../src/web3adapter/watchers/subscriber.ts | 28 +++-- .../src/web3adapter/watchers/subscriber.ts | 28 +++-- 13 files changed, 351 insertions(+), 143 deletions(-) create mode 100644 infrastructure/web3-adapter/src/__tests__/handle-change-enames.test.ts diff --git a/infrastructure/web3-adapter/src/__tests__/handle-change-enames.test.ts b/infrastructure/web3-adapter/src/__tests__/handle-change-enames.test.ts new file mode 100644 index 000000000..6a5be8895 --- /dev/null +++ b/infrastructure/web3-adapter/src/__tests__/handle-change-enames.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { Web3Adapter } from "../index"; + +/** + * `handleChange` is the one point every producer path passes through: direct + * entity writes, junction-table changes, debounced group webhooks, and backfill + * scripts all funnel into it. + * + * Enriching entity references anywhere else — a watcher's `enrichEntity`, say — + * only covers the paths that happen to call it. Several do not, and a group + * reaching the mapper without enrichment silently emits a bare local id, which + * no consumer accepts. So the enrichment belongs here, and this asserts it. + */ + +const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; + +function makeAdapter() { + const stored: { data: Record }[] = []; + + const adapter = new Web3Adapter({ + schemasPath: `${__dirname}/../../../../platforms/ereputation/api/src/web3adapter/mappings`, + dbPath: `/tmp/w3a-test-${Math.random().toString(36).slice(2)}`, + registryUrl: "http://registry.invalid", + platform: "http://platform.invalid", + resolveEnameByUserId: async (id) => (id === "local-alice" ? ALICE : null), + }); + + // Keep the test off the network: record what would have been stored. + adapter.evaultClient = { + storeMetaEnvelope: vi.fn(async (env: { data: Record }) => { + stored.push(env); + return "global-1"; + }), + storeReference: vi.fn(async () => undefined), + updateMetaEnvelopeById: vi.fn(async () => undefined), + } as never; + + return { adapter, stored }; +} + +describe("handleChange emits eNames for group ownership", () => { + it("rewrites a bare local owner id, on any producer path", async () => { + const { adapter, stored } = makeAdapter(); + // Wait for the mappings to load off disk. + await adapter.readPaths(); + + await adapter.handleChange({ + tableName: "groups", + data: { + id: "group-1", + ename: "@group", + name: "Standup", + // The shape a junction-table or debounced webhook hands over: + // a plain entity snapshot, never passed through enrichEntity. + owner: "local-alice", + participants: [{ id: "local-alice", ename: ALICE }], + admins: [{ id: "local-alice", ename: ALICE }], + }, + }); + + expect(stored).toHaveLength(1); + expect(stored[0].data.owner).toBe(ALICE); + expect(stored[0].data.participantIds).toEqual([ALICE]); + expect(stored[0].data.admins).toEqual([ALICE]); + }); +}); diff --git a/infrastructure/web3-adapter/src/index.ts b/infrastructure/web3-adapter/src/index.ts index 0952b308b..d253543f1 100644 --- a/infrastructure/web3-adapter/src/index.ts +++ b/infrastructure/web3-adapter/src/index.ts @@ -7,6 +7,7 @@ import { EVaultClient } from "./evault/evault"; import { logger } from "./logging"; import { fromGlobal, toGlobal } from "./mapper/mapper"; import type { IMapping } from "./mapper/mapper.types"; +import { enrichGroupOwnership } from "./w3ds/group-ownership"; export { EVaultClient } from "./evault/evault"; export type { @@ -278,6 +279,21 @@ export class Web3Adapter { registryUrl: string; platform: string; provisionerUrl?: string; + /** + * Resolves a local user id to that user's eName. + * + * Supplied by platforms whose group records name their `owner` (and + * sometimes `admins`) with bare local ids rather than a relation the + * mapping can follow to an `ename`. Those fields name people, so they + * have to leave as eNames like every other entity reference. + * + * It lives here rather than in each watcher because a group reaches + * `handleChange` from several call sites — direct writes, junction + * table changes, debounced group webhooks, backfill scripts — and + * enriching at each of them means every new call site is a chance to + * silently emit a local id again. + */ + resolveEnameByUserId?: (id: string) => Promise; }, ) { this.readPaths(); @@ -314,7 +330,15 @@ export class Web3Adapter { tableName: string; participants?: string[]; }) { - const { data, tableName, participants } = props; + const { tableName, participants } = props; + + // Entity references leave as eNames. Group-shaped records may name their + // owner or admins with bare local ids, which the mapping cannot follow to + // an `ename`, so they are rewritten here — the one point every producer + // path passes through. + const data = this.config.resolveEnameByUserId + ? await enrichGroupOwnership(props.data, this.config.resolveEnameByUserId) + : props.data; const existingGlobalId = await this.mappingDb.getGlobalId( data.id as string, diff --git a/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts b/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts index 0c9b271f4..3cc880802 100644 --- a/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts +++ b/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import type { MappingDatabase } from "../db"; +import { enrichGroupOwnership } from "../w3ds/group-ownership"; import { fromGlobal, toGlobal } from "./mapper"; import type { IMapping } from "./mapper.types"; @@ -176,4 +177,103 @@ describe("shipped chat mappings", () => { expect(back.data[senderKey]).toBe(ALICE); }, ); + + /** + * The full producer path, per platform: a local record shaped the way that + * platform's entity actually stores each field, run through the ownership + * enrichment and then the shipped mapping. + * + * This is the check that matters, because the platforms disagree about the + * shape of `admins` — a `User[]` relation on most, bare local ids on + * cerberus and group-charter-manager — and a mapping that reads + * `admins[].ename` emits an empty list if the producer hands it strings. + * Testing `participants` alone hides that entirely. + */ + it.each(chatLike.filter((c) => c.mapping.schemaId === CHAT_SCHEMA))( + "$platform emits every entity field as eNames from its own local shape", + async ({ mapping }) => { + const map = mapping.localToUniversalMap; + // Blabsy keys its user documents by eName, so its local records + // already hold eNames where other platforms hold local ids. + const keysAreEnames = mapping.tableName === "chat"; + + // Build each field the way this platform's mapping says it is stored. + // A `[].ename` path means a relation; a bare `[]` means a list of + // scalars, which on Blabsy are already eNames (user documents are + // keyed by eName) and elsewhere are local ids. + const scalars = keysAreEnames + ? [ALICE, BOB] + : ["local-alice", "local-bob"]; + const shaped = (spec: string | undefined) => + spec?.includes("[].ename") + ? [ + { id: "local-alice", ename: ALICE }, + { id: "local-bob", ename: BOB }, + ] + : scalars; + + const local: Record = { ename: "@group" }; + if (map.owner) local.owner = keysAreEnames ? ALICE : "local-alice"; + if (map.participants) local.participants = shaped(map.participants); + if (map.admins) local.admins = shaped(map.admins); + if (map.members) local.members = shaped(map.members); + + const enriched = await enrichGroupOwnership(local, async (id) => + id === "local-alice" ? ALICE : id === "local-bob" ? BOB : null, + ); + + const global = await toGlobal({ + data: enriched, + mapping, + mappingStore: emptyStore, + }); + + // Whatever each field is called globally, it must hold eNames and + // must not be empty when the local record named someone. + for (const [local_, spec] of Object.entries(map)) { + if (!ENTITY_FIELDS.has(local_)) continue; + const target = spec.includes(",") ? spec.split(",")[1] : local_; + const emitted = (global.data as Record)[target]; + + if (local_ === "owner") { + expect(emitted, `${mapping.tableName}.owner`).toBe(ALICE); + continue; + } + + expect( + emitted, + `${mapping.tableName}.${local_} -> ${target} emitted nothing`, + ).toEqual([ALICE, BOB]); + } + }, + ); + + it.each(chatLike.filter((c) => c.mapping.schemaId === MESSAGE_SCHEMA))( + "$platform emits a message sender as an eName from its own local shape", + async ({ mapping }) => { + const map = mapping.localToUniversalMap; + const senderSpec = map.sender ?? map.senderId; + if (!senderSpec) return; + + // `sender.ename` means a relation; a bare `senderId` is a scalar, + // which on Blabsy is already an eName. + const local: Record = { ename: "@group" }; + if (senderSpec.includes("sender.ename")) { + local.sender = { id: "local-alice", ename: ALICE }; + } else { + local.senderId = ALICE; + } + + const global = await toGlobal({ + data: local, + mapping, + mappingStore: emptyStore, + }); + + expect( + (global.data as Record).senderId, + `${mapping.tableName} senderId`, + ).toBe(ALICE); + }, + ); }); diff --git a/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts b/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts index a0f5b7071..a633ca4ba 100644 --- a/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts +++ b/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts @@ -39,12 +39,17 @@ describe("enrichGroupOwnership", () => { expect(spy).not.toHaveBeenCalled(); }); - it("reads an ename off an admin that arrives as a relation object", async () => { + it("leaves a relation-shaped admins list untouched", async () => { + // Most platforms store admins as a `User[]` relation, and their mapping + // reads `admins[].ename`. Flattening it to strings here would leave the + // mapping asking for `.ename` on a string and emitting an empty list, so + // a relation must pass through unchanged. + const admins = [{ id: "local-bob", ename: BOB }]; const group = await enrichGroupOwnership( - { owner: "local-alice", admins: [{ id: "local-bob", ename: BOB }] }, + { owner: "local-alice", admins }, lookup, ); - expect(group.admins).toEqual([BOB]); + expect(group.admins).toEqual(admins); }); it("drops admins that cannot be resolved rather than emitting an id", async () => { @@ -91,4 +96,17 @@ describe("enrichGroupOwnership", () => { const twice = await enrichGroupOwnership(once, lookup); expect(twice).toEqual(once); }); + + it("never fabricates an eName from an unresolved local id", async () => { + // `@` + a local uuid is syntactically a valid eName and semantically + // nobody. Emitting one would recreate the exact failure this change + // removes: a reference that looks fine and resolves to no one. + const group = await enrichGroupOwnership( + { owner: "3f8c1e2d-0000-4444-8888-aaaabbbbcccc", admins: [] }, + async () => null, + ); + + expect(group.owner).toBeNull(); + expect(group.owner).not.toBe("@3f8c1e2d-0000-4444-8888-aaaabbbbcccc"); + }); }); diff --git a/infrastructure/web3-adapter/src/w3ds/group-ownership.ts b/infrastructure/web3-adapter/src/w3ds/group-ownership.ts index b352effce..b6f8dbe9c 100644 --- a/infrastructure/web3-adapter/src/w3ds/group-ownership.ts +++ b/infrastructure/web3-adapter/src/w3ds/group-ownership.ts @@ -1,16 +1,22 @@ import { toEName } from "./ename"; /** - * Rewrites a group's `owner` and `admins` from local user ids to eNames. + * Rewrites a group's `owner`, and its `admins` when they are bare ids, to + * eNames. * * Participants and members are TypeORM relations, so the mapping can reach - * their `ename` directly. `owner` and `admins` are not: they are stored as bare - * local user ids, with no relation to follow. They still name people, so they - * are entity references and must go on the wire as eNames like every other one. + * their `ename` directly. `owner` is never a relation: it is stored as a bare + * local user id with nothing to follow. `admins` is one or the other depending + * on the platform — a `User[]` relation on most, a `string[]` of local ids on + * cerberus and group-charter-manager. * - * This runs on the producer side, just before a group is handed to the mapper, - * and is a no-op for values that are already eNames so it is safe to apply - * more than once. + * Either way they name people, so they are entity references and must go on the + * wire as eNames. A relation is left untouched, because the mapping for those + * platforms reads `admins[].ename` and flattening it to strings would leave it + * asking for `.ename` on a string and emitting an empty list. + * + * Runs on the producer side just before a group reaches the mapper, and is a + * no-op for values that are already eNames, so it is safe to apply twice. */ export async function enrichGroupOwnership( // biome-ignore lint/suspicious/noExplicitAny: TypeORM entity snapshot @@ -27,18 +33,18 @@ export async function enrichGroupOwnership( } if (Array.isArray(group.admins)) { - const admins = await Promise.all( - group.admins.map((admin: unknown) => - // An admin may already be a relation object once a platform loads - // it as one; prefer its ename before falling back to a lookup. - typeof admin === "object" && admin !== null - ? Promise.resolve( - toEName((admin as { ename?: unknown }).ename ?? null), - ) - : idToEName(admin, lookupEnameById), - ), + // A relation already carries `ename`, and the mapping reaches it + // directly. Only a list of bare ids needs rewriting. + const isRelation = group.admins.some( + (admin: unknown) => typeof admin === "object" && admin !== null, ); - enriched.admins = admins.filter((a): a is string => a !== null); + + if (!isRelation) { + const admins = await Promise.all( + group.admins.map((admin: unknown) => idToEName(admin, lookupEnameById)), + ); + enriched.admins = admins.filter((a): a is string => a !== null); + } } return enriched; @@ -55,7 +61,17 @@ async function idToEName( if (asEName) return asEName; try { - return toEName(await lookupEnameById(value)); + const ename = await lookupEnameById(value); + // Only a real lookup result becomes an eName. Falling back to the input + // would turn an unresolved local id into `@` — syntactically a + // valid eName, semantically nobody — which is precisely the kind of + // silently-wrong reference this whole change exists to remove. An + // unresolved owner is better left null and skipped by the consumer. + if (!ename) { + console.warn(`[chat] no eName for user ${value}, dropping reference`); + return null; + } + return toEName(ename); } catch (error) { console.warn(`[chat] could not resolve eName for user ${value}:`, error); return null; diff --git a/platforms/cerberus/client/src/web3adapter/watchers/subscriber.ts b/platforms/cerberus/client/src/web3adapter/watchers/subscriber.ts index 70618268c..7719000d1 100644 --- a/platforms/cerberus/client/src/web3adapter/watchers/subscriber.ts +++ b/platforms/cerberus/client/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; +import { Web3Adapter } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -17,6 +17,17 @@ export const adapter = new Web3Adapter({ dbPath: path.resolve(process.env.CERBERUS_MAPPING_DB_PATH as string), registryUrl: process.env.PUBLIC_REGISTRY_URL as string, platform: process.env.PUBLIC_CERBERUS_BASE_URL as string, + // `owner` (and on some platforms `admins`) is a bare local user id, which + // the mapping cannot follow to an `ename`. Resolving it here means every + // producer path emits eNames, including junction-table and backfill paths + // that never touch the watcher's enrichEntity. + resolveEnameByUserId: async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }, }); // Map of junction tables to their parent entities @@ -71,20 +82,7 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - // `owner` and `admins` are stored as bare local user ids with no - // relation to follow, but they name people, so they must go on the - // wire as eNames like every other entity reference. - const plain = this.entityToPlain(enrichedEntity); - if (tableName === "groups" || tableName === "group") { - return await enrichGroupOwnership(plain, async (id: string) => { - const user = await AppDataSource.getRepository("User").findOne({ - where: { id }, - select: ["id", "ename"], - }); - return (user as { ename?: string } | null)?.ename ?? null; - }); - } - return plain; + return this.entityToPlain(enrichedEntity); } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/dreamsync/api/src/web3adapter/watchers/subscriber.ts b/platforms/dreamsync/api/src/web3adapter/watchers/subscriber.ts index 3c0049da7..a335064bd 100644 --- a/platforms/dreamsync/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/dreamsync/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; +import { Web3Adapter } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -18,6 +18,17 @@ export const adapter = new Web3Adapter({ dbPath: path.resolve(process.env.DREAMSYNC_MAPPING_DB_PATH as string), registryUrl: process.env.PUBLIC_REGISTRY_URL as string, platform: process.env.VITE_DREAMSYNC_BASE_URL as string, + // `owner` (and on some platforms `admins`) is a bare local user id, which + // the mapping cannot follow to an `ename`. Resolving it here means every + // producer path emits eNames, including junction-table and backfill paths + // that never touch the watcher's enrichEntity. + resolveEnameByUserId: async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }, }); // Map of junction tables to their parent entities @@ -95,20 +106,7 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - // `owner` and `admins` are stored as bare local user ids with no - // relation to follow, but they name people, so they must go on the - // wire as eNames like every other entity reference. - const plain = this.entityToPlain(enrichedEntity); - if (tableName === "groups" || tableName === "group") { - return await enrichGroupOwnership(plain, async (id: string) => { - const user = await AppDataSource.getRepository("User").findOne({ - where: { id }, - select: ["id", "ename"], - }); - return (user as { ename?: string } | null)?.ename ?? null; - }); - } - return plain; + return this.entityToPlain(enrichedEntity); } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/ecurrency/api/src/web3adapter/watchers/subscriber.ts b/platforms/ecurrency/api/src/web3adapter/watchers/subscriber.ts index 862c56f17..79c121f64 100644 --- a/platforms/ecurrency/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/ecurrency/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; +import { Web3Adapter } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -18,6 +18,17 @@ export const adapter = new Web3Adapter({ dbPath: path.resolve(process.env.ECURRENCY_MAPPING_DB_PATH as string), registryUrl: process.env.PUBLIC_REGISTRY_URL as string, platform: process.env.VITE_ECURRENCY_BASE_URL as string, + // `owner` (and on some platforms `admins`) is a bare local user id, which + // the mapping cannot follow to an `ename`. Resolving it here means every + // producer path emits eNames, including junction-table and backfill paths + // that never touch the watcher's enrichEntity. + resolveEnameByUserId: async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }, }); // Map of junction tables to their parent entities @@ -78,20 +89,7 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } const enrichedEntity = { ...entity }; - // `owner` and `admins` are stored as bare local user ids with no - // relation to follow, but they name people, so they must go on the - // wire as eNames like every other entity reference. - const plain = this.entityToPlain(enrichedEntity); - if (tableName === "groups" || tableName === "group") { - return await enrichGroupOwnership(plain, async (id: string) => { - const user = await AppDataSource.getRepository("User").findOne({ - where: { id }, - select: ["id", "ename"], - }); - return (user as { ename?: string } | null)?.ename ?? null; - }); - } - return plain; + return this.entityToPlain(enrichedEntity); } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/ereputation/api/src/web3adapter/watchers/subscriber.ts b/platforms/ereputation/api/src/web3adapter/watchers/subscriber.ts index 525179bb0..334348d6d 100644 --- a/platforms/ereputation/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/ereputation/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; +import { Web3Adapter } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -18,6 +18,17 @@ export const adapter = new Web3Adapter({ dbPath: path.resolve(process.env.EREPUTATION_MAPPING_DB_PATH as string), registryUrl: process.env.PUBLIC_REGISTRY_URL as string, platform: process.env.VITE_EREPUTATION_BASE_URL as string, + // `owner` (and on some platforms `admins`) is a bare local user id, which + // the mapping cannot follow to an `ename`. Resolving it here means every + // producer path emits eNames, including junction-table and backfill paths + // that never touch the watcher's enrichEntity. + resolveEnameByUserId: async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }, }); // Map of junction tables to their parent entities @@ -59,20 +70,7 @@ export class PostgresSubscriber implements EntitySubscriberInterface { enrichedEntity.author = author; } - // `owner` and `admins` are stored as bare local user ids with no - // relation to follow, but they name people, so they must go on the - // wire as eNames like every other entity reference. - const plain = this.entityToPlain(enrichedEntity); - if (tableName === "groups" || tableName === "group") { - return await enrichGroupOwnership(plain, async (id: string) => { - const user = await AppDataSource.getRepository("User").findOne({ - where: { id }, - select: ["id", "ename"], - }); - return (user as { ename?: string } | null)?.ename ?? null; - }); - } - return plain; + return this.entityToPlain(enrichedEntity); } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/esigner/api/src/web3adapter/watchers/subscriber.ts b/platforms/esigner/api/src/web3adapter/watchers/subscriber.ts index 2be2e02bb..31301fa28 100644 --- a/platforms/esigner/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/esigner/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; +import { Web3Adapter } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -17,6 +17,17 @@ export const adapter = new Web3Adapter({ dbPath: path.resolve(process.env.ESIGNER_MAPPING_DB_PATH as string), registryUrl: process.env.PUBLIC_REGISTRY_URL as string, platform: process.env.PUBLIC_ESIGNER_BASE_URL as string, + // `owner` (and on some platforms `admins`) is a bare local user id, which + // the mapping cannot follow to an `ename`. Resolving it here means every + // producer path emits eNames, including junction-table and backfill paths + // that never touch the watcher's enrichEntity. + resolveEnameByUserId: async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }, }); @EventSubscriber() @@ -85,20 +96,7 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - // `owner` and `admins` are stored as bare local user ids with no - // relation to follow, but they name people, so they must go on the - // wire as eNames like every other entity reference. - const plain = this.entityToPlain(enrichedEntity); - if (tableName === "groups" || tableName === "group") { - return await enrichGroupOwnership(plain, async (id: string) => { - const user = await AppDataSource.getRepository("User").findOne({ - where: { id }, - select: ["id", "ename"], - }); - return (user as { ename?: string } | null)?.ename ?? null; - }); - } - return plain; + return this.entityToPlain(enrichedEntity); } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/evoting/api/src/web3adapter/watchers/subscriber.ts b/platforms/evoting/api/src/web3adapter/watchers/subscriber.ts index 861e5690c..d53c40616 100644 --- a/platforms/evoting/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/evoting/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; +import { Web3Adapter } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -17,6 +17,17 @@ export const adapter = new Web3Adapter({ dbPath: path.resolve(process.env.EVOTING_MAPPING_DB_PATH as string), registryUrl: process.env.PUBLIC_REGISTRY_URL as string, platform: process.env.PUBLIC_GROUP_CHARTER_BASE_URL as string, + // `owner` (and on some platforms `admins`) is a bare local user id, which + // the mapping cannot follow to an `ename`. Resolving it here means every + // producer path emits eNames, including junction-table and backfill paths + // that never touch the watcher's enrichEntity. + resolveEnameByUserId: async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }, }); // Map of junction tables to their parent entities @@ -116,20 +127,7 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - // `owner` and `admins` are stored as bare local user ids with no - // relation to follow, but they name people, so they must go on the - // wire as eNames like every other entity reference. - const plain = this.entityToPlain(enrichedEntity); - if (tableName === "groups" || tableName === "group") { - return await enrichGroupOwnership(plain, async (id: string) => { - const user = await AppDataSource.getRepository("User").findOne({ - where: { id }, - select: ["id", "ename"], - }); - return (user as { ename?: string } | null)?.ename ?? null; - }); - } - return plain; + return this.entityToPlain(enrichedEntity); } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/file-manager/api/src/web3adapter/watchers/subscriber.ts b/platforms/file-manager/api/src/web3adapter/watchers/subscriber.ts index c7b01d897..ffe106469 100644 --- a/platforms/file-manager/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/file-manager/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; +import { Web3Adapter } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -17,6 +17,17 @@ export const adapter = new Web3Adapter({ dbPath: path.resolve(process.env.FILE_MANAGER_MAPPING_DB_PATH as string), registryUrl: process.env.PUBLIC_REGISTRY_URL as string, platform: process.env.PUBLIC_FILE_MANAGER_BASE_URL as string, + // `owner` (and on some platforms `admins`) is a bare local user id, which + // the mapping cannot follow to an `ename`. Resolving it here means every + // producer path emits eNames, including junction-table and backfill paths + // that never touch the watcher's enrichEntity. + resolveEnameByUserId: async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }, }); @EventSubscriber() @@ -85,20 +96,7 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - // `owner` and `admins` are stored as bare local user ids with no - // relation to follow, but they name people, so they must go on the - // wire as eNames like every other entity reference. - const plain = this.entityToPlain(enrichedEntity); - if (tableName === "groups" || tableName === "group") { - return await enrichGroupOwnership(plain, async (id: string) => { - const user = await AppDataSource.getRepository("User").findOne({ - where: { id }, - select: ["id", "ename"], - }); - return (user as { ename?: string } | null)?.ename ?? null; - }); - } - return plain; + return this.entityToPlain(enrichedEntity); } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); diff --git a/platforms/group-charter-manager/api/src/web3adapter/watchers/subscriber.ts b/platforms/group-charter-manager/api/src/web3adapter/watchers/subscriber.ts index facc60385..aeaa4599c 100644 --- a/platforms/group-charter-manager/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/group-charter-manager/api/src/web3adapter/watchers/subscriber.ts @@ -6,7 +6,7 @@ import { RemoveEvent, ObjectLiteral, } from "typeorm"; -import { Web3Adapter, enrichGroupOwnership } from "web3-adapter"; +import { Web3Adapter } from "web3-adapter"; import path from "path"; import dotenv from "dotenv"; import { AppDataSource } from "../../database/data-source"; @@ -17,6 +17,17 @@ export const adapter = new Web3Adapter({ dbPath: path.resolve(process.env.GROUP_CHARTER_MAPPING_DB_PATH as string), registryUrl: process.env.PUBLIC_REGISTRY_URL as string, platform: process.env.PUBLIC_GROUP_CHARTER_BASE_URL as string, + // `owner` (and on some platforms `admins`) is a bare local user id, which + // the mapping cannot follow to an `ename`. Resolving it here means every + // producer path emits eNames, including junction-table and backfill paths + // that never touch the watcher's enrichEntity. + resolveEnameByUserId: async (id: string) => { + const user = await AppDataSource.getRepository("User").findOne({ + where: { id }, + select: ["id", "ename"], + }); + return (user as { ename?: string } | null)?.ename ?? null; + }, }); // Map of junction tables to their parent entities @@ -69,20 +80,7 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - // `owner` and `admins` are stored as bare local user ids with no - // relation to follow, but they name people, so they must go on the - // wire as eNames like every other entity reference. - const plain = this.entityToPlain(enrichedEntity); - if (tableName === "groups" || tableName === "group") { - return await enrichGroupOwnership(plain, async (id: string) => { - const user = await AppDataSource.getRepository("User").findOne({ - where: { id }, - select: ["id", "ename"], - }); - return (user as { ename?: string } | null)?.ename ?? null; - }); - } - return plain; + return this.entityToPlain(enrichedEntity); } catch (error) { console.error("Error loading relations:", error); return this.entityToPlain(entity); From d75258476f2e5334fc4395537cc2ac65ef4ad19f Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 09:50:45 +0530 Subject: [PATCH 08/13] Make the ontology describe what platforms actually emit The Chat/Group schema declared eleven properties with additionalProperties set to false, while the shipped mappings emit nine more: adminIds, memberIds, description, charter, isPrivate, visibility, signatureIds, originalMatchParticipants, and cerberus's eName casing. The Message schema was missing isSystemMessage and required senderId, which a system message by definition does not have. A published schema that disagrees with the mappings is how the original drift happened: the identifier convention lived only in two implementations agreeing, with nothing written down to contradict. Leaving the schema wrong in new ways while fixing it in one way would preserve exactly that failure mode. adminIds and memberIds are entity references and are declared as eNames. signatureIds and chatId are references to records, resolved through the id mapping, and are deliberately left without an eName pattern. The new test derives the expected field set from the mapping files themselves, so a mapping that emits something undeclared fails, as does an entity reference declared as a uuid. --- .../src/mapper/ontology-contract.test.ts | 152 ++++++++++++++++++ services/ontology/schemas/chat.json | 66 +++++++- services/ontology/schemas/message.json | 8 +- 3 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 infrastructure/web3-adapter/src/mapper/ontology-contract.test.ts diff --git a/infrastructure/web3-adapter/src/mapper/ontology-contract.test.ts b/infrastructure/web3-adapter/src/mapper/ontology-contract.test.ts new file mode 100644 index 000000000..b313b25be --- /dev/null +++ b/infrastructure/web3-adapter/src/mapper/ontology-contract.test.ts @@ -0,0 +1,152 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { IMapping } from "./mapper.types"; + +/** + * Keeps the published ontology honest about what platforms actually emit. + * + * The identifier drift this change fixes was possible because nothing written + * down said what a chat entity reference contained — the convention lived only + * in two implementations agreeing. A schema that quietly disagrees with the + * mappings is how that happens again, so the disagreement is a test failure. + */ + +const REPO = join(__dirname, "../../../.."); + +const CHAT_SCHEMA = "550e8400-e29b-41d4-a716-446655440003"; +const MESSAGE_SCHEMA = "550e8400-e29b-41d4-a716-446655440004"; + +const ENAME_PATTERN = "^@.+"; + +/** Global field names that name people, and so must be declared as eNames. */ +const ENTITY_TARGETS = new Set([ + "participantIds", + "admins", + "adminIds", + "memberIds", + "owner", + "senderId", + "readBy", +]); + +function schema(name: string): { + properties: Record< + string, + { type?: string; pattern?: string; items?: { pattern?: string } } + >; + required?: string[]; + additionalProperties?: boolean; +} { + return JSON.parse( + readFileSync(join(REPO, "services/ontology/schemas", name), "utf8"), + ); +} + +/** Every global field name any shipped mapping emits for a given schema. */ +function emittedTargets(schemaId: string): Map { + const out = new Map(); + + for (const file of [ + "chat.mapping.json", + "group.mapping.json", + "message.mapping.json", + ]) { + for (const dir of [ + "platforms/pictique/api", + "platforms/blabsy/api", + "platforms/ereputation/api", + "platforms/esigner/api", + "platforms/file-manager/api", + "platforms/ecurrency/api", + "platforms/dreamsync/api", + "platforms/evoting/api", + "platforms/group-charter-manager/api", + "platforms/cerberus/client", + ]) { + let mapping: IMapping; + try { + mapping = JSON.parse( + readFileSync( + join(REPO, dir, "src/web3adapter/mappings", file), + "utf8", + ), + ); + } catch { + continue; + } + if (mapping.schemaId !== schemaId) continue; + + for (const spec of Object.values(mapping.localToUniversalMap)) { + // `path,alias` targets the alias; a `__fn(x)` with no alias + // targets whatever the directive names. + const target = spec.includes(",") + ? spec.split(",")[1] + : spec.replace(/^__\w+\((.+)\)$/, "$1"); + + const platform = dir.split("/")[1]; + out.set(target, [...(out.get(target) ?? []), platform]); + } + } + } + return out; +} + +describe("ontology matches the shipped mappings", () => { + describe("Chat/Group schema", () => { + const chat = schema("chat.json"); + const emitted = emittedTargets(CHAT_SCHEMA); + + it("declares every field the platforms emit", () => { + // `additionalProperties: false` makes an undeclared field a + // contradiction rather than an omission. + expect(chat.additionalProperties).toBe(false); + + const undeclared = [...emitted.entries()] + .filter(([field]) => !(field in chat.properties)) + .map(([field, platforms]) => `${field} (${platforms.join(", ")})`); + + expect(undeclared).toEqual([]); + }); + + it("declares entity references as eNames, not uuids", () => { + for (const field of Object.keys(chat.properties)) { + if (!ENTITY_TARGETS.has(field)) continue; + const prop = chat.properties[field]; + + const pattern = + prop.type === "array" ? prop.items?.pattern : prop.pattern; + expect(pattern, `${field} should require an @-prefixed eName`).toBe( + ENAME_PATTERN, + ); + } + }); + }); + + describe("Message schema", () => { + const message = schema("message.json"); + const emitted = emittedTargets(MESSAGE_SCHEMA); + + it("declares every field the platforms emit", () => { + const undeclared = [...emitted.entries()] + .filter(([field]) => !(field in message.properties)) + .map(([field, platforms]) => `${field} (${platforms.join(", ")})`); + + expect(undeclared).toEqual([]); + }); + + it("declares senderId as an eName", () => { + expect(message.properties.senderId?.pattern).toBe(ENAME_PATTERN); + }); + + it("does not require senderId, since a system message has no sender", () => { + expect(message.required ?? []).not.toContain("senderId"); + }); + + it("leaves chatId a record reference rather than an eName", () => { + // chatId resolves through the producing platform's id mapping. Giving + // it an eName pattern would be wrong in the opposite direction. + expect(message.properties.chatId?.pattern).toBeUndefined(); + }); + }); +}); diff --git a/services/ontology/schemas/chat.json b/services/ontology/schemas/chat.json index c4ecd8397..4b09dbd5e 100644 --- a/services/ontology/schemas/chat.json +++ b/services/ontology/schemas/chat.json @@ -65,6 +65,69 @@ "ename": { "type": "string", "description": "The chat's own eName, when the chat is backed by a group eVault." + }, + "adminIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^@.+" + }, + "description": "Array of eNames of the chat or group's administrators. Alias of `admins` used by some platforms." + }, + "memberIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^@.+" + }, + "description": "Array of eNames of the group's members. Members, admins and the owner are all participants." + }, + "description": { + "type": "string", + "description": "Free-text description of the group." + }, + "charter": { + "type": "string", + "description": "Markdown charter for the group." + }, + "isPrivate": { + "type": "boolean", + "description": "Whether the group is private." + }, + "visibility": { + "type": "string", + "enum": [ + "public", + "private", + "restricted" + ], + "description": "Who can see the group." + }, + "signatureIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Ids of charter signature records. A record reference, not an entity reference." + }, + "originalMatchParticipants": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Participants of the match this group originated from." + }, + "avatarUrl": { + "type": "string", + "description": "Group avatar. A w3ds://file URI at rest." + }, + "bannerUrl": { + "type": "string", + "description": "Group banner. A w3ds://file URI at rest." + }, + "eName": { + "type": "string", + "description": "Deprecated casing of `ename`, still emitted by cerberus." } }, "required": [ @@ -73,5 +136,6 @@ "participantIds", "createdAt" ], - "additionalProperties": false + "additionalProperties": false, + "description": "Chat and Group share this schema. Entity references \u2014 participantIds, admins/adminIds, memberIds and owner \u2014 are eNames: @-prefixed W3IDs such as @48468c9a-dc1b-5663-92fb-5e46e3d2a7f0. They are not User profile MetaEnvelope ids, which are unresolvable on their own and do not exist for an eVault with no profile envelope yet. Consumers skip references they cannot resolve rather than dropping the room." } diff --git a/services/ontology/schemas/message.json b/services/ontology/schemas/message.json index 14e79b69d..edca1753e 100644 --- a/services/ontology/schemas/message.json +++ b/services/ontology/schemas/message.json @@ -60,15 +60,19 @@ "isArchived": { "type": "boolean", "description": "Whether the message is archived" + }, + "isSystemMessage": { + "type": "boolean", + "description": "Whether this is a system message. A system message has no sender, so senderId is absent." } }, "required": [ "id", "chatId", - "senderId", "content", "type", "createdAt" ], - "additionalProperties": false + "additionalProperties": false, + "description": "Entity references \u2014 senderId and readBy \u2014 are eNames: @-prefixed W3IDs such as @48468c9a-dc1b-5663-92fb-5e46e3d2a7f0, not User profile MetaEnvelope ids. senderId is absent on a system message. chatId is a record reference, resolved through the producing platform's id mapping, and is not an eName." } From 5acf8ac7dc25d0d66c1998c94a0e2c64dbf8dc1d Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 10:07:34 +0530 Subject: [PATCH 09/13] Add acceptance tests that drive real webhooks into real databases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing tests exercised the mapper and the resolvers directly. That cannot establish the thing this change is about: the bug was a producer and a consumer disagreeing about a wire format, and a disagreement like that only shows up when a whole envelope crosses the boundary and either lands in the database or does not. Pictique: an HTTP POST to /api/webhook, through the real controller, adapter and mapping files, into a real Postgres via the real TypeORM entities. Blabsy: an envelope through the real controller into a real Firestore emulator, with the documents read back out. Both suites cover the task's stated criteria against those real paths — an eName-only room ingests, a malformed entry does not lose the room, a message is attributed to the eName that sent it, display names still resolve — and both were confirmed non-vacuous by restoring the pre-fix parsing, which fails five of six cases in each. Running them surfaced something the unit tests could not: with the pre-fix code Pictique's webhook did not fail, it hung for the full 60s timeout. The catch-all logged the TypeError and never sent a response, so a crash in this handler was indistinguishable from a slow peer. It now answers 500. That is why an eVault could drop chats "with no error" — there was no error to see, only silence. Pictique needs unplugin-swc because esbuild, vitest's default transform, does not emit the decorator metadata TypeORM entities require. Blabsy starts the Firestore emulator from globalSetup, which needs a JRE and says so plainly when one is missing. --- platforms/blabsy/api/firebase.emulator.json | 12 + .../blabsy/api/firestore-emulator.setup.ts | 79 ++ platforms/blabsy/api/package.json | 1 + .../webhook-chat.acceptance.test.ts | 210 +++++ platforms/blabsy/api/vitest.config.ts | 12 + platforms/pictique/api/package.json | 5 + .../api/src/controllers/WebhookController.ts | 6 +- .../webhook-chat.acceptance.test.ts | 293 +++++++ platforms/pictique/api/vitest.config.ts | 18 + pnpm-lock.yaml | 775 ++++++++++++++---- 10 files changed, 1254 insertions(+), 157 deletions(-) create mode 100644 platforms/blabsy/api/firebase.emulator.json create mode 100644 platforms/blabsy/api/firestore-emulator.setup.ts create mode 100644 platforms/blabsy/api/src/controllers/webhook-chat.acceptance.test.ts create mode 100644 platforms/blabsy/api/vitest.config.ts create mode 100644 platforms/pictique/api/src/controllers/webhook-chat.acceptance.test.ts create mode 100644 platforms/pictique/api/vitest.config.ts diff --git a/platforms/blabsy/api/firebase.emulator.json b/platforms/blabsy/api/firebase.emulator.json new file mode 100644 index 000000000..e4af1bf91 --- /dev/null +++ b/platforms/blabsy/api/firebase.emulator.json @@ -0,0 +1,12 @@ +{ + "emulators": { + "firestore": { + "port": 8710, + "host": "127.0.0.1" + }, + "ui": { + "enabled": false + }, + "singleProjectMode": true + } +} diff --git a/platforms/blabsy/api/firestore-emulator.setup.ts b/platforms/blabsy/api/firestore-emulator.setup.ts new file mode 100644 index 000000000..f28088591 --- /dev/null +++ b/platforms/blabsy/api/firestore-emulator.setup.ts @@ -0,0 +1,79 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import net from "node:net"; + +/** + * Starts a Firestore emulator for the acceptance suite and stops it after. + * + * The emulator, rather than a stubbed Firestore, is the point: the reference + * handling under test ends in a document write, and whether a room survives + * ingest is only answerable by reading the document back. A fake would answer + * from whatever the fake was told. + * + * Requires a JRE, which the emulator itself needs. When one is missing the + * suite skips rather than failing, and says so, so a machine without Java does + * not look like a broken change. + */ + +let emulator: ChildProcess | undefined; + +const PORT = Number(process.env.FIRESTORE_TEST_PORT ?? 8710); +const PROJECT = "blabsy-test"; + +function waitForPort(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + + return new Promise((resolve) => { + const attempt = () => { + const socket = net.connect({ port, host: "127.0.0.1" }, () => { + socket.end(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + if (Date.now() > deadline) resolve(false); + else setTimeout(attempt, 500); + }); + }; + attempt(); + }); +} + +export async function setup(): Promise { + emulator = spawn( + "npx", + [ + "-y", + "firebase-tools@13", + "emulators:start", + "--only", + "firestore", + "--project", + PROJECT, + "--config", + `${__dirname}/firebase.emulator.json`, + ], + { stdio: "ignore", detached: true }, + ); + + const ready = await waitForPort(PORT, 180_000); + if (!ready) { + await teardown(); + throw new Error( + `Firestore emulator did not start on port ${PORT}. It needs a JRE on PATH.`, + ); + } + + process.env.FIRESTORE_EMULATOR_HOST = `127.0.0.1:${PORT}`; + process.env.GOOGLE_CLOUD_PROJECT = PROJECT; +} + +export async function teardown(): Promise { + if (!emulator?.pid) return; + try { + // The emulator spawns a Java child, so the whole group goes. + process.kill(-emulator.pid, "SIGTERM"); + } catch { + // Already gone. + } + emulator = undefined; +} diff --git a/platforms/blabsy/api/package.json b/platforms/blabsy/api/package.json index 57fb7e667..f08eed58b 100644 --- a/platforms/blabsy/api/package.json +++ b/platforms/blabsy/api/package.json @@ -43,6 +43,7 @@ "@typescript-eslint/parser": "^7.0.1", "eslint": "^8.56.0", "nodemon": "^3.0.3", + "testcontainers": "^10.28.0", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "typescript": "^5.3.3", diff --git a/platforms/blabsy/api/src/controllers/webhook-chat.acceptance.test.ts b/platforms/blabsy/api/src/controllers/webhook-chat.acceptance.test.ts new file mode 100644 index 000000000..9b9d4c3d8 --- /dev/null +++ b/platforms/blabsy/api/src/controllers/webhook-chat.acceptance.test.ts @@ -0,0 +1,210 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * Acceptance test for inbound chat replication into Blabsy. + * + * Drives a MetaEnvelope through the real `WebhookController` into a real + * Firestore (the emulator), then reads the documents back out. The reference + * handling, the mapping files and the Firestore writes are all the production + * ones. + * + * `mapChatData` is the site the task named as having no guard at all, and the + * failure it produced — a `TypeError` on a bare eName that lost the whole room + * — is only observable end to end: either the chat document exists with its + * participants, or it does not. + */ + +const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; +const BOB = "@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60"; +const CAROL = "@0c0ffee0-dead-4bee-8fee-000000000000"; + +const CHAT_SCHEMA = "550e8400-e29b-41d4-a716-446655440003"; +const MESSAGE_SCHEMA = "550e8400-e29b-41d4-a716-446655440004"; + +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let db: any; +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let controller: any; + +/** Minimal express req/res doubles: the controller only uses body and status. */ +function invoke(body: Record): Promise<{ status: number }> { + return new Promise((resolve) => { + let status = 0; + const res = { + status(code: number) { + status = code; + return this; + }, + json() { + resolve({ status }); + return this; + }, + send() { + resolve({ status }); + return this; + }, + }; + controller.handleWebhook({ body }, res).catch(() => resolve({ status })); + }); +} + +beforeAll(async () => { + // `globalSetup` has the emulator up and has set FIRESTORE_EMULATOR_HOST. + process.env.BLABSY_MAPPING_DB_PATH = `/tmp/blabsy-accept-${Date.now()}`; + process.env.PUBLIC_REGISTRY_URL = "http://registry.invalid"; + process.env.PUBLIC_BLABSY_BASE_URL = "http://blabsy.invalid"; + + const admin = await import("firebase-admin/app"); + const firestore = await import("firebase-admin/firestore"); + if (admin.getApps().length === 0) { + admin.initializeApp({ projectId: "blabsy-test" }); + } + db = firestore.getFirestore(); + + const mod = await import("./WebhookController"); + await mod.adapter.readPaths(); + // Outbound sync is not what this test is about; keep it off the network. + mod.adapter.evaultClient = { + storeMetaEnvelope: async () => "global-out", + storeReference: async () => undefined, + updateMetaEnvelopeById: async () => undefined, + } as never; + + controller = new mod.WebhookController(); + + // Blabsy keys user documents by eName, so these ids are the eNames. + await db.collection("users").doc(ALICE).set({ id: ALICE, name: "Alice", username: "alice" }); + await db.collection("users").doc(BOB).set({ id: BOB, name: "Bob", username: "bob" }); +}, 120_000); + + +async function chatNamed(name: string) { + const snap = await db.collection("chats").where("name", "==", name).get(); + return snap.empty ? null : snap.docs[0].data(); +} + +describe("blabsy inbound chat replication (controller -> Firestore emulator)", () => { + it("ingests a chat whose participants are all eNames", async () => { + await invoke({ + id: `chat-enames-${Date.now()}`, + schemaId: CHAT_SCHEMA, + data: { + ename: "@group-1", + name: "All eNames", + participantIds: [ALICE, BOB], + admins: [ALICE], + }, + }); + + const chat = await chatNamed("All eNames"); + expect(chat, "the room should exist").toBeTruthy(); + expect(chat.participants.sort()).toEqual([ALICE, BOB].sort()); + expect(chat.admins).toEqual([ALICE]); + expect(chat.type).toBe("direct"); + }); + + it("ingests a chat containing malformed participant entries", async () => { + // These are the values that threw in the unguarded + // `p.split("(")[1].split(")")[0]` and lost the whole room. + await invoke({ + id: `chat-malformed-${Date.now()}`, + schemaId: CHAT_SCHEMA, + data: { + ename: "@group-2", + name: "Malformed entries", + participantIds: [ALICE, null, 42, "", { nested: true }, [], BOB], + admins: null, + }, + }); + + const chat = await chatNamed("Malformed entries"); + expect(chat, "the room should still ingest").toBeTruthy(); + expect(chat.participants.sort()).toEqual([ALICE, BOB].sort()); + expect(chat.admins).toEqual([]); + }); + + it("keeps a participant this instance has no profile for", async () => { + // Blabsy stores the eName itself, so an unknown member is retained as a + // member; only their display name is unavailable. + await invoke({ + id: `chat-stranger-${Date.now()}`, + schemaId: CHAT_SCHEMA, + data: { + ename: "@group-3", + name: "With a stranger", + participantIds: [ALICE, CAROL, BOB], + admins: [], + }, + }); + + const chat = await chatNamed("With a stranger"); + expect(chat).toBeTruthy(); + expect(chat.participants.sort()).toEqual([ALICE, BOB, CAROL].sort()); + expect(chat.type).toBe("group"); + }); + + it("attributes a message to the eName in senderId", async () => { + const chatGlobalId = `chat-msg-${Date.now()}`; + await invoke({ + id: chatGlobalId, + schemaId: CHAT_SCHEMA, + data: { + ename: "@group-4", + name: "Message attribution", + participantIds: [ALICE, BOB], + admins: [], + }, + }); + + const { adapter } = await import("./WebhookController"); + const localChatId = await adapter.mappingDb.getLocalId(chatGlobalId); + expect(localChatId, "the chat should have been mapped").toBeTruthy(); + + await invoke({ + id: `message-${Date.now()}`, + schemaId: MESSAGE_SCHEMA, + data: { + chatId: chatGlobalId, + senderId: BOB, + content: "hello from an eName", + }, + }); + + const messages = await db + .collection(`chats/${localChatId}/messages`) + .where("text", "==", "hello from an eName") + .get(); + + expect(messages.empty, "the message should exist").toBe(false); + const message = messages.docs[0].data(); + expect(message.senderId).toBe(BOB); + expect(message.isSystemMessage).toBe(false); + }); + + it("resolves display names for the eName participants it knows", async () => { + const chat = await chatNamed("All eNames"); + const names = await Promise.all( + chat.participants.map(async (ename: string) => { + const doc = await db.collection("users").doc(ename).get(); + return doc.exists ? doc.data().name : null; + }), + ); + expect(names.filter(Boolean).sort()).toEqual(["Alice", "Bob"]); + }); + + it("drops a legacy envelope-id participant rather than resolving it", async () => { + await invoke({ + id: `chat-legacy-${Date.now()}`, + schemaId: CHAT_SCHEMA, + data: { + ename: "@group-5", + name: "Legacy refs", + participantIds: ["user(3f8c1e2d-0000-4444-8888-aaaabbbbcccc)"], + admins: [], + }, + }); + + const chat = await chatNamed("Legacy refs"); + if (chat) expect(chat.participants).toEqual([]); + }); +}); diff --git a/platforms/blabsy/api/vitest.config.ts b/platforms/blabsy/api/vitest.config.ts new file mode 100644 index 000000000..064d9c3d7 --- /dev/null +++ b/platforms/blabsy/api/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // The acceptance suite needs a Firestore emulator, which takes a while + // to come up and must outlive individual test files. + globalSetup: ["./firestore-emulator.setup.ts"], + testTimeout: 60_000, + hookTimeout: 240_000, + fileParallelism: false, + }, +}); diff --git a/platforms/pictique/api/package.json b/platforms/pictique/api/package.json index e7f3002c5..d07318578 100644 --- a/platforms/pictique/api/package.json +++ b/platforms/pictique/api/package.json @@ -30,18 +30,23 @@ "web3-adapter": "workspace:*" }, "devDependencies": { + "@swc/core": "^1.10.1", + "@testcontainers/postgresql": "^10.28.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.5", "@types/node": "^20.11.24", "@types/pg": "^8.11.2", + "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "eslint": "^8.56.0", "nodemon": "^3.0.3", + "supertest": "^7.0.0", "ts-node": "^10.9.2", "typescript": "^5.3.3", + "unplugin-swc": "^1.5.1", "vitest": "^3.1.2" } } diff --git a/platforms/pictique/api/src/controllers/WebhookController.ts b/platforms/pictique/api/src/controllers/WebhookController.ts index f34c07354..ece3aaa0e 100644 --- a/platforms/pictique/api/src/controllers/WebhookController.ts +++ b/platforms/pictique/api/src/controllers/WebhookController.ts @@ -455,7 +455,11 @@ export class WebhookController { } res.status(200).send(); } catch (e) { - console.error(e); + // Without a response here the request simply hangs: the sender waits + // out its own timeout and learns nothing, which is how a crash in + // this handler stayed invisible. Answer, and say it failed. + console.error("[webhook] failed to process envelope", e); + if (!res.headersSent) res.status(500).send(); } }; } diff --git a/platforms/pictique/api/src/controllers/webhook-chat.acceptance.test.ts b/platforms/pictique/api/src/controllers/webhook-chat.acceptance.test.ts new file mode 100644 index 000000000..21fb0ae15 --- /dev/null +++ b/platforms/pictique/api/src/controllers/webhook-chat.acceptance.test.ts @@ -0,0 +1,293 @@ +import type { StartedPostgreSqlContainer } from "@testcontainers/postgresql"; +import { PostgreSqlContainer } from "@testcontainers/postgresql"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +/** + * Acceptance test for inbound chat replication. + * + * This drives the path a chat actually takes into Pictique: an HTTP POST of a + * MetaEnvelope to `/api/webhook`, through the real `WebhookController`, the + * real `Web3Adapter` and mapping files, into a real Postgres database via the + * real TypeORM entities. Nothing about the reference handling is stubbed. + * + * The unit tests around the mapper are useful but cannot establish this: the + * bug being fixed was a producer and a consumer disagreeing about a wire + * format, and that disagreement only shows up when a whole envelope crosses the + * boundary and either lands in the database or does not. + */ + +const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; +const BOB = "@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60"; +/** Well-formed, but on a platform this instance knows nothing about. */ +const STRANGER = "@0c0ffee0-dead-4bee-8fee-000000000000"; + +const CHAT_SCHEMA = "550e8400-e29b-41d4-a716-446655440003"; +const MESSAGE_SCHEMA = "550e8400-e29b-41d4-a716-446655440004"; + +let container: StartedPostgreSqlContainer; +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let AppDataSource: any; +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let app: any; +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let User: any; +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let Chat: any; +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let Message: any; + +beforeAll(async () => { + container = await new PostgreSqlContainer("postgres:15-alpine") + .withDatabase("pictique_test") + .withUsername("test") + .withPassword("test") + .start(); + + // The data source reads this at module load, so it is set first. + process.env.PICTIQUE_DATABASE_URL = container.getConnectionUri(); + process.env.PICTIQUE_MAPPING_DB_PATH = `/tmp/pictique-accept-${Date.now()}`; + process.env.PUBLIC_REGISTRY_URL = "http://registry.invalid"; + process.env.PUBLIC_PICTIQUE_BASE_URL = "http://pictique.invalid"; + // The real service module refuses to load without one. + process.env.PICTIQUE_JWT_SECRET ??= "test-secret"; + + const ds = await import("../database/data-source"); + AppDataSource = ds.AppDataSource; + ({ User } = await import("../database/entities/User")); + ({ Chat } = await import("../database/entities/Chat")); + ({ Message } = await import("../database/entities/Message")); + + // Production config keeps `synchronize` off and loads migrations by glob; + // the test builds the schema from the entities instead. The subscriber is + // dropped too, since this test is about ingest, not outbound sync. + AppDataSource.setOptions({ + synchronize: true, + subscribers: [], + migrations: [], + }); + await AppDataSource.initialize(); + await AppDataSource.synchronize(); + + const { WebhookController } = await import("./WebhookController"); + const { adapter } = await import("../web3adapter/watchers/subscriber"); + await adapter.readPaths(); + + // Outbound sync is not what this test is about; keep it off the network. + adapter.evaultClient = { + storeMetaEnvelope: async () => "global-out", + storeReference: async () => undefined, + updateMetaEnvelopeById: async () => undefined, + fetchMetaEnvelope: async (id: string) => ({ + id, + schemaId: CHAT_SCHEMA, + w3id: ALICE, + data: {}, + }), + // Only the methods this path touches; the rest of EVaultClient is + // network machinery the ingest path never reaches. + } as unknown as typeof adapter.evaultClient; + + const controller = new WebhookController(adapter); + app = express(); + app.use(express.json()); + app.post("/api/webhook", controller.handleWebhook); + + // Two of the three people below are known here. The third is not, which is + // the realistic case: chats span platforms. + const users = AppDataSource.getRepository(User); + await users.save(users.create({ ename: ALICE, name: "Alice", handle: "alice" })); + await users.save(users.create({ ename: BOB, name: "Bob", handle: "bob" })); +}, 120_000); + +afterAll(async () => { + if (AppDataSource?.isInitialized) await AppDataSource.destroy(); + await container?.stop(); +}, 60_000); + +/** Posts a MetaEnvelope the way the eVault webhook does. */ +async function postEnvelope(body: Record) { + return request(app).post("/api/webhook").send(body).expect(200); +} + +describe("inbound chat replication (real HTTP -> controller -> Postgres)", () => { + it("ingests a chat whose participants are all eNames", async () => { + const globalId = `chat-all-enames-${Date.now()}`; + + await postEnvelope({ + id: globalId, + schemaId: CHAT_SCHEMA, + w3id: ALICE, + data: { + ename: "@group-1", + name: "All eNames", + participantIds: [ALICE, BOB], + admins: [ALICE], + }, + }); + + const chats = AppDataSource.getRepository(Chat); + const stored = await chats.findOne({ + where: { name: "All eNames" }, + relations: ["participants", "admins"], + }); + + expect(stored, "the room should exist").toBeTruthy(); + expect(stored.participants.map((p: { ename: string }) => p.ename).sort()).toEqual( + [ALICE, BOB].sort(), + ); + expect(stored.admins.map((a: { ename: string }) => a.ename)).toEqual([ALICE]); + }); + + it("keeps the room when one participant is unresolvable", async () => { + // The local user must still end up in the room. Dropping the whole room + // over a member who lives elsewhere is the bug being fixed. + const globalId = `chat-stranger-${Date.now()}`; + + await postEnvelope({ + id: globalId, + schemaId: CHAT_SCHEMA, + w3id: ALICE, + data: { + ename: "@group-2", + name: "With a stranger", + participantIds: [ALICE, STRANGER, BOB], + admins: [], + }, + }); + + const stored = await AppDataSource.getRepository(Chat).findOne({ + where: { name: "With a stranger" }, + relations: ["participants"], + }); + + expect(stored, "the room should survive an unresolvable member").toBeTruthy(); + expect(stored.participants.map((p: { ename: string }) => p.ename).sort()).toEqual( + [ALICE, BOB].sort(), + ); + }); + + it("ingests a chat containing malformed participant entries", async () => { + // null, a number, "", a nested object, an array. Each of these threw a + // TypeError in the old parsing and took the whole envelope down. + const globalId = `chat-malformed-${Date.now()}`; + + await postEnvelope({ + id: globalId, + schemaId: CHAT_SCHEMA, + w3id: ALICE, + data: { + ename: "@group-3", + name: "Malformed entries", + participantIds: [ALICE, null, 42, "", { nested: true }, [], BOB], + admins: [], + }, + }); + + const stored = await AppDataSource.getRepository(Chat).findOne({ + where: { name: "Malformed entries" }, + relations: ["participants"], + }); + + expect(stored, "the room should still ingest").toBeTruthy(); + expect(stored.participants.map((p: { ename: string }) => p.ename).sort()).toEqual( + [ALICE, BOB].sort(), + ); + }); + + it("attributes a message to the user named by its senderId eName", async () => { + const chatGlobalId = `chat-for-message-${Date.now()}`; + + await postEnvelope({ + id: chatGlobalId, + schemaId: CHAT_SCHEMA, + w3id: ALICE, + data: { + ename: "@group-4", + name: "Message attribution", + participantIds: [ALICE, BOB], + admins: [], + }, + }); + + await postEnvelope({ + id: `message-${Date.now()}`, + schemaId: MESSAGE_SCHEMA, + w3id: BOB, + data: { + chatId: chatGlobalId, + senderId: BOB, + content: "hello from an eName", + }, + }); + + const stored = await AppDataSource.getRepository(Message).findOne({ + where: { text: "hello from an eName" }, + relations: ["sender", "chat"], + }); + + expect(stored, "the message should exist").toBeTruthy(); + expect(stored.sender?.ename).toBe(BOB); + expect(stored.chat).toBeTruthy(); + }); + + it("resolves display names and avatars for eName-only participants", async () => { + // An eName carries identity but no profile data. The room still has to + // render a name for each member it knows. + const stored = await AppDataSource.getRepository(Chat).findOne({ + where: { name: "All eNames" }, + relations: ["participants"], + }); + + const names = stored.participants + .map((p: { name: string }) => p.name) + .sort(); + expect(names).toEqual(["Alice", "Bob"]); + }); + + it("rejects a legacy envelope-id participant rather than resolving it", async () => { + // Envelope-id support is removed. A room named only that way ends up + // with no participants, rather than silently resolving. + const alice = await AppDataSource.getRepository(User).findOneBy({ + ename: ALICE, + }); + + await postEnvelope({ + id: `chat-legacy-${Date.now()}`, + schemaId: CHAT_SCHEMA, + w3id: ALICE, + data: { + ename: "@group-5", + name: "Legacy refs", + participantIds: [`users(${alice.id})`, alice.id], + admins: [], + }, + }); + + const stored = await AppDataSource.getRepository(Chat).findOne({ + where: { name: "Legacy refs" }, + relations: ["participants"], + }); + + if (stored) expect(stored.participants).toEqual([]); + }); + + it("always answers the sender, even on an envelope it cannot handle", async () => { + // A handler that throws without responding leaves the eVault waiting out + // its own timeout and learning nothing. That is how the original crash + // stayed invisible: it looked like a slow peer, not a failure. + const response = await request(app) + .post("/api/webhook") + .send({ + id: `chat-garbage-${Date.now()}`, + schemaId: CHAT_SCHEMA, + w3id: ALICE, + data: null, + }) + .timeout(10_000); + + expect(response.status).toBeGreaterThanOrEqual(200); + expect(response.status).toBeLessThan(600); + }); +}); diff --git a/platforms/pictique/api/vitest.config.ts b/platforms/pictique/api/vitest.config.ts new file mode 100644 index 000000000..b31ffaed5 --- /dev/null +++ b/platforms/pictique/api/vitest.config.ts @@ -0,0 +1,18 @@ +import swc from "unplugin-swc"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // TypeORM entities rely on `emitDecoratorMetadata`, which esbuild — the + // default vitest transform — does not emit. Without it every relation column + // fails with ColumnTypeUndefinedError, so the acceptance tests cannot load + // the real entities. swc emits the metadata. + plugins: [swc.vite({ module: { type: "es6" } })], + test: { + // A container start plus schema sync is well past the default timeout. + testTimeout: 60_000, + hookTimeout: 180_000, + // The acceptance suite shares one Postgres container and one module-level + // data source, so its files must not run in parallel. + fileParallelism: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1f9a2ed2..89951487f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,13 +111,13 @@ importers: version: 20.19.26 jest: specifier: ^29.0.0 - version: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) ts-jest: specifier: ^29.0.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)))(typescript@5.8.2) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)))(typescript@5.8.2) ts-node: specifier: ^10.9.0 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.0.0 version: 5.8.2 @@ -226,7 +226,7 @@ importers: version: 9.1.20(eslint@9.39.4(jiti@2.6.1))(storybook@9.1.20(@testing-library/dom@10.4.1)(bufferutil@4.1.0)(prettier@3.8.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.8.2) eslint-plugin-svelte: specifier: ^3.0.0 - version: 3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.8.2)) + version: 3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.8.2)) globals: specifier: ^16.0.0 version: 16.5.0 @@ -247,7 +247,7 @@ importers: version: 5.53.11 svelte-check: specifier: ^4.0.0 - version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.8.2) + version: 4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.8.2) tailwindcss: specifier: ^4.0.0 version: 4.2.1 @@ -405,7 +405,7 @@ importers: version: 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11) '@storybook/sveltekit': specifier: ^8.6.7 - version: 8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/test': specifier: ^8.6.7 version: 8.6.15(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1)) @@ -462,7 +462,7 @@ importers: version: 5.53.11 svelte-check: specifier: ^4.0.0 - version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.6.3) + version: 4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.6.3) svelte-gestures: specifier: ^5.1.3 version: 5.2.2 @@ -564,7 +564,7 @@ importers: version: 1.0.3 typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^13.0.0 version: 13.0.0 @@ -598,7 +598,7 @@ importers: version: 3.1.14 ts-node-dev: specifier: ^2.0.0 - version: 2.0.0(@types/node@20.19.26)(typescript@5.8.2) + version: 2.0.0(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) tsx: specifier: ^4.7.1 version: 4.21.0 @@ -900,7 +900,7 @@ importers: version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) + version: 3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.9.3)) globals: specifier: ^16.4.0 version: 16.5.0 @@ -997,7 +997,7 @@ importers: version: link:../../../infrastructure/signature-validator typeorm: specifier: ^0.3.20 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -1035,12 +1035,15 @@ importers: nodemon: specifier: ^3.0.3 version: 3.1.14 + testcontainers: + specifier: ^10.28.0 + version: 10.28.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) ts-node-dev: specifier: ^2.0.0 - version: 2.0.0(@types/node@20.19.26)(typescript@5.8.2) + version: 2.0.0(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -1152,7 +1155,7 @@ importers: version: 8.0.3 jest: specifier: ^28.1.3 - version: 28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)) + version: 28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4)) jest-environment-jsdom: specifier: ^28.1.3 version: 28.1.3(bufferutil@4.1.0) @@ -1222,7 +1225,7 @@ importers: version: 9.0.8 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -1454,7 +1457,7 @@ importers: version: 0.2.2 typeorm: specifier: ^0.3.20 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -1494,19 +1497,19 @@ importers: version: 8.57.1 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) nodemon: specifier: ^3.0.3 version: 3.1.14 ts-jest: specifier: ^29.1.2 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)))(typescript@5.8.2) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)))(typescript@5.8.2) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) ts-node-dev: specifier: ^2.0.0 - version: 2.0.0(@types/node@20.19.26)(typescript@5.8.2) + version: 2.0.0(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -1554,7 +1557,7 @@ importers: version: link:../../../infrastructure/signature-validator typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -1597,7 +1600,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -1887,7 +1890,7 @@ importers: version: link:../../../infrastructure/signature-validator typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -1927,7 +1930,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -2060,7 +2063,7 @@ importers: version: 0.2.2 typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -2088,7 +2091,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -2204,7 +2207,7 @@ importers: version: 5.53.11 svelte-check: specifier: ^4.0.0 - version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.8.2) + version: 4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.8.2) tailwindcss: specifier: ^4.0.0 version: 4.2.1 @@ -2246,7 +2249,7 @@ importers: version: link:../../../infrastructure/signature-validator typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -2286,7 +2289,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -2545,7 +2548,7 @@ importers: version: link:../../../infrastructure/signature-validator typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -2588,7 +2591,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -2640,7 +2643,7 @@ importers: version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.0.0 - version: 3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)) + version: 3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)) globals: specifier: ^16.0.0 version: 16.5.0 @@ -2715,7 +2718,7 @@ importers: version: link:../../../infrastructure/signature-validator typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -2758,7 +2761,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -2984,7 +2987,7 @@ importers: version: link:../../../infrastructure/signature-validator typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -3030,7 +3033,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -3082,7 +3085,7 @@ importers: version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.0.0 - version: 3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)) + version: 3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)) globals: specifier: ^16.0.0 version: 16.5.0 @@ -3100,7 +3103,7 @@ importers: version: 5.53.11 svelte-check: specifier: ^4.0.0 - version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.8.2) + version: 4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.8.2) tailwindcss: specifier: ^4.0.0 version: 4.2.1 @@ -3145,7 +3148,7 @@ importers: version: link:../../../infrastructure/signature-validator typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -3185,7 +3188,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -3275,7 +3278,7 @@ importers: version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) draft-js: specifier: ^0.11.7 - version: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@18.3.1) @@ -3296,7 +3299,7 @@ importers: version: 18.3.1(react@18.3.1) react-draft-wysiwyg: specifier: ^1.15.0 - version: 1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-hook-form: specifier: ^7.55.0 version: 7.71.2(react@18.3.1) @@ -3619,7 +3622,7 @@ importers: version: link:../../../infrastructure/signature-validator typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -3627,6 +3630,12 @@ importers: specifier: workspace:* version: link:../../../infrastructure/web3-adapter devDependencies: + '@swc/core': + specifier: ^1.10.1 + version: 1.16.2 + '@testcontainers/postgresql': + specifier: ^10.28.0 + version: 10.28.0 '@types/cors': specifier: ^2.8.17 version: 2.8.19 @@ -3642,6 +3651,9 @@ importers: '@types/pg': specifier: ^8.11.2 version: 8.18.0 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 '@types/uuid': specifier: ^9.0.8 version: 9.0.8 @@ -3657,12 +3669,18 @@ importers: nodemon: specifier: ^3.0.3 version: 3.1.14 + supertest: + specifier: ^7.0.0 + version: 7.2.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 + unplugin-swc: + specifier: ^1.5.1 + version: 1.6.0(@swc/core@1.16.2)(esbuild@0.27.4)(rollup@4.59.0)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)) vitest: specifier: ^3.1.2 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -3723,7 +3741,7 @@ importers: version: 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11) '@storybook/sveltekit': specifier: ^8.6.12 - version: 8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/test': specifier: ^8.6.12 version: 8.6.15(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1)) @@ -3753,7 +3771,7 @@ importers: version: 10.1.8(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.0.0 - version: 3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)) + version: 3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)) globals: specifier: ^16.0.0 version: 16.5.0 @@ -3774,7 +3792,7 @@ importers: version: 5.53.11 svelte-check: specifier: ^4.0.0 - version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.8.2) + version: 4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.8.2) svelte-gestures: specifier: ^5.1.3 version: 5.2.2 @@ -3844,7 +3862,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.9.3) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.9.3) typescript: specifier: ^5.3.3 version: 5.9.3 @@ -3899,7 +3917,7 @@ importers: version: 5.53.11 svelte-check: specifier: ^4.0.0 - version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3) + version: 4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.9.3) tailwindcss: specifier: ^4.0.0 version: 4.2.1 @@ -3941,7 +3959,7 @@ importers: version: 0.2.2 typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) devDependencies: '@testcontainers/postgresql': specifier: ^10.0.0-beta.6 @@ -3957,13 +3975,13 @@ importers: version: 8.18.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + version: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) ts-jest: specifier: ^29.1.2 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)))(typescript@5.8.2) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)))(typescript@5.8.2) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 @@ -4004,7 +4022,7 @@ importers: version: link:../../../infrastructure/signature-validator typeorm: specifier: ^0.3.24 - version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.9.3)) + version: 0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.9.3)) uuid: specifier: ^9.0.1 version: 9.0.1 @@ -4032,7 +4050,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.26)(typescript@5.9.3) + version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.9.3) typescript: specifier: ^5.3.3 version: 5.9.3 @@ -4060,7 +4078,7 @@ importers: version: 5.53.11 svelte-check: specifier: ^4.0.0 - version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3) + version: 4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.9.3) tailwindcss: specifier: ^4.0.0 version: 4.2.1 @@ -4128,7 +4146,7 @@ importers: version: 5.53.11 svelte-check: specifier: ^4.0.0 - version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3) + version: 4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.9.3) tailwindcss: specifier: ^4.0.0 version: 4.2.1 @@ -4183,7 +4201,7 @@ importers: version: 5.53.11 svelte-check: specifier: ^4.0.0 - version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3) + version: 4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.9.3) tailwindcss: specifier: ^4.0.0 version: 4.2.1 @@ -7998,6 +8016,9 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -9065,6 +9086,15 @@ packages: rollup: optional: true + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -9895,9 +9925,96 @@ packages: resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} engines: {node: '>=14'} + '@swc/core-darwin-arm64@1.16.2': + resolution: {integrity: sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.16.2': + resolution: {integrity: sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.16.2': + resolution: {integrity: sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.16.2': + resolution: {integrity: sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.16.2': + resolution: {integrity: sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-ppc64-gnu@1.16.2': + resolution: {integrity: sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + + '@swc/core-linux-s390x-gnu@1.16.2': + resolution: {integrity: sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + + '@swc/core-linux-x64-gnu@1.16.2': + resolution: {integrity: sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.16.2': + resolution: {integrity: sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.16.2': + resolution: {integrity: sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.16.2': + resolution: {integrity: sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.16.2': + resolution: {integrity: sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.16.2': + resolution: {integrity: sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + '@szmarczak/http-timer@5.0.1': resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} @@ -10386,6 +10503,9 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -10606,6 +10726,9 @@ packages: '@types/memoizee@0.4.12': resolution: {integrity: sha512-EdtpwNYNhe3kZ+4TlXj/++pvBoU0KdrAICMzgI7vjWgu9sIvvUhu9XR8Ks4L6Wh3sxpZ22wkZR7yCLAqUjnZuQ==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -10751,6 +10874,12 @@ packages: '@types/strip-json-comments@0.0.30': resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/testing-library__jest-dom@5.14.9': resolution: {integrity: sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==} @@ -12343,6 +12472,9 @@ packages: resolution: {integrity: sha512-j1yoUo4gxPND1JWV9xj5ELih0yMv1iCWDG6eEQIPLSWLxzCXiFoyS7kvB+WwU+tZMf4snwJMMtaubV0laFpiBA==} hasBin: true + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + component-emitter@2.0.0: resolution: {integrity: sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==} engines: {node: '>=18'} @@ -12452,6 +12584,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + copy-webpack-plugin@11.0.0: resolution: {integrity: sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==} engines: {node: '>= 14.15.0'} @@ -13084,6 +13219,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -13971,6 +14109,9 @@ packages: fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@2.4.0: resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==} @@ -14210,6 +14351,10 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -15822,6 +15967,10 @@ packages: enquirer: optional: true + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + loader-runner@4.3.1: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} @@ -16307,6 +16456,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -17086,6 +17240,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -19165,6 +19323,14 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -19980,6 +20146,11 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin-swc@1.6.0: + resolution: {integrity: sha512-aOdzxAwJvz1GeQ8vwSNA2N8AreHYBrpwxqnwzxmB7LCdeZiXOBDj0rgjZod+lMxFsJGm8k6lF/1iKF/9wacLbw==} + peerDependencies: + '@swc/core': ^1.2.108 + unplugin@1.16.1: resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} engines: {node: '>=14.0.0'} @@ -19988,6 +20159,39 @@ packages: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -25042,7 +25246,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@28.1.3(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4))': + '@jest/core@28.1.3(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4))': dependencies: '@jest/console': 28.1.3 '@jest/reporters': 28.1.3 @@ -25056,7 +25260,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 28.1.3 - jest-config: 28.1.3(@types/node@20.19.26)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)) + jest-config: 28.1.3(@types/node@20.19.26)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4)) jest-haste-map: 28.1.3 jest-message-util: 28.1.3 jest-regex-util: 28.0.2 @@ -25077,7 +25281,7 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2))': + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -25091,7 +25295,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + jest-config: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -26190,6 +26394,10 @@ snapshots: '@opentelemetry/api@1.9.0': optional: true + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -27311,6 +27519,14 @@ snapshots: optionalDependencies: rollup: 4.59.0 + '@rollup/pluginutils@5.4.0(rollup@4.59.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.59.0 + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -28075,7 +28291,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.6.18(bufferutil@4.1.0)(prettier@3.8.1) - '@storybook/svelte-vite@8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@storybook/svelte-vite@8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@storybook/builder-vite': 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/svelte': 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11) @@ -28083,7 +28299,7 @@ snapshots: magic-string: 0.30.21 storybook: 8.6.18(bufferutil@4.1.0)(prettier@3.8.1) svelte: 5.53.11 - svelte-preprocess: 5.1.4(@babel/core@7.29.0)(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(svelte@5.53.11)(typescript@5.9.3) + svelte-preprocess: 5.1.4(@babel/core@7.29.0)(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(svelte@5.53.11)(typescript@5.9.3) svelte2tsx: 0.7.52(svelte@5.53.11)(typescript@5.9.3) sveltedoc-parser: 4.2.1 ts-dedent: 2.2.0 @@ -28101,7 +28317,7 @@ snapshots: - sugarss - supports-color - '@storybook/svelte-vite@8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@storybook/svelte-vite@8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@storybook/builder-vite': 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/svelte': 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11) @@ -28109,7 +28325,7 @@ snapshots: magic-string: 0.30.21 storybook: 8.6.18(bufferutil@4.1.0)(prettier@3.8.1) svelte: 5.53.11 - svelte-preprocess: 5.1.4(@babel/core@7.29.0)(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(svelte@5.53.11)(typescript@5.9.3) + svelte-preprocess: 5.1.4(@babel/core@7.29.0)(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(svelte@5.53.11)(typescript@5.9.3) svelte2tsx: 0.7.52(svelte@5.53.11)(typescript@5.9.3) sveltedoc-parser: 4.2.1 ts-dedent: 2.2.0 @@ -28162,12 +28378,12 @@ snapshots: ts-dedent: 2.2.0 type-fest: 2.19.0 - '@storybook/sveltekit@8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@storybook/sveltekit@8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@storybook/addon-actions': 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1)) '@storybook/builder-vite': 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/svelte': 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11) - '@storybook/svelte-vite': 8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/svelte-vite': 8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) storybook: 8.6.18(bufferutil@4.1.0)(prettier@3.8.1) svelte: 5.53.11 vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -28184,12 +28400,12 @@ snapshots: - sugarss - supports-color - '@storybook/sveltekit@8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@storybook/sveltekit@8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@storybook/addon-actions': 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1)) '@storybook/builder-vite': 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@storybook/svelte': 8.6.18(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11) - '@storybook/svelte-vite': 8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/svelte-vite': 8.6.18(@babel/core@7.29.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(storybook@8.6.18(bufferutil@4.1.0)(prettier@3.8.1))(svelte@5.53.11)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) storybook: 8.6.18(bufferutil@4.1.0)(prettier@3.8.1) svelte: 5.53.11 vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -28627,10 +28843,70 @@ snapshots: - supports-color - typescript + '@swc/core-darwin-arm64@1.16.2': + optional: true + + '@swc/core-darwin-x64@1.16.2': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.16.2': + optional: true + + '@swc/core-linux-arm64-gnu@1.16.2': + optional: true + + '@swc/core-linux-arm64-musl@1.16.2': + optional: true + + '@swc/core-linux-ppc64-gnu@1.16.2': + optional: true + + '@swc/core-linux-s390x-gnu@1.16.2': + optional: true + + '@swc/core-linux-x64-gnu@1.16.2': + optional: true + + '@swc/core-linux-x64-musl@1.16.2': + optional: true + + '@swc/core-win32-arm64-msvc@1.16.2': + optional: true + + '@swc/core-win32-ia32-msvc@1.16.2': + optional: true + + '@swc/core-win32-x64-msvc@1.16.2': + optional: true + + '@swc/core@1.16.2': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.16.2 + '@swc/core-darwin-x64': 1.16.2 + '@swc/core-linux-arm-gnueabihf': 1.16.2 + '@swc/core-linux-arm64-gnu': 1.16.2 + '@swc/core-linux-arm64-musl': 1.16.2 + '@swc/core-linux-ppc64-gnu': 1.16.2 + '@swc/core-linux-s390x-gnu': 1.16.2 + '@swc/core-linux-x64-gnu': 1.16.2 + '@swc/core-linux-x64-musl': 1.16.2 + '@swc/core-win32-arm64-msvc': 1.16.2 + '@swc/core-win32-ia32-msvc': 1.16.2 + '@swc/core-win32-x64-msvc': 1.16.2 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + '@szmarczak/http-timer@5.0.1': dependencies: defer-to-connect: 2.0.1 @@ -29219,6 +29495,8 @@ snapshots: '@types/cookie@0.6.0': {} + '@types/cookiejar@2.1.5': {} + '@types/cors@2.8.19': dependencies: '@types/node': 20.19.26 @@ -29482,6 +29760,8 @@ snapshots: '@types/memoizee@0.4.12': {} + '@types/methods@1.1.4': {} + '@types/mime@1.3.5': {} '@types/ms@2.1.0': {} @@ -29653,6 +29933,18 @@ snapshots: '@types/strip-json-comments@0.0.30': {} + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 20.19.26 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + '@types/testing-library__jest-dom@5.14.9': dependencies: '@types/jest': 29.5.14 @@ -30226,6 +30518,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/utils': 3.2.4 + magic-string: 0.30.21 + sirv: 3.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + ws: 8.19.0(bufferutil@4.1.0) + optionalDependencies: + playwright: 1.58.2 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 @@ -30245,16 +30557,16 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/utils': 3.2.4 magic-string: 0.30.21 sirv: 3.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) ws: 8.19.0(bufferutil@4.1.0) optionalDependencies: playwright: 1.58.2 @@ -30374,15 +30686,6 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - optional: true - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -31896,6 +32199,8 @@ snapshots: minimist: 1.2.8 string.prototype.repeat: 0.2.0 + component-emitter@1.3.1: {} + component-emitter@2.0.0: {} compress-commons@6.0.2: @@ -32011,6 +32316,8 @@ snapshots: cookie@0.7.2: {} + cookiejar@2.1.4: {} + copy-webpack-plugin@11.0.0(webpack@5.105.4): dependencies: fast-glob: 3.3.3 @@ -32098,13 +32405,13 @@ snapshots: safe-buffer: 5.2.1 sha.js: 2.4.12 - create-jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)): + create-jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + jest-config: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -32717,6 +33024,11 @@ snapshots: dependencies: dequal: 2.0.3 + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + didyoumean@1.2.2: {} diff-sequences@28.1.1: {} @@ -32854,9 +33166,9 @@ snapshots: dotenv@17.3.1: {} - draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - fbjs: 2.0.0 + fbjs: 2.0.0(encoding@0.1.13) immutable: 3.7.6 object-assign: 4.1.1 react: 18.3.1 @@ -32864,9 +33176,9 @@ snapshots: transitivePeerDependencies: - encoding - draftjs-utils@0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + draftjs-utils@0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 drizzle-kit@0.31.9: @@ -33592,7 +33904,7 @@ snapshots: - supports-color - typescript - eslint-plugin-svelte@3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.8.2)): + eslint-plugin-svelte@3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.8.2)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -33601,7 +33913,7 @@ snapshots: globals: 16.5.0 known-css-properties: 0.37.0 postcss: 8.5.8 - postcss-load-config: 3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.8.2)) + postcss-load-config: 3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.8.2)) postcss-safe-parser: 7.0.1(postcss@8.5.8) semver: 7.7.4 svelte-eslint-parser: 1.6.0(svelte@5.53.11) @@ -33610,7 +33922,7 @@ snapshots: transitivePeerDependencies: - ts-node - eslint-plugin-svelte@3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)): + eslint-plugin-svelte@3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -33619,7 +33931,7 @@ snapshots: globals: 16.5.0 known-css-properties: 0.37.0 postcss: 8.5.8 - postcss-load-config: 3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)) + postcss-load-config: 3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)) postcss-safe-parser: 7.0.1(postcss@8.5.8) semver: 7.7.4 svelte-eslint-parser: 1.6.0(svelte@5.53.11) @@ -33628,7 +33940,7 @@ snapshots: transitivePeerDependencies: - ts-node - eslint-plugin-svelte@3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)): + eslint-plugin-svelte@3.15.2(eslint@9.39.4(jiti@2.6.1))(svelte@5.53.11)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.9.3)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -33637,7 +33949,7 @@ snapshots: globals: 16.5.0 known-css-properties: 0.37.0 postcss: 8.5.8 - postcss-load-config: 3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) + postcss-load-config: 3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.9.3)) postcss-safe-parser: 7.0.1(postcss@8.5.8) semver: 7.7.4 svelte-eslint-parser: 1.6.0(svelte@5.53.11) @@ -34138,6 +34450,8 @@ snapshots: dependencies: fast-decode-uri-component: 1.0.1 + fast-safe-stringify@2.1.1: {} + fast-uri@2.4.0: {} fast-uri@3.1.0: {} @@ -34213,7 +34527,7 @@ snapshots: fbjs-css-vars@1.0.2: {} - fbjs@2.0.0: + fbjs@2.0.0(encoding@0.1.13): dependencies: core-js: 3.48.0 cross-fetch: 3.2.0(encoding@0.1.13) @@ -34230,6 +34544,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + feed@4.2.2: dependencies: xml-js: 1.6.11 @@ -34509,6 +34827,12 @@ snapshots: dependencies: fetch-blob: 3.2.0 + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fraction.js@5.3.4: {} @@ -35109,9 +35433,9 @@ snapshots: html-tags@3.3.1: {} - html-to-draftjs@1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + html-to-draftjs@1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 html-url-attributes@3.0.1: {} @@ -35729,16 +36053,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)): + jest-cli@28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4)): dependencies: - '@jest/core': 28.1.3(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)) + '@jest/core': 28.1.3(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4)) '@jest/test-result': 28.1.3 '@jest/types': 28.1.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 import-local: 3.2.0 - jest-config: 28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)) + jest-config: 28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4)) jest-util: 28.1.3 jest-validate: 28.1.3 prompts: 2.4.2 @@ -35748,16 +36072,16 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)): + jest-cli@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + create-jest: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + jest-config: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -35769,7 +36093,7 @@ snapshots: jest-cli@29.7.0(@types/node@24.12.0)(babel-plugin-macros@3.1.0): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 @@ -35786,7 +36110,7 @@ snapshots: - supports-color - ts-node - jest-config@28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)): + jest-config@28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4)): dependencies: '@babel/core': 7.29.0 '@jest/test-sequencer': 28.1.3 @@ -35812,11 +36136,11 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 18.19.130 - ts-node: 10.9.2(@types/node@18.19.130)(typescript@5.0.4) + ts-node: 10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4) transitivePeerDependencies: - supports-color - jest-config@28.1.3(@types/node@20.19.26)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)): + jest-config@28.1.3(@types/node@20.19.26)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4)): dependencies: '@babel/core': 7.29.0 '@jest/test-sequencer': 28.1.3 @@ -35842,11 +36166,11 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.19.26 - ts-node: 10.9.2(@types/node@18.19.130)(typescript@5.0.4) + ts-node: 10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4) transitivePeerDependencies: - supports-color - jest-config@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)): + jest-config@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)): dependencies: '@babel/core': 7.29.0 '@jest/test-sequencer': 29.7.0 @@ -35872,7 +36196,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.19.26 - ts-node: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + ts-node: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -36360,23 +36684,23 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)): + jest@28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4)): dependencies: - '@jest/core': 28.1.3(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)) + '@jest/core': 28.1.3(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4)) '@jest/types': 28.1.3 import-local: 3.2.0 - jest-cli: 28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4)) + jest-cli: 28.1.3(@types/node@18.19.130)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4)) transitivePeerDependencies: - '@types/node' - supports-color - ts-node - jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)): + jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + jest-cli: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -36385,7 +36709,7 @@ snapshots: jest@29.7.0(@types/node@24.12.0)(babel-plugin-macros@3.1.0): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) '@jest/types': 29.6.3 import-local: 3.2.0 jest-cli: 29.7.0(@types/node@24.12.0)(babel-plugin-macros@3.1.0) @@ -36775,6 +37099,8 @@ snapshots: optionalDependencies: enquirer: 2.4.1 + load-tsconfig@0.2.5: {} + loader-runner@4.3.1: {} loader-utils@2.0.4: @@ -37616,6 +37942,8 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + mime@3.0.0: {} mimic-fn@2.1.0: {} @@ -38433,6 +38761,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.7: {} + pidtree@0.6.0: {} pify@2.3.0: {} @@ -38668,38 +38998,38 @@ snapshots: '@csstools/utilities': 2.0.0(postcss@8.5.8) postcss: 8.5.8 - postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.6.3)): + postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.6.3)): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: postcss: 8.5.8 - ts-node: 10.9.2(@types/node@22.19.15)(typescript@5.6.3) + ts-node: 10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.6.3) optional: true - postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.8.2)): + postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.8.2)): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: postcss: 8.5.8 - ts-node: 10.9.2(@types/node@22.19.15)(typescript@5.8.2) + ts-node: 10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.8.2) - postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)): + postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: postcss: 8.5.8 - ts-node: 10.9.2(@types/node@24.12.0)(typescript@5.8.2) + ts-node: 10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2) - postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)): + postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.9.3)): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: postcss: 8.5.8 - ts-node: 10.9.2(@types/node@24.12.0)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.9.3) postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.2): dependencies: @@ -39483,12 +39813,12 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-draft-wysiwyg@1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-draft-wysiwyg@1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: classnames: 2.5.1 - draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - draftjs-utils: 0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) - html-to-draftjs: 1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draftjs-utils: 0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + html-to-draftjs: 1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) immutable: 5.1.5 linkify-it: 2.2.0 prop-types: 15.8.1 @@ -40982,6 +41312,28 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@5.5.0) + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.0 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -41007,7 +41359,7 @@ snapshots: svelte: 5.53.11 zimmerframe: 1.1.2 - svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.6.3): + svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.8.2): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 @@ -41015,11 +41367,11 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 svelte: 5.53.11 - typescript: 5.6.3 + typescript: 5.8.2 transitivePeerDependencies: - picomatch - svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.8.2): + svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 @@ -41027,15 +41379,39 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 svelte: 5.53.11 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte-check@4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.6.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.7) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.53.11 + typescript: 5.6.3 + transitivePeerDependencies: + - picomatch + + svelte-check@4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.8.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.7) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.53.11 typescript: 5.8.2 transitivePeerDependencies: - picomatch - svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3): + svelte-check@4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.7) picocolors: 1.1.1 sade: 1.8.1 svelte: 5.53.11 @@ -41059,7 +41435,7 @@ snapshots: svelte-loading-spinners@0.3.6: {} - svelte-preprocess@5.1.4(@babel/core@7.29.0)(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(svelte@5.53.11)(typescript@5.9.3): + svelte-preprocess@5.1.4(@babel/core@7.29.0)(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.6.3)))(postcss@8.5.8)(sass@1.98.0)(svelte@5.53.11)(typescript@5.9.3): dependencies: '@types/pug': 2.0.10 detect-indent: 6.1.0 @@ -41070,11 +41446,11 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 postcss: 8.5.8 - postcss-load-config: 3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.6.3)) + postcss-load-config: 3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.6.3)) sass: 1.98.0 typescript: 5.9.3 - svelte-preprocess@5.1.4(@babel/core@7.29.0)(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(svelte@5.53.11)(typescript@5.9.3): + svelte-preprocess@5.1.4(@babel/core@7.29.0)(postcss-load-config@3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)))(postcss@8.5.8)(sass@1.98.0)(svelte@5.53.11)(typescript@5.9.3): dependencies: '@types/pug': 2.0.10 detect-indent: 6.1.0 @@ -41085,7 +41461,7 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 postcss: 8.5.8 - postcss-load-config: 3.1.4(postcss@8.5.8)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2)) + postcss-load-config: 3.1.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2)) sass: 1.98.0 typescript: 5.9.3 @@ -41315,6 +41691,18 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 + terser-webpack-plugin@5.4.0(@swc/core@1.16.2)(esbuild@0.27.4)(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.46.0 + webpack: 5.105.4(@swc/core@1.16.2)(esbuild@0.27.4) + optionalDependencies: + '@swc/core': 1.16.2 + esbuild: 0.27.4 + optional: true + terser-webpack-plugin@5.4.0(webpack@5.105.4): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -41505,12 +41893,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)))(typescript@5.8.2): + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)))(typescript@5.8.2): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)) + jest: 29.7.0(@types/node@20.19.26)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -41545,7 +41933,7 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.0) jest-util: 29.7.0 - ts-node-dev@2.0.0(@types/node@20.19.26)(typescript@5.8.2): + ts-node-dev@2.0.0(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2): dependencies: chokidar: 3.6.0 dynamic-dedupe: 0.3.0 @@ -41555,7 +41943,7 @@ snapshots: rimraf: 2.7.1 source-map-support: 0.5.21 tree-kill: 1.2.2 - ts-node: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + ts-node: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) tsconfig: 7.0.0 typescript: 5.8.2 transitivePeerDependencies: @@ -41563,7 +41951,7 @@ snapshots: - '@swc/wasm' - '@types/node' - ts-node@10.9.2(@types/node@18.19.130)(typescript@5.0.4): + ts-node@10.9.2(@swc/core@1.16.2)(@types/node@18.19.130)(typescript@5.0.4): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -41580,9 +41968,11 @@ snapshots: typescript: 5.0.4 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.16.2 optional: true - ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2): + ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -41599,8 +41989,10 @@ snapshots: typescript: 5.8.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.16.2 - ts-node@10.9.2(@types/node@20.19.26)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -41617,8 +42009,10 @@ snapshots: typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.16.2 - ts-node@10.9.2(@types/node@22.19.15)(typescript@5.6.3): + ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.6.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -41635,9 +42029,11 @@ snapshots: typescript: 5.6.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.16.2 optional: true - ts-node@10.9.2(@types/node@22.19.15)(typescript@5.8.2): + ts-node@10.9.2(@swc/core@1.16.2)(@types/node@22.19.15)(typescript@5.8.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -41654,9 +42050,11 @@ snapshots: typescript: 5.8.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.16.2 optional: true - ts-node@10.9.2(@types/node@24.12.0)(typescript@5.8.2): + ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.8.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -41673,9 +42071,11 @@ snapshots: typescript: 5.8.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.16.2 optional: true - ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.16.2)(@types/node@24.12.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -41692,6 +42092,8 @@ snapshots: typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.16.2 optional: true tsconfig-paths@3.15.0: @@ -41854,7 +42256,7 @@ snapshots: typeorm-ts-node-commonjs@0.3.20: {} - typeorm@0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.8.2)): + typeorm@0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2)): dependencies: '@sqltools/formatter': 1.2.5 ansis: 4.2.0 @@ -41874,12 +42276,12 @@ snapshots: optionalDependencies: pg: 8.20.0 sqlite3: 5.1.7 - ts-node: 10.9.2(@types/node@20.19.26)(typescript@5.8.2) + ts-node: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) transitivePeerDependencies: - babel-plugin-macros - supports-color - typeorm@0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@20.19.26)(typescript@5.9.3)): + typeorm@0.3.28(babel-plugin-macros@3.1.0)(pg@8.20.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.9.3)): dependencies: '@sqltools/formatter': 1.2.5 ansis: 4.2.0 @@ -41899,7 +42301,7 @@ snapshots: optionalDependencies: pg: 8.20.0 sqlite3: 5.1.7 - ts-node: 10.9.2(@types/node@20.19.26)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -42041,6 +42443,23 @@ snapshots: unpipe@1.0.0: {} + unplugin-swc@1.6.0(@swc/core@1.16.2)(esbuild@0.27.4)(rollup@4.59.0)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)): + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@4.59.0) + '@swc/core': 1.16.2 + load-tsconfig: 0.2.5 + unplugin: 3.3.0(esbuild@0.27.4)(rollup@4.59.0)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - vite + - webpack + unplugin@1.16.1: dependencies: acorn: 8.16.0 @@ -42053,6 +42472,17 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 + unplugin@3.3.0(esbuild@0.27.4)(rollup@4.59.0)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.7 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + esbuild: 0.27.4 + rollup: 4.59.0 + vite: 7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + webpack: 5.105.4(@swc/core@1.16.2)(esbuild@0.27.4) + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -42694,7 +43124,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 20.19.26 - '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) + '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) jsdom: 19.0.0(bufferutil@4.1.0) transitivePeerDependencies: - jiti @@ -42738,7 +43168,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.19.15 - '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) + '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) jsdom: 19.0.0(bufferutil@4.1.0) transitivePeerDependencies: - jiti @@ -42988,6 +43418,39 @@ snapshots: - esbuild - uglify-js + webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.20.0 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.4.0(@swc/core@1.16.2)(esbuild@0.27.4)(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)) + watchpack: 2.5.1 + webpack-sources: 3.3.4 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + optional: true + webpackbar@6.0.1(webpack@5.105.4): dependencies: ansi-escapes: 4.3.2 From 4ca907033d3095e224d85f5b0df5b6999f20ac68 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 10:10:46 +0530 Subject: [PATCH 10/13] Drive group-charter-manager end to end, in both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the platform where owner and admins are bare local user ids in plain columns rather than a relation the mapping can follow, so it exercises the part of the change with the most room to be wrong: an eName has to resolve back to a local id on the way in, and that local id has to leave as an eName on the way out. The outbound case calls handleChange directly, the way a junction-table change or a backfill script does, bypassing the watcher entirely. Removing the enrichment makes it emit "@" — a syntactically valid eName naming nobody — and the test fails on exactly that. --- .../group-charter-manager/api/package.json | 11 +- .../group-replication.acceptance.test.ts | 250 ++++++++++++++++++ .../api/vitest.config.ts | 18 ++ pnpm-lock.yaml | 107 ++++---- 4 files changed, 339 insertions(+), 47 deletions(-) create mode 100644 platforms/group-charter-manager/api/src/controllers/group-replication.acceptance.test.ts create mode 100644 platforms/group-charter-manager/api/vitest.config.ts diff --git a/platforms/group-charter-manager/api/package.json b/platforms/group-charter-manager/api/package.json index 5da80f182..9db680e38 100644 --- a/platforms/group-charter-manager/api/package.json +++ b/platforms/group-charter-manager/api/package.json @@ -11,7 +11,8 @@ "migration:generate": "npm run typeorm migration:generate -- -d src/database/data-source.ts", "migration:run": "npm run typeorm migration:run -- -d src/database/data-source.ts", "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts", - "migrate:evaults": "ts-node src/scripts/migrate-eVaults.ts" + "migrate:evaults": "ts-node src/scripts/migrate-eVaults.ts", + "test": "vitest run" }, "dependencies": { "axios": "^1.6.7", @@ -28,17 +29,23 @@ "web3-adapter": "workspace:*" }, "devDependencies": { + "@swc/core": "^1.10.1", + "@testcontainers/postgresql": "^10.28.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.5", "@types/node": "^20.11.24", "@types/pg": "^8.11.2", + "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "eslint": "^8.56.0", "nodemon": "^3.0.3", + "supertest": "^7.0.0", "ts-node": "^10.9.2", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "unplugin-swc": "^1.5.1", + "vitest": "^3.1.2" } } diff --git a/platforms/group-charter-manager/api/src/controllers/group-replication.acceptance.test.ts b/platforms/group-charter-manager/api/src/controllers/group-replication.acceptance.test.ts new file mode 100644 index 000000000..cb2388d35 --- /dev/null +++ b/platforms/group-charter-manager/api/src/controllers/group-replication.acceptance.test.ts @@ -0,0 +1,250 @@ +import type { StartedPostgreSqlContainer } from "@testcontainers/postgresql"; +import { PostgreSqlContainer } from "@testcontainers/postgresql"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +/** + * Acceptance test for group replication in group-charter-manager. + * + * This platform is the awkward one, and therefore the one worth driving end to + * end. Its `participants` are a TypeORM relation, but `owner` and `admins` are + * bare local user ids in plain columns with no relation for the mapping to + * follow. Both directions have to work: + * + * - inbound, an eName has to be resolved back to the local id the column holds + * - outbound, that local id has to leave as an eName + * + * The outbound half is the one unit tests kept missing, because a group reaches + * `handleChange` from several call sites and only some pass through the + * watcher's enrichment. + */ + +const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; +const BOB = "@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60"; +const STRANGER = "@0c0ffee0-dead-4bee-8fee-000000000000"; + +const GROUP_SCHEMA = "550e8400-e29b-41d4-a716-446655440003"; + +let container: StartedPostgreSqlContainer; +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let AppDataSource: any; +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let app: any; +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let User: any; +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let Group: any; +// biome-ignore lint/suspicious/noExplicitAny: modules are imported after env setup +let adapter: any; + +/** Envelopes the adapter would have sent, captured instead of posted. */ +const sent: { data: Record }[] = []; + +beforeAll(async () => { + container = await new PostgreSqlContainer("postgres:15-alpine") + .withDatabase("gcm_test") + .withUsername("test") + .withPassword("test") + .start(); + + process.env.GROUP_CHARTER_DATABASE_URL = container.getConnectionUri(); + process.env.GROUP_CHARTER_MAPPING_DB_PATH = `/tmp/gcm-accept-${Date.now()}`; + process.env.PUBLIC_REGISTRY_URL = "http://registry.invalid"; + process.env.PUBLIC_GROUP_CHARTER_BASE_URL = "http://gcm.invalid"; + process.env.CHARTER_JWT_SECRET ??= "test-secret"; + + const ds = await import("../database/data-source"); + AppDataSource = ds.AppDataSource; + ({ User } = await import("../database/entities/User")); + ({ Group } = await import("../database/entities/Group")); + + AppDataSource.setOptions({ + synchronize: true, + subscribers: [], + migrations: [], + }); + await AppDataSource.initialize(); + await AppDataSource.synchronize(); + + ({ adapter } = await import("../web3adapter/watchers/subscriber")); + await adapter.readPaths(); + adapter.evaultClient = { + storeMetaEnvelope: async (env: { data: Record }) => { + sent.push(env); + return `global-${sent.length}`; + }, + storeReference: async () => undefined, + updateMetaEnvelopeById: async ( + _id: string, + env: { data: Record }, + ) => { + sent.push(env); + }, + }; + + const { WebhookController } = await import("./WebhookController"); + const controller = new WebhookController(adapter); + app = express(); + app.use(express.json()); + app.post("/api/webhook", controller.handleWebhook); + + const users = AppDataSource.getRepository(User); + await users.save(users.create({ ename: ALICE, name: "Alice", handle: "alice" })); + await users.save(users.create({ ename: BOB, name: "Bob", handle: "bob" })); +}, 180_000); + +afterAll(async () => { + if (AppDataSource?.isInitialized) await AppDataSource.destroy(); + await container?.stop(); +}, 60_000); + +describe("gcm group replication (real HTTP -> controller -> Postgres)", () => { + it("ingests a group whose participants, admins and owner are eNames", async () => { + await request(app) + .post("/api/webhook") + .send({ + id: `group-enames-${Date.now()}`, + schemaId: GROUP_SCHEMA, + w3id: ALICE, + data: { + ename: "@group-1", + name: "All eNames", + description: "d", + participantIds: [ALICE, BOB], + admins: [ALICE], + owner: ALICE, + }, + }) + .expect(200); + + const group = await AppDataSource.getRepository(Group).findOne({ + where: { name: "All eNames" }, + relations: ["participants"], + }); + + expect(group, "the group should exist").toBeTruthy(); + expect( + group.participants.map((p: { ename: string }) => p.ename).sort(), + ).toEqual([ALICE, BOB].sort()); + + // owner and admins are local id columns, so the eNames resolve back. + const alice = await AppDataSource.getRepository(User).findOneBy({ + ename: ALICE, + }); + expect(group.owner).toBe(alice.id); + expect(group.admins).toEqual([alice.id]); + }); + + it("keeps the group when a participant, admin or owner is unresolvable", async () => { + await request(app) + .post("/api/webhook") + .send({ + id: `group-stranger-${Date.now()}`, + schemaId: GROUP_SCHEMA, + w3id: ALICE, + data: { + ename: "@group-2", + name: "With a stranger", + description: "d", + participantIds: [ALICE, STRANGER, BOB], + admins: [STRANGER], + owner: STRANGER, + }, + }) + .expect(200); + + const group = await AppDataSource.getRepository(Group).findOne({ + where: { name: "With a stranger" }, + relations: ["participants"], + }); + + expect(group, "the group should survive").toBeTruthy(); + expect( + group.participants.map((p: { ename: string }) => p.ename).sort(), + ).toEqual([ALICE, BOB].sort()); + // An unresolvable admin is skipped rather than stored as a dangling id. + expect(group.admins ?? []).toEqual([]); + }); + + it("ingests a group with malformed entries without losing it", async () => { + await request(app) + .post("/api/webhook") + .send({ + id: `group-malformed-${Date.now()}`, + schemaId: GROUP_SCHEMA, + w3id: ALICE, + data: { + ename: "@group-3", + name: "Malformed entries", + description: "d", + participantIds: [ALICE, null, 42, "", { nested: true }, [], BOB], + admins: [null, 7], + owner: null, + }, + }) + .expect(200); + + const group = await AppDataSource.getRepository(Group).findOne({ + where: { name: "Malformed entries" }, + relations: ["participants"], + }); + + expect(group, "the group should still ingest").toBeTruthy(); + expect( + group.participants.map((p: { ename: string }) => p.ename).sort(), + ).toEqual([ALICE, BOB].sort()); + }); + + it("emits eNames outbound, including for the bare-id owner and admins", async () => { + // The producer half. `handleChange` is called directly here, the way a + // junction-table change or a backfill script calls it — bypassing the + // watcher's enrichment entirely. + const users = AppDataSource.getRepository(User); + const alice = await users.findOneBy({ ename: ALICE }); + const bob = await users.findOneBy({ ename: BOB }); + + const groups = AppDataSource.getRepository(Group); + const group = await groups.save( + groups.create({ + name: "Outbound", + description: "d", + ename: "@group-out", + owner: alice.id, + admins: [alice.id, bob.id], + participants: [alice, bob], + }), + ); + + sent.length = 0; + await adapter.handleChange({ + data: { + ...group, + participants: [alice, bob], + }, + tableName: "groups", + }); + + expect(sent.length, "an envelope should have been produced").toBe(1); + const emitted = sent[0].data; + + expect(emitted.owner, "owner must leave as an eName").toBe(ALICE); + expect( + (emitted.admins as string[]).sort(), + "admins must leave as eNames", + ).toEqual([ALICE, BOB].sort()); + expect((emitted.participantIds as string[]).sort()).toEqual( + [ALICE, BOB].sort(), + ); + + // Nothing that looks like a local uuid should be on the wire. + for (const value of [ + emitted.owner, + ...(emitted.admins as string[]), + ...(emitted.participantIds as string[]), + ]) { + expect(String(value)).toMatch(/^@/); + expect(String(value)).not.toContain(alice.id); + } + }); +}); diff --git a/platforms/group-charter-manager/api/vitest.config.ts b/platforms/group-charter-manager/api/vitest.config.ts new file mode 100644 index 000000000..b31ffaed5 --- /dev/null +++ b/platforms/group-charter-manager/api/vitest.config.ts @@ -0,0 +1,18 @@ +import swc from "unplugin-swc"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // TypeORM entities rely on `emitDecoratorMetadata`, which esbuild — the + // default vitest transform — does not emit. Without it every relation column + // fails with ColumnTypeUndefinedError, so the acceptance tests cannot load + // the real entities. swc emits the metadata. + plugins: [swc.vite({ module: { type: "es6" } })], + test: { + // A container start plus schema sync is well past the default timeout. + testTimeout: 60_000, + hookTimeout: 180_000, + // The acceptance suite shares one Postgres container and one module-level + // data source, so its files must not run in parallel. + fileParallelism: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89951487f..bed093805 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2661,7 +2661,7 @@ importers: version: 5.53.11 svelte-check: specifier: ^4.0.0 - version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.8.2) + version: 4.4.5(picomatch@4.0.7)(svelte@5.53.11)(typescript@5.8.2) tailwindcss: specifier: ^4.0.0 version: 4.2.1 @@ -3156,6 +3156,12 @@ importers: specifier: workspace:* version: link:../../../infrastructure/web3-adapter devDependencies: + '@swc/core': + specifier: ^1.10.1 + version: 1.16.2 + '@testcontainers/postgresql': + specifier: ^10.28.0 + version: 10.28.0 '@types/cors': specifier: ^2.8.17 version: 2.8.19 @@ -3171,6 +3177,9 @@ importers: '@types/pg': specifier: ^8.11.2 version: 8.18.0 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 '@types/uuid': specifier: ^9.0.8 version: 9.0.8 @@ -3186,12 +3195,21 @@ importers: nodemon: specifier: ^3.0.3 version: 3.1.14 + supertest: + specifier: ^7.0.0 + version: 7.2.2 ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.16.2)(@types/node@20.19.26)(typescript@5.8.2) typescript: specifier: ^5.3.3 version: 5.8.2 + unplugin-swc: + specifier: ^1.5.1 + version: 1.6.0(@swc/core@1.16.2)(esbuild@0.27.4)(rollup@4.59.0)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)) + vitest: + specifier: ^3.1.2 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) platforms/group-charter-manager/client: dependencies: @@ -9077,15 +9095,6 @@ packages: rollup: optional: true - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/pluginutils@5.4.0': resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} engines: {node: '>=14.0.0'} @@ -26442,7 +26451,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.3 + picomatch: 4.0.7 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -27467,19 +27476,19 @@ snapshots: '@rollup/plugin-commonjs@29.0.2(rollup@4.59.0)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + '@rollup/pluginutils': 5.4.0(rollup@4.59.0) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.7) is-reference: 1.2.1 magic-string: 0.30.21 - picomatch: 4.0.3 + picomatch: 4.0.7 optionalDependencies: rollup: 4.59.0 '@rollup/plugin-inject@5.0.5(rollup@4.59.0)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + '@rollup/pluginutils': 5.4.0(rollup@4.59.0) estree-walker: 2.0.2 magic-string: 0.30.21 optionalDependencies: @@ -27487,13 +27496,13 @@ snapshots: '@rollup/plugin-json@6.1.0(rollup@4.59.0)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + '@rollup/pluginutils': 5.4.0(rollup@4.59.0) optionalDependencies: rollup: 4.59.0 '@rollup/plugin-node-resolve@15.3.1(rollup@4.59.0)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + '@rollup/pluginutils': 5.4.0(rollup@4.59.0) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 @@ -27503,7 +27512,7 @@ snapshots: '@rollup/plugin-node-resolve@16.0.3(rollup@4.59.0)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + '@rollup/pluginutils': 5.4.0(rollup@4.59.0) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 @@ -27511,19 +27520,11 @@ snapshots: optionalDependencies: rollup: 4.59.0 - '@rollup/pluginutils@5.3.0(rollup@4.59.0)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.3 - optionalDependencies: - rollup: 4.59.0 - '@rollup/pluginutils@5.4.0(rollup@4.59.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 - picomatch: 4.0.3 + picomatch: 4.0.7 optionalDependencies: rollup: 4.59.0 @@ -41359,18 +41360,6 @@ snapshots: svelte: 5.53.11 zimmerframe: 1.1.2 - svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.8.2): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - chokidar: 4.0.3 - fdir: 6.5.0(picomatch@4.0.3) - picocolors: 1.1.1 - sade: 1.8.1 - svelte: 5.53.11 - typescript: 5.8.2 - transitivePeerDependencies: - - picomatch - svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -41806,8 +41795,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinypool@0.8.4: {} @@ -42443,6 +42432,23 @@ snapshots: unpipe@1.0.0: {} + unplugin-swc@1.6.0(@swc/core@1.16.2)(esbuild@0.27.4)(rollup@4.59.0)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)): + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@4.59.0) + '@swc/core': 1.16.2 + load-tsconfig: 0.2.5 + unplugin: 3.3.0(esbuild@0.27.4)(rollup@4.59.0)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - vite + - webpack + unplugin-swc@1.6.0(@swc/core@1.16.2)(esbuild@0.27.4)(rollup@4.59.0)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.59.0) @@ -42469,9 +42475,20 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.16.0 - picomatch: 4.0.3 + picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 + unplugin@3.3.0(esbuild@0.27.4)(rollup@4.59.0)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.7 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + esbuild: 0.27.4 + rollup: 4.59.0 + vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + webpack: 5.105.4(@swc/core@1.16.2)(esbuild@0.27.4) + unplugin@3.3.0(esbuild@0.27.4)(rollup@4.59.0)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.4(@swc/core@1.16.2)(esbuild@0.27.4)): dependencies: '@jridgewell/remapping': 2.3.5 @@ -43111,7 +43128,7 @@ snapshots: expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.7 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 @@ -43155,7 +43172,7 @@ snapshots: expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.7 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 @@ -43199,7 +43216,7 @@ snapshots: expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.7 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 From ecb139f1ccf2aa1318d13ff377b929e600d55c64 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 10:15:33 +0530 Subject: [PATCH 11/13] Drop the unused ENameProfileCache from the adapter It had no call sites. Only Blabsy actually needed per-eName profile hydration, because its chat surfaces read participants one document at a time; that cache lives in the client and is wired into all five of those components. Every other platform stores participants as a TypeORM relation, so display names arrive through a SQL join and there is nothing to cache. A generic cache in the shared adapter was an abstraction written for a caller that never existed, and its tests only proved it was self-consistent. --- infrastructure/web3-adapter/src/index.ts | 2 - .../src/w3ds/ename-profile-cache.test.ts | 121 ------------------ .../src/w3ds/ename-profile-cache.ts | 107 ---------------- 3 files changed, 230 deletions(-) delete mode 100644 infrastructure/web3-adapter/src/w3ds/ename-profile-cache.test.ts delete mode 100644 infrastructure/web3-adapter/src/w3ds/ename-profile-cache.ts diff --git a/infrastructure/web3-adapter/src/index.ts b/infrastructure/web3-adapter/src/index.ts index d253543f1..b761bb73a 100644 --- a/infrastructure/web3-adapter/src/index.ts +++ b/infrastructure/web3-adapter/src/index.ts @@ -22,8 +22,6 @@ export { normaliseENameList, toEName, } from "./w3ds/ename"; -export type { ENameProfileCacheOptions } from "./w3ds/ename-profile-cache"; -export { ENameProfileCache } from "./w3ds/ename-profile-cache"; export type { ENameLookup, ResolveOptions } from "./w3ds/entity-refs"; export { resolveENameRef, resolveENameRefs } from "./w3ds/entity-refs"; export { enrichGroupOwnership } from "./w3ds/group-ownership"; diff --git a/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.test.ts b/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.test.ts deleted file mode 100644 index 2a4605272..000000000 --- a/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ENameProfileCache } from "./ename-profile-cache"; - -const ALICE = "@48468c9a-dc1b-5663-92fb-5e46e3d2a7f0"; -const BOB = "@7f3d2e1a-9b8c-4d5e-8f0a-1b2c3d4e5f60"; - -describe("ENameProfileCache", () => { - beforeEach(() => { - vi.useRealTimers(); - }); - - it("loads once and serves repeats from cache", async () => { - const load = vi.fn(async (ename: string) => ({ name: ename })); - const cache = new ENameProfileCache({ load }); - - expect(await cache.get(ALICE)).toEqual({ name: ALICE }); - expect(await cache.get(ALICE)).toEqual({ name: ALICE }); - expect(await cache.get(ALICE)).toEqual({ name: ALICE }); - - expect(load).toHaveBeenCalledTimes(1); - }); - - it("collapses a concurrent burst into a single load", async () => { - // The room-render case: every participant tile asks at once. - const load = vi.fn( - async (ename: string) => - new Promise<{ name: string }>((resolve) => - setTimeout(() => resolve({ name: ename }), 10), - ), - ); - const cache = new ENameProfileCache({ load }); - - const results = await Promise.all([ - cache.get(ALICE), - cache.get(ALICE), - cache.get(ALICE), - ]); - - expect(results).toEqual([ - { name: ALICE }, - { name: ALICE }, - { name: ALICE }, - ]); - expect(load).toHaveBeenCalledTimes(1); - }); - - it("caches a miss so unknown participants are not re-queried", async () => { - // A member on a platform this instance knows nothing about is a normal, - // permanent condition — not something to retry on every render. - const load = vi.fn(async () => null); - const cache = new ENameProfileCache({ load }); - - expect(await cache.get(ALICE)).toBeNull(); - expect(await cache.get(ALICE)).toBeNull(); - - expect(load).toHaveBeenCalledTimes(1); - }); - - it("reloads after the TTL expires", async () => { - vi.useFakeTimers(); - const load = vi.fn(async (ename: string) => ({ name: ename })); - const cache = new ENameProfileCache({ load, ttlMs: 1000 }); - - await cache.get(ALICE); - vi.advanceTimersByTime(1500); - await cache.get(ALICE); - - expect(load).toHaveBeenCalledTimes(2); - vi.useRealTimers(); - }); - - it("does not cache a failed lookup", async () => { - // A transient failure must not be remembered for the whole TTL. - const load = vi - .fn<(ename: string) => Promise<{ name: string } | null>>() - .mockRejectedValueOnce(new Error("registry down")) - .mockResolvedValueOnce({ name: ALICE }); - const cache = new ENameProfileCache({ load }); - - expect(await cache.get(ALICE)).toBeNull(); - expect(await cache.get(ALICE)).toEqual({ name: ALICE }); - expect(load).toHaveBeenCalledTimes(2); - }); - - it("resolves many eNames and omits the unknown ones", async () => { - const load = vi.fn(async (ename: string) => - ename === ALICE ? { name: "Alice" } : null, - ); - const cache = new ENameProfileCache({ load }); - - const found = await cache.getMany([ALICE, BOB, ALICE]); - - expect(found.get(ALICE)).toEqual({ name: "Alice" }); - expect(found.has(BOB)).toBe(false); - // ALICE appears twice in the input but is loaded once. - expect(load).toHaveBeenCalledTimes(2); - }); - - it("evicts oldest entries past the cap", async () => { - const load = vi.fn(async (ename: string) => ({ name: ename })); - const cache = new ENameProfileCache({ load, maxEntries: 2 }); - - await cache.get("@a"); - await cache.get("@b"); - await cache.get("@c"); // evicts @a - await cache.get("@a"); // reloads - - expect(load).toHaveBeenCalledTimes(4); - }); - - it("invalidates a single entry on demand", async () => { - const load = vi.fn(async (ename: string) => ({ name: ename })); - const cache = new ENameProfileCache({ load }); - - await cache.get(ALICE); - cache.invalidate(ALICE); - await cache.get(ALICE); - - expect(load).toHaveBeenCalledTimes(2); - }); -}); diff --git a/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.ts b/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.ts deleted file mode 100644 index 6e1bb9168..000000000 --- a/infrastructure/web3-adapter/src/w3ds/ename-profile-cache.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * A small TTL cache for eName → profile lookups. - * - * Under the old envelope-id scheme a participant reference dereferenced - * straight to a full User record, so display names and avatars arrived free as - * part of the mapping. An eName carries identity but no profile data, so - * hydrating a room's members is now a separate lookup per member — an N+1 on - * every render if left alone. This caches those lookups. - * - * Misses are cached too. A participant who lives on a platform this instance - * knows nothing about is a normal, permanent condition, and re-querying for - * them on every render is exactly the cost this exists to avoid. - */ -export interface ENameProfileCacheOptions { - /** Resolves one eName to a profile, or `null` when nobody is known by it. */ - load: (ename: string) => Promise; - /** How long an entry stays fresh. Defaults to five minutes. */ - ttlMs?: number; - /** Maximum entries retained. Defaults to 1000. */ - maxEntries?: number; -} - -interface CacheEntry { - value: T | null; - expiresAt: number; -} - -const DEFAULT_TTL_MS = 5 * 60 * 1000; -const DEFAULT_MAX_ENTRIES = 1000; - -export class ENameProfileCache { - private entries = new Map>(); - /** In-flight loads, so a burst for one eName makes a single query. */ - private inflight = new Map>(); - private readonly load: (ename: string) => Promise; - private readonly ttlMs: number; - private readonly maxEntries: number; - - constructor(options: ENameProfileCacheOptions) { - this.load = options.load; - this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; - this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; - } - - async get(ename: string): Promise { - const cached = this.entries.get(ename); - if (cached && cached.expiresAt > Date.now()) { - return cached.value; - } - this.entries.delete(ename); - - const existing = this.inflight.get(ename); - if (existing) return existing; - - const pending = this.load(ename) - .then((value) => { - this.set(ename, value); - return value; - }) - .catch((error) => { - // A failed lookup is not cached: unlike "nobody is known by this - // eName", a transient failure should not be remembered for the - // whole TTL. - console.warn(`[ename-cache] failed to load profile ${ename}:`, error); - return null; - }) - .finally(() => { - this.inflight.delete(ename); - }); - - this.inflight.set(ename, pending); - return pending; - } - - /** Resolves many eNames at once, returning only those that are known. */ - async getMany(enames: readonly string[]): Promise> { - const unique = [...new Set(enames)]; - const resolved = await Promise.all( - unique.map(async (ename) => [ename, await this.get(ename)] as const), - ); - - const found = new Map(); - for (const [ename, value] of resolved) { - if (value !== null && value !== undefined) found.set(ename, value); - } - return found; - } - - /** Drops an entry, for when a profile is known to have changed. */ - invalidate(ename: string): void { - this.entries.delete(ename); - } - - clear(): void { - this.entries.clear(); - } - - private set(ename: string, value: T | null): void { - // Oldest-first eviction. Insertion order is Map's iteration order, and a - // refreshed entry is deleted before being re-set, so it moves to the back. - if (this.entries.size >= this.maxEntries) { - const oldest = this.entries.keys().next(); - if (!oldest.done) this.entries.delete(oldest.value); - } - this.entries.set(ename, { value, expiresAt: Date.now() + this.ttlMs }); - } -} From f9b9675bd631ddb4e03302a293a5b356ae5897ad Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 11:20:35 +0530 Subject: [PATCH 12/13] Allow outbound eVault sync to be switched off for local runs Running either API locally against a real Firestore or Postgres also starts the producer side: Blabsy's watchers and Pictique's TypeORM subscriber replicate every change to the eVaults named by PUBLIC_REGISTRY_URL, which is a shared environment. A developer starting the app to look at it does not necessarily expect it to publish there. That matters more than usual right now. This branch changes the format of chat entity references, and the rollout has a required order: consumers must accept eNames before any producer emits them. A local Blabsy pointed at the shared registry would emit the new format from an unreviewed branch, ahead of the deployment that teaches the other platforms to read it. BLABSY_DISABLE_EVAULT_SYNC and PICTIQUE_DISABLE_EVAULT_SYNC turn the producer off while leaving inbound webhooks working, so the apps stay usable for local work. Both are documented in .env.example and default to off, so nothing about deployed behaviour changes. --- .env.example | 9 +++++++++ platforms/blabsy/api/src/web3adapter/index.ts | 17 +++++++++++++++++ .../api/src/web3adapter/watchers/subscriber.ts | 9 +++++++++ 3 files changed, 35 insertions(+) diff --git a/.env.example b/.env.example index 35429aec9..8ba76cb21 100644 --- a/.env.example +++ b/.env.example @@ -198,3 +198,12 @@ PPA_MESSENGER_PLATFORM_NAME="meshenger" # Path that opens a conversation with one person. Only used when the messenger # publishes no handle for the User ontology; a declared handle always wins. PPA_MESSENGER_CONTACT_PATH="/contacts/{ename}" + +# Local development safety: keep outbound eVault sync off. +# +# The Blabsy watchers and the Pictique subscriber replicate every local change +# to the eVaults named by PUBLIC_REGISTRY_URL, which is a shared environment. +# Set these while working locally so a dev database does not publish to it. +# Inbound webhooks keep working either way. +BLABSY_DISABLE_EVAULT_SYNC=true +PICTIQUE_DISABLE_EVAULT_SYNC=true diff --git a/platforms/blabsy/api/src/web3adapter/index.ts b/platforms/blabsy/api/src/web3adapter/index.ts index cd10ba289..fbd924ae8 100644 --- a/platforms/blabsy/api/src/web3adapter/index.ts +++ b/platforms/blabsy/api/src/web3adapter/index.ts @@ -20,6 +20,23 @@ export class Web3Adapter { async initialize(): Promise { console.log("Initializing Web3Adapter..."); + // Outbound sync can be switched off for local work. + // + // The watchers replicate every Firestore change to the eVaults named by + // PUBLIC_REGISTRY_URL, which is a shared environment. Running the API + // locally against a real Firestore therefore publishes to it, which is + // rarely what you want while developing and is actively unsafe while a + // change to the wire format is still being rolled out: producers must + // not emit a format consumers have not been deployed to accept yet. + if (process.env.BLABSY_DISABLE_EVAULT_SYNC === "true") { + console.warn( + "⚠️ BLABSY_DISABLE_EVAULT_SYNC=true — Firestore watchers are off, " + + "so no changes will be replicated to eVaults. Inbound webhooks " + + "still work.", + ); + return; + } + // Initialize watchers for each collection const collections = [ { name: "users", type: "user" }, diff --git a/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts b/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts index 96fe22ef3..106d2107d 100644 --- a/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts @@ -206,6 +206,15 @@ export class PostgresSubscriber implements EntitySubscriberInterface { * Process the change and send it to the Web3Adapter */ private async handleChange(entity: any, tableName: string): Promise { + // Outbound sync can be switched off for local work. + // + // Every local write is otherwise replicated to the eVaults named by + // PUBLIC_REGISTRY_URL, which is a shared environment. That is rarely + // wanted while developing, and is actively unsafe while a change to the + // wire format is mid-rollout: a producer must not emit a format that + // consumers have not been deployed to accept yet. + if (process.env.PICTIQUE_DISABLE_EVAULT_SYNC === "true") return; + // Check if this is a junction table if ( tableName === "message_read_status" || From b987508d43f0337f98d0903d5d9e8e79645861ac Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Tue, 8 Sep 2026 11:54:46 +0530 Subject: [PATCH 13/13] Revert "Allow outbound eVault sync to be switched off for local runs" This reverts commit f9b9675bd631ddb4e03302a293a5b356ae5897ad. --- .env.example | 9 --------- platforms/blabsy/api/src/web3adapter/index.ts | 17 ----------------- .../api/src/web3adapter/watchers/subscriber.ts | 9 --------- 3 files changed, 35 deletions(-) diff --git a/.env.example b/.env.example index 8ba76cb21..35429aec9 100644 --- a/.env.example +++ b/.env.example @@ -198,12 +198,3 @@ PPA_MESSENGER_PLATFORM_NAME="meshenger" # Path that opens a conversation with one person. Only used when the messenger # publishes no handle for the User ontology; a declared handle always wins. PPA_MESSENGER_CONTACT_PATH="/contacts/{ename}" - -# Local development safety: keep outbound eVault sync off. -# -# The Blabsy watchers and the Pictique subscriber replicate every local change -# to the eVaults named by PUBLIC_REGISTRY_URL, which is a shared environment. -# Set these while working locally so a dev database does not publish to it. -# Inbound webhooks keep working either way. -BLABSY_DISABLE_EVAULT_SYNC=true -PICTIQUE_DISABLE_EVAULT_SYNC=true diff --git a/platforms/blabsy/api/src/web3adapter/index.ts b/platforms/blabsy/api/src/web3adapter/index.ts index fbd924ae8..cd10ba289 100644 --- a/platforms/blabsy/api/src/web3adapter/index.ts +++ b/platforms/blabsy/api/src/web3adapter/index.ts @@ -20,23 +20,6 @@ export class Web3Adapter { async initialize(): Promise { console.log("Initializing Web3Adapter..."); - // Outbound sync can be switched off for local work. - // - // The watchers replicate every Firestore change to the eVaults named by - // PUBLIC_REGISTRY_URL, which is a shared environment. Running the API - // locally against a real Firestore therefore publishes to it, which is - // rarely what you want while developing and is actively unsafe while a - // change to the wire format is still being rolled out: producers must - // not emit a format consumers have not been deployed to accept yet. - if (process.env.BLABSY_DISABLE_EVAULT_SYNC === "true") { - console.warn( - "⚠️ BLABSY_DISABLE_EVAULT_SYNC=true — Firestore watchers are off, " + - "so no changes will be replicated to eVaults. Inbound webhooks " + - "still work.", - ); - return; - } - // Initialize watchers for each collection const collections = [ { name: "users", type: "user" }, diff --git a/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts b/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts index 106d2107d..96fe22ef3 100644 --- a/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/pictique/api/src/web3adapter/watchers/subscriber.ts @@ -206,15 +206,6 @@ export class PostgresSubscriber implements EntitySubscriberInterface { * Process the change and send it to the Web3Adapter */ private async handleChange(entity: any, tableName: string): Promise { - // Outbound sync can be switched off for local work. - // - // Every local write is otherwise replicated to the eVaults named by - // PUBLIC_REGISTRY_URL, which is a shared environment. That is rarely - // wanted while developing, and is actively unsafe while a change to the - // wire format is mid-rollout: a producer must not emit a format that - // consumers have not been deployed to accept yet. - if (process.env.PICTIQUE_DISABLE_EVAULT_SYNC === "true") return; - // Check if this is a junction table if ( tableName === "message_read_status" ||