PrompTugal for iOS

05 · Architecture

Five layers: the websites in isolated web views, one typed bridge, a native core that owns identity, schedule, alerts and capture, a vault client that owns all private data, and system surfaces (widgets, Live Activities, Siri) fed from a shared App Group store.

The layers

┌───────────────────────────── iPhone / iPad ─────────────────────────────┐
│  System surfaces   Widgets · Live Activity · Siri/Shortcuts · Spotlight │
│                    Notifications · Share extension · Control Center     │
│                         ▲ reads the App Group store only                │
│  Native core       Identity · Schedule · Alerts · Capture · Reader      │
│                         ▲                         ▲                     │
│  Bridge            pt.* messages (WKScriptMessageHandlerWithReply)      │
│                         ▲                         │                     │
│  Web views         promptugal.news · web-summit.sgit.ai · newsroom …    │
│                    one WKWebsiteDataStore per site, App-Bound Domains   │
│                                                   ▼                     │
│  Vault client      Swift: read, cache, sync, commit, push               │
│                    keys: Keychain + Secure Enclave, unlocked by passkey │
└──────────────────────────────────┬──────────────────────────────────────┘
                                   ▼
          dev.send.sgraph.ai (ciphertext only)   ·   promptugal.news /api/v1 (public)

The bridge

One message handler, pt, registered in its own WKContentWorld and only on App-Bound Domains. The page calls await window.webkit.messageHandlers.pt.postMessage({op, …}) and gets a reply. The website detects the app with one injected flag (window.ptApp = {version, ops}) and falls back to its browser behaviour when the flag is missing, so the same page works in Safari and in the app.

opDirectionWhat it doesWho can call it
schedule.get / schedule.setpage → appRead or replace picks, in the nr.schedule.v1 shapepromptugal.news, web-summit.sgit.ai
schedule.changedapp → pageFires the page’s existing nr:schedule eventthe same
notify.planpage → appAsk for reminders for given picks. The app asks permission oncethe same
vault.status / vault.load / vault.save / vault.listpage → appThe native implementation of the page’s window.__vault. Keys stay nativepromptugal.news (later, vault apps)
key.signpage → appSign bytes with my team key after Face ID. Returns the signature, never the keyweb-summit.sgit.ai
identity.getpage → appWho am I (team id, display name). No secretsteam sites
capture.openpage → appOpen the native scanner, recorder or camera, and return the saved item’s idall
sharepage → appNative share sheet for a page or entityall

Rules. Every op checks the calling frame’s origin against an allow-list. Every op that touches a secret needs a fresh Face ID (LAContext, reuse window of 60 seconds or less). No op returns key material. The list is versioned, and both sides ignore unknown ops.

Stores

StoreHoldsWhereSynced by
Feed cacheagenda, events, news, wiresApplication Support (today’s content.json)the native reader’s refresh
Shared App Group storenext items, badge counts, the last sync timeApp Group container (SQLite or JSON)written by the core, read by widgets and intents
Vault object cacheencrypted obj-cas-imm-* objectsCaches, kept forever (objects never change)the vault client
Vault working statedecrypted picks, stars, notes, contactsmemory, plus a protected file (NSFileProtectionComplete)the vault client, on unlock
Keyswrapped master key, team keys, device signing keyKeychain (.biometryCurrentSet, this device only), Secure Enclavenever synced. Recovery through the passkey
Web storageeach site’s own localStorage, IndexedDB and service workerone WKWebsiteDataStore per sitenot a source of truth. Rebuilt from the vault through the bridge

Sync

  • Pull. Fetch the ref. If the commit is new, walk only the trees and objects not already cached (-imm- objects never change). Do this on open, on pull-to-refresh, and in BGAppRefreshTask (about 30 seconds, at times iOS chooses, never after a force quit).
  • Push. The local change becomes a commit on this device’s branch, signed in the Secure Enclave, then pushed. A failed push stays queued and retries on the next open or background run.
  • Merge. Picks merge by id (the union, with the latest at deciding a removal), the same rule the website uses. Other paths follow sgit’s merge. A conflict is shown, never silently resolved.
  • Feeds. The public feeds are separate and need no unlock.

Security model

ThreatDefence
A page script tries to read keysKeys never enter a web view. The bridge returns results, not keys. The bridge lives in its own content world and checks the frame origin.
One site reads another site’s dataA separate WKWebsiteDataStore per site. Origin checks on every bridge call.
A stolen, unlocked phoneSecrets need a fresh Face ID. The master key is wrapped by a Secure Enclave key that stops working when Face ID enrolment changes.
A lost phoneNothing usable is on it without Face ID. The passkey and recovery code restore access on a new device.
The server is compromisedIt has only ciphertext and opaque IDs. The client verifies object hashes (obj-cas-imm- = SHA-256 of the ciphertext) and commit signatures.
A bad website releaseThe app pins the bridge to known ops. The website cannot ask for anything new without an app update.
WebKit clears website storageWeb storage is a cache. The vault is the source of truth.

Targets

  • iOS 18 minimum is recommended. It brings passkey PRF, broadcast Live Activities, Control Center controls and Spotlight IndexedEntity. Apple Intelligence features (Foundation Models, iOS 26, iPhone 15 Pro and later) stay optional.
  • Extensions: a widget, a Live Activity, an App Intents extension and a share extension. All of them read the App Group store only, never the vault directly.
  • No third-party packages, as today. Everything above is in Apple’s SDK.