Hawk Ai Log in

Changelog

Everything shipped, newest first.

Everything shipped, newest first. Dates are the day the change went live on https://aisnsiodev-kyc.hf.space.

2026-09-15 — single sign-on, real invoices, bulk work, and an audit of all 97 endpoints

Added

  • Single sign-on over OpenID Connect. Authorization code with PKCE, and no dependency: server/oidc.js does discovery, the token exchange, and verifies the id_token against the provider's published JWKS — signature, issuer, audience, expiry and the nonce we generated. alg: none and HMAC algorithms are refused outright. It works with Okta, Entra, Google Workspace, Auth0 or Keycloak; set OIDC_ISSUER, OIDC_CLIENT_ID and OIDC_CLIENT_SECRET. Two deliberate refusals: an address the provider vouches for that has no account here is refused rather than created (an SSO login that provisions its own accounts turns a misconfigured directory into a way in), and email_verified: false is refused, because an unverified address from a directory with self-service sign-up is an impersonation route. GET /v2/auth/sso/status tells the sign-in page whether to offer the button at all.
  • Real usage and real invoices. GET /v2/tenant/usage quotes the current period; GET /v2/tenant/invoices lists one invoice per closed month, written once. Counted from the applicant records rather than from a counter, because a counter drifts and a billing number that drifts cannot be defended in front of the customer it overcharged. The prices mirror the pricing page, and a verification counts when it reached a decision — which is what that page says.
  • Self-serve plan change. POST /v2/tenant/plan: Starter and Growth apply at once and are invoiced; Enterprise is recorded as a request, because it is a contract rather than a setting. No card is taken anywhere — no payment provider is connected, and holding card details in a system that cannot process them would be worse than invoicing. The reply says so.
  • The console's billing screen is now real for a signed-in operator: their plan, their usage, their invoices, their daily volume. The sample world's priced month stays for the demo, and the two no longer share a screen.
  • Bulk actions. POST /v2/applicants/bulk assigns, escalates, requests a resubmission and annotates up to 200 applicants at a time, each one through the same decision gate as a single action. approve and reject return 422 with the reason: a bulk approve is how one mistake becomes two hundred, and a bulk reject decides against that many people at once. POST /v2/alerts/bulk clears alerts — work items rather than decisions — and closing one still needs a reason.
  • DATA_DIR moves the live database, so two instances can run on one machine. A test suite and a development server used to share one file, and the loser of that race silently lost its writes.

Fixed — two found by writing the audit

  • Re-issuing an invitation did not kill the previous one. The usual reason to re-issue is that the first link went to the wrong address or leaked, so leaving it live meant the act of replacing it changed nothing. Issuing now supersedes every earlier unused link for that person, marked used rather than deleted so the history is still answerable.
  • A password reset with no address returned 200. An address that is not an address cannot belong to anybody, so saying so reveals nothing — while whether a *well-formed* address has an account stays unknowable, which is the part that matters.
  • The bulk response had skipped twice in one object literal, so the count callers read was silently an array.
  • The console fired a 401 on every anonymous visit, because the go-live checklist asked for a workspace that needs a session. Noise in a console log hides the errors worth seeing.
  • A real operator's billing page could still show the sample client's name in its heading: the redraw after adopting the real workspace did nothing when it arrived before the first route had finished rendering.
  • Translations could be missed on screens that only exist when signed in. The billing screen renders entirely from the API, so an anonymous sweep never saw its strings — which is how a panel of English got built. 34 strings are now translated into all 13 languages, including three sentences the server itself sends, and the sweep signs in to check four authenticated surfaces.

Fixed after the fact

  • The audit was run against production, and it writes. It signed up a test client, created applicants and left a webhook, which satisfied two go-live readiness items with data nobody put there — /readyz briefly reported this deployment as readier than it is, which is the exact failure the readiness endpoint exists to prevent. The two snapshots containing that data were dropped, the deployment was rebuilt from the last clean one, and the suite now refuses a non-local target unless ALLOW_REMOTE=1 is set deliberately.

Also fixed

  • The translation sweep was measuring too early over the network. It slept six seconds; production needs about nine before the dictionary is fetched and applied, so fully translated pages read as 42% — the untranslated half had simply not been replaced yet. The page now reports when the dictionary is applied (data-i18n-ready on the root element) and the sweep waits for that signal instead of guessing, with a ceiling so a page that never signals still fails rather than hanging. A guess that is fine on localhost is three seconds short over the internet, and the number it produces looks exactly like a regression.

