@oxyhq/services 17.0.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +77 -98
  2. package/lib/commonjs/index.js +19 -0
  3. package/lib/commonjs/index.js.map +1 -1
  4. package/lib/commonjs/ui/components/OxyAccountDialog.js +62 -84
  5. package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
  6. package/lib/commonjs/ui/components/OxyConsentScreen.js +461 -0
  7. package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -0
  8. package/lib/commonjs/ui/components/OxyProvider.js +3 -1
  9. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  10. package/lib/commonjs/ui/components/OxySignInButton.js +176 -7
  11. package/lib/commonjs/ui/components/OxySignInButton.js.map +1 -1
  12. package/lib/commonjs/ui/components/oauthNavigation.js +73 -0
  13. package/lib/commonjs/ui/components/oauthNavigation.js.map +1 -0
  14. package/lib/commonjs/ui/context/OxyContext.js +16 -2
  15. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  16. package/lib/module/index.js +2 -2
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/ui/components/OxyAccountDialog.js +63 -85
  19. package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
  20. package/lib/module/ui/components/OxyConsentScreen.js +454 -0
  21. package/lib/module/ui/components/OxyConsentScreen.js.map +1 -0
  22. package/lib/module/ui/components/OxyProvider.js +3 -1
  23. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  24. package/lib/module/ui/components/OxySignInButton.js +177 -7
  25. package/lib/module/ui/components/OxySignInButton.js.map +1 -1
  26. package/lib/module/ui/components/oauthNavigation.js +70 -0
  27. package/lib/module/ui/components/oauthNavigation.js.map +1 -0
  28. package/lib/module/ui/context/OxyContext.js +16 -2
  29. package/lib/module/ui/context/OxyContext.js.map +1 -1
  30. package/lib/typescript/commonjs/index.d.ts +4 -1
  31. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts +18 -2
  33. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +45 -0
  35. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -0
  36. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts +56 -0
  38. package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts +36 -0
  40. package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +14 -0
  42. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts +1 -1
  44. package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/types/navigation.d.ts +18 -0
  46. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  47. package/lib/typescript/module/index.d.ts +4 -1
  48. package/lib/typescript/module/index.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts +18 -2
  50. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +45 -0
  52. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -0
  53. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  54. package/lib/typescript/module/ui/components/OxySignInButton.d.ts +56 -0
  55. package/lib/typescript/module/ui/components/OxySignInButton.d.ts.map +1 -1
  56. package/lib/typescript/module/ui/components/oauthNavigation.d.ts +36 -0
  57. package/lib/typescript/module/ui/components/oauthNavigation.d.ts.map +1 -0
  58. package/lib/typescript/module/ui/context/OxyContext.d.ts +14 -0
  59. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  60. package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts +1 -1
  61. package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
  62. package/lib/typescript/module/ui/types/navigation.d.ts +18 -0
  63. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  64. package/package.json +4 -4
  65. package/src/index.ts +12 -1
  66. package/src/ui/components/OxyAccountDialog.tsx +60 -85
  67. package/src/ui/components/OxyConsentScreen.tsx +456 -0
  68. package/src/ui/components/OxyProvider.tsx +2 -0
  69. package/src/ui/components/OxySignInButton.tsx +222 -6
  70. package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +130 -0
  71. package/src/ui/components/oauthNavigation.ts +89 -0
  72. package/src/ui/context/OxyContext.tsx +30 -2
  73. package/src/ui/context/__tests__/OxyContext.coldBoot.test.tsx +165 -0
  74. package/src/ui/types/navigation.ts +18 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `OxyContextProvider` cold-boot opt-out (IdP mode).
