Skip to content

Commit e40ce8f

Browse files
authored
feat: PermissionsUser.events (#2007)
1 parent 6173160 commit e40ce8f

11 files changed

Lines changed: 305 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ensapi": minor
3+
---
4+
5+
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.

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ Fail fast and loudly on invalid inputs.
7676
- **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.
7777
- **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)`.
7878

79+
## Ponder
80+
81+
- 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.
82+
- Schema or handler changes always require a re-index. This is implicit — never qualify plans with "requires reindex" or similar.
83+
- 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.
84+
7985
## Workflow
8086

8187
- 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

apps/ensapi/src/omnigraph-api/lib/find-events/find-events-resolver.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants";
1313
type EventJoinTable =
1414
| typeof ensIndexerSchema.domainEvent
1515
| typeof ensIndexerSchema.resolverEvent
16-
| typeof ensIndexerSchema.permissionsEvent;
16+
| typeof ensIndexerSchema.permissionsEvent
17+
| typeof ensIndexerSchema.permissionsUserEvent;
1718

1819
/**
1920
* Available filter options for find-events queries.

apps/ensapi/src/omnigraph-api/schema/permissions.integration.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
PermissionsUserId,
77
RegistryId,
88
} from "enssdk";
9-
import { toEventSelector } from "viem";
9+
import { pad, toEventSelector } from "viem";
1010
import { beforeAll, describe, expect, it } from "vitest";
1111

1212
import { DatasourceNames, EnhancedAccessControlABI } from "@ensnode/datasources";
@@ -503,3 +503,97 @@ describe("Permissions.events filtering (EventsWhereInput)", () => {
503503
expect(events.length).toBe(0);
504504
});
505505
});
506+
507+
describe("PermissionsUser.events", () => {
508+
type PermissionsUserEventsResult = {
509+
permissions: {
510+
root: {
511+
users: GraphQLConnection<PermissionUser & { events: GraphQLConnection<EventResult> }>;
512+
};
513+
};
514+
};
515+
516+
const PermissionsUserEvents = gql`
517+
query PermissionsUserEvents($contract: AccountIdInput!, $where: EventsWhereInput) {
518+
permissions(by: { contract: $contract }) {
519+
root {
520+
users {
521+
edges { node {
522+
id resource user { address } roles
523+
events(where: $where, first: 1000) { edges { node { ...EventFragment } } }
524+
} }
525+
}
526+
}
527+
}
528+
}
529+
${EventFragment}
530+
`;
531+
532+
let users: (PermissionUser & { events: GraphQLConnection<EventResult> })[];
533+
534+
beforeAll(async () => {
535+
const result = await request<PermissionsUserEventsResult>(PermissionsUserEvents, {
536+
contract: V2_ETH_REGISTRY,
537+
});
538+
users = flattenConnection(result.permissions.root.users);
539+
expect(users.length).toBeGreaterThan(0);
540+
});
541+
542+
it("returns events scoped to each PermissionsUser", () => {
543+
for (const user of users) {
544+
const events = flattenConnection(user.events);
545+
expect(events.length).toBeGreaterThan(0);
546+
547+
// every event must be an EACRolesChanged on the contract
548+
for (const event of events) {
549+
expect(event.address).toBe(V2_ETH_REGISTRY.address);
550+
expect(event.topics[0]).toBe(EAC_ROLES_CHANGED_SELECTOR);
551+
}
552+
}
553+
});
554+
555+
it("scopes each user's events to that (resource, user)", () => {
556+
// EACRolesChanged(resource indexed, account indexed, ...) — so
557+
// topics[1] == resource, topics[2] == padded user address
558+
for (const user of users) {
559+
const events = flattenConnection(user.events);
560+
for (const event of events) {
561+
expect(BigInt(event.topics[1])).toBe(BigInt(user.resource));
562+
expect(event.topics[2]).toBe(pad(user.user.address, { size: 32 }));
563+
}
564+
}
565+
});
566+
567+
it("filters by selector_in", async () => {
568+
const result = await request<PermissionsUserEventsResult>(PermissionsUserEvents, {
569+
contract: V2_ETH_REGISTRY,
570+
where: { selector_in: [EAC_ROLES_CHANGED_SELECTOR] },
571+
});
572+
const filteredUsers = flattenConnection(result.permissions.root.users);
573+
574+
for (const user of filteredUsers) {
575+
const events = flattenConnection(user.events);
576+
expect(events.length).toBeGreaterThan(0);
577+
for (const event of events) {
578+
expect(event.topics[0]).toBe(EAC_ROLES_CHANGED_SELECTOR);
579+
}
580+
}
581+
});
582+
583+
it("filters by empty selector_in returns no results", async () => {
584+
const result = await request<PermissionsUserEventsResult>(PermissionsUserEvents, {
585+
contract: V2_ETH_REGISTRY,
586+
where: { selector_in: [] },
587+
});
588+
const filteredUsers = flattenConnection(result.permissions.root.users);
589+
590+
for (const user of filteredUsers) {
591+
expect(flattenConnection(user.events).length).toBe(0);
592+
}
593+
});
594+
595+
// requires integration-test-env to emit a grant followed by a revoke (newRoleBitmap = 0) for the
596+
// same (resource, user). exercises the handler path where the permissionsUser row is deleted but
597+
// both EACRolesChanged events must remain joined to the (now-removed) PermissionsUserId.
598+
it.todo("preserves event history for a PermissionsUser whose roles were revoked to 0");
599+
});

apps/ensapi/src/omnigraph-api/schema/permissions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,24 @@ PermissionsUserRef.implement({
275275
nullable: false,
276276
resolve: (parent) => parent.roles,
277277
}),
278+
279+
//////////////////////////
280+
// PermissionsUser.events
281+
//////////////////////////
282+
events: t.connection({
283+
description: "All Events associated with this PermissionsUser.",
284+
type: EventRef,
285+
args: {
286+
where: t.arg({ type: EventsWhereInput }),
287+
},
288+
resolve: (parent, args) =>
289+
resolveFindEvents(args, {
290+
through: {
291+
table: ensIndexerSchema.permissionsUserEvent,
292+
scope: eq(ensIndexerSchema.permissionsUserEvent.permissionsUserId, parent.id),
293+
},
294+
}),
295+
}),
278296
}),
279297
});
280298

apps/ensindexer/src/lib/ensv2/event-db-helpers.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { type AccountId, type DomainId, makePermissionsId, makeResolverId } from "enssdk";
1+
import {
2+
type AccountId,
3+
type DomainId,
4+
makePermissionsId,
5+
makeResolverId,
6+
type PermissionsUserId,
7+
} from "enssdk";
28
import type { Hash } from "viem";
39

410
import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder";
@@ -91,3 +97,14 @@ export async function ensurePermissionsEvent(
9197
.values({ permissionsId: makePermissionsId(contract), eventId })
9298
.onConflictDoNothing();
9399
}
100+
101+
export async function ensurePermissionsUserEvent(
102+
context: IndexingEngineContext,
103+
permissionsUserId: PermissionsUserId,
104+
eventId: string,
105+
) {
106+
await context.ensDb
107+
.insert(ensIndexerSchema.permissionsUserEvent)
108+
.values({ permissionsUserId, eventId })
109+
.onConflictDoNothing();
110+
}

apps/ensindexer/src/plugins/ensv2/handlers/ensv2/EnhancedAccessControl.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import { isAddressEqual, zeroAddress } from "viem";
1111
import { PluginName } from "@ensnode/ensnode-sdk";
1212

1313
import { ensureAccount } from "@/lib/ensv2/account-db-helpers";
14-
import { ensureEvent, ensurePermissionsEvent } from "@/lib/ensv2/event-db-helpers";
14+
import {
15+
ensureEvent,
16+
ensurePermissionsEvent,
17+
ensurePermissionsUserEvent,
18+
} from "@/lib/ensv2/event-db-helpers";
1519
import { getThisAccountId } from "@/lib/get-this-account-id";
1620
import {
1721
addOnchainEventListener,
@@ -95,9 +99,10 @@ export default function () {
9599
.onConflictDoUpdate({ roles });
96100
}
97101

98-
// push event to permissions
102+
// push event to permissions and permissions user
99103
const eventId = await ensureEvent(context, event);
100104
await ensurePermissionsEvent(context, contract, eventId);
105+
await ensurePermissionsUserEvent(context, permissionsUserId, eventId);
101106
},
102107
);
103108
}

packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,15 @@ export const permissionsEvent = onchainTable(
153153
(t) => ({ pk: primaryKey({ columns: [t.permissionsId, t.eventId] }) }),
154154
);
155155

156+
export const permissionsUserEvent = onchainTable(
157+
"permissions_user_events",
158+
(t) => ({
159+
permissionsUserId: t.text().notNull().$type<PermissionsUserId>(),
160+
eventId: t.text().notNull(),
161+
}),
162+
(t) => ({ pk: primaryKey({ columns: [t.permissionsUserId, t.eventId] }) }),
163+
);
164+
156165
///////////
157166
// Account
158167
///////////

packages/enssdk/src/omnigraph/generated/introspection.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4008,6 +4008,51 @@ const introspection = {
40084008
"args": [],
40094009
"isDeprecated": false
40104010
},
4011+
{
4012+
"name": "events",
4013+
"type": {
4014+
"kind": "OBJECT",
4015+
"name": "PermissionsUserEventsConnection"
4016+
},
4017+
"args": [
4018+
{
4019+
"name": "after",
4020+
"type": {
4021+
"kind": "SCALAR",
4022+
"name": "String"
4023+
}
4024+
},
4025+
{
4026+
"name": "before",
4027+
"type": {
4028+
"kind": "SCALAR",
4029+
"name": "String"
4030+
}
4031+
},
4032+
{
4033+
"name": "first",
4034+
"type": {
4035+
"kind": "SCALAR",
4036+
"name": "Int"
4037+
}
4038+
},
4039+
{
4040+
"name": "last",
4041+
"type": {
4042+
"kind": "SCALAR",
4043+
"name": "Int"
4044+
}
4045+
},
4046+
{
4047+
"name": "where",
4048+
"type": {
4049+
"kind": "INPUT_OBJECT",
4050+
"name": "EventsWhereInput"
4051+
}
4052+
}
4053+
],
4054+
"isDeprecated": false
4055+
},
40114056
{
40124057
"name": "id",
40134058
"type": {
@@ -4059,6 +4104,86 @@ const introspection = {
40594104
],
40604105
"interfaces": []
40614106
},
4107+
{
4108+
"kind": "OBJECT",
4109+
"name": "PermissionsUserEventsConnection",
4110+
"fields": [
4111+
{
4112+
"name": "edges",
4113+
"type": {
4114+
"kind": "NON_NULL",
4115+
"ofType": {
4116+
"kind": "LIST",
4117+
"ofType": {
4118+
"kind": "NON_NULL",
4119+
"ofType": {
4120+
"kind": "OBJECT",
4121+
"name": "PermissionsUserEventsConnectionEdge"
4122+
}
4123+
}
4124+
}
4125+
},
4126+
"args": [],
4127+
"isDeprecated": false
4128+
},
4129+
{
4130+
"name": "pageInfo",
4131+
"type": {
4132+
"kind": "NON_NULL",
4133+
"ofType": {
4134+
"kind": "OBJECT",
4135+
"name": "PageInfo"
4136+
}
4137+
},
4138+
"args": [],
4139+
"isDeprecated": false
4140+
},
4141+
{
4142+
"name": "totalCount",
4143+
"type": {
4144+
"kind": "NON_NULL",
4145+
"ofType": {
4146+
"kind": "SCALAR",
4147+
"name": "Int"
4148+
}
4149+
},
4150+
"args": [],
4151+
"isDeprecated": false
4152+
}
4153+
],
4154+
"interfaces": []
4155+
},
4156+
{
4157+
"kind": "OBJECT",
4158+
"name": "PermissionsUserEventsConnectionEdge",
4159+
"fields": [
4160+
{
4161+
"name": "cursor",
4162+
"type": {
4163+
"kind": "NON_NULL",
4164+
"ofType": {
4165+
"kind": "SCALAR",
4166+
"name": "String"
4167+
}
4168+
},
4169+
"args": [],
4170+
"isDeprecated": false
4171+
},
4172+
{
4173+
"name": "node",
4174+
"type": {
4175+
"kind": "NON_NULL",
4176+
"ofType": {
4177+
"kind": "OBJECT",
4178+
"name": "Event"
4179+
}
4180+
},
4181+
"args": [],
4182+
"isDeprecated": false
4183+
}
4184+
],
4185+
"interfaces": []
4186+
},
40624187
{
40634188
"kind": "SCALAR",
40644189
"name": "PermissionsUserId"

packages/enssdk/src/omnigraph/generated/schema.graphql

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,9 @@ type PermissionsUser {
845845
"""The contract within which these Permissions are granted."""
846846
contract: AccountId!
847847

848+
"""All Events associated with this PermissionsUser."""
849+
events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): PermissionsUserEventsConnection
850+
848851
"""A unique reference to this PermissionsUser."""
849852
id: PermissionsUserId!
850853

@@ -858,6 +861,17 @@ type PermissionsUser {
858861
user: Account!
859862
}
860863

864+
type PermissionsUserEventsConnection {
865+
edges: [PermissionsUserEventsConnectionEdge!]!
866+
pageInfo: PageInfo!
867+
totalCount: Int!
868+
}
869+
870+
type PermissionsUserEventsConnectionEdge {
871+
cursor: String!
872+
node: Event!
873+
}
874+
861875
"""PermissionsUserId represents a enssdk#PermissionsUserId."""
862876
scalar PermissionsUserId
863877

0 commit comments

Comments
 (0)