Verified

  • tools/test/api-audit.js — all 97 endpoints, 250 assertions. Every route is exercised for the happy path, the unauthenticated path and malformed input; the case list is compared against the route table parsed out of server/api.js, so a new endpoint fails the test until somebody writes a case for it. Coverage that has to be remembered is coverage that rots.
  • tools/test/sso-flow.js starts a stub identity provider — discovery, JWKS, a real RS256 id_token — and runs the whole ceremony plus every refusal: a tampered signature, alg: none, the wrong audience, a stale nonce, an expired token, an unverified address, a domain outside the allow-list, and a replayed callback. 23 checks.
  • 143 browser rows (13 languages × 11 surfaces, four of them signed in): 100% translated, no horizontal overflow, 0 JavaScript errors.
  • Passkeys, the applicant capture flow and sanctions precision all re-run unchanged: 6/6 recall, 2/23 referral.

2026-09-14 (end of day) — fingerprinted assets, and the mobile path written down

Fixed

  • Translations could be left queued while a tab was in the background. The observer flushed on requestAnimationFrame, which is the right moment — it runs before the browser paints, so nothing is ever seen in English first — but it does not run at all while a tab is hidden. Content inserted in that state stayed English until the tab came back. A timer now races the frame callback, and whichever fires first wins. Caught by the live language sweep reporting 94% on the sign-in page, where an alert ticker inserts a row every three seconds.

Added

  • Fingerprinted assets. Every /assets/… reference in a served page gets ?v=<hash of the file's contents> appended, and a request carrying a version is answered with a year of immutable. A changed file is a changed URL, so a browser may keep the old one for ever without ever showing a stale bundle. Query fingerprints rather than hashed filenames on purpose: the rewrite happens as the page is served, so the files on disk keep the names the repository and every editor use, and a mis-rewritten reference cannot be baked into a built artefact.
  • The mobile integration, documented. There is no native SDK, and saying otherwise on the back of a snippet would be the kind of claim this changelog exists to avoid. What is supported is the hosted flow in a web view — which is what most native SDKs are underneath — with working Swift and Kotlin, including the camera permission each platform needs and the return-address callback. An in-app web view denies getUserMedia by default; without the permission the flow silently falls back to the file picker.

2026-09-14 (later still) — passkeys, and two sign-in buttons that were lying

Added

  • Passkeys, implemented properly. server/webauthn.js: our own CBOR decoder for the attestation object, the packed authenticator-data structure unpacked by hand, and the COSE public key converted to a JWK so node:crypto can verify the ES256 (or RS256) signature. No dependency, as with everything else here. Register one in the console under Integrations → Your passkeys; sign in with it on /sign-in. The private key never leaves the operator's device and the browser refuses to use it on any origin but ours, which makes it the only sign-in here that cannot be phished. Deliberately *not* implemented: attestation-statement verification — we do not care which manufacturer made the authenticator, only that the same key signs every later assertion, and checking attestation would mean shipping a root certificate list we cannot keep current.
  • Asking for a passkey by e-mail returns the same answer whether or not the account exists, so it cannot be used to find out who has one.
  • A signature counter that moves backwards is refused as a cloned authenticator; a counter that is always zero (which is what most platform passkeys report, on purpose) is not treated as suspicious.

Fixed

  • "Continue with Google / Microsoft / Okta" animated a redirect and then landed you in the console. There is no identity provider connected, so nothing was verified — it just looked as though something had been. The buttons now say plainly that SSO is not connected and point at the two sign-ins that do work. /features records SSO as not started, with that reason.
  • "Use a passkey" did the same thing: a 1.4-second pause, "Passkey confirmed — phishing-resistant sign-in", and through to the console. It is now the real ceremony, and a cancelled prompt says nothing was changed.

Verified

  • tools/test/passkey-flow.py runs the whole thing against Chrome's virtual authenticator over CDP — register, list, sign in with the passkey alone, and a replayed challenge refused. Nothing is stubbed in page script: a mistake in the CBOR parsing, the COSE conversion or the signature check fails the test.

2026-09-14 (later) — the hosted flow now takes real photographs

Publishing the catalogue immediately earned its keep: writing down *"document image capture in the browser — camera + file fallback"* meant reading the code to cite it, and the code had no camera. The flow drew a specimen document, read plausible fields off it, and sent the server three booleans. Every provider we are about to connect scores an image, so there was nothing for them to score, and a live applicant was shown a document number, an expiry and a face-match percentage that were invented.

