@oxy.so/services 11.9.0 → 11.12.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 (210) hide show
  1. package/lib/commonjs/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  2. package/lib/commonjs/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  3. package/lib/commonjs/assets/assets/fonts/icons/manifest.json +5 -4
  4. package/lib/commonjs/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  5. package/lib/commonjs/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  6. package/lib/commonjs/assets/fonts/icons/manifest.json +5 -4
  7. package/lib/commonjs/index.js +45 -0
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/ui/appAssertion/AppAssertionContext.js +79 -0
  10. package/lib/commonjs/ui/appAssertion/AppAssertionContext.js.map +1 -0
  11. package/lib/commonjs/ui/appAssertion/appAssertionManager.js +309 -0
  12. package/lib/commonjs/ui/appAssertion/appAssertionManager.js.map +1 -0
  13. package/lib/commonjs/ui/appAssertion/appIntegrityPlatform.js +18 -0
  14. package/lib/commonjs/ui/appAssertion/appIntegrityPlatform.js.map +1 -0
  15. package/lib/commonjs/ui/appAssertion/appIntegrityPlatform.native.js +30 -0
  16. package/lib/commonjs/ui/appAssertion/appIntegrityPlatform.native.js.map +1 -0
  17. package/lib/commonjs/ui/appAssertion/appIntegrityTypes.js +6 -0
  18. package/lib/commonjs/ui/appAssertion/appIntegrityTypes.js.map +1 -0
  19. package/lib/commonjs/ui/components/AccountInvitationAcceptance.js +97 -0
  20. package/lib/commonjs/ui/components/AccountInvitationAcceptance.js.map +1 -0
  21. package/lib/commonjs/ui/components/DisplayCurrencySelector.js +49 -0
  22. package/lib/commonjs/ui/components/DisplayCurrencySelector.js.map +1 -0
  23. package/lib/commonjs/ui/components/fileManagement/FileAppAccessSection.js +121 -0
  24. package/lib/commonjs/ui/components/fileManagement/FileAppAccessSection.js.map +1 -0
  25. package/lib/commonjs/ui/components/fileManagement/FileDetailsModal.js +6 -2
  26. package/lib/commonjs/ui/components/fileManagement/FileDetailsModal.js.map +1 -1
  27. package/lib/commonjs/ui/context/OxyContext.js +10 -1
  28. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  29. package/lib/commonjs/ui/hooks/mutations/mutationKeys.js +4 -0
  30. package/lib/commonjs/ui/hooks/mutations/mutationKeys.js.map +1 -1
  31. package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js +49 -1
  32. package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js.map +1 -1
  33. package/lib/commonjs/ui/hooks/queries/queryKeys.js +6 -1
  34. package/lib/commonjs/ui/hooks/queries/queryKeys.js.map +1 -1
  35. package/lib/commonjs/ui/hooks/queries/useFileQueries.js +35 -1
  36. package/lib/commonjs/ui/hooks/queries/useFileQueries.js.map +1 -1
  37. package/lib/commonjs/ui/hooks/useDisplayCurrency.js +77 -0
  38. package/lib/commonjs/ui/hooks/useDisplayCurrency.js.map +1 -0
  39. package/lib/commonjs/ui/hooks/useDisplayPrice.js +59 -0
  40. package/lib/commonjs/ui/hooks/useDisplayPrice.js.map +1 -0
  41. package/lib/commonjs/ui/icons/subsetGlyphMaps.js +1 -0
  42. package/lib/commonjs/ui/icons/subsetGlyphMaps.js.map +1 -1
  43. package/lib/commonjs/ui/index.js +7 -0
  44. package/lib/commonjs/ui/index.js.map +1 -1
  45. package/lib/commonjs/ui/screens/AccountMembersScreen.js +63 -20
  46. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
  47. package/lib/commonjs/ui/screens/PreferencesScreen.js +4 -3
  48. package/lib/commonjs/ui/screens/PreferencesScreen.js.map +1 -1
  49. package/lib/module/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  50. package/lib/module/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  51. package/lib/module/assets/assets/fonts/icons/manifest.json +5 -4
  52. package/lib/module/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  53. package/lib/module/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  54. package/lib/module/assets/fonts/icons/manifest.json +5 -4
  55. package/lib/module/index.js +8 -0
  56. package/lib/module/index.js.map +1 -1
  57. package/lib/module/ui/appAssertion/AppAssertionContext.js +72 -0
  58. package/lib/module/ui/appAssertion/AppAssertionContext.js.map +1 -0
  59. package/lib/module/ui/appAssertion/appAssertionManager.js +304 -0
  60. package/lib/module/ui/appAssertion/appAssertionManager.js.map +1 -0
  61. package/lib/module/ui/appAssertion/appIntegrityPlatform.js +14 -0
  62. package/lib/module/ui/appAssertion/appIntegrityPlatform.js.map +1 -0
  63. package/lib/module/ui/appAssertion/appIntegrityPlatform.native.js +26 -0
  64. package/lib/module/ui/appAssertion/appIntegrityPlatform.native.js.map +1 -0
  65. package/lib/module/ui/appAssertion/appIntegrityTypes.js +4 -0
  66. package/lib/module/ui/appAssertion/appIntegrityTypes.js.map +1 -0
  67. package/lib/module/ui/components/AccountInvitationAcceptance.js +94 -0
  68. package/lib/module/ui/components/AccountInvitationAcceptance.js.map +1 -0
  69. package/lib/module/ui/components/DisplayCurrencySelector.js +44 -0
  70. package/lib/module/ui/components/DisplayCurrencySelector.js.map +1 -0
  71. package/lib/module/ui/components/fileManagement/FileAppAccessSection.js +116 -0
  72. package/lib/module/ui/components/fileManagement/FileAppAccessSection.js.map +1 -0
  73. package/lib/module/ui/components/fileManagement/FileDetailsModal.js +6 -2
  74. package/lib/module/ui/components/fileManagement/FileDetailsModal.js.map +1 -1
  75. package/lib/module/ui/context/OxyContext.js +10 -1
  76. package/lib/module/ui/context/OxyContext.js.map +1 -1
  77. package/lib/module/ui/hooks/mutations/mutationKeys.js +4 -0
  78. package/lib/module/ui/hooks/mutations/mutationKeys.js.map +1 -1
  79. package/lib/module/ui/hooks/mutations/useAccountMutations.js +47 -0
  80. package/lib/module/ui/hooks/mutations/useAccountMutations.js.map +1 -1
  81. package/lib/module/ui/hooks/queries/queryKeys.js +6 -1
  82. package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
  83. package/lib/module/ui/hooks/queries/useFileQueries.js +33 -1
  84. package/lib/module/ui/hooks/queries/useFileQueries.js.map +1 -1
  85. package/lib/module/ui/hooks/useDisplayCurrency.js +72 -0
  86. package/lib/module/ui/hooks/useDisplayCurrency.js.map +1 -0
  87. package/lib/module/ui/hooks/useDisplayPrice.js +56 -0
  88. package/lib/module/ui/hooks/useDisplayPrice.js.map +1 -0
  89. package/lib/module/ui/icons/subsetGlyphMaps.js +1 -0
  90. package/lib/module/ui/icons/subsetGlyphMaps.js.map +1 -1
  91. package/lib/module/ui/index.js +1 -0
  92. package/lib/module/ui/index.js.map +1 -1
  93. package/lib/module/ui/screens/AccountMembersScreen.js +63 -20
  94. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
  95. package/lib/module/ui/screens/PreferencesScreen.js +4 -3
  96. package/lib/module/ui/screens/PreferencesScreen.js.map +1 -1
  97. package/lib/typescript/commonjs/index.d.ts +7 -0
  98. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/appAssertion/AppAssertionContext.d.ts +41 -0
  100. package/lib/typescript/commonjs/ui/appAssertion/AppAssertionContext.d.ts.map +1 -0
  101. package/lib/typescript/commonjs/ui/appAssertion/appAssertionManager.d.ts +92 -0
  102. package/lib/typescript/commonjs/ui/appAssertion/appAssertionManager.d.ts.map +1 -0
  103. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.d.ts +9 -0
  104. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.d.ts.map +1 -0
  105. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.native.d.ts +3 -0
  106. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.native.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityTypes.d.ts +22 -0
  108. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityTypes.d.ts.map +1 -0
  109. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts +5 -0
  110. package/lib/typescript/commonjs/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts +3 -0
  112. package/lib/typescript/commonjs/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/ui/components/fileManagement/FileAppAccessSection.d.ts +15 -0
  114. package/lib/typescript/commonjs/ui/components/fileManagement/FileAppAccessSection.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/ui/components/fileManagement/FileDetailsModal.d.ts +2 -1
  116. package/lib/typescript/commonjs/ui/components/fileManagement/FileDetailsModal.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/ui/hooks/mutations/mutationKeys.d.ts +3 -0
  119. package/lib/typescript/commonjs/ui/hooks/mutations/mutationKeys.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +20 -2
  121. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +5 -0
  123. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts +11 -1
  125. package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
  127. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  128. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts +11 -0
  129. package/lib/typescript/commonjs/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
  130. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts +6 -0
  131. package/lib/typescript/commonjs/ui/hooks/useDisplayPrice.d.ts.map +1 -0
  132. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  133. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  134. package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts +1 -0
  135. package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
  136. package/lib/typescript/commonjs/ui/index.d.ts +2 -0
  137. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +1 -1
  139. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  140. package/lib/typescript/commonjs/ui/screens/PreferencesScreen.d.ts.map +1 -1
  141. package/lib/typescript/module/index.d.ts +7 -0
  142. package/lib/typescript/module/index.d.ts.map +1 -1
  143. package/lib/typescript/module/ui/appAssertion/AppAssertionContext.d.ts +41 -0
  144. package/lib/typescript/module/ui/appAssertion/AppAssertionContext.d.ts.map +1 -0
  145. package/lib/typescript/module/ui/appAssertion/appAssertionManager.d.ts +92 -0
  146. package/lib/typescript/module/ui/appAssertion/appAssertionManager.d.ts.map +1 -0
  147. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.d.ts +9 -0
  148. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.d.ts.map +1 -0
  149. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.native.d.ts +3 -0
  150. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.native.d.ts.map +1 -0
  151. package/lib/typescript/module/ui/appAssertion/appIntegrityTypes.d.ts +22 -0
  152. package/lib/typescript/module/ui/appAssertion/appIntegrityTypes.d.ts.map +1 -0
  153. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts +5 -0
  154. package/lib/typescript/module/ui/components/AccountInvitationAcceptance.d.ts.map +1 -0
  155. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts +3 -0
  156. package/lib/typescript/module/ui/components/DisplayCurrencySelector.d.ts.map +1 -0
  157. package/lib/typescript/module/ui/components/fileManagement/FileAppAccessSection.d.ts +15 -0
  158. package/lib/typescript/module/ui/components/fileManagement/FileAppAccessSection.d.ts.map +1 -0
  159. package/lib/typescript/module/ui/components/fileManagement/FileDetailsModal.d.ts +2 -1
  160. package/lib/typescript/module/ui/components/fileManagement/FileDetailsModal.d.ts.map +1 -1
  161. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  162. package/lib/typescript/module/ui/hooks/mutations/mutationKeys.d.ts +3 -0
  163. package/lib/typescript/module/ui/hooks/mutations/mutationKeys.d.ts.map +1 -1
  164. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +20 -2
  165. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  166. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts +5 -0
  167. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  168. package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts +11 -1
  169. package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
  170. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +2 -1
  171. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  172. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts +11 -0
  173. package/lib/typescript/module/ui/hooks/useDisplayCurrency.d.ts.map +1 -0
  174. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts +6 -0
  175. package/lib/typescript/module/ui/hooks/useDisplayPrice.d.ts.map +1 -0
  176. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  177. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  178. package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts +1 -0
  179. package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
  180. package/lib/typescript/module/ui/index.d.ts +2 -0
  181. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  182. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +1 -1
  183. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  184. package/lib/typescript/module/ui/screens/PreferencesScreen.d.ts.map +1 -1
  185. package/package.json +9 -6
  186. package/src/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  187. package/src/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  188. package/src/assets/fonts/icons/manifest.json +5 -4
  189. package/src/index.ts +17 -0
  190. package/src/ui/appAssertion/AppAssertionContext.tsx +109 -0
  191. package/src/ui/appAssertion/appAssertionManager.ts +363 -0
  192. package/src/ui/appAssertion/appIntegrityPlatform.native.ts +27 -0
  193. package/src/ui/appAssertion/appIntegrityPlatform.ts +12 -0
  194. package/src/ui/appAssertion/appIntegrityTypes.ts +23 -0
  195. package/src/ui/components/AccountInvitationAcceptance.tsx +90 -0
  196. package/src/ui/components/DisplayCurrencySelector.tsx +38 -0
  197. package/src/ui/components/__tests__/AccountInvitationAcceptance.test.tsx +61 -0
  198. package/src/ui/components/fileManagement/FileAppAccessSection.tsx +111 -0
  199. package/src/ui/components/fileManagement/FileDetailsModal.tsx +5 -1
  200. package/src/ui/context/OxyContext.tsx +13 -1
  201. package/src/ui/hooks/mutations/mutationKeys.ts +5 -0
  202. package/src/ui/hooks/mutations/useAccountMutations.ts +33 -0
  203. package/src/ui/hooks/queries/queryKeys.ts +5 -0
  204. package/src/ui/hooks/queries/useFileQueries.ts +44 -2
  205. package/src/ui/hooks/useDisplayCurrency.ts +64 -0
  206. package/src/ui/hooks/useDisplayPrice.ts +40 -0
  207. package/src/ui/icons/subsetGlyphMaps.ts +1 -0
  208. package/src/ui/index.ts +2 -0
  209. package/src/ui/screens/AccountMembersScreen.tsx +71 -27
  210. package/src/ui/screens/PreferencesScreen.tsx +2 -0
