Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e61619e
chore: snapshot pre-plan workspace state
LeJamon May 3, 2026
647139e
chore: add vitest test infrastructure
LeJamon May 3, 2026
0cad6dd
feat(server): add canonical-json utility for VC signing
LeJamon May 3, 2026
83900d1
feat(server): add W3C VC builder, signer, verifier with XRPL keys
LeJamon May 3, 2026
cd0c7e1
feat(server): add Pinata IPFS utility with pin/fetch/unpin
LeJamon May 3, 2026
801375e
feat(schema): publish public schemas to IPFS and emit CID memo
LeJamon May 3, 2026
dec2450
chore: harden canonical-json, ipfs, and xrpl utilities for phase 4
LeJamon May 3, 2026
ee9a6dc
feat(credential): build and pin signed W3C VC for public credentials
LeJamon May 3, 2026
20baef8
feat(verify): add credential verification endpoint and UI
LeJamon May 3, 2026
21c46d0
feat(accept): use wallet-signed CredentialAccept instead of seed paste
LeJamon May 3, 2026
eb24cf7
fix(accept): guard against missing tx hash; update docs for wallet-si…
LeJamon May 3, 2026
4efb907
feat(ux): poll sink for indexed record before claiming success
LeJamon May 3, 2026
0a54986
fix(ux): correct schema query param; tighten indexer-wait loop and to…
LeJamon May 3, 2026
d7984b0
feat(substream): index optional parent_uid for schema versioning
LeJamon May 3, 2026
ccf8291
feat(schema): expose parent/descendant version chain in API and UI
LeJamon May 3, 2026
3b16efa
fix(schema): cap ancestor walk depth and tighten parent_uid type
LeJamon May 3, 2026
091ccd4
feat(credential): filter and badge expired credentials
LeJamon May 3, 2026
0513294
docs: mark phase 1–9 spec gaps as resolved
LeJamon May 3, 2026
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
28 changes: 19 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,27 @@
XRPL_SERVER=wss://s.altnet.rippletest.net:51233
ISSUER_SEED=sXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/xrpl_credentials
# Address that receives schema-register Payment txs (must differ from ISSUER to avoid temREDUNDANT)
XRPL_REGISTRY_ADDRESS=rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh

# IPFS Configuration
IPFS_PROVIDER=pinata
PINATA_JWT=your_pinata_jwt_here
IPFS_GATEWAY=https://gateway.pinata.cloud
# Database (written by substreams-sink-sql, read by the app)
DATABASE_URL=postgresql://user:12345678@localhost:5432/xcs

# Firehose / Substreams
# Ledger index to start indexing from.
# Ledger 0 does NOT exist on testnet — always use a real ledger index.
# Check current ledger: curl -s -X POST https://s.altnet.rippletest.net:51234/ -H 'Content-Type: application/json' -d '{"method":"ledger","params":[{"ledger_index":"current"}]}'
# For indexing from "now", use current ledger minus ~100.
FIREHOSE_START_BLOCK=15366000
# XRPL JSON-RPC endpoint used by firehose-xrpl (HTTP, not WebSocket)
XRPL_RPC_ENDPOINT=https://s.altnet.rippletest.net:51234/

# Wallet Configuration
NUXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id_here
# Wallet Connect (optional, for wallet plugin)
NUXT_PUBLIC_WALLETCONNECT_PROJECT_ID=

# Application
BASE_URL=http://localhost:3000
PORT=3000

# IPFS (Pinata) — required for public schemas/credentials
PINATA_JWT=
IPFS_GATEWAY=https://gateway.pinata.cloud
19 changes: 19 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM node:20-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app

COPY --from=build /app/.output ./

ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000

CMD ["node", "server/index.mjs"]
109 changes: 27 additions & 82 deletions app/components/credential/CredentialAcceptance.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,112 +10,57 @@
</p>
<p>
<span class="font-medium">Type:</span>
<span class="ml-2">{{ credential.credentialType }}</span>
<code class="ml-2 text-xs truncate">{{ credential.credential_type }}</code>
</p>
<p>
<span class="font-medium">Issued:</span>
<span class="ml-2">{{
new Date(credential.createdAt).toLocaleDateString()
}}</span>
<p v-if="credential.created_ledger">
<span class="font-medium">Ledger:</span>
<span class="ml-2">{{ credential.created_ledger }}</span>
</p>
<p v-if="credential.expiresAt">
<p v-if="expirationDate">
<span class="font-medium">Expires:</span>
<span class="ml-2">{{
new Date(credential.expiresAt).toLocaleDateString()
}}</span>
<span class="ml-2">{{ expirationDate }}</span>
</p>
<p v-if="credential.uri">
<span class="font-medium">URI:</span>
<a :href="credential.uri" target="_blank" class="ml-2 text-blue-600 hover:underline truncate">
{{ credential.uri }}
</a>
</p>
</div>
</div>