Added

  • A real camera. On a live session the document and selfie steps open getUserMedia, downscale to 1600px, and upload the photo against the verification link. Permission denied, no camera, an in-app browser, a LAN over http — every one of those falls back to a file input, which on a phone opens the camera anyway. A flow that dead-ends on a permission prompt is a flow that loses the applicant.
  • POST /v2/verify/document — the applicant's own upload, authenticated by the same session token as the submission, because there is no account yet. Five kinds, eight files, 8 MB each, rate limited: an unauthenticated upload endpoint without those is a free image host. Images are held encrypted against the session and moved onto the applicant record the moment one is created, *before* the checks run.
  • Abandoned images are swept. A session's images belong to no applicant, so no retention window would ever have applied to them. server/retention.js now erases the images of links that were completed, expired or simply closed.
  • Proof of address, asked for in the flow when the client sets requireProofOfAddress, with the submit button waiting for it.
  • Every ISO 3166-1 country in the document-country picker, named from the reader's own CLDR data. It had 24 hard-coded countries, so an applicant from anywhere else could not say where their document was issued.

Fixed — four fabrications a real applicant would have seen

  • Invented document fields. The capture step read out a surname, a document number, an expiry date and "chip read via NFC — data matches the photo page". All demonstration values. A live session now shows the photograph that was taken and says *"photo captured — not yet checked"*.
  • An invented face-match score. The selfie step showed "Matches your ID · 98%" and "Real person · 99%" with no provider connected. A live session says the photo was received and that the face checks run after submission.
  • Somebody else's social profiles. The footprint step listed the sample person's Instagram, LinkedIn and X accounts and would not let the applicant continue until they had "found" them. A live session collects handles, explains that the lookup happens after submission and only with the consent already recorded, and lets them continue.
  • A one-time code that was never sent. The flow claimed to SMS a code, took any six digits, and then told the applicant their number was confirmed. There is no SMS provider, so on a live session the step is skipped and the number is carried as what it is: declared.
  • The review screen quoted the demonstration client's name in its consent sentence, and "Use demo identity" was visible to real applicants.
  • S.live was consulted during initialisation but assigned three hundred lines later, so the country picker was set up as if every session were a rehearsal.

Verified

  • tools/test/capture-flow.py walks the whole flow on a live session with Chrome's fake camera and asserts 20 things, including that the photographs reach the server, that no document number, expiry, match score or sample profile appears anywhere, and that the result screen is the server's decision.
  • The demonstration flow is untouched and still runs its specimen path end to end — checked separately, because that is what a sales call shows.
  • 91 browser rows (13 languages × 7 surfaces): 100% translated, 0 JavaScript errors. Sanctions precision unchanged: 6/6 recall, 2/23 referral.

2026-09-14 — the 500-feature catalogue, and building what it exposed

Writing down every feature and what its real status is turned out to be the most productive thing we have done in a while: an honest list is a work list. Twenty-three rows that said "not built" or "half built" are now built, and the ones still open say why, with the name of whoever is waiting on it.

