Plan: OCR-Verified Permohonan Ingest (Apostille / Legalisasi Rebuild)
Status: Proposal / design branch feature/ocr-verified-ingest. Nothing is wired into AppModule; the intended endpoint is dormant behind a feature flag. Author: Efran Nathanael, 2026-07-13. Codebase evidence (file:line) from ahu-apostille-api @ main (34c82c7).
1. What this is
An external OCR/AI verification service (AHU-AI team) receives a user redirected from the Apostille portal, has them upload documents, runs AI extraction including signature-specimen matching, and its own verifikators approve. On completion it must push the verified permohonan back into Apostille so the official record is created with all real side effects.
The OCR service is authoritative: Apostille should trust its verdict and skip its own Verifikator→Kasi manual review, but must still run finalization (no_permohonan numbering, PNBP billing voucher, Cetak/no_transaksi, certificate/stiker QR, notifications, email).
2. Decision: API call, not direct DB write
Use a new authenticated endpoint that calls Apostille's existing createPermohonan + verifikasiPermohonan. Do NOT write tables directly.
Finalization is a transaction-wrapped web of effects a row insert cannot reproduce: sequential counters (generateNomorPermohonan src/permohonan/permohonan.service.ts:432, generateNomorTransaksi src/verifikasi/verifikasi.service.ts:797) that desync on a raw insert; an external SIMPADHU billing call that produces the no_voucher/kodeBilling (verifikasi.service.ts:1731); S3 object moves + QR image generation (permohonan.service.ts:529-572, verifikasi.service.ts:809-870); notifications + email (permohonan.service.ts:638, verifikasi.service.ts:953); and spesimen-tangguhan→pejabat promotion (:890-940). A DB write yields a permohonan with no voucher, no certificate/QR, unmoved files, no notifications, and corrupted counters. So the endpoint delegates; the only schema change is additive metadata (§4).
3. Reachability + the one real blocker (PNBP token)
Both service methods take the acting user as an explicit argument, so server-to-server invocation works:
createPermohonan(id_user: number, request)—permohonan.service.ts:150. Withstatus_permohonan: 'dikirim'it numbers, creates the twoVerifikasiPermohonanrows, auto-assigns a verifikator by least-load (:461-493), moves S3 files, fires notifications + email. Ends atdikirim.verifikasiPermohonan(access_token, id_user, request)—verifikasi.service.ts:527.id_user= acting verifikator/kasi;access_tokenis used only for the SIMPADHU call.
Status machine (prisma/main/schema.prisma:127-138): draft → dikirim → verifikasi_verifikator → verifikasi_kasi → pencetakan. The Verifikator approval accepts both dikirim and verifikasi_verifikator (verifikasi.service.ts:572-576), so our orchestrator can go dikirim → verifikasi_kasi → pencetakan in two verify calls.
The crux: PNBP / SIMPADHU token at the Kasi step
The Kasi approval generates PNBP vouchers by POSTing ${BE_URL}/simpadhu/billing/v2 with Authorization: Bearer ${access_token} (verifikasi.service.ts:1731-1734). Key finding: the billing identity (NPWP, NIK, name, email, phone, wilayah) is read from the applicant's DB record, not from the token (:1750-1760) — the bearer is purely transport auth to the SIMPADHU gateway. So a machine push does not need "the user's" token to bill correctly; it needs any bearer SIMPADHU will accept.
Options:
1. Keycloak service-account token (recommended primary). Mint a client_credentials token from ${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/.../token for the apostille service client, pass it as access_token. Works iff the SIMPADHU gateway authorizes the service client — one integration confirmation.
2. Deferred finalize (recommended fallback). Our push advances to verifikasi_kasi and records our attestation; an Apostille Kasi performs the final paid click with their own token (existing UI, unchanged). Always works, zero SIMPADHU changes; cost = one human paid click stays in Apostille.
Both are the same code path with one env switch (OCR_FINALIZE_MODE=authoritative|deferred). If SIMPADHU will not accept a service bearer, full authoritative finalization is infeasible without an Apostille-operator step — and the deferred boundary is the clean one: we own verification, they own the paid finalization.
POST ingest
→ dedupe on ocr_submission_id (unique, §5)
→ createPermohonan(subject_user_id, { status:'dikirim', is_ocr, source:'ocr', ocr_submission_id, ... })
→ verifikasiPermohonan('', SYS_VERIFIKATOR_ID, { Verifikator, verified:true, keterangan:<attestation> }) // → verifikasi_kasi
→ if mode=authoritative: token = mintServiceToken();
verifikasiPermohonan(token, SYS_KASI_ID, { Kasi, verified:true }) // → pencetakan (+ PNBP, Cetak, QR)
else (deferred): stop at verifikasi_kasi
4. Auth + is_ocr marker
No service-to-service auth exists today (no API keys/HMAC/webhooks). The JWT guard is global (src/common/common.module.ts:50-51) and JwtStrategy.validate crashes on a service token lacking resource_access.djahu.roles / djahu_userId (src/auth/jwt.strategy.ts:22-32).
Recommendation: @Public() (bypasses the global JWT, public.decorator.ts + auth.guard.ts:12-19; precedent: permohonan.controller.ts:249 etc.) plus a route-scoped ApiKeyGuard (constant-time key compare, or HMAC over body+timestamp). Keep inbound machine auth (API key) separate from the outbound SIMPADHU service token (§3.1) — different concerns. (Independently, harden jwt.strategy.ts:24-26 with optional chaining so a malformed token yields a clean 401 instead of a 500.)
is_ocr marker mirrors the existing is_superapp external-channel precedent (schema.prisma:71, permohonan.validation.ts:209, persisted permohonan.service.ts:300):
// additive, nullable, defaulted → byte-identical to existing rows
is_ocr Boolean? @default(false)
source String? @db.VarChar(20) // 'web' | 'superapp' | 'ocr'
ocr_submission_id String? @unique @db.VarChar(64) // idempotency (§5)
5. Idempotency + upload precondition
No idempotency exists today — Permohonan has only its PK and id_spesimen_tangguhan @unique; nothing dedupes createPermohonan, so a retry would create a duplicate permohonan (and duplicate PNBP vouchers if it reached Kasi). Proposal: the ocr_submission_id @unique column (§4). Orchestrator findFirst({ where: { ocr_submission_id } }) first and returns the existing result; the DB unique is the race backstop (on P2002, re-read and return).
Upload precondition (dependency): createPermohonan requires the berkas to already exist as unbound DB rows (permohonan.service.ts:181-202, rejects fake/foreign ids). Files are created by the existing multipart POST /permohonan/upload, which stamps ownership from the JWT (uploadFiles(files, user.user_id)). So the OCR service must upload the documents first to obtain id_berkas — either reuse /permohonan/upload under the subject user's live session at redirect time, or add an api-key-guarded ingest-upload that stamps id_user = subject_user_id.
6. Side effects the endpoint must let fire (never replicate)
no_permohonan numbering (permohonan.service.ts:432), verifikator auto-assign (:461-493), QR surat pengantar + S3 (:352-428), S3 tmp→final (:529-572), notifications + email (:583-762); then PNBP vouchers (verifikasi.service.ts:557-563→generateManyNomorVouchers:1706), no_transaksi (:797), Cetak rows + no_voucher per copy (:872-885), certificate/stiker QR per copy (:809-870), spesimen promotion (:890-940), notif + email (:943-1091). (There is no no_legalisasi_sertifikat column in this rebuild — the finalization artifact is Cetak.no_transaksi + QR.)
7. Dormant / feature-flagged
Config here is bare process.env (no ConfigService; ConfigModule.forRoot({ isGlobal: true }) only loads .env, common.module.ts:35-37). The ApiKeyGuard throws NotFoundException unless OCR_INGEST_ENABLED === 'true', and it's mounted only on the new controller — so unset env ⇒ byte-identical current behavior. New keys:
OCR_INGEST_ENABLED=false
OCR_INGEST_API_KEY=
OCR_FINALIZE_MODE=deferred # 'authoritative' (mint service token → Kasi) | 'deferred' (stop at verifikasi_kasi)
OCR_SYSTEM_VERIFIKATOR_USER_ID= # real djahu user recorded on the Verifikator approval
OCR_SYSTEM_KASI_USER_ID= # real djahu user recorded on the Kasi approval
KEYCLOAK_SERVICE_CLIENT_ID= # outbound SIMPADHU service token (mode=authoritative)
KEYCLOAK_SERVICE_CLIENT_SECRET=
8. Module wiring
New src/ingest/ module. PermohonanService/VerifikasiService are not currently exported — add exports:[…] to PermohonanModule (permohonan.module.ts) and VerifikasiModule (verifikasi.module.ts). IngestModule imports both + HttpModule; register in app.module.ts. ApiKeyGuard is a route-scoped provider (@UseGuards), not an APP_GUARD.
9. Payload contract (what the OCR service must send)
ocr_submission_id (idempotency), application_type (Apostille|Legalisasi), subject_user_id (explicit applicant djahu id → Permohonan.id_user), applicant fields, berkas[] (pre-uploaded ids, §5), data_dokumen[], signer/pejabat (id_spesimen_pejabat or name/jabatan/instansi), and the verification attestation (verdict, verifikator name/id, signature-match score, verified_at). The attestation is echoed into the verifikasi keterangan so the trail records why it was auto-approved. A valid jumlah_cetak (1..5, verifikasi.service.ts:553-556) per document is required for the Kasi/voucher step.
10. Open items (confirm before build / go-live)
- SIMPADHU service-token acceptance (§3) — the single go/no-go for
authoritativemode. If no → shipOCR_FINALIZE_MODE=deferred. - Upload path (§5) — reuse
/permohonan/uploadunder the subject session vs. add an api-key ingest-upload. - System user ids —
OCR_SYSTEM_VERIFIKATOR_USER_ID/OCR_SYSTEM_KASI_USER_IDmust be realauthusers. is_ocr/ocr_submission_idmigration — additive, defaulted.- Harden
jwt.strategy.ts:24-26— optional chaining (defensive; currently 500s on a malformed/service token). - Policy re-decision — our schema records a 2026-06-23 "read-only integration only, never submit back to Apostille" note; this initiative reverses it and should be re-decided explicitly by the Apostille product owner.
Appendix A — Reference scaffold
Proposal skeletons kept in this doc (not committed under
src/) so the branch build stays green. Lift into the listed paths when implementing. Business logic delegated toPermohonanService/VerifikasiService; nothing reimplements finalization. TODOs mark where the final contract is needed.
prisma/main/schema.prisma — additive fields on model Permohonan
is_ocr Boolean? @default(false) // OCR/AI-verified origin (mirrors is_superapp:71)
source String? @db.VarChar(20) // 'web' | 'superapp' | 'ocr'
ocr_submission_id String? @unique @db.VarChar(64) // idempotency key (§5)
src/common/guards/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { timingSafeEqual } from 'crypto';
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
if (process.env.OCR_INGEST_ENABLED !== 'true') throw new NotFoundException(); // dormant
const req = context.switchToHttp().getRequest<Request>();
const provided = (req.headers['x-api-key'] as string) ?? '';
const expected = process.env.OCR_INGEST_API_KEY ?? '';
// TODO: prefer HMAC over (timestamp + raw body) with a replay window instead of a static key.
if (!expected || provided.length !== expected.length || !timingSafeEqual(Buffer.from(provided), Buffer.from(expected)))
throw new UnauthorizedException('Invalid API key');
return true;
}
}
src/ingest/keycloak-service-token.service.ts (outbound SIMPADHU token, §3.1)
import { HttpService } from '@nestjs/axios';
import { HttpException, Injectable } from '@nestjs/common';
import { firstValueFrom } from 'rxjs';
@Injectable()
export class KeycloakServiceTokenService {
constructor(private readonly httpService: HttpService) {}
/** client_credentials grant → bearer for BE_URL/SIMPADHU. Feasibility depends on SIMPADHU authorizing this client (§3). */
async mintServiceToken(): Promise<string> {
const url = `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`;
const params = new URLSearchParams({ grant_type: 'client_credentials',
client_id: process.env.KEYCLOAK_SERVICE_CLIENT_ID ?? '', client_secret: process.env.KEYCLOAK_SERVICE_CLIENT_SECRET ?? '' });
try {
const { data } = await firstValueFrom(this.httpService.post(url, params.toString(),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000 }));
return data.access_token; // TODO cache until exp-30s
} catch (e: any) { throw new HttpException(e.response?.data ?? 'Gagal memperoleh service token', e.response?.status ?? 502); }
}
}
src/ingest/ingest.service.ts (orchestrator — delegates everything)
import { Injectable } from '@nestjs/common';
import { PermohonanService } from 'src/permohonan/permohonan.service';
import { VerifikasiService } from 'src/verifikasi/verifikasi.service';
import { PermohonanRepository } from 'src/permohonan/permohonan.repository';
import { KeycloakServiceTokenService } from './keycloak-service-token.service';
import { ValidationService } from 'src/common/validation.service';
import { IngestValidation } from './ingest.validation';
@Injectable()
export class IngestService {
constructor(
private readonly permohonanService: PermohonanService,
private readonly verifikasiService: VerifikasiService,
private readonly permohonanRepository: PermohonanRepository,
private readonly serviceToken: KeycloakServiceTokenService,
private readonly validationService: ValidationService,
) {}
async ingestVerifiedPermohonan(request: any): Promise<any> {
const req = this.validationService.validate(IngestValidation.INGEST, request);
// 1) IDEMPOTENCY (§5): TODO permohonanRepository.findByOcrSubmissionId(req.ocr_submission_id) → return existing.
// 2) CREATE — delegates numbering/assign/S3/notif/email. id_user is explicit (permohonan.service.ts:150).
const created = await this.permohonanService.createPermohonan(req.subject_user_id, {
...req.applicant, data_dokumen: req.data_dokumen, berkas: req.berkas,
status_permohonan: 'dikirim', is_ocr: true, source: 'ocr', ocr_submission_id: req.ocr_submission_id,
// TODO destructure is_ocr/source/ocr_submission_id in createPermohonan like is_superapp (:170,:300), or set post-create.
} as any);
const idPermohonan = created.data_dokumen[0].id; // TODO confirm return→id mapping
const keterangan = `[OCR-VERIFIED] ${req.attestation.verifikator_name} score=${req.attestation.signature_match_score ?? 'n/a'} at ${req.attestation.verified_at}`;
// 3) VERIFIKATOR approve → verifikasi_kasi (token unused at this stage).
await this.verifikasiService.verifikasiPermohonan('', Number(process.env.OCR_SYSTEM_VERIFIKATOR_USER_ID),
{ id_permohonan: idPermohonan, tipe_verifikator: 'Verifikator', verified: true, keterangan });
// 4) KASI finalize — mode switch (§3 / §7).
if (process.env.OCR_FINALIZE_MODE === 'authoritative') {
const token = await this.serviceToken.mintServiceToken();
await this.verifikasiService.verifikasiPermohonan(token, Number(process.env.OCR_SYSTEM_KASI_USER_ID),
{ id_permohonan: idPermohonan, tipe_verifikator: 'Kasi', verified: true, keterangan });
return { message: 'finalized_pencetakan', data: { id_permohonan: idPermohonan } };
}
return { message: 'verified_pending_finalization', data: { id_permohonan: idPermohonan } }; // deferred (§3.2)
}
}
src/ingest/ingest.controller.ts
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import { ApiSecurity } from '@nestjs/swagger';
import { Public } from 'src/common/decorators/public.decorator'; // bypass global JwtAuthGuard (§4)
import { ApiKeyGuard } from 'src/common/guards/api-key.guard';
import { IngestService } from './ingest.service';
import { IngestPermohonanRequestDto } from './dto/ingest.dto';
@Controller('/ingest') @Public() @UseGuards(ApiKeyGuard) @ApiSecurity('x-api-key')
export class IngestController {
constructor(private readonly ingestService: IngestService) {}
@Post('/permohonan') @HttpCode(200)
async ingestPermohonan(@Body() request: IngestPermohonanRequestDto) {
const result = await this.ingestService.ingestVerifiedPermohonan(request);
return { message: result.message, data: result.data };
}
}
src/ingest/ingest.module.ts (+ ingest.validation.ts, dto/ingest.dto.ts)
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { IngestController } from './ingest.controller';
import { IngestService } from './ingest.service';
import { KeycloakServiceTokenService } from './keycloak-service-token.service';
import { ApiKeyGuard } from 'src/common/guards/api-key.guard';
import { PermohonanModule } from 'src/permohonan/permohonan.module'; // add exports:[PermohonanService]
import { VerifikasiModule } from 'src/verifikasi/verifikasi.module'; // add exports:[VerifikasiService]
import { PermohonanRepository } from 'src/permohonan/permohonan.repository';
@Module({
imports: [HttpModule, PermohonanModule, VerifikasiModule],
controllers: [IngestController],
providers: [IngestService, KeycloakServiceTokenService, ApiKeyGuard, PermohonanRepository],
})
export class IngestModule {}
Validation (Zod, following validation.service.ts): INGEST = z.object({ ocr_submission_id: z.string().max(64), application_type: z.enum-like, subject_user_id: z.number(), applicant, data_dokumen: z.array().min(1), berkas: z.array({id_berkas}).min(1), attestation: { verified: literal(true), verifikator_name, signature_match_score?, verified_at } }). Register IngestModule in app.module.ts; add findByOcrSubmissionId to PermohonanRepository; add the schema fields + migration.