@oxyhq/core 20.0.0 → 21.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 (94) hide show
  1. package/NOTICE +10 -9
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  4. package/dist/cjs/i18n/locales/en-US.json +19 -2
  5. package/dist/cjs/i18n/locales/es-ES.json +19 -2
  6. package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
  7. package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
  8. package/dist/cjs/index.js +50 -16
  9. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  10. package/dist/cjs/mixins/OxyServices.chains.js +73 -0
  11. package/dist/cjs/mixins/OxyServices.store.js +266 -0
  12. package/dist/cjs/mixins/OxyServices.utility.js +159 -104
  13. package/dist/cjs/mixins/index.js +7 -0
  14. package/dist/cjs/server/rateLimit.js +15 -6
  15. package/dist/cjs/session/SessionClient.js +361 -1
  16. package/dist/cjs/session/accountDialogController.js +121 -147
  17. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  18. package/dist/cjs/session/deviceDirectory.js +143 -0
  19. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  20. package/dist/cjs/session/projectSessionState.js +8 -1
  21. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  22. package/dist/esm/.tsbuildinfo +1 -1
  23. package/dist/esm/boot/sessionColdBoot.js +107 -8
  24. package/dist/esm/i18n/locales/en-US.json +19 -2
  25. package/dist/esm/i18n/locales/es-ES.json +19 -2
  26. package/dist/esm/i18n/locales/locales/en-US.json +19 -2
  27. package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
  28. package/dist/esm/index.js +32 -10
  29. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  30. package/dist/esm/mixins/OxyServices.chains.js +70 -0
  31. package/dist/esm/mixins/OxyServices.store.js +263 -0
  32. package/dist/esm/mixins/OxyServices.utility.js +159 -104
  33. package/dist/esm/mixins/index.js +7 -0
  34. package/dist/esm/server/rateLimit.js +15 -6
  35. package/dist/esm/session/SessionClient.js +362 -2
  36. package/dist/esm/session/accountDialogController.js +121 -147
  37. package/dist/esm/session/accountSwitchTargets.js +71 -0
  38. package/dist/esm/session/deviceDirectory.js +135 -0
  39. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  40. package/dist/esm/session/projectSessionState.js +8 -2
  41. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  42. package/dist/types/.tsbuildinfo +1 -1
  43. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  44. package/dist/types/index.d.ts +15 -3
  45. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  46. package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
  47. package/dist/types/mixins/OxyServices.store.d.ts +334 -0
  48. package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
  49. package/dist/types/mixins/index.d.ts +3 -1
  50. package/dist/types/models/session.d.ts +11 -0
  51. package/dist/types/session/SessionClient.d.ts +202 -1
  52. package/dist/types/session/accountDialogController.d.ts +76 -64
  53. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  54. package/dist/types/session/deviceDirectory.d.ts +182 -0
  55. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  56. package/dist/types/session/projectSessionState.d.ts +29 -0
  57. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  58. package/package.json +3 -3
  59. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  60. package/src/boot/sessionColdBoot.ts +133 -9
  61. package/src/i18n/locales/en-US.json +19 -2
  62. package/src/i18n/locales/es-ES.json +19 -2
  63. package/src/index.ts +105 -18
  64. package/src/mixins/OxyServices.auth.ts +67 -5
  65. package/src/mixins/OxyServices.chains.ts +134 -0
  66. package/src/mixins/OxyServices.store.ts +585 -0
  67. package/src/mixins/OxyServices.utility.ts +161 -108
  68. package/src/mixins/__tests__/chains.test.ts +113 -0
  69. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  70. package/src/mixins/__tests__/store.test.ts +304 -0
  71. package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
  72. package/src/mixins/index.ts +9 -0
  73. package/src/models/session.ts +11 -0
  74. package/src/server/__tests__/rateLimit.test.ts +47 -0
  75. package/src/server/rateLimit.ts +18 -8
  76. package/src/session/SessionClient.ts +386 -1
  77. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  78. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  79. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  80. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  81. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  82. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  83. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  84. package/src/session/accountDialogController.ts +141 -179
  85. package/src/session/accountSwitchTargets.ts +87 -0
  86. package/src/session/deviceDirectory.ts +269 -0
  87. package/src/session/deviceSwitcherRows.ts +145 -0
  88. package/src/session/projectSessionState.ts +9 -3
  89. package/src/session/sharedDeviceCredential.ts +349 -0
  90. package/dist/cjs/session/accountProjection.js +0 -213
  91. package/dist/esm/session/accountProjection.js +0 -207
  92. package/dist/types/session/accountProjection.d.ts +0 -198
  93. package/src/session/__tests__/accountProjection.test.ts +0 -447
  94. package/src/session/accountProjection.ts +0 -354