Added

  • /features — the whole product written down. 500 entries across 18 areas, each marked built, partial or not started, and each built one naming the file you can check it against. server/features.js is the source of truth and the database is a mirror of it, synced at boot, so the page can never describe a build other than this one. The same data is at GET /v2/features. Anything that depends on a provider is rendered in its unconfigured state at build time and only raised in the browser if the live capability probe says the provider actually answers — a page that is optimistic by default is a page that eventually lies. Today: 451 built, 11 partial, 38 not started.
  • Ongoing monitoring. Screening somebody once tells you they were not on a list the day they applied. Every list refresh now re-screens everyone still on the books, in small batches, and raises an alert only for hits that are *new* — a hit a reviewer already dismissed is not raised twice. server/monitoring.js; progress and the last result are on GET /v2/screening/lists.
  • An internal blocklist per client. PUT /v2/screening/internal takes a CSV of the people that client has already decided not to onboard. The list is tagged with the tenant and the matcher refuses to score it for anyone else, which is the difference between a useful feature and a way to leak one customer's decisions to another. A hit is reported with internal: true.
  • Four-eyes approval. When a client turns it on, an approval above their risk threshold needs an endorsement from a *different* person first (POST /v2/applicants/:id/endorse). Enforced in the one decision gate every status change passes through, not in the console — a control that lives in the UI is a suggestion. Off by default, deliberately: switched on for a client with one reviewer it would deadlock every high-risk case.
  • Pausing a client now stops verifications. POST /v2/tenant/pause blocks new links, API creations and the applicant's own submission with a 503 that says why; existing cases can still be reviewed and decided.
  • Duplicate document detection. One identity document behind two applicants is either a duplicate account or somebody using a document that is not theirs. The number itself is never stored — what is stored is a per-tenant keyed hash, so the comparison works, the number is not sitting in the database waiting to leak, and two clients cannot correlate their applicants through us.
  • Alert suppression, without hiding anything. A known false positive can be suppressed with a reason and an expiry. A matching alert is still recorded, marked suppressed and attributed to the rule that suppressed it — the decision to stop looking is itself in the record.
  • Subject access and rectification. GET /v2/applicants/:id/data returns everything held about one person: the record, the documents (metadata and hash, never image bytes in a JSON payload), the decisions, the consents, the alerts and every access to it. PATCH /v2/applicants/:id corrects a field — reason required, and the previous value goes into the audit entry, because a correction that erases what it corrected is indistinguishable from tampering.
  • Two-factor backup codes. Ten single-use codes, issued when two-factor is enabled and shown once, stored only as hashes. A spent code keeps its date, so "which code was used, and when" is still answerable.
  • Bulk import (POST /v2/applicants/import) — a CSV, validated before anything is written, with the refused rows and their reasons in the response. Every imported applicant is screened inline: an import that is never screened is a list of names, not an onboarding.
  • Rule simulation (POST /v2/tenant/rules/simulate) — replays the stored check results of past applicants through a proposed rule set and reports what would have changed. It writes nothing, and a check that never ran still cannot pass.
  • A web SDK: assets/js/hawkai-sdk.js, one file, no dependencies, no build step. The flow runs in an iframe on our origin — so the camera permission is ours and your page keeps its state — and posts the outcome back. The SDK checks the message origin before believing it; without that, any page could post a fake approval into your callback.
  • OpenAPI 3.1 at /openapi.json, generated from the server route table at build time like the reference table, so it cannot describe an endpoint that does not exist. Bodies are described where the server validates them and left open where it does not.
  • Idempotent applicant creation via an Idempotency-Key header. The same key with a different body is a 409, not a silent replay of the first answer.
  • Save and resume. A half-finished application is kept, encrypted, against the verification link, so an applicant interrupted by a phone call can come back — on another device if they like. Photographs are deliberately *not* kept: a draft must not leave images of somebody's passport on a server for a week, so the capture steps are retaken.
  • Hand-back to the client. The flow now navigates to the return address the client set on the link, with the outcome appended. http(s) only, so it cannot be turned into an open redirect.
  • Logo upload (POST /v2/tenant/branding/logo), 200 KB cap. An SVG carrying script or event handlers is refused rather than sanitised — a sanitiser that gets it wrong once is worse than a no.
  • Side-by-side comparison of two applicants, which says which fields agree rather than leaving a reviewer to compare two screens by eye. The read is audited against both.
  • Slack alerts, through the same delivery queue and retries as any webhook. What Slack gets is the event, the applicant id and nothing that identifies the person: a channel is usually wider than the review team.
  • Saved queue filters, per reviewer, optionally shared. A reviewer who rebuilds the same four filters every morning eventually stops filtering.
  • Audit log by date range, and an audit CSV export — an audit log an examiner cannot take away with them is a screenshot. Same filters as the screen.
  • Real breakdowns at GET /v2/analytics/breakdown: volume by status, document type and country, median and 90th-percentile time to decision, and alert time-to-close — computed from this client's rows, not from the demonstration data the dashboards were built against.
  • A warehouse feed at GET /v2/export/warehouse.ndjson — applicants with their decisions and alerts, one JSON record per line.
  • Structured data (JSON-LD) on the landing page and every standing page, generated from the same strings the page shows.
  • Country names in the reader's language, from the browser's own CLDR data, falling back to the English name — an empty label is worse than an untranslated one.

Fixed

  • A live applicant read parts of their own result in English. Four sentences on the result screen had the demonstration client's name and e-mail address baked into them, which meant two things at once: a real applicant was told "Northwind Bank can now open your account", and because the sentence no longer matched the catalogue key, it was never translated. Both are now {client} and {email} placeholders filled *after* translation, with the 38 affected entries derived in all 13 languages.
  • Ongoing monitoring would have re-screened everybody, for ever. The two timestamps being compared are written by different code paths — the list loader stores an ISO string, SQLite's datetime('now') stores a space — and compared as text the ISO form always sorts later. Everyone looked permanently stale. Both sides are normalised before comparison.
  • Route matching made declaration order load-bearing. First-match-wins meant /v2/applicants/compare was served by /v2/applicants/:id with the id "compare". A literal segment now beats a parameter whatever order the routes were declared in.
  • A new column could never reach an existing database. CREATE TABLE IF NOT EXISTS covers a new table and does nothing for a new column, so seven of the features above would have worked only on a fresh deployment. ADD COLUMN migrations now run at boot — additive only: no drops, no type changes, no rewrites, because a deployment must not be able to lose a client's data.
  • Unexpected errors now return a reference that appears in the log, so a support e-mail can be traced to a stack without the 500 telling a stranger anything about the cause.
  • The inline-script check compiled <script type="application/ld+json"> as JavaScript and reported a syntax error in valid markup. It now validates data blocks as data.

