@oxy.so/services 10.0.0 → 11.0.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 (27) hide show
  1. package/android/build.gradle +1 -1
  2. package/android/src/main/AndroidManifest.xml +6 -6
  3. package/android/src/main/java/so/oxy/devicesession/OxyDeviceSessionModule.kt +106 -41
  4. package/android/src/main/java/so/oxy/devicesession/OxyDeviceSessionProvider.kt +45 -32
  5. package/android/src/main/java/so/oxy/devicesession/OxyDeviceSessionStore.kt +22 -27
  6. package/android/src/main/java/so/oxy/identity/OxyIdentityModule.kt +89 -43
  7. package/android/src/main/java/so/oxy/security/OxyCallerPolicy.kt +97 -0
  8. package/android/src/main/java/so/oxy/session/OxyBackgroundSessionStore.kt +4 -10
  9. package/android/src/main/java/so/oxy/storage/OxyEncryptedPrefs.kt +17 -18
  10. package/ios/OxyIdentityModule.swift +13 -14
  11. package/lib/commonjs/ui/boot/runProviderColdBoot.js +2 -2
  12. package/lib/commonjs/ui/session/tokenLossRecovery.js +1 -1
  13. package/lib/module/ui/boot/runProviderColdBoot.js +2 -2
  14. package/lib/module/ui/session/tokenLossRecovery.js +1 -1
  15. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +2 -2
  16. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +2 -2
  17. package/package.json +6 -6
  18. package/plugins/withOxySharedPermissions.js +89 -0
  19. package/plugins/withSharedDeviceSessionProvider.js +26 -71
  20. package/src/ui/boot/runProviderColdBoot.ts +2 -2
  21. package/src/ui/session/__tests__/tokenLossRecovery.test.ts +2 -2
  22. package/src/ui/session/tokenLossRecovery.ts +1 -1
  23. package/android/src/main/java/so/oxy/identity/OxyIdentityProvider.kt +0 -77
  24. package/android/src/main/java/so/oxy/identity/OxyIdentityStore.kt +0 -66
  25. package/plugins/withSharedDeviceSessionReader.js +0 -57
  26. package/plugins/withSharedIdentityProvider.js +0 -83
  27. package/plugins/withSharedIdentityReader.js +0 -46
@@ -0,0 +1,97 @@
1
+ package so.oxy.security
2
+
3
+ import android.content.Context
4
+ import android.content.pm.PackageManager
5
+ import android.os.Binder
6
+ import android.os.Build
7
+ import android.util.Log
8
+ import java.security.MessageDigest
9
+
10
+ /**
11
+ * Which app is calling one of this package's ContentProviders, and whether it
12
+ * is an Oxy app.
13
+ *
14
+ * Oxy Android apps do not share a UID, so every cross-app call crosses a real
15
+ * process and permission boundary. The manifest already demands a
16
+ * `signature`-level permission (`so.oxy.permission.DEVICE_SESSION`); inside
17
+ * `call()` this checks again, because a permission is necessary but not
18
+ * sufficient:
19
+ *
20
+ * 1. **The package is an Oxy app** listed in [OXY_PACKAGES]. The caller is taken
21
+ * from `Binder.getCallingUid()`, never from anything the caller sends.
22
+ * 2. **It is signed with this app's certificate**: `hasSigningCertificate`
23
+ * (SHA-256, API 28+), `checkSignatures` below that.
24
+ *
25
+ * Keep [OXY_PACKAGES] identical to the identity host's list in Commons
26
+ * (`modules/oxy-identity-host`, `OxyCallerPolicy.kt`); a test compares them.
27
+ */
28
+ internal object OxyCallerPolicy {
29
+ private const val TAG = "OxyCallerPolicy"
30
+
31
+ val OXY_PACKAGES: Set<String> = setOf(
32
+ "earth.mention.app",
33
+ "earth.mention.app.dev",
34
+ "onl.alia.app",
35
+ "onl.alia.app.dev",
36
+ "com.allo.app",
37
+ "com.allo.app.dev",
38
+ "com.homiio.android",
39
+ "com.homiio.android.dev",
40
+ "so.oxy.crowdsource",
41
+ "so.oxy.crowdsource.dev",
42
+ "to.peable.app",
43
+ "to.peable.app.dev",
44
+ "so.oxy.atlas",
45
+ "so.oxy.atlas.dev",
46
+ "to.goway.app",
47
+ "to.goway.app.dev",
48
+ "so.oxy.move",
49
+ "so.oxy.move.dev",
50
+ "sh.willo.app",
51
+ "sh.willo.app.dev",
52
+ "now.moovo.app",
53
+ "now.moovo.app.dev",
54
+ "now.moovo.go",
55
+ "now.moovo.go.dev",
56
+ "now.moovo.tracker",
57
+ "now.moovo.tracker.dev",
58
+ "now.moovo.hub",
59
+ "now.moovo.hub.dev",
60
+ "so.oxy.noted",
61
+ "so.oxy.noted.dev",
62
+ "so.oxy.accounts",
63
+ "so.oxy.accounts.dev",
64
+ "so.oxy.commons",
65
+ "so.oxy.commons.dev",
66
+ )
67
+
68
+ /**
69
+ * The calling package when it is allow-listed and signed like this app, or
70
+ * null (logged with the package names and the method only, never a value).
71
+ */
72
+ fun resolveCaller(context: Context, method: String): String? {
73
+ val pm = context.packageManager
74
+ val uid = Binder.getCallingUid()
75
+ val packages = runCatching { pm.getPackagesForUid(uid) }.getOrNull().orEmpty()
76
+ val caller = packages.firstOrNull { it in OXY_PACKAGES && signedLikeSelf(context, it) }
77
+ if (caller == null) {
78
+ Log.w(TAG, "refused $method for ${packages.joinToString().ifEmpty { "uid $uid" }}")
79
+ }
80
+ return caller
81
+ }
82
+
83
+ private fun signedLikeSelf(context: Context, pkg: String): Boolean = runCatching {
84
+ val pm = context.packageManager
85
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
86
+ val info = pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
87
+ val signers = info.signingInfo?.apkContentsSigners.orEmpty()
88
+ signers.isNotEmpty() && signers.any { signer ->
89
+ val digest = MessageDigest.getInstance("SHA-256").digest(signer.toByteArray())
90
+ pm.hasSigningCertificate(pkg, digest, PackageManager.CERT_INPUT_SHA256)
91
+ }
92
+ } else {
93
+ @Suppress("DEPRECATION")
94
+ pm.checkSignatures(pkg, context.packageName) == PackageManager.SIGNATURE_MATCH
95
+ }
96
+ }.getOrDefault(false)
97
+ }
@@ -22,13 +22,7 @@ import so.oxy.storage.RecoveryPolicy
22
22
  * Neither ever rotates the credential — the server does not rotate it either.