@@ -0,0 +1,304 @@
1
+ /**
2
+ * App Store Mixin Tests
3
+ *
4
+ * `makeRequest` is stubbed, so what these cover is the seam between this client
5
+ * and the API: the method, the URL, whether a value is URL-encoded, whether a
6
+ * read is cached, and — the part worth the most here — which envelope a
7
+ * response is unwrapped from.
8
+ *
9
+ * The API has two of those and they are not interchangeable. A single object
10
+ * comes back under `data`; a page comes back under `data` with its counts under
11
+ * `pagination`. Reading `meta` instead, which is the plausible mistake, yields
12
+ * `total: 0` on every page with no error anywhere and no type complaint — so it
13
+ * is asserted against a fixture that has a non-zero total rather than trusted.
14
+ *
15
+ * The publisher's listing routes answer with the object itself, no envelope at
16
+ * all, which is a third shape and is asserted separately for the same reason.
17
+ */
18
+
19
+ import { OxyServices } from '../../OxyServices';
20
+ import type {
21
+ PublisherListing,
22
+ StoreCategory,
23
+ StoreListingDetail,
24
+ StoreListingSummary,
25
+ StoreOwnReview,
26
+ StoreReview,
27
+ StoreScreenshot,
28
+ } from '../OxyServices.store';
29
+
30
+ const categoryFixture: StoreCategory = {
31
+ slug: 'productivity',
32
+ label: 'Productivity',
33
+ description: 'Get things done',
34
+ };
35
+
36
+ const summaryFixture: StoreListingSummary = {
37
+ slug: 'mention',
38
+ name: 'Mention',
39
+ tagline: 'The fediverse, at home',
40
+ icon: 'file-1',
41
+ category: categoryFixture,
42
+ rating: { average: 4.6, count: 31 },
43
+ };
44
+
45
+ const detailFixture: StoreListingDetail = {
46
+ ...summaryFixture,
47
+ description: '# Mention',
48
+ websiteUrl: 'https://mention.earth',
49
+ privacyPolicyUrl: null,
50
+ termsUrl: null,
51
+ supportUrl: null,
52
+ supportEmail: null,
53
+ publishedAt: '2026-08-01T00:00:00.000Z',
54
+ screenshots: [],
55
+ ratingBreakdown: { 4: 10, 5: 21 },
56
+ };
57
+
58
+ const reviewFixture: StoreReview = {
59
+ id: 'r1',
60
+ rating: 5,
61
+ title: null,
62
+ body: 'Good',
63
+ createdAt: '2026-08-01T00:00:00.000Z',
64
+ author: { id: 'u1', username: 'nate' },
65
+ reply: null,
66
+ authorUsesApp: true,
67
+ };
68
+
69
+ const ownReviewFixture: StoreOwnReview = {
70
+ id: 'r1',
71
+ rating: 5,
72
+ title: null,
73
+ body: 'Good',
74
+ status: 'visible',
75
+ createdAt: '2026-08-01T00:00:00.000Z',
76
+ updatedAt: '2026-08-01T00:00:00.000Z',
77
+ };
78
+
79
+ const listingFixture: PublisherListing = {
80
+ id: 'l1',
81
+ applicationId: 'app1',
82
+ slug: 'mention',
83
+ tagline: 'The fediverse, at home',
84
+ description: null,
85
+ category: categoryFixture,
86
+ supportUrl: null,
87
+ supportEmail: null,
88
+ status: 'draft',
89
+ publishedAt: null,
90
+ createdAt: '2026-08-01T00:00:00.000Z',
91
+ updatedAt: '2026-08-01T00:00:00.000Z',
92
+ };
93
+
94
+ const screenshotFixture: StoreScreenshot = {
95
+ id: 's1',
96
+ fileId: 'file-9',
97
+ platform: 'desktop',
98
+ caption: null,
99
+ position: 0,
100
+ };
101
+
102
+ describe('OxyServicesStoreMixin', () => {
103
+ let oxy: OxyServices;
104
+ let makeRequestSpy: jest.SpyInstance;
105
+
106
+ beforeEach(() => {
107
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
108
+ oxy.httpService.setTokens('test-token');
109
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
110
+ });
111
+
112
+ describe('the storefront', () => {
113
+ it('unwraps the shelves from `data` and caches the read', async () => {
114
+ makeRequestSpy.mockResolvedValue({ data: [categoryFixture] });
115
+
116
+ expect(await oxy.listStoreCategories()).toEqual([categoryFixture]);
117
+ expect(makeRequestSpy).toHaveBeenCalledWith(
118
+ 'GET',
119
+ '/store/categories',
120
+ undefined,
121
+ expect.objectContaining({ cache: true }),
122
+ );
123
+ });
124
+
125
+ it('reads a page count from `pagination`, not from `meta`', async () => {
126
+ makeRequestSpy.mockResolvedValue({
127
+ data: [summaryFixture],
128
+ pagination: { total: 42, limit: 24, offset: 0, hasMore: true },
129
+ });
130
+
131
+ const page = await oxy.listStoreApps();
132
+
133
+ // The fixture's total is deliberately not the length of `data`, and not
134
+ // zero: reading the wrong envelope key would give 0 and reading `length`
135
+ // would give 1, so only the right one gives 42.
136
+ expect(page).toEqual({ items: [summaryFixture], total: 42, hasMore: true });
137
+ });
138
+
139
+ it('sends only the options that were supplied', async () => {
140
+ makeRequestSpy.mockResolvedValue({ data: [], pagination: { total: 0 } });
141
+
142
+ await oxy.listStoreApps();
143
+ expect(makeRequestSpy.mock.calls[0][1]).toBe('/store/apps');
144
+
145
+ // An absent option must not travel as `undefined`, which is what a naive
146
+ // template string produces and what the server would then have to reject.
147
+ await oxy.listStoreApps({ category: 'productivity', limit: 12 });
148
+ expect(makeRequestSpy.mock.calls[1][1]).toBe('/store/apps?category=productivity&limit=12');
149
+ });
150
+
151
+ it('survives a response with neither key', async () => {
152
+ makeRequestSpy.mockResolvedValue({});
153
+ expect(await oxy.listStoreApps()).toEqual({ items: [], total: 0, hasMore: false });
154
+ });
155
+
156
+ it('URL-encodes a slug in the path', async () => {
157
+ makeRequestSpy.mockResolvedValue({ data: detailFixture });
158
+
159
+ expect(await oxy.getStoreApp('a b/c')).toEqual(detailFixture);
160
+ expect(makeRequestSpy.mock.calls[0][1]).toBe('/store/apps/a%20b%2Fc');
161
+ });
162
+
163
+ it('pages reviews, and passes the sort through', async () => {
164
+ makeRequestSpy.mockResolvedValue({
165
+ data: [reviewFixture],
166
+ pagination: { total: 3, hasMore: false },
167
+ });
168
+
169
+ const page = await oxy.listStoreReviews('mention', { sort: 'rating', limit: 5 });
170
+
171
+ expect(page.total).toBe(3);
172
+ expect(page.items[0].authorUsesApp).toBe(true);
173
+
174
+ // Parsed rather than compared as a string: the parameter ORDER follows
175
+ // whatever order the caller wrote their options object in, and pinning
176
+ // that would make the test fail on a harmless edit at the call site.
177
+ const [path, query] = String(makeRequestSpy.mock.calls[0][1]).split('?');
178
+ expect(path).toBe('/store/apps/mention/reviews');
179
+ expect(Object.fromEntries(new URLSearchParams(query))).toEqual({
180
+ sort: 'rating',
181
+ limit: '5',
182
+ });
183
+ });
184
+ });
185
+
186
+ describe('reviewing', () => {
187
+ it('answers null when the caller has not written one', async () => {
188
+ makeRequestSpy.mockResolvedValue({ data: null });
189
+ expect(await oxy.getMyStoreReview('mention')).toBeNull();
190
+ });
191
+
192
+ it('PUTs the review — one per person, so it sets rather than adds', async () => {
193
+ makeRequestSpy.mockResolvedValue({ data: ownReviewFixture });
194
+
195
+ expect(await oxy.writeStoreReview('mention', { rating: 5, body: 'Good' })).toEqual(
196
+ ownReviewFixture,
197
+ );
198
+ expect(makeRequestSpy).toHaveBeenCalledWith(
199
+ 'PUT',
200
+ '/store/apps/mention/review',
201
+ { rating: 5, body: 'Good' },
202
+ expect.objectContaining({ cache: false }),
203
+ );
204
+ });
205
+
206
+ it('withdraws the caller’s own review', async () => {
207
+ makeRequestSpy.mockResolvedValue(undefined);
208
+
209
+ await oxy.deleteMyStoreReview('mention');
210
+ expect(makeRequestSpy).toHaveBeenCalledWith(
211
+ 'DELETE',
212
+ '/store/apps/mention/review',
213
+ undefined,
214
+ expect.objectContaining({ cache: false }),
215
+ );
216
+ });
217
+
218
+ it('addresses a reply by review id, not by the listing’s slug', async () => {
219
+ makeRequestSpy.mockResolvedValue({ data: { id: 'p1', reviewId: 'r1', body: 'Fixed' } });
220
+
221
+ await oxy.replyToStoreReview('r1', 'Fixed');
222
+ expect(makeRequestSpy.mock.calls[0][1]).toBe('/store/reviews/r1/reply');
223
+ });
224
+ });
225
+
226
+ describe('the publisher’s listing', () => {
227
+ it('takes the object straight — these routes carry no envelope', async () => {
228
+ makeRequestSpy.mockResolvedValue(listingFixture);
229
+
230
+ expect(await oxy.getAppListing('app1')).toEqual(listingFixture);
231
+ expect(makeRequestSpy).toHaveBeenCalledWith(
232
+ 'GET',
233
+ '/applications/app1/listing',
234
+ undefined,
235
+ expect.objectContaining({ cache: false }),
236
+ );
237
+ });
238
+
239
+ it('PUTs the whole page, without a status field', async () => {
240
+ makeRequestSpy.mockResolvedValue(listingFixture);
241
+
242
+ await oxy.writeAppListing('app1', { slug: 'mention', tagline: 'Hello' });
243
+
244
+ const body = makeRequestSpy.mock.calls[0][2];
245
+ expect(body).toEqual({ slug: 'mention', tagline: 'Hello' });
246
+ expect(body).not.toHaveProperty('status');
247
+ });
248
+
249
+ it('submits and unpublishes through their own routes', async () => {
250
+ makeRequestSpy.mockResolvedValue(listingFixture);
251
+
252
+ await oxy.submitAppListing('app1');
253
+ expect(makeRequestSpy.mock.calls[0].slice(0, 2)).toEqual([
254
+ 'POST',
255
+ '/applications/app1/listing/submit',
256
+ ]);
257
+
258
+ await oxy.unpublishAppListing('app1');
259
+ expect(makeRequestSpy.mock.calls[1].slice(0, 2)).toEqual([
260
+ 'POST',
261
+ '/applications/app1/listing/unpublish',
262
+ ]);
263
+ });
264
+
265
+ it('URL-encodes the application id', async () => {
266
+ makeRequestSpy.mockResolvedValue(listingFixture);
267
+ await oxy.getAppListing('a b/c');
268
+ expect(makeRequestSpy.mock.calls[0][1]).toBe('/applications/a%20b%2Fc/listing');
269
+ });
270
+ });
271
+
272
+ describe('screenshots', () => {
273
+ it('attaches an uploaded file', async () => {
274
+ makeRequestSpy.mockResolvedValue(screenshotFixture);
275
+
276
+ await oxy.addAppListingScreenshot('app1', { fileId: 'file-9', platform: 'phone' });
277
+ expect(makeRequestSpy).toHaveBeenCalledWith(
278
+ 'POST',
279
+ '/applications/app1/listing/screenshots',
280
+ { fileId: 'file-9', platform: 'phone' },
281
+ expect.objectContaining({ cache: false }),
282
+ );
283
+ });
284
+
285
+ it('encodes both ids on an edit', async () => {
286
+ makeRequestSpy.mockResolvedValue(screenshotFixture);
287
+
288
+ await oxy.updateAppListingScreenshot('a b', 's/1', { caption: 'Home' });
289
+ expect(makeRequestSpy.mock.calls[0][1]).toBe('/applications/a%20b/listing/screenshots/s%2F1');
290
+ });
291
+
292
+ it('reorders by sending every id, on its own route', async () => {
293
+ makeRequestSpy.mockResolvedValue([screenshotFixture]);
294
+
295
+ await oxy.reorderAppListingScreenshots('app1', ['s2', 's1']);
296
+ expect(makeRequestSpy).toHaveBeenCalledWith(
297
+ 'PUT',
298
+ '/applications/app1/listing/screenshots/order',
299
+ { screenshotIds: ['s2', 's1'] },
300
+ expect.objectContaining({ cache: false }),
301
+ );
302
+ });
303
+ });
304
+ });