Verified

  • 91 real-browser rows (13 languages × 7 surfaces): 100% translated, no horizontal overflow, 0 JavaScript errors.
  • Sanctions precision unchanged by the tenant-list work: 6/6 known designations found, 2/23 ordinary names referred, 44 ms per check.
  • tools/test/features-evidence.js holds the catalogue to its own promise — every done row's evidence is checked against the repository, and a row that points at nothing fails the test.

2026-09-13 — sanctions screening, without a vendor

Added

  • Sanctions screening now actually runs. It was listed as needing a provider contract, which was wrong: the authorities publish their lists. tools/load-sanctions.js loads OFAC SDN, OFAC Consolidated and the EU Consolidated list directly — about 26,000 designations, 67,000 searchable names with aliases — and the server refreshes them daily, because they change several times a week and one loaded once is a control that only looks like it is working.
  • GET /v2/screening/lists reports what is loaded, its age, and flags it stale past seven days. POST /v2/screening/refresh forces a refresh.
  • /readyz now separates sanctions from pep_adverse. Loading the public sanctions lists does not give you PEP or adverse-media data — those are commercial — and a single green tick covering all three would have claimed two checks that are not running. Blocking items went from four to two.

Fixed

  • The screening matcher flagged essentially every applicant. It had never been tested against a real list, because there had never been one. Two faults only real data exposes: the name normaliser kept [a-z0-9] plus CJK, so every Greek, Cyrillic, Arabic and Hebrew designation collapsed to an empty string — and name.includes('') is true, so they matched everybody at 0.9. And any substring containment scored 0.9, so a short designation (ADA, ELIZABET) matched any name containing it. In a fail-closed engine that does not merely annoy: it blocks every approval and teaches reviewers that hits are noise.
  • Rewritten as token-level Jaro-Winkler matching with a coverage requirement on both sides. Tuned against the loaded lists: 6/6 known designations found, ~5% of ordinary names referred, 34 ms per check.
  • The first index build cost 211 MB of heap in bigram sets — more than a small container has to spare. Jaro-Winkler needs no precomputation, so the index holds strings and a first-character block keeps the candidate set small: 51 MB, and checks got 9× faster rather than slower.

Fixed

  • The privacy notice, the DPA and the security page all stated a retention period the system does not use. They said verification records and documents are "deleted 30 days after a decision". The code runs two clocks: document images are erased 90 days after the decision, and the applicant record is kept ~7 years — the AML floor. The notice did not mention the second clock at all, so an applicant reading it would believe everything was gone in a month. All three documents now state both clocks, and the DPA names the settings that control them.
  • The applicant flow told people the same wrong thing, in 14 languages. The sentence is corrected and retranslated; the old one is deleted from the catalogue rather than left sitting there as a false statement.
  • The hosted flow was hardcoded to the sample client. CLIENT was cl_northwind and "Northwind Bank" appeared 18 times, so every applicant of every real client read another company's name — on their own consent statement. GET /v2/verify/session/:token now returns the real client's name and brand colour, authenticated by the session token like the submission is.
  • The demo controls were still visible on a live session. They were set hidden, but .demo sets its own display and a class rule beats the attribute's default — so a real applicant saw a panel offering to change their own outcome. Hidden with an inline !important now, which a stylesheet cannot quietly undo.

Added

  • All 59 user-facing server messages are translated into the 13 languages — block reasons, remedies, authentication and rate-limit errors, and the ones an applicant sees. These reach a person through the DOM, so the catalogue translates them like any other string; they had simply never been added. The catalogue is 3,100 keys.

Changed

  • A refused approval no longer stutters. Three unconfigured providers produced the same sentence three times. Blocks are grouped by code with the specifics kept underneath — "Document check did not run", "Face and liveness check did not run", "Screening did not run" — which is what the operator actually needs to know.
  • Sentences that name the client use a {client} placeholder resolved after translation, rather than splitting the sentence around a <span>. Splitting produced catalogue fragments like "'s policy — most people are approved automatically.", which cannot be translated into a language that puts the name somewhere else. Japanese and Korean now read correctly.

2026-09-11 — the pages a launch needs

