diff --git a/package.json b/package.json index c9eaa16c..35067fba 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "e2e:rebase": "tsx --env-file-if-exists=.env.local --env-file-if-exists=.env scripts/e2e-stale-version-rebase.ts", "e2e:register-pdf": "tsx --env-file-if-exists=.env.local --env-file-if-exists=.env scripts/e2e-register-pdf.ts", "e2e:read-scope": "tsx scripts/e2e-read-scope.ts", + "e2e:write-api": "tsx scripts/e2e-write-api.ts", "e2e:read-scope-lite": "tsx scripts/e2e-read-scope-lite.ts", "validate": "npm run lint && npm run lint:eslint && npm run format:check && npm test", "prepare": "husky" diff --git a/packages/core/package.json b/packages/core/package.json index c59ee51d..6ba71ec5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -123,6 +123,10 @@ "./payment": { "types": "./dist/payment/index.d.ts", "import": "./dist/payment/index.js" + }, + "./write": { + "types": "./dist/write/index.d.ts", + "import": "./dist/write/index.js" } }, "scripts": { diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index c8f4e84c..e324c6b1 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -43,6 +43,7 @@ import type { GatewayClient, } from "@opendatalabs/vana-sdk/browser"; import type { ServerSigner } from "../signing/index.js"; +import type { WriterAttribution } from "../write/attribution.js"; import { buildChallenge, parsePaymentHeader, @@ -68,6 +69,24 @@ export interface PersonalServerReadAuthResult { grantId?: string; } +export interface PersonalServerWriteAuthInput { + request: Request; + /** Raw scope path param (validated by parseDataScopeContract after auth, + * same precedence as the owner write path). */ + scope: string; +} + +/** + * Result of authorizing a DELEGATED (write-session) write. A void return + * means the request was authorized as the owner instead — the ingest then + * proceeds exactly as today, with no attribution stamped. + */ +export interface PersonalServerWriteAuthResult { + builder: `0x${string}`; + grantId: string; + attribution: WriterAttribution; +} + export interface PersonalServerReadFulfillment { builder: string; fileId?: string; @@ -89,6 +108,18 @@ export interface PersonalServerApiAuthPort { authorizeBuilderRead( input: PersonalServerReadAuthInput, ): Promise; + /** + * Authorize a data ingest (POST /v1/data/:scope). Optional — auth ports + * that don't support delegated writes omit it and the handler falls back + * to authorizeOwner, preserving today's owner-only behavior. Ports that DO + * support write sessions handle BOTH paths here: a recognized session + * token authorizes as the builder (write policy + attribution proof) and + * returns the attribution to store; anything else falls through to the + * owner path and returns void. + */ + authorizeWrite?( + input: PersonalServerWriteAuthInput, + ): Promise; } export interface PersonalServerApiLogger { @@ -862,12 +893,42 @@ export async function handlePersonalServerDataRequest( } if (request.method === "POST") { - await deps.auth.authorizeOwner(request); + // Delegated writes: an auth port that supports write sessions handles + // both paths in authorizeWrite (builder session token -> write policy + + // attribution; anything else -> owner path). Ports without it keep + // today's owner-only gate. + let writeAuth: PersonalServerWriteAuthResult | undefined; + if (deps.auth.authorizeWrite) { + writeAuth = + (await deps.auth.authorizeWrite({ request, scope: scopeParam })) ?? + undefined; + } else { + await deps.auth.authorizeOwner(request); + } const scopeResult = parseDataScopeContract(scopeParam); if (!scopeResult.ok) return contractErrorResponse(scopeResult); const collectedAtValue = collectedAt(deps.now ?? (() => new Date())); const status = deps.syncManager ? "syncing" : "stored"; + // Builder writes land in the same access log as builder reads, so the + // owner sees who wrote what under which grant. + const logBuilderWrite = async (): Promise => { + if (!writeAuth) return; + await deps.accessLogWriter.write({ + logId: deps.createLogId?.() ?? crypto.randomUUID(), + grantId: writeAuth.grantId, + builder: writeAuth.builder, + action: "write", + scope: scopeResult.scope, + timestamp: (deps.now ?? (() => new Date()))().toISOString(), + ipAddress: + request.headers.get("x-forwarded-for") ?? + request.headers.get("x-real-ip") ?? + "unknown", + userAgent: request.headers.get("user-agent") ?? "unknown", + }); + }; + // Binary / unstructured data (e.g. a PDF): the body is raw bytes. DPv2 // data points are scope-addressed and carry no schemaId, so unstructured // data needs no schema at all — we ingest it schemaless. (Structured JSON @@ -883,6 +944,7 @@ export async function handlePersonalServerDataRequest( metadata: parseMetadataHeader(request.headers.get("x-vana-metadata")), collectedAt: collectedAtValue, status, + attribution: writeAuth?.attribution, }); if (!result.ok) return contractErrorResponse(result); deps.logger?.info?.( @@ -892,9 +954,11 @@ export async function handlePersonalServerDataRequest( path: result.writeResult.relativePath, mimeType: binaryMimeType(request), sizeBytes: bytes.length, + ...(writeAuth ? { builder: writeAuth.builder } : {}), }, "Binary data file ingested", ); + await logBuilderWrite(); notifyNewData(deps.syncManager); return jsonResponse(result.response, { status: 201 }); } @@ -912,6 +976,7 @@ export async function handlePersonalServerDataRequest( body: parsed.body, collectedAt: collectedAtValue, status, + attribution: writeAuth?.attribution, }); if (!result.ok) return contractErrorResponse(result); deps.logger?.info?.( @@ -919,9 +984,11 @@ export async function handlePersonalServerDataRequest( scope: scopeResult.scope, collectedAt: collectedAtValue, path: result.writeResult.relativePath, + ...(writeAuth ? { builder: writeAuth.builder } : {}), }, "Data file ingested", ); + await logBuilderWrite(); notifyNewData(deps.syncManager); return jsonResponse(result.response, { status: 201 }); } diff --git a/packages/core/src/contracts/data.ts b/packages/core/src/contracts/data.ts index 0903ad2d..d4c947b7 100644 --- a/packages/core/src/contracts/data.ts +++ b/packages/core/src/contracts/data.ts @@ -7,6 +7,11 @@ import { import { type WriteResult } from "../storage/hierarchy/index.js"; import { buildBinaryEnvelopeData, sha256Hex } from "./binary.js"; import { buildDataBlocksAsync } from "../storage/blocks/build.js"; +import { + hasReservedWriterKey, + stampWriterAttribution, + type WriterAttribution, +} from "../write/attribution.js"; export type DataContractErrorCode = "INVALID_SCOPE" | "INVALID_BODY" | "NOT_FOUND"; @@ -81,6 +86,13 @@ export interface IngestDataContractInput { body: unknown; collectedAt: string; status: "stored" | "syncing"; + /** + * Builder attribution for delegated (write-session) writes. When present, + * it is stamped into the envelope `data` under the reserved `$writtenBy` + * key so it travels through the unchanged encrypt/upload/register path. + * Owner writes pass nothing and the envelope is byte-identical to today. + */ + attribution?: WriterAttribution; } export interface IngestDataContractResult { @@ -105,6 +117,8 @@ export interface IngestBinaryDataContractInput { metadata?: unknown; collectedAt: string; status: "stored" | "syncing"; + /** Builder attribution for delegated writes (see IngestDataContractInput). */ + attribution?: WriterAttribution; } export interface DeleteDataScopeContractInput { @@ -280,10 +294,25 @@ export async function ingestDataContract( }; } + // The attribution key is server-stamped, never caller-supplied — a payload + // that already carries it could forge (or shadow) its own attribution. + if (input.attribution && hasReservedWriterKey(input.body)) { + return { + ok: false, + status: 400, + body: { + error: "INVALID_BODY", + message: "Request body must not contain the reserved $writtenBy key", + }, + }; + } + const envelope = createDataFileEnvelope( scopeResult.scope, input.collectedAt, - input.body, + input.attribution + ? stampWriterAttribution(input.body, input.attribution) + : input.body, ); const writeResult = await input.storage.writeEnvelope(envelope); try { @@ -349,7 +378,7 @@ export async function ingestBinaryDataContract( const envelope = createDataFileEnvelope( scopeResult.scope, input.collectedAt, - data, + input.attribution ? stampWriterAttribution(data, input.attribution) : data, ); const writeResult = await input.storage.writeEnvelope(envelope); try { diff --git a/packages/core/src/logging/access-log.ts b/packages/core/src/logging/access-log.ts index 9ef4ee0d..e8ed4619 100644 --- a/packages/core/src/logging/access-log.ts +++ b/packages/core/src/logging/access-log.ts @@ -2,7 +2,7 @@ export interface AccessLogEntry { logId: string; grantId: string; builder: string; - action: "read"; + action: "read" | "write"; scope: string; timestamp: string; ipAddress: string; diff --git a/packages/core/src/policy/data-read.ts b/packages/core/src/policy/data-read.ts index 1810006d..c625cc05 100644 --- a/packages/core/src/policy/data-read.ts +++ b/packages/core/src/policy/data-read.ts @@ -43,7 +43,14 @@ export interface DataReadPolicyPorts { runtimeAvailability?: RuntimeAvailabilityPort; } -function parseExpiresAtSeconds(value: unknown): number | null { +/** + * Parse a grant's `expiresAt` into unix seconds. Accepts the legacy + * uint256-seconds string, a numeric value, or the current gateway ISO + * timestamp. Returns 0 for "perpetual" encodings (null/undefined/"0") and + * null for unparseable input. Shared by the read and write policies so both + * stay aligned with the gateway response shape. + */ +export function parseGrantExpiresAtSeconds(value: unknown): number | null { if (value === null || value === undefined || value === "0") return 0; if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value !== "string") return null; @@ -101,7 +108,7 @@ export async function verifyDataReadPolicy( // DPv2 may surface either the legacy uint256-seconds string or the // current gateway ISO timestamp. Parse both so the policy stays aligned // with the gateway response shape. - const expiresAtSec = parseExpiresAtSeconds(grant.expiresAt); + const expiresAtSec = parseGrantExpiresAtSeconds(grant.expiresAt); if (expiresAtSec === null) { throw new ScopeMismatchError({ requestedScope: input.requestedScope, diff --git a/packages/core/src/policy/data-write.test.ts b/packages/core/src/policy/data-write.test.ts new file mode 100644 index 00000000..92e34b60 --- /dev/null +++ b/packages/core/src/policy/data-write.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + Builder, + GatewayGrantResponse, +} from "@opendatalabs/vana-sdk/browser"; +import { + scopeCoveredByWriteGrant, + verifyDataWritePolicy, + writeScopePatterns, +} from "./data-write.js"; +import { verifyDataReadPolicy } from "./data-read.js"; + +const BUILDER_ADDRESS = "0x0000000000000000000000000000000000000001"; +const BUILDER_ID = "0xbuilder1"; +const SERVER_OWNER = "0xOwner" as `0x${string}`; + +const builder: Builder = { + id: BUILDER_ID, + ownerAddress: "0xOwner", + granteeAddress: BUILDER_ADDRESS, + publicKey: "0x04key", + appUrl: "https://app.example.com", + addedAt: "2026-01-21T10:00:00.000Z", +}; + +// Write-grants ride the flat canary grant shape unchanged — the ONLY +// difference from a read-grant is the `write:` prefix on scope entries. +function makeGrant( + overrides: Partial = {}, +): GatewayGrantResponse { + return { + id: "grant-w-1", + grantorAddress: "0xOwner", + granteeId: BUILDER_ID, + scopes: ["write:notes.entries"], + status: "confirmed", + addedAt: "2026-01-21T10:00:00.000Z", + expiresAt: String(Math.floor(Date.now() / 1000) + 3600), + expired: false, + revokedAt: null, + revocationSignature: null, + paymentStatus: "paid", + paidAt: null, + paidBy: null, + grantVersion: "1", + settleTxHash: null, + settleSubmittedAt: null, + revocationTxHash: null, + revocationSubmittedAt: null, + fee: { + asset: "0x0000000000000000000000000000000000000000", + registrationFee: "0", + dataAccessFee: "0", + totalDue: "0", + }, + ...overrides, + }; +} + +function makePorts(grant: GatewayGrantResponse | null) { + return { + authSessionVerifier: { getBuilder: vi.fn().mockResolvedValue(builder) }, + grantVerifier: { getGrant: vi.fn().mockResolvedValue(grant) }, + }; +} + +describe("writeScopePatterns", () => { + it("extracts only write: entries, prefix stripped", () => { + expect( + writeScopePatterns([ + "write:notes.entries", + "instagram.profile", + "write:chatgpt.*", + ]), + ).toEqual(["notes.entries", "chatgpt.*"]); + }); + + it("ignores a bare write: entry with no pattern", () => { + expect(writeScopePatterns(["write:"])).toEqual([]); + }); +}); + +describe("scopeCoveredByWriteGrant", () => { + it("matches exact and wildcard write patterns", () => { + expect( + scopeCoveredByWriteGrant("notes.entries", ["write:notes.entries"]), + ).toBe(true); + expect( + scopeCoveredByWriteGrant("chatgpt.conversations", ["write:chatgpt.*"]), + ).toBe(true); + }); + + it("never matches plain (read) scope entries", () => { + expect(scopeCoveredByWriteGrant("notes.entries", ["notes.entries"])).toBe( + false, + ); + expect(scopeCoveredByWriteGrant("notes.entries", ["notes.*"])).toBe(false); + }); +}); + +describe("verifyDataWritePolicy", () => { + it("returns the grant when all invariants pass", async () => { + const grant = makeGrant(); + const result = await verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + makePorts(grant), + ); + expect(result).toBe(grant); + }); + + it("rejects when the grant only carries READ scopes for the target (separation of powers)", async () => { + const grant = makeGrant({ scopes: ["notes.entries", "write:other.scope"] }); + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + makePorts(grant), + ), + ).rejects.toMatchObject({ errorCode: "SCOPE_MISMATCH" }); + }); + + it("a write-grant never satisfies the READ policy for the same scope", async () => { + const grant = makeGrant(); + await expect( + verifyDataReadPolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + makePorts(grant), + ), + ).rejects.toMatchObject({ errorCode: "SCOPE_MISMATCH" }); + }); + + it("rejects a grant with no write scopes at all", async () => { + const grant = makeGrant({ scopes: ["notes.entries"] }); + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + makePorts(grant), + ), + ).rejects.toMatchObject({ errorCode: "SCOPE_MISMATCH" }); + }); + + it("rejects an unregistered builder", async () => { + const grant = makeGrant(); + const ports = makePorts(grant); + ports.authSessionVerifier.getBuilder.mockResolvedValue(null); + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + ports, + ), + ).rejects.toMatchObject({ errorCode: "UNREGISTERED_BUILDER" }); + }); + + it("rejects a missing grantId", async () => { + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + makePorts(makeGrant()), + ), + ).rejects.toMatchObject({ errorCode: "GRANT_REQUIRED" }); + }); + + it("rejects a revoked grant", async () => { + const grant = makeGrant({ revokedAt: "2026-01-22T00:00:00.000Z" }); + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + makePorts(grant), + ), + ).rejects.toMatchObject({ errorCode: "GRANT_REVOKED" }); + }); + + it("rejects an expired grant", async () => { + const grant = makeGrant({ + expiresAt: String(Math.floor(Date.now() / 1000) - 60), + }); + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + makePorts(grant), + ), + ).rejects.toMatchObject({ errorCode: "GRANT_EXPIRED" }); + }); + + it("treats a null expiresAt as perpetual", async () => { + const grant = makeGrant({ expiresAt: null }); + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + makePorts(grant), + ), + ).resolves.toBe(grant); + }); + + it("rejects a signer that is not the grant builder", async () => { + const grant = makeGrant({ granteeId: "0xother-builder" }); + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + makePorts(grant), + ), + ).rejects.toMatchObject({ errorCode: "INVALID_SIGNATURE" }); + }); + + it("rejects a grant issued by a different owner", async () => { + const grant = makeGrant({ grantorAddress: "0xSomeoneElse" }); + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + makePorts(grant), + ), + ).rejects.toMatchObject({ errorCode: "GRANT_OWNER_MISMATCH" }); + }); + + it("fails closed on a grantor-less grant", async () => { + const grant = makeGrant({ + grantorAddress: undefined as unknown as string, + }); + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + makePorts(grant), + ), + ).rejects.toMatchObject({ errorCode: "GRANT_OWNER_MISMATCH" }); + }); + + it("rejects when the runtime reports unavailable", async () => { + const grant = makeGrant(); + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + { + ...makePorts(grant), + runtimeAvailability: { isAvailable: () => false }, + }, + ), + ).rejects.toMatchObject({ errorCode: "PS_UNAVAILABLE" }); + }); + + it("invokes the fee seam after authorization passes, and propagates its rejection", async () => { + const grant = makeGrant(); + const assertWriteAllowed = vi + .fn() + .mockRejectedValue(new Error("fee required")); + await expect( + verifyDataWritePolicy( + { + signer: BUILDER_ADDRESS, + grantId: grant.id, + requestedScope: "notes.entries", + serverOwner: SERVER_OWNER, + }, + { + ...makePorts(grant), + writeFeeVerifier: { assertWriteAllowed }, + }, + ), + ).rejects.toThrow("fee required"); + expect(assertWriteAllowed).toHaveBeenCalledWith({ + builder: BUILDER_ADDRESS, + grant, + scope: "notes.entries", + }); + }); +}); diff --git a/packages/core/src/policy/data-write.ts b/packages/core/src/policy/data-write.ts new file mode 100644 index 00000000..ac6bd0d8 --- /dev/null +++ b/packages/core/src/policy/data-write.ts @@ -0,0 +1,208 @@ +import type { GatewayGrantResponse } from "@opendatalabs/vana-sdk/browser"; +import { scopeMatchesPattern } from "@opendatalabs/vana-sdk/browser"; +import { + GrantExpiredError, + GrantOwnerMismatchError, + GrantRequiredError, + GrantRevokedError, + InvalidSignatureError, + PsUnavailableError, + ScopeMismatchError, + ServerNotConfiguredError, + UnregisteredBuilderError, +} from "../errors/catalog.js"; +import { + type AuthSessionVerifierPort, + type GrantVerifierPort, + type RuntimeAvailabilityPort, +} from "../ports/index.js"; +import { parseGrantExpiresAtSeconds } from "./data-read.js"; + +/** + * Write-grant encoding: a grant scope entry prefixed with `write:` authorizes + * the grantee to WRITE into the scope it names (e.g. `write:notes.entries`, + * `write:notes.*`). The suffix uses the same pattern grammar as read scopes + * (`*` / `{prefix}.*` / exact), evaluated with the SDK's scopeMatchesPattern. + * + * Why a scope-entry prefix and not a new grant field: the gateway's grant + * shape (GatewayGrantResponse) has no permission axis — `scopes` is its only + * capability carrier — and the gateway validates scope entries as opaque + * non-empty strings, so `write:`-prefixed entries flow through createGrant / + * getGrant / the EIP-712 GrantRegistration signature unchanged. The Personal + * Server is the sole interpreter. + * + * Separation of powers falls out of the encoding: + * - read policy matches the REQUESTED scope against grant entries verbatim + * (scopeCoveredByGrant), and a requested scope never carries the prefix, + * so `write:x` entries can never satisfy a read; + * - write policy (below) only honors `write:`-prefixed entries, so plain + * read entries can never satisfy a write. + */ +export const WRITE_SCOPE_PREFIX = "write:"; + +export function isWriteScopeEntry(entry: string): boolean { + return entry.startsWith(WRITE_SCOPE_PREFIX); +} + +/** The scope patterns a grant authorizes for writing (prefix stripped). */ +export function writeScopePatterns(grantScopes: readonly string[]): string[] { + return grantScopes + .filter(isWriteScopeEntry) + .map((entry) => entry.slice(WRITE_SCOPE_PREFIX.length)) + .filter((pattern) => pattern.length > 0); +} + +export function scopeCoveredByWriteGrant( + requestedScope: string, + grantScopes: readonly string[], +): boolean { + return writeScopePatterns(grantScopes).some((pattern) => + scopeMatchesPattern(requestedScope, pattern), + ); +} + +/** + * Fee seam for builder writes. Write fee mechanics are undecided — the + * default (no port wired) is FREE. When a fee model lands (x402-style like + * reads, or something else), implement this port and wire it into + * DataWritePolicyPorts; the policy calls it after all authorization + * invariants pass, so a fee rejection never masks an auth failure. + */ +export interface WriteFeeVerifierPort { + assertWriteAllowed(input: { + builder: `0x${string}`; + grant: GatewayGrantResponse; + scope: string; + }): Promise; +} + +export interface DataWritePolicyInput { + signer: `0x${string}`; + grantId?: string; + requestedScope: string; + /** + * This server's owner address. The grant's grantor MUST equal it — a grant + * issued by a different owner is rejected. Required (not optional) so that + * TypeScript flags any caller that fails to bind the write to the server + * owner; the check also fails closed at runtime for untyped/JS callers. + */ + serverOwner: `0x${string}`; +} + +export interface DataWritePolicyPorts { + authSessionVerifier: AuthSessionVerifierPort; + grantVerifier: GrantVerifierPort; + runtimeAvailability?: RuntimeAvailabilityPort; + /** Absent = writes are free (see WriteFeeVerifierPort). */ + writeFeeVerifier?: WriteFeeVerifierPort; +} + +/** + * Authorize one builder write against a write-grant. Mirrors + * verifyDataReadPolicy invariant-for-invariant (builder registered, grant + * present / not revoked / not expired, scope coverage, grantee binding, + * owner binding) — but scope coverage only honors `write:`-prefixed grant + * entries, so a read-grant never confers write access. + */ +export async function verifyDataWritePolicy( + input: DataWritePolicyInput, + ports: DataWritePolicyPorts, +): Promise { + const available = await ports.runtimeAvailability?.isAvailable(); + if (available === false) { + throw new PsUnavailableError(); + } + + const builder = await ports.authSessionVerifier.getBuilder(input.signer); + if (!builder) { + throw new UnregisteredBuilderError(); + } + + if (!input.grantId) { + throw new GrantRequiredError({ + reason: "No grantId bound to the write session", + }); + } + + const grant = await ports.grantVerifier.getGrant(input.grantId); + if (!grant) { + throw new GrantRequiredError({ + reason: "Grant not found", + grantId: input.grantId, + }); + } + + if (grant.revokedAt !== null) { + throw new GrantRevokedError({ grantId: grant.id }); + } + + if (!grant.scopes || writeScopePatterns(grant.scopes).length === 0) { + throw new ScopeMismatchError({ + requestedScope: input.requestedScope, + reason: "Grant has no write scopes", + }); + } + + if (grant.expiresAt !== null && grant.expiresAt !== undefined) { + const expiresAtSec = parseGrantExpiresAtSeconds(grant.expiresAt); + if (expiresAtSec === null) { + throw new ScopeMismatchError({ + requestedScope: input.requestedScope, + reason: "Grant expiry is invalid", + }); + } + if (expiresAtSec > 0) { + const nowSec = Math.floor(Date.now() / 1000); + if (expiresAtSec < nowSec) { + throw new GrantExpiredError({ + expiresAt: expiresAtSec, + }); + } + } + } + + if (!scopeCoveredByWriteGrant(input.requestedScope, grant.scopes)) { + throw new ScopeMismatchError({ + requestedScope: input.requestedScope, + grantedScopes: grant.scopes, + reason: "Grant does not authorize writing to this scope", + }); + } + + if (builder.id.toLowerCase() !== grant.granteeId.toLowerCase()) { + throw new InvalidSignatureError({ + reason: "Write signer is not the grant builder", + expected: grant.granteeId, + actual: input.signer, + }); + } + + // Ownership binding — the grant MUST have been issued by THIS server's + // owner (same fail-closed rules as verifyDataReadPolicy: a missing + // serverOwner or a grantor-less gateway response rejects, never skips). + if (!input.serverOwner) { + throw new ServerNotConfiguredError({ + reason: "serverOwner is required to verify grant ownership", + }); + } + if ( + !grant.grantorAddress || + grant.grantorAddress.toLowerCase() !== input.serverOwner.toLowerCase() + ) { + throw new GrantOwnerMismatchError({ + grantId: grant.id, + expected: input.serverOwner, + actual: grant.grantorAddress ?? null, + }); + } + + // All authorization invariants passed — apply the fee seam last (default + // free; see WriteFeeVerifierPort). + await ports.writeFeeVerifier?.assertWriteAllowed({ + builder: input.signer, + grant, + scope: input.requestedScope, + }); + + return grant; +} diff --git a/packages/core/src/policy/index.ts b/packages/core/src/policy/index.ts index 64508af4..3bde5253 100644 --- a/packages/core/src/policy/index.ts +++ b/packages/core/src/policy/index.ts @@ -1,5 +1,16 @@ export { verifyDataReadPolicy, + parseGrantExpiresAtSeconds, type DataReadPolicyInput, type DataReadPolicyPorts, } from "./data-read.js"; +export { + WRITE_SCOPE_PREFIX, + isWriteScopeEntry, + writeScopePatterns, + scopeCoveredByWriteGrant, + verifyDataWritePolicy, + type DataWritePolicyInput, + type DataWritePolicyPorts, + type WriteFeeVerifierPort, +} from "./data-write.js"; diff --git a/packages/core/src/write/attribution.test.ts b/packages/core/src/write/attribution.test.ts new file mode 100644 index 00000000..70a9be59 --- /dev/null +++ b/packages/core/src/write/attribution.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import { + parseWeb3SignedHeader, + verifyWeb3Signed, +} from "@opendatalabs/vana-sdk/browser"; +import { + WRITE_SIGNATURE_HEADER, + WRITER_ATTRIBUTION_KEY, + hasReservedWriterKey, + stampWriterAttribution, + verifyWriterAttribution, + type WriterAttribution, +} from "./attribution.js"; +import { + buildWeb3SignedHeader, + createTestWallet, +} from "../test-utils/index.js"; + +const SERVER_ORIGIN = "http://localhost:8080"; +const builderWallet = createTestWallet(3); +const otherWallet = createTestWallet(4); +const GRANT_ID = "0xgrant_w1"; + +async function buildWriteRequest(params: { + body?: string; + signer?: typeof builderWallet; + header?: string | null; + signedBody?: string; +}): Promise { + const body = params.body ?? JSON.stringify({ note: "hello" }); + const bodyBytes = new TextEncoder().encode(params.signedBody ?? body); + const headers: Record = { + "Content-Type": "application/json", + Authorization: "Bearer vana_write_sessiontoken", + }; + if (params.header !== null) { + headers[WRITE_SIGNATURE_HEADER] = + params.header ?? + (await buildWeb3SignedHeader({ + wallet: params.signer ?? builderWallet, + aud: SERVER_ORIGIN, + method: "POST", + uri: "/v1/data/notes.entries", + body: bodyBytes, + })); + } + return new Request(`${SERVER_ORIGIN}/v1/data/notes.entries`, { + method: "POST", + headers, + body, + }); +} + +describe("verifyWriterAttribution", () => { + it("accepts a valid builder proof and returns a verifiable attribution", async () => { + const request = await buildWriteRequest({}); + const attribution = await verifyWriterAttribution({ + request, + builderAddress: builderWallet.address, + grantId: GRANT_ID, + serverOrigin: SERVER_ORIGIN, + }); + expect(attribution.builder).toBe(builderWallet.address); + expect(attribution.grantId).toBe(GRANT_ID); + expect(attribution.bodyHash).toMatch(/^sha256:[0-9a-f]{64}$/i); + + // The stored compact proof is independently verifiable: re-frame it as a + // header and run full verification against the original request shape. + const verified = await verifyWeb3Signed({ + headerValue: `Web3Signed ${attribution.signature}`, + expectedOrigin: SERVER_ORIGIN, + expectedMethod: "POST", + expectedPath: "/v1/data/notes.entries", + bodyBytes: new TextEncoder().encode(JSON.stringify({ note: "hello" })), + }); + expect(verified.signer.toLowerCase()).toBe( + builderWallet.address.toLowerCase(), + ); + // And the compact form round-trips through the parser. + expect(() => + parseWeb3SignedHeader(`Web3Signed ${attribution.signature}`), + ).not.toThrow(); + }); + + it("rejects a missing proof header", async () => { + const request = await buildWriteRequest({ header: null }); + await expect( + verifyWriterAttribution({ + request, + builderAddress: builderWallet.address, + grantId: GRANT_ID, + serverOrigin: SERVER_ORIGIN, + }), + ).rejects.toMatchObject({ errorCode: "WRITE_ATTRIBUTION_REQUIRED" }); + }); + + it("rejects a proof signed by a different key than the session builder", async () => { + const request = await buildWriteRequest({ signer: otherWallet }); + await expect( + verifyWriterAttribution({ + request, + builderAddress: builderWallet.address, + grantId: GRANT_ID, + serverOrigin: SERVER_ORIGIN, + }), + ).rejects.toMatchObject({ + errorCode: "WRITE_ATTRIBUTION_SIGNER_MISMATCH", + }); + }); + + it("rejects a proof whose bodyHash does not commit to the received bytes", async () => { + const request = await buildWriteRequest({ + signedBody: JSON.stringify({ note: "something else" }), + }); + await expect( + verifyWriterAttribution({ + request, + builderAddress: builderWallet.address, + grantId: GRANT_ID, + serverOrigin: SERVER_ORIGIN, + }), + ).rejects.toMatchObject({ errorCode: "WRITE_ATTRIBUTION_INVALID" }); + }); + + it("rejects a malformed header", async () => { + const request = await buildWriteRequest({ header: "Web3Signed not.valid" }); + await expect( + verifyWriterAttribution({ + request, + builderAddress: builderWallet.address, + grantId: GRANT_ID, + serverOrigin: SERVER_ORIGIN, + }), + ).rejects.toMatchObject({ errorCode: "WRITE_ATTRIBUTION_INVALID" }); + }); +}); + +describe("stampWriterAttribution", () => { + const attribution: WriterAttribution = { + builder: builderWallet.address, + grantId: GRANT_ID, + signature: "payload.sig", + bodyHash: "abc", + writtenAt: "2026-08-21T00:00:00.000Z", + }; + + it("stamps the attribution under the reserved key without touching payload fields", () => { + const stamped = stampWriterAttribution({ note: "hello" }, attribution); + expect(stamped.note).toBe("hello"); + expect(stamped[WRITER_ATTRIBUTION_KEY]).toEqual(attribution); + }); + + it("hasReservedWriterKey detects caller-supplied attribution", () => { + expect(hasReservedWriterKey({ note: "x" })).toBe(false); + expect(hasReservedWriterKey({ [WRITER_ATTRIBUTION_KEY]: {} })).toBe(true); + }); +}); diff --git a/packages/core/src/write/attribution.ts b/packages/core/src/write/attribution.ts new file mode 100644 index 00000000..0d4b0708 --- /dev/null +++ b/packages/core/src/write/attribution.ts @@ -0,0 +1,143 @@ +/** + * Builder attribution for delegated writes. + * + * The write-session token authorizes a write; it does not PROVE who authored + * the payload (a bearer token is not a signature). For cryptographic + * attribution the builder also signs the payload: a Web3Signed proof over the + * exact write request (aud / method / uri / bodyHash / iat / exp), carried in + * the `X-Vana-Write-Signature` header and signed by the builder key proven at + * handshake. The PS verifies the proof recovers to the session's builder and + * that its bodyHash commits to the received bytes, then stores the proof and + * the builder identity WITH the record — inside the envelope's `data` under + * the reserved `$writtenBy` key (same in-`data` marker idiom as `$binary`), + * so attribution travels through the unchanged encrypt / upload / register + * path and back out on read. The on-chain shape is untouched. + * + * A third party holding the record can verify authorship: decode the stored + * compact proof, recover the signer over its base64url payload (EIP-191), and + * check the payload's bodyHash against the original request body bytes. + */ + +import { + parseWeb3SignedHeader, + verifyWeb3Signed, +} from "@opendatalabs/vana-sdk/browser"; +import { ProtocolError } from "../errors/catalog.js"; + +/** Header carrying the builder's signed-payload proof on a session write. */ +export const WRITE_SIGNATURE_HEADER = "x-vana-write-signature"; + +/** + * Reserved key inside the envelope's `data` record for builder attribution. + * Mirrors the `$binary` marker convention (contracts/binary.ts). + */ +export const WRITER_ATTRIBUTION_KEY = "$writtenBy" as const; + +export interface WriterAttribution { + /** The builder address the proof recovered to. */ + builder: `0x${string}`; + /** The write-grant the record was written under. */ + grantId: string; + /** + * The builder's compact Web3Signed proof (`{base64url(payload)}.{sig}`, + * scheme prefix stripped). Verifiable offline: recover the EIP-191 signer + * over the base64url payload string; the decoded payload's `bodyHash` + * commits to the written bytes. + */ + signature: string; + /** `bodyHash` claim from the proof (sha-256 of the request body bytes). */ + bodyHash: string; + /** ISO timestamp the PS accepted the write. */ + writtenAt: string; +} + +export interface VerifyWriterAttributionInput { + request: Request; + /** The builder address bound to the write session at handshake. */ + builderAddress: `0x${string}`; + /** The grant the session (and therefore this write) is bound to. */ + grantId: string; + serverOrigin: string | (() => string); + now?: () => Date; +} + +function resolveOrigin(origin: string | (() => string)): string { + return typeof origin === "function" ? origin() : origin; +} + +/** + * Verify the `X-Vana-Write-Signature` proof on a session write and produce + * the attribution record to store with the data. Throws ProtocolError(401) + * when the proof is missing, malformed, expired, fails EIP-191 recovery / + * bodyHash binding, or recovers to a different key than the session builder. + */ +export async function verifyWriterAttribution( + input: VerifyWriterAttributionInput, +): Promise { + const headerValue = + input.request.headers.get(WRITE_SIGNATURE_HEADER) ?? undefined; + if (!headerValue) { + throw new ProtocolError( + 401, + "WRITE_ATTRIBUTION_REQUIRED", + `Session writes must carry a builder-signed payload proof in ${WRITE_SIGNATURE_HEADER}`, + ); + } + + const url = new URL(input.request.url); + const bodyBytes = new Uint8Array(await input.request.clone().arrayBuffer()); + + let verified; + try { + verified = await verifyWeb3Signed({ + headerValue, + expectedOrigin: resolveOrigin(input.serverOrigin), + expectedMethod: input.request.method, + expectedPath: url.pathname, + bodyBytes, + }); + } catch (err) { + throw new ProtocolError( + 401, + "WRITE_ATTRIBUTION_INVALID", + err instanceof Error ? err.message : String(err), + ); + } + + if (verified.signer.toLowerCase() !== input.builderAddress.toLowerCase()) { + throw new ProtocolError( + 401, + "WRITE_ATTRIBUTION_SIGNER_MISMATCH", + "Payload proof is not signed by the session builder", + { expected: input.builderAddress, actual: verified.signer }, + ); + } + + // Store the compact `{payload}.{signature}` form (scheme prefix stripped) + // so verifiers don't need to know the header framing. + const { payloadBase64, signature } = parseWeb3SignedHeader(headerValue); + + return { + builder: input.builderAddress, + grantId: input.grantId, + signature: `${payloadBase64}.${signature}`, + bodyHash: verified.payload.bodyHash, + writtenAt: (input.now?.() ?? new Date()).toISOString(), + }; +} + +/** + * Stamp attribution into an envelope `data` record. The reserved key must not + * appear in the caller's payload — a builder must not be able to forge (or + * shadow) its own attribution. + */ +export function stampWriterAttribution( + data: Record, + attribution: WriterAttribution, +): Record { + return { ...data, [WRITER_ATTRIBUTION_KEY]: attribution }; +} + +export function hasReservedWriterKey(data: Record): boolean { + return Object.prototype.hasOwnProperty.call(data, WRITER_ATTRIBUTION_KEY); +} diff --git a/packages/core/src/write/index.ts b/packages/core/src/write/index.ts new file mode 100644 index 00000000..faff9133 --- /dev/null +++ b/packages/core/src/write/index.ts @@ -0,0 +1,21 @@ +export { + createInMemoryWriteProofReplayStore, + createInMemoryWriteSessionStore, + createWriteSession, + hashWriteSessionToken, + type CreateWriteSessionInput, + type CreateWriteSessionOptions, + type CreateWriteSessionResult, + type WriteProofReplayStore, + type WriteSessionRecord, + type WriteSessionStore, +} from "./session.js"; +export { + WRITE_SIGNATURE_HEADER, + WRITER_ATTRIBUTION_KEY, + hasReservedWriterKey, + stampWriterAttribution, + verifyWriterAttribution, + type VerifyWriterAttributionInput, + type WriterAttribution, +} from "./attribution.js"; diff --git a/packages/core/src/write/session.test.ts b/packages/core/src/write/session.test.ts new file mode 100644 index 00000000..46958d7a --- /dev/null +++ b/packages/core/src/write/session.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi } from "vitest"; +import { + createInMemoryWriteProofReplayStore, + createInMemoryWriteSessionStore, + createWriteSession, + hashWriteSessionToken, +} from "./session.js"; +import type { + AuthSessionVerifierPort, + GrantVerifierPort, +} from "../ports/index.js"; + +const BUILDER = "0xabc0000000000000000000000000000000000001" as const; +const OWNER = "0x0000000000000000000000000000000000000aaa" as const; + +function grant(overrides: Record = {}) { + return { + id: "grant_w1", + grantorAddress: OWNER, + granteeId: BUILDER, + scopes: ["write:notes.entries"], + revokedAt: null, + expiresAt: null, + ...overrides, + }; +} + +// Minimal fakes for the two ports createWriteSession uses. +function fakeGateway( + grantValue: unknown, + builderId: string | null = BUILDER, +): AuthSessionVerifierPort & GrantVerifierPort { + return { + getBuilder: async () => (builderId ? { id: builderId } : null), + getGrant: async () => grantValue, + } as unknown as AuthSessionVerifierPort & GrantVerifierPort; +} + +describe("createWriteSession", () => { + it("mints a token for a valid builder + write-grant and stores it by hash", async () => { + const store = createInMemoryWriteSessionStore(); + const gw = fakeGateway(grant()); + const result = await createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1" }, + { + store, + authSessionVerifier: gw, + grantVerifier: gw, + serverOwner: OWNER, + randomToken: () => "tok_write", + }, + ); + expect(result.accessToken).toBe("tok_write"); + expect(result.grantId).toBe("grant_w1"); + expect(result.writeScopes).toEqual(["notes.entries"]); + const rec = await store.getByTokenHash( + await hashWriteSessionToken("tok_write"), + ); + expect(rec?.builderAddress).toBe(BUILDER); + expect(rec?.grantId).toBe("grant_w1"); + expect(rec?.writeScopes).toEqual(["notes.entries"]); + }); + + it("rejects a grant with no write scopes (a read-grant cannot open a write session)", async () => { + const gw = fakeGateway(grant({ scopes: ["notes.entries"] })); + await expect( + createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1" }, + { + store: createInMemoryWriteSessionStore(), + authSessionVerifier: gw, + grantVerifier: gw, + serverOwner: OWNER, + randomToken: () => "t", + }, + ), + ).rejects.toMatchObject({ errorCode: "SCOPE_MISMATCH" }); + }); + + it("rejects an unregistered builder", async () => { + const gw = fakeGateway(grant(), null); + await expect( + createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1" }, + { + store: createInMemoryWriteSessionStore(), + authSessionVerifier: gw, + grantVerifier: gw, + serverOwner: OWNER, + randomToken: () => "t", + }, + ), + ).rejects.toMatchObject({ errorCode: "UNREGISTERED_BUILDER" }); + }); + + it("rejects a missing grant", async () => { + const gw = fakeGateway(null); + await expect( + createWriteSession( + { builderAddress: BUILDER, grantId: "grant_missing" }, + { + store: createInMemoryWriteSessionStore(), + authSessionVerifier: gw, + grantVerifier: gw, + serverOwner: OWNER, + randomToken: () => "t", + }, + ), + ).rejects.toMatchObject({ errorCode: "GRANT_REQUIRED" }); + }); + + it("rejects a revoked grant", async () => { + const gw = fakeGateway(grant({ revokedAt: "2026-01-22T00:00:00.000Z" })); + await expect( + createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1" }, + { + store: createInMemoryWriteSessionStore(), + authSessionVerifier: gw, + grantVerifier: gw, + serverOwner: OWNER, + randomToken: () => "t", + }, + ), + ).rejects.toMatchObject({ errorCode: "GRANT_REVOKED" }); + }); + + it("rejects a handshake signer that is not the grant builder", async () => { + const gw = fakeGateway(grant({ granteeId: "0xother" })); + await expect( + createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1" }, + { + store: createInMemoryWriteSessionStore(), + authSessionVerifier: gw, + grantVerifier: gw, + serverOwner: OWNER, + randomToken: () => "t", + }, + ), + ).rejects.toMatchObject({ errorCode: "INVALID_SIGNATURE" }); + }); + + it("rejects a grant issued by a grantor that is not the server owner", async () => { + const gw = fakeGateway(grant({ grantorAddress: "0xNotTheOwner" })); + await expect( + createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1" }, + { + store: createInMemoryWriteSessionStore(), + authSessionVerifier: gw, + grantVerifier: gw, + serverOwner: OWNER, + randomToken: () => "t", + }, + ), + ).rejects.toMatchObject({ errorCode: "GRANT_OWNER_MISMATCH" }); + }); + + it("fails closed on a grant with no grantor", async () => { + const gw = fakeGateway(grant({ grantorAddress: null })); + await expect( + createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1" }, + { + store: createInMemoryWriteSessionStore(), + authSessionVerifier: gw, + grantVerifier: gw, + serverOwner: OWNER, + randomToken: () => "t", + }, + ), + ).rejects.toMatchObject({ errorCode: "GRANT_OWNER_MISMATCH" }); + }); + + it("rejects a replayed handshake proof (same proof id, still live)", async () => { + const gw = fakeGateway(grant()); + const replayStore = createInMemoryWriteProofReplayStore(); + const options = { + store: createInMemoryWriteSessionStore(), + authSessionVerifier: gw, + grantVerifier: gw, + serverOwner: OWNER, + randomToken: () => `t_${Math.random()}`, + replayStore, + }; + const proof = { id: "proof-1", expiresAtMs: Date.now() + 60_000 }; + await createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1", proof }, + options, + ); + await expect( + createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1", proof }, + options, + ), + ).rejects.toMatchObject({ errorCode: "WRITE_SESSION_PROOF_REPLAY" }); + }); + + it("releases the proof when session persistence fails, so a retry succeeds", async () => { + const gw = fakeGateway(grant()); + const replayStore = createInMemoryWriteProofReplayStore(); + const failingStore = { + create: vi.fn().mockRejectedValueOnce(new Error("disk full")), + getByTokenHash: vi.fn().mockResolvedValue(null), + }; + const proof = { id: "proof-2", expiresAtMs: Date.now() + 60_000 }; + const options = { + store: failingStore, + authSessionVerifier: gw, + grantVerifier: gw, + serverOwner: OWNER, + randomToken: () => "t", + replayStore, + }; + await expect( + createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1", proof }, + options, + ), + ).rejects.toThrow("disk full"); + // Same proof retries cleanly after the rollback. + failingStore.create.mockResolvedValueOnce(undefined); + await expect( + createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1", proof }, + options, + ), + ).resolves.toMatchObject({ grantId: "grant_w1" }); + }); + + it("expires stored sessions", async () => { + const store = createInMemoryWriteSessionStore(); + const gw = fakeGateway(grant()); + const now = Date.now(); + await createWriteSession( + { builderAddress: BUILDER, grantId: "grant_w1" }, + { + store, + authSessionVerifier: gw, + grantVerifier: gw, + serverOwner: OWNER, + randomToken: () => "tok_exp", + ttlMs: -1, + now: () => now, + }, + ); + expect( + await store.getByTokenHash(await hashWriteSessionToken("tok_exp")), + ).toBeNull(); + }); +}); diff --git a/packages/core/src/write/session.ts b/packages/core/src/write/session.ts new file mode 100644 index 00000000..0e6c2cb4 --- /dev/null +++ b/packages/core/src/write/session.ts @@ -0,0 +1,234 @@ +/** + * Write API session (server-signed delegated writes, v1). + * + * A registered builder that holds its OWN key and a WRITE-grant proves control + * of that key ONCE — a Web3Signed handshake to `POST /v1/write/session` — and + * the PS mints a short-lived bearer token bound to `{ builderAddress, grantId }` + * (the grant's `write:`-prefixed scope entries define what it may write). + * Writes then present that token on the EXISTING ingest endpoint + * (`POST /v1/data/:scope`); each write authorizes as the builder via + * `verifyDataWritePolicy` and the PS ingests / encrypts / uploads / registers + * through the normal owner path — the PS signs AddData as the owner exactly as + * it does today, and the builder never holds the owner key. + * + * Deliberately the same handshake shape as the self-signing MCP session + * (mcp/session.ts): prove key control once, short-lived bearer after. The + * session token is authorization only; per-write builder ATTRIBUTION is a + * separate signed proof (see ./attribution.ts). + */ + +import { hashConnectionToken } from "../mcp/connection-api.js"; +import { + createInMemoryMcpProofReplayStore, + type McpProofReplayStore, +} from "../mcp/session.js"; +import { writeScopePatterns } from "../policy/data-write.js"; +import type { + AuthSessionVerifierPort, + GrantVerifierPort, +} from "../ports/index.js"; +import { + GrantOwnerMismatchError, + GrantRequiredError, + GrantRevokedError, + InvalidSignatureError, + ProtocolError, + ScopeMismatchError, + UnregisteredBuilderError, +} from "../errors/catalog.js"; + +// The MCP session's proof-replay guard and token hashing are runtime-agnostic +// (Web Crypto + a Map); reuse them rather than duplicating. Re-exported under +// write-flavored names so callers don't couple to the MCP module. +export type WriteProofReplayStore = McpProofReplayStore; +export const createInMemoryWriteProofReplayStore = + createInMemoryMcpProofReplayStore; +export const hashWriteSessionToken = hashConnectionToken; + +export interface WriteSessionRecord { + /** SHA-256 hex of the raw session token. Only the hash is stored. */ + tokenHash: string; + /** The builder's address (recovered from the handshake proof). */ + builderAddress: `0x${string}`; + /** The write-grant id the builder was issued (grantee == builder). */ + grantId: string; + /** The grant's write patterns at handshake time (prefix stripped). */ + writeScopes: string[]; + createdAt: string; + expiresAtMs: number; +} + +export interface WriteSessionStore { + create(record: WriteSessionRecord): Promise; + /** Returns a live (non-expired) session, or null. */ + getByTokenHash(tokenHash: string): Promise; +} + +/** + * KNOWN LIMITATION: in-memory only, same trade-off as the MCP session store — + * a process restart drops live tokens and the builder must re-handshake. + * Acceptable for today's single-instance Personal Server; persist via the + * host's state store before multi-instance. + */ +export function createInMemoryWriteSessionStore(): WriteSessionStore { + const byHash = new Map(); + return { + async create(record) { + byHash.set(record.tokenHash, record); + }, + async getByTokenHash(tokenHash) { + const record = byHash.get(tokenHash); + if (!record) return null; + if (record.expiresAtMs <= Date.now()) { + byHash.delete(tokenHash); + return null; + } + return record; + }, + }; +} + +const DEFAULT_SESSION_TTL_MS = 60 * 60 * 1000; + +export interface CreateWriteSessionInput { + builderAddress: `0x${string}`; + grantId: string; + /** + * Handshake-proof replay guard. When supplied together with a `replayStore`, + * a proof id already seen (and still live) is rejected instead of minting a + * fresh token. `expiresAtMs` bounds how long the id is remembered (the + * proof's own expiry). + */ + proof?: { id: string; expiresAtMs: number }; +} + +export interface CreateWriteSessionOptions { + store: WriteSessionStore; + authSessionVerifier: AuthSessionVerifierPort; + grantVerifier: GrantVerifierPort; + /** + * This server's owner address. The grant's grantor MUST equal it — mirrors + * `verifyDataWritePolicy`'s ownership binding, applied at handshake so a + * wrong-owner grant fails with a clear error instead of minting a token + * whose every write would 403. + */ + serverOwner: `0x${string}`; + randomToken: () => string; + ttlMs?: number; + now?: () => number; + /** Optional replay guard for the handshake proof (see `input.proof`). */ + replayStore?: WriteProofReplayStore; +} + +export interface CreateWriteSessionResult { + accessToken: string; + expiresInSeconds: number; + grantId: string; + /** Write patterns (prefix stripped) the session may write into. */ + writeScopes: string[]; +} + +/** + * Validate the handshake identity and mint a write-session token. Same + * division of labor as createMcpSession: the handshake does the minimal + * checks that give a clear error (builder registered, grant exists + not + * revoked + carries write scopes, grantee == builder, grantor == owner); + * per-scope / expiry enforcement stays authoritative at write time, where + * `verifyDataWritePolicy` runs against the live grant on every POST. + */ +export async function createWriteSession( + input: CreateWriteSessionInput, + options: CreateWriteSessionOptions, +): Promise { + const builder = await options.authSessionVerifier.getBuilder( + input.builderAddress, + ); + if (!builder) throw new UnregisteredBuilderError(); + + const grant = await options.grantVerifier.getGrant(input.grantId); + if (!grant) { + throw new GrantRequiredError({ + reason: "Grant not found", + grantId: input.grantId, + }); + } + if (grant.revokedAt !== null) { + throw new GrantRevokedError({ grantId: grant.id }); + } + const patterns = writeScopePatterns(grant.scopes ?? []); + if (patterns.length === 0) { + throw new ScopeMismatchError({ + reason: + "Grant has no write scopes (write-grant entries use the write: prefix)", + grantedScopes: grant.scopes ?? [], + }); + } + if (builder.id.toLowerCase() !== grant.granteeId.toLowerCase()) { + throw new InvalidSignatureError({ + reason: "Handshake signer is not the grant builder", + expected: grant.granteeId, + actual: input.builderAddress, + }); + } + // Ownership binding — fail closed on a grantor-less grant: gateway + // responses are untrusted runtime data, despite their type. + if ( + !grant.grantorAddress || + grant.grantorAddress.toLowerCase() !== options.serverOwner.toLowerCase() + ) { + throw new GrantOwnerMismatchError({ + grantId: grant.id, + expected: options.serverOwner, + actual: grant.grantorAddress ?? null, + }); + } + + // Prepare the token first (deterministic, can't meaningfully fail) so the + // only fallible step after consuming the proof is persistence. + const token = options.randomToken(); + const tokenHash = await hashWriteSessionToken(token); + const ttlMs = options.ttlMs ?? DEFAULT_SESSION_TTL_MS; + const nowMs = options.now?.() ?? Date.now(); + + // Replay guard: a still-valid handshake proof must mint at most one token. + // Same consume/rollback discipline as createMcpSession. + const usingReplayGuard = Boolean(input.proof && options.replayStore); + if (input.proof && options.replayStore) { + const replayed = await options.replayStore.consume( + input.proof.id, + input.proof.expiresAtMs, + ); + if (replayed) { + throw new ProtocolError( + 401, + "WRITE_SESSION_PROOF_REPLAY", + "Handshake proof already used; sign a fresh proof", + ); + } + } + + try { + await options.store.create({ + tokenHash, + builderAddress: input.builderAddress, + grantId: grant.id, + writeScopes: patterns, + createdAt: new Date(nowMs).toISOString(), + expiresAtMs: nowMs + ttlMs, + }); + } catch (err) { + // Persistence failed — release the reservation so a legitimate retry with + // the same still-valid proof isn't rejected as a replay. + if (usingReplayGuard && input.proof && options.replayStore?.release) { + await options.replayStore.release(input.proof.id); + } + throw err; + } + + return { + accessToken: token, + expiresInSeconds: Math.floor(ttlMs / 1000), + grantId: grant.id, + writeScopes: patterns, + }; +} diff --git a/packages/server/src/api-auth.ts b/packages/server/src/api-auth.ts index c60b3dbb..87187be6 100644 --- a/packages/server/src/api-auth.ts +++ b/packages/server/src/api-auth.ts @@ -6,8 +6,18 @@ import { import type { PersonalServerApiAuthPort, PersonalServerReadAuthInput, + PersonalServerWriteAuthInput, + PersonalServerWriteAuthResult, } from "@opendatalabs/personal-server-ts-core/api"; -import { verifyDataReadPolicy } from "@opendatalabs/personal-server-ts-core/policy"; +import { + verifyDataReadPolicy, + verifyDataWritePolicy, +} from "@opendatalabs/personal-server-ts-core/policy"; +import { + hashWriteSessionToken, + verifyWriterAttribution, + type WriteSessionStore, +} from "@opendatalabs/personal-server-ts-core/write"; import { NotOwnerError, ProtocolError, @@ -28,6 +38,14 @@ export interface ServerApiAuthDeps { tokenStore?: SessionTokenVerifierPort; dataStorage?: Pick; runtimeAvailability?: RuntimeAvailabilityPort; + /** + * Write API sessions. When present, POST /v1/data/:scope accepts a bearer + * write-session token (minted by POST /v1/write/session) and authorizes the + * write as the session's builder via verifyDataWritePolicy + the + * X-Vana-Write-Signature attribution proof. Absent = owner-only ingest, + * exactly as before. + */ + writeSessionStore?: WriteSessionStore; } function serverNotConfigured(): ProtocolError { @@ -73,21 +91,80 @@ async function assertRegisteredBuilder( throw new UnregisteredBuilderError(); } +function bearerToken(request: Request): string | null { + const header = request.headers.get("authorization"); + if (!header?.startsWith("Bearer ")) return null; + return header.slice(7); +} + export function createServerApiAuth( deps: ServerApiAuthDeps, ): PersonalServerApiAuthPort { - return { - async authorizeOwner(request) { - const result = await authenticate(request, deps); - if (result.isPolicyBypass) return; - if (!deps.serverOwner) throw serverNotConfigured(); - if (!isOwner(result.auth.signer, deps.serverOwner)) { - throw new NotOwnerError({ - signer: result.auth.signer, - expected: deps.serverOwner, + async function authorizeOwner(request: Request): Promise { + const result = await authenticate(request, deps); + if (result.isPolicyBypass) return; + if (!deps.serverOwner) throw serverNotConfigured(); + if (!isOwner(result.auth.signer, deps.serverOwner)) { + throw new NotOwnerError({ + signer: result.auth.signer, + expected: deps.serverOwner, + }); + } + } + + /** + * Delegated ingest. A bearer token that resolves to a live write session + * authorizes as the session builder: the write policy re-runs against the + * LIVE grant (revocation / expiry / scope coverage stay authoritative per + * write), and the builder's X-Vana-Write-Signature payload proof is + * verified and returned for the handler to store with the record. Any + * other credential (owner Web3Signed, dev token, control-plane token, + * unknown bearer) falls through to the owner path unchanged. + */ + async function authorizeWrite( + input: PersonalServerWriteAuthInput, + ): Promise { + const token = bearerToken(input.request); + if (token && deps.writeSessionStore) { + const session = await deps.writeSessionStore.getByTokenHash( + await hashWriteSessionToken(token), + ); + if (session) { + if (!deps.serverOwner) throw serverNotConfigured(); + const grant = await verifyDataWritePolicy( + { + signer: session.builderAddress, + grantId: session.grantId, + requestedScope: input.scope, + serverOwner: deps.serverOwner, + }, + { + authSessionVerifier: deps.gateway, + grantVerifier: deps.gateway, + runtimeAvailability: deps.runtimeAvailability, + // Fee seam intentionally not wired: builder writes are free in + // the demo slice (write fee mechanics undecided). + }, + ); + const attribution = await verifyWriterAttribution({ + request: input.request, + builderAddress: session.builderAddress, + grantId: grant.id, + serverOrigin: deps.serverOrigin, }); + return { + builder: session.builderAddress, + grantId: grant.id, + attribution, + }; } - }, + } + await authorizeOwner(input.request); + } + + return { + authorizeOwner, + authorizeWrite, async authorizeBuilderList(request) { const result = await authenticate(request, deps); diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 9af63d83..d04fa974 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -13,6 +13,11 @@ import type { AccessLogReader } from "@opendatalabs/personal-server-ts-core/logg import type { PersonalServerReadFulfillmentReporter } from "@opendatalabs/personal-server-ts-core/api"; import { healthRoute, type HealthDeps } from "./routes/health.js"; import { dataRoutes } from "./routes/data.js"; +import { writeSessionRoutes } from "./routes/write-session.js"; +import { + createInMemoryWriteSessionStore, + type WriteSessionStore, +} from "@opendatalabs/personal-server-ts-core/write"; import { grantsRoutes } from "./routes/grants.js"; import { accessLogsRoutes } from "./routes/access-logs.js"; import { syncRoutes } from "./routes/sync.js"; @@ -107,11 +112,21 @@ export interface AppDeps { mcpOAuthAuthorizationStore?: McpOAuthAuthorizationStore; mcpOAuthApprovalUrl?: string | (() => string); mcpActivityRecorder?: McpActivityRecorder; + /** + * Write API session store shared between POST /v1/write/session (which + * mints tokens) and the ingest endpoint (which redeems them). Defaults to + * an in-memory store, mirroring the MCP connection store default. + */ + writeSessionStore?: WriteSessionStore; } export function createApp(deps: AppDeps): Hono { const app = new Hono(); + // One store for mint (POST /v1/write/session) and redeem (data ingest). + const writeSessionStore = + deps.writeSessionStore ?? createInMemoryWriteSessionStore(); + // CORS — allow all origins for browser-based clients app.use( "*", @@ -169,10 +184,26 @@ export function createApp(deps: AppDeps): Hono { gatewayUrl: deps.gatewayUrl ?? deps.config?.gateway.url ?? deps.gatewayConfig?.url, paymentEnabled: deps.paymentEnabled, + writeSessionStore, mountPath: "/v1/data", }), ); + // Mount the Write API session handshake (delegated builder writes). + app.route( + "/v1/write", + writeSessionRoutes({ + logger: deps.logger, + serverOrigin: deps.serverOrigin, + serverOwner: deps.serverOwner, + gateway: deps.gateway, + devToken: deps.devToken, + accessToken: deps.accessToken, + tokenStore: deps.tokenStore, + sessionStore: writeSessionStore, + }), + ); + // Mount grants routes (POST /verify is public, GET / and POST / need owner auth) app.route( "/v1/grants", diff --git a/packages/server/src/logging/access-log.ts b/packages/server/src/logging/access-log.ts index e9bfd32e..aa114e64 100644 --- a/packages/server/src/logging/access-log.ts +++ b/packages/server/src/logging/access-log.ts @@ -5,7 +5,7 @@ export interface AccessLogEntry { logId: string; grantId: string; builder: string; - action: "read"; + action: "read" | "write"; scope: string; timestamp: string; ipAddress: string; diff --git a/packages/server/src/routes/data-write.test.ts b/packages/server/src/routes/data-write.test.ts new file mode 100644 index 00000000..57fca2a5 --- /dev/null +++ b/packages/server/src/routes/data-write.test.ts @@ -0,0 +1,379 @@ +/** + * Delegated (Write API) ingest through the data routes: a write-session + * bearer token + a builder-signed attribution proof drive the EXISTING + * POST /v1/data/:scope path, and the record lands with `$writtenBy` stamped. + * Read-back separation: the write-grant never satisfies a builder read. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { pino } from "pino"; +import { initializeDatabase } from "../storage/index-schema.js"; +import { createIndexManager } from "../storage/index-manager.js"; +import type { HierarchyManagerOptions } from "@opendatalabs/personal-server-ts-core/storage/hierarchy"; +import type { GatewayClient, Builder } from "@opendatalabs/vana-sdk/node"; +import type { GatewayGrantResponse } from "@opendatalabs/vana-sdk/node"; +import type { AccessLogWriter } from "@opendatalabs/personal-server-ts-core/logging/access-log"; +import { + createTestWallet, + buildWeb3SignedHeader, +} from "@opendatalabs/personal-server-ts-core/test-utils"; +import { + WRITE_SIGNATURE_HEADER, + WRITER_ATTRIBUTION_KEY, + createInMemoryWriteSessionStore, + hashWriteSessionToken, + type WriteSessionStore, +} from "@opendatalabs/personal-server-ts-core/write"; +import { dataRoutes } from "./data.js"; + +const SERVER_ORIGIN = "http://localhost:8080"; +const builderWallet = createTestWallet(0); +const ownerWallet = createTestWallet(9); + +const BUILDER_ID = "0xbuilder1"; +const SESSION_TOKEN = "vana_write_test_token"; +const WRITE_GRANT_ID = "grant-w-1"; +const SCOPE = "notes.entries"; + +function createMockGateway( + overrides: Partial = {}, +): GatewayClient { + return { + isRegisteredBuilder: vi.fn().mockResolvedValue(true), + getBuilder: vi.fn().mockResolvedValue({ + id: BUILDER_ID, + ownerAddress: "0xOwner", + granteeAddress: builderWallet.address, + publicKey: "0x04key", + appUrl: "https://app.example.com", + addedAt: "2026-01-21T10:00:00.000Z", + } satisfies Builder), + getGrant: vi.fn().mockResolvedValue(null), + ...overrides, + } as unknown as GatewayClient; +} + +function makeGrant( + overrides: Partial = {}, +): GatewayGrantResponse { + return { + id: WRITE_GRANT_ID, + grantorAddress: ownerWallet.address, + granteeId: BUILDER_ID, + scopes: [`write:${SCOPE}`], + status: "confirmed", + addedAt: "2026-01-21T10:00:00.000Z", + expiresAt: null, + expired: false, + revokedAt: null, + revocationSignature: null, + paymentStatus: "paid", + paidAt: null, + paidBy: null, + grantVersion: "1", + settleTxHash: null, + settleSubmittedAt: null, + revocationTxHash: null, + revocationSubmittedAt: null, + fee: { + asset: "0x0000000000000000000000000000000000000000", + registrationFee: "0", + dataAccessFee: "0", + totalDue: "0", + }, + ...overrides, + }; +} + +const logger = pino({ level: "silent" }); + +async function seedSession(store: WriteSessionStore): Promise { + await store.create({ + tokenHash: await hashWriteSessionToken(SESSION_TOKEN), + builderAddress: builderWallet.address, + grantId: WRITE_GRANT_ID, + writeScopes: [SCOPE], + createdAt: new Date().toISOString(), + expiresAtMs: Date.now() + 60_000, + }); +} + +async function sessionWrite( + app: ReturnType, + scope: string, + body: unknown, + options: { + signatureWallet?: typeof builderWallet; + omitSignature?: boolean; + token?: string; + } = {}, +) { + const rawBody = JSON.stringify(body); + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${options.token ?? SESSION_TOKEN}`, + }; + if (!options.omitSignature) { + headers[WRITE_SIGNATURE_HEADER] = await buildWeb3SignedHeader({ + wallet: options.signatureWallet ?? builderWallet, + aud: SERVER_ORIGIN, + method: "POST", + uri: `/${scope}`, + body: new TextEncoder().encode(rawBody), + }); + } + return app.request(`/${scope}`, { + method: "POST", + headers, + body: rawBody, + }); +} + +describe("POST /v1/data/:scope with a write session", () => { + let dataDir: string; + let hierarchyOptions: HierarchyManagerOptions; + let app: ReturnType; + let cleanup: () => void; + let writeSessionStore: WriteSessionStore; + let accessLogWriter: AccessLogWriter; + let gateway: GatewayClient; + + beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), "data-write-route-test-")); + hierarchyOptions = { dataDir }; + + const db = initializeDatabase(":memory:"); + const indexManager = createIndexManager(db); + + writeSessionStore = createInMemoryWriteSessionStore(); + await seedSession(writeSessionStore); + accessLogWriter = { write: vi.fn().mockResolvedValue(undefined) }; + gateway = createMockGateway({ + getGrant: vi.fn().mockResolvedValue(makeGrant()), + }); + + app = dataRoutes({ + indexManager, + hierarchyOptions, + logger, + serverOrigin: SERVER_ORIGIN, + serverOwner: ownerWallet.address, + gateway, + accessLogWriter, + writeSessionStore, + }); + cleanup = () => { + indexManager.close(); + }; + }); + + afterEach(async () => { + cleanup(); + await rm(dataDir, { recursive: true, force: true }); + }); + + async function ownerRead(scope: string) { + const auth = await buildWeb3SignedHeader({ + wallet: ownerWallet, + aud: SERVER_ORIGIN, + method: "GET", + uri: `/${scope}`, + }); + return app.request(`/${scope}`, { headers: { Authorization: auth } }); + } + + it("accepts a session write and stores the record with builder attribution", async () => { + const res = await sessionWrite(app, SCOPE, { note: "written by builder" }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.scope).toBe(SCOPE); + expect(body.status).toBe("stored"); + + // Read back as owner: payload intact + $writtenBy stamped. + const read = await ownerRead(SCOPE); + expect(read.status).toBe(200); + const envelope = await read.json(); + expect(envelope.data.note).toBe("written by builder"); + const attribution = envelope.data[WRITER_ATTRIBUTION_KEY]; + expect(attribution.builder).toBe(builderWallet.address); + expect(attribution.grantId).toBe(WRITE_GRANT_ID); + expect(attribution.signature).toContain("."); + expect(attribution.bodyHash).toMatch(/^sha256:/); + + // The write landed in the access log under the grant. + expect(accessLogWriter.write).toHaveBeenCalledWith( + expect.objectContaining({ + action: "write", + builder: builderWallet.address, + grantId: WRITE_GRANT_ID, + scope: SCOPE, + }), + ); + }); + + it("rejects a session write without the attribution proof", async () => { + const res = await sessionWrite( + app, + SCOPE, + { note: "x" }, + { + omitSignature: true, + }, + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error.errorCode).toBe("WRITE_ATTRIBUTION_REQUIRED"); + }); + + it("rejects an attribution proof signed by a different key", async () => { + const res = await sessionWrite( + app, + SCOPE, + { note: "x" }, + { + signatureWallet: createTestWallet(5), + }, + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error.errorCode).toBe("WRITE_ATTRIBUTION_SIGNER_MISMATCH"); + }); + + it("rejects a session write to a scope the grant does not cover", async () => { + const res = await sessionWrite(app, "other.scope", { note: "x" }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error.errorCode).toBe("SCOPE_MISMATCH"); + }); + + it("re-checks the live grant per write: a revoked grant is rejected even with a live token", async () => { + (gateway.getGrant as ReturnType).mockResolvedValue( + makeGrant({ revokedAt: "2026-08-20T00:00:00.000Z" }), + ); + const res = await sessionWrite(app, SCOPE, { note: "x" }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error.errorCode).toBe("GRANT_REVOKED"); + }); + + it("rejects a payload that carries the reserved $writtenBy key", async () => { + const res = await sessionWrite(app, SCOPE, { + note: "x", + [WRITER_ATTRIBUTION_KEY]: { builder: "0xforged" }, + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("INVALID_BODY"); + }); + + it("falls through to owner auth for an unknown bearer token", async () => { + const res = await sessionWrite( + app, + SCOPE, + { note: "x" }, + { + token: "not-a-session-token", + }, + ); + expect(res.status).toBe(401); + }); + + it("owner writes remain unchanged: no attribution is stamped", async () => { + const rawBody = JSON.stringify({ note: "owner write" }); + const auth = await buildWeb3SignedHeader({ + wallet: ownerWallet, + aud: SERVER_ORIGIN, + method: "POST", + uri: `/${SCOPE}`, + body: new TextEncoder().encode(rawBody), + }); + const res = await app.request(`/${SCOPE}`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: auth }, + body: rawBody, + }); + expect(res.status).toBe(201); + + const read = await ownerRead(SCOPE); + const envelope = await read.json(); + expect(envelope.data.note).toBe("owner write"); + expect(envelope.data[WRITER_ATTRIBUTION_KEY]).toBeUndefined(); + }); + + it("a write-grant never satisfies a builder READ of the same scope", async () => { + // Land a record first (via the session write). + const write = await sessionWrite(app, SCOPE, { note: "secret" }); + expect(write.status).toBe(201); + + // Builder read authorized by the WRITE grant must fail scope coverage. + const auth = await buildWeb3SignedHeader({ + wallet: builderWallet, + aud: SERVER_ORIGIN, + method: "GET", + uri: `/${SCOPE}`, + grantId: WRITE_GRANT_ID, + }); + const res = await app.request(`/${SCOPE}`, { + headers: { Authorization: auth }, + }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error.errorCode).toBe("SCOPE_MISMATCH"); + }); + + it("a separate read-grant on the same scope authorizes the read-back", async () => { + const write = await sessionWrite(app, SCOPE, { note: "readable" }); + expect(write.status).toBe(201); + + const READ_GRANT_ID = "grant-r-1"; + (gateway.getGrant as ReturnType).mockImplementation( + async (id: string) => + id === READ_GRANT_ID + ? makeGrant({ id: READ_GRANT_ID, scopes: [SCOPE] }) + : makeGrant(), + ); + + const auth = await buildWeb3SignedHeader({ + wallet: builderWallet, + aud: SERVER_ORIGIN, + method: "GET", + uri: `/${SCOPE}`, + grantId: READ_GRANT_ID, + }); + const res = await app.request(`/${SCOPE}`, { + headers: { Authorization: auth }, + }); + expect(res.status).toBe(200); + const envelope = await res.json(); + expect(envelope.data.note).toBe("readable"); + // Attribution rides along on the read — verifiable by the reader. + expect(envelope.data[WRITER_ATTRIBUTION_KEY].builder).toBe( + builderWallet.address, + ); + }); + + it("rejects an expired session token", async () => { + const expired = "vana_write_expired_token"; + await writeSessionStore.create({ + tokenHash: await hashWriteSessionToken(expired), + builderAddress: builderWallet.address, + grantId: WRITE_GRANT_ID, + writeScopes: [SCOPE], + createdAt: new Date().toISOString(), + expiresAtMs: Date.now() - 1, + }); + const res = await sessionWrite( + app, + SCOPE, + { note: "x" }, + { + token: expired, + }, + ); + // Expired session is unknown to the store -> owner fall-through -> 401. + expect(res.status).toBe(401); + }); +}); diff --git a/packages/server/src/routes/data.ts b/packages/server/src/routes/data.ts index ad33628e..2cb57973 100644 --- a/packages/server/src/routes/data.ts +++ b/packages/server/src/routes/data.ts @@ -18,6 +18,7 @@ import type { } from "@opendatalabs/personal-server-ts-core/ports"; import type { ServerSigner } from "@opendatalabs/personal-server-ts-core/signing"; import type { TokenStore } from "../token-store.js"; +import type { WriteSessionStore } from "@opendatalabs/personal-server-ts-core/write"; import type { Logger } from "pino"; import { createBodyLimit, @@ -59,6 +60,12 @@ export interface DataRouteDeps { tokenStore?: TokenStore; dataStorage?: DataStoragePort; runtimeAvailability?: RuntimeAvailabilityPort; + /** + * Write API sessions (POST /v1/write/session). When present, the ingest + * endpoint accepts write-session bearer tokens for delegated builder + * writes; absent = owner-only ingest, unchanged. + */ + writeSessionStore?: WriteSessionStore; /** * Powers the RECORD_DATA_ACCESS attestation embedded in 402 challenges. * When supplied alongside serverOwner + paymentEnabled, every challenge @@ -93,6 +100,7 @@ export function dataRoutes(deps: DataRouteDeps): Hono { tokenStore: deps.tokenStore, dataStorage, runtimeAvailability: deps.runtimeAvailability, + writeSessionStore: deps.writeSessionStore, }); app.use("/:scope", createBodyLimit(DATA_INGEST_MAX_SIZE)); diff --git a/packages/server/src/routes/write-session.test.ts b/packages/server/src/routes/write-session.test.ts new file mode 100644 index 00000000..7ad81d44 --- /dev/null +++ b/packages/server/src/routes/write-session.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi } from "vitest"; +import { pino } from "pino"; +import type { GatewayClient, Builder } from "@opendatalabs/vana-sdk/node"; +import type { GatewayGrantResponse } from "@opendatalabs/vana-sdk/node"; +import { + createTestWallet, + buildWeb3SignedHeader, +} from "@opendatalabs/personal-server-ts-core/test-utils"; +import { + createInMemoryWriteSessionStore, + hashWriteSessionToken, +} from "@opendatalabs/personal-server-ts-core/write"; +import { writeSessionRoutes } from "./write-session.js"; + +const SERVER_ORIGIN = "http://localhost:8080"; +const builderWallet = createTestWallet(0); +const ownerWallet = createTestWallet(9); + +const BUILDER_ID = "0xbuilder1"; + +function createMockGateway( + overrides: Partial = {}, +): GatewayClient { + return { + isRegisteredBuilder: vi.fn().mockResolvedValue(true), + getBuilder: vi.fn().mockResolvedValue({ + id: BUILDER_ID, + ownerAddress: "0xOwner", + granteeAddress: builderWallet.address, + publicKey: "0x04key", + appUrl: "https://app.example.com", + addedAt: "2026-01-21T10:00:00.000Z", + } satisfies Builder), + getGrant: vi.fn().mockResolvedValue(null), + ...overrides, + } as unknown as GatewayClient; +} + +function makeWriteGrant( + overrides: Partial = {}, +): GatewayGrantResponse { + return { + id: "grant-w-1", + grantorAddress: ownerWallet.address, + granteeId: BUILDER_ID, + scopes: ["write:notes.entries"], + status: "confirmed", + addedAt: "2026-01-21T10:00:00.000Z", + expiresAt: null, + expired: false, + revokedAt: null, + revocationSignature: null, + paymentStatus: "paid", + paidAt: null, + paidBy: null, + grantVersion: "1", + settleTxHash: null, + settleSubmittedAt: null, + revocationTxHash: null, + revocationSubmittedAt: null, + fee: { + asset: "0x0000000000000000000000000000000000000000", + registrationFee: "0", + dataAccessFee: "0", + totalDue: "0", + }, + ...overrides, + }; +} + +const logger = pino({ level: "silent" }); + +async function handshakeHeader( + options: { grantId?: string; iat?: number } = {}, +) { + return buildWeb3SignedHeader({ + wallet: builderWallet, + aud: SERVER_ORIGIN, + method: "POST", + uri: "/session", + grantId: options.grantId ?? "grant-w-1", + iat: options.iat, + }); +} + +describe("POST /v1/write/session", () => { + it("mints a bearer token for a valid builder + write-grant handshake", async () => { + const sessionStore = createInMemoryWriteSessionStore(); + const app = writeSessionRoutes({ + logger, + serverOrigin: SERVER_ORIGIN, + serverOwner: ownerWallet.address, + gateway: createMockGateway({ + getGrant: vi.fn().mockResolvedValue(makeWriteGrant()), + }), + sessionStore, + }); + + const res = await app.request("/session", { + method: "POST", + headers: { Authorization: await handshakeHeader() }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.token_type).toBe("Bearer"); + expect(body.access_token).toMatch(/^vana_write_/); + expect(body.expires_in).toBeGreaterThan(0); + expect(body.scope).toBe("notes.entries"); + + const record = await sessionStore.getByTokenHash( + await hashWriteSessionToken(body.access_token), + ); + expect(record?.builderAddress).toBe(builderWallet.address); + expect(record?.grantId).toBe("grant-w-1"); + expect(record?.writeScopes).toEqual(["notes.entries"]); + }); + + it("rejects a read-grant (no write: scope entries)", async () => { + const app = writeSessionRoutes({ + logger, + serverOrigin: SERVER_ORIGIN, + serverOwner: ownerWallet.address, + gateway: createMockGateway({ + getGrant: vi + .fn() + .mockResolvedValue(makeWriteGrant({ scopes: ["notes.entries"] })), + }), + sessionStore: createInMemoryWriteSessionStore(), + }); + + const res = await app.request("/session", { + method: "POST", + headers: { Authorization: await handshakeHeader() }, + }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error.errorCode).toBe("SCOPE_MISMATCH"); + }); + + it("rejects a proof without a grantId claim", async () => { + const app = writeSessionRoutes({ + logger, + serverOrigin: SERVER_ORIGIN, + serverOwner: ownerWallet.address, + gateway: createMockGateway(), + sessionStore: createInMemoryWriteSessionStore(), + }); + + const auth = await buildWeb3SignedHeader({ + wallet: builderWallet, + aud: SERVER_ORIGIN, + method: "POST", + uri: "/session", + }); + const res = await app.request("/session", { + method: "POST", + headers: { Authorization: auth }, + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.errorCode).toBe("GRANT_ID_REQUIRED"); + }); + + it("rejects non-Web3Signed mechanisms (a dev token cannot open a write session)", async () => { + const app = writeSessionRoutes({ + logger, + serverOrigin: SERVER_ORIGIN, + serverOwner: ownerWallet.address, + gateway: createMockGateway(), + devToken: "dev-token-1", + sessionStore: createInMemoryWriteSessionStore(), + }); + + const res = await app.request("/session", { + method: "POST", + headers: { Authorization: "Bearer dev-token-1" }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error.errorCode).toBe("WRITE_SESSION_PROOF_REQUIRED"); + }); + + it("rejects a replayed handshake proof", async () => { + const app = writeSessionRoutes({ + logger, + serverOrigin: SERVER_ORIGIN, + serverOwner: ownerWallet.address, + gateway: createMockGateway({ + getGrant: vi.fn().mockResolvedValue(makeWriteGrant()), + }), + sessionStore: createInMemoryWriteSessionStore(), + }); + + const auth = await handshakeHeader(); + const first = await app.request("/session", { + method: "POST", + headers: { Authorization: auth }, + }); + expect(first.status).toBe(200); + + const replay = await app.request("/session", { + method: "POST", + headers: { Authorization: auth }, + }); + expect(replay.status).toBe(401); + const body = await replay.json(); + expect(body.error.errorCode).toBe("WRITE_SESSION_PROOF_REPLAY"); + }); + + it("returns 500 when the server owner is not configured", async () => { + const app = writeSessionRoutes({ + logger, + serverOrigin: SERVER_ORIGIN, + gateway: createMockGateway(), + sessionStore: createInMemoryWriteSessionStore(), + }); + + const res = await app.request("/session", { + method: "POST", + headers: { Authorization: await handshakeHeader() }, + }); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.errorCode).toBe("SERVER_NOT_CONFIGURED"); + }); +}); diff --git a/packages/server/src/routes/write-session.ts b/packages/server/src/routes/write-session.ts new file mode 100644 index 00000000..2e7a0811 --- /dev/null +++ b/packages/server/src/routes/write-session.ts @@ -0,0 +1,165 @@ +/** + * Write API session route — POST /v1/write/session. + * + * The builder proves control of its key + write-grant with a Web3Signed proof + * (same handshake shape as the self-signing MCP session, routes/mcp.ts) and + * gets a short-lived bearer token. The token then gates delegated writes on + * the EXISTING ingest endpoint (POST /v1/data/:scope) — see api-auth.ts + * authorizeWrite. The PS keeps signing AddData as the owner; the builder + * never holds the owner key. + */ + +import { createHash, randomBytes } from "node:crypto"; +import { Hono } from "hono"; +import type { Logger } from "pino"; +import type { GatewayClient } from "@opendatalabs/vana-sdk/node"; +import { authenticateRequest } from "@opendatalabs/personal-server-ts-core/auth"; +import { ProtocolError } from "@opendatalabs/personal-server-ts-core/errors"; +import { + createInMemoryWriteProofReplayStore, + createWriteSession, + type WriteProofReplayStore, + type WriteSessionStore, +} from "@opendatalabs/personal-server-ts-core/write"; +import type { TokenStore } from "../token-store.js"; + +export interface WriteSessionRouteDeps { + logger: Logger; + serverOrigin: string | (() => string); + serverOwner?: `0x${string}`; + gateway: GatewayClient; + devToken?: string; + accessToken?: string; + tokenStore?: TokenStore; + /** + * Session store shared with the data routes (api-auth authorizeWrite reads + * the tokens this route mints). The caller wires ONE store into both. + */ + sessionStore: WriteSessionStore; + /** Defaults to a per-route in-memory replay guard. */ + proofReplayStore?: WriteProofReplayStore; +} + +function jsonError(status: number, errorCode: string, message: string) { + return { + error: { code: status, errorCode, message }, + }; +} + +export function writeSessionRoutes(deps: WriteSessionRouteDeps): Hono { + const app = new Hono(); + const proofReplayStore = + deps.proofReplayStore ?? createInMemoryWriteProofReplayStore(); + + app.post("/session", async (c) => { + // Fail closed: minting a session requires this server's owner to bind + // the grant to (createWriteSession rejects wrong-owner grants). + if (!deps.serverOwner) { + return c.json( + jsonError( + 500, + "SERVER_NOT_CONFIGURED", + "Server owner is not configured", + ), + 500, + ); + } + let authResult; + try { + authResult = await authenticateRequest({ + request: c.req.raw, + serverOrigin: deps.serverOrigin, + devToken: deps.devToken, + accessToken: deps.accessToken, + sessionTokenVerifier: deps.tokenStore, + serverOwner: deps.serverOwner, + }); + } catch (err) { + return c.json( + jsonError( + 401, + "WRITE_SESSION_AUTH_FAILED", + err instanceof Error ? err.message : String(err), + ), + 401, + ); + } + if (authResult.mechanism !== "web3-signed") { + return c.json( + jsonError( + 401, + "WRITE_SESSION_PROOF_REQUIRED", + "POST /v1/write/session requires a Web3Signed proof signed by the builder key", + ), + 401, + ); + } + const grantId = authResult.auth.payload.grantId; + if (!grantId) { + return c.json( + jsonError( + 400, + "GRANT_ID_REQUIRED", + "The Web3Signed proof must carry a grantId (the write-grant issued to the builder)", + ), + 400, + ); + } + // Replay guard: bind to a digest of the exact proof header, remembered + // until the proof's own expiry (a replay only matters while still valid). + const proofHeader = c.req.raw.headers.get("authorization") ?? ""; + const proofId = createHash("sha256").update(proofHeader).digest("hex"); + const expSec = authResult.auth.payload.exp; + const expiresAtMs = + (typeof expSec === "number" + ? expSec + : Math.floor(Date.now() / 1000) + 300) * 1000; + try { + const session = await createWriteSession( + { + builderAddress: authResult.auth.signer, + grantId, + proof: { id: proofId, expiresAtMs }, + }, + { + store: deps.sessionStore, + authSessionVerifier: deps.gateway, + grantVerifier: deps.gateway, + serverOwner: deps.serverOwner, + randomToken: () => `vana_write_${randomBytes(32).toString("hex")}`, + replayStore: proofReplayStore, + }, + ); + deps.logger.info( + { + builder: authResult.auth.signer, + grantId: session.grantId, + writeScopes: session.writeScopes, + }, + "Write session minted", + ); + return c.json({ + access_token: session.accessToken, + token_type: "Bearer", + expires_in: session.expiresInSeconds, + // Write patterns, prefix stripped — what POST /v1/data/:scope will + // accept under this token. + scope: session.writeScopes.join(" "), + }); + } catch (err) { + if (err instanceof ProtocolError) { + return c.json(err.toJSON(), err.code as 400 | 401 | 403 | 404); + } + return c.json( + jsonError( + 400, + "WRITE_SESSION_FAILED", + err instanceof Error ? err.message : String(err), + ), + 400, + ); + } + }); + + return app; +} diff --git a/scripts/e2e-write-api.ts b/scripts/e2e-write-api.ts new file mode 100644 index 00000000..9e242caa --- /dev/null +++ b/scripts/e2e-write-api.ts @@ -0,0 +1,293 @@ +/** + * Self-contained end-to-end demo of the Write API slice. + * + * Boots a real personal server in-process (no external gateway, no network: + * an injected mock GatewayClient supplies the builder + grants), then runs + * the full builder flow: + * + * 1. open a write-session: Web3Signed handshake with a WRITE-grant + * (`write:`-prefixed scope entries) -> short-lived bearer token + * 2. write a JSON record through the EXISTING ingest endpoint + * (POST /v1/data/:scope) with the session token + a builder-signed + * payload proof (X-Vana-Write-Signature) -> stored with $writtenBy + * attribution, PS-side owner signing untouched + * 3. read back WITHOUT a read-grant -> 403 (write never confers read) + * 4. read back with a SEPARATE read-grant -> 200, attribution verifiable + * + * Run: + * npm run e2e:write-api + * (or npx tsx scripts/e2e-write-api.ts) + * + * Exits non-zero if any check fails. + */ + +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + MASTER_KEY_MESSAGE, + verifyWeb3Signed, +} from "@opendatalabs/vana-sdk/node"; +import type { + Builder, + GatewayClient, + GatewayGrantResponse, +} from "@opendatalabs/vana-sdk/node"; +import { + createTestWallet, + buildWeb3SignedHeader, +} from "@opendatalabs/personal-server-ts-core/test-utils"; +import { + ServerConfigSchema, + type ServerConfig, +} from "../packages/core/src/schemas/server-config.js"; +import { createServer } from "../packages/server/src/bootstrap.js"; + +const PORT = 8798; +const ORIGIN = `http://localhost:${PORT}`; +const SCOPE = "notes.entries"; +const WRITE_GRANT_ID = "0xwritegrant1"; +const READ_GRANT_ID = "0xreadgrant1"; + +const owner = createTestWallet(0); +const builder = createTestWallet(3); + +const failures: string[] = []; +function check(condition: boolean, label: string, detail: unknown = "") { + const note = detail ? ` — ${String(detail)}` : ""; + console.log(`${condition ? "PASS" : "FAIL"} ${label}${note}`); + if (!condition) failures.push(label); +} + +function makeMockGateway(): GatewayClient { + const grantFor = (grantId: string, scopes: string[]): GatewayGrantResponse => + ({ + id: grantId, + grantorAddress: owner.address, + granteeId: builder.address, + scopes, + status: "confirmed", + addedAt: "2026-01-21T10:00:00.000Z", + expiresAt: null, + expired: false, + revokedAt: null, + revocationSignature: null, + paymentStatus: "paid", + paidAt: null, + paidBy: null, + grantVersion: "1", + settleTxHash: null, + settleSubmittedAt: null, + revocationTxHash: null, + revocationSubmittedAt: null, + fee: { + asset: "0x0000000000000000000000000000000000000000", + registrationFee: "0", + dataAccessFee: "0", + totalDue: "0", + }, + }) as GatewayGrantResponse; + const builderFor = (address: string): Builder => ({ + id: address, + ownerAddress: owner.address, + granteeAddress: address as `0x${string}`, + publicKey: "0x04", + appUrl: "https://e2e.test", + addedAt: "2026-01-21T10:00:00.000Z", + }); + return { + isRegisteredBuilder: async () => true, + getBuilder: async (address: string) => builderFor(address), + getGrant: async (grantId: string) => { + if (grantId === WRITE_GRANT_ID) { + return grantFor(WRITE_GRANT_ID, [`write:${SCOPE}`]); + } + if (grantId === READ_GRANT_ID) { + return grantFor(READ_GRANT_ID, [SCOPE]); + } + return null; + }, + listGrantsByUser: async () => [], + getSchemaForScope: async () => null, + getSchema: async () => null, + getServer: async () => null, + getFile: async () => null, + listFilesSince: async () => ({ files: [], cursor: null }), + registerServer: async () => ({ alreadyRegistered: true }), + registerFile: async () => ({}), + createGrant: async () => ({ grantId: WRITE_GRANT_ID }), + revokeGrant: async () => undefined, + } as unknown as GatewayClient; +} + +function makeConfig(): ServerConfig { + return ServerConfigSchema.parse({ + server: { port: PORT, origin: ORIGIN }, + gateway: { url: "http://localhost:9999" }, + sync: { enabled: false }, + tunnel: { enabled: false }, + devUi: { enabled: true }, + logging: { level: "error" }, + }); +} + +type App = { request: (path: string, init?: RequestInit) => Promise }; + +async function main() { + const rootPath = await mkdtemp(join(tmpdir(), "vana-write-api-e2e-")); + const ownerSignature = await owner.signMessage(MASTER_KEY_MESSAGE); + + const ctx = await createServer(makeConfig(), { + rootPath, + ownerSignature, + gatewayClient: makeMockGateway(), + }); + const app = ctx.app as unknown as App; + + try { + // 1. Open a write-session (builder proves control of its key + grant). + const handshake = await buildWeb3SignedHeader({ + wallet: builder, + aud: ORIGIN, + method: "POST", + uri: "/v1/write/session", + grantId: WRITE_GRANT_ID, + }); + const sessionRes = await app.request(`${ORIGIN}/v1/write/session`, { + method: "POST", + headers: { Authorization: handshake }, + }); + check( + sessionRes.status === 200, + "open write-session", + `status=${sessionRes.status}`, + ); + const session = (await sessionRes.json()) as { + access_token?: string; + scope?: string; + }; + check(Boolean(session.access_token), "session token minted"); + check(session.scope === SCOPE, "session scope", session.scope); + const token = session.access_token ?? ""; + + // 2. Write a JSON record with the session token + signed payload. + const rawBody = JSON.stringify({ + note: "hello from the builder", + source: "e2e-write-api", + }); + const payloadProof = await buildWeb3SignedHeader({ + wallet: builder, + aud: ORIGIN, + method: "POST", + uri: `/v1/data/${SCOPE}`, + body: new TextEncoder().encode(rawBody), + }); + const writeRes = await app.request(`${ORIGIN}/v1/data/${SCOPE}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-Vana-Write-Signature": payloadProof, + }, + body: rawBody, + }); + check( + writeRes.status === 201, + "builder write ingested", + `status=${writeRes.status}`, + ); + + // 3. Read back WITHOUT a read-grant (using the WRITE grant) -> 403. + const writeGrantRead = await buildWeb3SignedHeader({ + wallet: builder, + aud: ORIGIN, + method: "GET", + uri: `/v1/data/${SCOPE}`, + grantId: WRITE_GRANT_ID, + }); + const deniedRes = await app.request(`${ORIGIN}/v1/data/${SCOPE}`, { + headers: { Authorization: writeGrantRead }, + }); + check( + deniedRes.status === 403, + "read with write-grant denied", + `status=${deniedRes.status}`, + ); + + // 4. Read back with a SEPARATE read-grant -> 200 + verifiable attribution. + const readGrantRead = await buildWeb3SignedHeader({ + wallet: builder, + aud: ORIGIN, + method: "GET", + uri: `/v1/data/${SCOPE}`, + grantId: READ_GRANT_ID, + }); + const readRes = await app.request(`${ORIGIN}/v1/data/${SCOPE}`, { + headers: { Authorization: readGrantRead }, + }); + check( + readRes.status === 200, + "read with read-grant served", + `status=${readRes.status}`, + ); + const envelope = (await readRes.json()) as { + data?: Record; + }; + const data = envelope.data ?? {}; + check(data.note === "hello from the builder", "payload intact"); + const attribution = data.$writtenBy as + | { + builder?: string; + grantId?: string; + signature?: string; + bodyHash?: string; + } + | undefined; + check( + attribution?.builder?.toLowerCase() === builder.address.toLowerCase(), + "attribution builder identity", + attribution?.builder, + ); + check( + attribution?.grantId === WRITE_GRANT_ID, + "attribution grant id", + attribution?.grantId, + ); + + // Cryptographic attribution: the stored compact proof recovers to the + // builder over the original body bytes. + let recovered: string | null = null; + try { + const verified = await verifyWeb3Signed({ + headerValue: `Web3Signed ${attribution?.signature ?? ""}`, + expectedOrigin: ORIGIN, + expectedMethod: "POST", + expectedPath: `/v1/data/${SCOPE}`, + bodyBytes: new TextEncoder().encode(rawBody), + }); + recovered = verified.signer; + } catch (err) { + check(false, "attribution signature verifies", (err as Error).message); + } + if (recovered) { + check( + recovered.toLowerCase() === builder.address.toLowerCase(), + "attribution signature recovers to builder", + recovered, + ); + } + } finally { + await ctx.cleanup(); + } + + if (failures.length > 0) { + console.error(`\n${failures.length} check(s) failed`); + process.exit(1); + } + console.log("\nAll checks passed"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/e2e/helpers/mock-gateway.ts b/tests/e2e/helpers/mock-gateway.ts index c77b850c..a55fcad8 100644 --- a/tests/e2e/helpers/mock-gateway.ts +++ b/tests/e2e/helpers/mock-gateway.ts @@ -5,6 +5,8 @@ import type { ServerType } from "@hono/node-server"; export interface MockGateway { url: string; + /** Register (or replace) a grant served by GET /v1/grants/:grantId. */ + setGrant: (grantId: string, grant: Record) => void; cleanup: () => Promise; } @@ -62,8 +64,13 @@ export async function startMockGateway( ); }); + // Grants registered via mockGateway.setGrant are served; unknown ids 404 + // (the SDK client maps 404 to null). + const grants = new Map>(); app.get("/v1/grants/:grantId", (c) => { - return c.json({ error: "not found" }, 404); + const grant = grants.get(c.req.param("grantId")); + if (!grant) return c.json({ error: "not found" }, 404); + return c.json(wrapEnvelope(grant)); }); app.get("/v1/grants", (c) => { @@ -154,6 +161,9 @@ export async function startMockGateway( return { url, + setGrant: (grantId, grant) => { + grants.set(grantId, grant); + }, cleanup: async () => { await new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); diff --git a/tests/e2e/write-api.e2e.test.ts b/tests/e2e/write-api.e2e.test.ts new file mode 100644 index 00000000..3010957e --- /dev/null +++ b/tests/e2e/write-api.e2e.test.ts @@ -0,0 +1,220 @@ +/** + * Write API demo slice (e2e): a builder opens a write-session against a + * running PS with a WRITE-grant, POSTs a JSON record into a scope through the + * normal ingest path (PS-side owner signing, builder attribution stamped), + * and read-back requires a SEPARATE read-grant — the write-grant never + * confers read. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { startTestServer, type TestServer } from "./helpers/server.js"; +import { startMockGateway, type MockGateway } from "./helpers/mock-gateway.js"; +import { + createTestWallet, + buildWeb3SignedHeader, +} from "../../packages/core/src/test-utils/index.js"; + +const KNOWN_SIG = + "0xedbb7743cce459345238442dcfb291f234a321d253485eaa58251aa0f28ea8f1410ab988bae2657b689cd24417b41e315efc22ba333024f4a6269c424ded8d361b"; + +// The mock gateway registers every builder under this id (see helpers). +const BUILDER_ID = "0xbuilder1"; +const WRITE_GRANT_ID = "0xwritegrant1"; +const READ_GRANT_ID = "0xreadgrant1"; +const SCOPE = "notes.entries"; + +const builderWallet = createTestWallet(3); + +function grantBase(owner: string): Record { + return { + grantorAddress: owner, + granteeId: BUILDER_ID, + status: "confirmed", + addedAt: new Date().toISOString(), + expiresAt: null, + expired: false, + revokedAt: null, + revocationSignature: null, + paymentStatus: "paid", + paidAt: null, + paidBy: null, + grantVersion: "1", + settleTxHash: null, + settleSubmittedAt: null, + revocationTxHash: null, + revocationSubmittedAt: null, + fee: { + asset: "0x0000000000000000000000000000000000000000", + registrationFee: "0", + dataAccessFee: "0", + totalDue: "0", + }, + }; +} + +describe("Write API (e2e)", () => { + let server: TestServer; + let gateway: MockGateway; + let owner: string; + + beforeAll(async () => { + gateway = await startMockGateway(); + server = await startTestServer({ + gatewayUrl: gateway.url, + masterKeySignature: KNOWN_SIG, + }); + + const health = await fetch(`${server.url}/health`); + owner = (await health.json()).owner as string; + expect(owner).toMatch(/^0x/); + + // The owner has issued the builder a WRITE-grant on the scope. (Grant + // registration itself reuses the existing POST /v1/grants surface; here + // the gateway is mocked so we seed the stored grant directly.) + gateway.setGrant(WRITE_GRANT_ID, { + id: WRITE_GRANT_ID, + scopes: [`write:${SCOPE}`], + ...grantBase(owner), + }); + }, 30000); + + afterAll(async () => { + await server?.cleanup(); + await gateway?.cleanup(); + }); + + let sessionToken: string; + + it("opens a write-session with a Web3Signed handshake + write-grant", async () => { + const auth = await buildWeb3SignedHeader({ + wallet: builderWallet, + aud: server.url, + method: "POST", + uri: "/v1/write/session", + grantId: WRITE_GRANT_ID, + }); + const res = await fetch(`${server.url}/v1/write/session`, { + method: "POST", + headers: { Authorization: auth }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.token_type).toBe("Bearer"); + expect(body.scope).toBe(SCOPE); + sessionToken = body.access_token; + expect(sessionToken).toMatch(/^vana_write_/); + }); + + it("writes a JSON record through the normal ingest path with attribution", async () => { + const rawBody = JSON.stringify({ + note: "hello from the builder", + source: "write-api-e2e", + }); + const signature = await buildWeb3SignedHeader({ + wallet: builderWallet, + aud: server.url, + method: "POST", + uri: `/v1/data/${SCOPE}`, + body: new TextEncoder().encode(rawBody), + }); + const res = await fetch(`${server.url}/v1/data/${SCOPE}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${sessionToken}`, + "X-Vana-Write-Signature": signature, + }, + body: rawBody, + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.scope).toBe(SCOPE); + expect(body.collectedAt).toBeDefined(); + }); + + it("read-back WITHOUT a read-grant fails: the write-grant never confers read", async () => { + const auth = await buildWeb3SignedHeader({ + wallet: builderWallet, + aud: server.url, + method: "GET", + uri: `/v1/data/${SCOPE}`, + grantId: WRITE_GRANT_ID, + }); + const res = await fetch(`${server.url}/v1/data/${SCOPE}`, { + headers: { Authorization: auth }, + }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error.errorCode).toBe("SCOPE_MISMATCH"); + }); + + it("read-back with a SEPARATE read-grant succeeds and carries the attribution", async () => { + gateway.setGrant(READ_GRANT_ID, { + id: READ_GRANT_ID, + scopes: [SCOPE], + ...grantBase(owner), + }); + + const auth = await buildWeb3SignedHeader({ + wallet: builderWallet, + aud: server.url, + method: "GET", + uri: `/v1/data/${SCOPE}`, + grantId: READ_GRANT_ID, + }); + const res = await fetch(`${server.url}/v1/data/${SCOPE}`, { + headers: { Authorization: auth }, + }); + expect(res.status).toBe(200); + const envelope = await res.json(); + expect(envelope.scope).toBe(SCOPE); + expect(envelope.data.note).toBe("hello from the builder"); + // Builder attribution stored with the record (Tim's cryptographic + // attribution): identity + payload signature + grant. + const attribution = envelope.data.$writtenBy; + expect(attribution.builder.toLowerCase()).toBe( + builderWallet.address.toLowerCase(), + ); + expect(attribution.grantId).toBe(WRITE_GRANT_ID); + expect(attribution.signature).toContain("."); + expect(attribution.bodyHash).toMatch(/^sha256:/); + }); + + it("a session write to an uncovered scope is rejected", async () => { + const rawBody = JSON.stringify({ note: "nope" }); + const signature = await buildWeb3SignedHeader({ + wallet: builderWallet, + aud: server.url, + method: "POST", + uri: "/v1/data/other.scope", + body: new TextEncoder().encode(rawBody), + }); + const res = await fetch(`${server.url}/v1/data/other.scope`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${sessionToken}`, + "X-Vana-Write-Signature": signature, + }, + body: rawBody, + }); + expect(res.status).toBe(403); + }); + + it("a read-grant cannot open a write-session", async () => { + const auth = await buildWeb3SignedHeader({ + wallet: builderWallet, + aud: server.url, + method: "POST", + uri: "/v1/write/session", + grantId: READ_GRANT_ID, + }); + const res = await fetch(`${server.url}/v1/write/session`, { + method: "POST", + headers: { Authorization: auth }, + }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error.errorCode).toBe("SCOPE_MISMATCH"); + }); +});