Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
69 changes: 68 additions & 1 deletion packages/core/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -89,6 +108,18 @@ export interface PersonalServerApiAuthPort {
authorizeBuilderRead(
input: PersonalServerReadAuthInput,
): Promise<PersonalServerReadAuthResult | void>;
/**
* 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<PersonalServerWriteAuthResult | void>;
}

export interface PersonalServerApiLogger {
Expand Down Expand Up @@ -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<void> => {
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
Expand All @@ -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?.(
Expand All @@ -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 });
}
Expand All @@ -912,16 +976,19 @@ export async function handlePersonalServerDataRequest(
body: parsed.body,
collectedAt: collectedAtValue,
status,
attribution: writeAuth?.attribution,
});
if (!result.ok) return contractErrorResponse(result);
deps.logger?.info?.(
{
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 });
}
Expand Down
33 changes: 31 additions & 2 deletions packages/core/src/contracts/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/logging/access-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export interface AccessLogEntry {
logId: string;
grantId: string;
builder: string;
action: "read";
action: "read" | "write";
scope: string;
timestamp: string;
ipAddress: string;
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/policy/data-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading