@oxy.so/services 11.10.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 (153) 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 +17 -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/fileManagement/FileAppAccessSection.js +121 -0
  20. package/lib/commonjs/ui/components/fileManagement/FileAppAccessSection.js.map +1 -0
  21. package/lib/commonjs/ui/components/fileManagement/FileDetailsModal.js +6 -2
  22. package/lib/commonjs/ui/components/fileManagement/FileDetailsModal.js.map +1 -1
  23. package/lib/commonjs/ui/context/OxyContext.js +10 -1
  24. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  25. package/lib/commonjs/ui/hooks/mutations/mutationKeys.js +4 -0
  26. package/lib/commonjs/ui/hooks/mutations/mutationKeys.js.map +1 -1
  27. package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js +49 -1
  28. package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js.map +1 -1
  29. package/lib/commonjs/ui/hooks/queries/queryKeys.js +6 -1
  30. package/lib/commonjs/ui/hooks/queries/queryKeys.js.map +1 -1
  31. package/lib/commonjs/ui/hooks/queries/useFileQueries.js +35 -1
  32. package/lib/commonjs/ui/hooks/queries/useFileQueries.js.map +1 -1
  33. package/lib/commonjs/ui/icons/subsetGlyphMaps.js +1 -0
  34. package/lib/commonjs/ui/icons/subsetGlyphMaps.js.map +1 -1
  35. package/lib/commonjs/ui/index.js +7 -0
  36. package/lib/commonjs/ui/index.js.map +1 -1
  37. package/lib/module/assets/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  38. package/lib/module/assets/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  39. package/lib/module/assets/assets/fonts/icons/manifest.json +5 -4
  40. package/lib/module/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  41. package/lib/module/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  42. package/lib/module/assets/fonts/icons/manifest.json +5 -4
  43. package/lib/module/index.js +4 -0
  44. package/lib/module/index.js.map +1 -1
  45. package/lib/module/ui/appAssertion/AppAssertionContext.js +72 -0
  46. package/lib/module/ui/appAssertion/AppAssertionContext.js.map +1 -0
  47. package/lib/module/ui/appAssertion/appAssertionManager.js +304 -0
  48. package/lib/module/ui/appAssertion/appAssertionManager.js.map +1 -0
  49. package/lib/module/ui/appAssertion/appIntegrityPlatform.js +14 -0
  50. package/lib/module/ui/appAssertion/appIntegrityPlatform.js.map +1 -0
  51. package/lib/module/ui/appAssertion/appIntegrityPlatform.native.js +26 -0
  52. package/lib/module/ui/appAssertion/appIntegrityPlatform.native.js.map +1 -0
  53. package/lib/module/ui/appAssertion/appIntegrityTypes.js +4 -0
  54. package/lib/module/ui/appAssertion/appIntegrityTypes.js.map +1 -0
  55. package/lib/module/ui/components/fileManagement/FileAppAccessSection.js +116 -0
  56. package/lib/module/ui/components/fileManagement/FileAppAccessSection.js.map +1 -0
  57. package/lib/module/ui/components/fileManagement/FileDetailsModal.js +6 -2
  58. package/lib/module/ui/components/fileManagement/FileDetailsModal.js.map +1 -1
  59. package/lib/module/ui/context/OxyContext.js +10 -1
  60. package/lib/module/ui/context/OxyContext.js.map +1 -1
  61. package/lib/module/ui/hooks/mutations/mutationKeys.js +4 -0
  62. package/lib/module/ui/hooks/mutations/mutationKeys.js.map +1 -1
  63. package/lib/module/ui/hooks/mutations/useAccountMutations.js +47 -0
  64. package/lib/module/ui/hooks/mutations/useAccountMutations.js.map +1 -1
  65. package/lib/module/ui/hooks/queries/queryKeys.js +6 -1
  66. package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
  67. package/lib/module/ui/hooks/queries/useFileQueries.js +33 -1
  68. package/lib/module/ui/hooks/queries/useFileQueries.js.map +1 -1
  69. package/lib/module/ui/icons/subsetGlyphMaps.js +1 -0
  70. package/lib/module/ui/icons/subsetGlyphMaps.js.map +1 -1
  71. package/lib/module/ui/index.js +1 -0
  72. package/lib/module/ui/index.js.map +1 -1
  73. package/lib/typescript/commonjs/index.d.ts +3 -0
  74. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/ui/appAssertion/AppAssertionContext.d.ts +41 -0
  76. package/lib/typescript/commonjs/ui/appAssertion/AppAssertionContext.d.ts.map +1 -0
  77. package/lib/typescript/commonjs/ui/appAssertion/appAssertionManager.d.ts +92 -0
  78. package/lib/typescript/commonjs/ui/appAssertion/appAssertionManager.d.ts.map +1 -0
  79. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.d.ts +9 -0
  80. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.native.d.ts +3 -0
  82. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityPlatform.native.d.ts.map +1 -0
  83. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityTypes.d.ts +22 -0
  84. package/lib/typescript/commonjs/ui/appAssertion/appIntegrityTypes.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/ui/components/fileManagement/FileAppAccessSection.d.ts +15 -0
  86. package/lib/typescript/commonjs/ui/components/fileManagement/FileAppAccessSection.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/ui/components/fileManagement/FileDetailsModal.d.ts +2 -1
  88. package/lib/typescript/commonjs/ui/components/fileManagement/FileDetailsModal.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/ui/hooks/mutations/mutationKeys.d.ts +3 -0
  91. package/lib/typescript/commonjs/ui/hooks/mutations/mutationKeys.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +13 -1
  93. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +5 -0
  95. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts +11 -1
  97. package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  99. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts +1 -0
  101. package/lib/typescript/commonjs/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/ui/index.d.ts +2 -0
  103. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  104. package/lib/typescript/module/index.d.ts +3 -0
  105. package/lib/typescript/module/index.d.ts.map +1 -1
  106. package/lib/typescript/module/ui/appAssertion/AppAssertionContext.d.ts +41 -0
  107. package/lib/typescript/module/ui/appAssertion/AppAssertionContext.d.ts.map +1 -0
  108. package/lib/typescript/module/ui/appAssertion/appAssertionManager.d.ts +92 -0
  109. package/lib/typescript/module/ui/appAssertion/appAssertionManager.d.ts.map +1 -0
  110. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.d.ts +9 -0
  111. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.d.ts.map +1 -0
  112. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.native.d.ts +3 -0
  113. package/lib/typescript/module/ui/appAssertion/appIntegrityPlatform.native.d.ts.map +1 -0
  114. package/lib/typescript/module/ui/appAssertion/appIntegrityTypes.d.ts +22 -0
  115. package/lib/typescript/module/ui/appAssertion/appIntegrityTypes.d.ts.map +1 -0
  116. package/lib/typescript/module/ui/components/fileManagement/FileAppAccessSection.d.ts +15 -0
  117. package/lib/typescript/module/ui/components/fileManagement/FileAppAccessSection.d.ts.map +1 -0
  118. package/lib/typescript/module/ui/components/fileManagement/FileDetailsModal.d.ts +2 -1
  119. package/lib/typescript/module/ui/components/fileManagement/FileDetailsModal.d.ts.map +1 -1
  120. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  121. package/lib/typescript/module/ui/hooks/mutations/mutationKeys.d.ts +3 -0
  122. package/lib/typescript/module/ui/hooks/mutations/mutationKeys.d.ts.map +1 -1
  123. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +13 -1
  124. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  125. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts +5 -0
  126. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  127. package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts +11 -1
  128. package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts.map +1 -1
  129. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  130. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
  131. package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts +1 -0
  132. package/lib/typescript/module/ui/icons/subsetGlyphMaps.d.ts.map +1 -1
  133. package/lib/typescript/module/ui/index.d.ts +2 -0
  134. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  135. package/package.json +7 -5
  136. package/src/assets/fonts/icons/OxyServicesIonicons.ttf +0 -0
  137. package/src/assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf +0 -0
  138. package/src/assets/fonts/icons/manifest.json +5 -4
  139. package/src/index.ts +12 -0
  140. package/src/ui/appAssertion/AppAssertionContext.tsx +109 -0
  141. package/src/ui/appAssertion/appAssertionManager.ts +363 -0
  142. package/src/ui/appAssertion/appIntegrityPlatform.native.ts +27 -0
  143. package/src/ui/appAssertion/appIntegrityPlatform.ts +12 -0
  144. package/src/ui/appAssertion/appIntegrityTypes.ts +23 -0
  145. package/src/ui/components/fileManagement/FileAppAccessSection.tsx +111 -0
  146. package/src/ui/components/fileManagement/FileDetailsModal.tsx +5 -1
  147. package/src/ui/context/OxyContext.tsx +13 -1
  148. package/src/ui/hooks/mutations/mutationKeys.ts +5 -0
  149. package/src/ui/hooks/mutations/useAccountMutations.ts +33 -0
  150. package/src/ui/hooks/queries/queryKeys.ts +5 -0
  151. package/src/ui/hooks/queries/useFileQueries.ts +44 -2
  152. package/src/ui/icons/subsetGlyphMaps.ts +1 -0
  153. package/src/ui/index.ts +2 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `useAppAssertion()` (ADR 0045): this app's Oxy-signed assertion of which app