3
+ *
4
+ * The provider is the ecosystem's device-first session authority by default:
5
+ * it runs `runSessionColdBoot` on mount and opens the signed-out device-state
6
+ * socket. The IdP host (`auth.oxy.so`) is NOT a session authority, so it mounts
7
+ * the provider with `coldBoot={false}` — the cold boot must never run and the
8
+ * device socket must never open, yet interactive sign-in must still commit a
9
+ * normal session on that origin.
10
+ */
11
+ import { render, act, waitFor } from '@testing-library/react';
12
+ import type { ReactNode } from 'react';
13
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
14
+ import { OxyServices, runSessionColdBoot } from '@oxyhq/core';
15
+ import type { User } from '@oxyhq/core';
16
+ import type { LoginSessionResult } from '@oxyhq/contracts';
17
+ import { OxyContextProvider, useOxy, type OxyContextState } from '../OxyContext';
18
+ import { createSessionClient } from '../../session';
19
+
20
+ // Spy on the device-first cold boot without touching the rest of core.
21
+ jest.mock('@oxyhq/core', () => {
22
+ const actual = jest.requireActual('@oxyhq/core');
23
+ return {
24
+ __esModule: true,
25
+ ...actual,
26
+ runSessionColdBoot: jest.fn(() => Promise.resolve()),
27
+ };
28
+ });
29
+
30
+ // Replace the real SessionClient with an inert fake so no socket connects and
31
+ // no network is hit. `getState()` returns null, so `syncFromClient` short-
32
+ // circuits and never reaches profile resolution.
33
+ jest.mock('../../session', () => {
34
+ const actual = jest.requireActual('../../session');
35
+ return {
36
+ __esModule: true,
37
+ ...actual,
38
+ createSessionClient: jest.fn(() => {
39
+ const listeners = new Set<() => void>();
40
+ const client = {
41
+ getState: jest.fn(() => null),
42
+ subscribe: jest.fn((listener: () => void) => {
43
+ listeners.add(listener);
44
+ return () => {
45
+ listeners.delete(listener);
46
+ };
47
+ }),
48
+ start: jest.fn(() => Promise.resolve()),
49
+ bootstrap: jest.fn(() => Promise.resolve()),
50
+ addCurrentAccount: jest.fn(() => Promise.resolve()),
51
+ registerAndActivate: jest.fn(() => Promise.resolve()),
52
+ switchAccount: jest.fn(() => Promise.resolve()),
53
+ };
54
+ const host = { setCurrentAccountId: jest.fn() };
55
+ return { client, host };
56
+ }),
57
+ };
58
+ });
59
+
60
+ const coldBootMock = runSessionColdBoot as jest.Mock;
61
+ const createSessionClientMock = createSessionClient as jest.Mock;
62
+
63
+ function renderProvider(props: { coldBoot?: boolean; oxyServices: OxyServices }) {
64
+ let latest: OxyContextState | null = null;
65
+ const Probe = (): ReactNode => {
66
+ latest = useOxy();
67
+ return null;
68
+ };
69
+ const queryClient = new QueryClient({
70
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
71
+ });
72
+ const view = render(
73
+ <QueryClientProvider client={queryClient}>
74
+ <OxyContextProvider oxyServices={props.oxyServices} coldBoot={props.coldBoot}>
75
+ <Probe />
76
+ </OxyContextProvider>
77
+ </QueryClientProvider>,
78
+ );
79
+ return { ...view, getState: (): OxyContextState | null => latest };
80
+ }
81
+
82
+ /** The most recently constructed fake SessionClient. */
83
+ function lastSessionClient(): { start: jest.Mock } {
84
+ const results = createSessionClientMock.mock.results;
85
+ return results[results.length - 1].value.client as { start: jest.Mock };
86
+ }
87
+
88
+ beforeEach(() => {
89
+ coldBootMock.mockClear();
90
+ createSessionClientMock.mockClear();
91
+ });
92
+
93
+ describe('OxyContextProvider coldBoot opt-out (IdP mode)', () => {
94
+ it('coldBoot={false}: never runs the cold boot and resolves auth immediately as signed out', async () => {
95
+ const oxy = new OxyServices({ baseURL: 'https://api.test.local' });
96
+ const { getState } = renderProvider({ coldBoot: false, oxyServices: oxy });
97
+
98
+ await waitFor(() => {
99
+ expect(getState()?.isAuthResolved).toBe(true);
100
+ });
101
+
102
+ expect(coldBootMock).not.toHaveBeenCalled();
103
+ expect(getState()?.isAuthenticated).toBe(false);
104
+ expect(getState()?.user).toBeNull();
105
+ // No boot spinner: private-API readiness settles to a definitive "no".
106
+ expect(getState()?.isPrivateApiPending).toBe(false);
107
+ expect(getState()?.canUsePrivateApi).toBe(false);
108
+ // The signed-out device-state socket must never open in IdP mode.
109
+ expect(lastSessionClient().start).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it('coldBoot={false}: interactive password sign-in still commits a normal session', async () => {
113
+ const oxy = new OxyServices({ baseURL: 'https://api.test.local' });
114
+ const loginResult: LoginSessionResult = {
115
+ sessionId: 's-1',
116
+ deviceId: 'd-1',
117
+ expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
118
+ accessToken: 'access-1',
119
+ refreshToken: 'refresh-1',
120
+ user: { id: 'u-1', username: 'alice' },
121
+ };
122
+ jest.spyOn(oxy, 'passwordSignIn').mockResolvedValue(loginResult);
123
+ jest.spyOn(oxy, 'getCurrentUser').mockResolvedValue({
124
+ id: 'u-1',
125
+ username: 'alice',
126
+ name: { displayName: 'Alice' },
127
+ } as User);
128
+
129
+ const { getState } = renderProvider({ coldBoot: false, oxyServices: oxy });
130
+ await waitFor(() => {
131
+ expect(getState()?.isAuthResolved).toBe(true);
132
+ });
133
+
134
+ const state = getState();
135
+ if (!state) {
136
+ throw new Error('context did not resolve');
137
+ }
138
+ await act(async () => {
139
+ const result = await state.signInWithPassword('alice', 'secret');
140
+ expect(result.status).toBe('ok');
141
+ });
142
+
143
+ await waitFor(() => {
144
+ expect(getState()?.isAuthenticated).toBe(true);
145
+ });
146
+ expect(getState()?.user?.id).toBe('u-1');
147
+ expect(oxy.passwordSignIn).toHaveBeenCalledWith(
148
+ 'alice',
149
+ 'secret',
150
+ expect.objectContaining({ deviceToken: undefined }),
151
+ );
152
+ });
153
+
154
+ it('default (coldBoot omitted): runs the device-first cold boot', async () => {
155
+ const oxy = new OxyServices({ baseURL: 'https://api.test.local' });
156
+ const { getState } = renderProvider({ oxyServices: oxy });
157
+
158
+ await waitFor(() => {
159
+ expect(coldBootMock).toHaveBeenCalledTimes(1);
160
+ });
161
+ await waitFor(() => {
162
+ expect(getState()?.isAuthResolved).toBe(true);
163
+ });
164
+ });
165
+ });
@@ -72,4 +72,22 @@ export interface OxyProviderProps {
72
72
  * @default 'off'
73
73
  */
74
74
  requireAuth?: 'off' | 'soft' | 'hard';
75
+ /**
76
+ * Whether this provider acts as the ecosystem's device-first **session
77
+ * authority**. `true` (default) runs the full device-first cold boot on
78
+ * mount (bootstrap-return → stored tokens → shared key / bootstrap hop) and
79
+ * opens the signed-out device-state socket so an idle tab self-acquires when
80
+ * a sibling signs in — the correct behavior for every Relying Party app.
81
+ *
82
+ * Set `false` for the **IdP host** (`auth.oxy.so`): the IdP is NOT a session
83
+ * authority (see the handoff "IdP vs RP" section). With `coldBoot={false}`
84
+ * the provider skips `runSessionColdBoot` entirely and never opens the
85
+ * signed-out device socket — auth resolves immediately as signed out (no
86
+ * boot spinner). Interactive sign-in is unaffected: a user who signs in
87
+ * through this provider (password, 2FA, or the "Sign in with Oxy" QR device
88
+ * flow) still commits a normal session scoped to this origin, which is all
89
+ * the IdP needs to drive its OAuth authorize/consent flow.
90
+ * @default true
91
+ */
92
+ coldBoot?: boolean;
75
93
  }