Skip to content

Commit ffae07f

Browse files
authored
Merge branch 'master' into rayane/event-ika-polling
2 parents f181fe4 + 22a77a9 commit ffae07f

File tree

10 files changed

+191
-34
lines changed

10 files changed

+191
-34
lines changed

api/btcindexer/api.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import (
1010
)
1111

1212
type Client struct {
13-
baseUrl string
14-
c http.Client
13+
baseUrl string
14+
authToken string
15+
c http.Client
1516
}
1617

1718
const (
@@ -20,10 +21,11 @@ const (
2021
pathDepositsBySender = "/bitcoin/deposits/"
2122
)
2223

23-
func NewClient(workerUrl string) Client {
24+
func NewClient(workerUrl string, authToken string) Client {
2425
return Client{
25-
baseUrl: workerUrl,
26-
c: http.Client{Timeout: time.Second * 30},
26+
baseUrl: workerUrl,
27+
authToken: authToken,
28+
c: http.Client{Timeout: time.Second * 30},
2729
}
2830
}
2931

@@ -38,6 +40,9 @@ func (c Client) PutBlocks(putBlocks PutBlocksReq) (*http.Response, error) {
3840
return nil, err
3941
}
4042
req.Header.Set("Content-Type", "application/msgpack")
43+
if c.authToken != "" {
44+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.authToken))
45+
}
4146
return c.c.Do(req)
4247
}
4348

api/btcindexer/api_test.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@ import (
44
"encoding/hex"
55
"fmt"
66
"io"
7+
"net/http"
78
"os"
9+
"strings"
810
"testing"
911

1012
"gotest.tools/v3/assert"
1113
)
1214

15+
type roundTripperFunc func(*http.Request) (*http.Response, error)
16+
17+
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
18+
return f(req)
19+
}
20+
1321
// The Bitcoin mainnet genesis block. See packages/btcindexer/src/api/put-blocks.test.ts
1422
const blockHex = "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c0101000000010000000000000000000000000000000000000000000000000000000000000000ffffffff4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000"
1523