Added

  • Eight standing pages, generated by tools/build-pages.js into real static HTML: privacy, terms, DPA, cookies, security and trust, docs, changelog and status. Static rather than fetched-and-rendered because a privacy notice that needs JavaScript to be readable is one that sometimes is not.
  • The API reference is generated from server/api.js, including the scope each route requires, so it cannot describe an endpoint that does not exist or drift from one that does.
  • A verification-links panel in Integrations, so a client can create the link that authorises an applicant's submission without touching the API.
  • robots.txt and a sitemap — 154 URLs with hreflang alternates for all 14 languages. The consoles are excluded: they are behind a session and hold client data, so every URL there is a dead end for a crawler.
  • The security page publishes the three vulnerabilities found in the API audit and what was done about them, because a security page with no history is either very new or not being looked at.

Fixed

  • A signed-in client was shown fabricated API keys and webhook endpoints. The console seeds every client with sample integrations, and live.js never replaced them — so a real tenant with nothing configured saw three ak_live_… keys and a webhook pointing at another company's domain. Somebody could reasonably have believed one of those keys was theirs, or that events were being delivered. Keys and webhooks are now read from the server, and an empty tenant sees "No API keys yet".
  • Five footer links — Privacy, Terms, DPA, Cookies, Security — pointed at #, and Quickstart, API reference, Webhooks, Status and Changelog pointed at unrelated anchors on the marketing page. All eleven now go where they say.

Changed

  • Legal documents are published in English and say so. A privacy notice or DPA machine-translated into 13 languages is a liability, and a *partially* translated one — which is what a source-string catalogue produces when a few headings happen to be in it — is worse than either. The chrome, headings and the notice explaining this are translated; the legal text is pinned to English, and the contents list is pinned with it so it cannot half-agree with the headings it links to.

2026-09-10 — API audit, invitations, and durable storage without the paid disk

Fixed (security)

  • Anyone could write applicant records into any client's queue. POST /v2/verify/submit took the tenant from the request body when no verification session was supplied — unauthenticated, with a tenant id of the caller's choosing. Proven by injecting a record into a second tenant from an anonymous request. A valid session is now required, and a link is single-use.
  • Webhook URLs were a request-forgery primitive. Any tenant could point one at https://169.254.169.254/, https://127.0.0.1/ or metadata.google.internal and read the result out of the delivery log. URLs are now checked against loopback, link-local, private and unique-local ranges, and internal-only names, in both IPv4 and IPv6 forms.

Fixed (correctness)

  • PUT /v2/tenant/settings accepted a string and spread it into {0:'n',1:'o',…}, silently corrupting the whole settings object.
  • Team invitations accepted x as an e-mail address and created a user the invitation could never reach. Roles are validated too, case-insensitively, because the consoles display them capitalised.
  • API keys accepted any scope, including *. Unknown scopes are rejected against a known list; a key with a garbage scope used to be created quietly and then fail every request.

Added

  • Invitations. Adding someone to a team created an account with no password and no way to set one, so every invited user was a dead end. POST /v2/tenant/team and client provisioning now return a single-use, expiring invite link; /sign-in?invite=… accepts it and sets a password. An owner can re-issue a link for a colleague who is locked out. Self-serve "forgot password" is deliberately absent: with no mail transport there is no way to prove who is asking, and a reset link handed to an anonymous caller is an account takeover.
  • Durable storage without the paid disk. Hugging Face sells persistent storage only through a browser billing step, so DB_PATH is unavailable on a Space nobody has paid for — and without a durable target this deployment refuses client accounts, correctly but uselessly. The database now snapshots to a private Hugging Face Dataset instead: gzipped, base64-encoded as text (the commit API rejects binaries), restored in a child process before SQLite opens. Verified by wiping the container disk entirely and watching the account come back.
  • The invitation flow, the sign-up errors and the new validation messages are translated into all 13 languages — the catalogue is 3,012 keys.

2026-09-09 — onboarding a real client

Added

  • A client can now exist. Tenants used to arrive only from environment variables at boot, so taking on a client meant a redeploy. Two ways in now: POST /v2/auth/signup, which the *Create account* form actually posts to, and POST /v2/tenants for a platform admin, which is what the Console's *New client* wizard calls when someone is signed in. Both create the tenant, the owner and the membership in one step, and roll the whole thing back if any part fails — a half-made client is worse than none.
  • Sign-up refuses on a deployment that cannot keep the data. Without DB_PATH it returns 503 and says why: an account created on a deployment whose database does not survive a rebuild would be lost on the next deploy, and the client would only find out after trusting us with real applicants. Same for a missing JWT_SECRET. SIGNUP_OPEN=0 closes self-serve entirely.
  • A go-live checklist at GET /v2/tenant/onboarding, rendered in the Console at #/onboarding. Eight steps, every one derived from stored state — keys that exist, webhooks that actually delivered, operators who really have two-factor on. Nothing can be ticked by hand. It distinguishes "endpoint saved" from "endpoint delivered", and says plainly that until the provider row is green this deployment is a demonstration rather than a compliance control.
  • Provisioning a client with an owner_email creates that user without a password, so an administrator never knows a client's credentials.

