diff --git a/.gitleaksignore b/.gitleaksignore
new file mode 100644
index 0000000..2fd40cb
--- /dev/null
+++ b/.gitleaksignore
@@ -0,0 +1,5 @@
+# Public DID keys (not secrets)
+did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK
+
+# Environment variable references (not actual secrets)
+INFISICAL_CLIENT_SECRET
diff --git a/poc/collab-server/Dockerfile b/poc/collab-server/Dockerfile
index 77fceac..b6e8769 100644
--- a/poc/collab-server/Dockerfile
+++ b/poc/collab-server/Dockerfile
@@ -1,17 +1,18 @@
# Build from Fileverse collaboration-server source
# https://github.com/fileverse/collaboration-server
+# Uses tsup for ESM build, requires Node 23.x
-FROM node:20-slim AS builder
+FROM node:23-slim AS builder
WORKDIR /app
# Clone and build collaboration-server
-RUN apt-get update && apt-get install -y git && \
+RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* && \
git clone --depth 1 https://github.com/fileverse/collaboration-server.git . && \
npm ci && \
npm run build
-FROM node:20-slim
+FROM node:23-slim
WORKDIR /app
@@ -19,6 +20,11 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
-EXPOSE 5000
+# Default port from config
+EXPOSE 5001
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD node -e "fetch('http://localhost:5001/health').then(r => process.exit(r.ok ? 0 : 1))"
CMD ["node", "dist/index.js"]
diff --git a/poc/collab-server/docker-compose.yml b/poc/collab-server/docker-compose.yml
index 94abc2b..f305051 100644
--- a/poc/collab-server/docker-compose.yml
+++ b/poc/collab-server/docker-compose.yml
@@ -1,8 +1,9 @@
-# Fileverse Collaboration Server — Self-hosted on Netcup
-# Y.js WebSocket relay for real-time document collaboration
+# Fileverse Stack — Self-hosted on Netcup
+# Collab server (Y.js WebSocket relay) + kubo IPFS node + MongoDB
#
-# Deploy: scp to Netcup, docker compose up -d
-# Requires: Traefik network, DNS for collab.jeffemmett.com
+# Deploy: scp to Netcup /opt/apps/collab-server/, docker compose up -d
+# Requires: Traefik proxy network
+# DNS: collab.jeffemmett.com, ipfs.jeffemmett.com, ipfs-api.jeffemmett.com
services:
collab-server:
@@ -11,31 +12,31 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
environment:
- PORT: 5000
+ PORT: 5001
HOST: 0.0.0.0
NODE_ENV: production
- MONGODB_URI: mongodb://collab-mongo:27017/collab
- REDIS_URL: redis://collab-redis:6379
- CORS_ORIGINS: "https://rnotes.jeffemmett.com,https://rspace.jeffemmett.com,http://localhost:3000"
- # SERVER_DID and other secrets via Infisical
- INFISICAL_CLIENT_ID: ${INFISICAL_CLIENT_ID}
- INFISICAL_CLIENT_SECRET: ${INFISICAL_CLIENT_SECRET}
+ MONGODB_URI: mongodb://collab-mongo:27017/collaboration
+ REDIS_ENABLED: "false"
+ CORS_ORIGINS: "https://rnotes.jeffemmett.com,https://rspace.jeffemmett.com,http://localhost:3000,http://localhost:5173"
+ SERVER_DID: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
+ RATE_LIMIT_WINDOW_MS: 900000
+ RATE_LIMIT_MAX: 100
networks:
- - proxy
+ - traefik-public
- collab-internal
labels:
- "traefik.enable=true"
- # HTTP router
- "traefik.http.routers.collab.rule=Host(`collab.jeffemmett.com`)"
- - "traefik.http.routers.collab.entrypoints=websecure"
- - "traefik.http.routers.collab.tls.certresolver=letsencrypt"
- - "traefik.http.services.collab.loadbalancer.server.port=5000"
- # WebSocket support
- - "traefik.http.middlewares.collab-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
- - "traefik.http.routers.collab.middlewares=collab-headers"
+ - "traefik.http.routers.collab.entrypoints=web"
+ - "traefik.http.services.collab.loadbalancer.server.port=5001"
depends_on:
- - collab-mongo
- - collab-redis
+ collab-mongo:
+ condition: service_started
+ healthcheck:
+ test: ["CMD", "node", "-e", "fetch('http://localhost:5001/health').then(r => process.exit(r.ok ? 0 : 1))"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
collab-mongo:
image: mongo:7
@@ -45,21 +46,45 @@ services:
networks:
- collab-internal
- collab-redis:
- image: redis:7-alpine
+ # ─── Self-hosted IPFS (kubo) ───
+ ipfs:
+ image: ipfs/kubo:v0.32.1
restart: unless-stopped
- command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
+ environment:
+ - IPFS_PROFILE=server
volumes:
- - collab-redis-data:/data
+ - ipfs-data:/data/ipfs
+ - ./ipfs-init.sh:/container-init.d/01-config.sh:ro
networks:
+ - traefik-public
- collab-internal
+ labels:
+ - "traefik.enable=true"
+ # IPFS Gateway (public, read-only)
+ - "traefik.http.routers.ipfs-gw.rule=Host(`ipfs.jeffemmett.com`)"
+ - "traefik.http.routers.ipfs-gw.entrypoints=web"
+ - "traefik.http.routers.ipfs-gw.service=ipfs-gw"
+ - "traefik.http.services.ipfs-gw.loadbalancer.server.port=8080"
+ # IPFS API (private, Headscale-only access via IP allowlist)
+ - "traefik.http.routers.ipfs-api.rule=Host(`ipfs-api.jeffemmett.com`)"
+ - "traefik.http.routers.ipfs-api.entrypoints=web"
+ - "traefik.http.routers.ipfs-api.service=ipfs-api"
+ - "traefik.http.services.ipfs-api.loadbalancer.server.port=5001"
+ # Restrict API to Headscale mesh + Cloudflare tunnel IPs
+ - "traefik.http.middlewares.ipfs-api-ipallow.ipallowlist.sourcerange=100.64.0.0/10,127.0.0.1/32,172.16.0.0/12"
+ - "traefik.http.routers.ipfs-api.middlewares=ipfs-api-ipallow"
+ healthcheck:
+ test: ["CMD", "ipfs", "id"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
networks:
- proxy:
+ traefik-public:
external: true
collab-internal:
driver: bridge
volumes:
collab-mongo-data:
- collab-redis-data:
+ ipfs-data:
diff --git a/poc/collab-server/ipfs-init.sh b/poc/collab-server/ipfs-init.sh
new file mode 100644
index 0000000..82285a9
--- /dev/null
+++ b/poc/collab-server/ipfs-init.sh
@@ -0,0 +1,33 @@
+#!/bin/sh
+# Configure kubo IPFS node for rStack self-hosted deployment
+# Runs once on first start via /container-init.d/
+
+set -e
+
+# Allow API access from Docker network (collab-server needs it)
+ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["*"]'
+ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["PUT", "POST", "GET"]'
+
+# Listen on all interfaces (inside container)
+ipfs config Addresses.API /ip4/0.0.0.0/tcp/5001
+ipfs config Addresses.Gateway /ip4/0.0.0.0/tcp/8080
+
+# Gateway: subdomain mode for CID isolation
+ipfs config --json Gateway.PublicGateways '{
+ "ipfs.jeffemmett.com": {
+ "Paths": ["/ipfs", "/ipns"],
+ "UseSubdomains": false
+ }
+}'
+
+# Storage limits — keep it reasonable for Netcup
+ipfs config Datastore.StorageMax "50GB"
+
+# Enable GC
+ipfs config --json Datastore.GCPeriod '"24h"'
+
+# Reduce swarm connections (server mode, not a public gateway)
+ipfs config --json Swarm.ConnMgr.LowWater 50
+ipfs config --json Swarm.ConnMgr.HighWater 200
+
+echo "[ipfs-init] Configuration applied"
diff --git a/poc/crypto-eval/src/benchmark.ts b/poc/crypto-eval/src/benchmark.ts
index d9df2c1..ba7b817 100644
--- a/poc/crypto-eval/src/benchmark.ts
+++ b/poc/crypto-eval/src/benchmark.ts
@@ -13,6 +13,15 @@ import {
shareDocKey,
receiveDocKey,
} from './mit-crypto.js'
+function generateTestData(size: number): Uint8Array {
+ const data = new Uint8Array(size)
+ // getRandomValues has 64KB limit, fill in chunks
+ for (let offset = 0; offset < size; offset += 65536) {
+ const chunk = Math.min(65536, size - offset)
+ crypto.getRandomValues(data.subarray(offset, offset + chunk))
+ }
+ return data
+}
const encoder = new TextEncoder()
const decoder = new TextDecoder()
@@ -36,8 +45,7 @@ async function main() {
const key = generateSymmetricKey()
for (const size of [100, 1_000, 10_000, 100_000]) {
- const data = new Uint8Array(size)
- crypto.getRandomValues(data)
+ const data = generateTestData(size)
await benchmark(` Encrypt ${size.toLocaleString()}B`, async () => {
await aesEncrypt(key, data)
diff --git a/poc/dsheet-embed/index.html b/poc/dsheet-embed/index.html
new file mode 100644
index 0000000..cce0b82
--- /dev/null
+++ b/poc/dsheet-embed/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ rSheet POC — Fileverse dSheet Embed
+
+
+
+
+
+
+
diff --git a/poc/dsheet-embed/package.json b/poc/dsheet-embed/package.json
new file mode 100644
index 0000000..94e41a6
--- /dev/null
+++ b/poc/dsheet-embed/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@rstack/dsheet-embed-poc",
+ "version": "0.1.0",
+ "private": true,
+ "description": "dSheet embedded as rSheet module POC",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@fileverse-dev/dsheet": "latest",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/poc/dsheet-embed/src/RSheetApp.tsx b/poc/dsheet-embed/src/RSheetApp.tsx
new file mode 100644
index 0000000..b3fe3c9
--- /dev/null
+++ b/poc/dsheet-embed/src/RSheetApp.tsx
@@ -0,0 +1,96 @@
+/**
+ * rSheet POC — Fileverse dSheet embedded as rStack module
+ *
+ * This wraps @fileverse-dev/dsheet in a React component that demonstrates:
+ * - Standalone spreadsheet rendering
+ * - Collaborative editing via WebRTC
+ * - IndexedDB offline persistence
+ * - Integration points for EncryptID auth
+ *
+ * In production, this would be wrapped in a LitElement for rSpace module compatibility.
+ */
+
+import React, { useState, useCallback } from 'react'
+
+// Dynamic import — dSheet is a large package, lazy-load it
+const DSheet = React.lazy(async () => {
+ try {
+ const mod = await import('@fileverse-dev/dsheet')
+ // dSheet exports may vary — handle both default and named exports
+ return { default: (mod as any).DSheet ?? (mod as any).default ?? mod }
+ } catch (e) {
+ console.error('Failed to load dSheet:', e)
+ return { default: () =>
+ Failed to load @fileverse-dev/dsheet. Run: npm install @fileverse-dev/dsheet
+
}
+ }
+})
+
+export function RSheetApp() {
+ const [sheetId] = useState(() => `rsheet-${crypto.randomUUID().slice(0, 8)}`)
+ const [isCollaborative, setIsCollaborative] = useState(false)
+ const [lastChange, setLastChange] = useState('')
+
+ const handleChange = useCallback((data: unknown) => {
+ setLastChange(new Date().toLocaleTimeString())
+ // In production: save metadata to Automerge document
+ console.log('[rSheet] Data changed:', typeof data)
+ }, [])
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Spreadsheet */}
+
+
+ Loading spreadsheet component...
+
+ }>
+
+
+
+
+ )
+}
diff --git a/poc/dsheet-embed/src/main.tsx b/poc/dsheet-embed/src/main.tsx
new file mode 100644
index 0000000..16607a5
--- /dev/null
+++ b/poc/dsheet-embed/src/main.tsx
@@ -0,0 +1,9 @@
+import React from 'react'
+import { createRoot } from 'react-dom/client'
+import { RSheetApp } from './RSheetApp'
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+)
diff --git a/poc/dsheet-embed/tsconfig.json b/poc/dsheet-embed/tsconfig.json
new file mode 100644
index 0000000..38f3275
--- /dev/null
+++ b/poc/dsheet-embed/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src"]
+}
diff --git a/poc/dsheet-embed/vite.config.ts b/poc/dsheet-embed/vite.config.ts
new file mode 100644
index 0000000..61af21e
--- /dev/null
+++ b/poc/dsheet-embed/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 3001,
+ },
+})
diff --git a/poc/ipfs-storage/package.json b/poc/ipfs-storage/package.json
new file mode 100644
index 0000000..27e9e04
--- /dev/null
+++ b/poc/ipfs-storage/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@rstack/ipfs-storage-poc",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Encrypted file upload/download to IPFS for rStack",
+ "type": "module",
+ "scripts": {
+ "test": "tsx src/test.ts",
+ "test:live": "tsx src/test-live.ts",
+ "demo": "tsx src/demo.ts"
+ },
+ "dependencies": {
+ "@noble/hashes": "^1.7.0",
+ "pinata": "^1.5.0"
+ },
+ "devDependencies": {
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/poc/ipfs-storage/src/ipfs-client.ts b/poc/ipfs-storage/src/ipfs-client.ts
new file mode 100644
index 0000000..01b3d1b
--- /dev/null
+++ b/poc/ipfs-storage/src/ipfs-client.ts
@@ -0,0 +1,279 @@
+/**
+ * Encrypted IPFS file storage client for rStack
+ *
+ * Flow: encrypt locally → upload to IPFS → store CID + key in document
+ * Default backend: self-hosted kubo at ipfs.jeffemmett.com
+ * Fallback: Pinata (managed) if needed
+ */
+
+// ─── Encryption (reuses crypto-eval primitives) ───
+
+async function generateFileKey(): Promise {
+ const key = new Uint8Array(32)
+ crypto.getRandomValues(key)
+ return key
+}
+
+async function encryptFile(key: Uint8Array, data: Uint8Array): Promise {
+ const iv = new Uint8Array(12)
+ crypto.getRandomValues(iv)
+ const cryptoKey = await crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt'])
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, data)
+ const result = new Uint8Array(12 + ciphertext.byteLength)
+ result.set(iv)
+ result.set(new Uint8Array(ciphertext), 12)
+ return result
+}
+
+async function decryptFile(key: Uint8Array, encrypted: Uint8Array): Promise {
+ const iv = encrypted.slice(0, 12)
+ const ciphertext = encrypted.slice(12)
+ const cryptoKey = await crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['decrypt'])
+ const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, ciphertext)
+ return new Uint8Array(plaintext)
+}
+
+// ─── IPFS Storage Backends ───
+
+export interface IPFSBackend {
+ upload(data: Uint8Array, filename: string): Promise // returns CID
+ download(cid: string): Promise
+ unpin(cid: string): Promise
+}
+
+/** Pinata managed IPFS pinning */
+export class PinataBackend implements IPFSBackend {
+ private jwt: string
+ private gateway: string
+
+ constructor(jwt: string, gateway?: string) {
+ this.jwt = jwt
+ this.gateway = gateway ?? 'https://ipfs.jeffemmett.com/ipfs'
+ }
+
+ async upload(data: Uint8Array, filename: string): Promise {
+ const blob = new Blob([data])
+ const formData = new FormData()
+ formData.append('file', blob, filename)
+ formData.append('pinataMetadata', JSON.stringify({ name: filename }))
+
+ const response = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${this.jwt}` },
+ body: formData,
+ })
+
+ if (!response.ok) {
+ throw new Error(`Pinata upload failed: ${response.status} ${await response.text()}`)
+ }
+
+ const result = await response.json() as { IpfsHash: string }
+ return result.IpfsHash
+ }
+
+ async download(cid: string): Promise {
+ const response = await fetch(`${this.gateway}/${cid}`)
+ if (!response.ok) {
+ throw new Error(`Pinata download failed: ${response.status}`)
+ }
+ return new Uint8Array(await response.arrayBuffer())
+ }
+
+ async unpin(cid: string): Promise {
+ await fetch(`https://api.pinata.cloud/pinning/unpin/${cid}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${this.jwt}` },
+ })
+ }
+}
+
+/** Self-hosted kubo (go-ipfs) HTTP API */
+export class KuboBackend implements IPFSBackend {
+ private apiUrl: string
+ private gatewayUrl: string
+ private headers: Record
+
+ constructor(apiUrl: string, gatewayUrl?: string, authToken?: string) {
+ this.apiUrl = apiUrl
+ this.gatewayUrl = gatewayUrl ?? apiUrl.replace(':5001', ':8080')
+ this.headers = authToken ? { Authorization: `Bearer ${authToken}` } : {}
+ }
+
+ /** Create KuboBackend from environment variables */
+ static fromEnv(): KuboBackend {
+ const apiUrl = process.env.IPFS_API_URL || 'https://ipfs-api.jeffemmett.com'
+ const gatewayUrl = process.env.IPFS_GATEWAY_URL || 'https://ipfs.jeffemmett.com'
+ const authToken = process.env.IPFS_AUTH_TOKEN
+ return new KuboBackend(apiUrl, gatewayUrl, authToken)
+ }
+
+ async upload(data: Uint8Array, filename: string): Promise {
+ const formData = new FormData()
+ formData.append('file', new Blob([data]), filename)
+
+ const response = await fetch(`${this.apiUrl}/api/v0/add?pin=true`, {
+ method: 'POST',
+ headers: this.headers,
+ body: formData,
+ })
+
+ if (!response.ok) {
+ throw new Error(`Kubo upload failed: ${response.status}`)
+ }
+
+ const result = await response.json() as { Hash: string }
+ return result.Hash
+ }
+
+ async download(cid: string): Promise {
+ const response = await fetch(`${this.gatewayUrl}/ipfs/${cid}`)
+ if (!response.ok) {
+ throw new Error(`Kubo download failed: ${response.status}`)
+ }
+ return new Uint8Array(await response.arrayBuffer())
+ }
+
+ async unpin(cid: string): Promise {
+ await fetch(`${this.apiUrl}/api/v0/pin/rm?arg=${cid}`, {
+ method: 'POST',
+ headers: this.headers,
+ })
+ }
+
+ getGatewayUrl(): string {
+ return this.gatewayUrl
+ }
+}
+
+// ─── Encrypted IPFS Client ───
+
+export interface FileMetadata {
+ cid: string
+ encryptionKey: string // base64-encoded 32-byte AES key
+ filename: string
+ mimeType: string
+ size: number // original unencrypted size
+ encryptedSize: number
+ uploadedAt: number
+}
+
+export class EncryptedIPFSClient {
+ private backend: IPFSBackend
+
+ constructor(backend: IPFSBackend) {
+ this.backend = backend
+ }
+
+ /**
+ * Encrypt and upload a file to IPFS
+ * Returns metadata including CID and encryption key (store in document)
+ */
+ async upload(
+ data: Uint8Array,
+ filename: string,
+ mimeType: string
+ ): Promise {
+ // Generate per-file encryption key
+ const fileKey = await generateFileKey()
+
+ // Encrypt the file
+ const encrypted = await encryptFile(fileKey, data)
+
+ // Upload encrypted blob to IPFS
+ const cid = await this.backend.upload(encrypted, `${filename}.enc`)
+
+ return {
+ cid,
+ encryptionKey: uint8ArrayToBase64(fileKey),
+ filename,
+ mimeType,
+ size: data.byteLength,
+ encryptedSize: encrypted.byteLength,
+ uploadedAt: Date.now(),
+ }
+ }
+
+ /**
+ * Download and decrypt a file from IPFS
+ */
+ async download(metadata: FileMetadata): Promise {
+ const fileKey = base64ToUint8Array(metadata.encryptionKey)
+ const encrypted = await this.backend.download(metadata.cid)
+ return decryptFile(fileKey, encrypted)
+ }
+
+ /**
+ * Remove a file from IPFS
+ */
+ async remove(cid: string): Promise {
+ await this.backend.unpin(cid)
+ }
+
+ /**
+ * Generate an IPFS gateway URL (returns encrypted content — client must decrypt)
+ */
+ gatewayUrl(cid: string, gateway?: string): string {
+ return `${gateway ?? 'https://ipfs.jeffemmett.com/ipfs'}/${cid}`
+ }
+}
+
+// ─── TipTap Integration Types ───
+
+/**
+ * TipTap image node attributes for IPFS-backed images
+ * Store these in the ProseMirror document
+ */
+export interface IPFSImageAttrs {
+ src: string // IPFS gateway URL (encrypted content)
+ cid: string // IPFS CID
+ encKey: string // base64 encryption key (stored in document, encrypted at doc level)
+ alt?: string
+ title?: string
+ width?: number
+ height?: number
+}
+
+/**
+ * Helper to create TipTap image attributes from upload metadata
+ */
+export function createImageAttrs(
+ metadata: FileMetadata,
+ gateway?: string,
+ alt?: string
+): IPFSImageAttrs {
+ return {
+ src: `${gateway ?? 'https://ipfs.jeffemmett.com/ipfs'}/${metadata.cid}`,
+ cid: metadata.cid,
+ encKey: metadata.encryptionKey,
+ alt: alt ?? metadata.filename,
+ }
+}
+
+// ─── Factory ───
+
+/** Create an EncryptedIPFSClient using the self-hosted kubo node */
+export function createSelfHostedClient(
+ apiUrl = 'https://ipfs-api.jeffemmett.com',
+ gatewayUrl = 'https://ipfs.jeffemmett.com'
+): EncryptedIPFSClient {
+ return new EncryptedIPFSClient(new KuboBackend(apiUrl, gatewayUrl))
+}
+
+// ─── Utilities ───
+
+function uint8ArrayToBase64(bytes: Uint8Array): string {
+ let binary = ''
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte)
+ }
+ return btoa(binary)
+}
+
+function base64ToUint8Array(base64: string): Uint8Array {
+ const binary = atob(base64)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+ return bytes
+}
diff --git a/poc/ipfs-storage/src/test-live.ts b/poc/ipfs-storage/src/test-live.ts
new file mode 100644
index 0000000..464a255
--- /dev/null
+++ b/poc/ipfs-storage/src/test-live.ts
@@ -0,0 +1,86 @@
+/**
+ * Live integration test for encrypted IPFS storage
+ * Requires a running kubo node — skips if IPFS_API_URL not set
+ *
+ * Usage:
+ * IPFS_API_URL=https://ipfs-api.jeffemmett.com \
+ * IPFS_GATEWAY_URL=https://ipfs.jeffemmett.com \
+ * npx tsx src/test-live.ts
+ */
+
+import { EncryptedIPFSClient, KuboBackend } from './ipfs-client.js'
+
+const encoder = new TextEncoder()
+const decoder = new TextDecoder()
+
+if (!process.env.IPFS_API_URL) {
+ console.log('IPFS_API_URL not set — skipping live tests')
+ process.exit(0)
+}
+
+const backend = KuboBackend.fromEnv()
+const client = new EncryptedIPFSClient(backend)
+
+async function testLiveRoundtrip() {
+ console.log('\n--- Live Test: Encrypted Upload/Download Roundtrip ---')
+
+ const content = `Live test @ ${new Date().toISOString()}`
+ const data = encoder.encode(content)
+
+ // Upload
+ const metadata = await client.upload(data, 'live-test.txt', 'text/plain')
+ console.log(` Uploaded: CID=${metadata.cid}`)
+ console.log(` Size: ${metadata.size}B → ${metadata.encryptedSize}B encrypted`)
+
+ // Download via gateway
+ const decrypted = await client.download(metadata)
+ const result = decoder.decode(decrypted)
+ console.log(` Decrypted: "${result}"`)
+
+ const pass = result === content
+ console.log(` Result: ${pass ? 'PASS' : 'FAIL'}`)
+
+ // Cleanup: unpin
+ await client.remove(metadata.cid)
+ console.log(` Unpinned: ${metadata.cid}`)
+
+ return pass
+}
+
+async function testLiveGatewayUrl() {
+ console.log('\n--- Live Test: Gateway URL Access ---')
+
+ const data = encoder.encode('gateway-test')
+ const metadata = await client.upload(data, 'gw-test.txt', 'text/plain')
+
+ const gatewayUrl = `${process.env.IPFS_GATEWAY_URL}/ipfs/${metadata.cid}`
+ console.log(` Gateway URL: ${gatewayUrl}`)
+
+ const response = await fetch(gatewayUrl)
+ const pass = response.ok
+ console.log(` Fetch status: ${response.status}`)
+ console.log(` Result: ${pass ? 'PASS' : 'FAIL'}`)
+
+ await client.remove(metadata.cid)
+ return pass
+}
+
+async function main() {
+ console.log('=== Live IPFS Integration Tests ===')
+ console.log(`API: ${process.env.IPFS_API_URL}`)
+ console.log(`Gateway: ${process.env.IPFS_GATEWAY_URL}`)
+
+ const results = [
+ await testLiveRoundtrip(),
+ await testLiveGatewayUrl(),
+ ]
+
+ const passed = results.filter(Boolean).length
+ console.log(`\n=== Results: ${passed}/${results.length} passed ===`)
+ process.exit(passed === results.length ? 0 : 1)
+}
+
+main().catch(err => {
+ console.error('Live test error:', err)
+ process.exit(1)
+})
diff --git a/poc/ipfs-storage/src/test.ts b/poc/ipfs-storage/src/test.ts
new file mode 100644
index 0000000..3e56e78
--- /dev/null
+++ b/poc/ipfs-storage/src/test.ts
@@ -0,0 +1,206 @@
+/**
+ * End-to-end test for encrypted IPFS storage
+ * Tests: encrypt → upload → download → decrypt roundtrip
+ *
+ * Uses a mock IPFS backend (in-memory) for testing without network
+ */
+
+import {
+ EncryptedIPFSClient,
+ type IPFSBackend,
+ type FileMetadata,
+ createImageAttrs,
+} from './ipfs-client.js'
+
+// ─── Mock IPFS Backend (in-memory) ───
+
+class MockIPFSBackend implements IPFSBackend {
+ private store = new Map()
+ private counter = 0
+
+ async upload(data: Uint8Array, filename: string): Promise {
+ const cid = `Qm${(++this.counter).toString().padStart(44, 'a')}` // fake CID
+ this.store.set(cid, new Uint8Array(data)) // clone
+ console.log(` [mock-ipfs] Pinned ${filename} → ${cid} (${data.byteLength} bytes)`)
+ return cid
+ }
+
+ async download(cid: string): Promise {
+ const data = this.store.get(cid)
+ if (!data) throw new Error(`CID not found: ${cid}`)
+ console.log(` [mock-ipfs] Fetched ${cid} (${data.byteLength} bytes)`)
+ return data
+ }
+
+ async unpin(cid: string): Promise {
+ this.store.delete(cid)
+ console.log(` [mock-ipfs] Unpinned ${cid}`)
+ }
+
+ get size(): number {
+ return this.store.size
+ }
+}
+
+// ─── Tests ───
+
+const encoder = new TextEncoder()
+const decoder = new TextDecoder()
+
+async function testTextFileRoundtrip() {
+ console.log('\n--- Test: Text File Roundtrip ---')
+ const backend = new MockIPFSBackend()
+ const client = new EncryptedIPFSClient(backend)
+
+ const content = 'Hello, encrypted IPFS world! 🔒'
+ const data = encoder.encode(content)
+
+ // Upload
+ const metadata = await client.upload(data, 'hello.txt', 'text/plain')
+ console.log(` Uploaded: CID=${metadata.cid}`)
+ console.log(` Original: ${metadata.size}B, Encrypted: ${metadata.encryptedSize}B`)
+ console.log(` Key: ${metadata.encryptionKey.slice(0, 16)}...`)
+
+ // Download
+ const decrypted = await client.download(metadata)
+ const result = decoder.decode(decrypted)
+ console.log(` Decrypted: "${result}"`)
+
+ const pass = result === content
+ console.log(` Result: ${pass ? 'PASS ✓' : 'FAIL ✗'}`)
+ return pass
+}
+
+async function testLargeFileRoundtrip() {
+ console.log('\n--- Test: Large File (1MB) Roundtrip ---')
+ const backend = new MockIPFSBackend()
+ const client = new EncryptedIPFSClient(backend)
+
+ // Generate 1MB of test data
+ const size = 1_000_000
+ const data = new Uint8Array(size)
+ for (let offset = 0; offset < size; offset += 65536) {
+ const chunk = Math.min(65536, size - offset)
+ crypto.getRandomValues(data.subarray(offset, offset + chunk))
+ }
+
+ const start = performance.now()
+ const metadata = await client.upload(data, 'large-file.bin', 'application/octet-stream')
+ const uploadTime = performance.now() - start
+
+ const start2 = performance.now()
+ const decrypted = await client.download(metadata)
+ const downloadTime = performance.now() - start2
+
+ // Verify byte-for-byte equality
+ const pass = data.length === decrypted.length && data.every((b, i) => b === decrypted[i])
+ console.log(` Size: ${(size / 1024).toFixed(0)}KB`)
+ console.log(` Encrypt+upload: ${uploadTime.toFixed(1)}ms`)
+ console.log(` Download+decrypt: ${downloadTime.toFixed(1)}ms`)
+ console.log(` Overhead: ${((metadata.encryptedSize - metadata.size) / metadata.size * 100).toFixed(1)}% (IV + auth tag)`)
+ console.log(` Result: ${pass ? 'PASS ✓' : 'FAIL ✗'}`)
+ return pass
+}
+
+async function testImageMetadata() {
+ console.log('\n--- Test: TipTap Image Attributes ---')
+ const backend = new MockIPFSBackend()
+ const client = new EncryptedIPFSClient(backend)
+
+ // Simulate a small PNG
+ const fakeImage = new Uint8Array([0x89, 0x50, 0x4E, 0x47, ...Array(100).fill(0)])
+ const metadata = await client.upload(fakeImage, 'screenshot.png', 'image/png')
+
+ const attrs = createImageAttrs(metadata, 'https://ipfs.jeffemmett.com', 'A screenshot')
+ console.log(` Image attrs:`)
+ console.log(` src: ${attrs.src}`)
+ console.log(` cid: ${attrs.cid}`)
+ console.log(` encKey: ${attrs.encKey.slice(0, 16)}...`)
+ console.log(` alt: ${attrs.alt}`)
+
+ const pass = attrs.src.includes(metadata.cid) && attrs.encKey === metadata.encryptionKey
+ console.log(` Result: ${pass ? 'PASS ✓' : 'FAIL ✗'}`)
+ return pass
+}
+
+async function testMultipleFiles() {
+ console.log('\n--- Test: Multiple Files with Independent Keys ---')
+ const backend = new MockIPFSBackend()
+ const client = new EncryptedIPFSClient(backend)
+
+ const files = [
+ { name: 'note1.md', content: '# Meeting Notes\nDiscussed token allocation' },
+ { name: 'note2.md', content: '# Research\nFileverse integration plan' },
+ { name: 'budget.csv', content: 'item,amount\ninfra,500\ndev,2000' },
+ ]
+
+ const metadatas: FileMetadata[] = []
+ for (const file of files) {
+ const meta = await client.upload(encoder.encode(file.content), file.name, 'text/plain')
+ metadatas.push(meta)
+ }
+
+ // Verify each file has a unique key
+ const keys = new Set(metadatas.map(m => m.encryptionKey))
+ const uniqueKeys = keys.size === files.length
+ console.log(` Unique keys: ${uniqueKeys ? 'PASS ✓' : 'FAIL ✗'} (${keys.size}/${files.length})`)
+
+ // Verify each can be independently decrypted
+ let allCorrect = true
+ for (let i = 0; i < files.length; i++) {
+ const decrypted = decoder.decode(await client.download(metadatas[i]))
+ if (decrypted !== files[i].content) {
+ console.log(` File ${files[i].name}: FAIL ✗`)
+ allCorrect = false
+ }
+ }
+ console.log(` Independent decryption: ${allCorrect ? 'PASS ✓' : 'FAIL ✗'}`)
+
+ // Verify unpin works
+ await client.remove(metadatas[0].cid)
+ console.log(` Unpin: ${backend.size === 2 ? 'PASS ✓' : 'FAIL ✗'}`)
+
+ return uniqueKeys && allCorrect && backend.size === 2
+}
+
+async function testWrongKeyFails() {
+ console.log('\n--- Test: Wrong Key Rejection ---')
+ const backend = new MockIPFSBackend()
+ const client = new EncryptedIPFSClient(backend)
+
+ const data = encoder.encode('Secret content')
+ const metadata = await client.upload(data, 'secret.txt', 'text/plain')
+
+ // Tamper with the key
+ const tampered: FileMetadata = { ...metadata, encryptionKey: btoa(String.fromCharCode(...new Uint8Array(32))) }
+
+ try {
+ await client.download(tampered)
+ console.log(` Result: FAIL ✗ (should have thrown)`)
+ return false
+ } catch (e) {
+ console.log(` Correctly rejected wrong key: ${(e as Error).message.slice(0, 50)}`)
+ console.log(` Result: PASS ✓`)
+ return true
+ }
+}
+
+// ─── Run All ───
+
+async function main() {
+ console.log('=== Encrypted IPFS Storage Tests ===')
+
+ const results = [
+ await testTextFileRoundtrip(),
+ await testLargeFileRoundtrip(),
+ await testImageMetadata(),
+ await testMultipleFiles(),
+ await testWrongKeyFails(),
+ ]
+
+ const passed = results.filter(Boolean).length
+ console.log(`\n=== Results: ${passed}/${results.length} passed ===`)
+ process.exit(passed === results.length ? 0 : 1)
+}
+
+main().catch(console.error)
diff --git a/poc/ipfs-storage/src/tiptap-image-extension.ts b/poc/ipfs-storage/src/tiptap-image-extension.ts
new file mode 100644
index 0000000..e6a140a
--- /dev/null
+++ b/poc/ipfs-storage/src/tiptap-image-extension.ts
@@ -0,0 +1,138 @@
+/**
+ * TipTap Image Extension for IPFS-backed encrypted images
+ *
+ * This is a conceptual implementation showing how to integrate encrypted
+ * IPFS images into TipTap's editor. In production, this would be a proper
+ * TipTap extension that handles:
+ *
+ * 1. Upload: User pastes/drops image → encrypt → upload to IPFS → insert node
+ * 2. Render: Fetch CID → decrypt → create blob URL → display
+ * 3. Cleanup: Revoke blob URLs on node removal
+ *
+ * Usage in rSpace/rNotes:
+ *
+ * ```typescript
+ * import { Extension } from '@tiptap/core'
+ * import { Plugin } from '@tiptap/pm/state'
+ * import { EncryptedIPFSClient, type IPFSImageAttrs } from './ipfs-client'
+ *
+ * export const IPFSImage = Extension.create({
+ * name: 'ipfsImage',
+ * addProseMirrorPlugins() {
+ * return [
+ * new Plugin({
+ * props: {
+ * handleDrop: (view, event) => { ... },
+ * handlePaste: (view, event) => { ... },
+ * }
+ * })
+ * ]
+ * }
+ * })
+ * ```
+ */
+
+import type { EncryptedIPFSClient, FileMetadata, IPFSImageAttrs } from './ipfs-client.js'
+
+/**
+ * Handles image upload from file input, paste, or drag-and-drop
+ *
+ * @param client - EncryptedIPFSClient instance
+ * @param file - File object from input/paste/drop
+ * @param gateway - IPFS gateway URL
+ * @returns IPFSImageAttrs to store in ProseMirror document
+ */
+export async function handleImageUpload(
+ client: EncryptedIPFSClient,
+ file: File,
+ gateway?: string
+): Promise {
+ // Read file as Uint8Array
+ const buffer = await file.arrayBuffer()
+ const data = new Uint8Array(buffer)
+
+ // Encrypt and upload
+ const metadata = await client.upload(data, file.name, file.type)
+
+ return {
+ src: `${gateway ?? 'https://gateway.pinata.cloud/ipfs'}/${metadata.cid}`,
+ cid: metadata.cid,
+ encKey: metadata.encryptionKey,
+ alt: file.name,
+ }
+}
+
+/**
+ * Decrypts and creates a blob URL for displaying an IPFS image
+ * Call URL.revokeObjectURL() when the image is removed from the editor
+ *
+ * @param client - EncryptedIPFSClient instance
+ * @param attrs - Image attributes from ProseMirror document
+ * @returns Blob URL for
+ */
+export async function resolveImage(
+ client: EncryptedIPFSClient,
+ attrs: IPFSImageAttrs
+): Promise {
+ // Build minimal metadata for download
+ const metadata: FileMetadata = {
+ cid: attrs.cid,
+ encryptionKey: attrs.encKey,
+ filename: attrs.alt ?? 'image',
+ mimeType: 'image/png', // Could be stored in attrs for accuracy
+ size: 0,
+ encryptedSize: 0,
+ uploadedAt: 0,
+ }
+
+ const decrypted = await client.download(metadata)
+ const blob = new Blob([decrypted], { type: 'image/png' })
+ return URL.createObjectURL(blob)
+}
+
+/**
+ * Image cache for the editor session
+ * Maps CID → blob URL to avoid re-downloading the same image
+ */
+export class ImageCache {
+ private cache = new Map()
+ private pending = new Map>()
+
+ constructor(private client: EncryptedIPFSClient) {}
+
+ async resolve(attrs: IPFSImageAttrs): Promise {
+ // Return cached
+ if (this.cache.has(attrs.cid)) {
+ return this.cache.get(attrs.cid)!
+ }
+
+ // Deduplicate in-flight requests
+ if (this.pending.has(attrs.cid)) {
+ return this.pending.get(attrs.cid)!
+ }
+
+ const promise = resolveImage(this.client, attrs).then(url => {
+ this.cache.set(attrs.cid, url)
+ this.pending.delete(attrs.cid)
+ return url
+ })
+
+ this.pending.set(attrs.cid, promise)
+ return promise
+ }
+
+ revoke(cid: string): void {
+ const url = this.cache.get(cid)
+ if (url) {
+ URL.revokeObjectURL(url)
+ this.cache.delete(cid)
+ }
+ }
+
+ revokeAll(): void {
+ for (const [cid, url] of this.cache) {
+ URL.revokeObjectURL(url)
+ }
+ this.cache.clear()
+ }
+}
diff --git a/poc/ipfs-storage/tsconfig.json b/poc/ipfs-storage/tsconfig.json
new file mode 100644
index 0000000..4636154
--- /dev/null
+++ b/poc/ipfs-storage/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src"]
+}
diff --git a/poc/ucan-bridge/package-lock.json b/poc/ucan-bridge/package-lock.json
new file mode 100644
index 0000000..f434d97
--- /dev/null
+++ b/poc/ucan-bridge/package-lock.json
@@ -0,0 +1,597 @@
+{
+ "name": "@rstack/ucan-bridge",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@rstack/ucan-bridge",
+ "version": "0.1.0",
+ "dependencies": {
+ "@noble/ed25519": "^2.2.0",
+ "@noble/hashes": "^1.7.0"
+ },
+ "devDependencies": {
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
+ "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
+ "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
+ "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
+ "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
+ "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
+ "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
+ "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
+ "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
+ "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
+ "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
+ "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
+ "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
+ "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
+ "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
+ "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
+ "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
+ "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
+ "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
+ "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
+ "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
+ "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
+ "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
+ "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@noble/ed25519": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz",
+ "integrity": "sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
+ "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.4",
+ "@esbuild/android-arm": "0.27.4",
+ "@esbuild/android-arm64": "0.27.4",
+ "@esbuild/android-x64": "0.27.4",
+ "@esbuild/darwin-arm64": "0.27.4",
+ "@esbuild/darwin-x64": "0.27.4",
+ "@esbuild/freebsd-arm64": "0.27.4",
+ "@esbuild/freebsd-x64": "0.27.4",
+ "@esbuild/linux-arm": "0.27.4",
+ "@esbuild/linux-arm64": "0.27.4",
+ "@esbuild/linux-ia32": "0.27.4",
+ "@esbuild/linux-loong64": "0.27.4",
+ "@esbuild/linux-mips64el": "0.27.4",
+ "@esbuild/linux-ppc64": "0.27.4",
+ "@esbuild/linux-riscv64": "0.27.4",
+ "@esbuild/linux-s390x": "0.27.4",
+ "@esbuild/linux-x64": "0.27.4",
+ "@esbuild/netbsd-arm64": "0.27.4",
+ "@esbuild/netbsd-x64": "0.27.4",
+ "@esbuild/openbsd-arm64": "0.27.4",
+ "@esbuild/openbsd-x64": "0.27.4",
+ "@esbuild/openharmony-arm64": "0.27.4",
+ "@esbuild/sunos-x64": "0.27.4",
+ "@esbuild/win32-arm64": "0.27.4",
+ "@esbuild/win32-ia32": "0.27.4",
+ "@esbuild/win32-x64": "0.27.4"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.7",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
+ "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ }
+ }
+}
diff --git a/poc/ucan-bridge/package.json b/poc/ucan-bridge/package.json
new file mode 100644
index 0000000..3fdd9c7
--- /dev/null
+++ b/poc/ucan-bridge/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@rstack/ucan-bridge",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Bridge EncryptID DIDs to UCAN tokens for Fileverse collab-server auth",
+ "type": "module",
+ "scripts": {
+ "test": "tsx src/test.ts",
+ "generate-did": "tsx src/generate-did.ts"
+ },
+ "dependencies": {
+ "@noble/ed25519": "^2.2.0",
+ "@noble/hashes": "^1.7.0"
+ },
+ "devDependencies": {
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/poc/ucan-bridge/src/test.ts b/poc/ucan-bridge/src/test.ts
new file mode 100644
index 0000000..f23b2a1
--- /dev/null
+++ b/poc/ucan-bridge/src/test.ts
@@ -0,0 +1,130 @@
+/**
+ * Test UCAN bridge: generate key pairs, create tokens, verify signatures, delegate access
+ */
+
+import {
+ generateKeyPair,
+ didToPublicKey,
+ createCollaborationToken,
+ createOwnerToken,
+ delegateAccess,
+ verifyUCAN,
+} from './ucan-bridge.js'
+
+async function main() {
+ console.log('=== UCAN Bridge Tests ===\n')
+
+ // ─── Test 1: Key pair generation ───
+ console.log('--- Test: Key Pair Generation ---')
+ const owner = generateKeyPair()
+ const collaborator = generateKeyPair()
+ console.log(` Owner DID: ${owner.did}`)
+ console.log(` Collab DID: ${collaborator.did}`)
+ console.log(` DIDs are unique: ${owner.did !== collaborator.did ? 'PASS' : 'FAIL'}`)
+
+ // ─── Test 2: DID roundtrip ───
+ console.log('\n--- Test: DID ↔ Public Key Roundtrip ---')
+ const recoveredPubKey = didToPublicKey(owner.did)
+ const didRoundtrip = owner.publicKey.every((b, i) => b === recoveredPubKey[i])
+ console.log(` Roundtrip: ${didRoundtrip ? 'PASS' : 'FAIL'}`)
+
+ // ─── Test 3: Collaboration token ───
+ console.log('\n--- Test: Collaboration Token ---')
+ const serverDid = 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'
+ const docId = 'doc-abc-123'
+
+ const collabToken = await createCollaborationToken(
+ collaborator.privateKey,
+ collaborator.did,
+ serverDid,
+ docId,
+ 3600
+ )
+ console.log(` Token: ${collabToken.slice(0, 60)}...`)
+ console.log(` Parts: ${collabToken.split('.').length} (expected 3)`)
+
+ const collabVerify = await verifyUCAN(collabToken)
+ console.log(` Signature valid: ${collabVerify.valid ? 'PASS' : 'FAIL'}`)
+ console.log(` Issuer: ${collabVerify.payload.iss.slice(0, 30)}...`)
+ console.log(` Audience: ${collabVerify.payload.aud.slice(0, 30)}...`)
+ console.log(` Capabilities: ${JSON.stringify(collabVerify.payload.att)}`)
+ console.log(` Expires in: ${collabVerify.payload.exp - Math.floor(Date.now() / 1000)}s`)
+
+ // ─── Test 4: Owner token ───
+ console.log('\n--- Test: Owner Token ---')
+ const ownerToken = await createOwnerToken(
+ owner.privateKey,
+ owner.did,
+ serverDid,
+ docId,
+ 7200
+ )
+
+ const ownerVerify = await verifyUCAN(ownerToken)
+ console.log(` Signature valid: ${ownerVerify.valid ? 'PASS' : 'FAIL'}`)
+ console.log(` Capabilities: ${JSON.stringify(ownerVerify.payload.att)}`)
+ console.log(` Has wildcard: ${ownerVerify.payload.att[0]?.can === 'doc/*' ? 'PASS' : 'FAIL'}`)
+
+ // ─── Test 5: Delegation ───
+ console.log('\n--- Test: Access Delegation ---')
+ const delegatedToken = await delegateAccess(
+ owner.privateKey,
+ owner.did,
+ collaborator.did,
+ docId,
+ ['doc/READ'], // read-only access
+ 1800,
+ ownerToken // chain from owner token
+ )
+
+ const delegateVerify = await verifyUCAN(delegatedToken)
+ console.log(` Signature valid: ${delegateVerify.valid ? 'PASS' : 'FAIL'}`)
+ console.log(` Issuer (owner): ${delegateVerify.payload.iss === owner.did ? 'PASS' : 'FAIL'}`)
+ console.log(` Audience (collab): ${delegateVerify.payload.aud === collaborator.did ? 'PASS' : 'FAIL'}`)
+ console.log(` Capabilities: ${JSON.stringify(delegateVerify.payload.att)}`)
+ console.log(` Has proof chain: ${delegateVerify.payload.prf?.length === 1 ? 'PASS' : 'FAIL'}`)
+
+ // ─── Test 6: Token with wrong key fails verification ───
+ console.log('\n--- Test: Wrong Key Rejection ---')
+ // Create token with collaborator's key but claim to be owner
+ const fakeToken = await createCollaborationToken(
+ collaborator.privateKey, // wrong key
+ owner.did, // claim to be owner
+ serverDid,
+ docId
+ )
+ const fakeVerify = await verifyUCAN(fakeToken)
+ console.log(` Forged token rejected: ${!fakeVerify.valid ? 'PASS' : 'FAIL'}`)
+
+ // ─── Test 7: Expired token ───
+ console.log('\n--- Test: Expired Token Detection ---')
+ const expiredToken = await createCollaborationToken(
+ collaborator.privateKey,
+ collaborator.did,
+ serverDid,
+ docId,
+ -1 // already expired
+ )
+ const expiredVerify = await verifyUCAN(expiredToken)
+ const isExpired = expiredVerify.payload.exp < Math.floor(Date.now() / 1000)
+ console.log(` Signature still valid: ${expiredVerify.valid ? 'PASS' : 'FAIL'} (sig is valid, expiry is app-level)`)
+ console.log(` Token is expired: ${isExpired ? 'PASS' : 'FAIL'}`)
+
+ // ─── Summary ───
+ const results = [
+ didRoundtrip,
+ collabVerify.valid,
+ ownerVerify.valid,
+ ownerVerify.payload.att[0]?.can === 'doc/*',
+ delegateVerify.valid,
+ delegateVerify.payload.iss === owner.did,
+ delegateVerify.payload.aud === collaborator.did,
+ !fakeVerify.valid,
+ isExpired,
+ ]
+ const passed = results.filter(Boolean).length
+ console.log(`\n=== Results: ${passed}/${results.length} passed ===`)
+ process.exit(passed === results.length ? 0 : 1)
+}
+
+main().catch(console.error)
diff --git a/poc/ucan-bridge/src/ucan-bridge.ts b/poc/ucan-bridge/src/ucan-bridge.ts
new file mode 100644
index 0000000..91d24de
--- /dev/null
+++ b/poc/ucan-bridge/src/ucan-bridge.ts
@@ -0,0 +1,272 @@
+/**
+ * UCAN Bridge for rStack ↔ Fileverse Collaboration Server
+ *
+ * The Fileverse collab-server requires UCAN tokens for authentication.
+ * This bridge generates UCAN tokens from EncryptID DIDs, allowing
+ * rStack users to authenticate with the self-hosted collab server.
+ *
+ * UCAN (User Controlled Authorization Network) tokens are JWTs that
+ * encode capability-based permissions. They can be delegated without
+ * contacting a central server.
+ *
+ * Flow:
+ * 1. User authenticates with EncryptID (DID-based, passwordless)
+ * 2. Client generates an Ed25519 key pair (or derives from EncryptID)
+ * 3. Client creates a UCAN token for the collab-server
+ * 4. Client presents UCAN to collab-server WebSocket /auth endpoint
+ */
+
+import * as ed from '@noble/ed25519'
+import { sha512 } from '@noble/hashes/sha512'
+
+// Configure noble/ed25519 to use sha512
+ed.etc.sha512Sync = (...msgs) => {
+ const m = msgs.reduce((acc, msg) => {
+ const combined = new Uint8Array(acc.length + msg.length)
+ combined.set(acc)
+ combined.set(msg, acc.length)
+ return combined
+ })
+ return sha512(m)
+}
+
+// ─── DID Key Utilities ───
+
+const DID_KEY_PREFIX = 'did:key:'
+const ED25519_MULTICODEC = new Uint8Array([0xed, 0x01]) // varint for ed25519-pub
+
+/** Generate a new Ed25519 key pair and its did:key */
+export function generateKeyPair(): {
+ privateKey: Uint8Array
+ publicKey: Uint8Array
+ did: string
+} {
+ const privateKey = ed.utils.randomPrivateKey()
+ const publicKey = ed.getPublicKey(privateKey)
+
+ // did:key format: did:key:z + base58btc(multicodec_prefix + public_key)
+ const multicodecKey = new Uint8Array(ED25519_MULTICODEC.length + publicKey.length)
+ multicodecKey.set(ED25519_MULTICODEC)
+ multicodecKey.set(publicKey, ED25519_MULTICODEC.length)
+
+ const did = `${DID_KEY_PREFIX}z${base58btcEncode(multicodecKey)}`
+
+ return { privateKey, publicKey, did }
+}
+
+/** Extract the public key bytes from a did:key */
+export function didToPublicKey(did: string): Uint8Array {
+ if (!did.startsWith(`${DID_KEY_PREFIX}z`)) {
+ throw new Error(`Invalid did:key format: ${did}`)
+ }
+
+ const decoded = base58btcDecode(did.slice(DID_KEY_PREFIX.length + 1)) // skip "z"
+
+ // Verify Ed25519 multicodec prefix
+ if (decoded[0] !== 0xed || decoded[1] !== 0x01) {
+ throw new Error('Not an Ed25519 did:key')
+ }
+
+ return decoded.slice(2)
+}
+
+// ─── UCAN Token Creation ───
+
+export interface UCANPayload {
+ iss: string // Issuer DID
+ aud: string // Audience DID (collab-server's DID)
+ nbf?: number // Not before (Unix timestamp)
+ exp: number // Expiration (Unix timestamp)
+ att: UCANCapability[] // Attenuations (capabilities)
+ prf?: string[] // Proofs (parent UCANs for delegation chains)
+ fct?: Record[] // Facts (arbitrary metadata)
+}
+
+export interface UCANCapability {
+ with: string // Resource URI (e.g., "doc:*" or "doc:document-id")
+ can: string // Action (e.g., "doc/UPDATE", "doc/READ")
+}
+
+/** Create a UCAN token for the collab-server */
+export async function createCollaborationToken(
+ privateKey: Uint8Array,
+ issuerDid: string,
+ serverDid: string,
+ documentId: string,
+ ttlSeconds: number = 3600
+): Promise {
+ const now = Math.floor(Date.now() / 1000)
+
+ const payload: UCANPayload = {
+ iss: issuerDid,
+ aud: serverDid,
+ nbf: now,
+ exp: now + ttlSeconds,
+ att: [
+ { with: `doc:${documentId}`, can: 'doc/UPDATE' },
+ { with: `doc:${documentId}`, can: 'doc/READ' },
+ ],
+ }
+
+ return signUCAN(payload, privateKey)
+}
+
+/** Create a UCAN owner token (full permissions including commit/terminate) */
+export async function createOwnerToken(
+ privateKey: Uint8Array,
+ issuerDid: string,
+ serverDid: string,
+ documentId: string,
+ ttlSeconds: number = 3600
+): Promise {
+ const now = Math.floor(Date.now() / 1000)
+
+ const payload: UCANPayload = {
+ iss: issuerDid,
+ aud: serverDid,
+ nbf: now,
+ exp: now + ttlSeconds,
+ att: [
+ { with: `doc:${documentId}`, can: 'doc/*' },
+ ],
+ }
+
+ return signUCAN(payload, privateKey)
+}
+
+/** Delegate a capability to another user's DID */
+export async function delegateAccess(
+ ownerPrivateKey: Uint8Array,
+ ownerDid: string,
+ delegateDid: string,
+ documentId: string,
+ capabilities: string[] = ['doc/UPDATE', 'doc/READ'],
+ ttlSeconds: number = 3600,
+ parentToken?: string
+): Promise {
+ const now = Math.floor(Date.now() / 1000)
+
+ const payload: UCANPayload = {
+ iss: ownerDid,
+ aud: delegateDid,
+ nbf: now,
+ exp: now + ttlSeconds,
+ att: capabilities.map(can => ({ with: `doc:${documentId}`, can })),
+ prf: parentToken ? [parentToken] : undefined,
+ }
+
+ return signUCAN(payload, ownerPrivateKey)
+}
+
+// ─── UCAN JWT Signing ───
+
+async function signUCAN(payload: UCANPayload, privateKey: Uint8Array): Promise {
+ const header = { alg: 'EdDSA', typ: 'JWT', ucv: '0.10.0' }
+
+ const encodedHeader = base64urlEncode(JSON.stringify(header))
+ const encodedPayload = base64urlEncode(JSON.stringify(payload))
+ const signingInput = `${encodedHeader}.${encodedPayload}`
+
+ const signature = await ed.signAsync(
+ new TextEncoder().encode(signingInput),
+ privateKey
+ )
+
+ return `${signingInput}.${base64urlEncodeBytes(signature)}`
+}
+
+/** Verify a UCAN token's signature (for testing) */
+export async function verifyUCAN(token: string): Promise<{
+ valid: boolean
+ payload: UCANPayload
+}> {
+ const parts = token.split('.')
+ if (parts.length !== 3) throw new Error('Invalid JWT format')
+
+ const payload = JSON.parse(base64urlDecode(parts[1])) as UCANPayload
+ const publicKey = didToPublicKey(payload.iss)
+ const signingInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`)
+ const signature = base64urlDecodeBytes(parts[2])
+
+ const valid = await ed.verifyAsync(signature, signingInput, publicKey)
+
+ return { valid, payload }
+}
+
+// ─── Encoding Utilities ───
+
+function base64urlEncode(str: string): string {
+ return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
+}
+
+function base64urlDecode(str: string): string {
+ const padded = str + '='.repeat((4 - str.length % 4) % 4)
+ return atob(padded.replace(/-/g, '+').replace(/_/g, '/'))
+}
+
+function base64urlEncodeBytes(bytes: Uint8Array): string {
+ let binary = ''
+ for (const byte of bytes) binary += String.fromCharCode(byte)
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
+}
+
+function base64urlDecodeBytes(str: string): Uint8Array {
+ const padded = str + '='.repeat((4 - str.length % 4) % 4)
+ const binary = atob(padded.replace(/-/g, '+').replace(/_/g, '/'))
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
+ return bytes
+}
+
+// Base58btc (Bitcoin alphabet)
+const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
+
+function base58btcEncode(bytes: Uint8Array): string {
+ const digits = [0]
+ for (const byte of bytes) {
+ let carry = byte
+ for (let j = 0; j < digits.length; j++) {
+ carry += digits[j] << 8
+ digits[j] = carry % 58
+ carry = (carry / 58) | 0
+ }
+ while (carry > 0) {
+ digits.push(carry % 58)
+ carry = (carry / 58) | 0
+ }
+ }
+ // Leading zeros
+ let output = ''
+ for (const byte of bytes) {
+ if (byte !== 0) break
+ output += BASE58_ALPHABET[0]
+ }
+ for (let i = digits.length - 1; i >= 0; i--) {
+ output += BASE58_ALPHABET[digits[i]]
+ }
+ return output
+}
+
+function base58btcDecode(str: string): Uint8Array {
+ const bytes = [0]
+ for (const char of str) {
+ const value = BASE58_ALPHABET.indexOf(char)
+ if (value === -1) throw new Error(`Invalid base58 character: ${char}`)
+ let carry = value
+ for (let j = 0; j < bytes.length; j++) {
+ carry += bytes[j] * 58
+ bytes[j] = carry & 0xff
+ carry >>= 8
+ }
+ while (carry > 0) {
+ bytes.push(carry & 0xff)
+ carry >>= 8
+ }
+ }
+ // Leading '1's → leading zeros
+ const leadingZeros = str.split('').findIndex(c => c !== '1')
+ const result = new Uint8Array(leadingZeros + bytes.length)
+ bytes.reverse()
+ result.set(bytes, leadingZeros)
+ return result
+}
diff --git a/poc/ucan-bridge/tsconfig.json b/poc/ucan-bridge/tsconfig.json
new file mode 100644
index 0000000..4636154
--- /dev/null
+++ b/poc/ucan-bridge/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src"]
+}