@oxyhq/core 12.10.6 → 12.11.1

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 (139) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +66 -6
  3. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  4. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  5. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  6. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  7. package/dist/cjs/i18n/locales/en-US.json +13 -4
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  9. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  10. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  11. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  12. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  16. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  18. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  23. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  24. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  25. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  26. package/dist/cjs/index.js +31 -5
  27. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  28. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  29. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  30. package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
  31. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  32. package/dist/cjs/mixins/index.js +5 -0
  33. package/dist/cjs/session/SessionClient.js +40 -4
  34. package/dist/cjs/session/accountDialogController.js +288 -41
  35. package/dist/cjs/session/identityPin.js +256 -0
  36. package/dist/cjs/session/identitySession.js +135 -0
  37. package/dist/cjs/session/projectSessionState.js +54 -15
  38. package/dist/cjs/session/refresh.js +73 -8
  39. package/dist/cjs/utils/commonsDelivery.js +59 -0
  40. package/dist/cjs/utils/oauthPkce.js +33 -3
  41. package/dist/esm/.tsbuildinfo +1 -1
  42. package/dist/esm/HttpService.js +66 -6
  43. package/dist/esm/boot/sessionColdBoot.js +142 -37
  44. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  45. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  46. package/dist/esm/i18n/locales/de-DE.json +13 -1
  47. package/dist/esm/i18n/locales/en-US.json +13 -4
  48. package/dist/esm/i18n/locales/es-ES.json +13 -4
  49. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  50. package/dist/esm/i18n/locales/it-IT.json +13 -1
  51. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  52. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  53. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  54. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  55. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  56. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  57. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  58. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  59. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  60. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  61. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  62. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  63. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  64. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  65. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  66. package/dist/esm/index.js +14 -1
  67. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  68. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  69. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  70. package/dist/esm/mixins/OxyServices.privacy.js +6 -0
  71. package/dist/esm/mixins/OxyServices.user.js +1 -0
  72. package/dist/esm/mixins/index.js +5 -0
  73. package/dist/esm/session/SessionClient.js +40 -4
  74. package/dist/esm/session/accountDialogController.js +288 -41
  75. package/dist/esm/session/identityPin.js +249 -0
  76. package/dist/esm/session/identitySession.js +131 -0
  77. package/dist/esm/session/projectSessionState.js +54 -15
  78. package/dist/esm/session/refresh.js +73 -8
  79. package/dist/esm/utils/commonsDelivery.js +54 -0
  80. package/dist/esm/utils/oauthPkce.js +31 -2
  81. package/dist/types/.tsbuildinfo +1 -1
  82. package/dist/types/HttpService.d.ts +18 -1
  83. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  84. package/dist/types/index.d.ts +12 -4
  85. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  86. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  87. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  88. package/dist/types/mixins/OxyServices.user.d.ts +2 -0
  89. package/dist/types/mixins/index.d.ts +2 -1
  90. package/dist/types/session/SessionClient.d.ts +28 -0
  91. package/dist/types/session/accountDialogController.d.ts +187 -9
  92. package/dist/types/session/identityPin.d.ts +87 -0
  93. package/dist/types/session/identitySession.d.ts +105 -0
  94. package/dist/types/session/projectSessionState.d.ts +21 -19
  95. package/dist/types/session/refresh.d.ts +32 -1
  96. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  97. package/dist/types/utils/oauthPkce.d.ts +21 -1
  98. package/package.json +3 -3
  99. package/src/HttpService.ts +67 -6
  100. package/src/__tests__/inSessionRefresh.test.ts +67 -0
  101. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  102. package/src/boot/sessionColdBoot.ts +183 -37
  103. package/src/i18n/locales/ar-SA.json +13 -1
  104. package/src/i18n/locales/ca-ES.json +13 -1
  105. package/src/i18n/locales/de-DE.json +13 -1
  106. package/src/i18n/locales/en-US.json +13 -4
  107. package/src/i18n/locales/es-ES.json +13 -4
  108. package/src/i18n/locales/fr-FR.json +13 -1
  109. package/src/i18n/locales/it-IT.json +13 -1
  110. package/src/i18n/locales/ja-JP.json +13 -1
  111. package/src/i18n/locales/ko-KR.json +13 -1
  112. package/src/i18n/locales/pt-PT.json +13 -1
  113. package/src/i18n/locales/zh-CN.json +13 -1
  114. package/src/index.ts +49 -0
  115. package/src/mixins/OxyServices.auth.ts +464 -24
  116. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  117. package/src/mixins/OxyServices.notifications.ts +142 -0
  118. package/src/mixins/OxyServices.privacy.ts +6 -0
  119. package/src/mixins/OxyServices.user.ts +3 -0
  120. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  121. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  122. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  123. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +2 -0
  124. package/src/mixins/index.ts +6 -0
  125. package/src/session/SessionClient.ts +57 -4
  126. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  127. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  128. package/src/session/__tests__/identityPin.test.ts +131 -0
  129. package/src/session/__tests__/identitySession.test.ts +194 -0
  130. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  131. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  132. package/src/session/accountDialogController.ts +435 -44
  133. package/src/session/identityPin.ts +310 -0
  134. package/src/session/identitySession.ts +206 -0
  135. package/src/session/projectSessionState.ts +61 -13
  136. package/src/session/refresh.ts +103 -9
  137. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  138. package/src/utils/commonsDelivery.ts +132 -0
  139. package/src/utils/oauthPkce.ts +56 -3
