@oxy.so/services 12.12.0 → 12.14.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.
- package/lib/commonjs/ui/boot/runProviderColdBoot.js +188 -88
- package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/commonjs/ui/components/OxyProvider.js +2 -1
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +116 -3
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/hooks/queries/useSupervision.js +45 -9
- package/lib/commonjs/ui/hooks/queries/useSupervision.js.map +1 -1
- package/lib/commonjs/ui/session/cachedBoot.js +95 -0
- package/lib/commonjs/ui/session/cachedBoot.js.map +1 -0
- package/lib/module/ui/boot/runProviderColdBoot.js +188 -88
- package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/module/ui/components/OxyProvider.js +2 -1
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +117 -4
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/hooks/queries/useSupervision.js +45 -10
- package/lib/module/ui/hooks/queries/useSupervision.js.map +1 -1
- package/lib/module/ui/session/cachedBoot.js +92 -0
- package/lib/module/ui/session/cachedBoot.js.map +1 -0
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +24 -0
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useSupervision.d.ts +22 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useSupervision.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts +48 -0
- package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts.map +1 -0
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +24 -0
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/useSupervision.d.ts +22 -1
- package/lib/typescript/module/ui/hooks/queries/useSupervision.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/cachedBoot.d.ts +48 -0
- package/lib/typescript/module/ui/session/cachedBoot.d.ts.map +1 -0
- package/package.json +7 -8
- package/src/ui/boot/__tests__/cachedColdBoot.test.ts +187 -0
- package/src/ui/boot/runProviderColdBoot.ts +229 -95
- package/src/ui/components/OxyProvider.tsx +2 -0
- package/src/ui/context/OxyContext.tsx +146 -3
- package/src/ui/hooks/queries/__tests__/useSupervision.test.tsx +73 -0
- package/src/ui/hooks/queries/useSupervision.ts +43 -9
- package/src/ui/session/__tests__/cachedBoot.test.ts +32 -0
- package/src/ui/session/cachedBoot.ts +139 -0
|
@@ -1,25 +1,59 @@
|
|
|
1
|
-
import { useQuery } from '@tanstack/react-query';
|
|
1
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { SUPERVISION_CHANGED_EVENT } from '@oxy.so/contracts/client';
|
|
2
3
|
import { useOxy } from '../../context/oxyRuntimeContext';
|
|
4
|
+
import { useOxyEvent } from '../useOxyEvent';
|
|
5
|
+
|
|
3
6
|
export const supervisionQueryKeys = {
|
|
4
7
|
all: ['oxy', 'safety', 'supervision'] as const,
|
|
5
8
|
account: (id: string, sessionId: string) =>
|
|
6
9
|
['oxy', 'safety', 'supervision', id, sessionId] as const,
|
|
7
10
|
};
|
|
8
|
-
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The signed-in personal account's supervision (parental-control) state.
|
|
14
|
+
*
|
|
15
|
+
* Policy: the SDK keeps the session's supervision state always current — it is
|
|
16
|
+
* fetched when the session is established, refetched when the app returns to
|
|
17
|
+
* the foreground, and refetched when the session socket signals
|
|
18
|
+
* `supervision_changed` (`SupervisionStateObserver`, mounted by `OxyProvider`).
|
|
19
|
+
* Screens read it from the cache without waiting for the network: no observer
|
|
20
|
+
* forces its own refetch on mount. `data` is undefined only while the session's
|
|
21
|
+
* first fetch is pending, when the query is disabled (signed out, or not a
|
|
22
|
+
* personal account) and after a failed fetch.
|
|
23
|
+
*
|
|
24
|
+
* Advisory UI state only. The server keeps enforcing supervision on every
|
|
25
|
+
* content request; never treat this value as authorization.
|
|
26
|
+
*/
|
|
9
27
|
export function useSupervision() {
|
|
10
|
-
const { oxyServices, user,
|
|
28
|
+
const { oxyServices, user, canUsePrivateApi, activeSessionId } = useOxy();
|
|
11
29
|
const result = useQuery({
|
|
12
30
|
queryKey: supervisionQueryKeys.account(user?.id ?? '', activeSessionId ?? ''),
|
|
13
31
|
queryFn: () => oxyServices.safety.supervisionState(),
|
|
14
|
-
enabled:
|
|
15
|
-
|
|
16
|
-
|
|
32
|
+
enabled: canUsePrivateApi && !!activeSessionId && user?.kind === 'personal',
|
|
33
|
+
// Fresh until the socket signal or a return to the foreground says otherwise.
|
|
34
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
35
|
+
// The observer below keeps the current session's entry alive; this only
|
|
36
|
+
// bounds how long a previous session's entry outlives it.
|
|
37
|
+
gcTime: 30 * 60 * 1000,
|
|
17
38
|
refetchOnWindowFocus: 'always',
|
|
18
|
-
refetchOnMount: 'always',
|
|
19
|
-
retry: false,
|
|
20
39
|
});
|
|
21
40
|
return {
|
|
22
41
|
...result,
|
|
23
|
-
data: result.
|
|
42
|
+
data: result.isError ? undefined : result.data,
|
|
24
43
|
};
|
|
25
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Keeps the session's supervision state current for every `useSupervision()`
|
|
48
|
+
* reader: its observer fetches once the session is established and on each
|
|
49
|
+
* return to the foreground, and the `supervision_changed` socket signal
|
|
50
|
+
* refetches it. Rendered once by `OxyProvider`.
|
|
51
|
+
*/
|
|
52
|
+
export function SupervisionStateObserver() {
|
|
53
|
+
const queryClient = useQueryClient();
|
|
54
|
+
useSupervision();
|
|
55
|
+
useOxyEvent(SUPERVISION_CHANGED_EVENT, () => {
|
|
56
|
+
void queryClient.invalidateQueries({ queryKey: supervisionQueryKeys.all });
|
|
57
|
+
});
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { User } from '@oxy.so/core';
|
|
2
|
+
import type { AuthStateStore, PersistedAuthState } from '@oxy.so/core/session';
|
|
3
|
+
import { createCachedBootStore } from '../cachedBoot';
|
|
4
|
+
import { createMemoryStorage } from '../../utils/storageHelpers';
|
|
5
|
+
|
|
6
|
+
function authStore(state: PersistedAuthState): AuthStateStore {
|
|
7
|
+
return {
|
|
8
|
+
load: async () => state,
|
|
9
|
+
save: async () => true,
|
|
10
|
+
clear: async () => undefined,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
it('offers a cached session only for the account the saved session belongs to', async () => {
|
|
15
|
+
const cache = createCachedBootStore(createMemoryStorage(), 'oxy_session');
|
|
16
|
+
const saved = { sessionId: 's1', userId: 'u1', deviceId: 'd1', deviceSecret: 'secret' };
|
|
17
|
+
await cache.saveLane('client', 'device');
|
|
18
|
+
await cache.saveAccount({ id: 'u2', username: 'other' } as User);
|
|
19
|
+
|
|
20
|
+
expect(await cache.readSession('client', authStore(saved))).toBeNull();
|
|
21
|
+
|
|
22
|
+
await cache.saveAccount({ id: 'u1', username: 'me' } as User);
|
|
23
|
+
expect(await cache.readSession('client', authStore(saved))).toMatchObject({
|
|
24
|
+
sessionId: 's1',
|
|
25
|
+
user: { id: 'u1' },
|
|
26
|
+
warmToken: null,
|
|
27
|
+
});
|
|
28
|
+
// Another app's classification, or an OAuth-only one, never paints.
|
|
29
|
+
expect(await cache.readSession('another-client', authStore(saved))).toBeNull();
|
|
30
|
+
await cache.saveLane('client', 'oauth');
|
|
31
|
+
expect(await cache.readSession('client', authStore(saved))).toBeNull();
|
|
32
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a returning session needs for its first paint, kept on this device.
|
|
3
|
+
*
|
|
4
|
+
* A cold boot used to wait on two round trips before it showed anything: the
|
|
5
|
+
* registry's classification of this app and the profile of the signed-in
|
|
6
|
+
* account. With a session saved on the device, the app now paints at once from
|
|
7
|
+
* what the last run left here, and the boot verifies both in the background
|
|
8
|
+
* (`runProviderColdBoot`). This module is that cache:
|
|
9
|
+
*
|
|
10
|
+
* - the registry's last verdict on this app (`device` or `oauth`), tied to the
|
|
11
|
+
* `clientId` it was given for;
|
|
12
|
+
* - the last account profile the runtime held, tied to that account's id.
|
|
13
|
+
*
|
|
14
|
+
* Both live in the provider's ordinary storage (AsyncStorage on native,
|
|
15
|
+
* `localStorage` on web), next to the per-account query cache, which already
|
|
16
|
+
* holds the same profile under the SDK's `users` queries. Neither is a
|
|
17
|
+
* credential: the device secret and the access token stay in the auth store.
|
|
18
|
+
*
|
|
19
|
+
* A cached session is offered only when it matches the auth store: the profile
|
|
20
|
+
* must belong to the account the persisted session is for, so a switch or a
|
|
21
|
+
* sign-out on a previous run can never paint somebody else.
|
|
22
|
+
*/
|
|
23
|
+
import type { User } from '@oxy.so/core';
|
|
24
|
+
import { TOKEN_REFRESH_LEAD_MS, type AuthStateStore } from '@oxy.so/core/session';
|
|
25
|
+
import type { StorageInterface } from '../utils/storageHelpers';
|
|
26
|
+
|
|
27
|
+
export type SessionLane = 'device' | 'oauth';
|
|
28
|
+
|
|
29
|
+
/** A persisted session the app can show before the network answers. */
|
|
30
|
+
export interface CachedSession {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
userId: string;
|
|
33
|
+
deviceId: string;
|
|
34
|
+
/** The account profile the last run held for {@link userId}. */
|
|
35
|
+
user: User;
|
|
36
|
+
/** The persisted access token, when it is still valid past the refresh lead. */
|
|
37
|
+
warmToken: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CachedBootStore {
|
|
41
|
+
saveLane(clientId: string, lane: SessionLane): Promise<void>;
|
|
42
|
+
saveAccount(user: User): Promise<void>;
|
|
43
|
+
clearAccount(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* The cached session for this `clientId`, or `null` when anything a first
|
|
46
|
+
* paint needs is missing or does not match the auth store.
|
|
47
|
+
*/
|
|
48
|
+
readSession(clientId: string, authStore: AuthStateStore): Promise<CachedSession | null>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SCHEMA_VERSION = 1;
|
|
52
|
+
|
|
53
|
+
function parse(raw: string | null): Record<string, unknown> | null {
|
|
54
|
+
if (!raw) return null;
|
|
55
|
+
try {
|
|
56
|
+
const value: unknown = JSON.parse(raw);
|
|
57
|
+
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isUser(value: unknown): value is User {
|
|
64
|
+
return (
|
|
65
|
+
Boolean(value) &&
|
|
66
|
+
typeof value === 'object' &&
|
|
67
|
+
typeof (value as { id?: unknown }).id === 'string' &&
|
|
68
|
+
(value as { id: string }).id.length > 0
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createCachedBootStore(
|
|
73
|
+
storage: StorageInterface,
|
|
74
|
+
prefix: string,
|
|
75
|
+
now: () => number = Date.now,
|
|
76
|
+
): CachedBootStore {
|
|
77
|
+
const laneKey = `${prefix}_boot_lane`;
|
|
78
|
+
const accountKey = `${prefix}_boot_account`;
|
|
79
|
+
// Storage failures are never fatal here: a missing entry only means the next
|
|
80
|
+
// boot waits for the network, as every boot did before this cache existed.
|
|
81
|
+
const write = (key: string, value: unknown) =>
|
|
82
|
+
storage.setItem(key, JSON.stringify({ v: SCHEMA_VERSION, ...(value as object) })).catch(() => {
|
|
83
|
+
/* best effort */
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
saveLane: (clientId, lane) => write(laneKey, { clientId, lane }),
|
|
88
|
+
saveAccount: (user) => write(accountKey, { user }),
|
|
89
|
+
clearAccount: () =>
|
|
90
|
+
storage.removeItem(accountKey).catch(() => {
|
|
91
|
+
/* best effort */
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
async readSession(clientId, authStore) {
|
|
95
|
+
const [laneEntry, accountEntry] = await Promise.all([
|
|
96
|
+
storage.getItem(laneKey).then(parse, () => null),
|
|
97
|
+
storage.getItem(accountKey).then(parse, () => null),
|
|
98
|
+
]);
|
|
99
|
+
if (
|
|
100
|
+
laneEntry?.v !== SCHEMA_VERSION ||
|
|
101
|
+
laneEntry.clientId !== clientId ||
|
|
102
|
+
laneEntry.lane !== 'device'
|
|
103
|
+
) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const user = accountEntry?.v === SCHEMA_VERSION ? accountEntry.user : null;
|
|
107
|
+
if (!isUser(user)) return null;
|
|
108
|
+
|
|
109
|
+
const persisted = await authStore.load();
|
|
110
|
+
// Only the app's OWN holder: the background check mints with it, which is
|
|
111
|
+
// the only way the server can confirm or end this session.
|
|
112
|
+
if (
|
|
113
|
+
!persisted?.sessionId ||
|
|
114
|
+
!persisted.deviceId ||
|
|
115
|
+
!persisted.deviceSecret ||
|
|
116
|
+
persisted.userId !== user.id
|
|
117
|
+
) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
// A recorded sign-out on this device restores nothing automatically.
|
|
121
|
+
if (await authStore.isAutomaticIdentitySignInSuppressed?.()) return null;
|
|
122
|
+
|
|
123
|
+
const expiresAtMs = persisted.expiresAt ? new Date(persisted.expiresAt).getTime() : NaN;
|
|
124
|
+
const warmToken =
|
|
125
|
+
persisted.accessToken &&
|
|
126
|
+
Number.isFinite(expiresAtMs) &&
|
|
127
|
+
expiresAtMs > now() + TOKEN_REFRESH_LEAD_MS
|
|
128
|
+
? persisted.accessToken
|
|
129
|
+
: null;
|
|
130
|
+
return {
|
|
131
|
+
sessionId: persisted.sessionId,
|
|
132
|
+
userId: persisted.userId,
|
|
133
|
+
deviceId: persisted.deviceId,
|
|
134
|
+
user,
|
|
135
|
+
warmToken,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|