Fixed

  • A newly onboarded client saw someone else's identity. The console shipped with a sample list of client organisations and a sample operator in the sidebar; live.js only knew how to replace the *admin* page's markup, so a brand-new owner landed in their own workspace showing "Northwind Bank" and "Nick Sato". A signed-in client now sees their own tenant and nothing else. Platform staff still see every client, which is their job.
  • DB_PATH pointed at a directory silently disabled every snapshot. Hugging Face mounts persistent storage as a directory, so aiming DB_PATH at the mount is the obvious mistake; it failed at boot with ENOTSUP … copyfile and then blocked snapshots for the life of the process — the database looked healthy and nothing was being persisted. A directory is now accepted and app.db created inside it.
  • The console's rerender was a top-level const, so it never became the window.rerender that live.js looks for, and the console never re-rendered after live data arrived.

Changed

  • 57 new strings across the sign-up flow and the checklist are translated into all 13 languages — the catalogue is 2,986 keys. A client reads these before they ever see a decision, so they are not a place to leave English.

2026-09-09 — translations: dates that never matched, and the strings that never entered the catalogue

Fixed

  • Dictionary keys had the harvest day's date baked into them. Nine keys read Live compliance picture for … · Tuesday, September 8 — the day the catalogue was collected. From 9 September onward the lookup could never match, so the admin and console page subtitles fell back to English in all 13 languages, and would have done so every day after. The live date now renders in its own element and the sentence is a date-free key, derived from the old ones so every language keeps its existing wording.
  • Dates, times, durations, currency and standalone numbers were pinned to en-US. A Japanese reader got a fully translated page with Aug 11 on the chart axis, 10:41 AM in the alert feed, 2h 14m in a KPI and $738.1k for revenue. These now follow the active language through Intl.
  • The active language was applied too late for the first render. data-lang was set only after the dictionary fetch resolved, so anything formatted during the first paint used the default locale. It is now set in <head>, beside the dictionary preload.
  • 20 rendered strings were never in the catalogue at all and could not be translated in any language: the City and Employer form labels, six country names in the applicant flow (Mexico, Portugal, Singapore, Spain, New Zealand, Cyprus), the Workforce / HR industry, Webhook/Webhooks, the New and sample tags, developer view, the pricing footnote, and four figure captions. All 13 languages now carry them; the catalogue is 2,929 keys.
  • Japanese was missing one value it had a key for (PASSPORT · AUTO-CAPTURE).
  • Durations rendered through CLDR's *narrow* unit forms, which in Japanese are Latin (2h 14m) and read as a missed string. They use *short* forms now (2 時間 14 分).

Changed

  • Formatters that land inside a sentence (ago, age, num, compact, pct) deliberately keep emitting English, because the catalogue translates the whole phrase around them — {n}d ago → {n}日前, and some 70 composites built on it. Localised twins (numL, compactL, pctL, moneyCompact) are used where a value stands on its own. Both rules are written down in docs/DESIGN.md.
  • Changing language re-renders the current view in place, so values derived from a locale follow the switch without a reload and without losing the route.

2026-09-09 — the Hawk Ai mark, a new palette, and dark mode

Added

  • The logo, everywhere. The supplied artwork is now the identity across the product: the full hawk-head silhouette — every stroke and the pixel dissolve, with the eye socket left as an interior hole — traced from the original into a vector mark that stays legible from 16px up. Nav bars, sidebars, footers, the sign-in panel, the verify header's "Secured by", the tab icon and the iOS home screen icon all draw the same mark. The artwork itself, as a raster, carries the surfaces with room for it: the sign-in panel and the closing call to action.
  • A share card. og:image plus Open Graph and Twitter card meta on all five pages, absolutised to the serving host at request time because social crawlers reject a relative image URL.
  • Dark mode on the marketing site. It was pinned to light; it now follows the theme and carries a toggle in the nav, like the consoles always have.
  • Theme without a flash. Each page applies the stored theme in <head>, before first paint, so choosing dark no longer means a white flash on every load. "System" is now live: changing the OS theme repaints immediately.

