From e84196a80a5a2b4066a9e0a6d7c307dc3f5ef8a2 Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 21 Aug 2026 21:37:34 +0800 Subject: [PATCH] fix: ontologies and file uploads --- .../Post Platform Guide/webhook-controller.md | 6 +- docs/docs/Services/Awareness-as-a-Service.md | 17 ++ docs/docs/W3DS Protocol/Awareness-Protocol.md | 1 + docs/docs/W3DS Protocol/File-URIs.md | 18 +- .../src/core/protocol/graphql-server.ts | 53 ++++ .../protocol/uploadFile-awareness.spec.ts | 246 ++++++++++++++++++ .../api/src/controllers/WebhookController.ts | 7 +- .../src/controllers/WebhookController.ts | 6 +- .../api/src/controllers/WebhookController.ts | 8 +- .../api/src/controllers/WebhookController.ts | 5 +- .../api/src/controllers/WebhookController.ts | 5 +- .../api/src/controllers/WebhookController.ts | 9 +- .../api/src/controllers/WebhookController.ts | 6 +- .../api/src/controllers/WebhookController.ts | 9 +- .../api/src/controllers/WebhookController.ts | 6 +- skills/w3ds/reference/evault.md | 2 +- skills/w3ds/reference/platform.md | 7 +- skills/w3ds/reference/protocols.md | 6 +- 18 files changed, 394 insertions(+), 23 deletions(-) create mode 100644 infrastructure/evault-core/src/core/protocol/uploadFile-awareness.spec.ts diff --git a/docs/docs/Post Platform Guide/webhook-controller.md b/docs/docs/Post Platform Guide/webhook-controller.md index 48eb77820..6228ce069 100644 --- a/docs/docs/Post Platform Guide/webhook-controller.md +++ b/docs/docs/Post Platform Guide/webhook-controller.md @@ -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 diff --git a/docs/docs/Services/Awareness-as-a-Service.md b/docs/docs/Services/Awareness-as-a-Service.md index bfbb7db0e..ddd635450 100644 --- a/docs/docs/Services/Awareness-as-a-Service.md +++ b/docs/docs/Services/Awareness-as-a-Service.md @@ -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 @@ -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 diff --git a/docs/docs/W3DS Protocol/Awareness-Protocol.md b/docs/docs/W3DS Protocol/Awareness-Protocol.md index 36047a31c..7818dd0c8 100644 --- a/docs/docs/W3DS Protocol/Awareness-Protocol.md +++ b/docs/docs/W3DS Protocol/Awareness-Protocol.md @@ -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. diff --git a/docs/docs/W3DS Protocol/File-URIs.md b/docs/docs/W3DS Protocol/File-URIs.md index 68cda4ab3..1d32a8a21 100644 --- a/docs/docs/W3DS Protocol/File-URIs.md +++ b/docs/docs/W3DS Protocol/File-URIs.md @@ -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` @@ -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=/` 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 diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index d68f0b5e8..7d72e0f41 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -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, diff --git a/infrastructure/evault-core/src/core/protocol/uploadFile-awareness.spec.ts b/infrastructure/evault-core/src/core/protocol/uploadFile-awareness.spec.ts new file mode 100644 index 000000000..17d6f6091 --- /dev/null +++ b/infrastructure/evault-core/src/core/protocol/uploadFile-awareness.spec.ts @@ -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; + 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=/ 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); + }); +}); diff --git a/platforms/blabsy/api/src/controllers/WebhookController.ts b/platforms/blabsy/api/src/controllers/WebhookController.ts index e5968cb68..904beb348 100644 --- a/platforms/blabsy/api/src/controllers/WebhookController.ts +++ b/platforms/blabsy/api/src/controllers/WebhookController.ts @@ -97,7 +97,12 @@ export class WebhookController { const mapping = Object.values(adapter.mapping).find( (m) => m.schemaId === schemaId, ); - if (!mapping) throw new Error(); + if (!mapping) { + console.log( + `[webhook] skipping unknown schema ${schemaId} for ${id}`, + ); + return res.status(200).send(); + } const tableName = mapping.tableName + "s"; // For chats, skip the lock check and use timestamp comparison instead diff --git a/platforms/cerberus/client/src/controllers/WebhookController.ts b/platforms/cerberus/client/src/controllers/WebhookController.ts index e2d9aff1e..1b5653113 100644 --- a/platforms/cerberus/client/src/controllers/WebhookController.ts +++ b/platforms/cerberus/client/src/controllers/WebhookController.ts @@ -46,8 +46,10 @@ export class WebhookController { console.log("Available mappings:", Object.keys(this.adapter.mapping)); if (!mapping) { - console.error("No mapping found for schemaId:", schemaId); - throw new Error("No mapping found"); + console.log( + `[webhook] skipping unknown schema ${schemaId} for ${globalId}` + ); + return res.status(200).send(); } // Check if this globalId is already locked (being processed) diff --git a/platforms/dreamsync/api/src/controllers/WebhookController.ts b/platforms/dreamsync/api/src/controllers/WebhookController.ts index 8cccd1ba8..dad0f01c8 100644 --- a/platforms/dreamsync/api/src/controllers/WebhookController.ts +++ b/platforms/dreamsync/api/src/controllers/WebhookController.ts @@ -78,9 +78,11 @@ export class WebhookController { console.log("Available mappings:", Object.keys(this.adapter.mapping)); if (!mapping) { - console.error("No mapping found for schemaId:", schemaId); - await this.webhookProcessingService.markWebhookFailed(req.body, "No mapping found"); - throw new Error("No mapping found"); + console.log( + `[webhook] skipping unknown schema ${schemaId} for ${globalId}` + ); + await this.webhookProcessingService.markWebhookCompleted(req.body); + return res.status(200).send(); } // Check if this globalId is already locked (being processed) diff --git a/platforms/ecurrency/api/src/controllers/WebhookController.ts b/platforms/ecurrency/api/src/controllers/WebhookController.ts index bc0c90061..9303aba80 100644 --- a/platforms/ecurrency/api/src/controllers/WebhookController.ts +++ b/platforms/ecurrency/api/src/controllers/WebhookController.ts @@ -43,7 +43,10 @@ export class WebhookController { ) as any; if (!mapping) { - throw new Error("No mapping found"); + console.log( + `[webhook] skipping unknown schema ${schemaId} for ${globalId}` + ); + return res.status(200).send(); } // Check if this globalId is already locked (being processed) diff --git a/platforms/ereputation/api/src/controllers/WebhookController.ts b/platforms/ereputation/api/src/controllers/WebhookController.ts index 6e8cc207a..05ad4b8c2 100644 --- a/platforms/ereputation/api/src/controllers/WebhookController.ts +++ b/platforms/ereputation/api/src/controllers/WebhookController.ts @@ -56,7 +56,10 @@ export class WebhookController { ) as any; if (!mapping) { - throw new Error("No mapping found"); + console.log( + `[webhook] skipping unknown schema ${schemaId} for ${globalId}` + ); + return res.status(200).send(); } // Check if this globalId is already locked (being processed) diff --git a/platforms/esigner/api/src/controllers/WebhookController.ts b/platforms/esigner/api/src/controllers/WebhookController.ts index 233ea9e09..c89c35c5c 100644 --- a/platforms/esigner/api/src/controllers/WebhookController.ts +++ b/platforms/esigner/api/src/controllers/WebhookController.ts @@ -42,12 +42,15 @@ export class WebhookController { const mapping = Object.values(this.adapter.mapping).find( (m) => m.schemaId === schemaId ); - this.adapter.addToLockedIds(globalId); - if (!mapping) { - return res.status(400).json({ error: "Unknown schema" }); + console.log( + `[webhook] skipping unknown schema ${schemaId} for ${globalId}` + ); + return res.status(200).send(); } + this.adapter.addToLockedIds(globalId); + const local = await this.adapter.fromGlobal({ data: req.body.data, mapping, diff --git a/platforms/evoting/api/src/controllers/WebhookController.ts b/platforms/evoting/api/src/controllers/WebhookController.ts index c66871b7d..b420176c3 100644 --- a/platforms/evoting/api/src/controllers/WebhookController.ts +++ b/platforms/evoting/api/src/controllers/WebhookController.ts @@ -52,8 +52,10 @@ export class WebhookController { console.log("Available mappings:", Object.keys(this.adapter.mapping)); if (!mapping) { - console.error("No mapping found for schemaId:", schemaId); - throw new Error("No mapping found"); + console.log( + `[webhook] skipping unknown schema ${schemaId} for ${globalId}` + ); + return res.status(200).send(); } // Check if this globalId is already locked (being processed) diff --git a/platforms/file-manager/api/src/controllers/WebhookController.ts b/platforms/file-manager/api/src/controllers/WebhookController.ts index f389c9f17..e85d81e92 100644 --- a/platforms/file-manager/api/src/controllers/WebhookController.ts +++ b/platforms/file-manager/api/src/controllers/WebhookController.ts @@ -43,12 +43,15 @@ export class WebhookController { const mapping = Object.values(this.adapter.mapping).find( (m) => m.schemaId === schemaId ); - this.adapter.addToLockedIds(globalId); - if (!mapping) { - return res.status(400).json({ error: "Unknown schema" }); + console.log( + `[webhook] skipping unknown schema ${schemaId} for ${globalId}` + ); + return res.status(200).send(); } + this.adapter.addToLockedIds(globalId); + const local = await this.adapter.fromGlobal({ data: req.body.data, mapping, diff --git a/platforms/group-charter-manager/api/src/controllers/WebhookController.ts b/platforms/group-charter-manager/api/src/controllers/WebhookController.ts index f1db568cc..c7892f9d2 100644 --- a/platforms/group-charter-manager/api/src/controllers/WebhookController.ts +++ b/platforms/group-charter-manager/api/src/controllers/WebhookController.ts @@ -44,8 +44,10 @@ export class WebhookController { console.log("Available mappings:", Object.keys(this.adapter.mapping)); if (!mapping) { - console.error("No mapping found for schemaId:", schemaId); - throw new Error("No mapping found"); + console.log( + `[webhook] skipping unknown schema ${schemaId} for ${globalId}` + ); + return res.status(200).send(); } // Check if this globalId is already locked (being processed) diff --git a/skills/w3ds/reference/evault.md b/skills/w3ds/reference/evault.md index 05b29eb54..c5da4a952 100644 --- a/skills/w3ds/reference/evault.md +++ b/skills/w3ds/reference/evault.md @@ -147,7 +147,7 @@ mutation UploadFile($input: UploadFileInput!) { } ``` -`UploadFileInput`: `filename` (string), `contentType` (string, MIME), `content` (base64 or `data:` URI), `acl` (array). Decoded size must be ≤ 50 MB. Requires `X-ENAME` and object storage configured on the eVault. Detail on the `w3ds://file` scheme → [protocols.md](protocols.md). +`UploadFileInput`: `filename` (string), `contentType` (string, MIME), `content` (base64 or `data:` URI), `acl` (array). Decoded size must be ≤ 250 MB. Requires `X-ENAME` and object storage configured on the eVault. Detail on the `w3ds://file` scheme → [protocols.md](protocols.md). ### Binding documents diff --git a/skills/w3ds/reference/platform.md b/skills/w3ds/reference/platform.md index d133242b9..bf8a1a630 100644 --- a/skills/w3ds/reference/platform.md +++ b/skills/w3ds/reference/platform.md @@ -111,7 +111,12 @@ handleWebhook = async (req: Request, res: Response) => { const mapping = Object.values(this.adapter.mapping).find( (m: any) => m.schemaId === schemaId, ); - if (!mapping) throw new Error("No mapping found"); + // Broadcast delivery: ack ontologies you do not consume with a 200. + // A 4xx is retried by AaaS and then dead-lettered. + if (!mapping) { + console.log(`[webhook] skipping unknown schema ${schemaId} for ${globalId}`); + return res.status(200).send(); + } const local = await this.adapter.fromGlobal({ data: req.body.data, mapping }); diff --git a/skills/w3ds/reference/protocols.md b/skills/w3ds/reference/protocols.md index ef0868c66..dc1d68bba 100644 --- a/skills/w3ds/reference/protocols.md +++ b/skills/w3ds/reference/protocols.md @@ -273,8 +273,12 @@ Files uploaded via the eVault `uploadFile` mutation: ``` 3. The `w3ds://file` URI is built from the owner ename and the File Meta Envelope ID. +4. An awareness packet is dispatched with `schemaId: "w3ds-file-v1"` and + `operation: "create"`, carrying the stored payload as `data`. Subscribe to + that ontology to observe uploads; do not mirror the blob as a `File` record + just to make it visible in AaaS. -Max size 50 MB decoded. `uploadFile` API signature in [evault.md § File upload](evault.md#file-upload). +Max size 250 MB decoded. `uploadFile` API signature in [evault.md § File upload](evault.md#file-upload). ### Dereferencing