Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs/Post Platform Guide/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
49 changes: 42 additions & 7 deletions docs/docs/Post Platform Guide/mapping-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)"
}
Expand Down
12 changes: 8 additions & 4 deletions docs/docs/W3DS Protocol/Access-Control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }[] = [];

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<string, unknown> }) => {
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]);
});
});
36 changes: 35 additions & 1 deletion infrastructure/web3-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,24 @@ 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 {
MetaEnvelope,
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
Expand Down Expand Up @@ -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<string | null>;
},
) {
this.readPaths();
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading