@oxyhq/core 12.10.6 → 12.11.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +142 -37
- package/dist/cjs/i18n/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/cjs/i18n/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/zh-CN.json +13 -1
- package/dist/cjs/index.js +31 -5
- package/dist/cjs/mixins/OxyServices.auth.js +257 -17
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/index.js +5 -0
- package/dist/cjs/session/SessionClient.js +40 -4
- package/dist/cjs/session/accountDialogController.js +288 -41
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +54 -15
- package/dist/cjs/session/refresh.js +73 -8
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/oauthPkce.js +33 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +142 -37
- package/dist/esm/i18n/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/esm/i18n/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/zh-CN.json +13 -1
- package/dist/esm/index.js +14 -1
- package/dist/esm/mixins/OxyServices.auth.js +257 -17
- package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/index.js +5 -0
- package/dist/esm/session/SessionClient.js +40 -4
- package/dist/esm/session/accountDialogController.js +288 -41
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +54 -15
- package/dist/esm/session/refresh.js +73 -8
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/oauthPkce.js +31 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +38 -1
- package/dist/types/index.d.ts +12 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +28 -0
- package/dist/types/session/accountDialogController.d.ts +187 -9
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +21 -19
- package/dist/types/session/refresh.d.ts +32 -1
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/oauthPkce.d.ts +21 -1
- package/package.json +2 -2
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/sessionColdBoot.ts +183 -37
- package/src/i18n/locales/ar-SA.json +13 -1
- package/src/i18n/locales/ca-ES.json +13 -1
- package/src/i18n/locales/de-DE.json +13 -1
- package/src/i18n/locales/en-US.json +13 -4
- package/src/i18n/locales/es-ES.json +13 -4
- package/src/i18n/locales/fr-FR.json +13 -1
- package/src/i18n/locales/it-IT.json +13 -1
- package/src/i18n/locales/ja-JP.json +13 -1
- package/src/i18n/locales/ko-KR.json +13 -1
- package/src/i18n/locales/pt-PT.json +13 -1
- package/src/i18n/locales/zh-CN.json +13 -1
- package/src/index.ts +49 -0
- package/src/mixins/OxyServices.auth.ts +464 -24
- package/src/mixins/OxyServices.deviceBoot.ts +54 -2
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
- package/src/mixins/index.ts +6 -0
- package/src/session/SessionClient.ts +57 -4
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/accountDialogController.test.ts +812 -1
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +88 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
- package/src/session/accountDialogController.ts +435 -44
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +61 -13
- package/src/session/refresh.ts +103 -9
- package/src/utils/__tests__/oauthPkce.test.ts +51 -0
- package/src/utils/commonsDelivery.ts +132 -0
- 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
|
-
|
|
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
|
+
}
|