23
23
  */
24
24
  internal object OxyBackgroundSessionStore {
25
- /**
26
- * Suffix is the hosting app's package name. Oxy apps share the `so.oxy.shared`
27
- * Android UID (and therefore one data directory), so a single global prefs name
28
- * would let one app that leaves `backgroundSession` off wipe another app's
29
- * live credential on launch. Scoping by package keeps each app's widget/sync
30
- * store independent.
31
- */
25
+ /** Suffix is the hosting app's package name, so the file says whose credential it holds. */
32
26
  private const val PREFS_NAME_PREFIX = "oxy_background_session_"
33
27
 
34
28
  private fun prefsName(context: Context): String = PREFS_NAME_PREFIX + context.packageName
@@ -61,13 +55,13 @@ internal object OxyBackgroundSessionStore {
61
55
 
62
56
  /**
63
57
  * [RecoveryPolicy.RebuildFileOnly] — this store must NEVER escalate to a
64
- * UID-shared master-key reset.
58
+ * master-key reset.
65
59
  *
66
60
  * What it holds is disposable: the app re-provisions the credential on its next
67
61
  * foreground, so "no credential" is a complete recovery that costs the user
68
62
  * nothing (background refreshes render signed out until then). A master-key reset
69
- * would wipe every other Oxy store sharing the alias — including the self-custody
70
- * identity keypair, which is IRREPLACEABLE. Trading someone else's unrecoverable
63
+ * would wipe every other encrypted store of this app sharing the alias — in
64
+ * Commons, including the identity signer store. Trading someone else's unrecoverable
71
65
  * data to save data we can simply ask for again is never the right trade, so the
72
66
  * escalation is not merely discouraged here, it is unreachable.
73
67
  */
@@ -18,11 +18,11 @@ import java.security.GeneralSecurityException
18
18
  *
19
19
  * It used to have a second value, `RegenerateSharedMasterKey`, which deleted the
20
20
  * androidx master key (`_androidx_security_master_key_`) when a rebuild failed.
21
- * That key is ONE Keystore entry for the whole `so.oxy.shared` UID and wraps the
22
- * keyset of every Oxy prefs file in every Oxy app, so deleting it let one app
23
- * make every sibling's encrypted prefs unreadable. It was removed for
24
- * OxyHQ/oxy#1388 and must not come back: no store in any Oxy app deletes a
25
- * UID-shared key.
21
+ * That key is ONE Keystore entry for the whole app and wraps the keyset of every
22
+ * encrypted prefs file in it, so deleting it to heal one file made every other
23
+ * file unreadable (when Oxy apps still shared a UID, every OTHER app's files
24
+ * too). It was removed for OxyHQ/oxy#1388 and must not come back: no store
25
+ * deletes a Keystore entry.
26
26
  */
27
27
  internal enum class RecoveryPolicy {
28
28
  /**
@@ -37,9 +37,9 @@ internal enum class RecoveryPolicy {
37
37
  * The ONE way this package opens a hardware-backed [EncryptedSharedPreferences]
38
38
  * file.
39
39
  *
40
- * Two stores are built on it — the cross-app identity keypair
41
- * (`so.oxy.identity.OxyIdentityStore`) and the background session credential
42
- * (`so.oxy.session.OxyBackgroundSessionStore`) — and both need the same two
40
+ * The stores built on it — the device session credential
41
+ * (`so.oxy.devicesession.OxyDeviceSessionStore`) and the background session
42
+ * credential (`so.oxy.session.OxyBackgroundSessionStore`) — all need the same two
43
43
  * non-obvious properties below. They are implemented once here rather than per
44
44
  * store: a second copy would be a copy of exactly the reasoning that is easiest
45
45
  * to get wrong, and the failure mode of getting it wrong is silent (every read
@@ -67,9 +67,9 @@ internal enum class RecoveryPolicy {
67
67
  * ## Keyset self-heal (CRITICAL)
68
68
  *
69
69
  * The androidx master key that wraps each file's Tink keyset lives under the
70
- * UID-scoped default alias [MasterKey.DEFAULT_MASTER_KEY_ALIAS] — ONE entry for
71
- * the whole `so.oxy.shared` UID. When a NEW package joins that shared UID, that
72
- * master key can be rotated/regenerated, leaving keysets already written on disk
70
+ * default alias [MasterKey.DEFAULT_MASTER_KEY_ALIAS] — ONE entry for the whole
71
+ * app. When that master key is regenerated (a Keystore invalidation, a restore
72
+ * from a device-to-device transfer), keysets already written on disk stay
73
73
  * wrapped under the OLD key. `create()` then fails GCM verification
74
74
  * (`AEADBadTag` -> `GeneralSecurityException`, or an unreadable keyset ->
75
75
  * `IOException`) on EVERY read/write, and `EncryptedSharedPreferences` never
@@ -81,14 +81,13 @@ internal enum class RecoveryPolicy {
81
81
  * keysets. If a fresh keyset still cannot be built, the failure propagates and
82
82
  * the `runCatching {}` at every call site degrades to "absent".
83
83
  *
84
- * The master key itself is NEVER deleted here. It is shared by the whole UID, so
85
- * deleting it would make every other Oxy app's prefs file unreadable (and each
86
- * would then wipe itself on its next open). When the master key is gone — the
87
- * Keystore of the UID was wiped because some Oxy app's storage was cleared —
88
- * androidx creates a new one on the next `MasterKey.Builder.build()` and each
84
+ * The master key itself is NEVER deleted here. It is shared by every file of the
85
+ * app, so deleting it would make every other one unreadable (and each would then
86
+ * wipe itself on its next open). When the master key is gone — the app's
87
+ * Keystore was wiped with its storage — androidx creates a new one on the next `MasterKey.Builder.build()` and each
89
88
  * file heals itself through the file-only rebuild above. What was IN those
90
89
  * files is lost with the Keystore either way; for the self-custody identity,
91
- * the keystore-independent device backup brings it back (docs/identity/device-backup.md).
90
+ * Commons' keystore-independent device backup brings it back (docs/identity/device-backup.md).
92
91
  *
93
92
  * None of this touches the app's device session, which lives in expo-secure-store
94
93
  * under a DISTINCT prefs file ("SecureStore") and DISTINCT keystore aliases
@@ -164,7 +163,7 @@ internal object OxyEncryptedPrefs {
164
163
  Log.w(
165
164
  TAG,
166
165
  "'$prefsName' is still unreadable after a file reset ($recovery). Giving up " +
167
- "without touching the UID-shared master key; its owner re-creates it.",
166
+ "without touching the app-wide master key; its owner re-creates it.",
168
167
  cause
169
168
  )
170
169
  throw cause
@@ -1,33 +1,32 @@
1
1
  import ExpoModulesCore
2
2
 
3
3
  /**
4
- * iOS no-op implementation of the shared Oxy identity bridge.
4
+ * iOS stub of the Commons identity bridge.
5
5
  *
6
- * On Apple platforms the cross-app identity share is handled directly by
7
- * `@oxy.so/core`'s `KeyManager` via the Keychain Access Group
8
- * (`group.so.oxy.shared`) — there is no ContentProvider equivalent to wrap. So
9
- * every function here resolves to `nil` / no-op, which makes the JS
10
- * `loadSharedIdentityBridge()` seam a pass-through on iOS: `KeyManager`'s iOS
11
- * branches keep using `expo-secure-store` with the keychain group untouched.
6
+ * On Apple platforms the Oxy identity is shared through the Keychain Access
7
+ * Group `group.so.oxy.shared`, which `@oxy.so/core`'s `KeyManager` reads
8
+ * directly; there is no Commons provider to call. Every function resolves `nil`,
9
+ * so `loadCommonsIdentityBridge()` answers "Commons could not help" and
10
+ * `KeyManager`'s iOS branches keep using the keychain group.
12
11
  */
13
12
  public class OxyIdentityModule: Module {
14
13
  public func definition() -> ModuleDefinition {
15
14
  Name("OxyIdentity")
16
15
 
17
- AsyncFunction("getShared") { () -> [String: String]? in
16
+ AsyncFunction("describe") { () -> [String: String]? in
18
17
  return nil
19
18
  }
20
19
 
21
- AsyncFunction("putShared") { (_ privateKey: String, _ publicKey: String) in
22
- // No-op on iOS: the keychain-access-group path in KeyManager owns writes.
20
+ AsyncFunction("proveIdentity") { (_ challenge: String) -> [String: String]? in
21
+ return nil
23
22
  }
24
23
 
25
- AsyncFunction("hasShared") { () -> Bool in
26
- return false
24
+ AsyncFunction("deriveScopedSeed") { (_ info: String) -> String? in
25
+ return nil
27
26
  }
28
27
 
29
- AsyncFunction("clearShared") { () in
30
- // No-op on iOS.
28
+ AsyncFunction("signSocialReceive") { (_ index: Int, _ digest: String) -> [String: String]? in
29
+ return nil
31
30
  }
32
31
  }
33
32
  }
@@ -70,8 +70,8 @@ async function detectOfflineHint() {
70
70
  *
71
71
  * Ordered pipeline:
72
72
  * 1. Complete an OAuth authorization-code return already on the URL (web)
73
- * 2. `runSessionColdBoot` — device-secret mint (+ native shared-key, or the
74
- * primary-identity-key lane in `sessionMode: 'identity'`)
73
+ * 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
74
+ * or the primary-identity-key lane in `sessionMode: 'identity'`)
75
75
  *
76
76
  * Step 1 is ACCOUNT-MODE ONLY: it commits whichever account the IdP resolves,
77
77
  * which for an identity-bound client is somebody else's account by construction.
@@ -29,7 +29,7 @@ var _core = require("@oxy.so/core");
29
29
  * this loop in a sign-out — so a real revocation still signs out.
30
30
  * - No credential, but a key-based lane can still re-establish (the native
31
31
  * shared identity, or an identity-bound client's own key) → try that a few
32
- * times, as the cold boot's shared-key step would, then sign out.
32
+ * times, as the cold boot's Commons-proof step would, then sign out.
33
33
  * - Neither → the session is over. Sign out now, as before.
34
34
  *
35
35
  * Framework-free and clock-injectable so the policy is unit tested without a
@@ -64,8 +64,8 @@ async function detectOfflineHint() {
64
64
  *
65
65
  * Ordered pipeline:
66
66
  * 1. Complete an OAuth authorization-code return already on the URL (web)
67
- * 2. `runSessionColdBoot` — device-secret mint (+ native shared-key, or the
68
- * primary-identity-key lane in `sessionMode: 'identity'`)
67
+ * 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
68
+ * or the primary-identity-key lane in `sessionMode: 'identity'`)
69
69
  *
70
70
  * Step 1 is ACCOUNT-MODE ONLY: it commits whichever account the IdP resolves,
71
71
  * which for an identity-bound client is somebody else's account by construction.
@@ -23,7 +23,7 @@
23
23
  * this loop in a sign-out — so a real revocation still signs out.
24
24
  * - No credential, but a key-based lane can still re-establish (the native
25
25
  * shared identity, or an identity-bound client's own key) → try that a few
26
- * times, as the cold boot's shared-key step would, then sign out.
26
+ * times, as the cold boot's Commons-proof step would, then sign out.
27
27
  * - Neither → the session is over. Sign out now, as before.
28
28
  *
29
29
  * Framework-free and clock-injectable so the policy is unit tested without a
@@ -44,8 +44,8 @@ export interface RunProviderColdBootOptions {
44
44
  *
45
45
  * Ordered pipeline:
46
46
  * 1. Complete an OAuth authorization-code return already on the URL (web)
47
- * 2. `runSessionColdBoot` — device-secret mint (+ native shared-key, or the
48
- * primary-identity-key lane in `sessionMode: 'identity'`)
47
+ * 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
48
+ * or the primary-identity-key lane in `sessionMode: 'identity'`)
49
49
  *
50
50
  * Step 1 is ACCOUNT-MODE ONLY: it commits whichever account the IdP resolves,
51
51
  * which for an identity-bound client is somebody else's account by construction.
@@ -44,8 +44,8 @@ export interface RunProviderColdBootOptions {
44
44
  *
45
45
  * Ordered pipeline:
46
46
  * 1. Complete an OAuth authorization-code return already on the URL (web)
47
- * 2. `runSessionColdBoot` — device-secret mint (+ native shared-key, or the
48
- * primary-identity-key lane in `sessionMode: 'identity'`)
47
+ * 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
48
+ * or the primary-identity-key lane in `sessionMode: 'identity'`)
49
49
  *
50
50
  * Step 1 is ACCOUNT-MODE ONLY: it commits whichever account the IdP resolves,
51
51
  * which for an identity-bound client is somebody else's account by construction.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/services",
3
- "version": "10.0.0",
3
+ "version": "11.0.0",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -155,7 +155,7 @@
155
155
  },
156
156
  "dependencies": {
157
157
  "@expo/config-plugins": "57.0.9",
158
- "@oxy.so/contracts": "^4.3.0",
158
+ "@oxy.so/contracts": "^4.4.0",
159
159
  "color": "^4.2.3"
160
160
  },
161
161
  "devDependencies": {
@@ -163,7 +163,7 @@
163
163
  "@commitlint/cli": "^21.2.1",
164
164
  "@commitlint/config-conventional": "^21.2.0",
165
165
  "@expo/vector-icons": "^15.0.3",
166
- "@oxy.so/core": "3.2.0",
166
+ "@oxy.so/core": "4.0.0",
167
167
  "@react-native-async-storage/async-storage": "^2.0.0",
168
168
  "@react-native-community/netinfo": "^12.0.1",
169
169
  "@react-native/babel-preset": "^0.86.2",
@@ -178,7 +178,7 @@
178
178
  "@types/jest": "^30.0.0",
179
179
  "@types/node": "^20.19.43",
180
180
  "@types/react": "*",
181
- "@oxy.so/bloom": "^6.0.0",
181
+ "@oxy.so/bloom": "^6.2.0",
182
182
  "copyfiles": "^2.4.1",
183
183
  "expo-clipboard": "~57.0.2",
184
184
  "expo-constants": "~57.0.5",
@@ -212,11 +212,11 @@
212
212
  "typescript": "^5.9.2",
213
213
  "zustand": "^5.0.14"
214
214
  },
215
- "//@oxy.so/bloom": "Bloom peer tracks the measured major: ^6.0.0 includes canonical appearance/tone/size props and shared Surface. Keep its upper bound and the root catalog in sync to prevent duplicate context instances. Services 10 removes FollowButton small/medium/large aliases in favor of sm/md/lg.",
215
+ "//@oxy.so/bloom": "RE-RAISE THIS FLOOR ON EVERY BREAKING BLOOM RELEASE, in the same commit as the root package.json catalog entry and the code that requires it. The breaking position is the MAJOR, so `^3.0.1` means `>=3.0.1 <4.0.0`: 3.x minors are admitted on semver's additive promise, and Bloom 4.0.0 is refused until someone raises this deliberately. The CAP is the part that is load-bearing. An earlier revision of this key recorded the opposite trade -- an uncapped `>=` floor, accepted so that intentionally-breaking Bloom 0.x minors would not be blocked -- and that trade is closed by evidence rather than by preference: an unbounded range is exactly what let `bun install` pair this package with Bloom 1.0.0 and emit no warning at all, and 1.0.0 broke it four ways (a deleted provider, a renamed confirm option, a renamed Chip fill, and two REMOVED SUBPATHS that fail at bundle time, before any typecheck runs). A floor is a claim about what was measured; a cap is the claim that nothing beyond it was. RAISED TO 3.0.1 from `^2.1.0`. Bloom 3.0.0 breaks exactly two things -- `PageHeader` defaults to `presentation=\"floating\"` and stops hiding the title under `transparent`, and `Field disabled` now reaches the control that reads the field context -- and THIS PACKAGE IMPORTS NEITHER: it renders no `PageHeader` and no `Field`, and 3.0 removes no subpath (unlike 2.0.0, which is why that floor cost code changes and this one does not). Its six `transparent` occurrences are CSS colour values, not the PageHeader prop. The cap had to move rather than widen to a union, because leaving `^2.1.0` in place is what made `bun install` resolve a SECOND, nested Bloom 2.12.4 under this package for any app on Bloom 3 -- two copies of a library whose 3.0 contracts (`control-surface`, `TopEdgeProvider`, `ScrollOffsetProvider`) are React contexts, so a provider from one copy cannot be read by a consumer from the other. Mention's `oxyRuntimeCompatibility` test is what caught it; nothing in a typecheck can. RAISED AGAIN TO 3.2.0 from `^3.0.1`, for the same class of reason the 2.x note records, only in the other direction: 3.2.0 ADDS the per-glyph icon subpaths (`@oxy.so/bloom/icons/RiAtLine`) that this package's screens now import instead of the `@oxy.so/bloom/icons` barrel, and a missing subpath fails at bundle time, in the consumer's Metro, long after this package has typechecked and shipped. `^3.0.1` would have admitted Bloom 3.0.1 and 3.1.0, neither of which has those subpaths, so the floor is the only thing standing between a consumer and a red build. Nothing else about 3.1.0 or 3.2.0 is required here; the cap stays at the major. RAISED TO ^4.2.0: FollowButton now delegates its visuals to Bloom media-header FollowButton with loading, textStyle and large size; the new shared UI contracts require one Bloom4 context across Services and its consumer. RAISED TO ^4.31.0: the sign-in and account screens type one-time codes into `InputOtp` with `type=\"alphanumeric\"` (the long sign-in code and backup codes), which 4.31.0 adds; older 4.x drops the letters. RAISED TO ^4.35.0: every password field passes `revealable` to `TextFieldInput` (the eye button, 4.35.0); older 4.x drops the prop silently and the password cannot be shown. The provider also mounts `@oxy.so/bloom/locale` (4.32.0+). RAISED TO ^5.1.0 (services 9.0.0, a major because consumers must move to Bloom 5 with it): Bloom 5.0.0 types `Card`'s `style` without radius, border width or `overflow` (geometry goes through `radius`/`border`/`clipContent`) and stops clipping a Card's children by default, so `BenefitList`'s `overflow: 'visible'` became both a type error and unnecessary; it is removed. The cap MOVES rather than widening to a `^4 || ^5` union, for the reason the 3.0.1 note records: a union lets an app resolve a second, nested Bloom copy and split the React contexts. The floor is 5.1.0 because that is the version measured here (every package builds and tests against it); 5.0.0 was not measured. RAISED TO ^6.0.0 (services 10.0.0, a major because consumers must move to Bloom 6 with it, and because this package's own `FollowButton`/`FollowTargetButton` `size` moves with Bloom's vocabulary): Bloom 6.0.0 removes the duplicate spellings its 5.x types still admitted, and this package used four of them. The control `size` narrows to `xs|sm|md|lg`, so every `small`/`medium`/`large` handed to a Bloom control became `sm`/`md`/`lg`, and the Follow buttons' public `size` became `sm|md|lg` because it is passed straight through to Bloom's media-header `FollowButton`. `Switch` is controlled by `checked`/`onCheckedChange`, not `value`/`onValueChange`. `Button` has no `variant`: each call site names `appearance` and `tone` (the pair the 5.x recipe resolved the variant to, so nothing repaints), `LinkButton`'s colour is `linkTone`, and `PeableButton` renders `InverseButton`. `Card` takes `appearance=\"outline\"` for `variant=\"outlined\"`. The deprecated spellings Bloom 6 still accepts (`isInvalid`, Chip `color`/`variant`) are not breaks and are left alone. The cap MOVES rather than widening to a `^5 || ^6` union, for the reason the 3.0.1 note records: a union lets an app resolve a second, nested Bloom copy and split the React contexts. The floor is 6.0.0 because that is the version measured when this raise was made; the workspace catalog has since moved to ^6.2.0, which adds only the `cover-header` subpath this package does not import, so the floor stays.",
216
216
  "peerDependencies": {
217
217
  "@expo/vector-icons": "^15.0.3",
218
218
  "@oxy.so/bloom": "^6.0.0",
219
- "@oxy.so/core": "^3.2.0",
219
+ "@oxy.so/core": "^4.0.0",
220
220
  "@react-native-async-storage/async-storage": "^2.0.0",
221
221
  "@react-native-community/netinfo": ">=11.4.1",
222
222
  "@tanstack/query-async-storage-persister": "^5.101",
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Config plugin: withOxySharedPermissions (EVERY Oxy Android app).
3
+ *
4
+ * Oxy Android apps do not share a UID. They share the Oxy identity and the
5
+ * device session with Commons (and Accounts) through signature-protected
6
+ * ContentProvider `call()` IPC, the way apps use `AccountManager`. Crossing a UID
7
+ * needs two `signature`-level permissions:
8
+ *
9
+ * - `so.oxy.permission.IDENTITY` — Commons' identity host
10
+ * (`so.oxy.commons[.dev].identity`): public key, challenge proofs, scoped
11
+ * derivations. Never the private key.
12
+ * - `so.oxy.permission.DEVICE_SESSION` — the device-session hosts
13
+ * (`so.oxy.commons[.dev].devicesession`, `so.oxy.accounts[.dev].devicesession`).
14
+ *
15
+ * This plugin makes the app both DECLARE and REQUEST both, and adds `<queries>`
16
+ * for every host authority:
17
+ *
18
+ * - Declared by every app, not only by the hosts: a signature permission is
19
+ * granted when the requesting app is installed, if the declaring app is
20
+ * already there. With every app declaring it, install order never matters.
21
+ * Android accepts the same declaration from several packages signed with one
22
+ * certificate, and rejects it from a differently-signed one
23
+ * (`INSTALL_FAILED_DUPLICATE_PERMISSION`), which is the protection we want.
24
+ * Every declaration must be identical, which is why this ONE plugin owns
25
+ * them; the provider plugins do not declare anything.
26
+ * - `<queries>`: Android 11+ package visibility hides a provider the app has
27
+ * not declared an interest in, and `ContentResolver.call` then silently finds
28
+ * nothing.
29
+ *
30
+ * `signature` is the whole trust boundary at the manifest level; each provider
31
+ * also checks the calling package and its certificate itself.
32
+ */
33
+ const { withAndroidManifest } = require('@expo/config-plugins');
34
+
35
+ const IDENTITY_PERMISSION = 'so.oxy.permission.IDENTITY';
36
+ const DEVICE_SESSION_PERMISSION = 'so.oxy.permission.DEVICE_SESSION';
37
+ const PERMISSIONS = [IDENTITY_PERMISSION, DEVICE_SESSION_PERMISSION];
38
+
39
+ /**
40
+ * Every host authority an Oxy app calls. Keep in step with `COMMONS_AUTHORITIES`
41
+ * in `OxyIdentityModule.kt` and `HOST_AUTHORITIES` in `OxyDeviceSessionModule.kt`
42
+ * (a test compares them).
43
+ */
44
+ const HOST_AUTHORITIES = [
45
+ 'so.oxy.commons.identity',
46
+ 'so.oxy.commons.dev.identity',
47
+ 'so.oxy.commons.devicesession',
48
+ 'so.oxy.commons.dev.devicesession',
49
+ 'so.oxy.accounts.devicesession',
50
+ 'so.oxy.accounts.dev.devicesession',
51
+ ];
52
+
53
+ function withOxySharedPermissions(config) {
54
+ return withAndroidManifest(config, (modConfig) => {
55
+ const manifest = modConfig.modResults.manifest;
56
+
57
+ manifest.permission = manifest.permission ?? [];
58
+ manifest['uses-permission'] = manifest['uses-permission'] ?? [];
59
+ for (const name of PERMISSIONS) {
60
+ if (!manifest.permission.some((p) => p.$['android:name'] === name)) {
61
+ manifest.permission.push({
62
+ $: { 'android:name': name, 'android:protectionLevel': 'signature' },
63
+ });
64
+ }
65
+ if (!manifest['uses-permission'].some((p) => p.$['android:name'] === name)) {
66
+ manifest['uses-permission'].push({ $: { 'android:name': name } });
67
+ }
68
+ }
69
+
70
+ manifest.queries = manifest.queries ?? [];
71
+ if (manifest.queries.length === 0) {
72
+ manifest.queries.push({});
73
+ }
74
+ const queries = manifest.queries[0];
75
+ queries.provider = queries.provider ?? [];
76
+ for (const authority of HOST_AUTHORITIES) {
77
+ if (!queries.provider.some((p) => p.$['android:authorities'] === authority)) {
78
+ queries.provider.push({ $: { 'android:authorities': authority } });
79
+ }
80
+ }
81
+
82
+ return modConfig;
83
+ });
84
+ }
85
+
86
+ module.exports = withOxySharedPermissions;
87
+ module.exports.IDENTITY_PERMISSION = IDENTITY_PERMISSION;
88
+ module.exports.DEVICE_SESSION_PERMISSION = DEVICE_SESSION_PERMISSION;
89
+ module.exports.HOST_AUTHORITIES = HOST_AUTHORITIES;
@@ -1,86 +1,39 @@
1
1
  /**
2
- * Config plugin: withSharedDeviceSessionProvider (UID-member hub apps).
2
+ * Config plugin: withSharedDeviceSessionProvider (the device-session HOSTS:
3
+ * Commons and Accounts, prod and dev).
3
4
  *
4
- * The shared DeviceSession credential is how several official Oxy apps end up on
5
+ * The shared DeviceSession credential is how the official Oxy apps end up on
5
6
  * ONE `DeviceSession` — and therefore one globally active account context —
6
- * WITHOUT any of them reading Commons's private identity key. This plugin wires
7
- * the Android side of `@oxy.so/services`:
7
+ * without any of them touching the identity key. Oxy apps do not share a UID,
8
+ * so the credential lives in the hosts' own storage, and this plugin declares
9
+ * the provider every other app reads and writes it through:
10
+ * `OxyDeviceSessionProvider` at `${applicationId}.devicesession` (AGP
11
+ * substitutes `${applicationId}` at build), guarded by the `signature`-level
12
+ * `so.oxy.permission.DEVICE_SESSION`.
8
13
  *
9
- * - Defines a `signature`-level permission
10
- * `so.oxy.shared.permission.READ_DEVICE_SESSION`. `signature` means only apps
11
- * signed with the SAME certificate (the shared Oxy release keystore) can hold
12
- * it — the trust boundary is the signing key, not the deprecated `sharedUserId`.
13
- * - Requests that same permission so this app can also read cross-authority.
14
- * - Declares `OxyDeviceSessionProvider` at authority
15
- * `${applicationId}.devicesession` (AGP substitutes `${applicationId}` at
16
- * build), guarded by that permission. The provider is READ-only across the
17
- * process boundary: a sibling may join the device session, never overwrite it.
18
- * - Adds `<queries>` entries so Android 11+ package-visibility filtering never
19
- * hides the sibling providers from the resolver.
14
+ * It declares NO permission itself: every Oxy app, host or not, declares and
15
+ * requests both Oxy permissions through `withOxySharedPermissions`, so the
16
+ * declarations stay identical. Apply that plugin too.
20
17
  *
21
- * WHICH APPS USE THIS PLUGIN: exactly the apps listed in `PROVIDER_AUTHORITIES`
22
- * below, which must be apps inside the `so.oxy.shared` UID. UID members share one
23
- * data directory, so they all serve the SAME file and the sweep in
24
- * `OxyDeviceSessionModule` is deterministic whichever one answers. An app that is
25
- * same-signature but outside that UID must use `withSharedDeviceSessionReader`
26
- * instead — hosting from there would publish a second, independent copy of "the
27
- * shared credential" and make the winner depend on list order.
28
- *
29
- * Keep this list identical to `PROVIDER_AUTHORITIES` in
30
- * `android/src/main/java/so/oxy/devicesession/OxyDeviceSessionModule.kt`.
31
- *
32
- * This is deliberately a SEPARATE permission and a SEPARATE provider from
33
- * `withSharedIdentityProvider`. Two secrets with different blast radii: the
34
- * identity key is self-custody and irreplaceable, the device credential is
35
- * server-revocable and re-publishable. An app that only needs a session gets only
36
- * the second one.
18
+ * WHICH APPS USE THIS PLUGIN: exactly the apps whose authorities are listed in
19
+ * `HOST_AUTHORITIES` of `OxyDeviceSessionModule.kt` (a test compares them).
37
20
  */
38
21
  const { withAndroidManifest } = require('@expo/config-plugins');
39
22
 
40
- const READ_DEVICE_SESSION_PERMISSION = 'so.oxy.shared.permission.READ_DEVICE_SESSION';
23
+ const DEVICE_SESSION_PERMISSION = 'so.oxy.permission.DEVICE_SESSION';
41
24
  const PROVIDER_CLASS = 'so.oxy.devicesession.OxyDeviceSessionProvider';
42
- const PROVIDER_AUTHORITIES = [
43
- 'so.oxy.accounts.devicesession',
44
- 'so.oxy.accounts.dev.devicesession',
25
+
26
+ /** The authorities this plugin can end up hosting, one per host app. */
27
+ const HOST_AUTHORITIES = [
45
28
  'so.oxy.commons.devicesession',
46
29
  'so.oxy.commons.dev.devicesession',
30
+ 'so.oxy.accounts.devicesession',
31
+ 'so.oxy.accounts.dev.devicesession',
47
32
  ];
48
33
 
49
- module.exports = function withSharedDeviceSessionProvider(config) {
34
+ function withSharedDeviceSessionProvider(config) {
50
35
  return withAndroidManifest(config, (modConfig) => {
51
36
  const manifest = modConfig.modResults.manifest;
52
-
53
- // 1. Define the signature-level permission.
54
- manifest['permission'] = manifest['permission'] ?? [];
55
- if (!manifest['permission'].some((p) => p.$['android:name'] === READ_DEVICE_SESSION_PERMISSION)) {
56
- manifest['permission'].push({
57
- $: {
58
- 'android:name': READ_DEVICE_SESSION_PERMISSION,
59
- 'android:protectionLevel': 'signature',
60
- },
61
- });
62
- }
63
-
64
- // 2. Request it (a hub app reads its siblings too — prod ⇆ dev variant).
65
- manifest['uses-permission'] = manifest['uses-permission'] ?? [];
66
- if (!manifest['uses-permission'].some((p) => p.$['android:name'] === READ_DEVICE_SESSION_PERMISSION)) {
67
- manifest['uses-permission'].push({ $: { 'android:name': READ_DEVICE_SESSION_PERMISSION } });
68
- }
69
-
70
- // 3. Make the sibling provider authorities visible under package filtering.
71
- manifest['queries'] = manifest['queries'] ?? [];
72
- if (manifest['queries'].length === 0) {
73
- manifest['queries'].push({});
74
- }
75
- const queries = manifest['queries'][0];
76
- queries.provider = queries.provider ?? [];
77
- for (const authority of PROVIDER_AUTHORITIES) {
78
- if (!queries.provider.some((p) => p.$['android:authorities'] === authority)) {
79
- queries.provider.push({ $: { 'android:authorities': authority } });
80
- }
81
- }
82
-
83
- // 4. Host the provider.
84
37
  const app = manifest.application?.[0];
85
38
  if (!app) {
86
39
  throw new Error('withSharedDeviceSessionProvider: AndroidManifest has no <application>');
@@ -92,11 +45,13 @@ module.exports = function withSharedDeviceSessionProvider(config) {
92
45
  'android:name': PROVIDER_CLASS,
93
46
  'android:authorities': '${applicationId}.devicesession',
94
47
  'android:exported': 'true',
95
- 'android:permission': READ_DEVICE_SESSION_PERMISSION,
48
+ 'android:permission': DEVICE_SESSION_PERMISSION,
96
49
  },
97
50
  });
98
51
  }
99
-
100
52
  return modConfig;
101
53
  });
102
- };
54
+ }
55
+
56
+ module.exports = withSharedDeviceSessionProvider;
57
+ module.exports.HOST_AUTHORITIES = HOST_AUTHORITIES;
@@ -91,8 +91,8 @@ export interface RunProviderColdBootOptions {
91
91
  *
92
92
  * Ordered pipeline:
93
93
  * 1. Complete an OAuth authorization-code return already on the URL (web)
94
- * 2. `runSessionColdBoot` — device-secret mint (+ native shared-key, or the
95
- * primary-identity-key lane in `sessionMode: 'identity'`)
94
+ * 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
95
+ * or the primary-identity-key lane in `sessionMode: 'identity'`)
96
96
  *
97
97
  * Step 1 is ACCOUNT-MODE ONLY: it commits whichever account the IdP resolves,
98
98
  * which for an identity-bound client is somebody else's account by construction.
@@ -102,7 +102,7 @@ describe('token loss recovery', () => {
102
102
  });
103
103
 
104
104
  it('re-establishes through the shared identity when the credential is gone, like the cold boot does', async () => {
105
- const h = harness([null, 'from-shared-key']);
105
+ const h = harness([null, 'from-commons-proof']);
106
106
  h.state.credential = false;
107
107
  h.state.keyed = true;
108
108
  const recovery = createTokenLossRecovery(h.deps);
@@ -111,7 +111,7 @@ describe('token loss recovery', () => {
111
111
  await runToCompletion(recovery);
112
112
 
113
113
  expect(h.signOutLocally).not.toHaveBeenCalled();
114
- expect(h.state.token).toBe('from-shared-key');
114
+ expect(h.state.token).toBe('from-commons-proof');
115
115
  });
116
116
 
117
117
  it('gives the key-based lane a bounded number of tries, then signs out', async () => {
@@ -21,7 +21,7 @@
21
21
  * this loop in a sign-out — so a real revocation still signs out.
22
22
  * - No credential, but a key-based lane can still re-establish (the native
23
23
  * shared identity, or an identity-bound client's own key) → try that a few
24
- * times, as the cold boot's shared-key step would, then sign out.
24
+ * times, as the cold boot's Commons-proof step would, then sign out.
25
25
  * - Neither → the session is over. Sign out now, as before.
26
26
  *
27
27
  * Framework-free and clock-injectable so the policy is unit tested without a