Security

A technical note on how the parts that matter are built, including the part that is a known trade-off. It describes the system as it stands today.

Tokens for a linked account

Linking a Swiggy account uses OAuth 2.1 with PKCE. The user authorises on Swiggy’s consent page with a phone number and a one-time password, so their credentials never reach us. The PKCE verifier is held server side for the two minutes the handshake takes and never travels through the browser, which is the entire point of PKCE.

The resulting access token is written to one row per household. Both the handshake table and the token table have row-level security enabled with no policies at all, which denies every role except the service role our server holds:

alter table public.swiggy_oauth_pending enable row level security;
alter table public.swiggy_token          enable row level security;
-- no policy is created for either table, so anon and
-- authenticated can reach neither.

The key embedded in the Android app and the companion app is the public, publishable key. It is subject to these policies like any other role, and no policy grants it access to the token table. Extracting that key from an app binary yields nothing that can read any household’s token. This was verified from outside with the anon key when the tables were created.

There are no refresh tokens in Swiggy’s current API, so a token simply expires after about five days and the household has to authorise again. We keep no history of expired tokens.

What the app’s public key can read

One table is deliberately readable with that public key, and it is better that you hear it here than find it yourself.

The elder’s screen updates live as the assistant works, which it does by subscribing to its own call record. With row-level security on and no policy, that subscription returns nothing and the screen never moves. So one policy exists:

create policy "anon reads call sessions"
  on call_session for select
  to anon
  using (true);

The apps ship with the anon key inside them, and an app bundle is a zip file, so the honest description is that this table should be treated as publicly readable. What that exposes is the basket contents and totals for a call, the product options the user was offered, and, while a call is open, the order id and a delivery address label. It does not expose the household record, the diagnostic log, or any token: those three tables have no anon policy.

Writes are not affected. There is no insert, update or delete policy for the anon role, so the server remains the only writer. That is enforced by the database rather than by convention.

This is a real trade and it has a stated expiry. It is replaced by per-household authentication, so that a household can read only its own rows, before the companion app ships to a second device. A second device is a second identity, and “readable by anyone holding the key” stops being acceptable at that point.

Payment

Orders are cash on delivery. The product has no payment screen, takes no card, bank, or UPI details, and stores no payment instrument of any kind. This is a property of the design rather than a policy layered on top of it.

Voice audio

Speech is streamed to Sarvam AI and converted to text. Audio retention and model training are both switched off on our Sarvam account, so the audio is discarded after the conversion and nothing said to Hello Beta becomes training data. We do not write audio to our own storage either, so there is no copy of a call on our side once it has ended.

Keeping addresses out of the logs

When the assistant sends the server an instruction it cannot parse, the failure is logged so the bug can be found. The log records the shape of the bad argument and never its contents, because tool arguments carry delivery addresses:

arg_shapes: { "items": "string(0)" }

The shape is also the more useful diagnostic. A constant misconfiguration sends the same empty value forever, while a model guessing badly varies, and the shape distinguishes those two where the raw value would not. This log has no anon policy and is reachable only by the service role.

Confirmation before action

No order is placed on the strength of a transcription alone. The basket and the total are read back aloud, and the user has to confirm out loud before anything is submitted. A misheard item becomes a correction rather than a wrong delivery.

Deletion is enforced by the schema

Every table that holds anything about a household references it with on delete cascade. Deleting the household row removes its calls, their baskets, the diagnostic events attached to those calls, any in-flight authorisation, and the stored access token, in one operation. Honouring a deletion request does not depend on anyone remembering which tables exist.

This website

Static HTML served by Cloudflare Pages under default-src 'none', which means no script runs on this site at all. The language switch on the home page is built from radio inputs and CSS rather than JavaScript for that reason. No cookies, no analytics, no third-party scripts, and no third-party fonts: every typeface is served from this origin, so reading this page does not tell anyone else that you read it.

Reporting something

If you have found a problem in any of this, write to contact@hellobeta.co and it will be read by a person. There is no bounty programme, but reports are welcome and will get a real reply.