<!-- W3C VC Data Preview -->
<div class="mb-6">
<h4 class="font-semibold mb-2">Credential Data</h4>
<div class="p-4 bg-gray-50 rounded-lg overflow-auto max-h-64">
<pre class="text-xs">{{
JSON.stringify(credential.vcDocument, null, 2)
}}</pre>
</div>
</div>

<!-- Accept Button -->
<div v-if="!showSeedInput">
<div>
<button
@click="showSeedInput = true"
@click="$emit('accept')"
class="w-full px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-semibold"
>
Accept Credential
</button>
</div>

<!-- Seed Input Form -->
<div v-else class="space-y-4">
<div>
<label for="seed" class="block text-sm font-medium mb-2">
Subject Seed (Private Key) *
</label>
<input
id="seed"
v-model="subjectSeed"
type="password"
required
class="w-full px-4 py-2 border rounded-lg font-mono"
placeholder="sXXXXXXXXXX..."
:disabled="isAccepting"
/>
<p class="text-xs text-gray-600 mt-1">
Your seed is required to sign the acceptance transaction on XRPL. It
is never stored or transmitted anywhere except directly to XRPL.
</p>
</div>

<div class="flex gap-3">
<button
@click="handleAccept"
:disabled="isAccepting"
class="flex-1 px-6 py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 font-semibold disabled:opacity-50"
>
{{ isAccepting ? 'Accepting...' : 'Confirm Accept' }}
</button>
<button
@click="showSeedInput = false"
:disabled="isAccepting"
class="px-6 py-3 border rounded-lg hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import type { Credential } from '~/lib/types/credential';
import type { Credential } from '~/lib/types/schema';

const RIPPLE_EPOCH = 946684800;

const props = defineProps<{
credential: Credential;
}>();

const emit = defineEmits<{
accept: [subjectSeed: string];
accept: [];
}>();

const subjectSeed = ref('');
const showSeedInput = ref(false);
const isAccepting = ref(false);

const handleAccept = async () => {
if (!subjectSeed.value) {
alert('Please enter your subject seed');
return;
}

isAccepting.value = true;
try {
emit('accept', subjectSeed.value);
} finally {
isAccepting.value = false;
subjectSeed.value = '';
showSeedInput.value = false;
}
};
const expirationDate = computed(() => {
if (!props.credential.expiration) return '';
const d = new Date((props.credential.expiration + RIPPLE_EPOCH) * 1000);
return new Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(d);
});
</script>
113 changes: 40 additions & 73 deletions app/components/credential/CredentialCard.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
<template>
<UCard
:ui="{
body: { padding: 'p-6' },
header: { padding: 'p-4 pb-0' },
}"
>
<UCard>
<template #header>
<div class="flex items-start justify-between">
<div class="flex-1">
<NuxtLink
:to="`/credentials/${credential.id}`"
:to="`/credentials/${encodeURIComponent(credential.id)}`"
class="text-lg font-semibold text-gray-900 hover:text-primary transition-colors"
>
Credential
Expand All @@ -18,31 +13,14 @@
</span>
</NuxtLink>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<UBadge :color="getStatusColor()" variant="subtle">
{{ getStatusText() }}
<UBadge :color="statusColor" variant="subtle">
{{ statusText }}
</UBadge>
<UBadge
:color="credential.isPublic ? 'green' : 'gray'"
variant="subtle"
>
{{ credential.isPublic ? 'Public' : 'Private' }}
</UBadge>
<UBadge v-if="isExpired" color="red" variant="subtle">
<UBadge v-if="isExpired" color="error" variant="subtle">
Expired
</UBadge>
</div>
</div>
<UButton
v-if="credential.ipfsCid"
:to="`${ipfsGateway}/ipfs/${credential.ipfsCid}`"
target="_blank"
color="gray"
variant="ghost"
size="xs"
icon="i-heroicons-arrow-top-right-on-square"
>
IPFS
</UButton>
</div>
</template>

