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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions infrastructure/evault-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
116 changes: 116 additions & 0 deletions infrastructure/evault-core/src/core/http/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,6 +55,10 @@ export async function registerHttpRoutes(
name: "provisioning",
description: "eVault provisioning endpoints",
},
{
name: "files",
description: "File dereferencing endpoints",
},
],
},
});
Expand Down Expand Up @@ -355,6 +360,117 @@ 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<string, any>)
?.publicUrl;
if (!publicUrl || typeof publicUrl !== "string") {
return reply.status(404).send({
error: "File meta-envelope has no public URL",
});
}

// 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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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",
Expand Down
158 changes: 158 additions & 0 deletions infrastructure/evault-core/src/core/protocol/graphql-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1145,6 +1147,162 @@ 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;

// 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: [
{
field: "content",
message: "File content is empty or not valid base64",
code: "INVALID_CONTENT",
},
],
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const buffer = Buffer.from(base64, "base64");

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",
},
],
};
}

// 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(
context.eName,
input.filename,
objectId,
);
storage = new StorageService();
const publicUrl = await storage.uploadObject({
buffer,
contentType: input.contentType,
key,
});
uploadedKey = 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);
// 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: [
{
message:
error instanceof Error
? error.message
: "Failed to upload file",
code: "UPLOAD_FAILED",
},
],
};
}
},
),
updateMetaEnvelopeById: this.accessGuard.middleware(
async (
_: any,
Expand Down
30 changes: 30 additions & 0 deletions infrastructure/evault-core/src/core/protocol/typedefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================================================
Expand Down Expand Up @@ -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!
Expand Down
Loading
Loading