@@ -24,6 +24,42 @@ import {
24
24
  } from '@oxyhq/contracts';
25
25
  import type { OxyServicesBase } from '../OxyServices.base';
26
26
 
27
+ /**
28
+ * The server's `401 account_not_on_device` for a PINNED mint: the requested
29
+ * `accountId` is not (or is no longer) a live account of this device session.
30
+ *
31
+ * Distinguished from every other mint 401 because the remedy is different: the
32
+ * device secret is FINE — it is the identity binding that went stale (the
33
+ * account was signed out on this device, or revoked). An identity-bound caller
34
+ * must re-establish its session from the local key rather than drop/clear the
35
+ * device credential.
36
+ */
37
+ export class AccountNotOnDeviceError extends Error {
38
+ override readonly name = 'AccountNotOnDeviceError';
39
+ /** HTTP status of the originating response; mirrors the ApiError shape. */
40
+ readonly status = 401;
41
+ constructor(readonly accountId: string, readonly cause?: unknown) {
42
+ super(
43
+ `account_not_on_device: ${accountId} is not a live account of this device session`,
44
+ );
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Structural (never `instanceof`) read of a normalized mint error: the thrown
50
+ * value may be a plain ApiError-shaped object or come from another realm.
51
+ */
52
+ function isAccountNotOnDevice(error: unknown): boolean {
53
+ if (!error || typeof error !== 'object') {
54
+ return false;
55
+ }
56
+ const { status, message } = error as { status?: unknown; message?: unknown };
57
+ if (status !== 401) {
58
+ return false;
59
+ }
60
+ return typeof message === 'string' && message.includes('account_not_on_device');
61
+ }
62
+
27
63
  export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Base: T) {
28
64
  return class extends Base {
29
65
  /**
@@ -47,17 +83,29 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
47
83
  * boot's worst-case time-to-route. A transient failure surfaces once and the
48
84
  * scheduler/401 path retries it later.
49
85
  *
86
+ * `options.accountId` PINS the mint to one account of the device instead of
87
+ * whichever account is currently active. It exists for identity-bound
88
+ * clients (Commons), whose authenticated user is fixed by a local
89
+ * cryptographic key and must never follow an account switch made by another
90
+ * app on the same device. The server never mutates `activeAccountId` for a
91
+ * pinned mint — the returned `state` still reports the device's true active
92
+ * account — and rejects a non-member/dead account with
93
+ * `401 account_not_on_device`, surfaced here as {@link AccountNotOnDeviceError}.
94
+ *
95
+ * @throws {AccountNotOnDeviceError} when a pinned mint's account is not on the device.
50
96
  * @throws if the response does not match {@link deviceTokenMintResponseSchema}.
51
97
  */
52
98
  async mintFromDeviceSecret(
53
99
  deviceId: string,
54
100
  deviceSecret: string,
101
+ options?: { accountId?: string },
55
102
  ): Promise<DeviceTokenMintResponse> {
103
+ const accountId = options?.accountId;
56
104
  try {
57
105
  const res = await this.makeRequest<unknown>(
58
106
  'POST',
59
107
  '/session/device/token',
60
- { deviceId, deviceSecret },
108
+ { deviceId, deviceSecret, ...(accountId ? { accountId } : {}) },
61
109
  // `bypassQueue`: this mint is the control-plane call the auth lane
62
110
  // depends on — it must run even when every RequestQueue slot is parked
63
111
  // awaiting it, or the whole client deadlocks. See RequestOptions.bypassQueue.
@@ -69,7 +117,11 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
69
117
  }
70
118
  return parsed;
71
119
  } catch (error) {
72
- throw this.handleError(error);
120
+ const normalized = this.handleError(error);
121
+ if (accountId && isAccountNotOnDevice(normalized)) {
122
+ throw new AccountNotOnDeviceError(accountId, normalized);
123
+ }
124
+ throw normalized;
73
125
  }
74
126
  }
75
127
 
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Push Notification Registration Mixin
3
+ *
4
+ * Registers this installation's push token with Oxy so the platform can reach
5
+ * the signed-in identity — most importantly to deliver a "Sign in with Oxy"
6
+ * approval request to a known Commons installation (issue #691, Phase 4)
7
+ * instead of forcing the user onto a QR.
8
+ *
9
+ * This lives in `@oxyhq/core` because push registration is cross-cutting: every
10
+ * Oxy app that wants to receive platform notifications performs the exact same
11
+ * bearer-authenticated register/unregister pair. App-local copies drift (see the
12
+ * token-kind hazard below), so there is ONE implementation here.
13
+ *
14
+ * ## The token is an EXPO PUSH TOKEN
15
+ *
16
+ * `registerPushToken` takes the token returned by
17
+ * `Notifications.getExpoPushTokenAsync()` — the `ExponentPushToken[…]` handle —
18
+ * because the server delivers through Expo's push service, which only accepts
19
+ * that form.
20
+ *
21
+ * It does NOT take `getDevicePushTokenAsync()`'s raw APNs/FCM device token.
22
+ * Registering one of those looks completely successful (the row is stored, the
23
+ * endpoint returns 200) and then every push silently fails at delivery time,
24
+ * which is exactly the failure mode this surface is written to make impossible:
25
+ * the token is validated here, at the call site, so a raw device token is
26
+ * rejected immediately with an explicit error rather than becoming a dead
27
+ * registration nobody notices.
28
+ *
29
+ * Acquiring the token (permission prompt, `expo-notifications`) is the app's
30
+ * job — `@oxyhq/core` never imports an `expo-*` module.
31
+ */
32
+ import type { OxyServicesBase } from '../OxyServices.base';
33
+
34
+ /**
35
+ * Platform the push token was minted on. Mirrors the enum the server accepts;
36
+ * a value outside it is rejected server-side.
37
+ */
38
+ export type PushTokenPlatform = 'ios' | 'android' | 'web';
39
+
40
+ /**
41
+ * Shape of an Expo push token: `ExponentPushToken[…]` (what
42
+ * `getExpoPushTokenAsync()` returns today) or the equivalent `ExpoPushToken[…]`
43
+ * spelling. Deliberately plain ASCII — `@oxyhq/core` ships to Hermes, where
44
+ * Unicode property escapes throw at runtime.
45
+ */
46
+ const EXPO_PUSH_TOKEN_PATTERN = /^Expo(nent)?PushToken\[[^[\]\s]+\]$/;
47
+
48
+ /** Registration input for {@link OxyServicesNotificationsMixin.registerPushToken}. */
49
+ export interface RegisterPushTokenInput {
50
+ /**
51
+ * The **Expo push token** for this installation — `ExponentPushToken[…]`,
52
+ * from `Notifications.getExpoPushTokenAsync()`.
53
+ *
54
+ * NOT the raw APNs/FCM token from `getDevicePushTokenAsync()`; that form is
55
+ * rejected before any request is sent.
56
+ */
57
+ expoPushToken: string;
58
+ /** Platform this token was minted on. */
59
+ platform: PushTokenPlatform;
60
+ /**
61
+ * Optional device-first `deviceId` this installation is registered under, so
62
+ * the server can retire the token when that device session goes away instead
63
+ * of pushing to an installation that has signed out.
64
+ */
65
+ deviceId?: string;
66
+ /**
67
+ * Optional registered OAuth client id (the caller's credential `publicKey`,
68
+ * `oxy_dk_…`) identifying WHICH application this installation is. The server
69
+ * resolves it to the registering `Application` and rejects an unusable one.
70
+ *
71
+ * This is what makes "deliver the approval request to a known Commons
72
+ * installation" possible: delivery targets the Commons application's
73
+ * registrations, never every push token the identity owns.
74
+ */
75
+ clientId?: string;
76
+ }
77
+
78
+ export function OxyServicesNotificationsMixin<T extends typeof OxyServicesBase>(Base: T) {
79
+ return class extends Base {
80
+ /**
81
+ * Register this installation's **Expo** push token for the authenticated
82
+ * identity (`POST /notifications/push-token`, bearer required).
83
+ *
84
+ * Idempotent server-side: re-registering the same token for the same
85
+ * identity refreshes the existing row rather than creating a duplicate, so
86
+ * callers can safely re-register on every cold boot.
87
+ *
88
+ * @throws When `expoPushToken` is not an Expo push token — a raw APNs/FCM
89
+ * device token fails here rather than becoming a silently undeliverable
90
+ * registration.
91
+ */
92
+ async registerPushToken(input: RegisterPushTokenInput): Promise<void> {
93
+ try {
94
+ if (!EXPO_PUSH_TOKEN_PATTERN.test(input.expoPushToken)) {
95
+ throw new Error(
96
+ 'registerPushToken expects an Expo push token ("ExponentPushToken[...]", from getExpoPushTokenAsync). ' +
97
+ 'A raw APNs/FCM device token from getDevicePushTokenAsync cannot be delivered to.',
98
+ );
99
+ }
100
+
101
+ await this.makeRequest<unknown>(
102
+ 'POST',
103
+ '/notifications/push-token',
104
+ {
105
+ token: input.expoPushToken,
106
+ platform: input.platform,
107
+ // Omitted entirely when absent so the body stays exactly what the
108
+ // endpoint has always received (the server reads presence).
109
+ ...(input.deviceId ? { deviceId: input.deviceId } : {}),
110
+ ...(input.clientId ? { clientId: input.clientId } : {}),
111
+ },
112
+ { cache: false },
113
+ );
114
+ } catch (error) {
115
+ throw this.handleError(error);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Retire an **Expo** push token for the authenticated identity
121
+ * (`DELETE /notifications/push-token`, bearer required).
122
+ *
123
+ * Call it when the user turns notifications off, when the vault identity is
124
+ * replaced, and on sign-out — otherwise the installation keeps receiving
125
+ * approval requests for an identity it no longer holds.
126
+ *
127
+ * @param expoPushToken - The exact token previously registered.
128
+ */
129
+ async unregisterPushToken(expoPushToken: string): Promise<void> {
130
+ try {
131
+ await this.makeRequest<unknown>(
132
+ 'DELETE',
133
+ '/notifications/push-token',
134
+ { token: expoPushToken },
135
+ { cache: false },
136
+ );
137
+ } catch (error) {
138
+ throw this.handleError(error);
139
+ }
140
+ }
141
+ };
142
+ }
@@ -167,6 +167,9 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
167
167
  cache: false,
168
168
  });
169
169
  this.clearCacheEntry('GET:/privacy/restricted');
170
+ // The restriction changed the viewer's graph (`restrictedIds`) — bust the
171
+ // cached consolidated `GET /users/me/graph` so the next read reflects it.
172
+ this.clearCacheEntry('GET:/users/me/graph');
170
173
  return result;
171
174
  } catch (error) {
172
175
  throw this.handleError(error);
@@ -190,6 +193,9 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
190
193
  cache: false,
191
194
  });
192
195
  this.clearCacheEntry('GET:/privacy/restricted');
196
+ // Symmetric to restrictUser: the unrestrict changed the viewer's
197
+ // `restrictedIds`, so bust the consolidated `GET /users/me/graph` cache.
198
+ this.clearCacheEntry('GET:/users/me/graph');
193
199
  return result;
194
200
  } catch (error) {
195
201
  throw this.handleError(error);
@@ -92,6 +92,8 @@ export interface ViewerGraph {
92
92
  mutualIds: string[];
93
93
  /** Accounts the viewer has blocked (bounded). */
94
94
  blockedIds: string[];
95
+ /** Accounts the viewer has restricted (bounded). */
96
+ restrictedIds: string[];
95
97
  }
96
98
 
97
99
  /** Per-user outcome returned by `POST /users/unfollow/bulk`. */
@@ -1044,6 +1046,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
1044
1046
  followingIds: graph?.followingIds || [],
1045
1047
  mutualIds: graph?.mutualIds || [],
1046
1048
  blockedIds: graph?.blockedIds || [],
1049
+ restrictedIds: graph?.restrictedIds || [],
1047
1050
  };
1048
1051
  } catch (error) {
1049
1052
  throw this.handleError(error);