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/__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 eb15d8c64..b761bb73a 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 { @@ -14,6 +15,16 @@ export type { UploadFileInput, UploadFileResult, } from "./evault/evault"; +export type { EName } from "./w3ds/ename"; +export { + isEName, + normaliseEName, + normaliseENameList, + toEName, +} from "./w3ds/ename"; +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 @@ -266,6 +277,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(); @@ -302,7 +328,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/ename-directive.test.ts b/infrastructure/web3-adapter/src/mapper/ename-directive.test.ts new file mode 100644 index 000000000..7410a2138 --- /dev/null +++ b/infrastructure/web3-adapter/src/mapper/ename-directive.test.ts @@ -0,0 +1,230 @@ +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/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/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..3cc880802 --- /dev/null +++ b/infrastructure/web3-adapter/src/mapper/shipped-mappings.test.ts @@ -0,0 +1,279 @@ +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"; + +/** + * 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); + }, + ); + + /** + * 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/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/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..a633ca4ba --- /dev/null +++ b/infrastructure/web3-adapter/src/w3ds/group-ownership.test.ts @@ -0,0 +1,112 @@ +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("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 }, + lookup, + ); + expect(group.admins).toEqual(admins); + }); + + 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); + }); + + 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 new file mode 100644 index 000000000..b6f8dbe9c --- /dev/null +++ b/infrastructure/web3-adapter/src/w3ds/group-ownership.ts @@ -0,0 +1,79 @@ +import { toEName } from "./ename"; + +/** + * 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` 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. + * + * 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 + 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)) { + // 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, + ); + + 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; +} + +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 { + 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/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 a76507bd9..f08eed58b 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", @@ -42,11 +43,10 @@ "@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", - "@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/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/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/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/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/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..7719000d1 100644 --- a/platforms/cerberus/client/src/web3adapter/watchers/subscriber.ts +++ b/platforms/cerberus/client/src/web3adapter/watchers/subscriber.ts @@ -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 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..a335064bd 100644 --- a/platforms/dreamsync/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/dreamsync/api/src/web3adapter/watchers/subscriber.ts @@ -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 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..79c121f64 100644 --- a/platforms/ecurrency/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/ecurrency/api/src/web3adapter/watchers/subscriber.ts @@ -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 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..334348d6d 100644 --- a/platforms/ereputation/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/ereputation/api/src/web3adapter/watchers/subscriber.ts @@ -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 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..31301fa28 100644 --- a/platforms/esigner/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/esigner/api/src/web3adapter/watchers/subscriber.ts @@ -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() 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..d53c40616 100644 --- a/platforms/evoting/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/evoting/api/src/web3adapter/watchers/subscriber.ts @@ -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 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..ffe106469 100644 --- a/platforms/file-manager/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/file-manager/api/src/web3adapter/watchers/subscriber.ts @@ -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() 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/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/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/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..aeaa4599c 100644 --- a/platforms/group-charter-manager/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/group-charter-manager/api/src/web3adapter/watchers/subscriber.ts @@ -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 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/platforms/pictique/api/package.json b/platforms/pictique/api/package.json index 593dec804..d07318578 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", @@ -28,17 +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" + "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 e8f67226f..ece3aaa0e 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 @@ -462,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/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..c03b60545 --- /dev/null +++ b/platforms/pictique/api/src/web3adapter/entity-refs.ts @@ -0,0 +1,30 @@ +import { resolveENameRef, resolveENameRefs } from "web3-adapter"; +import type { User } from "../database/entities/User"; +import type { UserService } from "../services/UserService"; + +/** + * 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 { + return resolveENameRefs(refs, (ename) => userService.findByEname(ename), { + context, + }); +} + +export async function resolveEntityRef( + ref: unknown, + userService: UserService, + context: string, +): Promise { + return resolveENameRef(ref, (ename) => userService.findByEname(ename), { + context, + }); +} 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/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 c7e15de67..bed093805 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 @@ -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,24 +1032,24 @@ 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) + 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 + 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: @@ -1117,6 +1114,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 @@ -1155,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) @@ -1225,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 @@ -1457,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 @@ -1497,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 @@ -1557,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 @@ -1600,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 @@ -1890,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 @@ -1930,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 @@ -2063,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 @@ -2091,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 @@ -2207,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 @@ -2249,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 @@ -2289,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 @@ -2548,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 @@ -2591,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 @@ -2643,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 @@ -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 @@ -2718,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 @@ -2761,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 @@ -2987,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 @@ -3033,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 @@ -3085,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 @@ -3103,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 @@ -3148,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 @@ -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(@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@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: @@ -3622,7 +3640,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 @@ -3630,6 +3648,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 @@ -3645,6 +3669,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 @@ -3660,12 +3687,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(@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) platforms/pictique/client: dependencies: @@ -3723,7 +3759,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 +3789,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 +3810,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 +3880,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 +3935,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 +3977,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 +3993,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 +4040,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 +4068,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 +4096,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 +4164,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 +4219,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 +8034,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'} @@ -9056,8 +9095,8 @@ packages: rollup: optional: true - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + '@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 @@ -9895,9 +9934,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 +10512,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 +10735,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 +10883,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 +12481,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 +12593,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 +13228,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 +14118,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 +14360,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 +15976,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 +16465,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 +17249,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 +19332,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 +20155,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 +20168,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 +25255,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 +25269,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 +25290,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 +25304,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 +26403,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 @@ -26234,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 @@ -27259,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: @@ -27279,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 @@ -27295,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 @@ -27303,11 +27520,11 @@ snapshots: optionalDependencies: rollup: 4.59.0 - '@rollup/pluginutils@5.3.0(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 @@ -28075,7 +28292,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 +28300,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 +28318,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 +28326,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 +28379,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 +28401,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 +28844,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 +29496,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 +29761,8 @@ snapshots: '@types/memoizee@0.4.12': {} + '@types/methods@1.1.4': {} + '@types/mime@1.3.5': {} '@types/ms@2.1.0': {} @@ -29653,6 +29934,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 +30519,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 +30558,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 +30687,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 +32200,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 +32317,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 +32406,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 +33025,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: {} @@ -33317,8 +33630,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 +33694,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 +33736,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 +33775,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 +33786,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 @@ -33647,7 +33905,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 @@ -33656,7 +33914,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) @@ -33665,7 +33923,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 @@ -33674,7 +33932,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) @@ -33683,7 +33941,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 @@ -33692,7 +33950,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) @@ -34193,6 +34451,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: {} @@ -34285,6 +34545,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 @@ -34564,6 +34828,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: {} @@ -35784,16 +36054,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 @@ -35803,16 +36073,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 @@ -35824,7 +36094,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 @@ -35841,7 +36111,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 @@ -35867,11 +36137,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 @@ -35897,11 +36167,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 @@ -35927,7 +36197,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 @@ -36415,23 +36685,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 @@ -36440,7 +36710,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) @@ -36830,6 +37100,8 @@ snapshots: optionalDependencies: enquirer: 2.4.1 + load-tsconfig@0.2.5: {} + loader-runner@4.3.1: {} loader-utils@2.0.4: @@ -37671,6 +37943,8 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + mime@3.0.0: {} mimic-fn@2.1.0: {} @@ -38488,6 +38762,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.7: {} + pidtree@0.6.0: {} pify@2.3.0: {} @@ -38723,38 +38999,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: @@ -41037,6 +41313,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 @@ -41062,7 +41360,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.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 @@ -41070,15 +41368,27 @@ 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.3)(svelte@5.53.11)(typescript@5.8.2): + 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.3) + fdir: 6.5.0(picomatch@4.0.7) picocolors: 1.1.1 sade: 1.8.1 svelte: 5.53.11 @@ -41086,11 +41396,11 @@ snapshots: 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 @@ -41114,7 +41424,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 @@ -41125,11 +41435,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 @@ -41140,7 +41450,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 @@ -41370,6 +41680,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 @@ -41473,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: {} @@ -41560,12 +41882,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 @@ -41600,7 +41922,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 @@ -41610,7 +41932,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: @@ -41618,7 +41940,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 @@ -41635,9 +41957,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 @@ -41654,8 +41978,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 @@ -41672,8 +41998,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 @@ -41690,9 +42018,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 @@ -41709,9 +42039,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 @@ -41728,9 +42060,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 @@ -41747,6 +42081,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: @@ -41909,7 +42245,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 @@ -41929,12 +42265,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 @@ -41954,7 +42290,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 @@ -42096,6 +42432,40 @@ 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) + '@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 @@ -42105,8 +42475,30 @@ 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 + 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: @@ -42736,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 @@ -42749,7 +43141,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 @@ -42780,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 @@ -42793,7 +43185,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 @@ -42824,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 @@ -43043,6 +43435,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 diff --git a/services/ontology/schemas/chat.json b/services/ontology/schemas/chat.json index 33e8dfcce..4b09dbd5e 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,86 @@ "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." + }, + "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": [ @@ -56,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 b3bcaa904..edca1753e 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", @@ -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." }