Retain Git operation authority while validated provider receipts are credited, preserving teardown fences and owned nested operations. Vendor ntig 0.6.1 for libgit2 sideband push reports and bounded gzip fetch negotiation. Include complete source provenance and regression coverage.
- commit
- 5d3126716ae5b6bf01d46cdf14fceb05244445e0
- parent
- 7793438373191af8e6c9972a1f4bf51cbcc885a2
- author
- Dami <dami.etoile@gmail.com>
| M | package-lock.json | 8 | ++-- |
| M | package.json | 2 | +- |
| M | src/ | 11 | ++++-- |
| M | src/ | 21 | +++++++++++ |
| M | test/ | 39 | ++++++++++++++++++++- |
| M | test/ | 58 | ++++++++++++++++++++++++++++++ |
| M | vendor/ | 15 | +++++--- |
| A | vendor/ | bin | |
| A | vendor/ | bin |
@@ -10,7 +10,7 @@
1010 "@noble/hashes": "^2.4.0",1111 "fflate": "0.8.3",1212 "nostr-tools": "^2.25.1",13- "ntig": "file:vendor/ntig-0.6.0.tgz"13+ "ntig": "file:vendor/ntig-0.6.1.tgz"1414 },1515 "devDependencies": {1616 "@cloudflare/vitest-pool-workers": "^0.22.0",@@ -2755,9 +2755,9 @@
27552755 "license": "MIT"27562756 },27572757 "node_modules/ntig": {2758- "version": "0.6.0",2759- "resolved": "file:vendor/ntig-0.6.0.tgz",2760- "integrity": "sha512-1ffRqrX/ab3f3sp2nYTuPmtOnoG1fnEwA9mwAud8Rk/Walnh/63nNcUWIrr/8Ouys1Vnu7tUIvEfRQquJ76ENQ==",2758+ "version": "0.6.1",2759+ "resolved": "file:vendor/ntig-0.6.1.tgz",2760+ "integrity": "sha512-DXBuOwApkCozClXB78UjJpe2JHQ4NX2dVSOZj+2W3gQU4qePzLqY5mrBz2ne+JCTnrjI53lZ6oVE1h88R/QZ8A==",27612761 "license": "MIT",27622762 "dependencies": {27632763 "@noble/hashes": "2.0.1",@@ -1012,11 +1012,14 @@ export class Relay extends DurableObject<Env> {
10121012 // acceptAny routes the special kinds (zap receipts, NIP-56 reports) and10131013 // falls back to the ordinary write path. Callers broadcast when stored.10141014 async acceptAny(e: Event, conn: ConnState): Promise<{ ok: boolean; msg: string; stored: boolean }> {1015+ if (e.kind === 9735) {1016+ const receipt = await this.repositoryAccess.runFuel(1017+ () => acceptReceipt(this, e, conn.host),1018+ () => null,1019+ );1020+ if (receipt) return receipt;1021+ }10151022 return this.repositoryAccess.run("event", async () => {1016- if (e.kind === 9735) {1017- const r = await acceptReceipt(this, e, conn.host);1018- if (r) return r;1019- }10201023 if (e.kind === KIND_REPORT) return this.acceptReport(e, conn.host);10211024 if (isGroupManagement(e.kind) && (hasTag(e, "h") || isNIP43Request(e.kind))) return this.acceptGroup(e, conn);10221025 return this.accept(e, conn);@@ -23,6 +23,27 @@ export class RepositoryAccess {
2323 finally { this.release(lease); }2424 }2525 26+ // Fuel receipts are provider-signed credits, not ordinary relay writes.27+ // They may be recorded while a Git transfer holds the long-lived lease so28+ // a payment can unblock that transfer. Other exclusive operations still29+ // fence them, and an idle relay uses the normal event lease.30+ async runFuel<T>(work: () => Promise<T> | T, refused: () => Promise<T> | T): Promise<T> {31+ if (this.active && this.active.kind !== "git") {32+ // Preserve the existing nested-work behavior for a caller that already33+ // owns control (for example, the authenticated bridge route). A34+ // separate caller remains fenced by the active operation.35+ if (this.owned) return this.run("event", work, refused);36+ return refused();37+ }38+ if (this.active?.kind === "git") {39+ const lease = this.active;40+ lease.holders++;41+ try { return await this.context.run(lease, work); }42+ finally { this.release(lease); }43+ }44+ return this.run("event", work, refused);45+ }46+2647 // A streamed Git response still reads repository state after fetch returns.2748 // Carry authority into each pull, and release it on EOF, error or disconnect.2849 async response(kind: Kind, work: () => Promise<Response>, refused: () => Response): Promise<Response> {@@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest";
33 import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";44 import { getToken } from "nostr-tools/nip98";55 import type { Relay } from "../../src/relay.ts";6-import { now, ev, rpc } from "../helpers/relay.ts";6+import { now, ev, pk, rpc } from "../helpers/relay.ts";77 import { WS } from "../helpers/ws.ts";88 99 const SERVICE = "ab".repeat(32);@@ -45,6 +45,43 @@ function receipt(req: Event, bolt11: string, signer = provider, p = SERVICE) {
4545 }4646 4747 describe("fuel", () => {48+ it("credits a provider receipt while a Git response is streaming", async () => {49+ const host = "fuel-stream.bind.ws";50+ await mockProvider("fuel-stream");51+ const owner = generateSecretKey();52+ const payer = generateSecretKey();53+ await claim(host, owner);54+ const req = zapRequest(payer, host, 1_000_000);55+ const good = receipt(req, "lnbc10u1streaminvoice");56+ const forged = receipt(req, "lnbc10u1forgedinvoice", generateSecretKey());57+ const stub = env.RELAY.getByName("fuel-stream");58+59+ await runInDurableObject(stub, async (relay: Relay) => {60+ const response = await relay.repositoryAccess.response(61+ "git",62+ async () => new Response(new ReadableStream<Uint8Array>({ start() {} })),63+ () => new Response("busy", { status: 429 }),64+ );65+ expect(relay.repositoryAccess.busy).toBe(true);66+67+ const credited = await relay.acceptAny(good, relay.virtualConn(host, pk(provider)));68+ expect(credited.ok).toBe(true);69+ expect(credited.msg).toBe("fuel: credited 1000 sats");70+ expect(relay.fuelStatus().creditedMsats).toBe(1_000_000);71+72+ const ordinary = await relay.acceptAny(ev(owner, 1, "during Git"), relay.virtualConn(host, pk(owner)));73+ expect(ordinary.ok).toBe(false);74+ expect(ordinary.msg).toContain("relay operation in progress");75+76+ const invalid = await relay.acceptAny(forged, relay.virtualConn(host, pk(owner)));77+ expect(invalid.ok).toBe(false);78+ expect(invalid.msg).toContain("relay operation in progress");79+80+ await response.body!.cancel();81+ expect(relay.repositoryAccess.busy).toBe(false);82+ });83+ });84+4885 it("issues invoices, credits valid receipts once, ignores forgeries, and lifts the out-of-fuel restriction", async () => {4986 const host = "fuel-a.bind.ws";5087 await mockProvider("fuel-a");@@ -63,6 +63,64 @@ describe("Repository admission", () => {
6363 expect(access.busy).toBe(false);6464 });6565 66+ it("admits provider fuel receipts during Git but fences them during other work", async () => {67+ const access = new RepositoryAccess();68+ const entered = latch();69+ const finish = latch();70+ const active = access.run<string>("git", async () => {71+ entered.release();72+ await finish.promise;73+ return "done";74+ }, refused);75+ await entered.promise;76+ expect(await access.runFuel(() => "credited", refused)).toBe("credited");77+ finish.release();78+ await active;79+80+ const controlEntered = latch();81+ const controlFinish = latch();82+ const control = access.run<string>("control", async () => {83+ controlEntered.release();84+ await controlFinish.promise;85+ return "done";86+ }, refused);87+ await controlEntered.promise;88+ expect(await access.runFuel(() => "unexpected", refused)).toBe("refused");89+ controlFinish.release();90+ await control;91+ await access.run<void>("control", async () => {92+ expect(await access.runFuel(() => "nested", refused)).toBe("nested");93+ }, () => undefined);94+ expect(access.busy).toBe(false);95+ });96+97+ it("keeps the Git lease alive until a concurrent fuel receipt finishes", async () => {98+ const access = new RepositoryAccess();99+ const gitEntered = latch();100+ const gitFinish = latch();101+ const fuelEntered = latch();102+ const fuelFinish = latch();103+ const git = access.run<string>("git", async () => {104+ gitEntered.release();105+ await gitFinish.promise;106+ return "done";107+ }, refused);108+ await gitEntered.promise;109+ const fuel = access.runFuel(async () => {110+ fuelEntered.release();111+ await fuelFinish.promise;112+ return "credited";113+ }, refused);114+ await fuelEntered.promise;115+ gitFinish.release();116+ await git;117+ expect(access.busy).toBe(true);118+ expect(await access.run("teardown", () => "unexpected", refused)).toBe("refused");119+ fuelFinish.release();120+ expect(await fuel).toBe("credited");121+ expect(access.busy).toBe(false);122+ });123+66124 it("releases synchronous and asynchronous owners after failures", async () => {67125 const access = new RepositoryAccess();68126 expect(() => access.sync("event", () => { throw new Error("sync"); }, refused)).toThrow("sync");@@ -1,6 +1,6 @@
11 # Vendored dependencies22 3-`ntig-0.6.0.tgz` supplies Git pack validation, Smart HTTP and Nostr repository3+`ntig-0.6.1.tgz` supplies Git pack validation, Smart HTTP and Nostr repository44 policy wrappers. Bindws stores Git objects, refs and retry receipts in its55 relay Durable Object's SQLite database. The package's R2 adapters remain66 available to other ntig consumers; bindws does not use them for Git storage.@@ -11,14 +11,19 @@ adds configurable transfer limits, streamed indexed downloads, incremental
1111 hashing and the large-push HTTP probe. It includes the sibling checkout's1212 preexisting development changes; this is not an upstream registry release.1313 14+Version 0.6.1 adds receive-pack sideband reports for libgit2/ngit and bounded15+gzip decoding for native Git's larger fetch negotiations. Plain Git requests16+retain their existing framing. Both native Git and libgit2 pushes were checked17+against the local handler; the full library suite and package checks passed.18+1419 | Fact | Value |1520 |---|---|1621 | Source repository | [FelineStateMachine/ntig](https://github.com/FelineStateMachine/ntig) |1722 | Checkout base | `caa8258e6cf6ec3220b89312d66860258e096f0f`, plus the preserved 0.5.0 behavior and local changes |18-| Source commit | [`52145156e06cd1d8e360c067bc0271f85d36dbd9`](https://github.com/FelineStateMachine/ntig/commit/52145156e06cd1d8e360c067bc0271f85d36dbd9) |19-| Source archive | `ntig-source-0.6.0.tar.gz`; `SOURCE-SNAPSHOT.json` records the complete source file hashes |20-| Package SHA-256 | `7d517c5acd0de2ecca044dc744a5af275ce3710089be698d9d3aa816c1aac94f` |21-| Source archive SHA-256 | `9f6818e9e603fd1f383141f8a8a481371a08863fa351fddb5a8a73fc39b8e52a` |23+| Source commit | [`7b1a517ef9a1f05a412520d15ec5e13c0aea1adb`](https://github.com/FelineStateMachine/ntig/commit/7b1a517ef9a1f05a412520d15ec5e13c0aea1adb) |24+| Source archive | `ntig-source-0.6.1.tar.gz`; `SOURCE-SNAPSHOT.json` records the source commit and complete source file hashes |25+| Package SHA-256 | `4b61ecd241fa1ce8745e543597aa50e8076c6cc0e2499ff9e15dd91f1a9913eb` |26+| Source archive SHA-256 | `4e790e8120d416f24e543ac3384baea8bf70647565a531748ed2d239434528bc` |2227 | License | MIT, included in both archives |2328 | Library build | `npm ci --ignore-scripts`, then `npm run build:library` in the extracted source |2429 | Package build | `npm pack` in the extracted source |Binary file.
Binary file.