diff --git a/.changeset/permissions-user-events.md b/.changeset/permissions-user-events.md new file mode 100644 index 000000000..b916d6753 --- /dev/null +++ b/.changeset/permissions-user-events.md @@ -0,0 +1,5 @@ +--- +"ensapi": minor +--- + +Adds `PermissionsUser.events` to the Omnigraph API, exposing the per-role-assignment event history (grants, revokes, role-bitmap mutations) for a specific `(contract, resource, user)` tuple. diff --git a/AGENTS.md b/AGENTS.md index b92765790..fdf796ddb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,12 @@ Fail fast and loudly on invalid inputs. - **API boundaries:** Use the shared `errorResponse` helper (`apps/ensapi/src/lib/handlers/error-response.ts`) for all error responses in ENSApi (and equivalent pattern in other Hono apps). Mapping: validation (ZodError / Standard Schema) → 400 with `{ message, details }`; other known client errors → 4xx with `{ message }`; server errors → 500 with `{ message }`. Response shape: `{ message: string, details?: unknown }` (see `packages/ensnode-sdk/src/ensapi/api/shared/errors/response.ts`). A `code` field may be adopted later for machine-readable codes; do not add it inconsistently today. - **Examples:** Validation at boundary: route uses `validate("json", MySchema)`; on failure → 400 + `{ message: "Invalid Input", details }`. Non-API: `const config = ConfigSchema.parse(env)` or `const parsed = MySchema.safeParse(input); if (!parsed.success) return fallback;`. Handler: `return errorResponse(c, err)` or `return errorResponse(c, "Not found", 404)`. +## Ponder + +- Schema changes never require a migration step. Ponder only runs fully-compatible indexes against existing schemas; otherwise the index is dropped and rebuilt from scratch. Do not propose, plan, or write migration code for the ensindexer drizzle schema. +- Schema or handler changes always require a re-index. This is implicit — never qualify plans with "requires reindex" or similar. +- Access entities by primary key only. Ponder's cache layer keys on PK; filters or complex selects force a flush to Postgres and are extremely unperformant in the hot path. If you need a non-PK lookup at index time, design the schema so the lookup key is the primary key. + ## Workflow - Add a changeset when your PR includes a logical change that should bump versions or be communicated in release notes: https://ensnode.io/docs/contributing/prs#changesets diff --git a/apps/ensapi/src/omnigraph-api/lib/find-events/find-events-resolver.ts b/apps/ensapi/src/omnigraph-api/lib/find-events/find-events-resolver.ts index 5031b290f..bec3513c8 100644 --- a/apps/ensapi/src/omnigraph-api/lib/find-events/find-events-resolver.ts +++ b/apps/ensapi/src/omnigraph-api/lib/find-events/find-events-resolver.ts @@ -13,7 +13,8 @@ import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants"; type EventJoinTable = | typeof ensIndexerSchema.domainEvent | typeof ensIndexerSchema.resolverEvent - | typeof ensIndexerSchema.permissionsEvent; + | typeof ensIndexerSchema.permissionsEvent + | typeof ensIndexerSchema.permissionsUserEvent; /** * Available filter options for find-events queries. diff --git a/apps/ensapi/src/omnigraph-api/schema/permissions.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/permissions.integration.test.ts index 69e2df7c1..9e3c9e0a6 100644 --- a/apps/ensapi/src/omnigraph-api/schema/permissions.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/permissions.integration.test.ts @@ -6,7 +6,7 @@ import type { PermissionsUserId, RegistryId, } from "enssdk"; -import { toEventSelector } from "viem"; +import { pad, toEventSelector } from "viem"; import { beforeAll, describe, expect, it } from "vitest"; import { DatasourceNames, EnhancedAccessControlABI } from "@ensnode/datasources"; @@ -503,3 +503,97 @@ describe("Permissions.events filtering (EventsWhereInput)", () => { expect(events.length).toBe(0); }); }); + +describe("PermissionsUser.events", () => { + type PermissionsUserEventsResult = { + permissions: { + root: { + users: GraphQLConnection }>; + }; + }; + }; + + const PermissionsUserEvents = gql` + query PermissionsUserEvents($contract: AccountIdInput!, $where: EventsWhereInput) { + permissions(by: { contract: $contract }) { + root { + users { + edges { node { + id resource user { address } roles + events(where: $where, first: 1000) { edges { node { ...EventFragment } } } + } } + } + } + } + } + ${EventFragment} + `; + + let users: (PermissionUser & { events: GraphQLConnection })[]; + + beforeAll(async () => { + const result = await request(PermissionsUserEvents, { + contract: V2_ETH_REGISTRY, + }); + users = flattenConnection(result.permissions.root.users); + expect(users.length).toBeGreaterThan(0); + }); + + it("returns events scoped to each PermissionsUser", () => { + for (const user of users) { + const events = flattenConnection(user.events); + expect(events.length).toBeGreaterThan(0); + + // every event must be an EACRolesChanged on the contract + for (const event of events) { + expect(event.address).toBe(V2_ETH_REGISTRY.address); + expect(event.topics[0]).toBe(EAC_ROLES_CHANGED_SELECTOR); + } + } + }); + + it("scopes each user's events to that (resource, user)", () => { + // EACRolesChanged(resource indexed, account indexed, ...) — so + // topics[1] == resource, topics[2] == padded user address + for (const user of users) { + const events = flattenConnection(user.events); + for (const event of events) { + expect(BigInt(event.topics[1])).toBe(BigInt(user.resource)); + expect(event.topics[2]).toBe(pad(user.user.address, { size: 32 })); + } + } + }); + + it("filters by selector_in", async () => { + const result = await request(PermissionsUserEvents, { + contract: V2_ETH_REGISTRY, + where: { selector_in: [EAC_ROLES_CHANGED_SELECTOR] }, + }); + const filteredUsers = flattenConnection(result.permissions.root.users); + + for (const user of filteredUsers) { + const events = flattenConnection(user.events); + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.topics[0]).toBe(EAC_ROLES_CHANGED_SELECTOR); + } + } + }); + + it("filters by empty selector_in returns no results", async () => { + const result = await request(PermissionsUserEvents, { + contract: V2_ETH_REGISTRY, + where: { selector_in: [] }, + }); + const filteredUsers = flattenConnection(result.permissions.root.users); + + for (const user of filteredUsers) { + expect(flattenConnection(user.events).length).toBe(0); + } + }); + + // requires integration-test-env to emit a grant followed by a revoke (newRoleBitmap = 0) for the + // same (resource, user). exercises the handler path where the permissionsUser row is deleted but + // both EACRolesChanged events must remain joined to the (now-removed) PermissionsUserId. + it.todo("preserves event history for a PermissionsUser whose roles were revoked to 0"); +}); diff --git a/apps/ensapi/src/omnigraph-api/schema/permissions.ts b/apps/ensapi/src/omnigraph-api/schema/permissions.ts index 664bad766..fda7f12a3 100644 --- a/apps/ensapi/src/omnigraph-api/schema/permissions.ts +++ b/apps/ensapi/src/omnigraph-api/schema/permissions.ts @@ -275,6 +275,24 @@ PermissionsUserRef.implement({ nullable: false, resolve: (parent) => parent.roles, }), + + ////////////////////////// + // PermissionsUser.events + ////////////////////////// + events: t.connection({ + description: "All Events associated with this PermissionsUser.", + type: EventRef, + args: { + where: t.arg({ type: EventsWhereInput }), + }, + resolve: (parent, args) => + resolveFindEvents(args, { + through: { + table: ensIndexerSchema.permissionsUserEvent, + scope: eq(ensIndexerSchema.permissionsUserEvent.permissionsUserId, parent.id), + }, + }), + }), }), }); diff --git a/apps/ensindexer/src/lib/ensv2/event-db-helpers.ts b/apps/ensindexer/src/lib/ensv2/event-db-helpers.ts index 62f3a93db..ac7c99aa2 100644 --- a/apps/ensindexer/src/lib/ensv2/event-db-helpers.ts +++ b/apps/ensindexer/src/lib/ensv2/event-db-helpers.ts @@ -1,4 +1,10 @@ -import { type AccountId, type DomainId, makePermissionsId, makeResolverId } from "enssdk"; +import { + type AccountId, + type DomainId, + makePermissionsId, + makeResolverId, + type PermissionsUserId, +} from "enssdk"; import type { Hash } from "viem"; import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; @@ -91,3 +97,14 @@ export async function ensurePermissionsEvent( .values({ permissionsId: makePermissionsId(contract), eventId }) .onConflictDoNothing(); } + +export async function ensurePermissionsUserEvent( + context: IndexingEngineContext, + permissionsUserId: PermissionsUserId, + eventId: string, +) { + await context.ensDb + .insert(ensIndexerSchema.permissionsUserEvent) + .values({ permissionsUserId, eventId }) + .onConflictDoNothing(); +} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts index 1aa01c195..2048faaff 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts @@ -11,7 +11,11 @@ import { isAddressEqual, zeroAddress } from "viem"; import { PluginName } from "@ensnode/ensnode-sdk"; import { ensureAccount } from "@/lib/ensv2/account-db-helpers"; -import { ensureEvent, ensurePermissionsEvent } from "@/lib/ensv2/event-db-helpers"; +import { + ensureEvent, + ensurePermissionsEvent, + ensurePermissionsUserEvent, +} from "@/lib/ensv2/event-db-helpers"; import { getThisAccountId } from "@/lib/get-this-account-id"; import { addOnchainEventListener, @@ -95,9 +99,10 @@ export default function () { .onConflictDoUpdate({ roles }); } - // push event to permissions + // push event to permissions and permissions user const eventId = await ensureEvent(context, event); await ensurePermissionsEvent(context, contract, eventId); + await ensurePermissionsUserEvent(context, permissionsUserId, eventId); }, ); } diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts index 8b0fe2ced..543f7232e 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts @@ -153,6 +153,15 @@ export const permissionsEvent = onchainTable( (t) => ({ pk: primaryKey({ columns: [t.permissionsId, t.eventId] }) }), ); +export const permissionsUserEvent = onchainTable( + "permissions_user_events", + (t) => ({ + permissionsUserId: t.text().notNull().$type(), + eventId: t.text().notNull(), + }), + (t) => ({ pk: primaryKey({ columns: [t.permissionsUserId, t.eventId] }) }), +); + /////////// // Account /////////// diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index c0dfeb04e..c6763e36a 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -4008,6 +4008,51 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "events", + "type": { + "kind": "OBJECT", + "name": "PermissionsUserEventsConnection" + }, + "args": [ + { + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int" + } + }, + { + "name": "where", + "type": { + "kind": "INPUT_OBJECT", + "name": "EventsWhereInput" + } + } + ], + "isDeprecated": false + }, { "name": "id", "type": { @@ -4059,6 +4104,86 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "OBJECT", + "name": "PermissionsUserEventsConnection", + "fields": [ + { + "name": "edges", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PermissionsUserEventsConnectionEdge" + } + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "pageInfo", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "PageInfo" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "totalCount", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "PermissionsUserEventsConnectionEdge", + "fields": [ + { + "name": "cursor", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "node", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "Event" + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "SCALAR", "name": "PermissionsUserId" diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 3587a2579..ac546669a 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -845,6 +845,9 @@ type PermissionsUser { """The contract within which these Permissions are granted.""" contract: AccountId! + """All Events associated with this PermissionsUser.""" + events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): PermissionsUserEventsConnection + """A unique reference to this PermissionsUser.""" id: PermissionsUserId! @@ -858,6 +861,17 @@ type PermissionsUser { user: Account! } +type PermissionsUserEventsConnection { + edges: [PermissionsUserEventsConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type PermissionsUserEventsConnectionEdge { + cursor: String! + node: Event! +} + """PermissionsUserId represents a enssdk#PermissionsUserId.""" scalar PermissionsUserId diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts index 163ed6b70..a23398487 100644 --- a/vitest.integration.config.ts +++ b/vitest.integration.config.ts @@ -2,7 +2,12 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - projects: ["./**/*/vitest.integration.config.ts"], + projects: [ + "./**/*/vitest.integration.config.ts", + "!**/.git/**", + "!**/.claude/**", + "!**/node_modules/**", + ], env: { // allows the syntax highlight of graphql request/responses to propagate through vitest's logs FORCE_COLOR: "true",