Expand All @@ -51,33 +29,33 @@
<div class="space-y-2">
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-500 w-20">Issuer:</span>
<code class="text-xs bg-gray-100 px-2 py-1 rounded flex-1">
<code class="text-xs bg-gray-100 px-2 py-1 rounded flex-1 truncate">
{{ credential.issuer }}
</code>
</div>
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-500 w-20">Subject:</span>
<code class="text-xs bg-gray-100 px-2 py-1 rounded flex-1">
<code class="text-xs bg-gray-100 px-2 py-1 rounded flex-1 truncate">
{{ credential.subject }}
</code>
</div>
</div>

<!-- Dates -->
<!-- Ledger info -->
<div class="flex items-center justify-between text-sm">
<div class="space-y-1">
<div class="text-gray-500">
<span class="font-medium">Issued:</span>
{{ formatDate(credential.createdAt) }}
<div v-if="credential.created_ledger" class="text-gray-500">
<span class="font-medium">Ledger:</span>
{{ credential.created_ledger }}
</div>
<div v-if="credential.expiresAt" class="text-gray-500">
<div v-if="credential.expiration" class="text-gray-500">
<span class="font-medium">Expires:</span>
{{ formatDate(credential.expiresAt) }}
{{ expirationDate }}
</div>
</div>

<UButton
:to="`/credentials/${credential.id}`"
:to="`/credentials/${encodeURIComponent(credential.id)}`"
color="primary"
variant="soft"
size="sm"
Expand All @@ -86,37 +64,25 @@
</UButton>
</div>

<!-- XRPL Info -->
<!-- XRPL TX Hash -->
<div
v-if="credential.xrplTxHash"
v-if="credential.tx_hash"
class="pt-3 border-t border-gray-100 space-y-2"
>
<div class="flex items-center gap-2 text-xs">
<span class="text-gray-500">TX Hash:</span>
<span class="text-gray-500">TX:</span>
<code class="bg-gray-100 px-2 py-1 rounded flex-1 truncate">
{{ credential.xrplTxHash }}
{{ credential.tx_hash }}
</code>
<UButton
:to="`https://testnet.xrpl.org/transactions/${credential.xrplTxHash}`"
:to="`https://testnet.xrpl.org/transactions/${credential.tx_hash}`"
target="_blank"
color="gray"
color="neutral"
variant="ghost"
size="xs"
icon="i-heroicons-arrow-top-right-on-square"
/>
</div>
<div
v-if="credential.acceptedAt"
class="flex items-center gap-2 text-xs text-gray-500"
>
<span>Accepted on {{ formatDate(credential.acceptedAt) }}</span>
</div>
<div
v-if="credential.revokedAt"
class="flex items-center gap-2 text-xs text-red-600"
>
<span>Revoked on {{ formatDate(credential.revokedAt) }}</span>
</div>
</div>
</div>
</UCard>
Expand All @@ -129,32 +95,33 @@ const props = defineProps<{
credential: Credential;
}>();

const config = useRuntimeConfig();
const ipfsGateway = config.public.ipfsGateway;
// Ripple epoch offset: Jan 1 2000 00:00:00 UTC = 946684800 Unix seconds
const RIPPLE_EPOCH = 946684800;

const isExpired = computed(() => {
if (!props.credential.expiresAt) return false;
return new Date(props.credential.expiresAt) < new Date();
if (!props.credential.expiration) return false;
return (props.credential.expiration + RIPPLE_EPOCH) * 1000 < Date.now();
});

const getStatusColor = () => {
if (props.credential.revoked) return 'red';
if (props.credential.accepted) return 'green';
return 'yellow';
};

const getStatusText = () => {
if (props.credential.revoked) return 'Revoked';
if (props.credential.accepted) return 'Accepted';
return 'Pending';
};

const formatDate = (date: Date | string) => {
const d = new Date(date);
const expirationDate = computed(() => {
if (!props.credential.expiration) return '';
const d = new Date((props.credential.expiration + RIPPLE_EPOCH) * 1000);
return new Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(d);
};
});

const statusColor = computed(() => {
if (props.credential.status === 'revoked') return 'error';
if (props.credential.status === 'accepted') return 'success';
return 'warning';
});

const statusText = computed(() => {
if (props.credential.status === 'revoked') return 'Revoked';
if (props.credential.status === 'accepted') return 'Accepted';
return 'Pending';
});
</script>
Loading