Privacy and Security Architecture
Verify this document at the code level
Sanitised, cross-referenced code excerpts for every mechanism described in this document are published in our public repository:
Table of Contents
1. Introduction: From Promise to Simple Facts
A privacy promise is cheap. Any website can claim to be "100% GDPR-compliant", that it "stores no data", or that "all processing takes place within the EU". Public security tests — A+ ratings, 100% badge scores — measure the transport layer (TLS configuration, HTTP headers, certificates), but say nothing about what happens under the bonnet: how the application actually handles user data during processing.
This document is designed to bridge that gap. It is not a marketing piece — it is architectural evidence. For a knowledgeable reader — an auditor, security professional, or developer — it makes the claims the platform presents in its user interface verifiable at the code level. Where possible, sanitised code excerpts demonstrate how each mechanism works. The excerpts focus on the key decision points; they do not expose the full source code, but they are sufficient to show exactly where each privacy decision is made.
How to read this document: This document is deliberately nuanced. It presents not only strengths, but also consciously accepted trade-offs. Real engineering systems always involve trade-offs; naming them openly — rather than concealing them — is precisely what distinguishes an auditable system from a marketing claim.
The platform comprises three independent applications, each facing a different privacy challenge, and each applying a different architectural strategy suited to that challenge:
- Interview & Speech Transcriber — processing large audio files. Challenge: file size and long processing time. Answer: EU data-handling guarantees and multi-layer deletion assurance.
- Bank Statement and Invoice Converter — processing financial documents. Challenge: extremely sensitive personal data. Answer: data minimisation and user-verifiable, irreversible redaction.
- Secure Academic Proofreader — analysing academic texts. Challenge: the full document content as sensitive data. Answer: session-level privacy and client-side encryption.
2. Shared Architectural Foundations
Before examining the individual applications, it is worth reviewing the security and privacy mechanisms that run throughout the entire platform. These are implemented once at the backend (server.js) level, and every application inherits them.
2.1. The GDPR Compliance Guard — the server cannot start with an invalid configuration
In most systems, compliance is a matter of a configuration setting that can be misconfigured. Here, the backend imposes a start-up precondition: if the Google Cloud credential is missing or invalid, the process halts immediately. The application is physically incapable of starting in a broken state.
// GDPR COMPLIANCE GUARD — mandatory check at start-up
// If this block throws, the server CANNOT start.
if (!process.env.GOOGLE_CREDENTIALS_JSON) {
console.error('FATAL: GOOGLE_CREDENTIALS_JSON is not set. ' +
'Server cannot start due to EU GDPR compliance requirements.');
process.exit(1);
}
try {
JSON.parse(process.env.GOOGLE_CREDENTIALS_JSON);
} catch (e) {
console.error('FATAL: GOOGLE_CREDENTIALS_JSON is not valid JSON.');
process.exit(1);
}
Excerpt from the backend start-up sequence. The process.exit(1) guarantees there is no intermediate "started but misconfigured" state.
This is complemented by explicitly pinning the Vertex AI to the EU region. The AI SDK is initialised with the location: 'eu' parameter, which constrains all data processing to EU multi-region infrastructure:
// Strict EU-only Vertex AI routing (100% EU Jurisdiction)
ai = new GoogleGenAI({
vertexai: true,
project: process.env.GOOGLE_CLOUD_PROJECT,
location: 'eu'
});
All AI calls remain within EU jurisdiction. From an auditor's perspective, this claim is directly verifiable from the code.
Auditor's note: "EU-only processing" here is not a statement of intent — it is an invariant enforced by the code. Compliance is not a runtime toggle: if the EU credentials are missing, there is no running server to audit.
2.2. Organisation-Level Resource Location Policy — EU Enforcement Independent of Application Code
The GDPR Compliance Guard (2.1) is a safeguard inside the application: it prevents the code from starting in a non-compliant state. But a safeguard that lives only in application code can, in principle, be bypassed by a misconfigured deployment, a new service added later, or a developer mistake. The platform closes this gap one layer below the application, at the Google Cloud Organization itself.
An Organization Policy constraint (gcp.resourceLocations) is set at the root of the Google Cloud organisation, with enforcement explicitly set to override the parent policy rather than merely inherit Google's default — a deliberate hardening decision, not a passive default:
Constraint: gcp.resourceLocations ("Resource Location Restriction")
Applies to: the Google Cloud Organization (root level)
Policy source: Override parent's policy
Enforcement: Replace (ignore parent's policy, use these rules)
Rule: Allow "in:eu-locations"
Every project under the organisation — including any created in the future — automatically inherits this constraint as "Policy source: Inherit parent's policy". No project owner can opt out of it without organisation-level administrative rights.
The in:eu-locations value is a Google-managed location group that expands, at evaluation time, to the full set of EU regions, zones, and multi-region aliases (e.g. europe-west1, europe-west3, europe-west4, europe-west8–europe-west12, europe-north1, europe-north2, europe-central2, europe-southwest1, the multi-region alias eu, and per-member-state groupings such as de-locations and it-locations). Notably, the group correctly excludes European GCP locations that fall outside the EU — there is no europe-west2 (London, UK) and no europe-west6 (Zurich, Switzerland) anywhere in the allowed set. This confirms the guarantee tracks genuine EU legal jurisdiction, not merely geographic proximity to Europe.
In practice, this constraint means that any attempt to provision a regionable resource — a storage bucket, for instance — outside an EU location fails at creation time, rejected by Google Cloud's resource-management layer before the resource ever comes into existence. This holds independently of, and prior to, any application code: even a hypothetical bug or misconfiguration in server.js could not cause data to be stored outside the EU, because there would be no non-EU location left available to provision.
The storage buckets used by the platform are configured consistently with this policy: multi-region location eu, uniform bucket-level access control, and public access prevention enabled (the buckets are never publicly reachable). As a further hardening measure, customer-supplied encryption keys (CSEK) are explicitly restricted on the bucket used for audio processing, leaving only Google-managed or Cloud KMS-managed keys as permitted encryption options — removing the operational risk of a lost or mismanaged externally-supplied key.
Auditor's note: This is the clearest possible illustration of "privacy by design, not by policy document": the EU-only guarantee does not rest on a promise, a contractual clause, or solely on application code — it is enforced structurally by the cloud provider's own access-control layer, at the level of the entire organisation, and cascades automatically to every current and future project beneath it.
2.3. Defence Middleware: Helmet, CSP and Attack-Pattern Filtering
The backend uses the helmet package to enforce a strict Content Security Policy (CSP). The core principle is defaultSrc: 'none' — everything is blocked by default, and only explicitly whitelisted sources may run. Scripts may only be loaded from the application's own origin ('self'), and styles likewise — with a single, precisely pinned exception described below. This drastically reduces the XSS attack surface.
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
scriptSrc: ["'self'"],
// Hash of the single inline style block that prevents FOUC on the
// landing pages. An exact, static content hash — NOT 'unsafe-inline'.
styleSrc: ["'self'", "'sha256-2PEbkGLmtHfzMevGDD4W1C0L4JGlMm5gslo3JqC4g7M='"],
imgSrc: ["'self'", "data:", "blob:"],
mediaSrc: ["'self'"],
connectSrc: ["'self'", "blob:", "https://storage.googleapis.com"],
workerSrc: ["'self'", "blob:"],
fontSrc: ["'self'"],
manifestSrc:["'self'"],
baseUri: ["'self'"],
formAction: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
}
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'no-referrer' },
frameguard: { action: 'deny' }
}));
The CSP is whitelist-based: connectSrc allows only the application's own server and Google Cloud Storage — precisely what the Signed URL architecture requires, and nothing more.
Two details are worth stating precisely, because both are visible in the live response header. First, styleSrc is not simply 'self': it also carries the SHA-256 hash of one specific inline style block — the one that prevents a flash of unstyled content on the landing pages. A hash pins that exact byte sequence and nothing else; any other inline style remains blocked. This is materially stronger than the 'unsafe-inline' most sites reach for in the same situation. Second, the policy goes beyond the directives usually shown in examples: baseUri and formAction are pinned to 'self', which blocks base-tag injection and form-action hijacking; fontSrc: 'self' is what actually enforces the no-external-fonts claim made above; and workerSrc permits blob: because pdf.js renders inside a worker created from a blob URL.
The platform serves all JavaScript libraries and fonts locally (noted in the CSS as Zero Tracking). There are no external CDN calls and no third-party scripts loaded — a benefit that is simultaneously a privacy advantage (no external tracking) and a security advantage (no supply-chain risk through an external CDN).
The backend also runs a lightweight URL-level attack-pattern filter (against path traversal, SQL injection, and XSS patterns), and blocks by user-agent those AI scrapers that do not respect the robots.txt file.
2.4. Rate Limiting — Tuned per Endpoint
Every sensitive endpoint sits behind its own rate limiter, calibrated to the risk profile of that endpoint. Token verification and uploads are subject to stricter limits than general API calls; feedback submission is the most restrictive of all (five requests per hour) to prevent spam.
const apiLimiter = rateLimit({ windowMs: 15*60*1000, max: 150 });
const tokenLimiter = rateLimit({ windowMs: 15*60*1000, max: 30 });
const feedbackLimiter = rateLimit({ windowMs: 60*60*1000, max: 5 });
const transcribeLimiter = rateLimit({ windowMs: 15*60*1000, max: 30 });
const proofreadLimiter = rateLimit({ windowMs: 15*60*1000, max: 40 });
const statusLimiter = rateLimit({ windowMs: 60*1000, max: 120 });
The limiters are differentiated: status polling is permissive (frequent, low risk); feedback is extremely strict (abuse-prone).
2.5. Account-Free, Anonymous Access: the Credit Pack Model
The platform deliberately avoids user accounts. There is no registration, no email address, no password. Access is provided by a Credit Pack Code (format: SAS-XXXX-XXXX-XXXX), which is tied to a simple credit balance in the server-side database. This is a data-minimisation decision: the system simply does not hold personal identifiers, because it does not collect them.
The free trial allocation uses IP-address-based abuse prevention, but the raw IP address is never stored. Instead, a salted SHA-256 hash — combined with the current date — is computed:
const today = new Date().toISOString().split('T')[0];
const salt = process.env.FREE_TOKEN_SALT;
const ipHash = crypto.createHash('sha256')
.update(clientIp + today + salt)
.digest('hex');
// The raw IP is never stored — only the hash.
The IP address is immediately converted into a one-way hash. Because the current date is embedded in the hash input, the resulting hash changes every day, making it unsuitable for long-term tracking across days — even though the salt itself is a static, server-side value.
Auditor's note: The anonymous Credit Pack model surpasses traditional account-based systems from a privacy standpoint: there is no password database to breach, no email list that can leak, and payment (Creem) is decoupled from usage. At the moment of use, the system does not know who the user is.
3. Interview & Speech Transcriber
The challenge: processing large audio files (interviews potentially hours in length), which may contain conversations, personal opinions, and third-party data. Processing takes a long time, and file sizes preclude simple synchronous handling.
The architectural answer: the audio file goes directly to Google Cloud Storage (GCS) via a time-limited, signed upload URL, it remains in the EU, and it is deleted through multiple independent mechanisms once processing is complete.
3.1. The Signed URL Architecture — the Secure Academic Studio backend server never sees the user's audio file
This is the most important decision in the design. The typical transcription service flow: the user uploads the file to the provider's backend, which stores it and then forwards it to the AI. Here, by contrast, the file goes directly to Google Cloud Storage (GCS) via a time-limited, signed upload URL (Signed URL). The backend only generates the URL — the upload itself never passes through it, and no request this endpoint handles ever contains the file's bytes.
Step 1 — the backend generates a signed URL (without receiving the file):
app.post('/api/transcribe/get-upload-url', transcribeLimiter, async (req, res) => {
const { token, fileName, contentType } = req.body;
// Token check (does the wallet exist?)
const wallet = await db.get('SELECT credits FROM wallets WHERE token = ?', [token]);
if (!wallet) return res.status(404).json({ error: 'CREDIT_ERROR' });
const objectName = `pending/${Date.now()}_${rand}.${ext}`;
// v4 signed URL, 'write' permission, 1-hour expiry
const options = { version: 'v4', action: 'write',
expires: Date.now() + 60*60*1000, contentType };
const [uploadUrl] = await gcsStorage.bucket(GCS_BUCKET)
.file(objectName)
.getSignedUrl(options);
// Register the GCS object in the lifecycle tracker
await db.run('INSERT INTO gcs_lifecycle_tracking ' +
'(object_name, created_at, status) VALUES (?, ?, ?)',
[objectName, Date.now(), 'uploading']);
return res.status(200).json({ uploadUrl, objectName });
});
The backend returns only a time-limited, write-scoped URL. The audio file's bytes never pass through it.
Step 2 — the browser uploads directly to GCS (from the client-side code):
// The frontend PUTs to the signed URL — directly to cloud storage
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl, true);
xhr.setRequestHeader('Content-Type', mimeType);
xhr.upload.onprogress = (e) => { /* update progress bar */ };
xhr.send(audioFile);
The data path: browser → GCS. The application's own backend is bypassed entirely.
Auditor's note: The value of this decision is worth stating precisely, because it is easy to overclaim. What the signed-URL flow removes is the upload path through our own infrastructure: the audio is never buffered in the backend's process memory as a side effect of the upload, never written to its filesystem, and never sits in a temporary upload directory that a compromised host could be mined for. An earlier version of this document went further and noted, honestly, that the backend still held the Cloud Storage credentials needed to read that object directly, and exercised that capability once, for the integrity check now described in 3.1.1. That is no longer the case for this flow. As described below, the integrity check runs inside a separate, single-purpose Cloud Run service under its own service account — one restricted, by an IAM Condition, to read-only access on the upload prefix and nothing else. The main backend process is never handed the object's bytes, and never holds a credential capable of reading them for this purpose; it only obtains a short-lived, narrowly-scoped identity token to call that separate service, and gets back a pass/fail verdict. This is a stronger property than delegation of access — it is removal of the read capability itself from the process that also terminates user sessions, issues signed URLs, and, for the platform's other tools, does legitimately read other GCS objects into its own memory (see 5.3).
3.1.1. Media Integrity Validation — performed by an isolated service, not by the backend
A filename and its extension are attacker-controlled: the client can claim anything. Before a single credit is deducted and before any AI call is made, the platform therefore checks the bytes actually stored in Cloud Storage — but it does not do so inside the main backend process. That check runs in sas-transcriber-media-validator, a separate, single-purpose Cloud Run service dedicated to exactly this task and nothing else.
That service has its own, narrowly-scoped identity, constrained by an IAM Condition to read-only access on the upload prefix of the bucket alone — it cannot reach any other object, prefix, or bucket. It requires authentication at the platform level (Cloud Run's own IAM) and never accepts an unauthenticated request. The main backend calls it over HTTPS, authenticating with a short-lived, five-minute identity token obtained by impersonating a second, invoker-only service account — a pattern chosen because organisational policy forbids issuing static keys for any service account. The backend sends only an object name and a claimed duration; the validator sends back a verdict. No audio byte ever appears in a request or response between the two.
// Inside the backend (server.js) — sends only names, never bytes
async function callMediaValidator(objectName, durationSec) {
const impersonated = await getImpersonatedClient(); // cached helper, omitted here
const idToken = await impersonated.fetchIdToken(MEDIA_VALIDATOR_URL);
const resp = await fetch(`${MEDIA_VALIDATOR_URL}/validate`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${idToken}` },
body: JSON.stringify({ objectName, durationSec }),
});
return resp.json(); // { ok, reason? } — never the file's bytes
}
Most uploads are resolved by that single bounded read. Some containers, though, place their track metadata after the media payload rather than before it — a legitimate, common pattern usually described as "non-fast-start" — which can push that metadata past the 2 MB header prefix entirely. For that case, the parser computes the exact byte offset where the metadata begins, from sizes already declared in what it already has, and asks its caller for exactly one further bounded read (capped at 8 MB) targeted at that offset — never a blind re-download of the rest of the file.
// Inside the isolated validator service — never inside the backend
const HEADER_BYTES = 2 * 1024 * 1024; // first bounded read
const [headerBuf] = await file.download({ start: 0, end: rangeEnd });
const verdict = await validateAudioContainer({
buffer: headerBuf,
claimedExt: ext,
maxSecondaryReadBytes: 8 * 1024 * 1024, // hard cap on the follow-up
// Invoked by the parser itself, at most once — only if the header
// prefix alone didn't contain a full moov+trak (see above). This
// function doesn't decide that; it only fetches whatever byte range
// the parser computes and asks for.
fetchRange: async (start, endExclusive) => {
const end = Math.min(endExclusive, fileSize) - 1;
if (end < start) return null;
const [buf] = await file.download({ start, end });
return buf;
},
});
Two bounded reads, at most — never a download of the file. Both caps are enforced by the code, not by convention, and both happen entirely inside the validator's own process and memory.
The parser sniffs the container family (ISO-BMFF/MP4, EBML/WebM, Ogg, MP3), walks the track headers, and rejects the file if it finds a video track or if the bytes contradict the declared extension. On rejection — by the validator, or by the backend if the call to the validator itself fails or times out — the object is deleted from Cloud Storage on the spot, its lifecycle-tracking row is cleared, the request fails with HTTP 400, no AI call is made and no credit is spent. The contract between the two services is deliberately fail-closed in both directions: an ambiguous verdict, a validator error, and a network timeout are all treated identically to an explicit rejection.
Three things an auditor should note. First, the exact scope of the read: for any file larger than 2 MB, only the container header region — and, for a non-fast-start container, one further bounded region — is ever read, never the audio payload as a whole; for a file smaller than 2 MB, the first read already covers the entire object. Either way, that data reaches only the isolated validator service, never the backend. Second, the validator's own identity cannot reach anything outside the upload prefix, and cannot itself accept a request that isn't already authenticated by Cloud Run's platform layer — a compromise of the validator alone could not pivot to any other object or bucket. Third, the parser is deliberately dependency-free: there is no ffmpeg, no exiftool, no third-party media library in the chain, so untrusted input is never handed to a large external parser — historically one of the more productive sources of remote-code-execution bugs.
The purpose is not academic. A video file renamed to .mp3 would carry far more than audio: image frames, and frequently device identifiers and GPS coordinates in its metadata. Rejecting it at the door keeps it out of the AI pipeline entirely.
3.2. The Five-Layer Deletion Guarantee
Most services fulfil the "immediate deletion" promise with a single, potentially unreliable API call. Here, five independent layers — two enforced at the storage-infrastructure level, three enforced at the application-code level — ensure the audio file does not remain in cloud storage. In the overwhelming majority of cases, deletion completes at the second layer, within moments of processing finishing; the remaining layers exist purely as defence-in-depth, for the statistically rare case in which an earlier layer is bypassed.
3.2.1. Layer 1: Storage-Level Configuration — Soft Delete and Object Versioning Disabled
Before any application code runs, the storage bucket itself is configured so that a delete command is final. Google Cloud Storage's Soft Delete and Object Versioning features — which would otherwise retain a recoverable copy of a "deleted" object for a configured retention window — are explicitly disabled on the bucket used for audio processing.
Bucket protection settings:
Soft delete policy Off
Object versioning Off
Bucket retention policy None
With these settings off, a .delete() call issued by any of the layers below is truly final — there is no soft-deleted or versioned copy left recoverable in the bucket.
3.2.2. Layer 2: the processing job's finally block
The background processing function deletes the audio file in a finally block — meaning the deletion runs whether the AI processing succeeds or fails. There is no error path that leaves the file in cloud storage.
} finally {
let gcsDeleted = false;
if (gcsStorage) {
try {
await gcsStorage.bucket(GCS_BUCKET).file(objectName).delete();
gcsDeleted = true;
console.log('Cleanup: Audio permanently deleted from GCS.');
} catch (e) {
if (e.code === 404) gcsDeleted = true; // already gone
else console.error('GCS Cleanup failed for object.');
}
}
if (gcsDeleted) {
await db.run('DELETE FROM gcs_lifecycle_tracking ' +
'WHERE object_name = ?', [objectName]);
}
}
The finally block is guaranteed to execute in JavaScript's error-handling model. The 404 case (file already deleted) is treated as a successful deletion — idempotent behaviour.
3.2.3. Layer 3: Client-Initiated Deletion Confirmation Flow
This layer is not a single fire-and-forget request. It is a staged, fully transparent confirmation flow that plays out visibly inside the progress modal itself, escalating in scope and persistence until deletion is either confirmed, or explicitly handed off — with the user's knowledge — to Layers 4 and 5.
Step 1 — the first attempt, immediately after the transcript is ready. The client sends a single deletion request for the result object. The endpoint verifies that the request comes from the legitimate owner (by requiring both job_id and token to match), and only then proceeds with deletion.
app.post('/api/transcribe/delete-result', transcribeLimiter, async (req, res) => {
const { jobId, token } = req.body;
// Only the legitimate owner may delete (job_id AND token must match)
const job = await db.get('SELECT * FROM transcription_jobs ' +
'WHERE job_id = ? AND token = ?', [jobId, token]);
if (!job) return res.status(404).json({ error: 'Job not found' });
const resultObjectName = `results/${jobId}.json`;
await gcsStorage.bucket(GCS_BUCKET).file(resultObjectName).delete();
await db.run('DELETE FROM gcs_lifecycle_tracking ' +
'WHERE object_name = ?', [resultObjectName]);
return res.status(200).json({ success: true, deleted: true });
});
The ownership check prevents anyone else from deleting (or probing the existence of) another user's job through the deletion endpoint. In the overwhelming majority of sessions, this single request already succeeds, and the user never sees anything beyond a normal completion screen.
Step 2 — if Step 1 fails, an automatic, wider-scope retry cycle. Should the first request not return a confirmed deletion (network failure, timeout, transient server error), the client does not simply give up or silently move on. It automatically launches a retry cycle against a second, broader endpoint that sweeps every Cloud Storage object still tracked for that job — not only the single result file targeted in Step 1 — up to three times, with increasing back-off between attempts:
const delays = [1000, 2000, 3000]; // ms
for (let i = 0; i < 3; i++) {
// UI: "Retrying deletion ({i+1}/3)..."
await sleep(delays[i]);
const res = await fetch('/api/transcribe/force-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId, token })
});
const data = await res.json();
if (res.ok && data.deleted) { success = true; break; }
}
The force-delete endpoint re-queries the lifecycle-tracking table for the job and deletes every object still registered against it, rather than assuming only one file could possibly remain. The retry count and countdown are surfaced live in the progress modal — the user is watching the actual retry attempts happen, not a generic spinner.
Step 3 — if all three retries fail, an explicit, informed choice for the user. The platform does not quietly fall back to the later layers without telling the user. If Step 2 exhausts its three attempts, the modal switches to an alert state with the message "Transcript is ready, but immediate deletion from the cloud could not be verified. The server will try again automatically within approximately 4 hours." — and presents two explicit actions:
- Retry manual deletion — re-runs the full three-attempt cycle from Step 2 on demand.
- I accept automatic deletion, proceed — closes the modal and knowingly defers the outstanding deletion to Layer 4 (the hourly Orphan Sweeper — see 3.2.4 for why its practical worst case is closer to four hours than to one) and, as the ultimate backstop, Layer 5 (the Cloud Provider Lifecycle Rule).
Auditor's note: This is a rare degree of transparency for a consumer-facing tool: rather than hiding a rare failure mode behind an optimistic success screen, the interface surfaces the retry attempts as they happen and, in the residual failure case, hands the user an explicit, informed choice rather than a false assurance. Functionally, choosing either option leaves the file in exactly the same place — awaiting deletion by Layer 4 or Layer 5 — the difference is that the user knows it, rather than being told deletion is "immediate" when, in this edge case, it is not yet.
3.2.4. Layer 4: the Orphan Sweeper (hourly cleanup job)
If, for any reason (network error, browser crash), a file remains in cloud storage, a background process running every hour detects and deletes objects older than 3 hours.
async function orphanSweeper() {
const threshold = Date.now() - (3 * 60 * 60 * 1000); // 3 hours
const orphans = await db.all(
'SELECT object_name FROM gcs_lifecycle_tracking WHERE created_at < ?',
[threshold]);
for (const orphan of orphans) {
await gcsStorage.bucket(GCS_BUCKET).file(orphan.object_name).delete();
await db.run('DELETE FROM gcs_lifecycle_tracking ' +
'WHERE object_name = ?', [orphan.object_name]);
}
}
setInterval(orphanSweeper, 60 * 60 * 1000); // every hour
The lifecycle-tracking table (gcs_lifecycle_tracking) ensures no object can be "lost" without a record.
The three-hour threshold is not an arbitrary round number: it deliberately matches the platform's own maximum permitted recording length (the Transcriber accepts audio up to three hours long). Setting the threshold any lower would risk the Sweeper mistaking a still-legitimately-processing, maximum-length job for an abandoned one, and deleting its source file while it is still being read by the AI. Because the sweep itself runs on an hourly cycle and only targets objects that have already crossed this three-hour mark, the practical worst case is not three hours but up to approximately four hours: an object only becomes eligible once it turns three hours old, and may then wait up to a further hour for the next scheduled sweep to actually remove it.
3.2.5. Layer 5: Cloud Provider Lifecycle Rule — the Statistical Last-Resort Failsafe
As a final backstop, a native Google Cloud Storage lifecycle rule independently deletes any object older than a day, entirely outside the application's own logic:
Lifecycle rule on the bucket:
Action: Delete object
Object condition: Age >= 1 day
This layer is deliberately positioned last because, in ordinary operation, it should never be the layer that actually performs a deletion: Layers 2–4 already remove the file within seconds of processing completion, or within at most a few hours if a client-side failure prevents on-demand erasure. Layer 5 only comes into play if all four preceding layers failed at once — a scenario with a vanishingly small probability in practice. Its purpose is not to be the primary deletion mechanism, but to guarantee an absolute upper bound that holds even in that unlikely case.
One technical nuance is worth stating precisely, for auditors: Google Cloud Storage evaluates lifecycle conditions once per day as a background process, not as a real-time trigger. In the worst theoretical case — and only if Layers 2–4 had already failed — an object could in principle persist for slightly under two days rather than exactly one. This does not weaken the guarantee in practice, since Layer 5 is never the layer relied upon for normal-case deletion; it simply means the precise outer bound of this specific, rarely-invoked failsafe is best described as "close to 24 hours", not "exactly 24 hours".
GDPR connection: Together, the five layers constitute an auditable, documentable implementation of Article 5(1)(e) of the GDPR — the "storage limitation" principle. Two layers are enforced structurally at the storage-infrastructure level (independent of application code), and three are enforced within the application logic. Storage is restricted to the period strictly necessary for processing, and deletion is guaranteed through multiple independent mechanisms operating at both layers.
3.3. Client-Side Architecture: Parsing and Export
Hybrid duration parser. Before anything is sent to the server, the browser reads the audio file's duration at the binary level (OGG granule position, MP4 mvhd atom, MP3 Xing/Info frame). Length and size validation therefore happens client-side, before upload — a file that is too long or invalid never starts its journey to the cloud.
Client-side export. Exporting the completed transcript to TXT, DOCX, and PDF takes place entirely in the browser (using locally loaded docx.js and pdf-lib libraries). The transcript content itself arrives in the browser directly from GCS via a signed URL, and the exported document never returns to the backend server.
3.4. Accepted Trade-Off
Nuance — what an auditor would raise: The Gemini model accesses the audio file in GCS via a
gs://URI, meaning Google (as a data processor) has access to the content during processing. This is covered by Vertex AI Enterprise contractual guarantees: the API payload is not used for model training, and the data remains in the EU. This is not a hidden risk — it is an openly managed, documented data-processing relationship governed by Google Cloud's Data Processing Addendum (DPA). The platform's role is to minimise the window and scope of exposure to this relationship.
4. Bank Statement and Invoice Converter
The challenge: processing bank statements — some of the most sensitive financial and personal data in existence: names, addresses, account numbers, transaction history.
The architectural answer: sensitive data is technically incapable of leaving the user's device before the user's explicit confirmation — and this is verifiable by the user. The original file itself, including its metadata, never leaves the device at all; only the canvas-rendered version the user has approved is transmitted.
The Bank Statement Converter and the Invoice Converter share the same underlying architecture, client-side redaction mechanism, and server-side safeguards described in this section — everything below (4.1–4.4) applies identically to both tools.
4.1. Client-Side Pixel Destruction — Redaction as an Irreversible Operation
PDF processing begins entirely in the browser. pdf.js (a locally loaded library) renders the PDF on a Canvas element on the client's machine. The user draws black boxes over sensitive fields, and the code burns those boxes directly into the Canvas pixels via ctx.fillRect(). This is not a CSS overlay and not a visual layer: the redacted pixels are physically destroyed.
// Burning the masks into the canvas pixels
const masksToDraw = [];
globalRedactions.forEach(m => masksToDraw.push(m));
if (pageRedactions[p]) pageRedactions[p].forEach(m => masksToDraw.push(m));
ctx.fillStyle = "black";
masksToDraw.forEach(mask => {
ctx.fillRect(
(mask.pctX / 100) * canvas.width, (mask.pctY / 100) * canvas.height,
(mask.pctW / 100) * canvas.width, (mask.pctH / 100) * canvas.height
);
});
// Flattening the redacted canvas into an image (the original PDF layer is discarded)
allImages.push(canvas.toDataURL('image/webp', 0.85));
The output is a flat WebP image. The original PDF's text layer, metadata, and redacted pixels are gone — they cannot be recovered.
The key distinction from market competitors: typical online PDF redaction tools upload the PDF to their server, where redaction takes place. At that point, the sensitive data — names, account numbers, addresses — has already left the user's device before redaction occurs. Here the opposite is true: the raw PDF never leaves the browser.
Auditor's note: This is one of the rare cases where a privacy claim is not a matter of trust, but a directly verifiable fact from the client-side code. By monitoring the browser's network traffic (DevTools → Network), one can confirm that the raw PDF never leaves the client in any form — only the flattened, canvas-rendered image is transmitted, reflecting whatever redactions the user has applied.
4.2. The Audit Payload ZIP — Verifiable Transparency
Before extraction begins, the user can download a ZIP package containing exactly the images that the AI will receive — the already-redacted WebP files. The user can therefore verify with their own eyes that the sensitive data has been removed before sending anything.
// Building the "Audit Payload": the same redacted images the AI will receive
for (let p = 1; p <= pdfDoc.numPages; p++) {
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
ctx.fillStyle = "black";
masksToDraw.forEach(mask => { ctx.fillRect(/* ...redaction... */); });
const base64Data = canvas.toDataURL('image/webp', 0.85).split(',')[1];
zip.file(`redacted_page_${p}.webp`, base64Data, { base64: true });
}
const content = await zip.generateAsync({ type: "blob" });
// Downloaded to the user's device for inspection
The user can download and examine precisely what the AI will see. There is no discrepancy between what is previewed and what is submitted.
GDPR connection: This is an unusually strong technical implementation of Article 5(1)(a) of the GDPR — the "transparency" principle. Most systems fulfil transparency through a written statement; here the user can verify byte-for-byte what is submitted for processing.
4.3. Stateless Processing — the Principle of Proportionality
Unlike the Transcriber, the Converter does not use GCS, a lifecycle tracker, or an orphan sweeper — and this is a deliberate, correct decision. The redacted images travel to the backend as base64-encoded JSON in a single synchronous request; the backend immediately forwards them to the Gemini Vision model and returns the response. The data has no "residence" on the server: no database writes, no disk writes.
If the server were to crash mid-processing, the redacted images would simply be lost from memory — which in this case is precisely the desired behaviour. The architecture involves exactly as much infrastructure as is strictly necessary, and no more.
Credit integrity and automatic refund. The backend deducts credits using an atomic SQL operation:
// Atomic deduction: only succeeds if credits >= totalCost
const deduct = await db.run(
'UPDATE wallets SET credits = credits - ? ' +
'WHERE token = ? AND credits >= ?',
[totalCost, token, totalCost]);
if (!deduct || deduct.changes === 0) {
return res.status(402).json({ error: "CREDIT_ERROR: Insufficient credits." });
}
// ...AI call... on failure: rollbackCredits() returns the deducted credit
The atomic UPDATE eliminates race conditions: a negative balance cannot occur, even with concurrent requests.
Memory cleanup at the code level. After the API call payload is assembled, the code explicitly empties the array holding the images (allImages.length = 0), so that the base64 data does not remain unnecessarily in browser memory. This is not a required step — its presence indicates careful, data-aware development.
4.4. Accepted Trade-Off
Nuance — what an auditor would raise: Unlike the Transcriber, the images here pass through the application's own backend server (as base64 JSON) before reaching Gemini — there is no Signed URL. This is a consciously accepted simplification. The key point, however, is that what the backend sees is exactly the same flattened, canvas-rendered image the user has approved — not the original PDF, and not anything the user has not already had the opportunity to inspect and redact. Any redaction the user did apply was already burned into the pixels on the user's device before any network transmission. The image the backend sees is identical to the image the AI sees — and the same image the user was able to inspect in advance via the Audit ZIP.
4.5. The Batch Converter Variants — Inherited Architecture, Two Omitted Steps
The Batch Bank Statement Converter and the Batch Invoice Converter share the same backend as the single-file Converters described above — quite literally the same one. For a given document type, the single-file flow and the batch flow post to the same endpoint: /api/extract-statement-batch for bank statements, /api/extract-invoice-batch for invoices. The -batch suffix is historical and describes the payload shape, not the tool: the endpoint accepts the page images of one document per request, and the batch tool simply issues one such request per file, sequentially. This is worth stating precisely because it is directly observable: open DevTools in either tool and you will see the same request URL. It also means the EU-pinned Vertex AI client, the atomic credit deduction and rollback pattern, and the rate limiter are shared by construction rather than by convention — there is no second server-side code path that could drift out of sync with the first. Two client-side steps present in the single-file flow are deliberately absent here, for both variants:
- No local masking (4.1 is skipped). Each PDF page is still rendered to a canvas element in the browser via
pdf.js— the raw PDF file and its metadata never leave the device, exactly as in the single-file flow — but no redaction UI is offered. The rasterized WebP image is transmitted to the AI exactly as rendered, without the pixel-level destruction of personal data described in 4.1. - No Audit Payload ZIP (4.2 is skipped). Since there is no redaction step, there is nothing to verify before submission, so the pre-flight "Download Check File" preview is not offered. (The batch tool has its own "Export ZIP" button, but this operates only after processing completes — it bundles the resulting
.xlsxand.csvfiles together, and is unrelated to payload verification.)
Everything downstream of rasterization is identical to the single-file Converter, including its most important privacy property: the same stateless, no-persistence design described in 4.3. Like the single-file flow, the Batch Converter does not use GCS, a lifecycle tracker, or an orphan sweeper — the flattened image travels to the backend as base64 JSON in a single synchronous request and is never written to disk or database. There is no file, on either side of the request, for a deletion mechanism to act on — the guarantee here is not multi-layer deletion (as in the Transcriber, 3.2), but the absence of storage in the first place. Credits are deducted atomically with automatic rollback on failure, and the Vertex AI call is pinned to the EU region by the same server-side guard described in 2.1.
Accepted Trade-Off: Processing statements in bulk makes a manual, page-by-page redaction step impractical; the Batch Converter trades this away in exchange for throughput. The raw PDF still never leaves the browser in its original form — client-side rasterization remains — but the image transmitted to the backend is unmasked. Users who need to verify data removal before transmission should use the single-file Converter (4.1–4.2).
5. Secure Academic Proofreader
The challenge: analysing academic papers, research materials, and manuscripts. Here, the processed content itself is the sensitive data — intellectual property, unpublished research, and potentially personally identifiable text.
The architectural answer: session-level privacy and genuine client-side encryption, enabling the secure local storage of work in progress.
5.1. AES-GCM Encrypted .SAU Session File
This is the point at which the Proofreader goes beyond the privacy approach of the other two applications. The complete session state — document content, identified errors, accepted and rejected corrections — can be saved to a .sau file, which may optionally be protected with AES-256-GCM encryption, using the browser's native Web Crypto API. The key is derived from a user-supplied password via PBKDF2 (100,000 iterations, SHA-256) with a random salt.
async function deriveKey(password, salt) {
const keyMaterial = await window.crypto.subtle.importKey(
"raw", new TextEncoder().encode(password),
{ name: "PBKDF2" }, false, ["deriveKey"]);
return window.crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: salt, iterations: 100000, hash: "SHA-256" },
keyMaterial, { name: "AES-GCM", length: 256 }, false,
["encrypt", "decrypt"]);
}
// Encryption: random salt (16 bytes) + IV (12 bytes) + AES-256-GCM
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(pwd, salt);
const ciphertext = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv }, key, enc.encode(payload));
Standard, correctly parameterised cryptography: unique salt and IV for every save, authenticated encryption (GCM mode). The password and the derived key never leave the browser.
Auditor's note: This is the only one of the three applications capable of maintaining an encrypted, persistent state on the user's own machine — entirely bypassing the server. Encryption happens on the client; the key is never transmitted. Competing academic tools typically store session state on their own servers, with no client-side encryption option.
5.2. HTML Tokenisation — Separating Structure from Content
The DOCX is converted to HTML by mammoth.js in the browser. Before the content reaches the AI, the code replaces HTML tags with tokens ([__T_0__], [__T_1__], etc.). This serves two purposes simultaneously: it improves AI accuracy (the model does not conflate content with structure), and — from a privacy perspective — ensures that only the tokenised text is uploaded to GCS, without the DOCX's internal metadata (author, tracked-changes author names).
function tokenizeHtml(htmlStr) {
return htmlStr.replace(/<[^>]+>/g, (match) => {
// Emphasis tags are preserved (em, i, b, strong, sup, sub)
if (/^<\/?(em|i|b|strong|sup|sub)\b[^>]*>$/i.test(match)) return match;
// All other tags become tokens; the original is stored in a map
let token = `[__T_${globalTokenCounter}__]`;
tagMap[token] = match;
globalTokenCounter++;
return token;
});
}
The tag map (tagMap) remains in the browser. Detokenisation (reassembling the HTML) also happens client-side during export.
This is complemented by the Mammoth Artifact Cleaner, which strips internal footnote back-reference links generated during conversion, before the text enters processing — so that superfluous internal document identifiers do not reach the cloud.
5.3. Signed URL and the Content's Journey
As with the Transcriber, document content is uploaded directly to GCS via a signed URL — the backend never receives the upload itself. The /api/proofread/start call passes only metadata (character count, selected modules, language code) through the backend; the content does not travel with it.
One difference from the Transcriber must be stated precisely, because it is visible in the code. In the Transcriber, Gemini reads the audio straight from its gs:// URI. In the Proofreader the background job calls file.download() to pull the tokenised text from GCS into server memory, because the text has to be split into chunks and dispatched across several parallel AI calls — something that cannot be delegated to a storage URI. The text is held in RAM only; it is never written to disk or to a database, and the input object is deleted from GCS immediately after it has been read. The backend is therefore a processing step, not a storage step — but it is not accurate to say it never sees the text, and we prefer to say so plainly.
Integrity protection (fraud detection). Before processing begins, the backend fetches the actual size of the uploaded file from GCS and compares it with the character count declared by the client. A significant discrepancy causes the job to fail with a fraud_detected error. This simultaneously protects the credit system's integrity and prevents a payload substantially larger than declared from being processed.
Client-side export. Generating the corrected document and the errata report takes place entirely in the browser. The SmartReplace engine applies corrections to the tokenised content, detokenisation is client-side, and the finished HTML file never returns to the backend.
5.4. Accepted Trade-Off
Nuance — what an auditor would raise: The
.saufile encryption is optional: if the user does not provide a password when saving, the file contains unencrypted JSON. This is a deliberate, user-controlled decision (convenience vs. protection), but it should be explicitly documented in an audit. During processing, the full document text resides in a GCS object and is read into backend memory (5.3) — this is covered by exactly the lifecycle tracking and five-layer deletion guarantee described in the Transcriber section (3.2), Layer 3 included: the same staged flow of three automatic retries against a broaderforce-deleteendpoint, followed by an explicit user choice if all three fail.
6. Comparative Summary
The three applications do not copy the same template — each applies a proportionate privacy strategy suited to its own use case. This in itself is an auditor's virtue: applying the right level of protection — no more and no less — signals that genuine engineering judgement underlies the architecture.
| Transcriber | Converter | Proofreader | |
|---|---|---|---|
| Data processed | Audio file (large, long processing) | Financial PDF (maximum sensitivity) | Academic text (intellectual property) |
| Data path | Browser → GCS (backend bypassed) | The raw PDF never leaves the browser | Browser → GCS → backend RAM (never to disk) |
| Key mechanism | Signed URL + five-layer deletion | Client-side pixel destruction + Audit ZIP | AES-256-GCM .SAU + tokenisation |
| Strength | EU guarantees and deletion assurance | Data minimisation and verifiable redaction | Session privacy and client-side encryption |
In one sentence: the Transcriber excels at EU data-handling guarantees and deletion assurance; the Converter at data minimisation and verifiable redaction; the Proofreader at session privacy and client-side encryption.
6.1. Closing Auditor's Assessment
The platform's privacy and security architecture is not "checkbox compliance". Privacy considerations are built into the structure of the code — not added as an afterthought in a policy document. The mechanisms presented here — the Signed URL architecture, the five-layer deletion guarantee, client-side pixel destruction, the Audit ZIP, the AES-GCM session file, the GDPR Compliance Guard — are all decisions directly verifiable from the code.
That is precisely the purpose of this document: it does not claim that the applications are secure — it shows how they are secure, with enough detail to allow an auditor or developer to conduct an independent review. The code excerpts are sanitised (focused on the key mechanisms, not exposing the full implementation), but they are sufficient for the critical privacy decision points to be validated.
In summary: A promise is cheap; proof is expensive. This platform chose the expensive option: its privacy claims are implemented not in text, but in architecture and code — where a professional can verify them.
Secure Academic Studio — Architectural Documentation
Code excerpts in this document illustrate the key mechanisms and do not constitute the complete source code.