@burnt-labs/expo-satya-attest 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/PROVIDER_CONFIG.md +520 -0
  2. package/PROVIDER_REGISTRY.md +116 -152
  3. package/PUBLISHING.md +32 -2
  4. package/README.md +86 -15
  5. package/android/build.gradle +1 -0
  6. package/android/src/main/java/expo/modules/satyaattest/SatyaAttestModule.kt +483 -8
  7. package/android/src/main/java/uniffi/satya_ffi/satya_ffi.kt +1 -1
  8. package/android/src/main/jniLibs/arm64-v8a/libsatya_ffi.so +0 -0
  9. package/android/src/main/jniLibs/armeabi-v7a/libsatya_ffi.so +0 -0
  10. package/android/src/main/jniLibs/x86_64/libsatya_ffi.so +0 -0
  11. package/app.plugin.js +42 -10
  12. package/build/SatyaAttest.types.d.ts +120 -18
  13. package/build/SatyaAttest.types.d.ts.map +1 -1
  14. package/build/SatyaAttest.types.js.map +1 -1
  15. package/build/SatyaAttestModule.d.ts +15 -0
  16. package/build/SatyaAttestModule.d.ts.map +1 -1
  17. package/build/SatyaAttestModule.js.map +1 -1
  18. package/build/SatyaProviderAttestButton.d.ts +5 -1
  19. package/build/SatyaProviderAttestButton.d.ts.map +1 -1
  20. package/build/SatyaProviderAttestButton.js +722 -109
  21. package/build/SatyaProviderAttestButton.js.map +1 -1
  22. package/build/index.d.ts +1 -1
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +1 -1
  25. package/build/index.js.map +1 -1
  26. package/build/providerPolicy.d.ts +2 -1
  27. package/build/providerPolicy.d.ts.map +1 -1
  28. package/build/providerPolicy.js +33 -4
  29. package/build/providerPolicy.js.map +1 -1
  30. package/build/providerRegistry.d.ts +2 -1
  31. package/build/providerRegistry.d.ts.map +1 -1
  32. package/build/providerRegistry.js +585 -64
  33. package/build/providerRegistry.js.map +1 -1
  34. package/build/providerWebview.d.ts +59 -2
  35. package/build/providerWebview.d.ts.map +1 -1
  36. package/build/providerWebview.js +559 -8
  37. package/build/providerWebview.js.map +1 -1
  38. package/build/providers.json +2773 -905
  39. package/ios/Frameworks/libsatya_ffi-rs.xcframework/ios-arm64/Headers/satya_ffi.swift +2 -2
  40. package/ios/Frameworks/libsatya_ffi-rs.xcframework/ios-arm64/libsatya_ffi.a +0 -0
  41. package/ios/Generated/satya_ffi.swift +2 -2
  42. package/ios/SatyaAttestModule.swift +179 -9
  43. package/package.json +4 -2
  44. package/src/SatyaAttest.types.ts +125 -18
  45. package/src/SatyaAttestModule.ts +15 -0
  46. package/src/SatyaProviderAttestButton.tsx +840 -116
  47. package/src/index.ts +2 -0
  48. package/src/providerPolicy.ts +32 -3
  49. package/src/providerRegistry.ts +724 -87
  50. package/src/providerWebview.ts +603 -10
  51. package/src/providers.json +2773 -905
