From 40baa600944a5d267c443de06d555b3587a08cca Mon Sep 17 00:00:00 2001 From: coodos Date: Wed, 7 Oct 2026 18:55:57 +0800 Subject: [PATCH 1/3] feat(evault): eVault manifests and vault type in /whois --- .../evault-core/src/core/db/db.service.ts | 117 +++++++- .../evault-core/src/core/db/types.ts | 11 + .../evault-core/src/core/http/server.ts | 34 +++ .../core/manifest/manifest.service.spec.ts | 249 ++++++++++++++++++ .../src/core/manifest/manifest.service.ts | 223 ++++++++++++++++ services/ontology/schemas/company.json | 39 ++- 6 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 infrastructure/evault-core/src/core/manifest/manifest.service.spec.ts create mode 100644 infrastructure/evault-core/src/core/manifest/manifest.service.ts diff --git a/infrastructure/evault-core/src/core/db/db.service.ts b/infrastructure/evault-core/src/core/db/db.service.ts index 84ea81fc6..eeeb056db 100644 --- a/infrastructure/evault-core/src/core/db/db.service.ts +++ b/infrastructure/evault-core/src/core/db/db.service.ts @@ -22,6 +22,8 @@ import type { PageInfo, SearchMetaEnvelopesResult, StoreMetaEnvelopeResult, + VaultConfig, + VaultType, } from "./types"; export interface AwarenessWriteContext { @@ -1720,6 +1722,96 @@ export class DbService { ); } + /** + * Reads an eVault's type and pinned manifest from its per-eName node. + * @param eName - The eName identifier + */ + async getVaultConfig(eName: string): Promise { + if (!eName) { + throw new Error("eName is required for getting vault config"); + } + + const result = await this.runQueryInternal( + `MATCH (u:User { eName: $eName }) + RETURN u.vaultType AS vaultType, u.manifestId AS manifestId, + u.manifestPinnedAt AS manifestPinnedAt`, + { eName }, + ); + + const record = result.records[0]; + if (!record) { + return { vaultType: null, manifestId: null, manifestPinnedAt: null }; + } + const pinnedAt = record.get("manifestPinnedAt"); + return { + vaultType: record.get("vaultType") ?? null, + manifestId: record.get("manifestId") ?? null, + manifestPinnedAt: + typeof pinnedAt?.toNumber === "function" + ? pinnedAt.toNumber() + : (pinnedAt ?? null), + }; + } + + /** + * Pins an eVault's type and manifest unless a manifest is already pinned. + * Setting the lock property first takes the node's write lock before the + * pin is read, so of concurrent callers exactly one wins and every caller + * gets back the config that actually landed. + * @param eName - The eName identifier + * @param vaultType - The type to record + * @param manifestId - The manifest MetaEnvelope id + */ + async pinManifest( + eName: string, + vaultType: VaultType, + manifestId: string, + ): Promise { + if (!eName) { + throw new Error("eName is required for pinning a manifest"); + } + + const result = await this.runQueryInternal( + `MERGE (u:User { eName: $eName }) + ON CREATE SET u.publicKeys = [] + SET u._manifestLock = true + WITH u, u.manifestId IS NULL AS free + FOREACH (_ IN CASE WHEN free THEN [1] ELSE [] END | + SET u.vaultType = $vaultType, + u.manifestId = $manifestId, + u.manifestPinnedAt = $now) + REMOVE u._manifestLock + RETURN free AS won, u.vaultType AS vaultType, + u.manifestId AS manifestId, u.manifestPinnedAt AS manifestPinnedAt`, + { eName, vaultType, manifestId, now: Date.now() }, + ); + + const record = result.records[0]; + const pinnedAt = record.get("manifestPinnedAt"); + return { + won: record.get("won") === true, + vaultType: record.get("vaultType") ?? null, + manifestId: record.get("manifestId") ?? null, + manifestPinnedAt: + typeof pinnedAt?.toNumber === "function" + ? pinnedAt.toNumber() + : (pinnedAt ?? null), + }; + } + + /** + * Clears a pinned manifest, but only if it is still the given id, so a + * stale caller cannot clear a pin someone else just made. + */ + async clearManifest(eName: string, manifestId: string): Promise { + await this.runQueryInternal( + `MATCH (u:User { eName: $eName }) + WHERE u.manifestId = $manifestId + REMOVE u.manifestId, u.manifestPinnedAt`, + { eName, manifestId }, + ); + } + /** * Copies all meta-envelopes and their envelopes from this evault to a target evault instance. * Preserves all IDs (metaEnvelope.id, envelope.id) and the eName property. @@ -1847,12 +1939,35 @@ export class DbService { // Copy User node with public keys if it exists try { const userResult = await this.runQueryInternal( - `MATCH (u:User { eName: $eName }) RETURN u.publicKeys AS publicKeys`, + `MATCH (u:User { eName: $eName }) + RETURN u.publicKeys AS publicKeys, u.vaultType AS vaultType, + u.manifestId AS manifestId, u.manifestPinnedAt AS manifestPinnedAt`, { eName }, ); if (userResult.records.length > 0) { const publicKeys = userResult.records[0].get("publicKeys"); + const vaultType = userResult.records[0].get("vaultType"); + if (vaultType) { + // Keyless vaults (groups) have no keys to copy but still + // carry their type and manifest. + await targetDbService.runQuery( + `MERGE (u:User { eName: $eName }) + ON CREATE SET u.publicKeys = [] + SET u.vaultType = $vaultType, + u.manifestId = $manifestId, + u.manifestPinnedAt = $manifestPinnedAt`, + { + eName, + vaultType, + manifestId: + userResult.records[0].get("manifestId") ?? null, + manifestPinnedAt: + userResult.records[0].get("manifestPinnedAt") ?? + null, + }, + ); + } if ( publicKeys && Array.isArray(publicKeys) && diff --git a/infrastructure/evault-core/src/core/db/types.ts b/infrastructure/evault-core/src/core/db/types.ts index 29af0af28..1e078c335 100644 --- a/infrastructure/evault-core/src/core/db/types.ts +++ b/infrastructure/evault-core/src/core/db/types.ts @@ -215,3 +215,14 @@ export type FindMetaEnvelopesPaginatedOptions = { last?: number; before?: string; }; + +/** What kind of subject an eVault belongs to. */ +export type VaultType = "user" | "group" | "company"; + +/** An eVault's type and pinned manifest, kept on its per-eName node. */ +export type VaultConfig = { + vaultType: VaultType | null; + manifestId: string | null; + /** Epoch ms when the manifest was pinned. */ + manifestPinnedAt: number | null; +}; diff --git a/infrastructure/evault-core/src/core/http/server.ts b/infrastructure/evault-core/src/core/http/server.ts index 34614ca1a..0ea9d725c 100644 --- a/infrastructure/evault-core/src/core/http/server.ts +++ b/infrastructure/evault-core/src/core/http/server.ts @@ -10,6 +10,10 @@ import type { } from "../../services/ProvisioningService"; import { DbService } from "../db/db.service"; import { ProtectedZoneService } from "../db/protected-zone.service"; +import { + ManifestService, + type ResolvedManifest, +} from "../manifest/manifest.service"; import { connectWithRetry } from "../db/retry-neo4j"; import { validatePassphraseStrength } from "../utils/passphrase"; import { getProvisionerJwk } from "../utils/provisioner-signer"; @@ -95,6 +99,8 @@ export async function registerHttpRoutes( }, ); + const manifestService = dbService ? new ManifestService(dbService) : null; + // Whois endpoint - returns both W3ID identifier and public key server.get( "/whois", @@ -119,6 +125,21 @@ export async function registerHttpRoutes( type: "array", items: { type: "string" }, }, + type: { + type: ["string", "null"], + enum: ["user", "group", "company", null], + }, + manifest: { + type: ["object", "null"], + properties: { + id: { type: "string" }, + ontology: { type: "string" }, + parsed: { + type: "object", + additionalProperties: true, + }, + }, + }, }, }, 400: { @@ -237,10 +258,23 @@ export async function registerHttpRoutes( } } } + // The manifest is public by definition: it is served here + // regardless of its ACL, which still governs GraphQL reads. + let resolved: ResolvedManifest = { type: null, manifest: null }; + if (manifestService) { + try { + resolved = await manifestService.resolve(eName); + } catch (error) { + console.error("Error resolving eVault manifest:", error); + } + } + const result = { w3id: eName, evaultId, keyBindingCertificates: keyBindingCertificates, + type: resolved.type, + manifest: resolved.manifest, }; return result; }, diff --git a/infrastructure/evault-core/src/core/manifest/manifest.service.spec.ts b/infrastructure/evault-core/src/core/manifest/manifest.service.spec.ts new file mode 100644 index 000000000..72dfee259 --- /dev/null +++ b/infrastructure/evault-core/src/core/manifest/manifest.service.spec.ts @@ -0,0 +1,249 @@ +import type { Driver } from "neo4j-driver"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + setupTestNeo4j, + teardownTestNeo4j, +} from "../../test-utils/neo4j-setup"; +import { DbService } from "../db/db.service"; +import { + CHAT_ONTOLOGY, + COMPANY_ONTOLOGY, + GROUP_MANIFEST_ONTOLOGY, + ManifestService, + USER_PROFILE_ONTOLOGY, +} from "./manifest.service"; + +describe("ManifestService", () => { + let driver: Driver; + let db: DbService; + let manifests: ManifestService; + + beforeAll(async () => { + const setup = await setupTestNeo4j(); + driver = setup.driver; + db = new DbService(driver); + manifests = new ManifestService(db); + }, 120000); + + afterAll(async () => { + await teardownTestNeo4j(); + }); + + const store = async ( + vault: string, + ontology: string, + payload: Record, + ): Promise => { + const result = await db.storeMetaEnvelope( + { ontology, payload, acl: ["@someone-else"] }, + ["@someone-else"], + vault, + ); + return result.metaEnvelope.id; + }; + + const countOf = async (vault: string, ontology: string) => + (await db.findMetaEnvelopesByOntology(ontology, vault)).length; + + describe("user vaults", () => { + it("pins the UserProfile of a keyed vault and serves it despite its ACL", async () => { + const vault = "@keyed-user"; + await db.addPublicKey(vault, "z-key"); + const id = await store(vault, USER_PROFILE_ONTOLOGY, { + ename: vault, + username: "keyed", + displayName: "Keyed", + }); + + const resolved = await manifests.resolve(vault); + + expect(resolved.type).toBe("user"); + expect(resolved.manifest?.id).toBe(id); + expect(resolved.manifest?.parsed.displayName).toBe("Keyed"); + expect((await db.getVaultConfig(vault)).manifestId).toBe(id); + }); + + it("treats a keyless vault without group records as a user", async () => { + const vault = "@keyless-user"; + const id = await store(vault, USER_PROFILE_ONTOLOGY, { + ename: vault, + username: "keyless", + }); + + const resolved = await manifests.resolve(vault); + + expect(resolved.type).toBe("user"); + expect(resolved.manifest?.id).toBe(id); + }); + + it("ignores profiles that name someone else and picks the earliest self-naming one", async () => { + const vault = "@many-profiles"; + await store(vault, USER_PROFILE_ONTOLOGY, { + ename: "@a-contact", + createdAt: "2020-01-01T00:00:00.000Z", + }); + await store(vault, USER_PROFILE_ONTOLOGY, { + ename: vault, + createdAt: "2024-06-01T00:00:00.000Z", + }); + const earliest = await store(vault, USER_PROFILE_ONTOLOGY, { + ename: vault, + createdAt: "2023-01-01T00:00:00.000Z", + }); + + expect((await manifests.resolve(vault)).manifest?.id).toBe( + earliest, + ); + }); + + it("stays pinned once a later profile appears", async () => { + const vault = "@stable-user"; + const first = await store(vault, USER_PROFILE_ONTOLOGY, { + ename: vault, + createdAt: "2024-01-01T00:00:00.000Z", + }); + await manifests.resolve(vault); + await store(vault, USER_PROFILE_ONTOLOGY, { + ename: vault, + createdAt: "2000-01-01T00:00:00.000Z", + }); + + expect((await manifests.resolve(vault)).manifest?.id).toBe(first); + }); + + it("returns no manifest and writes nothing for an unknown eName", async () => { + const resolved = await manifests.resolve("@nobody-here"); + + expect(resolved).toEqual({ type: "user", manifest: null }); + const nodes = await db.runQuery( + "MATCH (u:User { eName: $e }) RETURN count(u) AS n", + { e: "@nobody-here" }, + ); + expect(nodes.records[0].get("n").toNumber()).toBe(0); + }); + }); + + describe("group vaults", () => { + it("pins an existing GroupManifest", async () => { + const vault = "@group-with-manifest"; + const id = await store(vault, GROUP_MANIFEST_ONTOLOGY, { + eName: vault, + name: "Ops", + members: ["@alice"], + admins: ["@alice"], + owner: "@alice", + }); + + const resolved = await manifests.resolve(vault); + + expect(resolved.type).toBe("group"); + expect(resolved.manifest?.id).toBe(id); + }); + + it("builds a GroupManifest once from an old group's Chat", async () => { + const vault = "@old-group"; + await store(vault, CHAT_ONTOLOGY, { + ename: vault, + name: "Book club", + participantIds: ["@alice", "@bob"], + admins: ["@alice"], + }); + + const resolved = await manifests.resolve(vault); + + expect(resolved.type).toBe("group"); + expect(resolved.manifest?.ontology).toBe(GROUP_MANIFEST_ONTOLOGY); + expect(resolved.manifest?.parsed).toMatchObject({ + eName: vault, + name: "Book club", + admins: ["@alice"], + owner: vault, + }); + expect( + [...(resolved.manifest?.parsed.members as string[])].sort(), + ).toEqual(["@alice", "@bob"]); + + await manifests.resolve(vault); + expect(await countOf(vault, GROUP_MANIFEST_ONTOLOGY)).toBe(1); + }); + + it("writes a single GroupManifest under concurrent lookups", async () => { + const vault = "@racing-group"; + await store(vault, CHAT_ONTOLOGY, { + ename: vault, + name: "Race", + participantIds: ["@alice"], + }); + + const results = await Promise.all( + Array.from({ length: 5 }, () => manifests.resolve(vault)), + ); + + expect(await countOf(vault, GROUP_MANIFEST_ONTOLOGY)).toBe(1); + const pinned = (await db.getVaultConfig(vault)).manifestId; + for (const r of results) { + expect(r.type).toBe("group"); + if (r.manifest) expect(r.manifest.id).toBe(pinned); + } + }); + + it("does not treat a keyed vault holding a self-naming Chat as a group", async () => { + const vault = "@keyed-with-chat"; + await db.addPublicKey(vault, "z-key"); + await store(vault, CHAT_ONTOLOGY, { ename: vault, name: "x" }); + + expect((await manifests.resolve(vault)).type).toBe("user"); + }); + }); + + describe("company vaults", () => { + it("prefers the Company record over an earlier GroupManifest", async () => { + const vault = "@acme"; + const groupManifest = await store(vault, GROUP_MANIFEST_ONTOLOGY, { + eName: vault, + name: "Acme", + members: [], + admins: [], + owner: "@founder", + createdAt: "2020-01-01T00:00:00.000Z", + }); + const company = await store(vault, COMPANY_ONTOLOGY, { + id: "acme", + eName: vault, + groupManifestEnvelopeId: groupManifest, + legalName: "Acme Ltd", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }); + + const resolved = await manifests.resolve(vault); + + expect(resolved.type).toBe("company"); + expect(resolved.manifest?.id).toBe(company); + expect(resolved.manifest?.parsed.legalName).toBe("Acme Ltd"); + }); + }); + + describe("deleted manifests", () => { + it("re-resolves when the pinned record is gone", async () => { + const vault = "@deleted-pin"; + const first = await store(vault, USER_PROFILE_ONTOLOGY, { + ename: vault, + createdAt: "2020-01-01T00:00:00.000Z", + }); + const second = await store(vault, USER_PROFILE_ONTOLOGY, { + ename: vault, + createdAt: "2021-01-01T00:00:00.000Z", + }); + await manifests.resolve(vault); + await db.deleteMetaEnvelope(first, vault); + // Age the pin past the grace window. + await db.runQuery( + "MATCH (u:User { eName: $e }) SET u.manifestPinnedAt = 0", + { e: vault }, + ); + + expect((await manifests.resolve(vault)).manifest?.id).toBe(second); + }); + }); +}); diff --git a/infrastructure/evault-core/src/core/manifest/manifest.service.ts b/infrastructure/evault-core/src/core/manifest/manifest.service.ts new file mode 100644 index 000000000..5a2143d75 --- /dev/null +++ b/infrastructure/evault-core/src/core/manifest/manifest.service.ts @@ -0,0 +1,223 @@ +import { W3IDBuilder } from "w3id"; +import { GroupMembershipService } from "../acl/group-membership.service"; +import type { DbService } from "../db/db.service"; +import type { VaultType } from "../db/types"; + +export const USER_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; +export const CHAT_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440003"; +export const GROUP_MANIFEST_ONTOLOGY = "a8bfb7cf-3200-4b25-9ea9-ee41100f212e"; +export const COMPANY_ONTOLOGY = "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402"; + +/** The ontology an eVault's manifest must have, by the vault's type. */ +export const MANIFEST_ONTOLOGIES: Record = { + user: USER_PROFILE_ONTOLOGY, + group: GROUP_MANIFEST_ONTOLOGY, + company: COMPANY_ONTOLOGY, +}; + +/** Platforms spelled the self-naming field both ways. */ +const ENAME_FIELDS = ["ename", "eName"]; + +/** + * How long a pin may point at a GroupManifest that is still being written + * before it is treated as broken and re-resolved. + */ +const PIN_GRACE_MS = 30_000; + +export type Manifest = { + id: string; + ontology: string; + parsed: Record; +}; + +export type ResolvedManifest = { + type: VaultType | null; + manifest: Manifest | null; +}; + +/** + * Resolves the manifest of an eVault: the record that says who the vault + * belongs to (a UserProfile, GroupManifest or Company). + * + * The manifest is pinned lazily, the first time it is asked for. The vault's + * type is decided first, then the earliest record of that type's ontology + * naming the vault itself becomes the manifest and stays pinned. + */ +export class ManifestService { + private groups: GroupMembershipService; + + constructor(private db: DbService) { + this.groups = new GroupMembershipService(db); + } + + async resolve(eName: string): Promise { + const config = await this.db.getVaultConfig(eName); + + if (config.manifestId) { + const manifest = await this.load(config.manifestId, eName); + if (manifest) return { type: config.vaultType, manifest }; + + const pinnedAt = config.manifestPinnedAt ?? 0; + if (Date.now() - pinnedAt < PIN_GRACE_MS) { + // Another caller claimed the pin and is still writing it. + return { type: config.vaultType, manifest: null }; + } + await this.db.clearManifest(eName, config.manifestId); + } + + const type = await this.inferType(eName); + const candidate = await this.earliestSelfNaming( + eName, + MANIFEST_ONTOLOGIES[type], + ); + + if (!candidate) { + if (type === "group") return this.createGroupManifest(eName); + // Nothing to pin yet; writing the type alone would leave a node + // behind for every eName anyone looks up. + return { type, manifest: null }; + } + + const pinned = await this.db.pinManifest(eName, type, candidate); + return { + type: pinned.vaultType, + manifest: pinned.manifestId + ? await this.load(pinned.manifestId, eName) + : null, + }; + } + + /** + * A company names itself in a Company record. A group vault is keyless and + * holds a Chat or GroupManifest naming itself. Anything else is a user, + * keyed or not. + */ + async inferType(eName: string): Promise { + const result = await this.db.runQuery( + ` + OPTIONAL MATCH (u:User { eName: $eName }) + WITH coalesce(size(u.publicKeys), 0) = 0 AS keyless + RETURN keyless, + EXISTS { + MATCH (m:MetaEnvelope { eName: $eName, ontology: $company })-[:LINKS_TO]->(n:Envelope) + WHERE n.ontology IN $enameFields AND n.value = $eName + } AS isCompany, + EXISTS { + MATCH (m:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(n:Envelope) + WHERE m.ontology IN $groupOntologies + AND n.ontology IN $enameFields AND n.value = $eName + } AS hasGroupRecord + `, + { + eName, + company: COMPANY_ONTOLOGY, + groupOntologies: [CHAT_ONTOLOGY, GROUP_MANIFEST_ONTOLOGY], + enameFields: ENAME_FIELDS, + }, + ); + + const record = result.records[0]; + if (record.get("isCompany")) return "company"; + if (record.get("keyless") && record.get("hasGroupRecord")) { + return "group"; + } + return "user"; + } + + /** + * The earliest record of an ontology in the vault that names the vault. + * + * Records written before version history existed have no history node and + * are older than any that do, so they sort first; within each group the + * record's own `createdAt` and then its history timestamp break ties. + */ + private async earliestSelfNaming( + eName: string, + ontology: string, + ): Promise { + const result = await this.db.runQuery( + ` + MATCH (m:MetaEnvelope { eName: $eName, ontology: $ontology }) + WHERE EXISTS { + MATCH (m)-[:LINKS_TO]->(n:Envelope) + WHERE n.ontology IN $enameFields AND n.value = $eName + } + OPTIONAL MATCH (h:MetaEnvelopeHistory { metaEnvelopeId: m.id, eName: $eName }) + OPTIONAL MATCH (m)-[:LINKS_TO]->(c:Envelope { ontology: "createdAt" }) + RETURN m.id AS id + ORDER BY h IS NOT NULL, c.value, h.createdAt, m.id + LIMIT 1 + `, + { eName, ontology, enameFields: ENAME_FIELDS }, + ); + return result.records[0]?.get("id") ?? null; + } + + /** + * Older group vaults only hold a Chat. A GroupManifest is built from it + * once: the pin is claimed with a fresh id first, so concurrent callers + * cannot both write one, and only the winner stores the record. + */ + private async createGroupManifest( + eName: string, + ): Promise { + const id = (await new W3IDBuilder().build()).id; + const pinned = await this.db.pinManifest(eName, "group", id); + if (!pinned.won) { + return { + type: pinned.vaultType, + manifest: pinned.manifestId + ? await this.load(pinned.manifestId, eName) + : null, + }; + } + + try { + const payload = await this.groupManifestFromChat(eName); + await this.db.storeMetaEnvelopeWithId( + { ontology: GROUP_MANIFEST_ONTOLOGY, payload, acl: ["*"] }, + ["*"], + eName, + id, + ); + } catch (error) { + await this.db.clearManifest(eName, id); + throw error; + } + return { type: "group", manifest: await this.load(id, eName) }; + } + + private async groupManifestFromChat( + eName: string, + ): Promise> { + const chatId = await this.earliestSelfNaming(eName, CHAT_ONTOLOGY); + const chat = chatId ? await this.load(chatId, eName) : null; + const fields = chat?.parsed ?? {}; + + const admins = Array.isArray(fields.admins) + ? fields.admins.filter((a): a is string => typeof a === "string") + : []; + const owner = typeof fields.owner === "string" ? fields.owner : eName; + const now = new Date().toISOString(); + + const manifest: Record = { + eName, + name: typeof fields.name === "string" ? fields.name : eName, + members: await this.groups.membersOf(eName), + admins, + owner, + createdAt: now, + updatedAt: now, + }; + for (const key of ["description", "avatar", "charter"]) { + if (typeof fields[key] === "string") manifest[key] = fields[key]; + } + return manifest; + } + + private async load(id: string, eName: string): Promise { + const found = await this.db.findMetaEnvelopeById(id, eName); + if (!found) return null; + return { id: found.id, ontology: found.ontology, parsed: found.parsed }; + } +} diff --git a/services/ontology/schemas/company.json b/services/ontology/schemas/company.json index 54786c052..227d69ec7 100644 --- a/services/ontology/schemas/company.json +++ b/services/ontology/schemas/company.json @@ -3,6 +3,7 @@ "schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402", "title": "Company", "domain": "work", + "description": "A company's profile. The earliest Company record naming its own eVault is that eVault's manifest.", "type": "object", "properties": { "id": { @@ -17,6 +18,43 @@ "type": "string", "description": "MetaEnvelope ID of the canonical GroupManifest" }, + "legalName": { + "type": "string", + "description": "Registered legal name" + }, + "displayName": { + "type": "string", + "description": "Name shown to people" + }, + "description": { + "type": "string" + }, + "logo": { + "type": "string", + "description": "Logo URL or w3ds://file URI" + }, + "website": { + "type": "string", + "format": "uri" + }, + "email": { + "type": "string", + "format": "email" + }, + "telephone": { + "type": "string" + }, + "address": { + "type": "string" + }, + "registrationNumber": { + "type": "string", + "description": "Company registration number in its jurisdiction" + }, + "jurisdiction": { + "type": "string", + "description": "Jurisdiction of registration, e.g. an ISO 3166 code" + }, "createdAt": { "type": "string", "format": "date-time" @@ -29,7 +67,6 @@ "required": [ "id", "eName", - "groupManifestEnvelopeId", "createdAt", "updatedAt" ], From 8661c356c2cae31806bafe0385391bb402c352af Mon Sep 17 00:00:00 2001 From: coodos Date: Wed, 7 Oct 2026 19:04:02 +0800 Subject: [PATCH 2/3] docs(evault): document /whois type and manifest --- docs/docs/Infrastructure/eVault.md | 27 ++++++++++++++++++++++++--- skills/w3ds/reference/evault.md | 12 +++++++++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index 1694cdfc0..5b7811caa 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -587,7 +587,7 @@ The following queries and mutations are preserved for backward compatibility but ### /whois -Get information about a W3ID, including key binding certificates. +Get information about a W3ID: key binding certificates, the eVault type and its manifest. **Request**: ```http @@ -604,13 +604,34 @@ X-ENAME: @user-a.w3id "keyBindingCertificates": [ "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...", "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..." - ] + ], + "type": "user", + "manifest": { + "id": "@a1b2c3d4-...", + "ontology": "550e8400-e29b-41d4-a716-446655440000", + "parsed": { "ename": "@user-a.w3id", "username": "alice", "displayName": "Alice" } + } } ``` - `w3id`: The W3ID (eName) from the request. - `evaultId`: The eVault instance identifier (when configured via `EVAULT_ID`). Matches the `evault` value registered with the Registry. - `keyBindingCertificates`: JWTs binding the eName to public keys. +- `type`: `user`, `group` or `company`, or `null` when nothing is known yet. +- `manifest`: The record describing who the eVault belongs to, or `null` if there is none yet. Always public via `/whois`, whatever its ACL; GraphQL reads still follow the ACL. + +#### eVault manifests + +The type is decided first, then the manifest is the **earliest** record of that type's ontology whose `ename` / `eName` field is the eVault's own eName: + +| Type | Detected when | Manifest ontology | +|------|---------------|-------------------| +| `company` | a Company record names the vault | Company (`0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402`) | +| `group` | the vault is keyless and a Chat or GroupManifest names it | GroupManifest (`a8bfb7cf-3200-4b25-9ea9-ee41100f212e`) | +| `user` | otherwise, keyed or keyless | UserProfile (`550e8400-e29b-41d4-a716-446655440000`) | + +- Pinned lazily on the first `/whois`, stored on the eVault's Neo4j `User` node (`vaultType`, `manifestId`), and never changed afterwards. If the pinned record is deleted, the next `/whois` re-resolves it. +- A group with only a Chat gets a GroupManifest built from it once (name, members, admins; owner defaults to the group's eName). **Example**: ```bash @@ -618,7 +639,7 @@ curl -X GET http://localhost:4000/whois \ -H "X-ENAME: @user-a.w3id" ``` -**Use Case**: Platforms use this endpoint to retrieve public keys for signature verification and the eVault instance id for routing or auditing. +**Use Case**: Platforms use this endpoint to retrieve public keys for signature verification and the eVault instance id for routing or auditing, and to learn who an eVault belongs to. ### /logs diff --git a/skills/w3ds/reference/evault.md b/skills/w3ds/reference/evault.md index 5a0cbbd9a..c6135db14 100644 --- a/skills/w3ds/reference/evault.md +++ b/skills/w3ds/reference/evault.md @@ -258,12 +258,22 @@ Returns: { "w3id": "@user-a.w3id", "evaultId": "@evault-identifier", - "keyBindingCertificates": ["eyJhbGciOiJFUzI1NiIs...", "..."] + "keyBindingCertificates": ["eyJhbGciOiJFUzI1NiIs...", "..."], + "type": "user", + "manifest": { "id": "@a1b2c3d4-...", "ontology": "550e8400-e29b-41d4-a716-446655440000", "parsed": { "ename": "@user-a.w3id", "username": "alice" } } } ``` Certificates are ES256 JWTs signed by the Registry. Payload: `{ ename, publicKey, exp, iat }`. Valid 1 hour. Verify signature verification recipe in [protocols.md](protocols.md). +`type` and `manifest` (either may be `null`): + +- `company`: a Company record names the vault. Manifest: that Company record. +- `group`: keyless vault with a Chat or GroupManifest naming it. Manifest: GroupManifest, built once from the Chat if missing. +- `user`: everything else. Manifest: UserProfile. +- Manifest = earliest such record whose `ename`/`eName` is the vault, pinned on first `/whois`, re-resolved only if deleted. +- Public via `/whois` regardless of ACL; GraphQL still enforces the ACL. + ### GET /logs Paginated envelope operation log. Query params: `limit` (default 20, max 100), `cursor`. From 333066601d8795dddbacb4d81498e1d604a56ae4 Mon Sep 17 00:00:00 2001 From: coodos Date: Wed, 7 Oct 2026 19:40:16 +0800 Subject: [PATCH 3/3] fix(evault): use the deployed GroupManifest ontology, unique User eName, adminIds --- docs/docs/Infrastructure/eVault.md | 4 +- .../src/core/acl/group-membership.service.ts | 2 +- .../migrations/add-user-ename-constraint.ts | 39 ++++++ .../src/core/db/migrations/add-user-index.ts | 34 ------ .../core/manifest/manifest.service.spec.ts | 111 ++++++++++++++---- .../src/core/manifest/manifest.service.ts | 97 +++++++++++---- infrastructure/evault-core/src/index.ts | 21 ++-- skills/w3ds/reference/evault.md | 2 +- 8 files changed, 220 insertions(+), 90 deletions(-) create mode 100644 infrastructure/evault-core/src/core/db/migrations/add-user-ename-constraint.ts delete mode 100644 infrastructure/evault-core/src/core/db/migrations/add-user-index.ts diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index 5b7811caa..76a3524c5 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -627,11 +627,11 @@ The type is decided first, then the manifest is the **earliest** record of that | Type | Detected when | Manifest ontology | |------|---------------|-------------------| | `company` | a Company record names the vault | Company (`0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402`) | -| `group` | the vault is keyless and a Chat or GroupManifest names it | GroupManifest (`a8bfb7cf-3200-4b25-9ea9-ee41100f212e`) | +| `group` | the vault is keyless and a Chat or GroupManifest names it | GroupManifest (`550e8400-e29b-41d4-a716-446655440003`, shared with Chat: only records with `members` and `owner` count; legacy `a8bfb7cf-3200-4b25-9ea9-ee41100f212e` as fallback) | | `user` | otherwise, keyed or keyless | UserProfile (`550e8400-e29b-41d4-a716-446655440000`) | - Pinned lazily on the first `/whois`, stored on the eVault's Neo4j `User` node (`vaultType`, `manifestId`), and never changed afterwards. If the pinned record is deleted, the next `/whois` re-resolves it. -- A group with only a Chat gets a GroupManifest built from it once (name, members, admins; owner defaults to the group's eName). +- A group with only a Chat gets a GroupManifest built from it once (name, members, admins including `adminIds`; owner defaults to the group's eName). **Example**: ```bash diff --git a/infrastructure/evault-core/src/core/acl/group-membership.service.ts b/infrastructure/evault-core/src/core/acl/group-membership.service.ts index e20c39caa..c7740e252 100644 --- a/infrastructure/evault-core/src/core/acl/group-membership.service.ts +++ b/infrastructure/evault-core/src/core/acl/group-membership.service.ts @@ -105,7 +105,7 @@ export class GroupMembershipService implements GroupResolver { * same profile syncs into several vaults, so the vault only identifies the * subject when the record does not say so itself. */ - private async enamesForProfileIds(ids: string[]): Promise { + async enamesForProfileIds(ids: string[]): Promise { if (ids.length === 0) return []; const result = await this.db.runQuery( diff --git a/infrastructure/evault-core/src/core/db/migrations/add-user-ename-constraint.ts b/infrastructure/evault-core/src/core/db/migrations/add-user-ename-constraint.ts new file mode 100644 index 000000000..9146fd9ef --- /dev/null +++ b/infrastructure/evault-core/src/core/db/migrations/add-user-ename-constraint.ts @@ -0,0 +1,39 @@ +/** + * Neo4j Migration: make User.eName unique + * + * The per-eName User node is the lock eVault manifests are pinned under, and + * a concurrent MERGE only guarantees uniqueness when a constraint backs it. + * Duplicate nodes left by earlier races are folded into one first, the old + * plain index is dropped, and the constraint (which brings its own index) + * replaces it. + */ + +import type { Driver } from "neo4j-driver"; + +export async function createUserENameConstraint(driver: Driver): Promise { + const session = driver.session(); + try { + await session.run( + `MATCH (u:User) + WHERE u.eName IS NOT NULL + WITH u.eName AS eName, collect(u) AS nodes + WHERE size(nodes) > 1 + WITH head(nodes) AS keep, tail(nodes) AS dupes + UNWIND dupes AS d + SET keep.publicKeys = reduce( + acc = coalesce(keep.publicKeys, []), + k IN coalesce(d.publicKeys, []) | + CASE WHEN k IN acc THEN acc ELSE acc + k END), + keep.vaultType = coalesce(keep.vaultType, d.vaultType), + keep.manifestId = coalesce(keep.manifestId, d.manifestId), + keep.manifestPinnedAt = coalesce(keep.manifestPinnedAt, d.manifestPinnedAt) + DETACH DELETE d`, + ); + await session.run(`DROP INDEX user_ename_index IF EXISTS`); + await session.run( + `CREATE CONSTRAINT user_ename_unique IF NOT EXISTS FOR (u:User) REQUIRE u.eName IS UNIQUE`, + ); + } finally { + await session.close(); + } +} diff --git a/infrastructure/evault-core/src/core/db/migrations/add-user-index.ts b/infrastructure/evault-core/src/core/db/migrations/add-user-index.ts deleted file mode 100644 index c2040948a..000000000 --- a/infrastructure/evault-core/src/core/db/migrations/add-user-index.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Neo4j Migration: Add User node index for eName property - * - * This migration creates an index on the eName property of User nodes - * to ensure optimal query performance for public key lookups. - * - * Run this migration once to create the index: - * ```cypher - * CREATE INDEX user_ename_index FOR (u:User) ON (u.eName) - * ``` - */ - -import { Driver } from "neo4j-driver"; - -export async function createUserIndex(driver: Driver): Promise { - const session = driver.session(); - try { - await session.run( - `CREATE INDEX user_ename_index IF NOT EXISTS FOR (u:User) ON (u.eName)` - ); - console.log("Created User eName index"); - } catch (error) { - // Index might already exist, which is fine - if (error instanceof Error && error.message.includes("already exists")) { - console.log("User eName index already exists"); - } else { - console.error("Error creating User eName index:", error); - throw error; - } - } finally { - await session.close(); - } -} - diff --git a/infrastructure/evault-core/src/core/manifest/manifest.service.spec.ts b/infrastructure/evault-core/src/core/manifest/manifest.service.spec.ts index 72dfee259..5bf4228e0 100644 --- a/infrastructure/evault-core/src/core/manifest/manifest.service.spec.ts +++ b/infrastructure/evault-core/src/core/manifest/manifest.service.spec.ts @@ -5,10 +5,11 @@ import { teardownTestNeo4j, } from "../../test-utils/neo4j-setup"; import { DbService } from "../db/db.service"; +import { createUserENameConstraint } from "../db/migrations/add-user-ename-constraint"; import { - CHAT_ONTOLOGY, COMPANY_ONTOLOGY, - GROUP_MANIFEST_ONTOLOGY, + GROUP_ONTOLOGY, + LEGACY_GROUP_MANIFEST_ONTOLOGY, ManifestService, USER_PROFILE_ONTOLOGY, } from "./manifest.service"; @@ -23,6 +24,14 @@ describe("ManifestService", () => { driver = setup.driver; db = new DbService(driver); manifests = new ManifestService(db); + + // Duplicates an earlier race could have left, for the migration test. + await db.runQuery( + `CREATE (:User { eName: "@dup", publicKeys: ["k1"] }), + (:User { eName: "@dup", publicKeys: ["k2", "k1"], vaultType: "user", manifestId: "@m" })`, + {}, + ); + await createUserENameConstraint(driver); }, 120000); afterAll(async () => { @@ -42,8 +51,28 @@ describe("ManifestService", () => { return result.metaEnvelope.id; }; - const countOf = async (vault: string, ontology: string) => - (await db.findMetaEnvelopesByOntology(ontology, vault)).length; + /** GroupManifests in a vault, told apart from Chats sharing their ontology. */ + const groupManifestsIn = async (vault: string) => + (await db.findMetaEnvelopesByOntology(GROUP_ONTOLOGY, vault)).filter( + (m) => + Array.isArray(m.parsed.members) && + typeof m.parsed.owner === "string", + ); + + describe("User eName constraint migration", () => { + it("folds duplicate User nodes into one and keeps their fields", async () => { + const result = await db.runQuery( + "MATCH (u:User { eName: '@dup' }) RETURN u.publicKeys AS keys, u.manifestId AS manifestId", + {}, + ); + expect(result.records).toHaveLength(1); + expect([...result.records[0].get("keys")].sort()).toEqual([ + "k1", + "k2", + ]); + expect(result.records[0].get("manifestId")).toBe("@m"); + }); + }); describe("user vaults", () => { it("pins the UserProfile of a keyed vault and serves it despite its ACL", async () => { @@ -124,9 +153,41 @@ describe("ManifestService", () => { }); describe("group vaults", () => { - it("pins an existing GroupManifest", async () => { + it("pins the GroupManifest createGroupEVault writes, not the Chat beside it", async () => { const vault = "@group-with-manifest"; - const id = await store(vault, GROUP_MANIFEST_ONTOLOGY, { + await store(vault, GROUP_ONTOLOGY, { + ename: vault, + name: "Ops chat", + participantIds: ["@alice"], + createdAt: "2020-01-01T00:00:00.000Z", + }); + const id = await store(vault, GROUP_ONTOLOGY, { + eName: vault, + name: "Ops", + members: ["@alice"], + admins: ["@alice"], + owner: "@alice", + createdAt: "2024-01-01T00:00:00.000Z", + }); + await store(vault, LEGACY_GROUP_MANIFEST_ONTOLOGY, { + eName: vault, + name: "Older legacy", + members: [], + admins: [], + owner: "@alice", + createdAt: "2019-01-01T00:00:00.000Z", + }); + + const resolved = await manifests.resolve(vault); + + expect(resolved.type).toBe("group"); + expect(resolved.manifest?.id).toBe(id); + expect(await groupManifestsIn(vault)).toHaveLength(1); + }); + + it("falls back to a legacy GroupManifest", async () => { + const vault = "@group-with-legacy-manifest"; + const id = await store(vault, LEGACY_GROUP_MANIFEST_ONTOLOGY, { eName: vault, name: "Ops", members: ["@alice"], @@ -142,17 +203,21 @@ describe("ManifestService", () => { it("builds a GroupManifest once from an old group's Chat", async () => { const vault = "@old-group"; - await store(vault, CHAT_ONTOLOGY, { + const alice = await store("@alice", USER_PROFILE_ONTOLOGY, { + ename: "@alice", + username: "alice", + }); + await store(vault, GROUP_ONTOLOGY, { ename: vault, name: "Book club", participantIds: ["@alice", "@bob"], - admins: ["@alice"], + adminIds: [alice], }); const resolved = await manifests.resolve(vault); expect(resolved.type).toBe("group"); - expect(resolved.manifest?.ontology).toBe(GROUP_MANIFEST_ONTOLOGY); + expect(resolved.manifest?.ontology).toBe(GROUP_ONTOLOGY); expect(resolved.manifest?.parsed).toMatchObject({ eName: vault, name: "Book club", @@ -164,12 +229,12 @@ describe("ManifestService", () => { ).toEqual(["@alice", "@bob"]); await manifests.resolve(vault); - expect(await countOf(vault, GROUP_MANIFEST_ONTOLOGY)).toBe(1); + expect(await groupManifestsIn(vault)).toHaveLength(1); }); it("writes a single GroupManifest under concurrent lookups", async () => { const vault = "@racing-group"; - await store(vault, CHAT_ONTOLOGY, { + await store(vault, GROUP_ONTOLOGY, { ename: vault, name: "Race", participantIds: ["@alice"], @@ -179,7 +244,7 @@ describe("ManifestService", () => { Array.from({ length: 5 }, () => manifests.resolve(vault)), ); - expect(await countOf(vault, GROUP_MANIFEST_ONTOLOGY)).toBe(1); + expect(await groupManifestsIn(vault)).toHaveLength(1); const pinned = (await db.getVaultConfig(vault)).manifestId; for (const r of results) { expect(r.type).toBe("group"); @@ -190,7 +255,7 @@ describe("ManifestService", () => { it("does not treat a keyed vault holding a self-naming Chat as a group", async () => { const vault = "@keyed-with-chat"; await db.addPublicKey(vault, "z-key"); - await store(vault, CHAT_ONTOLOGY, { ename: vault, name: "x" }); + await store(vault, GROUP_ONTOLOGY, { ename: vault, name: "x" }); expect((await manifests.resolve(vault)).type).toBe("user"); }); @@ -199,14 +264,18 @@ describe("ManifestService", () => { describe("company vaults", () => { it("prefers the Company record over an earlier GroupManifest", async () => { const vault = "@acme"; - const groupManifest = await store(vault, GROUP_MANIFEST_ONTOLOGY, { - eName: vault, - name: "Acme", - members: [], - admins: [], - owner: "@founder", - createdAt: "2020-01-01T00:00:00.000Z", - }); + const groupManifest = await store( + vault, + LEGACY_GROUP_MANIFEST_ONTOLOGY, + { + eName: vault, + name: "Acme", + members: [], + admins: [], + owner: "@founder", + createdAt: "2020-01-01T00:00:00.000Z", + }, + ); const company = await store(vault, COMPANY_ONTOLOGY, { id: "acme", eName: vault, diff --git a/infrastructure/evault-core/src/core/manifest/manifest.service.ts b/infrastructure/evault-core/src/core/manifest/manifest.service.ts index 5a2143d75..cd6aa7d94 100644 --- a/infrastructure/evault-core/src/core/manifest/manifest.service.ts +++ b/infrastructure/evault-core/src/core/manifest/manifest.service.ts @@ -4,17 +4,36 @@ import type { DbService } from "../db/db.service"; import type { VaultType } from "../db/types"; export const USER_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; -export const CHAT_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440003"; -export const GROUP_MANIFEST_ONTOLOGY = "a8bfb7cf-3200-4b25-9ea9-ee41100f212e"; +/** Shared by Chat and the GroupManifest `createGroupEVault` writes. */ +export const GROUP_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440003"; +/** The id in services/ontology/schemas/groupManifest.json. */ +export const LEGACY_GROUP_MANIFEST_ONTOLOGY = + "a8bfb7cf-3200-4b25-9ea9-ee41100f212e"; export const COMPANY_ONTOLOGY = "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402"; -/** The ontology an eVault's manifest must have, by the vault's type. */ -export const MANIFEST_ONTOLOGIES: Record = { - user: USER_PROFILE_ONTOLOGY, - group: GROUP_MANIFEST_ONTOLOGY, - company: COMPANY_ONTOLOGY, +/** + * Which records may be a manifest, by the vault's type, in order of preference. + * Under the shared group ontology only records shaped like a GroupManifest + * count, so a Chat is never mistaken for one. + */ +export const MANIFEST_CANDIDATES: Record< + VaultType, + { ontology: string; shape: RecordShape }[] +> = { + user: [{ ontology: USER_PROFILE_ONTOLOGY, shape: "any" }], + group: [ + { ontology: GROUP_ONTOLOGY, shape: "manifest" }, + { ontology: LEGACY_GROUP_MANIFEST_ONTOLOGY, shape: "any" }, + ], + company: [{ ontology: COMPANY_ONTOLOGY, shape: "any" }], }; +/** + * A GroupManifest has `members` and an `owner`, as the control panel also + * checks; a Chat sharing its ontology does not. + */ +type RecordShape = "any" | "manifest" | "chat"; + /** Platforms spelled the self-naming field both ways. */ const ENAME_FIELDS = ["ename", "eName"]; @@ -66,10 +85,11 @@ export class ManifestService { } const type = await this.inferType(eName); - const candidate = await this.earliestSelfNaming( - eName, - MANIFEST_ONTOLOGIES[type], - ); + let candidate: string | null = null; + for (const { ontology, shape } of MANIFEST_CANDIDATES[type]) { + candidate = await this.earliestSelfNaming(eName, ontology, shape); + if (candidate) break; + } if (!candidate) { if (type === "group") return this.createGroupManifest(eName); @@ -111,7 +131,10 @@ export class ManifestService { { eName, company: COMPANY_ONTOLOGY, - groupOntologies: [CHAT_ONTOLOGY, GROUP_MANIFEST_ONTOLOGY], + groupOntologies: [ + GROUP_ONTOLOGY, + LEGACY_GROUP_MANIFEST_ONTOLOGY, + ], enameFields: ENAME_FIELDS, }, ); @@ -125,7 +148,8 @@ export class ManifestService { } /** - * The earliest record of an ontology in the vault that names the vault. + * The earliest record of an ontology in the vault that names the vault, + * optionally limited to records shaped like a GroupManifest or not. * * Records written before version history existed have no history node and * are older than any that do, so they sort first; within each group the @@ -134,6 +158,7 @@ export class ManifestService { private async earliestSelfNaming( eName: string, ontology: string, + shape: RecordShape = "any", ): Promise { const result = await this.db.runQuery( ` @@ -142,21 +167,29 @@ export class ManifestService { MATCH (m)-[:LINKS_TO]->(n:Envelope) WHERE n.ontology IN $enameFields AND n.value = $eName } + AND ( + $shape = "any" + OR ($shape = "manifest") = ( + EXISTS { (m)-[:LINKS_TO]->(:Envelope { ontology: "members" }) } + AND EXISTS { (m)-[:LINKS_TO]->(:Envelope { ontology: "owner" }) } + ) + ) OPTIONAL MATCH (h:MetaEnvelopeHistory { metaEnvelopeId: m.id, eName: $eName }) OPTIONAL MATCH (m)-[:LINKS_TO]->(c:Envelope { ontology: "createdAt" }) RETURN m.id AS id ORDER BY h IS NOT NULL, c.value, h.createdAt, m.id LIMIT 1 `, - { eName, ontology, enameFields: ENAME_FIELDS }, + { eName, ontology, shape, enameFields: ENAME_FIELDS }, ); return result.records[0]?.get("id") ?? null; } /** * Older group vaults only hold a Chat. A GroupManifest is built from it - * once: the pin is claimed with a fresh id first, so concurrent callers - * cannot both write one, and only the winner stores the record. + * once, under the same ontology `createGroupEVault` writes. The pin is + * claimed with a fresh id first, so concurrent callers cannot both write + * one, and only the winner stores the record. */ private async createGroupManifest( eName: string, @@ -175,7 +208,7 @@ export class ManifestService { try { const payload = await this.groupManifestFromChat(eName); await this.db.storeMetaEnvelopeWithId( - { ontology: GROUP_MANIFEST_ONTOLOGY, payload, acl: ["*"] }, + { ontology: GROUP_ONTOLOGY, payload, acl: ["*"] }, ["*"], eName, id, @@ -190,13 +223,15 @@ export class ManifestService { private async groupManifestFromChat( eName: string, ): Promise> { - const chatId = await this.earliestSelfNaming(eName, CHAT_ONTOLOGY); + const chatId = await this.earliestSelfNaming( + eName, + GROUP_ONTOLOGY, + "chat", + ); const chat = chatId ? await this.load(chatId, eName) : null; const fields = chat?.parsed ?? {}; - const admins = Array.isArray(fields.admins) - ? fields.admins.filter((a): a is string => typeof a === "string") - : []; + const admins = await this.adminENames(fields); const owner = typeof fields.owner === "string" ? fields.owner : eName; const now = new Date().toISOString(); @@ -215,6 +250,26 @@ export class ManifestService { return manifest; } + /** + * Admins may be listed as eNames or, as the eVoting, eSigner and + * file-manager mappings write them, as profile ids under `adminIds`; ids + * are followed to the eNames behind them. + */ + private async adminENames( + fields: Record, + ): Promise { + const entries = [fields.admins, fields.adminIds] + .flatMap((v) => (Array.isArray(v) ? v : [v])) + .filter((v): v is string => typeof v === "string" && v.length > 0); + + const enames = new Set(entries.filter((e) => e.startsWith("@"))); + const ids = entries.filter((e) => !e.startsWith("@")); + for (const resolved of await this.groups.enamesForProfileIds(ids)) { + enames.add(resolved); + } + return [...enames]; + } + private async load(id: string, eName: string): Promise { const found = await this.db.findMetaEnvelopeById(id, eName); if (!found) return null; diff --git a/infrastructure/evault-core/src/index.ts b/infrastructure/evault-core/src/index.ts index 6b74776fb..c8b5a909d 100644 --- a/infrastructure/evault-core/src/index.ts +++ b/infrastructure/evault-core/src/index.ts @@ -115,16 +115,6 @@ const initializeEVault = async ( console.warn("Failed to create eName index:", error); } - // Create User index for public key lookups - try { - const { createUserIndex } = await import( - "./core/db/migrations/add-user-index" - ); - await createUserIndex(driver); - } catch (error) { - console.warn("Failed to create User index:", error); - } - // Create id indexes on Envelope and MetaEnvelope so per-field point // lookups inside updateMetaEnvelopeById don't scan the whole label. try { @@ -146,6 +136,17 @@ const initializeEVault = async ( console.warn("Failed to migrate publicKey to publicKeys array:", error); } + // Make User.eName unique (also serves public key lookups). Runs after the + // publicKey migration so duplicates are merged as arrays. + try { + const { createUserENameConstraint } = await import( + "./core/db/migrations/add-user-ename-constraint" + ); + await createUserENameConstraint(driver); + } catch (error) { + console.warn("Failed to create User eName constraint:", error); + } + // Create EnvelopeOperationLog indexes for /logs endpoint try { const { createEnvelopeOperationLogIndexes } = await import( diff --git a/skills/w3ds/reference/evault.md b/skills/w3ds/reference/evault.md index c6135db14..70c36bdb5 100644 --- a/skills/w3ds/reference/evault.md +++ b/skills/w3ds/reference/evault.md @@ -269,7 +269,7 @@ Certificates are ES256 JWTs signed by the Registry. Payload: `{ ename, publicKey `type` and `manifest` (either may be `null`): - `company`: a Company record names the vault. Manifest: that Company record. -- `group`: keyless vault with a Chat or GroupManifest naming it. Manifest: GroupManifest, built once from the Chat if missing. +- `group`: keyless vault with a Chat or GroupManifest naming it. Manifest: GroupManifest (`…440003` records with `members` + `owner`, else legacy `a8bfb7cf-…`), built once from the Chat if missing. - `user`: everything else. Manifest: UserProfile. - Manifest = earliest such record whose `ename`/`eName` is the vault, pinned on first `/whois`, re-resolved only if deleted. - Public via `/whois` regardless of ACL; GraphQL still enforces the ACL.