3
+ * it is, for the signed-in session.
4
+ *
5
+ * `OxyProvider` mounts {@link AppAssertionProvider} itself and the manager
6
+ * obtains and refreshes the assertion on its own; an app never handles the
7
+ * proof. Something that calls a service which needs to know the app (Alia's
8
+ * SDK, for an agent confined to its application) attaches
9
+ * `await getAppAssertionHeaders()` to the request.
10
+ */
11
+
12
+ import type { OxyServices } from '@oxy.so/core';
13
+ import {
14
+ createContext,
15
+ type ReactNode,
16
+ useContext,
17
+ useEffect,
18
+ useMemo,
19
+ useRef,
20
+ useSyncExternalStore,
21
+ } from 'react';
22
+ import type { StorageInterface } from '../utils/storageHelpers';
23
+ import { appIntegrityPlatform } from './appIntegrityPlatform';
24
+ import { AppAssertionManager, type AppAssertionSnapshot } from './appAssertionManager';
25
+ import type { AppIntegrityPlatform } from './appIntegrityTypes';
26
+
27
+ const AppAssertionContext = createContext<AppAssertionManager | null>(null);
28
+
29
+ export interface AppAssertionProviderProps {
30
+ readonly children: ReactNode;
31
+ readonly oxyServices: OxyServices;
32
+ readonly clientId: string | null;
33
+ /** The signed-in person, once the session can call Oxy; `null` otherwise. */
34
+ readonly userId: string | null;
35
+ readonly sessionId: string | null;
36
+ readonly storage: StorageInterface | null;
37
+ readonly storageKeyPrefix: string;
38
+ /** Replaces the build's own platform, in tests. */
39
+ readonly integrity?: AppIntegrityPlatform;
40
+ }
41
+
42
+ export function AppAssertionProvider({
43
+ children,
44
+ oxyServices,
45
+ clientId,
46
+ userId,
47
+ sessionId,
48
+ storage,
49
+ storageKeyPrefix,
50
+ integrity = appIntegrityPlatform,
51
+ }: AppAssertionProviderProps) {
52
+ // Read only when an iOS install needs its App Attest key id, never in render.
53
+ const storageRef = useRef<StorageInterface | null>(storage);
54
+ useEffect(() => {
55
+ storageRef.current = storage;
56
+ }, [storage]);
57
+
58
+ const manager = useMemo(
59
+ () =>
60
+ new AppAssertionManager({
61
+ oxyServices,
62
+ clientId,
63
+ integrity,
64
+ storage: () => storageRef.current,
65
+ storageKeyPrefix,
66
+ }),
67
+ [oxyServices, clientId, integrity, storageKeyPrefix],
68
+ );
69
+
70
+ useEffect(() => {
71
+ manager.setSession(userId && sessionId ? { userId, sessionId } : null);
72
+ }, [manager, userId, sessionId]);
73
+
74
+ useEffect(() => () => manager.dispose(), [manager]);
75
+
76
+ return <AppAssertionContext.Provider value={manager}>{children}</AppAssertionContext.Provider>;
77
+ }
78
+
79
+ export interface UseAppAssertionResult extends AppAssertionSnapshot {
80
+ /** A current assertion, obtained now when there is none or it is about to expire; `null` when there can be none. */
81
+ readonly getAppAssertion: () => Promise<string | null>;
82
+ /** `{ 'X-Oxy-App-Assertion': <assertion> }`, or `{}` when there is none. */
83
+ readonly getAppAssertionHeaders: () => Promise<Record<string, string>>;
84
+ }
85
+
86
+ export class AppAssertionProviderMissingError extends Error {
87
+ readonly code = 'oxy_provider_missing';
88
+
89
+ constructor() {
90
+ super('useAppAssertion() was called outside <OxyProvider>.');
91
+ this.name = 'AppAssertionProviderMissingError';
92
+ }
93
+ }
94
+
95
+ /** This app's assertion of which app it is, kept current for the signed-in session. */
96
+ export function useAppAssertion(): UseAppAssertionResult {
97
+ const manager = useContext(AppAssertionContext);
98
+ if (!manager) throw new AppAssertionProviderMissingError();
99
+ const snapshot = useSyncExternalStore(
100
+ manager.subscribe,
101
+ manager.getSnapshot,
102
+ manager.getSnapshot,
103
+ );
104
+ return {
105
+ ...snapshot,
106
+ getAppAssertion: manager.getAppAssertion,
107
+ getAppAssertionHeaders: manager.getAppAssertionHeaders,
108
+ };
109
+ }
@@ -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,111 @@
1
+ import type React from 'react';
2
+ import { useCallback, useState } from 'react';
3
+ import { ActivityIndicator, View } from 'react-native';
4
+ import { Avatar } from '@oxy.so/bloom/avatar';
5
+ import { Button } from '@oxy.so/bloom/button';
6
+ import { SettingsListGroup, SettingsListItem } from '@oxy.so/bloom/settings-list';
7
+ import { surfaces } from '@oxy.so/bloom/surfaces';
8
+ import { useTheme } from '@oxy.so/bloom/theme';
9
+ import { toast } from '@oxy.so/bloom/toast';
10
+ import { Text } from '@oxy.so/bloom/typography';
11
+ import type { FileApplicationAccess } from '@oxy.so/core';
12
+ import { useI18n } from '../../hooks/useI18n';
13
+ import { useFileAppAccess } from '../../hooks/queries/useFileQueries';
14
+ import { useRevokeFileAppAccess } from '../../hooks/mutations/useAccountMutations';
15
+
16
+ const APP_ICON_SIZE = 32;
17
+
18
+ interface FileAppAccessSectionProps {
19
+ fileId: string;
20
+ }
21
+
22
+ /**
23
+ * "Apps with access" (ADR 0043 stage 4c): the applications that can open this
24
+ * file on their own — one that stored it for the person (`upload`), one the
25
+ * person attached it to (`link`) — each with a Revoke action behind a
26
+ * confirmation. Revoking stops the application reading the file and every
27
+ * playback it started; the row leaves the list at once and comes back if the
28
+ * revoke fails. Shown only on the owner's own file.
29
+ */
30
+ export const FileAppAccessSection: React.FC<FileAppAccessSectionProps> = ({ fileId }) => {
31
+ const { t } = useI18n();
32
+ const { colors } = useTheme();
33
+ const { data: apps, isLoading, isError } = useFileAppAccess(fileId);
34
+ const revoke = useRevokeFileAppAccess();
35
+ const [revokingId, setRevokingId] = useState<string | null>(null);
36
+
37
+ const confirmRevoke = useCallback(
38
+ async (app: FileApplicationAccess) => {
39
+ const confirmed = await surfaces.confirm({
40
+ title: t('fileManagement.appAccess.confirm.title', { name: app.name }),
41
+ description: t('fileManagement.appAccess.confirm.message', { name: app.name }),
42
+ confirmLabel: t('fileManagement.appAccess.revoke'),
43
+ cancelLabel: t('common.cancel'),
44
+ destructive: true,
45
+ });
46
+ if (!confirmed) return;
47
+ setRevokingId(app.applicationId);
48
+ try {
49
+ await revoke.mutateAsync({ fileId, applicationId: app.applicationId });
50
+ toast.success(t('fileManagement.appAccess.toasts.revoked', { name: app.name }));
51
+ } catch {
52
+ toast.error(t('fileManagement.appAccess.toasts.revokeFailed', { name: app.name }));
53
+ } finally {
54
+ setRevokingId(null);
55
+ }
56
+ },
57
+ [fileId, revoke, t],
58
+ );
59
+
60
+ const viaText = (app: FileApplicationAccess): string =>
61
+ app.via.map((via) => t(`fileManagement.appAccess.via.${via}`)).join(' · ');
62
+
63
+ return (
64
+ <View className="w-full mb-space-24">
65
+ <Text className="text-text text-[16px] font-semibold mb-space-4">
66
+ {t('fileManagement.appAccess.title')}
67
+ </Text>
68
+ <Text className="text-text-secondary text-[14px] mb-space-12">
69
+ {t('fileManagement.appAccess.description')}
70
+ </Text>
71
+ {isLoading ? (
72
+ <ActivityIndicator color={colors.primary} />
73
+ ) : isError ? (
74
+ <Text className="text-text-secondary text-[14px]">
75
+ {t('fileManagement.appAccess.loadError')}
76
+ </Text>
77
+ ) : !apps || apps.length === 0 ? (
78
+ <Text className="text-text-secondary text-[14px]">
79
+ {t('fileManagement.appAccess.empty')}
80
+ </Text>
81
+ ) : (
82
+ <SettingsListGroup>
83
+ {apps.map((app) => (
84
+ <SettingsListItem
85
+ key={app.applicationId}
86
+ icon={<Avatar source={app.icon} name={app.name} size={APP_ICON_SIZE} />}
87
+ title={app.name}
88
+ description={viaText(app)}
89
+ showChevron={false}
90
+ rightElement={
91
+ <Button
92
+ appearance="subtle"
93
+ tone="danger"
94
+ size="sm"
95
+ loading={revokingId === app.applicationId}
96
+ disabled={revokingId !== null}
97
+ accessibilityLabel={t('fileManagement.appAccess.a11y.revoke', { name: app.name })}
98
+ onPress={() => {
99
+ void confirmRevoke(app);
100
+ }}
101
+ >
102
+ {t('fileManagement.appAccess.revoke')}
103
+ </Button>
104
+ }
105
+ />
106
+ ))}
107
+ </SettingsListGroup>
108
+ )}
109
+ </View>
110
+ );
111
+ };
@@ -9,6 +9,7 @@ import { surfaces, type SurfaceControls } from '@oxy.so/bloom/surfaces';
9
9
  import type { FileMetadata } from '@oxy.so/core';