@@ -45,11 +53,71 @@ func TestPutBlocksInt(t *testing.T) {
4553

4654
pb := PutBlock{Network: NetworkRegtest, Height: 156, Block: blockBz}
4755

48-
c := NewClient("http://localhost:8787")
56+
c := NewClient("http://localhost:8787", "")
4957
resp, err := c.PutBlocks(PutBlocksReq{pb})
5058
assert.NilError(t, err)
5159
respBody, err := io.ReadAll(resp.Body)
5260
assert.NilError(t, err)
5361
fmt.Println(string(respBody))
5462
assert.Equal(t, resp.StatusCode, 200)
5563
}
64+
65+
func TestClientPutBlocksAuthorizationHeader(t *testing.T) {
66+
tests := []struct {
67+
name string
68+
token string
69+
wantAuth string
70+
}{
71+
{
72+
name: "no auth token omits Authorization header",
73+
token: "",
74+
wantAuth: "",
75+
},
76+
{
77+
name: "non-empty auth token sets Authorization header",
78+
token: "my-secret-token",
79+
wantAuth: "Bearer my-secret-token",
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
var capturedReq *http.Request
86+
rt := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
87+
capturedReq = req
88+
89+
return &http.Response{
90+
StatusCode: http.StatusOK,
91+
Body: io.NopCloser(strings.NewReader(`{}`)),
92+
Header: make(http.Header),
93+
Request: req,
94+
}, nil
95+
})
96+
97+
httpClient := http.Client{
98+
Transport: rt,
99+
}
100+
101+
client := NewClient("http://localhost:8787", tt.token)
102+
client.c = httpClient
103+
104+
_, err := client.PutBlocks(PutBlocksReq{})
105+
assert.NilError(t, err)
106+
107+
if capturedReq == nil {
108+
t.Fatalf("expected request to be captured")
109+
}
110+
111+
gotAuth := capturedReq.Header.Get("Authorization")
112+
if tt.wantAuth == "" {
113+
if gotAuth != "" {
114+
t.Fatalf("expected no Authorization header, got %q", gotAuth)
115+
}
116+
} else {
117+
if gotAuth != tt.wantAuth {
118+
t.Fatalf("expected Authorization header %q, got %q", tt.wantAuth, gotAuth)
119+
}
120+
}
121+
})
122+
}
123+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"license": "MPL-2.0",
55
"private": true,
66
"type": "module",
7-
"packageManager": "bun@1.3.1",
7+
"packageManager": "bun@1.3.9",
88
"sideEffects": false,
99
"scripts": {
1010
"prepare": "cd .git/hooks; ln -s -f ../../contrib/git-hooks/pre-commit ./",

packages/block-ingestor/src/api/client.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,34 @@
11
import { type PutBlock, PutBlocksReq } from "./put-blocks";
22

33
export const RestPath = {
4-
blocks: "/",
4+
blocks: "/bitcoin/blocks",
55
};
66

77
export enum ContentType {
88
MSG_PACK = "application/vnd.msgpack",
99
}
1010

11-
const msgPackHeaders = {
12-
"Content-Type": ContentType.MSG_PACK,
13-
};
14-
1511
export class BtcIndexerClient {
1612
#url: string;
17-
constructor(url: string) {
13+
#authToken?: string;
14+
15+
constructor(url: string, authToken?: string) {
1816
this.#url = url.endsWith("/") ? url.slice(0, -1) : url;
17+
this.#authToken = authToken;
1918
}
2019

2120
async putBlocks(blocks: PutBlock[]): Promise<void> {
2221
const req = PutBlocksReq.encode(blocks);
22+
const headers: Record<string, string> = {
23+
"Content-Type": ContentType.MSG_PACK,
24+
};
25+
if (this.#authToken) {
26+
headers["Authorization"] = `Bearer ${this.#authToken}`;
27+
}
28+
2329
const res = await fetch(this.#url + RestPath.blocks, {
24-
method: "POST",
25-
headers: msgPackHeaders,
30+
method: "PUT",
31+
headers,
2632
body: req,
2733
});
2834
if (!res.ok) {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, it, expect } from "bun:test";
2+
import { isAuthorized } from "@gonative-cc/lib/auth";
3+
4+
describe("block-ingestor auth helper", () => {
5+
const env = {
6+
AUTH_BEARER_TOKEN: "test-token",
7+
BtcBlocks: {} as KVNamespace,
8+
BlockQueue: {} as Queue,
9+
BtcIndexer: {} as Fetcher,
10+
} as unknown as Env;
11+
12+
it("should return false if no auth header", () => {
13+
const request = new Request("http://localhost");
14+
expect(isAuthorized(request.headers, env.AUTH_BEARER_TOKEN)).toBe(false);
15+
});
16+
17+
it("should return false if token mismatch", () => {
18+
const request = new Request("http://localhost", {
19+
headers: {
20+
Authorization: "Bearer wrong-token",
21+
},
22+
});
23+
expect(isAuthorized(request.headers, env.AUTH_BEARER_TOKEN)).toBe(false);
24+
});
25+
26+
it("should return true if token matches", () => {
27+
const request = new Request("http://localhost", {
28+
headers: {
29+
Authorization: "Bearer test-token",
30+
},
31+
});
32+
expect(isAuthorized(request.headers, env.AUTH_BEARER_TOKEN)).toBe(true);
33+
});
34+
35+
it("should return false if AUTH_BEARER_TOKEN is missing in env", () => {
36+
const envMissing = {
37+
BtcBlocks: {} as KVNamespace,
38+
BlockQueue: {} as Queue,
39+
BtcIndexer: {} as Fetcher,
40+
} as unknown as Env;
41+
const request = new Request("http://localhost", {
42+
headers: {
43+
Authorization: "Bearer test-token",
44+
},
45+
});
46+
expect(isAuthorized(request.headers, envMissing.AUTH_BEARER_TOKEN)).toBe(false);
47+
});
48+
});

packages/block-ingestor/src/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { Router } from "itty-router";
22
import { PutBlocksReq } from "./api/put-blocks";
3+
import { isAuthorized } from "@gonative-cc/lib/auth";
34
import { handleIngestBlocks } from "./ingest";
45
import { type BtcIndexerRpc } from "@gonative-cc/btcindexer/rpc-interface";
56
import { logError } from "@gonative-cc/lib/logger";
67
import { btcNetFromString } from "@gonative-cc/lib/nbtc";
8+
import { RestPath } from "./api/client";
79

8-
const router = Router();
10+
export const router = Router();
11+
12+
router.put(RestPath.blocks, async (request, env: Env) => {
13+
if (!isAuthorized(request.headers, env.AUTH_BEARER_TOKEN)) {
14+
return new Response("Unauthorized", { status: 401 });
15+
}
916

10-
router.put("/bitcoin/blocks", async (request, env: Env) => {
1117
try {
1218
const blocks = PutBlocksReq.decode(await request.arrayBuffer());
1319
await handleIngestBlocks(blocks, env.BtcBlocks, env.BlockQueue);
@@ -45,7 +51,6 @@ function envBtcIndexer(env: Env): BtcIndexerRpc {
4551

4652
export default {
4753
async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
48-
// TODO: add authentication method here
4954
return router.handle(request, env);
5055
},
5156
};

packages/block-ingestor/worker-configuration.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
/* eslint-disable */
2-
// Generated by Wrangler by running `wrangler types` (hash: 0d3b4e2297618d67c6f58654954bb95b)
2+
// Generated by Wrangler by running `wrangler types` (hash: 4133e559722b1cf89e98b02f5af5306c)
33
// Runtime types generated with workerd@1.20251109.0 2025-06-20 nodejs_compat
44
declare namespace Cloudflare {
55
interface GlobalProps {
66
mainModule: typeof import("./src/index");
77
}
88
interface Env {
99
BtcBlocks: KVNamespace;
10+
AUTH_BEARER_TOKEN: "";
1011
BtcIndexer: Service /* entrypoint BtcIndexerRpc from btcindexer */;
1112
BlockQueue: Queue;
1213
}
1314
}
1415
interface Env extends Cloudflare.Env {}
16+
type StringifyValues<EnvType extends Record<string, unknown>> = {
17+
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
18+
};
19+
declare namespace NodeJS {
20+
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "AUTH_BEARER_TOKEN">> {}
21+
}
1522

1623
// Begin runtime types
1724
/*! *****************************************************************************

packages/block-ingestor/wrangler.jsonc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"queues": {
1414
"producers": [{ "queue": "block-queue", "binding": "BlockQueue" }],
1515
},
16+
"vars": {
17+
"AUTH_BEARER_TOKEN": "",
18+
},
1619
// "vars": {
1720
// },
1821
"services": [

packages/btcindexer/src/index.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,31 +11,18 @@ import { logError, logger } from "@gonative-cc/lib/logger";
1111
import HttpRouter from "./router";
1212
import { type BlockQueueRecord } from "@gonative-cc/lib/nbtc";
1313
import { processBlockBatch } from "./queue-handler";
14+
import { isAuthorized } from "@gonative-cc/lib/auth";
1415

1516
// Export RPC entrypoints for service bindings
1617
export { RPC } from "./rpc";
1718
export { RPCMock } from "./rpc-mock";
1819

1920
const router = new HttpRouter(undefined);
2021

21-
/**
22-
* Validates the Authorization header against the AUTH_BEARER_TOKEN env var.
23-
*/
24-
function isAuthorized(req: Request, env: Env): boolean {
25-
// If the token isn't set in the environment, we assume the endpoint is public
26-
if (!env.AUTH_BEARER_TOKEN) return true;
27-
28-
const authHeader = req.headers.get("Authorization");
29-
if (!authHeader || !authHeader.startsWith("Bearer ")) return false;
30-
31-
const token = authHeader.substring(7);
32-
return token === env.AUTH_BEARER_TOKEN;
33-
}
34-
3522
export default {
3623
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
3724
try {
38-
if (!isAuthorized(req, env)) {
25+
if (!isAuthorized(req.headers, env.AUTH_BEARER_TOKEN)) {
3926
return new Response("Unauthorized", { status: 401 });
4027
}
4128
const indexer = await indexerFromEnv(env);

packages/lib/src/auth.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { timingSafeEqual } from "node:crypto";
2+
3+
/**
4+
* Validates the Authorization header from request headers against an expected secret.
5+
* @param headers The request Headers object
6+
* @param expectedSecret The expected secret from environment variables
7+
* @returns true if authorized, false otherwise
8+
*/
9+
export function isAuthorized(headers: Headers, expectedSecret: string | undefined): boolean {
10+
if (!expectedSecret) {
11+
return false;
12+
}
13+
14+
const authHeader = headers.get("Authorization");
15+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
16+
return false;
17+
}
18+
19+
const token = authHeader.substring(7);
20+
21+
if (token.length !== expectedSecret.length) {
22+
return false;
23+
}
24+
25+
// we do that to prevent timing attacks
26+
const encoder = new TextEncoder();
27+
return timingSafeEqual(encoder.encode(token), encoder.encode(expectedSecret));
28+
}

0 commit comments

Comments
 (0)