From 4329ec806303814edab34bb31a55b274710d872e Mon Sep 17 00:00:00 2001 From: coodos Date: Sat, 16 May 2026 14:22:00 +0530 Subject: [PATCH 01/12] add DigitalOcean Spaces object storage service to evault-core --- .env.example | 10 ++ infrastructure/evault-core/package.json | 1 + .../src/services/StorageService.ts | 101 ++++++++++++++++++ pnpm-lock.yaml | 90 ++++------------ 4 files changed, 131 insertions(+), 71 deletions(-) create mode 100644 infrastructure/evault-core/src/services/StorageService.ts diff --git a/.env.example b/.env.example index fe924eff8..b78105e49 100644 --- a/.env.example +++ b/.env.example @@ -142,6 +142,16 @@ AWARENESS_INGEST_SECRET="replace-with-a-strong-secret" AWARENESS_SERVICE_URL="http://localhost:4100" # Comma-separated eNames allowed to act as AaaS portal admins AAAS_ADMIN_ENAMES="" + +# DigitalOcean Spaces object storage (S3-compatible) — used by eVault core to +# store file blobs and expose public URIs for the w3ds://file URI scheme. +DO_SPACES_ENDPOINT="https://nyc3.digitaloceanspaces.com" +DO_SPACES_REGION="nyc3" +DO_SPACES_KEY="your-spaces-access-key" +DO_SPACES_SECRET="your-spaces-secret-key" +DO_SPACES_BUCKET="your-spaces-bucket" +# Optional public/CDN base URL; defaults to the bucket sub-domain on the endpoint +DO_SPACES_CDN_URL="" # Secret used to sign AaaS portal session JWTs AAAS_JWT_SECRET="replace-with-a-strong-secret" # Webhook delivery tuning diff --git a/infrastructure/evault-core/package.json b/infrastructure/evault-core/package.json index d01a551c9..e9cc7db24 100644 --- a/infrastructure/evault-core/package.json +++ b/infrastructure/evault-core/package.json @@ -16,6 +16,7 @@ "migration:revert": "npm run typeorm migration:revert -- -d dist/config/database.js" }, "dependencies": { + "@aws-sdk/client-s3": "^3.700.0", "@fastify/cors": "^8.5.0", "@fastify/formbody": "^8.0.2", "@fastify/swagger": "^8.14.0", diff --git a/infrastructure/evault-core/src/services/StorageService.ts b/infrastructure/evault-core/src/services/StorageService.ts new file mode 100644 index 000000000..ba3ec9983 --- /dev/null +++ b/infrastructure/evault-core/src/services/StorageService.ts @@ -0,0 +1,101 @@ +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; + +/** + * Thrown when the DigitalOcean Spaces environment is not configured. + */ +export class StorageNotConfiguredError extends Error { + constructor() { + super( + "Object storage is not configured. Set DO_SPACES_ENDPOINT, " + + "DO_SPACES_REGION, DO_SPACES_KEY, DO_SPACES_SECRET and DO_SPACES_BUCKET.", + ); + this.name = "StorageNotConfiguredError"; + } +} + +interface UploadObjectInput { + buffer: Buffer; + contentType: string; + key: string; +} + +/** + * StorageService uploads file blobs to DigitalOcean Spaces (an S3-compatible + * object store) and returns publicly reachable URLs. eVault uses this instead + * of base64-stuffing binary data into Neo4j envelopes. + */ +export class StorageService { + private readonly client: S3Client; + private readonly bucket: string; + private readonly cdnBaseUrl: string; + + constructor() { + const endpoint = process.env.DO_SPACES_ENDPOINT; + const region = process.env.DO_SPACES_REGION; + const key = process.env.DO_SPACES_KEY; + const secret = process.env.DO_SPACES_SECRET; + const bucket = process.env.DO_SPACES_BUCKET; + + if (!endpoint || !region || !key || !secret || !bucket) { + throw new StorageNotConfiguredError(); + } + + this.bucket = bucket; + // Public base for constructed URLs — a CDN/edge domain if provided, + // otherwise the bucket sub-domain on the Spaces endpoint. + this.cdnBaseUrl = ( + process.env.DO_SPACES_CDN_URL || + `${endpoint.replace("https://", `https://${bucket}.`)}` + ).replace(/\/$/, ""); + + this.client = new S3Client({ + endpoint, + region, + forcePathStyle: false, + credentials: { accessKeyId: key, secretAccessKey: secret }, + }); + } + + /** + * Returns true when the Spaces environment variables are all present. + */ + static isConfigured(): boolean { + return Boolean( + process.env.DO_SPACES_ENDPOINT && + process.env.DO_SPACES_REGION && + process.env.DO_SPACES_KEY && + process.env.DO_SPACES_SECRET && + process.env.DO_SPACES_BUCKET, + ); + } + + /** + * Builds a deterministic object key for a file owned by an eName. + */ + static buildKey(eName: string, filename: string, id: string): string { + const owner = eName.replace(/^@/, "").replace(/[^\w.-]/g, "_"); + const safeName = filename.replace(/[^\w.-]/g, "_"); + return `files/${owner}/${id}-${safeName}`; + } + + /** + * Uploads a public-read object and returns its public URL. + */ + async uploadObject({ + buffer, + contentType, + key, + }: UploadObjectInput): Promise { + await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: buffer, + ContentType: contentType, + ACL: "public-read", + }), + ); + + return `${this.cdnBaseUrl}/${key}`; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e35217d3..5f23ce56c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -490,6 +490,9 @@ importers: infrastructure/evault-core: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.700.0 + version: 3.1009.0 '@fastify/cors': specifier: ^8.5.0 version: 8.5.0 @@ -3263,7 +3266,7 @@ importers: version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) draft-js: specifier: ^0.11.7 - version: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@18.3.1) @@ -3284,7 +3287,7 @@ importers: version: 18.3.1(react@18.3.1) react-draft-wysiwyg: specifier: ^1.15.0 - version: 1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-hook-form: specifier: ^7.55.0 version: 7.71.2(react@18.3.1) @@ -32644,9 +32647,9 @@ snapshots: dotenv@17.3.1: {} - draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - fbjs: 2.0.0(encoding@0.1.13) + fbjs: 2.0.0 immutable: 3.7.6 object-assign: 4.1.1 react: 18.3.1 @@ -32654,9 +32657,9 @@ snapshots: transitivePeerDependencies: - encoding - draftjs-utils@0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + draftjs-utils@0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 drizzle-kit@0.31.9: @@ -33107,8 +33110,8 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@2.6.1)) @@ -33171,21 +33174,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@5.5.0) - eslint: 9.39.4(jiti@2.6.1) - get-tsconfig: 4.13.6 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -33228,17 +33216,6 @@ snapshots: - supports-color eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -33278,35 +33255,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -33318,7 +33266,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -34058,7 +34006,7 @@ snapshots: fbjs-css-vars@1.0.2: {} - fbjs@2.0.0(encoding@0.1.13): + fbjs@2.0.0: dependencies: core-js: 3.48.0 cross-fetch: 3.2.0(encoding@0.1.13) @@ -34956,9 +34904,9 @@ snapshots: html-tags@3.3.1: {} - html-to-draftjs@1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + html-to-draftjs@1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 html-url-attributes@3.0.1: {} @@ -39327,12 +39275,12 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-draft-wysiwyg@1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-draft-wysiwyg@1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: classnames: 2.5.1 - draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - draftjs-utils: 0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) - html-to-draftjs: 1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draftjs-utils: 0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + html-to-draftjs: 1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) immutable: 5.1.5 linkify-it: 2.2.0 prop-types: 15.8.1 From 54de4e8866f1167068ec2d6f8b8bac4f7df153fe Mon Sep 17 00:00:00 2001 From: coodos Date: Sat, 16 May 2026 19:48:00 +0530 Subject: [PATCH 02/12] add uploadFile GraphQL mutation backed by object storage --- .../src/core/protocol/graphql-server.ts | 131 ++++++++++++++++++ .../evault-core/src/core/protocol/typedefs.ts | 30 ++++ .../evault-core/src/core/utils/w3ds-uri.ts | 16 +++ 3 files changed, 177 insertions(+) create mode 100644 infrastructure/evault-core/src/core/utils/w3ds-uri.ts diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index b449d6d89..843cea821 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -10,6 +10,8 @@ import { computeEnvelopeHashForDelete, } from "../db/envelope-hash"; import { exampleQueries } from "./examples/examples"; +import { StorageService } from "../../services/StorageService"; +import { buildFileUri, FILE_SCHEMA_ID } from "../utils/w3ds-uri"; import { typeDefs } from "./typedefs"; import { VaultAccessGuard, type VaultContext } from "./vault-access-guard"; import { MessageNotificationService } from "../../services/MessageNotificationService"; @@ -1145,6 +1147,135 @@ export class GraphQLServer { }; }, ), + // Upload a file to object storage and create a File meta-envelope + uploadFile: this.accessGuard.middleware( + async ( + _: any, + { + input, + }: { + input: { + filename: string; + contentType: string; + content: string; + acl: string[]; + }; + }, + context: VaultContext, + ) => { + if (!context.eName) { + return { + errors: [ + { + message: "X-ENAME header is required", + code: "MISSING_ENAME", + }, + ], + }; + } + + if (!StorageService.isConfigured()) { + return { + errors: [ + { + message: + "Object storage is not configured on this eVault", + code: "STORAGE_NOT_CONFIGURED", + }, + ], + }; + } + + // Accept either raw base64 or a data: URI + const base64 = input.content.includes(",") + ? input.content.slice( + input.content.indexOf(",") + 1, + ) + : input.content; + const buffer = Buffer.from(base64, "base64"); + + if (buffer.length === 0) { + return { + errors: [ + { + field: "content", + message: "File content is empty or not valid base64", + code: "INVALID_CONTENT", + }, + ], + }; + } + + const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 MB + if (buffer.length > MAX_FILE_BYTES) { + return { + errors: [ + { + field: "content", + message: "File exceeds the 50 MB upload limit", + code: "FILE_TOO_LARGE", + }, + ], + }; + } + + try { + const objectId = require("uuid").v4(); + const key = StorageService.buildKey( + context.eName, + input.filename, + objectId, + ); + const storage = new StorageService(); + const publicUrl = await storage.uploadObject({ + buffer, + contentType: input.contentType, + key, + }); + + const payload = { + filename: input.filename, + contentType: input.contentType, + size: buffer.length, + blobKey: key, + publicUrl, + uploadedAt: new Date().toISOString(), + }; + + const result = await this.db.storeMetaEnvelope( + { + ontology: FILE_SCHEMA_ID, + payload, + acl: input.acl, + }, + input.acl, + context.eName, + ); + + return { + uri: buildFileUri( + context.eName, + result.metaEnvelope.id, + ), + metaEnvelopeId: result.metaEnvelope.id, + publicUrl, + }; + } catch (error) { + console.error("uploadFile failed:", error); + return { + errors: [ + { + message: + error instanceof Error + ? error.message + : "Failed to upload file", + code: "UPLOAD_FAILED", + }, + ], + }; + } + }, + ), updateMetaEnvelopeById: this.accessGuard.middleware( async ( _: any, diff --git a/infrastructure/evault-core/src/core/protocol/typedefs.ts b/infrastructure/evault-core/src/core/protocol/typedefs.ts index 99f8b6d78..013d6eb80 100644 --- a/infrastructure/evault-core/src/core/protocol/typedefs.ts +++ b/infrastructure/evault-core/src/core/protocol/typedefs.ts @@ -137,6 +137,33 @@ export const typeDefs = /* GraphQL */ ` errors: [UserError!] } + # ============================================================================ + # File Upload Types + # ============================================================================ + + "Input for uploading a file to eVault object storage" + input UploadFileInput { + "Original file name" + filename: String! + "MIME type of the file" + contentType: String! + "Base64-encoded file content (raw base64 or a data: URI)" + content: String! + "Access control list for the created File meta-envelope" + acl: [String!]! + } + + type UploadFilePayload { + "The w3ds://file URI addressing the uploaded file, null if errors occurred" + uri: String + "The ID of the File meta-envelope describing the upload" + metaEnvelopeId: String + "The public object-storage URL of the file" + publicUrl: String + "List of errors that occurred during the upload" + errors: [UserError!] + } + # ============================================================================ # Binding Document Types # ============================================================================ @@ -291,6 +318,9 @@ export const typeDefs = /* GraphQL */ ` skipWebhooks: Boolean = false ): BulkCreateMetaEnvelopesPayload! + "Upload a file to object storage and create an addressable File meta-envelope" + uploadFile(input: UploadFileInput!): UploadFilePayload! + # --- Binding Document Mutations --- "Create a new binding document" createBindingDocument(input: CreateBindingDocumentInput!): CreateBindingDocumentPayload! diff --git a/infrastructure/evault-core/src/core/utils/w3ds-uri.ts b/infrastructure/evault-core/src/core/utils/w3ds-uri.ts new file mode 100644 index 000000000..fd3d5d90a --- /dev/null +++ b/infrastructure/evault-core/src/core/utils/w3ds-uri.ts @@ -0,0 +1,16 @@ +/** + * Schema ID (ontology) used for File meta-envelopes created by the uploadFile + * mutation. Stable so that the dereference endpoint can recognise file records. + */ +export const FILE_SCHEMA_ID = "w3ds-file-v1"; + +/** + * Builds a `w3ds://file` URI that uniquely addresses a file meta-envelope. + * + * @example buildFileUri("@alice", "abc123") + * => "w3ds://file?id=@alice/abc123" + */ +export function buildFileUri(eName: string, metaEnvelopeId: string): string { + const ename = eName.startsWith("@") ? eName : `@${eName}`; + return `w3ds://file?id=${ename}/${metaEnvelopeId}`; +} From 98371fc929e33d0c080e27a0b800ceed3db0fd27 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 17 May 2026 11:35:00 +0530 Subject: [PATCH 03/12] add GET /files dereference endpoint redirecting to public URL --- .../evault-core/src/core/http/server.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/infrastructure/evault-core/src/core/http/server.ts b/infrastructure/evault-core/src/core/http/server.ts index 2c0129e9a..0d6b862cb 100644 --- a/infrastructure/evault-core/src/core/http/server.ts +++ b/infrastructure/evault-core/src/core/http/server.ts @@ -17,6 +17,7 @@ import { recordAttempt, } from "./passphrase-rate-limiter"; import { type TypedReply, type TypedRequest, WatcherRequest } from "./types"; +import { FILE_SCHEMA_ID } from "../utils/w3ds-uri"; interface WatcherSignatureRequest { w3id: string; @@ -54,6 +55,10 @@ export async function registerHttpRoutes( name: "provisioning", description: "eVault provisioning endpoints", }, + { + name: "files", + description: "File dereferencing endpoints", + }, ], }, }); @@ -355,6 +360,98 @@ export async function registerHttpRoutes( }, ); + // Dereference a w3ds://file URI — resolves the File meta-envelope and + // redirects to the public object-storage URL of the underlying file. + server.get<{ Params: { metaEnvelopeId: string } }>( + "/files/:metaEnvelopeId", + { + schema: { + tags: ["files"], + description: + "Dereference a file by its meta-envelope ID and redirect to its public URL", + headers: { + type: "object", + required: ["X-ENAME"], + properties: { + "X-ENAME": { type: "string" }, + }, + }, + params: { + type: "object", + required: ["metaEnvelopeId"], + properties: { + metaEnvelopeId: { type: "string" }, + }, + }, + response: { + 302: { type: "null" }, + 400: { + type: "object", + properties: { error: { type: "string" } }, + }, + 404: { + type: "object", + properties: { error: { type: "string" } }, + }, + }, + }, + }, + async (request, reply) => { + const eName = + request.headers["x-ename"] || request.headers["X-ENAME"]; + + if (!eName || typeof eName !== "string") { + return reply + .status(400) + .send({ error: "X-ENAME header is required" }); + } + + const { metaEnvelopeId } = request.params; + if (!metaEnvelopeId || typeof metaEnvelopeId !== "string") { + return reply + .status(400) + .send({ error: "A valid meta-envelope ID is required" }); + } + + if (!dbService) { + return reply + .status(500) + .send({ error: "Database service not available" }); + } + + try { + const metaEnvelope = await dbService.findMetaEnvelopeById( + metaEnvelopeId, + eName, + ); + + if (!metaEnvelope || metaEnvelope.ontology !== FILE_SCHEMA_ID) { + return reply.status(404).send({ + error: `No file found for w3ds://file?id=${eName}/${metaEnvelopeId}`, + }); + } + + const publicUrl = (metaEnvelope.parsed as Record) + ?.publicUrl; + if (!publicUrl || typeof publicUrl !== "string") { + return reply.status(404).send({ + error: "File meta-envelope has no public URL", + }); + } + + return reply.redirect(publicUrl); + } catch (error) { + console.error("Error dereferencing file:", error); + return reply.status(500).send({ + error: + error instanceof Error + ? error.message + : "Failed to dereference file", + }); + } + }, + ); + // Temporary token-gated cross-eVault read by ontology. Intentionally undocumented in Swagger. server.get<{ Params: { ontology: string } }>( "/metaenvelopes/by-ontology/:ontology", From e9d4ab2f02f22037e807d256a538ea1a8c2a68ed Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 17 May 2026 17:12:00 +0530 Subject: [PATCH 04/12] add w3ds file URI parse and build utilities to web3-adapter --- .../web3-adapter/src/w3ds/uri.test.ts | 97 ++++++++++++++ infrastructure/web3-adapter/src/w3ds/uri.ts | 122 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 infrastructure/web3-adapter/src/w3ds/uri.test.ts create mode 100644 infrastructure/web3-adapter/src/w3ds/uri.ts diff --git a/infrastructure/web3-adapter/src/w3ds/uri.test.ts b/infrastructure/web3-adapter/src/w3ds/uri.test.ts new file mode 100644 index 000000000..0e1ff111c --- /dev/null +++ b/infrastructure/web3-adapter/src/w3ds/uri.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + buildFileUri, + InvalidW3dsUriError, + isFileUri, + parseFileUri, +} from "./uri"; + +describe("w3ds file URI", () => { + describe("buildFileUri", () => { + it("builds a canonical URI", () => { + expect( + buildFileUri({ ename: "@alice", metaEnvelopeId: "abc123" }), + ).toBe("w3ds://file?id=@alice/abc123"); + }); + + it("normalises a missing @ prefix on the ename", () => { + expect( + buildFileUri({ ename: "alice", metaEnvelopeId: "abc123" }), + ).toBe("w3ds://file?id=@alice/abc123"); + }); + + it("throws when ename or metaEnvelopeId is missing", () => { + expect(() => + buildFileUri({ ename: "", metaEnvelopeId: "abc" }), + ).toThrow(InvalidW3dsUriError); + expect(() => + buildFileUri({ ename: "@alice", metaEnvelopeId: "" }), + ).toThrow(InvalidW3dsUriError); + }); + }); + + describe("parseFileUri", () => { + it("round-trips with buildFileUri", () => { + const uri = buildFileUri({ + ename: "@alice", + metaEnvelopeId: "envelope-abc123", + }); + expect(parseFileUri(uri)).toEqual({ + ename: "@alice", + metaEnvelopeId: "envelope-abc123", + }); + }); + + it("rejects an empty input", () => { + expect(() => parseFileUri("")).toThrow(InvalidW3dsUriError); + }); + + it("rejects a non-w3ds scheme", () => { + expect(() => + parseFileUri("https://file?id=@alice/abc"), + ).toThrow(/expected scheme/); + }); + + it("rejects a wrong host", () => { + expect(() => + parseFileUri("w3ds://blob?id=@alice/abc"), + ).toThrow(/expected host/); + }); + + it("rejects a missing id parameter", () => { + expect(() => parseFileUri("w3ds://file")).toThrow( + /missing required `id`/, + ); + }); + + it("rejects an id without an ename", () => { + expect(() => + parseFileUri("w3ds://file?id=alice/abc"), + ).toThrow(/must be in the form/); + }); + + it("rejects an id without a meta-envelope-id segment", () => { + expect(() => parseFileUri("w3ds://file?id=@alice")).toThrow( + /missing the `\/`/, + ); + }); + + it("rejects an empty meta-envelope-id", () => { + expect(() => parseFileUri("w3ds://file?id=@alice/")).toThrow( + /meta-envelope-id is empty/, + ); + }); + }); + + describe("isFileUri", () => { + it("recognises a w3ds file URI", () => { + expect(isFileUri("w3ds://file?id=@alice/abc")).toBe(true); + }); + + it("rejects non-file values", () => { + expect(isFileUri("https://example.com/x.png")).toBe(false); + expect(isFileUri(42)).toBe(false); + expect(isFileUri(null)).toBe(false); + }); + }); +}); diff --git a/infrastructure/web3-adapter/src/w3ds/uri.ts b/infrastructure/web3-adapter/src/w3ds/uri.ts new file mode 100644 index 000000000..bf28b735a --- /dev/null +++ b/infrastructure/web3-adapter/src/w3ds/uri.ts @@ -0,0 +1,122 @@ +/** + * The `w3ds://file` URI scheme. + * + * A file attached to or described by a Meta Envelope is addressed with: + * + * w3ds://file?id=@/ + * + * This module provides helpers to construct, parse and recognise such URIs. + */ + +export const W3DS_SCHEME = "w3ds:"; +export const W3DS_FILE_HOST = "file"; + +/** + * Thrown when a string is not a valid `w3ds://file` URI. + */ +export class InvalidW3dsUriError extends Error { + constructor(uri: string, reason: string) { + super(`Invalid w3ds file URI "${uri}": ${reason}`); + this.name = "InvalidW3dsUriError"; + } +} + +export interface FileUriParts { + /** The owning user's entity name, always `@`-prefixed. */ + ename: string; + /** The Meta Envelope identifier of the file. */ + metaEnvelopeId: string; +} + +/** + * Builds a `w3ds://file` URI for a file described by a Meta Envelope. + * + * @example buildFileUri({ ename: "alice", metaEnvelopeId: "abc123" }) + * => "w3ds://file?id=@alice/abc123" + */ +export function buildFileUri({ ename, metaEnvelopeId }: FileUriParts): string { + if (!ename) { + throw new InvalidW3dsUriError("", "ename is required"); + } + if (!metaEnvelopeId) { + throw new InvalidW3dsUriError("", "metaEnvelopeId is required"); + } + const normalisedEname = ename.startsWith("@") ? ename : `@${ename}`; + return `${W3DS_SCHEME}//${W3DS_FILE_HOST}?id=${normalisedEname}/${metaEnvelopeId}`; +} + +/** + * Parses a `w3ds://file` URI into its `ename` and `metaEnvelopeId` parts. + * + * @throws {InvalidW3dsUriError} when the URI is malformed, uses the wrong + * scheme/host, or is missing the `id` query parameter. + */ +export function parseFileUri(uri: string): FileUriParts { + if (typeof uri !== "string" || uri.trim().length === 0) { + throw new InvalidW3dsUriError(String(uri), "URI is empty"); + } + + let parsed: URL; + try { + parsed = new URL(uri); + } catch { + throw new InvalidW3dsUriError(uri, "not a parseable URI"); + } + + if (parsed.protocol !== W3DS_SCHEME) { + throw new InvalidW3dsUriError( + uri, + `expected scheme "${W3DS_SCHEME}//" but got "${parsed.protocol}//"`, + ); + } + + if (parsed.host !== W3DS_FILE_HOST) { + throw new InvalidW3dsUriError( + uri, + `expected host "${W3DS_FILE_HOST}" but got "${parsed.host}"`, + ); + } + + const id = parsed.searchParams.get("id"); + if (!id) { + throw new InvalidW3dsUriError(uri, "missing required `id` query parameter"); + } + + if (!id.startsWith("@")) { + throw new InvalidW3dsUriError( + uri, + "`id` must be in the form @/", + ); + } + + const slashIndex = id.indexOf("/"); + if (slashIndex === -1) { + throw new InvalidW3dsUriError( + uri, + "`id` is missing the `/` segment", + ); + } + + const ename = id.slice(0, slashIndex); + const metaEnvelopeId = id.slice(slashIndex + 1); + + if (ename.length <= 1) { + throw new InvalidW3dsUriError(uri, "ename is empty"); + } + if (metaEnvelopeId.length === 0) { + throw new InvalidW3dsUriError(uri, "meta-envelope-id is empty"); + } + + return { ename, metaEnvelopeId }; +} + +/** + * Cheap guard: returns true when `value` looks like a `w3ds://file` URI. + * Used by the mapper to decide whether a field needs dereferencing. + */ +export function isFileUri(value: unknown): value is string { + return ( + typeof value === "string" && + value.startsWith(`${W3DS_SCHEME}//${W3DS_FILE_HOST}`) + ); +} From 06dfe6997701e797f8fa69be5f89acbe7072e6cb Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 12:05:00 +0530 Subject: [PATCH 05/12] add w3ds file resolver and EVaultClient uploadFile support --- .../web3-adapter/src/evault/evault.ts | 97 +++++++++++++++++++ .../web3-adapter/src/w3ds/resolver.ts | 82 ++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 infrastructure/web3-adapter/src/w3ds/resolver.ts diff --git a/infrastructure/web3-adapter/src/evault/evault.ts b/infrastructure/web3-adapter/src/evault/evault.ts index 8180854f6..4d6c910bf 100644 --- a/infrastructure/web3-adapter/src/evault/evault.ts +++ b/infrastructure/web3-adapter/src/evault/evault.ts @@ -62,6 +62,48 @@ const UPDATE_META_ENVELOPE = ` } `; +const UPLOAD_FILE = ` + mutation UploadFile($input: UploadFileInput!) { + uploadFile(input: $input) { + uri + metaEnvelopeId + publicUrl + errors { + field + message + code + } + } + } +`; + +export interface UploadFileInput { + filename: string; + contentType: string; + /** Base64-encoded file content (raw base64 or a data: URI). */ + content: string; + acl?: string[]; +} + +export interface UploadFileResult { + uri: string; + metaEnvelopeId: string; + publicUrl: string; +} + +interface UploadFileResponse { + uploadFile: { + uri: string | null; + metaEnvelopeId: string | null; + publicUrl: string | null; + errors?: Array<{ + field?: string | null; + message: string; + code?: string | null; + }> | null; + }; +} + interface MetaEnvelopeResponse { getMetaEnvelopeById: { id: string; @@ -554,6 +596,61 @@ export class EVaultClient { }); } + /** + * Uploads a file to the owner eVault's object storage and returns the + * resulting `w3ds://file` URI alongside the public object-storage URL. + */ + async uploadFile( + w3id: string, + input: UploadFileInput, + ): Promise { + return this.withRetry(async () => { + if (this.isDisposed) { + throw new Error("EVaultClient has been disposed"); + } + + const client = await this.ensureClient(w3id).catch((error) => { + throw new Error( + `Failed to establish client connection: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + + const response = await this.withTimeout(w3id, () => + client.request(UPLOAD_FILE, { + input: { + filename: input.filename, + contentType: input.contentType, + content: input.content, + acl: input.acl ?? ["*"], + }, + }), + ).catch((error) => { + throw new Error( + `Failed to upload file: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + + const result = response?.uploadFile; + if (!result) { + throw new Error("Failed to upload file: Invalid response"); + } + if (result.errors && result.errors.length > 0) { + throw new Error( + `Failed to upload file: ${result.errors.map((e) => e.message).join("; ")}`, + ); + } + if (!result.uri || !result.metaEnvelopeId || !result.publicUrl) { + throw new Error("Failed to upload file: incomplete response"); + } + + return { + uri: result.uri, + metaEnvelopeId: result.metaEnvelopeId, + publicUrl: result.publicUrl, + }; + }); + } + async fetchMetaEnvelope(id: string, w3id: string): Promise { return this.withRetry(async () => { const client = await this.ensureClient(w3id); diff --git a/infrastructure/web3-adapter/src/w3ds/resolver.ts b/infrastructure/web3-adapter/src/w3ds/resolver.ts new file mode 100644 index 000000000..2d1f97928 --- /dev/null +++ b/infrastructure/web3-adapter/src/w3ds/resolver.ts @@ -0,0 +1,82 @@ +/** + * Resolver / dereferencer for the `w3ds://file` URI scheme. + * + * - `referenceFile` uploads a file and returns a `w3ds://file` URI for it. + * - `dereferenceFileUri` takes such a URI and resolves it to the underlying + * file's public object-storage URL plus its descriptive metadata. + */ +import type { EVaultClient, UploadFileInput } from "../evault/evault"; +import { parseFileUri } from "./uri"; + +export interface DereferencedFile { + /** The original `w3ds://file` URI that was dereferenced. */ + uri: string; + /** The owning user's entity name. */ + ename: string; + /** The Meta Envelope identifier of the file. */ + metaEnvelopeId: string; + /** Publicly reachable object-storage URL of the file. */ + publicUrl: string; + /** Original file name, when recorded. */ + filename?: string; + /** MIME type, when recorded. */ + contentType?: string; + /** File size in bytes, when recorded. */ + size?: number; +} + +/** + * Dereferences a `w3ds://file` URI: resolves the owning eVault, fetches the + * File Meta Envelope and returns the file's public URL and metadata. + * + * @throws {InvalidW3dsUriError} when the URI is malformed. + * @throws {Error} when the eName or Meta Envelope cannot be resolved. + */ +export async function dereferenceFileUri( + uri: string, + evaultClient: EVaultClient, +): Promise { + const { ename, metaEnvelopeId } = parseFileUri(uri); + + let envelope: { data: Record }; + try { + envelope = await evaultClient.fetchMetaEnvelope(metaEnvelopeId, ename); + } catch (error) { + throw new Error( + `Unable to dereference "${uri}": could not resolve Meta Envelope ` + + `${metaEnvelopeId} for ${ename} (${error instanceof Error ? error.message : String(error)})`, + ); + } + + const data = envelope.data ?? {}; + const publicUrl = data.publicUrl; + if (typeof publicUrl !== "string" || publicUrl.length === 0) { + throw new Error( + `Unable to dereference "${uri}": Meta Envelope ${metaEnvelopeId} is not a file or has no public URL`, + ); + } + + return { + uri, + ename, + metaEnvelopeId, + publicUrl, + filename: typeof data.filename === "string" ? data.filename : undefined, + contentType: + typeof data.contentType === "string" ? data.contentType : undefined, + size: typeof data.size === "number" ? data.size : undefined, + }; +} + +/** + * Uploads a file to the owner eVault's object storage and returns the + * `w3ds://file` URI that addresses it. + */ +export async function referenceFile( + evaultClient: EVaultClient, + ename: string, + input: UploadFileInput, +): Promise { + const result = await evaultClient.uploadFile(ename, input); + return result.uri; +} From 1ac9ae84bf33c2f4f1cafa4d254ac83561cd49e4 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 18:40:00 +0530 Subject: [PATCH 06/12] wire __file mapping directive and document w3ds URI scheme --- infrastructure/web3-adapter/MAPPING_RULES.md | 22 +++++ infrastructure/web3-adapter/W3DS_URI.md | 94 +++++++++++++++++++ infrastructure/web3-adapter/src/index.ts | 3 + .../web3-adapter/src/mapper/mapper.ts | 59 +++++++++++- .../web3-adapter/src/mapper/mapper.types.ts | 6 ++ .../web3-adapter/src/w3ds/resolver.ts | 49 +++++++++- 6 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 infrastructure/web3-adapter/W3DS_URI.md diff --git a/infrastructure/web3-adapter/MAPPING_RULES.md b/infrastructure/web3-adapter/MAPPING_RULES.md index 64dd39131..40fa096b8 100644 --- a/infrastructure/web3-adapter/MAPPING_RULES.md +++ b/infrastructure/web3-adapter/MAPPING_RULES.md @@ -88,6 +88,28 @@ Performs mathematical calculations using field values. - Can reference other fields in the same entity - Automatically resolves field values before calculation +### File Referencing (`__file`) + +Uploads inline file payloads to the owner eVault's object storage and replaces +them with a stable `w3ds://file` URI. On the way back, the URI is dereferenced +to the file's public URL. + +```json +"avatar": "__file(avatar)" +"avatar": "__file(avatar),avatarUri" +``` + +- The inner path (`avatar`) points to the field holding the file value. +- An optional `,alias` sets the global field name (defaults to the inner path). +- **`toGlobal`**: a `data:` URI value is uploaded and replaced with a + `w3ds://file?id=@/` URI. Values that are already + `w3ds://file` URIs, plain URLs, or empty are passed through unchanged. +- **`fromGlobal`**: a `w3ds://file` URI is dereferenced to the file's public + object-storage URL; other values pass through unchanged. + +Requires an `EVaultClient` to be supplied to the mapper — the `Web3Adapter` +wires this automatically. See [`W3DS_URI.md`](./W3DS_URI.md) for the URI scheme. + ## Owner Path The `ownerEnamePath` defines how to determine which eVault owns the data: diff --git a/infrastructure/web3-adapter/W3DS_URI.md b/infrastructure/web3-adapter/W3DS_URI.md new file mode 100644 index 000000000..f7319a052 --- /dev/null +++ b/infrastructure/web3-adapter/W3DS_URI.md @@ -0,0 +1,94 @@ +# The `w3ds://file` URI Scheme + +A standardised, human-readable URI scheme for referencing and dereferencing +files within the MetaState ecosystem. + +## Format + +``` +w3ds://file?id=@/ +``` + +| Component | Description | +| ------------------ | ------------------------------------------------------------------ | +| `w3ds://` | The scheme. Always lowercase. | +| `file` | The resource host. Identifies the URI as addressing a file. | +| `id` | Required query parameter carrying the file's address. | +| `@` | The owning user's entity name (`ename`), always `@`-prefixed. | +| `` | The ID of the Meta Envelope describing the file. | + +Example: + +``` +w3ds://file?id=@alice/envelope-abc123 +``` + +## How files are stored + +A file uploaded to eVault is: + +1. Streamed to object storage (DigitalOcean Spaces, S3-compatible) as a + `public-read` object. +2. Recorded as a **File Meta Envelope** (ontology `w3ds-file-v1`) with payload: + `{ filename, contentType, size, blobKey, publicUrl, uploadedAt }`. +3. Addressed by a `w3ds://file` URI built from the owner `ename` and the + Meta Envelope ID. + +## Constructing a URI + +The eVault `uploadFile` GraphQL mutation returns the `w3ds://file` URI directly. +In the web3-adapter: + +```ts +import { buildFileUri } from "./w3ds/uri"; + +buildFileUri({ ename: "@alice", metaEnvelopeId: "abc123" }); +// => "w3ds://file?id=@alice/abc123" +``` + +## Resolving (dereferencing) a URI + +There are two dereferencers: + +### HTTP — eVault core + +``` +GET /files/:metaEnvelopeId (header: X-ENAME: @) +``` + +Resolves the File Meta Envelope and responds with a **302 redirect** to the +file's public object-storage URL. + +- `400` — missing `X-ENAME` header or malformed ID. +- `404` — no File Meta Envelope for that ID, or it has no public URL. + +### Programmatic — web3-adapter + +```ts +import { dereferenceFileUri } from "./w3ds/resolver"; + +const file = await dereferenceFileUri( + "w3ds://file?id=@alice/abc123", + evaultClient, +); +// => { uri, ename, metaEnvelopeId, publicUrl, filename, contentType, size } +``` + +## Error handling + +`parseFileUri` / `dereferenceFileUri` throw a descriptive `InvalidW3dsUriError` +or `Error` for: + +- Malformed URIs (not parseable, empty input). +- Wrong scheme (not `w3ds:`) or wrong host (not `file`). +- Missing `id` query parameter. +- `id` missing the `@` prefix or the `/` segment. +- Empty `ename` or `meta-envelope-id`. +- A non-existent `ename` (eVault cannot be resolved). +- A non-existent or non-file Meta Envelope. + +## Mapper integration + +The web3-adapter mapper exposes a `__file()` directive that automatically +references files on `toGlobal` and dereferences them on `fromGlobal`. See +[`MAPPING_RULES.md`](./MAPPING_RULES.md#file-referencing-__file). diff --git a/infrastructure/web3-adapter/src/index.ts b/infrastructure/web3-adapter/src/index.ts index 35772efd5..f584e41ff 100644 --- a/infrastructure/web3-adapter/src/index.ts +++ b/infrastructure/web3-adapter/src/index.ts @@ -315,6 +315,7 @@ export class Web3Adapter { data, mapping: this.mapping[tableName], mappingStore: this.mappingDb, + evaultClient: this.evaultClient, }); this.evaultClient @@ -344,6 +345,7 @@ export class Web3Adapter { data, mapping: this.mapping[tableName], mappingStore: this.mappingDb, + evaultClient: this.evaultClient, }); let globalId: string; @@ -401,6 +403,7 @@ export class Web3Adapter { data, mapping, mappingStore: this.mappingDb, + evaultClient: this.evaultClient, }); return local; diff --git a/infrastructure/web3-adapter/src/mapper/mapper.ts b/infrastructure/web3-adapter/src/mapper/mapper.ts index 6cc77d147..659d5444b 100644 --- a/infrastructure/web3-adapter/src/mapper/mapper.ts +++ b/infrastructure/web3-adapter/src/mapper/mapper.ts @@ -1,8 +1,18 @@ +import { dereferenceFileUri, referenceFileValue } from "../w3ds/resolver"; +import { isFileUri } from "../w3ds/uri"; import type { IMapperResponse, IMappingConversionOptions, } from "./mapper.types"; +/** + * Matches the `__file()` directive with an optional `,` suffix. + * `__file()` lets a mapped field carry a file: on `toGlobal` the value is + * uploaded and replaced with a `w3ds://file` URI; on `fromGlobal` that URI is + * dereferenced back to a public URL. + */ +const FILE_DIRECTIVE_RE = /^__file\((.+?)\)(?:,(.+))?$/; + // biome-ignore lint/suspicious/noExplicitAny: export function getValueByPath(obj: Record, path: string): any { // Handle array mapping case (e.g., "images[].src") @@ -124,6 +134,7 @@ export async function fromGlobal({ data, mapping, mappingStore, + evaultClient, }: IMappingConversionOptions): Promise> { const result: Record = {}; @@ -134,6 +145,31 @@ export async function fromGlobal({ const targetKey: string = localKey; let tableRef: string | null = null; + const fileMatch = globalPathRaw.match(FILE_DIRECTIVE_RE); + if (fileMatch) { + const [, localPath, alias] = fileMatch; + const uriValue = getValueByPath(data, alias ?? localPath); + if (isFileUri(uriValue) && evaultClient) { + try { + const dereferenced = await dereferenceFileUri( + uriValue, + evaultClient, + ); + result[localKey] = dereferenced.publicUrl; + } catch (error) { + console.error( + `Failed to dereference file URI for "${localKey}":`, + error, + ); + result[localKey] = uriValue; + } + } else { + // Not a w3ds URI, or no client — pass the value through. + result[localKey] = uriValue; + } + continue; + } + const internalFnMatch = globalPathRaw.match(/^__(\w+)\((.+)\)$/); if (internalFnMatch) { const [, outerFn, innerExpr] = internalFnMatch; @@ -239,9 +275,13 @@ export async function toGlobal({ data, mapping, mappingStore, + evaultClient, }: IMappingConversionOptions): Promise { const result: Record = {}; + // Resolved up-front so the `__file()` directive can upload to the owner eVault. + const ownerEvault = await extractOwnerEvault(data, mapping.ownerEnamePath); + for (const [localKey, globalPathRaw] of Object.entries( mapping.localToUniversalMap, )) { @@ -249,6 +289,24 @@ export async function toGlobal({ let value: any; let targetKey: string = globalPathRaw; + const fileMatch = globalPathRaw.match(FILE_DIRECTIVE_RE); + if (fileMatch) { + const [, localPath, alias] = fileMatch; + const fileTargetKey = alias ?? localPath; + const rawVal = getValueByPath(data, localPath); + if (evaultClient && ownerEvault) { + result[fileTargetKey] = await referenceFileValue( + rawVal, + ownerEvault, + evaultClient, + ); + } else { + // No client/owner available — pass the value through unchanged. + result[fileTargetKey] = rawVal; + } + continue; + } + if (globalPathRaw.includes(",")) { const [_, alias] = globalPathRaw.split(","); targetKey = alias; @@ -347,7 +405,6 @@ export async function toGlobal({ } result[targetKey] = value; } - const ownerEvault = await extractOwnerEvault(data, mapping.ownerEnamePath); return { ownerEvault, diff --git a/infrastructure/web3-adapter/src/mapper/mapper.types.ts b/infrastructure/web3-adapter/src/mapper/mapper.types.ts index 50b0f2fbf..00c2147e2 100644 --- a/infrastructure/web3-adapter/src/mapper/mapper.types.ts +++ b/infrastructure/web3-adapter/src/mapper/mapper.types.ts @@ -1,4 +1,5 @@ import type { MappingDatabase } from "../db"; +import type { EVaultClient } from "../evault/evault"; export interface IMapping { /** @@ -45,6 +46,11 @@ export interface IMappingConversionOptions { data: Record; mapping: IMapping; mappingStore: MappingDatabase; + /** + * Optional eVault client. Required only when a mapping uses the `__file()` + * directive — it is used to upload files and dereference `w3ds://file` URIs. + */ + evaultClient?: EVaultClient; } export interface IMapperResponse { diff --git a/infrastructure/web3-adapter/src/w3ds/resolver.ts b/infrastructure/web3-adapter/src/w3ds/resolver.ts index 2d1f97928..243e10296 100644 --- a/infrastructure/web3-adapter/src/w3ds/resolver.ts +++ b/infrastructure/web3-adapter/src/w3ds/resolver.ts @@ -6,7 +6,22 @@ * file's public object-storage URL plus its descriptive metadata. */ import type { EVaultClient, UploadFileInput } from "../evault/evault"; -import { parseFileUri } from "./uri"; +import { parseFileUri, W3DS_FILE_HOST, W3DS_SCHEME } from "./uri"; + +/** Minimal MIME → file extension map for naming uploaded files. */ +const MIME_EXTENSIONS: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/svg+xml": "svg", + "application/pdf": "pdf", + "text/plain": "txt", +}; + +function extensionForMime(mime: string): string { + return MIME_EXTENSIONS[mime] ?? "bin"; +} export interface DereferencedFile { /** The original `w3ds://file` URI that was dereferenced. */ @@ -80,3 +95,35 @@ export async function referenceFile( const result = await evaultClient.uploadFile(ename, input); return result.uri; } + +/** + * Converts a raw file field value into a `w3ds://file` URI. + * + * - `data:` URIs are uploaded to the owner eVault and replaced with their URI. + * - Values that are already `w3ds://file` URIs are returned unchanged. + * - Any other value (plain URL, empty, non-string) is returned untouched. + * + * Used by the mapper's `__file()` directive on the `toGlobal` path. + */ +export async function referenceFileValue( + value: unknown, + ename: string, + evaultClient: EVaultClient, +): Promise { + if (typeof value !== "string" || value.length === 0) return value; + const str: string = value; + if (str.startsWith(`${W3DS_SCHEME}//${W3DS_FILE_HOST}`)) return str; + + const dataUriMatch = str.match(/^data:([^;,]+)(;base64)?,/s); + if (!dataUriMatch) { + // Not an inline file payload — leave plain URLs / strings as-is. + return value; + } + + const contentType = dataUriMatch[1]; + return referenceFile(evaultClient, ename, { + filename: `file.${extensionForMime(contentType)}`, + contentType, + content: str, + }); +} From ad1476507a7fd164ad5d17f05e739a76462ff9ca Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 20:15:00 +0530 Subject: [PATCH 07/12] add array support to __file mapping directive --- .../src/mapper/file-directive.test.ts | 148 ++++++++++++++++++ .../web3-adapter/src/mapper/mapper.ts | 74 ++++++--- 2 files changed, 198 insertions(+), 24 deletions(-) create mode 100644 infrastructure/web3-adapter/src/mapper/file-directive.test.ts diff --git a/infrastructure/web3-adapter/src/mapper/file-directive.test.ts b/infrastructure/web3-adapter/src/mapper/file-directive.test.ts new file mode 100644 index 000000000..02cfe5adf --- /dev/null +++ b/infrastructure/web3-adapter/src/mapper/file-directive.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; +import type { EVaultClient } from "../evault/evault"; +import type { MappingDatabase } from "../db"; +import type { IMapping } from "./mapper.types"; +import { fromGlobal, toGlobal } from "./mapper"; + +// Minimal stubs — the `__file()` directive paths never touch the mapping store. +const mappingStore = {} as MappingDatabase; + +const PNG_DATA_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="; + +function mockClient(): EVaultClient { + return { + uploadFile: vi.fn(async (_w3id: string, input: { filename: string }) => ({ + uri: "w3ds://file?id=@owner/env-123", + metaEnvelopeId: "env-123", + publicUrl: "https://cdn.example.com/files/owner/env-123-" + input.filename, + })), + fetchMetaEnvelope: vi.fn(async (id: string) => ({ + id, + schemaId: "w3ds-file-v1", + w3id: "@owner", + data: { publicUrl: `https://cdn.example.com/files/${id}.png` }, + })), + } as unknown as EVaultClient; +} + +const userMapping: IMapping = { + tableName: "users", + schemaId: "schema-user", + ownerEnamePath: "ename", + localToUniversalMap: { + ename: "ename", + avatarUrl: "__file(avatarUrl)", + }, +}; + +const postMapping: IMapping = { + tableName: "posts", + schemaId: "schema-post", + ownerEnamePath: "ename", + localToUniversalMap: { + ename: "ename", + images: "__file(images),mediaUrls", + }, +}; + +describe("__file mapping directive", () => { + it("passes file fields through unchanged when no eVault client is supplied", async () => { + const global = await toGlobal({ + data: { ename: "@owner", avatarUrl: PNG_DATA_URI }, + mapping: userMapping, + mappingStore, + }); + expect(global.data.avatarUrl).toBe(PNG_DATA_URI); + + const local = await fromGlobal({ + data: { ename: "@owner", avatarUrl: "w3ds://file?id=@owner/env-123" }, + mapping: userMapping, + mappingStore, + }); + expect(local.data.avatarUrl).toBe("w3ds://file?id=@owner/env-123"); + }); + + it("uploads a data URI on toGlobal and replaces it with a w3ds URI", async () => { + const client = mockClient(); + const global = await toGlobal({ + data: { ename: "@owner", avatarUrl: PNG_DATA_URI }, + mapping: userMapping, + mappingStore, + evaultClient: client, + }); + expect(global.data.avatarUrl).toBe("w3ds://file?id=@owner/env-123"); + expect(client.uploadFile).toHaveBeenCalledOnce(); + }); + + it("leaves a plain URL untouched on toGlobal", async () => { + const client = mockClient(); + const global = await toGlobal({ + data: { ename: "@owner", avatarUrl: "https://example.com/a.png" }, + mapping: userMapping, + mappingStore, + evaultClient: client, + }); + expect(global.data.avatarUrl).toBe("https://example.com/a.png"); + expect(client.uploadFile).not.toHaveBeenCalled(); + }); + + it("dereferences a w3ds URI to its public URL on fromGlobal", async () => { + const client = mockClient(); + const local = await fromGlobal({ + data: { ename: "@owner", avatarUrl: "w3ds://file?id=@owner/env-abc" }, + mapping: userMapping, + mappingStore, + evaultClient: client, + }); + expect(local.data.avatarUrl).toBe( + "https://cdn.example.com/files/env-abc.png", + ); + }); + + it("leaves a non-w3ds value untouched on fromGlobal", async () => { + const client = mockClient(); + const local = await fromGlobal({ + data: { ename: "@owner", avatarUrl: "https://example.com/a.png" }, + mapping: userMapping, + mappingStore, + evaultClient: client, + }); + expect(local.data.avatarUrl).toBe("https://example.com/a.png"); + expect(client.fetchMetaEnvelope).not.toHaveBeenCalled(); + }); + + it("handles arrays of files in both directions", async () => { + const client = mockClient(); + const global = await toGlobal({ + data: { + ename: "@owner", + images: [PNG_DATA_URI, "https://example.com/keep.png"], + }, + mapping: postMapping, + mappingStore, + evaultClient: client, + }); + expect(global.data.mediaUrls).toEqual([ + "w3ds://file?id=@owner/env-123", + "https://example.com/keep.png", + ]); + + const local = await fromGlobal({ + data: { + ename: "@owner", + mediaUrls: [ + "w3ds://file?id=@owner/env-1", + "https://example.com/keep.png", + ], + }, + mapping: postMapping, + mappingStore, + evaultClient: client, + }); + expect(local.data.images).toEqual([ + "https://cdn.example.com/files/env-1.png", + "https://example.com/keep.png", + ]); + }); +}); diff --git a/infrastructure/web3-adapter/src/mapper/mapper.ts b/infrastructure/web3-adapter/src/mapper/mapper.ts index 659d5444b..e6ef5601b 100644 --- a/infrastructure/web3-adapter/src/mapper/mapper.ts +++ b/infrastructure/web3-adapter/src/mapper/mapper.ts @@ -1,3 +1,4 @@ +import type { EVaultClient } from "../evault/evault"; import { dereferenceFileUri, referenceFileValue } from "../w3ds/resolver"; import { isFileUri } from "../w3ds/uri"; import type { @@ -7,12 +8,34 @@ import type { /** * Matches the `__file()` directive with an optional `,` suffix. - * `__file()` lets a mapped field carry a file: on `toGlobal` the value is - * uploaded and replaced with a `w3ds://file` URI; on `fromGlobal` that URI is - * dereferenced back to a public URL. + * `__file()` lets a mapped field carry a file (single value or array): on + * `toGlobal` each value is uploaded and replaced with a `w3ds://file` URI; on + * `fromGlobal` each URI is dereferenced back to a public URL. */ const FILE_DIRECTIVE_RE = /^__file\((.+?)\)(?:,(.+))?$/; +/** + * Dereferences a single file value: a `w3ds://file` URI becomes its public + * object-storage URL; any other value is passed through unchanged. + */ +async function dereferenceFileValue( + value: unknown, + evaultClient?: EVaultClient, + fieldKey?: string, +): Promise { + if (!isFileUri(value) || !evaultClient) return value; + try { + const dereferenced = await dereferenceFileUri(value, evaultClient); + return dereferenced.publicUrl; + } catch (error) { + console.error( + `Failed to dereference file URI for "${fieldKey ?? "?"}":`, + error, + ); + return value; + } +} + // biome-ignore lint/suspicious/noExplicitAny: export function getValueByPath(obj: Record, path: string): any { // Handle array mapping case (e.g., "images[].src") @@ -149,23 +172,18 @@ export async function fromGlobal({ if (fileMatch) { const [, localPath, alias] = fileMatch; const uriValue = getValueByPath(data, alias ?? localPath); - if (isFileUri(uriValue) && evaultClient) { - try { - const dereferenced = await dereferenceFileUri( - uriValue, - evaultClient, - ); - result[localKey] = dereferenced.publicUrl; - } catch (error) { - console.error( - `Failed to dereference file URI for "${localKey}":`, - error, - ); - result[localKey] = uriValue; - } + if (Array.isArray(uriValue)) { + result[localKey] = await Promise.all( + uriValue.map((v) => + dereferenceFileValue(v, evaultClient, localKey), + ), + ); } else { - // Not a w3ds URI, or no client — pass the value through. - result[localKey] = uriValue; + result[localKey] = await dereferenceFileValue( + uriValue, + evaultClient, + localKey, + ); } continue; } @@ -295,11 +313,19 @@ export async function toGlobal({ const fileTargetKey = alias ?? localPath; const rawVal = getValueByPath(data, localPath); if (evaultClient && ownerEvault) { - result[fileTargetKey] = await referenceFileValue( - rawVal, - ownerEvault, - evaultClient, - ); + if (Array.isArray(rawVal)) { + result[fileTargetKey] = await Promise.all( + rawVal.map((v) => + referenceFileValue(v, ownerEvault, evaultClient), + ), + ); + } else { + result[fileTargetKey] = await referenceFileValue( + rawVal, + ownerEvault, + evaultClient, + ); + } } else { // No client/owner available — pass the value through unchanged. result[fileTargetKey] = rawVal; From e35fd72853be9c2446bf9c74bccc9a260a235679 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 21:30:00 +0530 Subject: [PATCH 08/12] apply __file directive to file fields across all platform mappings --- .../blabsy/api/src/web3adapter/mappings/message.mapping.json | 2 +- .../src/web3adapter/mappings/social-media-post.mapping.json | 2 +- .../blabsy/api/src/web3adapter/mappings/user.mapping.json | 4 ++-- .../client/src/web3adapter/mappings/user.mapping.json | 4 ++-- .../dreamsync/api/src/web3adapter/mappings/group.mapping.json | 4 ++-- .../dreamsync/api/src/web3adapter/mappings/user.mapping.json | 4 ++-- .../ecurrency/api/src/web3adapter/mappings/group.mapping.json | 4 ++-- .../ecurrency/api/src/web3adapter/mappings/user.mapping.json | 4 ++-- .../api/src/web3adapter/mappings/group.mapping.json | 4 ++-- .../api/src/web3adapter/mappings/user.mapping.json | 4 ++-- .../esigner/api/src/web3adapter/mappings/group.mapping.json | 4 ++-- .../esigner/api/src/web3adapter/mappings/user.mapping.json | 4 ++-- .../evoting/api/src/web3adapter/mappings/group.mapping.json | 4 ++-- .../evoting/api/src/web3adapter/mappings/user.mapping.json | 4 ++-- .../api/src/web3adapter/mappings/group.mapping.json | 4 ++-- .../api/src/web3adapter/mappings/user.mapping.json | 4 ++-- .../api/src/web3adapter/mappings/user.mapping.json | 4 ++-- .../api/src/web3adapter/mappings/comment.mapping.json | 2 +- .../pictique/api/src/web3adapter/mappings/post.mapping.json | 2 +- .../pictique/api/src/web3adapter/mappings/user.mapping.json | 4 ++-- 20 files changed, 36 insertions(+), 36 deletions(-) diff --git a/platforms/blabsy/api/src/web3adapter/mappings/message.mapping.json b/platforms/blabsy/api/src/web3adapter/mappings/message.mapping.json index 55d41d5d8..86b656c90 100644 --- a/platforms/blabsy/api/src/web3adapter/mappings/message.mapping.json +++ b/platforms/blabsy/api/src/web3adapter/mappings/message.mapping.json @@ -7,7 +7,7 @@ "senderId": "user(senderId),senderId", "text": "content", "type": "type", - "mediaUrl": "mediaUrl", + "mediaUrl": "__file(mediaUrl)", "createdAt": "__date(calc(createdAt._seconds * 1000)),createdAt", "updatedAt": "__date(calc(updatedAt._seconds * 1000)),updatedAt", "isArchived": "isArchived" diff --git a/platforms/blabsy/api/src/web3adapter/mappings/social-media-post.mapping.json b/platforms/blabsy/api/src/web3adapter/mappings/social-media-post.mapping.json index 4b0b9fe01..5e313020d 100644 --- a/platforms/blabsy/api/src/web3adapter/mappings/social-media-post.mapping.json +++ b/platforms/blabsy/api/src/web3adapter/mappings/social-media-post.mapping.json @@ -5,7 +5,7 @@ "localToUniversalMap": { "text": "content", "createdBy": "user(createdBy),authorId", - "images": "images[].src,mediaUrls", + "images": "__file(images[].src),mediaUrls", "parent": "tweet(parent.id),parentPostId", "userLikes": "user(userLikes)[],likedBy", "createdAt": "__date(calc(createdAt._seconds * 1000)),createdAt", diff --git a/platforms/blabsy/api/src/web3adapter/mappings/user.mapping.json b/platforms/blabsy/api/src/web3adapter/mappings/user.mapping.json index edbe0f60d..e34a97e4a 100644 --- a/platforms/blabsy/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/blabsy/api/src/web3adapter/mappings/user.mapping.json @@ -6,9 +6,9 @@ "bio": "bio", "username": "username", "name": "displayName", - "photoURL": "avatarUrl", + "photoURL": "__file(photoURL),avatarUrl", "ename": "ename", - "coverPhotoURL": "bannerUrl", + "coverPhotoURL": "__file(coverPhotoURL),bannerUrl", "website": "website", "location": "location", "verified": "isVerified", diff --git a/platforms/cerberus/client/src/web3adapter/mappings/user.mapping.json b/platforms/cerberus/client/src/web3adapter/mappings/user.mapping.json index ed570bc40..005b6f631 100644 --- a/platforms/cerberus/client/src/web3adapter/mappings/user.mapping.json +++ b/platforms/cerberus/client/src/web3adapter/mappings/user.mapping.json @@ -7,8 +7,8 @@ "handle": "username", "name": "displayName", "description": "bio", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "ename": "ename", "isVerified": "isVerified", "isPrivate": "isPrivate", diff --git a/platforms/dreamsync/api/src/web3adapter/mappings/group.mapping.json b/platforms/dreamsync/api/src/web3adapter/mappings/group.mapping.json index 4790736e5..fb5088e54 100644 --- a/platforms/dreamsync/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/dreamsync/api/src/web3adapter/mappings/group.mapping.json @@ -15,8 +15,8 @@ "originalMatchParticipants": "originalMatchParticipants", "isPrivate": "isPrivate", "visibility": "visibility", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "createdAt": "createdAt", "updatedAt": "updatedAt" }, diff --git a/platforms/dreamsync/api/src/web3adapter/mappings/user.mapping.json b/platforms/dreamsync/api/src/web3adapter/mappings/user.mapping.json index ee718b6a9..e17952293 100644 --- a/platforms/dreamsync/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/dreamsync/api/src/web3adapter/mappings/user.mapping.json @@ -7,8 +7,8 @@ "handle": "username", "name": "displayName", "description": "bio", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "ename": "ename", "isVerified": "isVerified", "isPrivate": "isPrivate", diff --git a/platforms/ecurrency/api/src/web3adapter/mappings/group.mapping.json b/platforms/ecurrency/api/src/web3adapter/mappings/group.mapping.json index 691cddbc1..cb9049307 100644 --- a/platforms/ecurrency/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/ecurrency/api/src/web3adapter/mappings/group.mapping.json @@ -15,8 +15,8 @@ "originalMatchParticipants": "originalMatchParticipants", "isPrivate": "isPrivate", "visibility": "visibility", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "createdAt": "createdAt", "updatedAt": "updatedAt" }, diff --git a/platforms/ecurrency/api/src/web3adapter/mappings/user.mapping.json b/platforms/ecurrency/api/src/web3adapter/mappings/user.mapping.json index 6daef2d77..61734be38 100644 --- a/platforms/ecurrency/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/ecurrency/api/src/web3adapter/mappings/user.mapping.json @@ -7,8 +7,8 @@ "handle": "username", "name": "displayName", "description": "bio", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "ename": "ename", "isVerified": "isVerified", "isPrivate": "isPrivate", diff --git a/platforms/ereputation/api/src/web3adapter/mappings/group.mapping.json b/platforms/ereputation/api/src/web3adapter/mappings/group.mapping.json index 4790736e5..fb5088e54 100644 --- a/platforms/ereputation/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/ereputation/api/src/web3adapter/mappings/group.mapping.json @@ -15,8 +15,8 @@ "originalMatchParticipants": "originalMatchParticipants", "isPrivate": "isPrivate", "visibility": "visibility", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "createdAt": "createdAt", "updatedAt": "updatedAt" }, diff --git a/platforms/ereputation/api/src/web3adapter/mappings/user.mapping.json b/platforms/ereputation/api/src/web3adapter/mappings/user.mapping.json index ee718b6a9..e17952293 100644 --- a/platforms/ereputation/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/ereputation/api/src/web3adapter/mappings/user.mapping.json @@ -7,8 +7,8 @@ "handle": "username", "name": "displayName", "description": "bio", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "ename": "ename", "isVerified": "isVerified", "isPrivate": "isPrivate", diff --git a/platforms/esigner/api/src/web3adapter/mappings/group.mapping.json b/platforms/esigner/api/src/web3adapter/mappings/group.mapping.json index d786648b7..92cf4a1af 100644 --- a/platforms/esigner/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/esigner/api/src/web3adapter/mappings/group.mapping.json @@ -17,8 +17,8 @@ "originalMatchParticipants": "originalMatchParticipants", "isPrivate": "isPrivate", "visibility": "visibility", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "createdAt": "createdAt", "updatedAt": "updatedAt" }, diff --git a/platforms/esigner/api/src/web3adapter/mappings/user.mapping.json b/platforms/esigner/api/src/web3adapter/mappings/user.mapping.json index 91156bb47..7ffa3183d 100644 --- a/platforms/esigner/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/esigner/api/src/web3adapter/mappings/user.mapping.json @@ -8,8 +8,8 @@ "handle": "username", "name": "name", "description": "bio", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "ename": "ename", "isVerified": "isVerified", "isPrivate": "isPrivate", diff --git a/platforms/evoting/api/src/web3adapter/mappings/group.mapping.json b/platforms/evoting/api/src/web3adapter/mappings/group.mapping.json index 7047f0bd4..f8b60e204 100644 --- a/platforms/evoting/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/evoting/api/src/web3adapter/mappings/group.mapping.json @@ -14,8 +14,8 @@ "members": "users(members[].id),memberIds", "isPrivate": "isPrivate", "visibility": "visibility", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "createdAt": "createdAt", "updatedAt": "updatedAt" }, diff --git a/platforms/evoting/api/src/web3adapter/mappings/user.mapping.json b/platforms/evoting/api/src/web3adapter/mappings/user.mapping.json index ee718b6a9..e17952293 100644 --- a/platforms/evoting/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/evoting/api/src/web3adapter/mappings/user.mapping.json @@ -7,8 +7,8 @@ "handle": "username", "name": "displayName", "description": "bio", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "ename": "ename", "isVerified": "isVerified", "isPrivate": "isPrivate", diff --git a/platforms/file-manager/api/src/web3adapter/mappings/group.mapping.json b/platforms/file-manager/api/src/web3adapter/mappings/group.mapping.json index 1d36fb5d0..10221ecae 100644 --- a/platforms/file-manager/api/src/web3adapter/mappings/group.mapping.json +++ b/platforms/file-manager/api/src/web3adapter/mappings/group.mapping.json @@ -17,8 +17,8 @@ "originalMatchParticipants": "originalMatchParticipants", "isPrivate": "isPrivate", "visibility": "visibility", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "createdAt": "createdAt", "updatedAt": "updatedAt" }, diff --git a/platforms/file-manager/api/src/web3adapter/mappings/user.mapping.json b/platforms/file-manager/api/src/web3adapter/mappings/user.mapping.json index ff269b929..e3f90d56f 100644 --- a/platforms/file-manager/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/file-manager/api/src/web3adapter/mappings/user.mapping.json @@ -8,8 +8,8 @@ "handle": "username", "name": "name", "description": "bio", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "ename": "ename", "isVerified": "isVerified", "isPrivate": "isPrivate", diff --git a/platforms/group-charter-manager/api/src/web3adapter/mappings/user.mapping.json b/platforms/group-charter-manager/api/src/web3adapter/mappings/user.mapping.json index ee718b6a9..e17952293 100644 --- a/platforms/group-charter-manager/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/group-charter-manager/api/src/web3adapter/mappings/user.mapping.json @@ -7,8 +7,8 @@ "handle": "username", "name": "displayName", "description": "bio", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "ename": "ename", "isVerified": "isVerified", "isPrivate": "isPrivate", diff --git a/platforms/pictique/api/src/web3adapter/mappings/comment.mapping.json b/platforms/pictique/api/src/web3adapter/mappings/comment.mapping.json index 822e8a5e4..67c6badd8 100644 --- a/platforms/pictique/api/src/web3adapter/mappings/comment.mapping.json +++ b/platforms/pictique/api/src/web3adapter/mappings/comment.mapping.json @@ -7,7 +7,7 @@ ], "localToUniversalMap": { "text": "content", - "images": "mediaUrls", + "images": "__file(images),mediaUrls", "hashtags": "tags", "createdAt": "createdAt", "parentPostId": "posts(post.id),parentPostId", diff --git a/platforms/pictique/api/src/web3adapter/mappings/post.mapping.json b/platforms/pictique/api/src/web3adapter/mappings/post.mapping.json index 4d09b2220..a0d208c0e 100644 --- a/platforms/pictique/api/src/web3adapter/mappings/post.mapping.json +++ b/platforms/pictique/api/src/web3adapter/mappings/post.mapping.json @@ -7,7 +7,7 @@ ], "localToUniversalMap": { "text": "content", - "images": "mediaUrls", + "images": "__file(images),mediaUrls", "hashtags": "tags", "createdAt": "createdAt", "parentPostId": "posts(parentPostId),parentPostId", diff --git a/platforms/pictique/api/src/web3adapter/mappings/user.mapping.json b/platforms/pictique/api/src/web3adapter/mappings/user.mapping.json index f896f59c4..b34d904a4 100644 --- a/platforms/pictique/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/pictique/api/src/web3adapter/mappings/user.mapping.json @@ -10,8 +10,8 @@ "handle": "username", "name": "displayName", "description": "bio", - "avatarUrl": "avatarUrl", - "bannerUrl": "bannerUrl", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", "ename": "ename", "isVerified": "isVerified", "isPrivate": "isPrivate", From 2007c68cfe375903ca4fe042c5edb76296ef2c5e Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 21:34:00 +0530 Subject: [PATCH 09/12] document array support for __file mapping directive --- infrastructure/web3-adapter/MAPPING_RULES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/infrastructure/web3-adapter/MAPPING_RULES.md b/infrastructure/web3-adapter/MAPPING_RULES.md index 40fa096b8..cb6df3c82 100644 --- a/infrastructure/web3-adapter/MAPPING_RULES.md +++ b/infrastructure/web3-adapter/MAPPING_RULES.md @@ -101,6 +101,8 @@ to the file's public URL. - The inner path (`avatar`) points to the field holding the file value. - An optional `,alias` sets the global field name (defaults to the inner path). +- The value may be a single file or an **array** of files. Array paths such as + `__file(images[].src)` are supported and each item is referenced/dereferenced. - **`toGlobal`**: a `data:` URI value is uploaded and replaced with a `w3ds://file?id=@/` URI. Values that are already `w3ds://file` URIs, plain URLs, or empty are passed through unchanged. From b286aa3d1ec250c31f06478f7ec53692cd62b2f3 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 18 May 2026 22:05:00 +0530 Subject: [PATCH 10/12] fix biome formatting in web3-adapter file mapping code --- .../src/mapper/file-directive.test.ts | 3 +- .../web3-adapter/src/mapper/mapper.ts | 8 ++--- .../web3-adapter/src/w3ds/uri.test.ts | 36 +++++++++---------- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/infrastructure/web3-adapter/src/mapper/file-directive.test.ts b/infrastructure/web3-adapter/src/mapper/file-directive.test.ts index 02cfe5adf..9fd43134b 100644 --- a/infrastructure/web3-adapter/src/mapper/file-directive.test.ts +++ b/infrastructure/web3-adapter/src/mapper/file-directive.test.ts @@ -15,7 +15,8 @@ function mockClient(): EVaultClient { uploadFile: vi.fn(async (_w3id: string, input: { filename: string }) => ({ uri: "w3ds://file?id=@owner/env-123", metaEnvelopeId: "env-123", - publicUrl: "https://cdn.example.com/files/owner/env-123-" + input.filename, + publicUrl: + "https://cdn.example.com/files/owner/env-123-" + input.filename, })), fetchMetaEnvelope: vi.fn(async (id: string) => ({ id, diff --git a/infrastructure/web3-adapter/src/mapper/mapper.ts b/infrastructure/web3-adapter/src/mapper/mapper.ts index e6ef5601b..e69b10b84 100644 --- a/infrastructure/web3-adapter/src/mapper/mapper.ts +++ b/infrastructure/web3-adapter/src/mapper/mapper.ts @@ -174,9 +174,7 @@ export async function fromGlobal({ const uriValue = getValueByPath(data, alias ?? localPath); if (Array.isArray(uriValue)) { result[localKey] = await Promise.all( - uriValue.map((v) => - dereferenceFileValue(v, evaultClient, localKey), - ), + uriValue.map((v) => dereferenceFileValue(v, evaultClient, localKey)), ); } else { result[localKey] = await dereferenceFileValue( @@ -315,9 +313,7 @@ export async function toGlobal({ if (evaultClient && ownerEvault) { if (Array.isArray(rawVal)) { result[fileTargetKey] = await Promise.all( - rawVal.map((v) => - referenceFileValue(v, ownerEvault, evaultClient), - ), + rawVal.map((v) => referenceFileValue(v, ownerEvault, evaultClient)), ); } else { result[fileTargetKey] = await referenceFileValue( diff --git a/infrastructure/web3-adapter/src/w3ds/uri.test.ts b/infrastructure/web3-adapter/src/w3ds/uri.test.ts index 0e1ff111c..24c740630 100644 --- a/infrastructure/web3-adapter/src/w3ds/uri.test.ts +++ b/infrastructure/web3-adapter/src/w3ds/uri.test.ts @@ -9,21 +9,21 @@ import { describe("w3ds file URI", () => { describe("buildFileUri", () => { it("builds a canonical URI", () => { - expect( - buildFileUri({ ename: "@alice", metaEnvelopeId: "abc123" }), - ).toBe("w3ds://file?id=@alice/abc123"); + expect(buildFileUri({ ename: "@alice", metaEnvelopeId: "abc123" })).toBe( + "w3ds://file?id=@alice/abc123", + ); }); it("normalises a missing @ prefix on the ename", () => { - expect( - buildFileUri({ ename: "alice", metaEnvelopeId: "abc123" }), - ).toBe("w3ds://file?id=@alice/abc123"); + expect(buildFileUri({ ename: "alice", metaEnvelopeId: "abc123" })).toBe( + "w3ds://file?id=@alice/abc123", + ); }); it("throws when ename or metaEnvelopeId is missing", () => { - expect(() => - buildFileUri({ ename: "", metaEnvelopeId: "abc" }), - ).toThrow(InvalidW3dsUriError); + expect(() => buildFileUri({ ename: "", metaEnvelopeId: "abc" })).toThrow( + InvalidW3dsUriError, + ); expect(() => buildFileUri({ ename: "@alice", metaEnvelopeId: "" }), ).toThrow(InvalidW3dsUriError); @@ -47,15 +47,15 @@ describe("w3ds file URI", () => { }); it("rejects a non-w3ds scheme", () => { - expect(() => - parseFileUri("https://file?id=@alice/abc"), - ).toThrow(/expected scheme/); + expect(() => parseFileUri("https://file?id=@alice/abc")).toThrow( + /expected scheme/, + ); }); it("rejects a wrong host", () => { - expect(() => - parseFileUri("w3ds://blob?id=@alice/abc"), - ).toThrow(/expected host/); + expect(() => parseFileUri("w3ds://blob?id=@alice/abc")).toThrow( + /expected host/, + ); }); it("rejects a missing id parameter", () => { @@ -65,9 +65,9 @@ describe("w3ds file URI", () => { }); it("rejects an id without an ename", () => { - expect(() => - parseFileUri("w3ds://file?id=alice/abc"), - ).toThrow(/must be in the form/); + expect(() => parseFileUri("w3ds://file?id=alice/abc")).toThrow( + /must be in the form/, + ); }); it("rejects an id without a meta-envelope-id segment", () => { From 192c8ef81d33c1c49bcfa633dc1115d82b9021e2 Mon Sep 17 00:00:00 2001 From: coodos Date: Tue, 19 May 2026 11:20:00 +0530 Subject: [PATCH 11/12] harden uploadFile: validate base64, redirect scheme and clean up orphaned blobs --- .../evault-core/src/core/http/server.ts | 19 +++++++++++ .../src/core/protocol/graphql-server.ts | 33 +++++++++++++++++-- .../evault-core/src/core/utils/w3ds-uri.ts | 19 +++++++++++ .../src/services/StorageService.ts | 16 ++++++++- 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/infrastructure/evault-core/src/core/http/server.ts b/infrastructure/evault-core/src/core/http/server.ts index 0d6b862cb..6d2d237cb 100644 --- a/infrastructure/evault-core/src/core/http/server.ts +++ b/infrastructure/evault-core/src/core/http/server.ts @@ -439,6 +439,25 @@ export async function registerHttpRoutes( }); } + // Only ever redirect to http(s) — guard against a stored URL + // with an unsafe scheme (javascript:, data:, file:, …). + let parsedUrl: URL; + try { + parsedUrl = new URL(publicUrl); + } catch { + return reply + .status(404) + .send({ error: "File meta-envelope has an invalid public URL" }); + } + if ( + parsedUrl.protocol !== "http:" && + parsedUrl.protocol !== "https:" + ) { + return reply.status(400).send({ + error: "File public URL uses an unsupported scheme", + }); + } + return reply.redirect(publicUrl); } catch (error) { console.error("Error dereferencing file:", error); diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index 843cea821..e51fa6c24 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -1192,9 +1192,16 @@ export class GraphQLServer { input.content.indexOf(",") + 1, ) : input.content; - const buffer = Buffer.from(base64, "base64"); - if (buffer.length === 0) { + // Strictly validate base64 before decoding — Buffer.from + // silently drops invalid characters, so malformed input + // must be rejected up-front. Padding ('=') is allowed + // only as the last 1-2 characters. + const isValidBase64 = + base64.length > 0 && + base64.length % 4 === 0 && + /^[A-Za-z0-9+/]+={0,2}$/.test(base64); + if (!isValidBase64) { return { errors: [ { @@ -1206,6 +1213,8 @@ export class GraphQLServer { }; } + const buffer = Buffer.from(base64, "base64"); + const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 MB if (buffer.length > MAX_FILE_BYTES) { return { @@ -1219,6 +1228,10 @@ export class GraphQLServer { }; } + // Track the uploaded object so a failed DB write can be + // compensated by deleting the now-orphaned blob. + let uploadedKey: string | null = null; + let storage: StorageService | null = null; try { const objectId = require("uuid").v4(); const key = StorageService.buildKey( @@ -1226,12 +1239,13 @@ export class GraphQLServer { input.filename, objectId, ); - const storage = new StorageService(); + storage = new StorageService(); const publicUrl = await storage.uploadObject({ buffer, contentType: input.contentType, key, }); + uploadedKey = key; const payload = { filename: input.filename, @@ -1262,6 +1276,19 @@ export class GraphQLServer { }; } catch (error) { console.error("uploadFile failed:", error); + // Compensating cleanup: if the blob was uploaded but + // a later step (DB write) failed, delete the now + // orphaned object so storage does not leak. + if (uploadedKey && storage) { + try { + await storage.deleteObject(uploadedKey); + } catch (cleanupError) { + console.error( + "uploadFile cleanup (delete orphaned object) failed:", + cleanupError, + ); + } + } return { errors: [ { diff --git a/infrastructure/evault-core/src/core/utils/w3ds-uri.ts b/infrastructure/evault-core/src/core/utils/w3ds-uri.ts index fd3d5d90a..0c0582a0a 100644 --- a/infrastructure/evault-core/src/core/utils/w3ds-uri.ts +++ b/infrastructure/evault-core/src/core/utils/w3ds-uri.ts @@ -11,6 +11,25 @@ export const FILE_SCHEMA_ID = "w3ds-file-v1"; * => "w3ds://file?id=@alice/abc123" */ export function buildFileUri(eName: string, metaEnvelopeId: string): string { + if (!eName || !eName.trim()) { + throw new Error("buildFileUri: eName is required"); + } + if (!metaEnvelopeId || !metaEnvelopeId.trim()) { + throw new Error("buildFileUri: metaEnvelopeId is required"); + } + const ename = eName.startsWith("@") ? eName : `@${eName}`; + + // The `id` is parsed as `@/`; characters that + // would break that structure or the surrounding URI must be rejected. + if (/[\s/?#&]/.test(ename.slice(1))) { + throw new Error(`buildFileUri: eName "${eName}" contains illegal characters`); + } + if (/[\s?#&]/.test(metaEnvelopeId)) { + throw new Error( + `buildFileUri: metaEnvelopeId "${metaEnvelopeId}" contains illegal characters`, + ); + } + return `w3ds://file?id=${ename}/${metaEnvelopeId}`; } diff --git a/infrastructure/evault-core/src/services/StorageService.ts b/infrastructure/evault-core/src/services/StorageService.ts index ba3ec9983..5fe4759c4 100644 --- a/infrastructure/evault-core/src/services/StorageService.ts +++ b/infrastructure/evault-core/src/services/StorageService.ts @@ -1,4 +1,8 @@ -import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { + DeleteObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; /** * Thrown when the DigitalOcean Spaces environment is not configured. @@ -98,4 +102,14 @@ export class StorageService { return `${this.cdnBaseUrl}/${key}`; } + + /** + * Deletes an object by key. Used to compensate for a failed upload flow + * (e.g. the blob uploaded but the meta-envelope write failed). + */ + async deleteObject(key: string): Promise { + await this.client.send( + new DeleteObjectCommand({ Bucket: this.bucket, Key: key }), + ); + } } From 597dfc8f2fdf95dc95f5082ab3bc609da643de8c Mon Sep 17 00:00:00 2001 From: coodos Date: Tue, 19 May 2026 12:05:00 +0530 Subject: [PATCH 12/12] fix biome lint and clarify w3ds documentation examples --- infrastructure/web3-adapter/MAPPING_RULES.md | 7 +++++++ infrastructure/web3-adapter/W3DS_URI.md | 6 +++--- .../web3-adapter/src/mapper/file-directive.test.ts | 7 +++---- infrastructure/web3-adapter/src/w3ds/resolver.ts | 2 +- infrastructure/web3-adapter/src/w3ds/uri.test.ts | 2 +- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/infrastructure/web3-adapter/MAPPING_RULES.md b/infrastructure/web3-adapter/MAPPING_RULES.md index cb6df3c82..977d60d2e 100644 --- a/infrastructure/web3-adapter/MAPPING_RULES.md +++ b/infrastructure/web3-adapter/MAPPING_RULES.md @@ -94,8 +94,15 @@ Uploads inline file payloads to the owner eVault's object storage and replaces them with a stable `w3ds://file` URI. On the way back, the URI is dereferenced to the file's public URL. +Same global field name as the local field: + ```json "avatar": "__file(avatar)" +``` + +Different global field name (via a `,alias` suffix): + +```json "avatar": "__file(avatar),avatarUri" ``` diff --git a/infrastructure/web3-adapter/W3DS_URI.md b/infrastructure/web3-adapter/W3DS_URI.md index f7319a052..afecda530 100644 --- a/infrastructure/web3-adapter/W3DS_URI.md +++ b/infrastructure/web3-adapter/W3DS_URI.md @@ -5,7 +5,7 @@ files within the MetaState ecosystem. ## Format -``` +```text w3ds://file?id=@/ ``` @@ -19,7 +19,7 @@ w3ds://file?id=@/ Example: -``` +```text w3ds://file?id=@alice/envelope-abc123 ``` @@ -52,7 +52,7 @@ There are two dereferencers: ### HTTP — eVault core -``` +```http GET /files/:metaEnvelopeId (header: X-ENAME: @) ``` diff --git a/infrastructure/web3-adapter/src/mapper/file-directive.test.ts b/infrastructure/web3-adapter/src/mapper/file-directive.test.ts index 9fd43134b..22bc62330 100644 --- a/infrastructure/web3-adapter/src/mapper/file-directive.test.ts +++ b/infrastructure/web3-adapter/src/mapper/file-directive.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import type { EVaultClient } from "../evault/evault"; import type { MappingDatabase } from "../db"; -import type { IMapping } from "./mapper.types"; +import type { EVaultClient } from "../evault/evault"; import { fromGlobal, toGlobal } from "./mapper"; +import type { IMapping } from "./mapper.types"; // Minimal stubs — the `__file()` directive paths never touch the mapping store. const mappingStore = {} as MappingDatabase; @@ -15,8 +15,7 @@ function mockClient(): EVaultClient { uploadFile: vi.fn(async (_w3id: string, input: { filename: string }) => ({ uri: "w3ds://file?id=@owner/env-123", metaEnvelopeId: "env-123", - publicUrl: - "https://cdn.example.com/files/owner/env-123-" + input.filename, + publicUrl: `https://cdn.example.com/files/owner/env-123-${input.filename}`, })), fetchMetaEnvelope: vi.fn(async (id: string) => ({ id, diff --git a/infrastructure/web3-adapter/src/w3ds/resolver.ts b/infrastructure/web3-adapter/src/w3ds/resolver.ts index 243e10296..be1cd99ec 100644 --- a/infrastructure/web3-adapter/src/w3ds/resolver.ts +++ b/infrastructure/web3-adapter/src/w3ds/resolver.ts @@ -6,7 +6,7 @@ * file's public object-storage URL plus its descriptive metadata. */ import type { EVaultClient, UploadFileInput } from "../evault/evault"; -import { parseFileUri, W3DS_FILE_HOST, W3DS_SCHEME } from "./uri"; +import { W3DS_FILE_HOST, W3DS_SCHEME, parseFileUri } from "./uri"; /** Minimal MIME → file extension map for naming uploaded files. */ const MIME_EXTENSIONS: Record = { diff --git a/infrastructure/web3-adapter/src/w3ds/uri.test.ts b/infrastructure/web3-adapter/src/w3ds/uri.test.ts index 24c740630..cb518e410 100644 --- a/infrastructure/web3-adapter/src/w3ds/uri.test.ts +++ b/infrastructure/web3-adapter/src/w3ds/uri.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { - buildFileUri, InvalidW3dsUriError, + buildFileUri, isFileUri, parseFileUri, } from "./uri";