-
Notifications
You must be signed in to change notification settings - Fork 17
Add EnsRainbowBeam app with endpoint for submitting labels #2015
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 18 commits
19b42ed
c57108e
c0c013e
3537486
440023c
4b49138
912c16c
3021d5c
8c41088
4634cc4
0b5365b
ff194f9
2adbc92
74ff599
053913b
2005200
caf277d
a723be4
d643370
7597168
6e1f410
07c9d1f
33e3ed1
23b4eaa
b79863c
d01acae
2ae9acc
5cfdb4a
e8e4767
ba036a6
e7f9df5
5b92ad7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |
| "ensrainbow", | ||
| "ensapi", | ||
| "fallback-ensapi", | ||
| "ensrainbowbeam", | ||
| "enssdk", | ||
| "enscli", | ||
| "enskit", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "ensapi": minor | ||
| --- | ||
|
|
||
| Omnigraph **`Query.labels`** improvements: add a **`LabelHash`** GraphQL scalar (`0x` + 64 lowercase hex, parsed via `parseLabelHash`), rename the input to **`LabelsByLabelHashesInput`** with field **`labelHashes`**, enforce stricter parsing/validation through the scalar layer, normalize mixed-case hex at parse time, cap batch size to **`100`** LabelHashes per request for a round-number limit, and keep development error masking aligned with Yoga defaults while ensuring intentional `GraphQLError`s still surface useful client messages where applicable. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "ensrainbowbeam": minor | ||
| --- | ||
|
|
||
| Add **`EnsRainbowBeam`** (`apps/ensrainbowbeam`) exposing **`POST /api/discover`**, classifies each submitted label literal against ENSNode via **`labels(by: { labelHashes })`** (with client-side chunking aligned to ENSApi batch limits), emits structured JSON Lines to stdout for future sinks, mirrors other apps’ Dockerfile + Compose service patterns (`docker/services/ensrainbowbeam.yml`), and includes MIT **`LICENSE`** in the app directory ([issue \#2003](https://github.com/namehash/ensnode/issues/2003)). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "enssdk": minor | ||
| --- | ||
|
|
||
| Regenerate `enssdk/omnigraph` artifacts for the Omnigraph **`LabelHash`** scalar, mapped in `OmnigraphScalars` for typed **`graphql`** documents. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import { | ||
| asInterpretedLabel, | ||
| encodeLabelHash, | ||
| type InterpretedLabel, | ||
| type LabelHash, | ||
| labelhashInterpretedLabel, | ||
| parseLabelHash, | ||
| } from "enssdk"; | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { LABELS_BY_LABELHASH_MAX } from "@/omnigraph-api/schema/label"; | ||
| import { request } from "@/test/integration/graphql-utils"; | ||
| import { gql } from "@/test/integration/omnigraph-api-client"; | ||
|
|
||
| type LabelsByLabelHashResult = { | ||
| labels: Array<{ hash: LabelHash; interpreted: InterpretedLabel }>; | ||
| }; | ||
|
|
||
| const LabelsByLabelHash = gql` | ||
| query LabelsByLabelHash($labelHashes: [LabelHash!]!) { | ||
| labels(by: { labelHashes: $labelHashes }) { | ||
| hash | ||
| interpreted | ||
| } | ||
| } | ||
| `; | ||
|
|
||
| // 'eth' is always seeded in the devnet fixture as a healed label | ||
| const ETH_LABEL_HASH: LabelHash = labelhashInterpretedLabel(asInterpretedLabel("eth")); | ||
|
|
||
| // a LabelHash that should not exist in the index (deterministic dummy bytes) | ||
| const ABSENT_LABEL_HASH = parseLabelHash(`0x${"ff".repeat(32)}`); | ||
|
|
||
| describe("Query.labels", () => { | ||
| it("returns a healed label entry for a known LabelHash", async () => { | ||
| await expect( | ||
| request<LabelsByLabelHashResult>(LabelsByLabelHash, { labelHashes: [ETH_LABEL_HASH] }), | ||
| ).resolves.toMatchObject({ | ||
| labels: [{ hash: ETH_LABEL_HASH, interpreted: "eth" }], | ||
| }); | ||
| }); | ||
|
|
||
| it("accepts non-normalized (mixed-case hex) LabelHash variables and resolves matches", async () => { | ||
| const uppercaseVariable = ETH_LABEL_HASH.toUpperCase(); | ||
| expect(parseLabelHash(uppercaseVariable)).toBe(ETH_LABEL_HASH); | ||
|
|
||
| await expect( | ||
| request<LabelsByLabelHashResult>(LabelsByLabelHash, { | ||
| labelHashes: [uppercaseVariable as LabelHash], | ||
| }), | ||
| ).resolves.toMatchObject({ | ||
| labels: [{ hash: ETH_LABEL_HASH, interpreted: "eth" }], | ||
| }); | ||
| }); | ||
|
|
||
| it("omits LabelHashes that are not present in the index", async () => { | ||
| await expect( | ||
| request<LabelsByLabelHashResult>(LabelsByLabelHash, { labelHashes: [ABSENT_LABEL_HASH] }), | ||
| ).resolves.toEqual({ labels: [] }); | ||
| }); | ||
|
|
||
| it("returns only the present labels when input mixes present and absent LabelHashes", async () => { | ||
| await expect( | ||
| request<LabelsByLabelHashResult>(LabelsByLabelHash, { | ||
| labelHashes: [ETH_LABEL_HASH, ABSENT_LABEL_HASH], | ||
| }), | ||
| ).resolves.toMatchObject({ | ||
| labels: [{ hash: ETH_LABEL_HASH }], | ||
| }); | ||
| }); | ||
|
|
||
| it("dedupes repeated input LabelHashes", async () => { | ||
| await expect( | ||
| request<LabelsByLabelHashResult>(LabelsByLabelHash, { | ||
| labelHashes: [ETH_LABEL_HASH, ETH_LABEL_HASH, ETH_LABEL_HASH], | ||
| }), | ||
| ).resolves.toMatchObject({ | ||
| labels: [{ hash: ETH_LABEL_HASH }], | ||
| }); | ||
| }); | ||
|
|
||
| it("returns an empty list when input is empty", async () => { | ||
| await expect(request(LabelsByLabelHash, { labelHashes: [] })).resolves.toEqual({ labels: [] }); | ||
| }); | ||
|
|
||
| it("classifies returned labels: 'eth' is healed (interpreted !== encodeLabelHash(hash))", async () => { | ||
| const { labels } = await request<LabelsByLabelHashResult>(LabelsByLabelHash, { | ||
| labelHashes: [ETH_LABEL_HASH], | ||
| }); | ||
|
|
||
| expect(labels).toHaveLength(1); | ||
| expect(labels[0].interpreted).not.toEqual(encodeLabelHash(ETH_LABEL_HASH)); | ||
| }); | ||
|
|
||
| it("rejects junk strings that cannot be parsed as LabelHashes", async () => { | ||
| await expect( | ||
|
Check failure on line 96 in apps/ensapi/src/omnigraph-api/schema/label.integration.test.ts
|
||
| request(LabelsByLabelHash, { | ||
| labelHashes: ["not-even-hex"], | ||
| }), | ||
| ).rejects.toThrow(/Invalid labelHash/i); | ||
| }); | ||
|
|
||
| it("rejects hex values that are not exactly 32 bytes", async () => { | ||
| await expect( | ||
|
Check failure on line 104 in apps/ensapi/src/omnigraph-api/schema/label.integration.test.ts
|
||
| request(LabelsByLabelHash, { | ||
| labelHashes: ["0x00"], | ||
| }), | ||
| ).rejects.toThrow(/Invalid labelHash/i); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| it("rejects requests over the maximum allowed LabelHash count", async () => { | ||
| const labelHashes: LabelHash[] = []; | ||
| for (let i = 0; i <= LABELS_BY_LABELHASH_MAX; i++) { | ||
| labelHashes.push(parseLabelHash(`0x${i.toString(16).padStart(64, "0")}`)); | ||
| } | ||
|
|
||
| await expect(request(LabelsByLabelHash, { labelHashes })).rejects.toThrow( | ||
| /Too many LabelHashes/i, | ||
| ); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,7 @@ | |
| // import { maxDepthPlugin } from "@escape.tech/graphql-armor-max-depth"; | ||
| // import { maxTokensPlugin } from "@escape.tech/graphql-armor-max-tokens"; | ||
|
|
||
| import { createYoga } from "graphql-yoga"; | ||
| import { createYoga, maskError } from "graphql-yoga"; | ||
|
|
||
| import { makeLogger } from "@/lib/logger"; | ||
| import { context } from "@/omnigraph-api/context"; | ||
|
|
@@ -16,6 +16,25 @@ export const yoga = createYoga({ | |
| context, | ||
| // CORS is handled by the Hono middleware in app.ts | ||
| cors: false, | ||
| // Error masking: | ||
| // - Production: use Yoga defaults so internal details are not exposed to clients. | ||
| // - Non-production: still apply the same masked client payload, but log the **original** | ||
| // error server-side first. This makes debugging much easier than only seeing the masked | ||
| // message, while keeping the client-facing behavior aligned with production. | ||
| // | ||
| // Motivation: some resolvers intentionally throw `GraphQLError` (e.g. validation for | ||
| // `Query.labels`), but other code paths may throw plain `Error`. Yoga's default `maskError` | ||
| // maps unknown errors to a generic "Unexpected error." on the client; logging here ensures | ||
| // the real stack/message is still visible in local/staging logs. | ||
| maskedErrors: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, not sure what's going on here. Can you please add documentation?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. In dev mode it allows for logging detailed errors. |
||
| process.env.NODE_ENV === "production" | ||
| ? true | ||
| : { | ||
| maskError(error, message, isDev) { | ||
| logger.error(error); | ||
| return maskError(error, message, isDev); | ||
| }, | ||
| }, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| graphiql: { | ||
| defaultQuery: `query DomainsByOwner { | ||
| account(by: { address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.