Skip to content
Merged
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
6 changes: 5 additions & 1 deletion docs/docs/Post Platform Guide/webhook-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ handleWebhook = async (req: Request, res: Response) => {
(m: any) => m.schemaId === schemaId
);

// Delivery is a broadcast: you will receive ontologies you have no
// mapping for (e.g. the w3ds-file-v1 envelopes uploadFile emits). Ack
// them with a 200 -- a 4xx here is retried and then dead-lettered.
if (!mapping) {
throw new Error("No mapping found");
console.log(`[webhook] skipping unknown schema ${schemaId} for ${globalId}`);
return res.status(200).send();
}

// Convert global to local
Expand Down
17 changes: 17 additions & 0 deletions docs/docs/Services/Awareness-as-a-Service.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ existing receivers need no changes:
delivering a packet back to its origin (the ping-pong guard the old fanout
enforced). It is never persisted or delivered.

### File uploads

The eVault `uploadFile` mutation emits a packet like any other write, stamped
`schemaId: "w3ds-file-v1"` with the storage payload (`filename`, `contentType`,
`size`, `blobKey`, `publicUrl`, `uploadedAt`) as `data`. Subscribe to it to
observe uploads rather than mirroring each blob as a second `File`-ontology
envelope.

`w3ds-file-v1` is a **slug, not a UUID** — `ontologyFilter` and the
`?ontology=` query parameter match ontologies as opaque strings, so it must be
given verbatim.

## Capabilities

### 1. Polling query API
Expand Down Expand Up @@ -100,6 +112,11 @@ A consumer manages only its own subscriptions (`GET`, `PATCH`, `DELETE`). If a
subscription has a `secret`, each delivery carries an `x-aaas-signature` header
(HMAC-SHA256 of the body).

Because catch-all subscriptions receive every ontology, a receiver **must ack
packets it does not consume with a 200**. There is no 4xx short-circuit in the
delivery engine: a 400 on an unknown `schemaId` is retried up to
`AWARENESS_MAX_ATTEMPTS` and then dead-lettered.

### 3. Retrying delivery + dead-letters