@@ -0,0 +1,363 @@
1
+ /**
2
+ * The app's assertion of which app it is (ADR 0045), obtained and refreshed for
3
+ * the signed-in session without any app code.
4
+ *
5
+ * One manager per `OxyProvider`. For the active session it resolves the app's
6
+ * application id from its registered `clientId`, proves the app by platform —
7
+ * the browser's `Origin` on the web, Play Integrity on Android, App Attest on
8
+ * iOS — trades the proof for an Oxy-signed `OXY-APP+JWT`, and refreshes it a
9
+ * minute before it expires. A session change drops the assertion at once: an
10
+ * assertion names exactly one person and one session.
11
+ *
12
+ * Outcomes are values, never thrown: `ready`, `unsupported` (this build cannot
13
+ * prove itself: no `clientId`, no native module, App Attest unavailable) or
14
+ * `unavailable` (Oxy refused or could not be reached). A refusal that will not
15
+ * change for this session (an unregistered origin or platform) is not retried
16
+ * until the session changes; anything else is retried with a backoff.
17
+ */
18
+
19
+ import type { AppAssertionPlatform, AppAssertionResponse } from '@oxy.so/contracts';
20
+ import { OXY_APP_ASSERTION_HEADER } from '@oxy.so/contracts/client';
21
+ import type { OxyServices } from '@oxy.so/core';
22
+ import type { StorageInterface } from '../utils/storageHelpers';
23
+ import type { AppIntegrityModule, AppIntegrityPlatform } from './appIntegrityTypes';
24
+
25
+ export type AppAssertionStatus = 'idle' | 'pending' | 'ready' | 'unavailable' | 'unsupported';
26
+
27
+ export interface AppAssertionSnapshot {
28
+ readonly status: AppAssertionStatus;
29
+ /** The `OXY-APP+JWT`, to send as `X-Oxy-App-Assertion`. */
30
+ readonly assertion: string | null;
31
+ readonly applicationId: string | null;
32
+ readonly platform: AppAssertionPlatform | null;
33
+ /** Epoch milliseconds. */
34
+ readonly expiresAt: number | null;
35
+ /** Why there is none: an `APP_ASSERTION_ERROR_CODES` value, or an SDK reason. */
36
+ readonly error: string | null;
37
+ }
38
+
39
+ export interface AppAssertionSession {
40
+ readonly userId: string;
41
+ readonly sessionId: string;
42
+ }
43
+
44
+ export interface AppAssertionManagerOptions {
45
+ readonly oxyServices: Pick<OxyServices, 'apps'>;
46
+ readonly clientId: string | null;
47
+ readonly integrity: AppIntegrityPlatform;
48
+ /** Where an iOS install keeps its App Attest key id. */
49
+ readonly storage: () => StorageInterface | null;
50
+ readonly storageKeyPrefix: string;
51
+ readonly now?: () => number;
52
+ readonly setTimer?: (run: () => void, ms: number) => unknown;
53
+ readonly clearTimer?: (timer: unknown) => void;
54
+ }
55
+
56
+ /** Refresh this long before expiry. */
57
+ const REFRESH_BEFORE_MS = 60_000;
58
+ /** `getAppAssertion()` fetches anew when less than this is left. */
59
+ const MIN_REMAINING_MS = 30_000;
60
+ const RETRY_FIRST_MS = 30_000;
61
+ const RETRY_MAX_MS = 15 * 60_000;
62
+
63
+ /** Refusals that will not change for this session. */
64
+ const PERMANENT_REFUSALS = new Set([
65
+ 'application_unavailable',
66
+ 'session_other_application',
67
+ 'platform_not_registered',
68
+ 'origin_not_registered',
69
+ 'integrity_unconfigured',
70
+ ]);
71
+
72
+ const IDLE: AppAssertionSnapshot = {
73
+ status: 'idle',
74
+ assertion: null,
75
+ applicationId: null,
76
+ platform: null,
77
+ expiresAt: null,
78
+ error: null,
79
+ };
80
+
81
+ class Unsupported extends Error {
82
+ constructor(readonly reason: string) {
83
+ super(reason);
84
+ }
85
+ }
86
+
87
+ function errorCode(error: unknown): string {
88
+ if (error instanceof Unsupported) return error.reason;
89
+ const code = (error as { code?: unknown } | null)?.code;
90
+ return typeof code === 'string' && code.length > 0 ? code : 'request_failed';
91
+ }
92
+
93
+ export class AppAssertionManager {
94
+ private snapshot: AppAssertionSnapshot = IDLE;
95
+ private readonly listeners = new Set<() => void>();
96
+ private session: AppAssertionSession | null = null;
97
+ /** Bumped on every session change; a result for an older generation is dropped. */
98
+ private generation = 0;
99
+ private inflight: Promise<string | null> | null = null;
100
+ private timer: unknown = null;
101
+ private retryMs = RETRY_FIRST_MS;
102
+ private retryAt = 0;
103
+ private applicationId: { clientId: string; id: Promise<string> } | null = null;
104
+ private preparedProject: string | null = null;
105
+ private readonly now: () => number;
106
+ private readonly setTimer: (run: () => void, ms: number) => unknown;
107
+ private readonly clearTimer: (timer: unknown) => void;
108
+
109
+ constructor(private readonly options: AppAssertionManagerOptions) {
110
+ this.now = options.now ?? Date.now;
111
+ this.setTimer = options.setTimer ?? ((run, ms) => setTimeout(run, ms));
112
+ this.clearTimer =
113
+ options.clearTimer ?? ((timer) => clearTimeout(timer as ReturnType<typeof setTimeout>));
114
+ }
115
+
116
+ readonly subscribe = (listener: () => void): (() => void) => {
117
+ this.listeners.add(listener);
118
+ return () => {
119
+ this.listeners.delete(listener);
120
+ };
121
+ };
122
+
123
+ readonly getSnapshot = (): AppAssertionSnapshot => this.snapshot;
124
+
125
+ /**
126
+ * A current assertion for the active session, obtaining one when there is
127
+ * none or it is about to expire; `null` when this app cannot have one now.
128
+ */
129
+ readonly getAppAssertion = async (): Promise<string | null> => {
130
+ const current = this.snapshot;
131
+ if (
132
+ current.status === 'ready' &&
133
+ current.assertion &&
134
+ current.expiresAt !== null &&
135
+ current.expiresAt - this.now() > MIN_REMAINING_MS
136
+ ) {
137
+ return current.assertion;
138
+ }
139
+ if (!this.session || current.status === 'unsupported') return null;
140
+ if (current.status === 'unavailable' && this.now() < this.retryAt) return null;
141
+ return this.refresh();
142
+ };
143
+
144
+ /**
145
+ * `{ 'X-Oxy-App-Assertion': <assertion> }` for a request to a service that
146
+ * needs to know the app (Alia), or `{}` when there is none.
147
+ */
148
+ readonly getAppAssertionHeaders = async (): Promise<Record<string, string>> => {
149
+ const assertion = await this.getAppAssertion();
150
+ return assertion ? { [OXY_APP_ASSERTION_HEADER]: assertion } : {};
151
+ };
152
+
153
+ /** Follow the active session; `null` when signed out or not yet able to call Oxy. */
154
+ setSession(session: AppAssertionSession | null): void {
155
+ const same =
156
+ session?.userId === this.session?.userId && session?.sessionId === this.session?.sessionId;
157
+ if (same) return;
158
+ this.session = session;
159
+ this.generation += 1;
160
+ this.inflight = null;
161
+ this.retryMs = RETRY_FIRST_MS;
162
+ this.retryAt = 0;
163
+ this.cancelTimer();
164
+ this.publish(IDLE);
165
+ if (session) void this.refresh();
166
+ }
167
+
168
+ /** Stop all work. A later `setSession` starts again. */
169
+ dispose(): void {
170
+ this.session = null;
171
+ this.generation += 1;
172
+ this.inflight = null;
173
+ this.cancelTimer();
174
+ }
175
+
176
+ private refresh(): Promise<string | null> {
177
+ if (this.inflight) return this.inflight;
178
+ const generation = this.generation;
179
+ const run = this.obtain()
180
+ .then((answer) => {
181
+ if (generation !== this.generation) return null;
182
+ const expiresAt = Date.parse(answer.expiresAt);
183
+ this.retryMs = RETRY_FIRST_MS;
184
+ this.publish({
185
+ status: 'ready',
186
+ assertion: answer.assertion,
187
+ applicationId: answer.applicationId,
188
+ platform: answer.platform,
189
+ expiresAt,
190
+ error: null,
191
+ });
192
+ this.schedule(Math.max(expiresAt - this.now() - REFRESH_BEFORE_MS, MIN_REMAINING_MS));
193
+ return answer.assertion;
194
+ })
195
+ .catch((error: unknown) => {
196
+ if (generation !== this.generation) return null;
197
+ const code = errorCode(error);
198
+ const unsupported = error instanceof Unsupported;
199
+ const current = this.snapshot;
200
+ if (
201
+ !unsupported &&
202
+ !PERMANENT_REFUSALS.has(code) &&
203
+ current.status === 'ready' &&
204
+ current.expiresAt !== null &&
205
+ current.expiresAt - this.now() > MIN_REMAINING_MS
206
+ ) {
207
+ // The assertion in hand is still good: keep it, and try again soon.
208
+ this.schedule(
209
+ Math.min(RETRY_FIRST_MS, current.expiresAt - this.now() - MIN_REMAINING_MS),
210
+ );
211
+ return current.assertion;
212
+ }
213
+ this.publish({
214
+ ...IDLE,
215
+ status: unsupported ? 'unsupported' : 'unavailable',
216
+ platform: this.options.integrity.platform,
217
+ error: code,
218
+ });
219
+ if (!unsupported) {
220
+ if (PERMANENT_REFUSALS.has(code)) {
221
+ this.retryAt = Number.POSITIVE_INFINITY;
222
+ } else {
223
+ this.retryAt = this.now() + this.retryMs;
224
+ this.schedule(this.retryMs);
225
+ this.retryMs = Math.min(this.retryMs * 2, RETRY_MAX_MS);
226
+ }
227
+ }
228
+ return null;
229
+ })
230
+ .finally(() => {
231
+ if (this.inflight === run) this.inflight = null;
232
+ });
233
+ this.inflight = run;
234
+ if (this.snapshot.status !== 'ready') this.publish({ ...this.snapshot, status: 'pending' });
235
+ return run;
236
+ }
237
+
238
+ private async obtain(): Promise<AppAssertionResponse> {
239
+ const applicationId = await this.resolveApplicationId();
240
+ const { platform } = this.options.integrity;
241
+ const assertions = this.options.oxyServices.apps.assertions;
242
+ if (platform === 'web') return assertions.create({ applicationId, platform: 'web' });
243
+
244
+ const integrity = await this.options.integrity.loadIntegrity();
245
+ if (!integrity) throw new Unsupported('integrity_module_unavailable');
246
+ if (platform === 'android') return this.proveAndroid(applicationId, integrity);
247
+ if (!integrity.isSupported) throw new Unsupported('app_attest_unsupported');
248
+ return this.proveIos(applicationId, integrity);
249
+ }
250
+
251
+ private resolveApplicationId(): Promise<string> {
252
+ const clientId = this.options.clientId;
253
+ if (!clientId) return Promise.reject(new Unsupported('client_id_missing'));
254
+ if (this.applicationId?.clientId !== clientId) {
255
+ const id = this.options.oxyServices.apps.getPublic(clientId).then((app) => app.id);
256
+ id.catch(() => {
257
+ if (this.applicationId?.id === id) this.applicationId = null;
258
+ });
259
+ this.applicationId = { clientId, id };
260
+ }
261
+ return this.applicationId.id;
262
+ }
263
+
264
+ private async proveAndroid(
265
+ applicationId: string,
266
+ integrity: AppIntegrityModule,
267
+ ): Promise<AppAssertionResponse> {
268
+ const assertions = this.options.oxyServices.apps.assertions;
269
+ const challenge = await assertions.challenge({ applicationId, platform: 'android' });
270
+ if (!challenge.cloudProjectNumber) throw new Unsupported('integrity_unconfigured');
271
+ try {
272
+ if (this.preparedProject !== challenge.cloudProjectNumber) {
273
+ await integrity.prepareIntegrityTokenProviderAsync(challenge.cloudProjectNumber);
274
+ this.preparedProject = challenge.cloudProjectNumber;
275
+ }
276
+ const integrityToken = await integrity.requestIntegrityCheckAsync(challenge.requestHash);
277
+ return await assertions.create({
278
+ applicationId,
279
+ platform: 'android',
280
+ proof: { challenge: challenge.challenge, integrityToken },
281
+ });
282
+ } catch (error) {
283
+ // A provider Google invalidated is prepared again on the next attempt.
284
+ this.preparedProject = null;
285
+ throw error;
286
+ }
287
+ }
288
+
289
+ private async proveIos(
290
+ applicationId: string,
291
+ integrity: AppIntegrityModule,
292
+ ): Promise<AppAssertionResponse> {
293
+ const keyName = `${this.options.storageKeyPrefix}_app_attest_key_${applicationId}`;
294
+ const storage = this.options.storage();
295
+ const assertOnce = async (keyId: string) => {
296
+ const assertions = this.options.oxyServices.apps.assertions;
297
+ const challenge = await assertions.challenge({
298
+ applicationId,
299
+ platform: 'ios',
300
+ purpose: 'assertion',
301
+ });
302
+ const assertion = await integrity.generateAssertionAsync(keyId, challenge.requestHash);
303
+ return assertions.create({
304
+ applicationId,
305
+ platform: 'ios',
306
+ proof: { challenge: challenge.challenge, keyId, assertion },
307
+ });
308
+ };
309
+
310
+ const stored = await storage?.getItem(keyName).catch(() => null);
311
+ if (stored) {
312
+ try {
313
+ return await assertOnce(stored);
314
+ } catch (error) {
315
+ // Oxy no longer knows the key (swept, or never registered): attest a new one.
316
+ if (errorCode(error) !== 'attest_key_unknown') throw error;
317
+ await storage?.removeItem(keyName).catch(() => undefined);
318
+ }
319
+ }
320
+ const keyId = await this.attestKey(applicationId, integrity);
321
+ await storage?.setItem(keyName, keyId).catch(() => undefined);
322
+ return assertOnce(keyId);
323
+ }
324
+
325
+ private async attestKey(applicationId: string, integrity: AppIntegrityModule): Promise<string> {
326
+ const assertions = this.options.oxyServices.apps.assertions;
327
+ const keyId = await integrity.generateKeyAsync();
328
+ const challenge = await assertions.challenge({
329
+ applicationId,
330
+ platform: 'ios',
331
+ purpose: 'attest_key',
332
+ });
333
+ const attestation = await integrity.attestKeyAsync(keyId, challenge.requestHash);
334
+ await assertions.registerAttestKey({
335
+ applicationId,
336
+ keyId,
337
+ challenge: challenge.challenge,
338
+ attestation,
339
+ });
340
+ return keyId;
341
+ }
342
+
343
+ private schedule(ms: number): void {
344
+ this.cancelTimer();
345
+ const generation = this.generation;
346
+ this.timer = this.setTimer(() => {
347
+ this.timer = null;
348
+ if (generation === this.generation && this.session) void this.refresh();
349
+ }, ms);
350
+ }
351
+
352
+ private cancelTimer(): void {
353
+ if (this.timer !== null) {
354
+ this.clearTimer(this.timer);
355
+ this.timer = null;
356
+ }
357
+ }
358
+
359
+ private publish(next: AppAssertionSnapshot): void {
360
+ this.snapshot = next;
361
+ for (const listener of this.listeners) listener();
362
+ }
363
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * iOS and Android: the app proves itself with App Attest or Play Integrity
3
+ * through `@expo/app-integrity` (ADR 0045).
4
+ *
5
+ * The module is loaded lazily, and its `import()` is a direct statement of a
6
+ * `try` block — the shape Metro marks OPTIONAL — so an app bundle that lacks
7
+ * the package, or a binary built before it was added (an over-the-air update
8
+ * of the JavaScript alone), still boots: `requireNativeModule` throws inside
9
+ * the import, and the app simply has no assertion.
10
+ */
11
+ import { Platform } from 'react-native';
12
+ import type { AppIntegrityModule, AppIntegrityPlatform } from './appIntegrityTypes';
13
+
14
+ async function loadIntegrity(): Promise<AppIntegrityModule | null> {
15
+ try {
16
+ const integrity = await import('@expo/app-integrity');
17
+ return integrity;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export const appIntegrityPlatform: AppIntegrityPlatform = {
24
+ platform: Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'web',
25
+ loadIntegrity:
26
+ Platform.OS === 'ios' || Platform.OS === 'android' ? loadIntegrity : async () => null,
27
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Web: the app proves itself with the browser's `Origin`, which Oxy reads from
3
+ * the request itself (ADR 0045). Nothing to load.
4
+ *
5
+ * Native builds resolve `appIntegrityPlatform.native.ts` instead.
6
+ */
7
+ import type { AppIntegrityPlatform } from './appIntegrityTypes';
8
+
9
+ export const appIntegrityPlatform: AppIntegrityPlatform = {
10
+ platform: 'web',
11
+ loadIntegrity: async () => null,
12
+ };
@@ -0,0 +1,23 @@
1
+ import type { AppAssertionPlatform } from '@oxy.so/contracts';
2
+
3
+ /**
4
+ * The part of `@expo/app-integrity` the SDK uses: App Attest on iOS, the Play
5
+ * Integrity standard request on Android. Declared here rather than imported so
6
+ * the web build never names the native package.
7
+ */
8
+ export interface AppIntegrityModule {
9
+ /** Whether this iOS device offers App Attest. `true` on Android. */
10
+ readonly isSupported: boolean;
11
+ generateKeyAsync(): Promise<string>;
12
+ attestKeyAsync(keyId: string, challenge: string): Promise<string>;
13
+ generateAssertionAsync(keyId: string, challenge: string): Promise<string>;
14
+ prepareIntegrityTokenProviderAsync(cloudProjectNumber: string): Promise<void>;
15
+ requestIntegrityCheckAsync(requestHash: string): Promise<string>;
16
+ }
17
+
18
+ /** How this build proves which app it is. */
19
+ export interface AppIntegrityPlatform {
20
+ readonly platform: AppAssertionPlatform;
21
+ /** The native integrity module, or `null` where there is none (web, or a binary without it). */
22
+ loadIntegrity(): Promise<AppIntegrityModule | null>;
23
+ }
@@ -0,0 +1,90 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { View } from 'react-native';
3
+ import { Text } from '@oxy.so/bloom/typography';
4
+ import { Button } from '@oxy.so/bloom/button';
5
+ import type { AccountInvitationPreview } from '@oxy.so/core';
6
+ import { getNormalizedUserHandle } from '@oxy.so/core';
7
+ import { useOxy } from '../context/OxyContext';
8
+
9
+ /** The link never grants access on navigation, preview, sign-in or account switching. */
10
+ export function AccountInvitationAcceptance({ token }: { token: string }) {
11
+ return <InvitationContent key={token} token={token} />;
12
+ }
13
+
14
+ function InvitationContent({ token }: { token: string }) {
15
+ const { oxyServices, user, canUsePrivateApi, openAccountDialog } = useOxy();
16
+ const [preview, setPreview] = useState<AccountInvitationPreview | null>(null);
17
+ const [error, setError] = useState('');
18
+ const [busy, setBusy] = useState(false);
19
+ const [accepted, setAccepted] = useState(false);
20
+ useEffect(() => {
21
+ let alive = true;
22
+ setPreview(null);
23
+ setError('');
24
+ if (canUsePrivateApi && token) {
25
+ oxyServices.accounts.invitations
26
+ .preview(token)
27
+ .then((value) => {
28
+ if (alive) setPreview(value);
29
+ })
30
+ .catch((reason) => {
31
+ if (alive) setError(reason instanceof Error ? reason.message : 'Invitation unavailable');
32
+ });
33
+ }
34
+ return () => {
35
+ alive = false;
36
+ };
37
+ }, [oxyServices, canUsePrivateApi, token]);
38
+
39
+ const accept = async () => {
40
+ if (!user || !preview) return;
41
+ setBusy(true);
42
+ setError('');
43
+ try {
44
+ await oxyServices.accounts.invitations.accept(token, user.id);
45
+ setAccepted(true);
46
+ } catch (reason) {
47
+ setError(reason instanceof Error ? reason.message : 'Could not accept invitation');
48
+ } finally {
49
+ setBusy(false);
50
+ }
51
+ };
52
+ return (
53
+ <View className="gap-space-16 p-space-24">
54
+ <Text className="text-title font-semibold text-text">Account invitation</Text>
55
+ {canUsePrivateApi && token && !preview && !error ? (
56
+ <Text accessibilityRole="progressbar">Loading invitation…</Text>
57
+ ) : null}
58
+ {accepted ? (
59
+ <Text>Invitation accepted. You can now access this account.</Text>
60
+ ) : (
61
+ <>
62
+ {preview ? (
63
+ <Text>
64
+ Join {preview.account.displayName ?? preview.account.username} as{' '}
65
+ {preview.invitation.role}.
66
+ </Text>
67
+ ) : null}
68
+ <Text>
69
+ Selected account:{' '}
70
+ {user
71
+ ? `${user.name?.displayName ?? getNormalizedUserHandle(user) ?? user.id} (${user.email ?? user.id})`
72
+ : 'Not signed in'}
73
+ </Text>
74
+ <Text>
75
+ The selected account receives access. Its email can differ from the invitation address.
76
+ No access is granted until you confirm.
77
+ </Text>
78
+ <Button disabled={busy} onPress={() => openAccountDialog()}>
79
+ {user ? 'Change account' : 'Sign in or create account'}
80
+ </Button>
81
+ <Button disabled={busy || !user || !preview || user.kind !== 'personal'} onPress={accept}>
82
+ Confirm and accept
83
+ </Button>
84
+ </>
85
+ )}
86
+ {error ? <Text accessibilityRole="alert">{error}</Text> : null}
87
+ {!token ? <Text>This invitation link is incomplete.</Text> : null}
88
+ </View>
89
+ );
90
+ }
@@ -0,0 +1,38 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import {
4
+ SegmentedControl,
5
+ SegmentedControlItem,
6
+ SegmentedControlItemText,
7
+ } from '@oxy.so/bloom/segmented-control';
8
+ import { Text } from '@oxy.so/bloom/typography';
9
+ import { DISPLAY_CURRENCIES, useDisplayCurrency } from '../hooks/useDisplayCurrency';
10
+ import { useI18n } from '../hooks/useI18n';
11
+
12
+ export function DisplayCurrencySelector() {
13
+ const { currency, selectCurrency, isSaving, error } = useDisplayCurrency();
14
+ const { t } = useI18n();
15
+ return (
16
+ <View>
17
+ <Text>{t('preferences.displayCurrency.label')}</Text>
18
+ <SegmentedControl
19
+ type="radio"
20
+ label={t('preferences.displayCurrency.label')}
21
+ value={currency}
22
+ onValueChange={(value) => {
23
+ const selected = DISPLAY_CURRENCIES.find((item) => item === value);
24
+ if (selected && !isSaving) selectCurrency(selected);
25
+ }}
26
+ >
27
+ {DISPLAY_CURRENCIES.map((value) => (
28
+ <SegmentedControlItem key={value} value={value} disabled={isSaving}>
29
+ <SegmentedControlItemText>{value}</SegmentedControlItemText>
30
+ </SegmentedControlItem>
31
+ ))}
32
+ </SegmentedControl>
33
+ {error && (
34
+ <Text accessibilityLiveRegion="polite">{t('preferences.displayCurrency.saveFailed')}</Text>
35
+ )}
36
+ </View>
37
+ );
38
+ }
@@ -0,0 +1,61 @@
1
+ import React from 'react';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { AccountInvitationAcceptance } from '../AccountInvitationAcceptance';
4
+ const mockPreview = jest.fn();
5
+ const mockAccept = jest.fn();
6
+ const mockSwitch = jest.fn();
7
+ let mockUser: { id: string; kind: string; email: string; username: string } | null = null;
8
+ const mockServices = { accounts: { invitations: { preview: mockPreview, accept: mockAccept } } };
9
+ jest.mock('../../context/OxyContext', () => ({
10
+ useOxy: () => ({
11
+ oxyServices: mockServices,
12
+ user: mockUser,
13
+ canUsePrivateApi: !!mockUser,
14
+ openAccountDialog: mockSwitch,
15
+ }),
16
+ }));
17
+ beforeEach(() => {
18
+ jest.clearAllMocks();
19
+ mockUser = null;
20
+ mockPreview.mockResolvedValue({
21
+ invitation: { role: 'editor' },
22
+ account: { displayName: 'Community' },
23
+ });
24
+ mockAccept.mockResolvedValue({ success: true });
25
+ });
26
+ test('sign-in and switching never accept; confirmation names the displayed selected account', async () => {
27
+ const { rerender } = render(<AccountInvitationAcceptance token="secret" />);
28
+ fireEvent.click(screen.getByText('Sign in or create account'));
29
+ expect(mockSwitch).toHaveBeenCalled();
30
+ expect(mockAccept).not.toHaveBeenCalled();
31
+ mockUser = { id: 'first', kind: 'personal', username: 'first', email: 'different@example.com' };
32
+ rerender(<AccountInvitationAcceptance token="secret" />);
33
+ await screen.findByText('Join Community as editor.');
34
+ fireEvent.click(screen.getByText('Change account'));
35
+ expect(mockAccept).not.toHaveBeenCalled();
36
+ mockUser = { id: 'second', kind: 'personal', username: 'second', email: 'second@example.com' };
37
+ rerender(<AccountInvitationAcceptance token="secret" />);
38
+ expect(screen.getByText(/Selected account: second/)).toBeTruthy();
39
+ fireEvent.click(screen.getByText('Confirm and accept'));
40
+ await waitFor(() => expect(mockAccept).toHaveBeenCalledWith('secret', 'second'));
41
+ await screen.findByText('Invitation accepted. You can now access this account.');
42
+ });
43
+ test('a new link resets previous acceptance and does not auto-accept', async () => {
44
+ mockUser = { id: 'first', kind: 'personal', username: 'first', email: 'first@example.com' };
45
+ const { rerender } = render(<AccountInvitationAcceptance token="old" />);
46
+ await screen.findByText('Join Community as editor.');
47
+ fireEvent.click(screen.getByText('Confirm and accept'));
48
+ await screen.findByText('Invitation accepted. You can now access this account.');
49
+ rerender(<AccountInvitationAcceptance token="new" />);
50
+ await screen.findByText('Join Community as editor.');
51
+ expect(screen.queryByText('Invitation accepted. You can now access this account.')).toBeNull();
52
+ expect(mockAccept).toHaveBeenCalledTimes(1);
53
+ });
54
+ test('preview failure leaves confirmation disabled', async () => {
55
+ mockUser = { id: 'first', kind: 'personal', username: 'first', email: 'first@example.com' };
56
+ mockPreview.mockRejectedValue(new Error('Invitation expired'));
57
+ render(<AccountInvitationAcceptance token="expired" />);
58
+ await screen.findByText('Invitation expired');
59
+ fireEvent.click(screen.getByText('Confirm and accept'));
60
+ expect(mockAccept).not.toHaveBeenCalled();
61
+ });