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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -54,6 +55,10 @@ export async function registerHttpRoutes(
name: "provisioning",
description: "eVault provisioning endpoints",
},
{
name: "files",
description: "File dereferencing endpoints",
},
],
},
});
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -54,6 +55,10 @@ export async function registerHttpRoutes(
name: "provisioning",
description: "eVault provisioning endpoints",
},
{
name: "files",
description: "File dereferencing endpoints",
},
],
},
});
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -54,6 +55,10 @@ export async function registerHttpRoutes(
name: "provisioning",
description: "eVault provisioning endpoints",
},
{
name: "files",
description: "File dereferencing endpoints",
},
],
},
});
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -54,6 +55,10 @@ export async function registerHttpRoutes(
name: "provisioning",
description: "eVault provisioning endpoints",
},
{
name: "files",
description: "File dereferencing endpoints",
},
],
},
});
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -54,6 +55,10 @@ export async function registerHttpRoutes(
name: "provisioning",
description: "eVault provisioning endpoints",
},
{
name: "files",
description: "File dereferencing endpoints",
},
],
},
});
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -54,6 +55,10 @@ export async function registerHttpRoutes(
name: "provisioning",
description: "eVault provisioning endpoints",
},
{
name: "files",
description: "File dereferencing endpoints",
},
],
},
});
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -54,6 +55,10 @@ export async function registerHttpRoutes(
name: "provisioning",
description: "eVault provisioning endpoints",
},
{
name: "files",
description: "File dereferencing endpoints",
},
],
},
});
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -54,6 +55,10 @@ export async function registerHttpRoutes(
name: "provisioning",
description: "eVault provisioning endpoints",
},
{
name: "files",
description: "File dereferencing endpoints",
},
],
},
});
Expand DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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