@@ -0,0 +1,520 @@
1
+ # Provider Config
2
+
3
+ Provider configs are trusted policy for the Expo native replay flow. A config decides
4
+ which provider URL can be replayed, which WebView request counts as a match, which
5
+ request headers may be copied into native replay, which response fields can be
6
+ revealed, and which template/parser identities the verifier accepts.
7
+
8
+ The built-in registry lives at [`src/providers.json`](./src/providers.json). It is a
9
+ vendored copy of the centralized provider registry maintained in the
10
+ [`burnt-labs/provider-bank`](https://github.com/burnt-labs/provider-bank) repo at
11
+ `provider-bank/providers.json` — edit providers there and sync the copy here
12
+ byte-identically (then run `npm run test:providers`). Custom registries use the same
13
+ provider shape inside a registry wrapper:
14
+
15
+ ```json
16
+ {
17
+ "schemaVersion": "1.0.0",
18
+ "providers": []
19
+ }
20
+ ```
21
+
22
+ The registry-level `schemaVersion` is optional in the SDK loader. Each provider entry
23
+ must still carry its own `schemaVersion`.
24
+
25
+ ## Minimal Provider
26
+
27
+ ```json
28
+ {
29
+ "schemaVersion": "1.0.0",
30
+ "providerId": "acme.current_user.v1",
31
+ "name": "Acme",
32
+ "iconUrl": "https://acme.example/icon.png",
33
+ "login": {
34
+ "url": "https://acme.example/login"
35
+ },
36
+ "endpoints": [
37
+ {
38
+ "id": "primary",
39
+ "url": "https://acme.example/api/current-user",
40
+ "method": "GET",
41
+ "headers": ["accept", "cookie", "user-agent"],
42
+ "responseType": "json",
43
+ "requestMatchRules": [
44
+ { "type": "methodEquals", "pattern": "GET" },
45
+ { "type": "urlContains", "pattern": "/api/current-user" }
46
+ ],
47
+ "revealRules": [
48
+ { "action": "REVEAL", "direction": "SENT", "part": "METHOD" },
49
+ { "action": "REVEAL", "direction": "SENT", "part": "REQUEST_TARGET" },
50
+ {
51
+ "action": "REVEAL",
52
+ "direction": "RECV",
53
+ "part": "BODY",
54
+ "params": {
55
+ "type": "json",
56
+ "path": "$.accountHolder.name",
57
+ "alias": "accountHolderName"
58
+ }
59
+ }
60
+ ]
61
+ }
62
+ ],
63
+ "tlsIdentityPolicy": {
64
+ "expectedSni": "acme.example",
65
+ "spkiSha256B64Pins": ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
66
+ },
67
+ "templateGovernance": {
68
+ "templateHash": "dev-acme-current-user-v1-template",
69
+ "parserHash": "parser-sha256:0000000000000000000000000000000000000000000000000000000000000000"
70
+ }
71
+ }
72
+ ```
73
+
74
+ Replace the example pin and parser hash with real values. `npm run test:providers`
75
+ computes the expected parser hash and fails if the value is stale.
76
+
77
+ ## Top-Level Fields
78
+
79
+ Required provider fields:
80
+
81
+ | Field | Description |
82
+ |---|---|
83
+ | `schemaVersion` | Provider schema version string. The built-in registry currently uses `1.0.0`. |
84
+ | `providerId` | Stable provider id, for example `kaggle.current_user.v1`. It must be unique within a registry. |
85
+ | `name` | User-facing provider name. Legacy `label` and `displayName` are accepted, but new configs should use `name`. |
86
+ | `login.url` | HTTPS page opened in the managed WebView for login and request discovery. |
87
+ | `endpoints` | Non-empty array of proof endpoints. If no endpoint id is selected, the SDK uses the first endpoint. |
88
+ | `tlsIdentityPolicy.expectedSni` | Expected HTTPS host/SNI. Every endpoint URL must use this same host. |
89
+ | `tlsIdentityPolicy.spkiSha256B64Pins` | Public SPKI SHA-256 pins for native replay. Built-in providers and device E2E should keep this non-empty. |
90
+ | `templateGovernance.templateHash` | Template identity accepted by the verifier. |
91
+ | `templateGovernance.parserHash` | Parser identity derived from provider id, endpoint ids, response types, and reveal rules. |
92
+
93
+ Optional provider fields:
94
+
95
+ | Field | Description |
96
+ |---|---|
97
+ | `iconUrl` | HTTPS image URL for provider selectors. |
98
+ | `description` | Optional selector/help text. |
99
+ | `label`, `displayName` | Legacy display fields. The loader normalizes them to `name`/`label`/`displayName`. |
100
+ | `version` | Optional version string. If omitted, the SDK infers it from the final `.vN` segment of `providerId`. |
101
+ | `auth` | Optional object for provider-auth metadata. The current SDK preserves it but does not interpret it in the managed flow. |
102
+ | `injection` | Optional JavaScript string, array of strings, or `null`. Runs in the managed WebView on every main and popup page alongside the SDK interceptor. |
103
+ | `target` | Legacy compatibility object. If present, it must be `{ "scheme": "https", "host": expectedSni, "port": 443 }`. The loader generates this when omitted. |
104
+ | `webLogin` | Legacy compatibility object. `login.url` is preferred. If only `webLogin.startUrl` is present, the loader maps it to `login.url`. |
105
+ | `userAgent` | Optional `{ "ios": string | null, "android": string | null }`. Used by the managed WebView when no explicit `webViewUserAgent` prop is supplied. |
106
+ | `tlsIdentityPolicy.allowedAlpn` | Optional ALPN metadata. Native replay measures negotiated ALPN and verifier policy checks the artifact. |
107
+ | `tlsIdentityPolicy.spkiSha256Hex` | Legacy/pass-through metadata. Prefer `spkiSha256B64Pins`. |
108
+ | `tlsIdentityPolicy.tlsTrustStorePolicy` | Optional, but if present it must be `web-pki-os-roots-no-user-roots`. The loader fills this value when omitted. |
109
+ | `tlsIdentityPolicy.userRootsAllowed` | Optional, but if present it must be `false`. The loader fills `false` when omitted. |
110
+ | `tlsIdentityPolicy.redirectPolicyId` | Optional redirect-policy metadata. Native replay currently blocks redirects. |
111
+ | `templateGovernance.fixtureSetHash` | Optional fixture-set identity. Defaults to `unversioned-fixtures`. |
112
+ | `templateGovernance.governancePolicyVersion` | Optional governance policy id. Defaults to `embedded-registry`. |
113
+ | `templateGovernance.providerActivationState` | Optional activation state. Defaults to `experimental`; production policy should require governed active templates. |
114
+
115
+ Unknown top-level keys are cloned through the JavaScript loader, but native replay and
116
+ backend verification only rely on documented policy fields. Do not place
117
+ security-critical behavior in an unknown key.
118
+
119
+ ## Endpoints
120
+
121
+ Each provider can define multiple endpoints for the same site:
122
+
123
+ ```json
124
+ {
125
+ "id": "primary",
126
+ "url": "https://acme.example/api/current-user",
127
+ "method": "GET",
128
+ "headers": ["accept", "cookie", "user-agent"],
129
+ "responseType": "json",
130
+ "requestMatchRules": [],
131
+ "revealRules": []
132
+ }
133
+ ```
134
+
135
+ Required endpoint fields:
136
+
137
+ | Field | Description |
138
+ |---|---|
139
+ | `id` | Endpoint id. It must start with a letter and contain only letters, numbers, `_`, or `-`. |
140
+ | `url` | HTTPS replay URL. The host must match `tlsIdentityPolicy.expectedSni`. |
141
+ | `method` | HTTP method. Supported values are `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, and `OPTIONS`. |
142
+ | `headers` | Non-empty request-header allowlist used by native replay. This is the collapsed replacement for `privacyPolicy.allowedRequestHeaders`. |
143
+ | `responseType` | Response parser type. One of `json`, `html`, or `text`. `html`/`text` endpoints disclose values via `regex` reveals (see below). |
144
+ | `requestMatchRules` | Non-empty array of rules used to detect the WebView request before native replay. |
145
+ | `revealRules` | Non-empty array of disclosure rules. It must include at least one `RECV BODY` reveal (`json`, `regex`, or `jsonRegex`). |
146
+
147
+ The raw endpoint schema intentionally does not include `allowedResponseBodyJsonPaths`.
148
+ The loader derives the response reveal allowlist from `revealRules` and emits a
149
+ structured `privacyPolicy.allowedResponseReveals` array (consumed by native replay) plus
150
+ the legacy `allowedResponseBodyJsonPaths` (the JSON-path subset).
151
+
152
+ ### Response types & HTML/text capture
153
+
154
+ - `json` — the response body is parsed as JSON; reveals may use `json`, `jsonRegex`, or `regex`.
155
+ - `html` / `text` — the body is treated as raw text; reveals must use `regex` only (a
156
+ JSON path cannot be applied to a non-JSON body).
157
+
158
+ The WebView request interceptor only sees `fetch`/`XHR` traffic, so a `json` endpoint is
159
+ normally captured when the logged-in page makes its own API call. For `html`/`text`
160
+ endpoints the proof page is a top-level navigation, which is not a `fetch`/`XHR`. When the
161
+ managed WebView lands on a URL that matches an `html`/`text` provider's
162
+ `requestMatchRules`, the SDK re-issues that URL as a same-origin credentialed `fetch()` so
163
+ the normal capture → native-replay path runs. A provider `injection` can drive the WebView
164
+ to the proof page after login (the bundled HTML providers do this).
165
+
166
+ When `requireProviderTemplate(providerId, registry, endpointId)` is called with an
167
+ endpoint id, that endpoint is normalized into the compatibility `proofTarget` used by
168
+ the native modules. If no endpoint id is supplied, the first endpoint is selected.
169
+
170
+ ### Active-fetch trigger (`triggerFromInjection`)
171
+
172
+ Some endpoints are cookie-authenticated APIs that **no page ever calls on its own** (e.g.
173
+ `reddit.com/api/me.json`, `spotify.com/api/account-settings/v1/profile`,
174
+ `users.roblox.com/v1/users/authenticated`). There is nothing for the interceptor to
175
+ capture. Set `triggerFromInjection: true` and the SDK actively fires the request itself
176
+ from inside the WebView (with cookies) once the user is logged in:
177
+
178
+ ```json
179
+ {
180
+ "id": "primary",
181
+ "url": "https://www.reddit.com/api/me.json?raw_json=1",
182
+ "method": "GET",
183
+ "headers": ["accept", "user-agent", "cookie"],
184
+ "responseType": "json",
185
+ "triggerFromInjection": true,
186
+ "requestMatchRules": [
187
+ { "type": "methodEquals", "pattern": "GET" },
188
+ { "type": "urlContains", "pattern": "/api/me.json" }
189
+ ],
190
+ "revealRules": [ /* … */ ]
191
+ }
192
+ ```
193
+
194
+ How it works:
195
+
196
+ - The SDK injects a small poller that calls `fetch(url, { method, headers, body, credentials: 'include' })`
197
+ every ~2s, **skipping login/auth pages**, until it succeeds.
198
+ - **Login gating is automatic via the response status:** the native replay only runs once
199
+ the injected request comes back **2xx**. Pre-login `401`s / redirects are ignored, so it
200
+ never captures before sign-in. (Regular, page-triggered endpoints are unaffected — they
201
+ still capture on the request.)
202
+ - Self-terminating: stops on the first 2xx, after `injectionMaxAttempts` real fetches, or a
203
+ hard poll cap.
204
+
205
+ Optional fields that pair with it:
206
+
207
+ | Field | Description |
208
+ |---|---|
209
+ | `triggerFromInjection` | `true` to enable the active fetch for this endpoint. |
210
+ | `injectionHeaders` | `{ name: value }` extra headers for the fetch. Values may be literals or `{{cookie:NAME}}` templates resolved from `document.cookie` at fire time (for non-httpOnly CSRF / app-id headers). Their names are auto-added to the request-header allowlist for native replay. |
211
+ | `requestBody` | Request body for POST/PUT/PATCH (e.g. a GraphQL query). |
212
+ | `injectionLandingUrl` | HTTPS page to navigate to if the fetch is stuck on a **sibling subdomain** of the proof host (so it becomes same-origin). Defaults to the proof origin root; set a stable signed-in page when the root redirects elsewhere. E.g. after login Spotify lands on `open.spotify.com`, so its endpoint sets `"https://www.spotify.com/account/"`. The poller tries the fetch from wherever it is first (so credentialed cross-origin CORS APIs like Roblox's still need no landing), and only hops after a couple of failures on a same-registrable-domain host — never across registrable domains (won't break a third-party SSO). |
213
+ | `injectionPollMs` | Poll interval in ms (default `2000`, range `500`–`15000`). |
214
+ | `injectionMaxAttempts` | Max real fetch attempts (default `60`, range `1`–`300`). |
215
+
216
+ Examples needing headers / a body:
217
+
218
+ ```jsonc
219
+ // Instagram — constant app-id + a CSRF token read from a cookie:
220
+ "triggerFromInjection": true,
221
+ "injectionHeaders": {
222
+ "x-ig-app-id": "936619743392459",
223
+ "x-csrftoken": "{{cookie:csrftoken}}",
224
+ "x-requested-with": "XMLHttpRequest"
225
+ }
226
+
227
+ // DoorDash — POST GraphQL with a body:
228
+ "triggerFromInjection": true,
229
+ "injectionHeaders": { "content-type": "application/json" },
230
+ "requestBody": "{\"operationName\":\"consumer\",\"variables\":{},\"query\":\"query consumer { consumer { id isGuest } }\"}"
231
+ ```
232
+
233
+ Notes:
234
+
235
+ - Use it for APIs the page doesn't call. For endpoints a page already triggers (the proof
236
+ fires naturally), leave it off — both work, but the flag is redundant there.
237
+ - Cross-origin APIs (e.g. `api.skillshare.com` from `www.skillshare.com`) work only if the
238
+ API allows credentialed CORS from the page's origin (the provider's own web app must use
239
+ the same call). Same-origin APIs always work.
240
+ - `httpOnly` cookies can't be read by `{{cookie:NAME}}` (they're sent automatically by
241
+ `credentials:'include'` anyway; only header tokens like CSRF need templating).
242
+ - `triggerFromInjection`, `injectionHeaders`, `requestBody`, and the poll tuning are **not**
243
+ part of the parser hash.
244
+
245
+ ### Dynamic URLs (`replayCapturedUrl`)
246
+
247
+ Some endpoints have a **dynamic path segment** — e.g. Garmin's
248
+ `/userprofile-service/socialProfile/{userId}`, where the user id varies per account. A fixed
249
+ template `url` can't represent that, and native replay normally uses the template `url`.
250
+
251
+ Set `"replayCapturedUrl": true` on the endpoint and native replay uses the URL the WebView
252
+ **actually fetched** (the one matched by `requestMatchRules`) instead of the template `url`.
253
+ The captured URL is still constrained to the pinned host (`expectedSni`) and the endpoint's
254
+ match rules, so it can't be redirected to an arbitrary origin. Configure it like:
255
+
256
+ ```json
257
+ {
258
+ "url": "https://connect.garmin.com/gc-api/userprofile-service/socialProfile",
259
+ "method": "GET",
260
+ "replayCapturedUrl": true,
261
+ "requestMatchRules": [
262
+ { "type": "methodEquals", "pattern": "GET" },
263
+ { "type": "urlContains", "pattern": "/userprofile-service/socialProfile" }
264
+ ]
265
+ }
266
+ ```
267
+
268
+ Here the template `url` is just the stable prefix (used for host/SNI validation and the
269
+ `urlContains` rule); the per-user `…/socialProfile/{userId}` request the page fires is what
270
+ gets captured and replayed. `replayCapturedUrl` is not part of the parser hash.
271
+
272
+ ## Request Match Rules
273
+
274
+ Request match rules decide which WebView request should trigger native replay. Every
275
+ endpoint must include a method rule and a URL rule:
276
+
277
+ ```json
278
+ [
279
+ { "type": "methodEquals", "pattern": "POST" },
280
+ { "type": "urlContains", "pattern": "/api/current-user" }
281
+ ]
282
+ ```
283
+
284
+ Supported rule types:
285
+
286
+ | Rule | Fields | Notes |
287
+ |---|---|---|
288
+ | `methodEquals` | `pattern` | Required. Must match endpoint `method`. |
289
+ | `urlContains` | `pattern` | Required by the current validator. Pattern must be at least four characters and must match the endpoint path or query. |
290
+ | `urlRegex` | `pattern` | Optional. The regex must compile and match either the full endpoint URL or its path/query. Use only when a simple path substring is not enough. |
291
+ | `headerEquals` | `key`, `pattern` | Optional. Supported by the matcher, but avoid it for discovery unless required because WebView-captured auth headers can be inconsistent. `pattern: "*"` means present and non-empty. |
292
+ | `bodyJsonPathEquals` | `path`, `pattern` | Optional. Useful for GraphQL or shared API paths; for example matching `$.operationName`. |
293
+
294
+ Bundled providers intentionally keep matching reachable: most use `methodEquals` plus
295
+ `urlContains`; Uber additionally uses `bodyJsonPathEquals` to distinguish the GraphQL
296
+ operation.
297
+
298
+ ## Reveal Rules
299
+
300
+ Reveal rules describe what the artifact may disclose. A `RECV BODY` reveal has a
301
+ `params.type` of `json`, `regex`, or `jsonRegex`.
302
+
303
+ **`json`** — read the value at a JSON path from a JSON body:
304
+
305
+ ```json
306
+ {
307
+ "action": "REVEAL",
308
+ "direction": "RECV",
309
+ "part": "BODY",
310
+ "params": { "type": "json", "path": "$.accountHolder.name", "alias": "accountHolderName" }
311
+ }
312
+ ```
313
+
314
+ **`regex`** — match a regex against the raw response body (HTML, text, or JSON) and
315
+ disclose a capture group. Requires an explicit `alias`:
316
+
317
+ ```json
318
+ {
319
+ "action": "REVEAL",
320
+ "direction": "RECV",
321
+ "part": "BODY",
322
+ "params": { "type": "regex", "pattern": "\"memberSince\":\"([^\"]+)\"", "group": 1, "alias": "member_since" }
323
+ }
324
+ ```
325
+
326
+ **`jsonRegex`** — read a string value at a JSON path, then match a regex against it.
327
+ Useful for pulling a substring out of one JSON field. Requires an explicit `alias`:
328
+
329
+ ```json
330
+ {
331
+ "action": "REVEAL",
332
+ "direction": "RECV",
333
+ "part": "BODY",
334
+ "params": { "type": "jsonRegex", "path": "$.profile.bio", "pattern": "id:(\\w+)", "alias": "bio_id" }
335
+ }
336
+ ```
337
+
338
+ Supported disclosure fields:
339
+
340
+ | Field | Description |
341
+ |---|---|
342
+ | `action` | Use `REVEAL` for disclosed values. |
343
+ | `direction` | `SENT` for request metadata or `RECV` for response data. |
344
+ | `part` | Common values are `METHOD`, `REQUEST_TARGET`, and `BODY`. |
345
+ | `params.type` | `json`, `regex`, or `jsonRegex`. |
346
+ | `params.path` | JSON path for `json`/`jsonRegex` reveals, e.g. `$.userProfile.userName` or `$.transactions[0].id`. |
347
+ | `params.pattern` | Regex for `regex`/`jsonRegex` reveals. Must compile and be ≤ 2048 chars. |
348
+ | `params.group` | Optional capture group for `regex`/`jsonRegex`: an integer index in `[0, 99]` (default `1`, falling back to the full match when the pattern has no groups) or a named group string `(?<name>...)`. |
349
+ | `params.alias` | Claim name. Optional for `json` (defaults to the final JSON-path key); **required** for `regex` and `jsonRegex`. |
350
+
351
+ Alias rules:
352
+
353
+ - aliases must start with a letter,
354
+ - aliases can contain letters, numbers, `_`, or `-`,
355
+ - aliases are limited to 80 characters,
356
+ - duplicate aliases are rejected case-insensitively across all reveal types.
357
+
358
+ Native replay (Android/iOS) applies these reveals to the pinned response and emits each
359
+ match as a disclosed claim. The raw response body is size-bounded (8 MiB) before regex
360
+ runs. Regex patterns come from the trusted provider registry, never from page content.
361
+
362
+ ## Writing Regex Patterns For HTML / Text Bodies
363
+
364
+ `regex` reveals run against the **raw response body** (the exact bytes the pinned replay
365
+ received — HTML, text, or JSON). Use this recipe to keep patterns short and readable.
366
+
367
+ ### The recipe: anchor, lazy capture, anchor
368
+
369
+ ```text
370
+ <text just before the value>(.*?)<text just before the value ends>
371
+ ```
372
+
373
+ - Put a short, unique literal **before** the value and a literal **after** it.
374
+ - Capture the value with a **lazy** group `(.*?)`. Lazy (`*?`) stops at the first
375
+ following anchor; greedy `(.*)` would run to the *last* match on the line and grab too
376
+ much.
377
+ - The captured **group 1** is the disclosed value. Set `params.group` only to use a
378
+ different index or a named group.
379
+
380
+ Examples (the regex is shown first, then how it appears JSON-escaped in `providers.json`):
381
+
382
+ | Goal | Regex | In JSON (`"pattern"`) |
383
+ |---|---|---|
384
+ | HTML meta tag | `octolytics-actor-login" content="(.*?)"` | `"octolytics-actor-login\" content=\"(.*?)\""` |
385
+ | JSON string field | `"memberSince":"(.*?)"` | `"\"memberSince\":\"(.*?)\""` |
386
+ | JSON boolean | `"isPrimeCustomer":(true\|false)` | `"\"isPrimeCustomer\":(true\|false)"` |
387
+ | JSON number | `"site_count":([0-9]+)` | `"\"site_count\":([0-9]+)"` |
388
+ | Single-quoted JS var | `firstName:.*?'(.*?)'` | `"firstName:.*?'(.*?)'"` |
389
+ | Email value | `"email":"(.*?@.*?)"` | `"\"email\":\"(.*?@.*?)\""` |
390
+
391
+ ### JSON escaping (the only escaping you usually need)
392
+
393
+ `providers.json` is JSON, so inside a `"pattern"` string:
394
+
395
+ - a double quote `"` becomes `\"`,
396
+ - a backslash `\` becomes `\\` (so the regex `\d` is written `\\d` — prefer `[0-9]` to
397
+ avoid this),
398
+ - everything else is literal.
399
+
400
+ Two tricks to avoid escaping:
401
+
402
+ - Match a **single-quoted** value (`'(.*?)'`) — single quotes need no JSON escaping (see
403
+ Zoom).
404
+ - Bridge punctuation with `.*?` instead of matching it exactly: `userType:.*?'(.*?)'`
405
+ skips the colon/space without you having to write `\s*` or `:`.
406
+
407
+ ### Rules and gotchas
408
+
409
+ - **Use `(.*?)`, not `(.*)`.** Greedy captures overshoot.
410
+ - **`.` does not match newlines.** Values are almost always on one line, so `(.*?)` is
411
+ fine. If a value spans lines, use `([\s\S]*?)` instead of `(.*)`.
412
+ - **Pick a unique anchor.** `content="(.*?)"` alone matches the first `content="..."` on
413
+ the page; prefix it with something specific (`octolytics-actor-login" content=`).
414
+ - **`html`/`text` endpoints accept `regex` reveals only.** `json` endpoints accept `json`,
415
+ `regex`, and `jsonRegex`.
416
+ - Patterns must compile and be ≤ 2048 characters. `params.alias` is **required** for
417
+ `regex`/`jsonRegex`.
418
+
419
+ ### Advanced: escaped JSON embedded in HTML
420
+
421
+ Some SSR pages embed JSON with the quotes already backslash-escaped (e.g. Facebook's
422
+ `/settings` ships `\"USER_ID\":\"123\"`). Two robust options:
423
+
424
+ - **Bridge with `.*?` and capture by shape** — skip the punctuation entirely:
425
+ `USER_ID.*?([0-9]{5,})` grabs the numeric id whether or not the quotes are escaped.
426
+ - **Match the optional backslash** with `\\?"` (regex `\\?` = an optional literal
427
+ backslash; JSON-escaped as `\\\\?\"`). This is why Facebook's bundled patterns look
428
+ busier than the others.
429
+
430
+ ### Testing a pattern
431
+
432
+ Test against a saved copy of the real (logged-in) page body before adding it — the body
433
+ the replay sees is the raw bytes, so test against *view-source*, not the rendered DOM:
434
+
435
+ ```js
436
+ // node or browser console
437
+ const body = `...paste the raw response body...`;
438
+ const m = body.match(/octolytics-actor-login" content="(.*?)"/);
439
+ console.log(m && m[1]); // the value that will be disclosed
440
+ ```
441
+
442
+ `npm test` validates that every pattern compiles and recomputes the parser hash.
443
+
444
+ ## Parser Hash
445
+
446
+ `templateGovernance.parserHash` is not an arbitrary hardcoded id. The provider test
447
+ script derives it from a canonical parser policy:
448
+
449
+ ```json
450
+ {
451
+ "schema": "satya-provider-parser-policy-v1",
452
+ "providerId": "<provider id>",
453
+ "endpoints": [
454
+ {
455
+ "id": "<endpoint id>",
456
+ "responseType": "<response type>",
457
+ "revealRules": []
458
+ }
459
+ ]
460
+ }
461
+ ```
462
+
463
+ The canonical JSON is SHA-256 hashed and stored as:
464
+
465
+ ```text
466
+ parser-sha256:<64 lowercase hex characters>
467
+ ```
468
+
469
+ Any change to `providerId`, endpoint ids, endpoint order, `responseType`, or
470
+ `revealRules` changes the parser hash. Run this after editing providers:
471
+
472
+ ```bash
473
+ cd platform_sdks/expo-satya-attest
474
+ npm run test:providers
475
+ ```
476
+
477
+ The client checks verifier policy against the selected template hash and parser hash
478
+ before using verifier-supplied pins. The backend verifier also pins provider id,
479
+ template hash, parser hash, activation state, and SPKI policy before accepting an
480
+ artifact.
481
+
482
+ ## Validation Checklist
483
+
484
+ The SDK and provider test script enforce the important invariants:
485
+
486
+ - registry `providers` must be a non-empty array,
487
+ - provider ids must be unique,
488
+ - endpoint arrays cannot be empty and are capped at 20 endpoints per provider,
489
+ - endpoint ids must be unique per provider,
490
+ - login and endpoint URLs must be HTTPS and cannot contain credentials,
491
+ - endpoint URL host must match `tlsIdentityPolicy.expectedSni`,
492
+ - endpoint `headers`, `requestMatchRules`, and `revealRules` cannot be empty,
493
+ - `responseType` must be one of `json`, `html`, or `text`,
494
+ - request matching must bind both method and URL,
495
+ - reveal rules must include at least one `RECV BODY` reveal (`json`, `regex`, or `jsonRegex`),
496
+ - `regex`/`jsonRegex` reveals require a valid (compilable, ≤ 2048 char) `pattern` and an
497
+ explicit `alias`; `html`/`text` endpoints accept `regex` reveals only,
498
+ - `allowedRequestHeaders`, `allowedResponseBodyJsonPaths`, and raw endpoint
499
+ `privacyPolicy` are rejected in `src/providers.json`,
500
+ - top-level `injection` must be a non-empty string or non-empty string array when
501
+ present,
502
+ - `login.injection` is rejected because injection is top-level provider policy,
503
+ - SPKI pins must be SHA-256 base64 values,
504
+ - parser hashes must match the canonical parser policy.
505
+
506
+ ## Provider Authoring Guidance
507
+
508
+ - Prefer `methodEquals` plus a stable API path in `urlContains`.
509
+ - Add `bodyJsonPathEquals` for shared GraphQL endpoints or overloaded API paths.
510
+ - Avoid `headerEquals` for normal discovery unless there is no reliable URL/body
511
+ discriminator.
512
+ - Keep `headers` to the minimum request headers needed for native replay. Include
513
+ `user-agent` when the endpoint is user-agent sensitive (e.g. desktop-only SSR pages) and
514
+ set the provider `userAgent` so the WebView and the replay agree.
515
+ - Keep reveal rules narrow; every `RECV BODY` reveal becomes a disclosed claim. Prefer
516
+ `json` for JSON APIs; use `regex` for HTML/text pages or to pull a substring; anchor
517
+ regex patterns tightly (`"field":"([^"]+)"`) so they match exactly one value.
518
+ - Treat `injection` as trusted code. Do not load registry files from untrusted hosts.
519
+ - Keep backend verifier policy in sync with registry changes before making
520
+ `providerPolicyRefresh="verifier-required"` the default for a provider.