A background engine drains the delivery queue. Failed deliveries are retried
Expand Down
1 change: 1 addition & 0 deletions docs/docs/W3DS Protocol/Awareness-Protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ Platforms that participate in W3DS must implement an HTTP endpoint that accepts
- **Request**: JSON body as described above.
- **Behavior**: The platform should (1) use `schemaId` to find the correct mapping from global ontology to local schema, (2) transform `data` from global to local format (e.g. using the [Web3 Adapter](/docs/Infrastructure/Web3-Adapter#fromglobal)'s `fromGlobal`), (3) resolve or create the local entity and store the global-ID-to-local-ID mapping, (4) return HTTP 200 on success.
- **Idempotency**: Implementors are encouraged to treat the same `id` (global ID) as idempotent (create or update the same local entity) so that duplicate or retried deliveries do not create duplicates.
- **Unknown ontologies**: Delivery is a broadcast — a platform receives packets for ontologies it has no mapping for, such as the `w3ds-file-v1` envelopes emitted by `uploadFile`. Log and **return HTTP 200**; do not return 4xx. AaaS has no 4xx short-circuit, so an error response is retried and then dead-lettered even though nothing was wrong.

For a step-by-step implementation guide, see the [Webhook Controller Guide](/docs/Post%20Platform%20Guide/webhook-controller) in the Post Platform Guide.

Expand Down
18 changes: 17 additions & 1 deletion docs/docs/W3DS Protocol/File-URIs.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ otherwise the mutation returns an error.
| `acl` | `[String!]!`| Access-control list for the created File Meta Envelope (e.g. `["*"]`).|

Constraints: content must be valid base64 (malformed input is rejected) and the
decoded size must not exceed **50 MB**.
decoded size must not exceed **250 MB**.

### Payload — `UploadFilePayload`

Expand All @@ -93,6 +93,22 @@ where `size` is the decoded byte length, `blobKey` is the object-storage key
> platform-level `File` ontology (`a1b2c3d4-e5f6-7890-abcd-ef1234567890`). See
> [File ontology vs. `w3ds-file-v1`](#file-ontology-vs-w3ds-file-v1) below.

### Awareness

`uploadFile` dispatches an awareness packet like every other write, with
`schemaId: "w3ds-file-v1"`, `operation: "create"`, and `data` set to the stored
payload verbatim. Consuming that packet is how a platform learns about a new
blob — there is no need to mirror the upload as a second envelope under the
`File` ontology just to make it observable.

The packet `id` is the File Meta Envelope ID and `w3id` is the owner eName, so a
consumer can address the blob as `w3ds://file?id=<w3id>/<id>` without a further
round trip.

Note that `w3ds-file-v1` is a slug, not a UUID. An AaaS subscription that
narrows by ontology must list the literal string; catch-all subscriptions (empty
`ontologyFilter`) receive it either way.

### Example

```graphql
Expand Down
53 changes: 53 additions & 0 deletions infrastructure/evault-core/src/core/protocol/graphql-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1417,6 +1417,59 @@ export class GraphQLServer {
context.eName,
);

// Forward the awareness packet, exactly as every
// other write path does. Without this an uploaded
// blob is invisible to AaaS, which forces consumers
// to mirror it as a second envelope under a
// different ontology just to observe the upload.
//
// `data` is the stored payload verbatim so the
// packet matches what a consumer reads back via
// metaEnvelope(id) or GET /api/packets.
//
// Fire-and-forget: the envelope is already
// committed, so an AaaS outage must not fail the
// upload. Awaiting here would drop into the catch
// block below and delete a blob that is still
// referenced by a live envelope.
const webhookPayload = {
id: result.metaEnvelope.id,
w3id: context.eName,
evaultPublicKey: this.evaultPublicKey,
data: payload,
schemaId: FILE_SCHEMA_ID,
operation: "create" as const,
};

this.notifyAwareness(
webhookPayload,
context.tokenPayload?.platform || null,
);

// Log envelope operation best-effort (do not fail mutation)
const envelopeHash = computeEnvelopeHash({
id: result.metaEnvelope.id,
ontology: FILE_SCHEMA_ID,
payload,
});
this.db
.appendEnvelopeOperationLog({
eName: context.eName,
metaEnvelopeId: result.metaEnvelope.id,
envelopeHash,
operation: "create",
platform:
context.tokenPayload?.platform ?? null,
timestamp: new Date().toISOString(),
ontology: FILE_SCHEMA_ID,
})
.catch((err) =>
console.error(
"appendEnvelopeOperationLog (uploadFile) failed:",
err,
),
);

return {
uri: buildFileUri(
context.eName,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
import axios from "axios";
import * as jose from "jose";
import {
setupE2ETestServer,
teardownE2ETestServer,
provisionTestEVault,
makeGraphQLRequest,
type E2ETestServer,
type ProvisionedEVault,
} from "../../test-utils/e2e-setup";
import { getSharedTestKeyPair } from "../../test-utils/shared-test-keys";
import { FILE_SCHEMA_ID } from "../utils/w3ds-uri";

// Keep a handle on the real axios.post: the spy below must still let the
// GraphQL requests through to the test server.
const originalAxiosPost = axios.post;

// evault-core forwards every awareness packet to AaaS at
// AWARENESS_SERVICE_URL/ingest; point it somewhere the spy can intercept.
process.env.AWARENESS_SERVICE_URL = "http://localhost:9999";

// StorageService.isConfigured() gates the uploadFile resolver, and its
// constructor throws without these. Set them before the module is imported.
process.env.DO_SPACES_ENDPOINT = "https://ams3.digitaloceanspaces.com";
process.env.DO_SPACES_REGION = "ams3";
process.env.DO_SPACES_KEY = "test-key";
process.env.DO_SPACES_SECRET = "test-secret";
process.env.DO_SPACES_BUCKET = "test-bucket";

// vi.mock is hoisted above every const in this module, so the shared spy has to
// be created inside vi.hoisted or the factory would hit it in the TDZ.
const { s3Send } = vi.hoisted(() => ({ s3Send: vi.fn() }));

// Stub the S3 transport so uploads never leave the process, while leaving
// StorageService itself real (buildKey and the public URL are what we assert).
vi.mock("@aws-sdk/client-s3", () => ({
S3Client: vi.fn().mockImplementation(() => ({ send: s3Send })),
PutObjectCommand: vi.fn().mockImplementation((input) => ({ input })),
DeleteObjectCommand: vi.fn().mockImplementation((input) => ({ input })),
}));

const UPLOAD_FILE = `
mutation UploadFile($input: UploadFileInput!) {
uploadFile(input: $input) {
uri
metaEnvelopeId
publicUrl
errors { field message code }
}
}
`;

// The platform claim the test Bearer token carries. evault-core passes it to
// AaaS as requestingPlatform so the packet is not delivered back to its origin.
const TEST_PLATFORM = "http://localhost:3000";

/** Every /ingest call the spy captured, in order. */
function ingestCalls() {
return (axios.post as any).mock.calls.filter(
(call: any[]) =>
typeof call[0] === "string" && call[0].includes("/ingest"),
);
}

/**
* uploadFile used to be the only write mutation that never dispatched an
* awareness packet, so uploaded blobs were invisible to AaaS. Consumers worked
* around it by mirroring every upload as a second envelope under a different
* ontology. These tests pin the dispatch in place.
*/
describe("uploadFile awareness ingest", () => {
let server: E2ETestServer;
let evault: ProvisionedEVault;
let authHeaders: Record<string, string>;
let axiosPostSpy: any;

beforeAll(async () => {
server = await setupE2ETestServer();
evault = await provisionTestEVault(server);

const { privateKey } = await getSharedTestKeyPair();
const token = await new jose.SignJWT({ platform: TEST_PLATFORM })
.setProtectedHeader({ alg: "ES256", kid: "entropy-key-1" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(privateKey);

authHeaders = {
"X-ENAME": evault.w3id,
Authorization: `Bearer ${token}`,
};
}, 120000);

afterAll(async () => {
await teardownE2ETestServer(server);
if (axiosPostSpy) axiosPostSpy.mockRestore();
});

beforeEach(() => {
if (axiosPostSpy) axiosPostSpy.mockRestore();
vi.clearAllMocks();
s3Send.mockResolvedValue({});

axiosPostSpy = vi
.spyOn(axios, "post")
.mockImplementation((url: string | any, data?: any, config?: any) => {
if (typeof url === "string" && url.includes("/ingest")) {
return Promise.resolve({
status: 200,
data: { ok: true },
}) as any;
}
return originalAxiosPost.call(axios, url, data, config);
});
});

it("dispatches an ingest packet stamped w3ds-file-v1", async () => {
const content = Buffer.from("hello world").toString("base64");

const result = await makeGraphQLRequest(
server,
UPLOAD_FILE,
{
input: {
filename: "greeting.txt",
contentType: "text/plain",
content,
acl: ["*"],
},
},
authHeaders,
);

expect(result.uploadFile.errors ?? []).toEqual([]);
const metaEnvelopeId = result.uploadFile.metaEnvelopeId;
expect(metaEnvelopeId).toBeTruthy();

// notifyAwareness is fire-and-forget; give it a moment to run.
await new Promise((resolve) => setTimeout(resolve, 1000));

const calls = ingestCalls();
expect(calls.length).toBeGreaterThan(0);

const payload = calls[0][1];
expect(payload.schemaId).toBe(FILE_SCHEMA_ID);
expect(payload.schemaId).toBe("w3ds-file-v1");
expect(payload.w3id).toBe(evault.w3id);
expect(payload.operation).toBe("create");
// The packet id is the MetaEnvelope id, so a consumer can address the
// blob as w3ds://file?id=<w3id>/<id> without another round trip.
expect(payload.id).toBe(metaEnvelopeId);
// Origin is forwarded so AaaS can skip delivering back to the uploader.
expect(payload.requestingPlatform).toBe(TEST_PLATFORM);
});

it("sends the stored payload verbatim, including blobKey", async () => {
const body = "second file";
const content = Buffer.from(body).toString("base64");

const result = await makeGraphQLRequest(
server,
UPLOAD_FILE,
{
input: {
filename: "notes.txt",
contentType: "text/plain",
content,
acl: ["*"],
},
},
authHeaders,
);

const { metaEnvelopeId, publicUrl } = result.uploadFile;
await new Promise((resolve) => setTimeout(resolve, 1000));

const payload = ingestCalls()[0][1];

// Packet data must equal what a consumer reads back via
// metaEnvelope(id) — any divergence is a trap for consumers that diff
// the two, and would muddy the contentHash dedupe in AaaS.
expect(payload.data).toEqual({
filename: "notes.txt",
contentType: "text/plain",
size: Buffer.byteLength(body),
blobKey: expect.stringContaining("notes.txt"),
publicUrl,
uploadedAt: expect.any(String),
});

const stored = await makeGraphQLRequest(
server,
`query Get($id: ID!) { metaEnvelope(id: $id) { id ontology parsed } }`,
{ id: metaEnvelopeId },
authHeaders,
);
expect(stored.metaEnvelope.ontology).toBe(FILE_SCHEMA_ID);
expect(stored.metaEnvelope.parsed).toEqual(payload.data);
});

it("does not dispatch when the upload is rejected", async () => {
const result = await makeGraphQLRequest(
server,
UPLOAD_FILE,
{
input: {
filename: "bad.txt",
contentType: "text/plain",
content: "not!valid!base64",
acl: ["*"],
},
},
authHeaders,
);

expect(result.uploadFile.errors?.[0]?.code).toBe("INVALID_CONTENT");
expect(result.uploadFile.metaEnvelopeId).toBeFalsy();

await new Promise((resolve) => setTimeout(resolve, 1000));
expect(ingestCalls()).toHaveLength(0);
});

it("does not dispatch when the object store write fails", async () => {
s3Send.mockRejectedValueOnce(new Error("spaces unavailable"));

const result = await makeGraphQLRequest(
server,
UPLOAD_FILE,
{
input: {
filename: "doomed.txt",
contentType: "text/plain",
content: Buffer.from("nope").toString("base64"),
acl: ["*"],
},
},
authHeaders,
);

expect(result.uploadFile.errors?.[0]?.code).toBe("UPLOAD_FAILED");

await new Promise((resolve) => setTimeout(resolve, 1000));
expect(ingestCalls()).toHaveLength(0);
});
});
Loading
Loading