10
10
  import { useI18n } from '../../hooks/useI18n';
11
11
  import { formatFileSize, getFileIcon } from '../../utils/fileManagement';
12
+ import { FileAppAccessSection } from './FileAppAccessSection';
12
13
 
13
14
  interface FileDetailsModalProps {
14
15
  /** The presenting surface's controls (from `surfaces.present`). */
@@ -21,7 +22,8 @@ interface FileDetailsModalProps {
21
22
 
22
23
  /**
23
24
  * File-detail panel — a rich presented surface (NOT a yes/no confirm): a file's
24
- * icon + metadata plus download / delete / cancel actions. Each action first
25
+ * icon + metadata, for the owner the applications that can open it ("Apps with
26
+ * access", ADR 0043 stage 4c), plus download / delete / cancel actions. Each action first
25
27
  * dismisses this surface, then runs its handler, so a follow-up delete confirm
26
28
  * stacks cleanly above whatever remains. Presented via {@link presentFileDetails}.
27
29
  */
@@ -83,6 +85,8 @@ export const FileDetailsModal: React.FC<FileDetailsModalProps> = ({
83
85
  </View>
84
86
  )}
85
87
  </View>
88
+
89
+ {isOwner ? <FileAppAccessSection fileId={file.id} /> : null}
86
90
  </Card>
87
91
 
88
92
  <View style={{ gap: 8 }}>