Changed

  • The palette is derived from the logo. Brand azure #0B84F5, the eye's cyan as the secondary, and the artwork's navy as the ink — replacing the previous green and violet. --info moved to indigo so it no longer collides with the new brand.
  • Brand fills are separate from the brand accent. #0B84F5 sits mid-way up the luminance range, so neither white nor black text clears AA on it. Anything carrying ink now uses --brand-fill with --on-brand, which flips per theme; --brand stays for glows, rings, hairlines and the logo's eye.
  • Client avatar initials pick their own ink. The old helper compared a raw linear average against a fixed threshold, which called mid-tone blues and greens "light" and left white text at about 3:1. It now compares actual WCAG contrast and picks whichever of white or near-black wins.

Fixed

  • Network badges (LinkedIn, Facebook, X and the rest) lost their white ink to caption rules that outranked them, leaving grey text on brand-coloured tiles.
  • The mega-menu drew its headings in a dark-surface ink on a white panel.
  • The phone mock inherited the site theme, so in dark mode a client's light-themed app was rendered dark with its own brand blue unreadable on it. It now pins the light palette, which is what a handset mock is depicting.
  • Muted text (--text-3) was below AA against paper in light mode.
  • Count badges and the danger button used white on --danger at 3.76:1.
  • Across the five surfaces in both themes, elements failing WCAG AA went from 382 to 7 — of which the remainder are Facebook's and Instagram's own brand colours, which are left authentic.

2026-09-08 — a real backend

Added

  • Storage. SQLite (node:sqlite) with the live file on the container disk and plain-file snapshots to DB_PATH; boot restore, 5-minute and post-write snapshots, and a snapshot on SIGTERM. Personal data is AES-256-GCM encrypted per field.
  • Identity. scrypt passwords, HMAC session tokens (cookie *and* Bearer), TOTP two-factor, session revocation, five roles, and scoped API keys hashed at rest.
  • Decision engine. Tunable scoring, plus four blocks that no setting can switch off: confirmed sanctions match, tampered document, underage applicant, and a required check that never ran.
  • Capability adapters for document, liveness, screening, e-mail/phone intelligence, device and footprint. Unconfigured ones fail closed.
  • API. 38 routes under /v2, rate limited, tenant-scoped.
  • Webhooks. HMAC-signed over timestamp.body, queued, retried with backoff, with a delivery log and a ping button.
  • Audit log covering decisions, configuration changes and reads of applicant records.
  • /readyz, which computes whether this deployment is a compliance control or a demonstration, and says which.
  • Live mode in both consoles: real rows replace the sample world when someone signs in, and a refused approval reverts on screen with the remedy.

Changed

  • The marketing site no longer shows customer logos or testimonials — we have no customers, and a "sample" tag under a wall of logos is decoration, not disclosure.
  • Invented performance figures (91.8% pass rate, 4.2s decisions, 99.6% synthetic identities caught) replaced with facts that can be checked.
  • SOC 2 and ISO 27001 now read "Not certified", because neither has begun.
  • Pricing marked indicative.

Fixed

  • A tenant could set requireScreening: false and a *confirmed* sanctions match stopped blocking approval. Verdicts are now unconditional; only whether a check is mandatory is configurable.
  • An applicant with no reviewer crashed the applicant table — the sample world always had one. The same exception silently swallowed the call that mirrors a decision to the server, so the first refused approval appeared to succeed.

2026-09-08 — language in the URL

  • Every page moved to /<lang>/<page> (/ja/verify, /ar/admin), no .html anywhere, auth renamed to sign-in. Old paths 301 to their prefixed form.
  • The dictionary fetch now starts in <head>: a cold visit was showing ~4.5s of English before the swap.
  • Wordmark capitalised to Hawk Ai everywhere it renders.

2026-09-08 — 14 languages

  • 2,889 strings per language across the marketing site, applicant flow, both consoles, sign-in and the sample data, in en ja ko zh fr it es th ms id hi ro ru ar, with right-to-left for Arabic.
  • Runtime translation keyed by the English source string, with a MutationObserver so anything the SPA renders later is translated too.
  • Dates in tables and chart axes deliberately stay English: Aug 10 and Aug 2025 normalise to the same lookup key.

2026-09-08 — Hawk Ai

  • Renamed from Argus; new mark (a hawk in a stoop, one wing in currentColor, the other carrying the brand gradient), chosen by rendering five directions down to 16px.

2026-09-08 — first deploy

  • Five surfaces — marketing site, applicant flow, admin console, tenant console, sign-in — served by a zero-dependency Node static server with gzip and a language-aware 404.