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
3 changes: 0 additions & 3 deletions .env.example

This file was deleted.

63 changes: 52 additions & 11 deletions src/app/api/verify-issuance-code/route.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,64 @@
import { NextResponse } from 'next/server';

const API_URLS: Record<string, string> = {
mainnet: process.env.NEXT_PUBLIC_ACTA_API_BASE_URL_MAINNET || 'https://acta.build/api/mainnet',
testnet: process.env.NEXT_PUBLIC_ACTA_API_BASE_URL_TESTNET || 'https://acta.build/api/testnet',
};

export async function POST(req: Request) {
try {
const { code } = (await req.json()) as { code?: string };
const { code, adminApiKey, network } = (await req.json()) as {
code?: string;
adminApiKey?: string;
network?: string;
};

const expected = process.env.IMPACTA_BOOTCAMP_CODE;
if (!expected) {
return NextResponse.json(
{ valid: false, error: 'Issuance is not configured on this server.' },
{ status: 503 }
);
if (!code?.trim()) {
return NextResponse.json({ valid: false, error: 'Issuance code is required.' });
}

if (!code || code.trim() !== expected.trim()) {
return NextResponse.json({ valid: false, error: 'Invalid issuance code.' });
if (!adminApiKey?.trim()) {
return NextResponse.json({ valid: false, error: 'Admin API key is required.' });
}

const net = network === 'mainnet' ? 'mainnet' : 'testnet';
const baseUrl = API_URLS[net].replace(/\/$/, '');

const resp = await fetch(`${baseUrl}/admin/issuance-codes/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-ACTA-Key': adminApiKey.trim(),
},
body: JSON.stringify({
code: code.trim(),
template_id: 'impacta-certificate',
}),
});

if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
const msg =
typeof body === 'object' && body !== null && 'error' in body
? String((body as Record<string, unknown>).error)
: `Verification service returned ${resp.status}`;

if (resp.status === 401) {
return NextResponse.json({ valid: false, error: 'Invalid admin API key.' });
}

return NextResponse.json(
{ valid: false, error: msg },
{ status: resp.status >= 500 ? 503 : 400 }
);
}

return NextResponse.json({ valid: true });
const data = (await resp.json()) as { valid?: boolean; error?: string };
return NextResponse.json({ valid: data.valid === true, error: data.error });
} catch {
return NextResponse.json({ valid: false, error: 'Bad request.' }, { status: 400 });
return NextResponse.json(
{ valid: false, error: 'Verification service unavailable.' },
{ status: 503 }
);
}
}
126 changes: 70 additions & 56 deletions src/components/modules/issue/hooks/useIssueCredential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useNetwork } from '@/providers/network.provider';
import { mapContractErrorToMessage } from '@/lib/utils';
import { actaFetchJson } from '@/lib/actaApi';

import { useVault } from '@/components/modules/vault/hooks/use-vault';
import { useVault, checkVaultExistsForOwner } from '@/components/modules/vault/hooks/use-vault';
import { useActaApiKey } from '@/components/modules/vault/hooks/use-acta-api-key';

import type { CredentialTemplate, TemplateField } from '@/@types/templates';
Expand Down Expand Up @@ -50,29 +50,36 @@ export function useIssueCredential() {
});
const [issuanceCodeValid, setIssuanceCodeValid] = useState<boolean | null>(null);

const handleSetIssuanceCode = useCallback(async (code: string) => {
setIssuanceCode(code);
if (typeof window !== 'undefined') {
if (code.trim()) {
sessionStorage.setItem('impacta_issuance_code', code);
} else {
sessionStorage.removeItem('impacta_issuance_code');
const handleSetIssuanceCode = useCallback(
async (code: string) => {
setIssuanceCode(code);
if (typeof window !== 'undefined') {
if (code.trim()) {
sessionStorage.setItem('impacta_issuance_code', code);
} else {
sessionStorage.removeItem('impacta_issuance_code');
}
}
}
setIssuanceCodeValid(null);
if (!code.trim()) return;
try {
const res = await fetch('/api/verify-issuance-code', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: code.trim() }),
});
const data = (await res.json()) as { valid?: boolean };
setIssuanceCodeValid(data.valid === true);
} catch {
setIssuanceCodeValid(false);
}
}, []);
setIssuanceCodeValid(null);
if (!code.trim() || !apiKey.trim()) return;
try {
const res = await fetch('/api/verify-issuance-code', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
code: code.trim(),
adminApiKey: apiKey.trim(),
network,
}),
});
const data = (await res.json()) as { valid?: boolean };
setIssuanceCodeValid(data.valid === true);
} catch {
setIssuanceCodeValid(false);
}
},
[apiKey, network]
);

const ownerDid = useMemo(() => {
return walletAddress
Expand Down Expand Up @@ -177,6 +184,13 @@ export function useIssueCredential() {

const isImpactaTpl = tpl.id === 'impacta-certificate';

const trimmedApiKey = apiKey.trim();
if (!trimmedApiKey) {
const msg = 'API key is required to issue via API.';
setState((s) => ({ ...s, error: msg }));
throw new Error(msg);
}

if (isImpactaTpl) {
if (!issuanceCode.trim()) {
const msg = 'Issuance code is required to issue Impacta certificates.';
Expand All @@ -186,7 +200,11 @@ export function useIssueCredential() {
const codeRes = await fetch('/api/verify-issuance-code', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: issuanceCode.trim() }),
body: JSON.stringify({
code: issuanceCode.trim(),
adminApiKey: trimmedApiKey,
network,
}),
});
const codeData = (await codeRes.json()) as { valid?: boolean; error?: string };
if (!codeData.valid) {
Expand All @@ -196,13 +214,6 @@ export function useIssueCredential() {
}
}

const trimmedApiKey = apiKey.trim();
if (!trimmedApiKey) {
const msg = 'API key is required to issue via API.';
setState((s) => ({ ...s, error: msg }));
throw new Error(msg);
}

const requiredErr = validateRequired(tpl.fields);
if (requiredErr) {
setState((s) => ({ ...s, error: requiredErr }));
Expand Down Expand Up @@ -258,42 +269,45 @@ export function useIssueCredential() {
}
}

// Impacta Bootcamp template: ensure recipient has a vault via sponsored vault (sponsor = issuer, owner = recipient, did = owner DID).
// Read config from API once (used for vault check and issuance).
const cfg = await actaFetchJson<{
rpcUrl: string;
networkPassphrase: string;
actaContractId: string;
}>({
network,
apiKey: trimmedApiKey,
method: 'GET',
path: '/config',
});

// Impacta Bootcamp template: ensure recipient has a vault. Only call create_sponsored_vault if they don't have one (avoids Error(Contract, #1) AlreadyInitialized).
const isImpactaTemplate = tpl.id === 'impacta-certificate';
if (isImpactaTemplate && !issuingToSelf && ownerG) {
const recipientDid = `did:pkh:stellar:${network === 'mainnet' ? 'mainnet' : 'testnet'}:${ownerG}`;
try {
await createSponsoredVault({ owner: ownerG, didUri: recipientDid });
} catch (sponsoredErr: unknown) {
const msg =
sponsoredErr && typeof (sponsoredErr as Error).message === 'string'
? (sponsoredErr as Error).message
: String(sponsoredErr);
// Vault already exists for this owner β€” continue to issue
if (/Vault already initialized|AlreadyInitialized|Error\(Contract,\s*#1\)/i.test(msg)) {
// continue
} else {
throw sponsoredErr;
const recipientHasVault = await checkVaultExistsForOwner(cfg, ownerG);
if (!recipientHasVault) {
const recipientDid = `did:pkh:stellar:${network === 'mainnet' ? 'mainnet' : 'testnet'}:${ownerG}`;
try {
await createSponsoredVault({ owner: ownerG, didUri: recipientDid });
} catch (sponsoredErr: unknown) {
const msg =
sponsoredErr && typeof (sponsoredErr as Error).message === 'string'
? (sponsoredErr as Error).message
: String(sponsoredErr);
if (/Vault already initialized|AlreadyInitialized|Error\(Contract,\s*#1\)/i.test(msg)) {
// race: vault was created meanwhile β€” continue
} else {
throw sponsoredErr;
}
}
}
}

// When issuing to another (ownerG !== activeAddress), no vault creation or self-auth;
// the recipient must have a vault; the contract auto-authorizes the issuer on first issuance.

const ensuredVcId = state.vcId || generateVcId();
if (!state.vcId) {
setState((s) => ({ ...s, vcId: ensuredVcId }));
}

// Read config from API (auth required).
const cfg = await actaFetchJson<{ networkPassphrase: string; actaContractId: string }>({
network,
apiKey: trimmedApiKey,
method: 'GET',
path: '/config',
});

// Prepare issuance: owner = recipient (ownerG), issuer = signer (activeAddress).
const issuerDidLocal = `did:pkh:stellar:${network === 'mainnet' ? 'mainnet' : 'testnet'}:${activeAddress}`;
const prep = await actaFetchJson<TxPrepareResp>({
Expand Down
55 changes: 29 additions & 26 deletions src/components/modules/issue/ui/DynamicIssueForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,35 @@ export default function DynamicIssueForm({
</div>
))}

{isImpacta ? (
<div>
<label className="block text-sm font-medium text-white mb-2">
API Key {isImpacta ? '(admin) *' : '*'}{' '}
{keyValidated && <span className="text-green-500 text-xs">βœ“ Validated</span>}
</label>
<input
type="password"
value={apiKey}
placeholder={
isImpacta
? 'Paste your admin API key here'
: 'Paste your API key here (can be a custom early/custom key)'
}
onChange={(e) => handleApiKeyChange(e.target.value)}
disabled={validatingKey}
className="w-full rounded-xl border border-zinc-800 bg-zinc-950/50 text-white placeholder:text-zinc-500 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all disabled:opacity-50"
/>
{keyValidationError && (
<p className="mt-2 text-xs text-red-400">{keyValidationError}</p>
)}
{validatingKey && <p className="mt-2 text-xs text-zinc-500">Validating API key...</p>}
<p className="mt-2 text-xs text-zinc-500">
{isImpacta
? 'An admin API key is required to verify the issuance code and issue Impacta certificates.'
: 'If you have an early or custom API key provided by the team, you can use it here. Otherwise, generate one from the API Keys page.'}
</p>
</div>

{isImpacta && (
<div>
<label className="block text-sm font-medium text-white mb-2">
Issuance Code *{' '}
Expand All @@ -198,31 +226,6 @@ export default function DynamicIssueForm({
an administrator if you don&apos;t have one.
</p>
</div>
) : (
<div>
<label className="block text-sm font-medium text-white mb-2">
API Key *{' '}
{keyValidated && <span className="text-green-500 text-xs">βœ“ Validated</span>}
</label>
<input
type="password"
value={apiKey}
placeholder="Paste your API key here (can be a custom early/custom key)"
onChange={(e) => handleApiKeyChange(e.target.value)}
disabled={validatingKey}
className="w-full rounded-xl border border-zinc-800 bg-zinc-950/50 text-white placeholder:text-zinc-500 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all disabled:opacity-50"
/>
{keyValidationError && (
<p className="mt-2 text-xs text-red-400">{keyValidationError}</p>
)}
{validatingKey && (
<p className="mt-2 text-xs text-zinc-500">Validating API key...</p>
)}
<p className="mt-2 text-xs text-zinc-500">
If you have an early or custom API key provided by the team, you can use it here.
Otherwise, generate one from the API Keys page.
</p>
</div>
)}

<div className="pt-4">
Expand Down
49 changes: 49 additions & 0 deletions src/components/modules/vault/hooks/use-vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,55 @@ type ApiConfig = {

type TxPrepareResponse = { xdr: string; network: string };

/**
* Returns true if the given owner address already has a vault (so sponsored vault creation can be skipped).
* Uses RPC simulation of authorize_issuer(owner, owner); contract returns #8 when vault does not exist.
*/
export async function checkVaultExistsForOwner(
cfg: ApiConfig,
ownerAddress: string
): Promise<boolean> {
try {
const server = new StellarSdk.rpc.Server(cfg.rpcUrl);
let sourceAccount: { sequenceNumber(): string };
try {
sourceAccount = await server.getAccount(ownerAddress);
} catch {
return false;
}
const account = new StellarSdk.Account(ownerAddress, sourceAccount.sequenceNumber());
const contract = new StellarSdk.Contract(cfg.actaContractId);
const tx = new StellarSdk.TransactionBuilder(account, {
fee: StellarSdk.BASE_FEE.toString(),
networkPassphrase: cfg.networkPassphrase,
})
.addOperation(
contract.call(
'authorize_issuer',
StellarSdk.Address.fromString(ownerAddress).toScVal(),
StellarSdk.Address.fromString(ownerAddress).toScVal()
)
)
.setTimeout(60)
.build();

const sim = (await server.simulateTransaction(tx)) as { error?: unknown };
if (sim.error) {
const errStr = String(sim.error);
if (/Error\(Contract,\s*#8\)/.test(errStr) || /VaultNotInitialized/i.test(errStr)) {
return false;
}
}
return true;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (/VaultNotInitialized/i.test(msg) || /Error\(Contract,\s*#8\)/.test(msg)) {
return false;
}
return false;
}
}

async function fetchApiConfig(params: { network: 'testnet' | 'mainnet'; apiKey?: string }) {
return actaFetchJson<ApiConfig>({
network: params.network,
Expand Down