diff --git a/.env.example b/.env.example deleted file mode 100644 index 783e58c..0000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -# Impacta Bootcamp Template -# Secret issuance code required to issue Impacta certificates (server-side only) -IMPACTA_BOOTCAMP_CODE= diff --git a/src/app/api/verify-issuance-code/route.ts b/src/app/api/verify-issuance-code/route.ts index 34d8b31..5189aa7 100644 --- a/src/app/api/verify-issuance-code/route.ts +++ b/src/app/api/verify-issuance-code/route.ts @@ -1,23 +1,64 @@ import { NextResponse } from 'next/server'; +const API_URLS: Record = { + 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).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 } + ); } } diff --git a/src/components/modules/issue/hooks/useIssueCredential.ts b/src/components/modules/issue/hooks/useIssueCredential.ts index c373280..82b87f1 100644 --- a/src/components/modules/issue/hooks/useIssueCredential.ts +++ b/src/components/modules/issue/hooks/useIssueCredential.ts @@ -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'; @@ -50,29 +50,36 @@ export function useIssueCredential() { }); const [issuanceCodeValid, setIssuanceCodeValid] = useState(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 @@ -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.'; @@ -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) { @@ -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 })); @@ -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({ diff --git a/src/components/modules/issue/ui/DynamicIssueForm.tsx b/src/components/modules/issue/ui/DynamicIssueForm.tsx index 769db3d..787cdda 100644 --- a/src/components/modules/issue/ui/DynamicIssueForm.tsx +++ b/src/components/modules/issue/ui/DynamicIssueForm.tsx @@ -175,7 +175,35 @@ export default function DynamicIssueForm({ ))} - {isImpacta ? ( +
+ + 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 && ( +

{keyValidationError}

+ )} + {validatingKey &&

Validating API key...

} +

+ {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.'} +

+
+ + {isImpacta && (
- ) : ( -
- - 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 && ( -

{keyValidationError}

- )} - {validatingKey && ( -

Validating API key...

- )} -

- 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. -

-
)}
diff --git a/src/components/modules/vault/hooks/use-vault.ts b/src/components/modules/vault/hooks/use-vault.ts index 9a794b8..00b2416 100644 --- a/src/components/modules/vault/hooks/use-vault.ts +++ b/src/components/modules/vault/hooks/use-vault.ts @@ -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 { + 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({ network: params.network,