@de./sdk 2.0.0 → 2.1.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 (43) hide show
  1. package/dist/allend/Access.js +22 -5
  2. package/dist/allend/Access.js.map +1 -1
  3. package/dist/allend/LSP/agents.d.ts +14 -1
  4. package/dist/allend/LSP/agents.js +38 -0
  5. package/dist/allend/LSP/agents.js.map +1 -1
  6. package/dist/allend/MSI/Handles.d.ts +10 -2
  7. package/dist/allend/MSI/Handles.js +11 -3
  8. package/dist/allend/MSI/Handles.js.map +1 -1
  9. package/dist/allend/MSI/index.d.ts +10 -0
  10. package/dist/allend/MSI/index.js +19 -0
  11. package/dist/allend/MSI/index.js.map +1 -1
  12. package/dist/allend/MSI/native.js +69 -35
  13. package/dist/allend/MSI/native.js.map +1 -1
  14. package/dist/allend/MSI/origin.d.ts +15 -0
  15. package/dist/allend/MSI/origin.js +25 -0
  16. package/dist/allend/MSI/origin.js.map +1 -0
  17. package/dist/allend/OTPAuth/index.d.ts +12 -18
  18. package/dist/allend/OTPAuth/index.js +17 -7
  19. package/dist/allend/OTPAuth/index.js.map +1 -1
  20. package/dist/baseUrl.d.ts +3 -5
  21. package/dist/baseUrl.js +3 -5
  22. package/dist/baseUrl.js.map +1 -1
  23. package/dist/core.d.ts +1 -0
  24. package/dist/core.js +3 -1
  25. package/dist/core.js.map +1 -1
  26. package/dist/response.d.ts +9 -0
  27. package/dist/response.js +42 -0
  28. package/dist/response.js.map +1 -0
  29. package/dist/utils/index.d.ts +14 -0
  30. package/dist/utils/index.js +24 -4
  31. package/dist/utils/index.js.map +1 -1
  32. package/package.json +13 -7
  33. package/src/allend/Access.ts +23 -4
  34. package/src/allend/LSP/agents.ts +46 -1
  35. package/src/allend/MSI/Handles.ts +12 -4
  36. package/src/allend/MSI/index.ts +20 -0
  37. package/src/allend/MSI/native.tsx +73 -34
  38. package/src/allend/MSI/origin.ts +20 -0
  39. package/src/allend/OTPAuth/index.ts +33 -25
  40. package/src/baseUrl.ts +3 -5
  41. package/src/core.ts +1 -0
  42. package/src/response.ts +38 -0
  43. package/src/utils/index.ts +27 -8
@@ -1,6 +1,6 @@
1
1
  import type { MapOptions } from '../../types'
2
2
 
3
- import React, { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
3
+ import React, { useRef, useEffect, useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
4
4
  import { View, StyleSheet, AppState, type AppStateStatus, Platform } from 'react-native'
5
5
  import { WebView } from 'react-native-webview'
6
6
  import WIO from 'webview.io'
@@ -8,6 +8,7 @@ import Handles from './Handles'
8
8
  import Controls from './Controls'
9
9
  import Plugins, { type Plugin } from './Plugins'
10
10
  import resolveAccessToken from './token'
11
+ import belongsToGateway from './origin'
11
12
 
12
13
  export interface MSIInterface {
13
14
  controls: Controls
@@ -115,6 +116,18 @@ export default forwardRef<MSIRef, MSIProps>( ( props, ref ) => {
115
116
  apiRef = useRef<MSIInterface | null>( null ),
116
117
  isInitializedRef = useRef( false ),
117
118
 
119
+ /**
120
+ * The bridge is built once, so its handlers close over the props of the
121
+ * render that built it. `bind` resolves the access token at connect time
122
+ * precisely so a reconnect picks up a rotated one -- but reading it from a
123
+ * captured `props` gave back whatever `getAccessToken` closed over at mount,
124
+ * which for the usual `() => token` is the token the host held then. Every
125
+ * reconnect after a rotation would have re-bound with a dead token. Same for
126
+ * the callbacks: a host passing inline handlers had the mount-time ones
127
+ * called forever.
128
+ */
129
+ propsRef = useRef( props ),
130
+
118
131
  [ isConnected, setIsConnected ] = useState( false ),
119
132
  [ isReady, setIsReady ] = useState( false ),
120
133
 
@@ -128,13 +141,46 @@ export default forwardRef<MSIRef, MSIProps>( ( props, ref ) => {
128
141
  ? error
129
142
  : new Error( typeof error === 'string' ? error : error?.error || 'Unknown MSI error')
130
143
 
131
- props.onError?.( normalized )
132
- }, [ props.onError ] ),
144
+ propsRef.current.onError?.( normalized )
145
+ }, [] ),
133
146
 
134
147
  initializeConnection = useCallback( () => {
135
148
  if( !wioRef.current || !webViewRef.current ) return
136
149
  wioRef.current.initiate( webViewRef, baseURL )
137
- }, [ baseURL ] )
150
+ }, [ baseURL ] ),
151
+
152
+ /**
153
+ * Refuse to navigate anywhere but the gateway -- see `belongsToGateway`.
154
+ */
155
+ isGatewayURL = useCallback( ( url: string ) => belongsToGateway( baseURL, url ), [ baseURL ] ),
156
+
157
+ /**
158
+ * One options object for both ends of the bridge.
159
+ *
160
+ * `getInjectedJavaScript()` bakes the transport's own configuration into the
161
+ * script the WebView runs — the auth secret, the skew tolerance, the replay
162
+ * window. It used to be called on a throwaway `new WIO({ type: 'WEBVIEW' })`
163
+ * constructed inline in the JSX, which shares nothing with the instance that
164
+ * actually holds the connection: the embedded side would have been handed a
165
+ * null secret and default tolerances no matter what the host was configured
166
+ * with. Nothing configures cryptoAuth today, so it never showed.
167
+ */
168
+ wioOptions = useMemo( () => ({
169
+ type: 'WEBVIEW' as const,
170
+ debug: props.env === 'dev',
171
+ allowedIncomingEvents: ALLOWED_INCOMING_EVENTS,
172
+ maxMessagesPerSecond: 100,
173
+ connectionTimeout: 15000,
174
+ connectionPingInterval: 2000,
175
+ maxConnectionAttempts: 5,
176
+ autoReconnect: true,
177
+ heartbeatInterval: 30000
178
+ }), [ props.env ] ),
179
+
180
+ // Rebuilt only when those options change, rather than on every render.
181
+ injectedBridge = useMemo( () => new WIO( wioOptions ).getInjectedJavaScript(), [ wioOptions ] )
182
+
183
+ useEffect( () => { propsRef.current = props } )
138
184
 
139
185
  // Expose the control surface to the host
140
186
  useImperativeHandle( ref, () => ({
@@ -149,17 +195,7 @@ export default forwardRef<MSIRef, MSIProps>( ( props, ref ) => {
149
195
  }), [ isConnected, isReady, initializeConnection ] )
150
196
 
151
197
  useEffect( () => {
152
- const wio = wioRef.current = new WIO({
153
- type: 'WEBVIEW',
154
- debug: props.env === 'dev',
155
- allowedIncomingEvents: ALLOWED_INCOMING_EVENTS,
156
- maxMessagesPerSecond: 100,
157
- connectionTimeout: 15000,
158
- connectionPingInterval: 2000,
159
- maxConnectionAttempts: 5,
160
- autoReconnect: true,
161
- heartbeatInterval: 30000
162
- })
198
+ const wio = wioRef.current = new WIO( wioOptions )
163
199
 
164
200
  wio
165
201
  .on('connect', async () => {
@@ -168,11 +204,11 @@ export default forwardRef<MSIRef, MSIProps>( ( props, ref ) => {
168
204
  * Resolve the token here rather than at mount, so a reconnect after a
169
205
  * backgrounded app or a dropped bridge binds with a current token.
170
206
  */
171
- const { getAccessToken, onReady, onError, onLoaded, style, ...config } = props
207
+ const { getAccessToken, onReady, onError, onLoaded, style, ...config } = propsRef.current
172
208
 
173
209
  await wio.emitAsync('bind', {
174
210
  ...config,
175
- accessToken: resolveAccessToken( props ),
211
+ accessToken: resolveAccessToken( propsRef.current ),
176
212
  origin: 'react-native'
177
213
  }, BIND_TIMEOUT )
178
214
 
@@ -182,7 +218,7 @@ export default forwardRef<MSIRef, MSIProps>( ( props, ref ) => {
182
218
  })
183
219
  .on('ready', () => {
184
220
  setIsReady( true )
185
- props.onReady?.()
221
+ propsRef.current.onReady?.()
186
222
  })
187
223
  .on('disconnect', () => {
188
224
  setIsConnected( false )
@@ -201,19 +237,21 @@ export default forwardRef<MSIRef, MSIProps>( ( props, ref ) => {
201
237
  })
202
238
 
203
239
  /**
204
- * Pause gateway work while backgrounded, and re-establish the bridge on
205
- * resume -- iOS in particular tears down the WebView's socket.
240
+ * Re-establish the bridge on resume -- iOS in particular tears down the
241
+ * WebView's socket while the app is backgrounded.
242
+ *
243
+ * This used to also emit `app:background` / `app:foreground`, which read
244
+ * like the gateway paused its work. It never did: the gateway has no
245
+ * handler for either, and its own allowlist dropped them before they got
246
+ * that far. Nor should there be a blanket one -- a rider streaming their
247
+ * position must keep streaming with the phone in their pocket, so whether
248
+ * backgrounding stops anything is the host's call, made through
249
+ * `controls.untrackLiveLocation()` and its counterpart.
206
250
  */
207
251
  const subscription = AppState.addEventListener('change', ( state: AppStateStatus ) => {
208
- if( state === 'background' ){
209
- wio.emit('app:background')
210
- return
211
- }
212
-
213
- if( state === 'active' ){
214
- wio.emit('app:foreground')
215
- !wio.isConnected() && initializeConnection()
216
- }
252
+ state === 'active'
253
+ && !wio.isConnected()
254
+ && initializeConnection()
217
255
  })
218
256
 
219
257
  return () => {
@@ -231,16 +269,16 @@ export default forwardRef<MSIRef, MSIProps>( ( props, ref ) => {
231
269
 
232
270
  const
233
271
  chn = wioRef.current,
234
- controls = new Controls( chn, props ),
235
- handles = new Handles( chn, controls, props ),
236
- plugins = new Plugins( chn, handles, controls, props )
272
+ controls = new Controls( chn, propsRef.current ),
273
+ handles = new Handles( chn, controls, propsRef.current ),
274
+ plugins = new Plugins( chn, handles, controls, propsRef.current )
237
275
 
238
276
  plugins.mount( REGISTERED_PLUGINS )
239
277
 
240
278
  apiRef.current = { controls, handles, plugins }
241
279
  isInitializedRef.current = true
242
280
 
243
- props.onLoaded?.( apiRef.current )
281
+ propsRef.current.onLoaded?.( apiRef.current )
244
282
  }, [ isConnected, isReady ] )
245
283
 
246
284
  const onMessage = ( event: any ) => {
@@ -274,7 +312,7 @@ export default forwardRef<MSIRef, MSIProps>( ( props, ref ) => {
274
312
  style={styles.webview}
275
313
  injectedJavaScript={`
276
314
  ${props.env === 'dev' ? injectedConsole() : ''}
277
- ${new WIO({ type: 'WEBVIEW' }).getInjectedJavaScript()}
315
+ ${injectedBridge}
278
316
  `}
279
317
  javaScriptEnabled
280
318
  domStorageEnabled
@@ -287,6 +325,7 @@ export default forwardRef<MSIRef, MSIProps>( ( props, ref ) => {
287
325
  bounces={false}
288
326
  scrollEnabled={false}
289
327
  originWhitelist={[ baseURL ]}
328
+ onShouldStartLoadWithRequest={( { url }: any ) => isGatewayURL( url )}
290
329
  onMessage={onMessage}
291
330
  onLoadEnd={initializeConnection}
292
331
  onError={( { nativeEvent }: any ) => reportError( new Error( nativeEvent?.description || 'WebView error') )}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Whether a URL belongs to the MSI gateway itself.
3
+ *
4
+ * React Native's `originWhitelist` is prefix-matched and unanchored at the
5
+ * end, so a whitelist of `https://msi.dedot.io` also admits
6
+ * `https://msi.dedot.io.example.com`. That matters more in a WebView than it
7
+ * would in a browser: the injected bridge — and the auth secret baked into
8
+ * it — runs in whatever page the WebView ends up on. So the origin has to
9
+ * match exactly, and only a path, query or fragment separator may follow it.
10
+ *
11
+ * @param baseURL - Gateway origin, without a trailing slash
12
+ * @param url - URL the WebView is asking to navigate to
13
+ * @return - Whether the navigation is to the gateway
14
+ */
15
+ export default function belongsToGateway( baseURL: string, url: string ): boolean {
16
+ if( !url || !baseURL ) return false
17
+ if( url === 'about:blank' || url === baseURL ) return true
18
+
19
+ return [ '/', '?', '#' ].some( separator => url.startsWith( baseURL + separator ) )
20
+ }
@@ -1,3 +1,4 @@
1
+ import type { SigninBody, CreateAccountBody, VerificationBody } from '@de./types'
1
2
  import type { AccessOptions, UserSession } from '../../types/access'
2
3
  import AccessManager from '../Access'
3
4
  import { qs, type Res } from '../../utils'
@@ -16,15 +17,23 @@ import { qs, type Res } from '../../utils'
16
17
  // single method it had could never succeed. Route shapes are taken from
17
18
  // de.auth/src/routes/{auth,user}/index.ts.
18
19
  //
20
+ // The paths now say what they do — `/auth/otp/send` rather than `/signin`,
21
+ // which sent a code and signed nobody in. The method names here are kept as
22
+ // they were: `signin` is what a caller is doing, even when what it costs is
23
+ // one SMS.
24
+ //
19
25
  // ─────────────────────────────────────────────────────────────────────────────
20
26
 
21
- export type SigninBody = {
22
- phone: string
23
- /** Device fingerprint — an object or its JSON string. */
24
- device: string | Record<string, any>
25
- /** ISO 3166-1 alpha-2. */
26
- country?: string
27
- }
27
+ /**
28
+ * Request bodies come from `@de./types`, which is what de.auth validates
29
+ * against — these were hand-declared here, and said `country?: string` where
30
+ * de.auth wants the Country OBJECT. Its schema is additionalProperties:false,
31
+ * so a caller trusting the SDK's type was answered `body/country must be
32
+ * object`. Nothing caught it because this client could not be constructed at
33
+ * all: AccessManager demanded a workspace context and a bearer token, which
34
+ * sign-in is what produces.
35
+ */
36
+ export type { SigninBody, CreateAccountBody, VerificationBody, Country } from '@de./types'
28
37
 
29
38
  export type SigninResult = {
30
39
  /** What the caller must do next — verification, or account creation first. */
@@ -38,17 +47,6 @@ export type SigninResult = {
38
47
  testVCode?: number
39
48
  }
40
49
 
41
- export type CreateAccountBody = {
42
- phone: string
43
- agreeTerms: boolean | string
44
- firstName?: string
45
- lastName?: string
46
- photo?: string
47
- type?: string
48
- country?: string
49
- newsletters?: boolean | string
50
- }
51
-
52
50
  export type VerifyResult = {
53
51
  ctoken?: string
54
52
  deviceId?: string
@@ -57,7 +55,17 @@ export type VerifyResult = {
57
55
 
58
56
  export default class OTPAuth extends AccessManager {
59
57
  constructor( access: AccessOptions ){
60
- super( access, 'ASI' )
58
+ /**
59
+ * de.auth admits an agent that does not start with `De.service` only if
60
+ * the request carries an Authorization header — and the whole point of
61
+ * this client is the flow that has no token yet. The SDK's default
62
+ * platform is `proxy`, so every sign-in through it was answered 412
63
+ * before reaching a handler.
64
+ *
65
+ * `service` is the platform this client is for; a caller that knows
66
+ * better can still say so.
67
+ */
68
+ super({ ...access, platform: access.platform || 'service' }, 'ASI' )
61
69
  }
62
70
 
63
71
  // ── Sign-in ───────────────────────────────────────────────────────────────
@@ -66,26 +74,26 @@ export default class OTPAuth extends AccessManager {
66
74
  async signin( body: SigninBody ): Promise<Res<SigninResult>> {
67
75
  if( !body.phone ) throw new Error('<phone> argument required')
68
76
 
69
- return this.request<Res<SigninResult>>({ url: '/signin', method: 'POST', body })
77
+ return this.request<Res<SigninResult>>({ url: '/auth/otp/send', method: 'POST', body })
70
78
  }
71
79
 
72
80
  /** Step 2 — exchanges the code for a session. */
73
81
  async verify( body: { phone: string, pvc: number, new_phone?: string }): Promise<Res<VerifyResult>> {
74
- return this.request<Res<VerifyResult>>({ url: '/verification', method: 'POST', body })
82
+ return this.request<Res<VerifyResult>>({ url: '/auth/otp/verify', method: 'POST', body })
75
83
  }
76
84
 
77
85
  /** Step 1a — for a phone de.auth has never seen. */
78
86
  async setAccount( body: CreateAccountBody ): Promise<Res<{ next: string }>> {
79
- return this.request<Res<{ next: string }>>({ url: '/set-account', method: 'POST', body })
87
+ return this.request<Res<{ next: string }>>({ url: '/auth/account/create', method: 'POST', body })
80
88
  }
81
89
 
82
90
  async resendSms( body: { phone: string }): Promise<Res<void>> {
83
- return this.request<Res<void>>({ url: '/resend/sms', method: 'POST', body })
91
+ return this.request<Res<void>>({ url: '/auth/otp/resend', method: 'POST', body })
84
92
  }
85
93
 
86
- /** GET, with the scope in the querystring — not a POST. */
94
+ /** A state change, so a POST — it was a GET, which is cacheable. */
87
95
  async signout( querystring?: { allDevices?: boolean | string }): Promise<Res<void>> {
88
- return this.request<Res<void>>({ url: `/signout${qs( querystring )}`, method: 'GET' })
96
+ return this.request<Res<void>>({ url: `/auth/session/revoke${qs( querystring )}`, method: 'POST' })
89
97
  }
90
98
 
91
99
  // ── The signed-in person ──────────────────────────────────────────────────
package/src/baseUrl.ts CHANGED
@@ -41,11 +41,9 @@ export const API_SERVER_BASEURL = {
41
41
  * It is not reachable through the API host: de.arch serves no `/workspace`
42
42
  * routes, so a client pointed there gets 404 for the whole surface.
43
43
  *
44
- * The staging and production hosts follow the naming of the other services
45
- * (auth.dedot.io, iot.dedot.io). Confirm them against the deployment before
46
- * relying on them, and pass `baseUrl` to address a host that differs — which
47
- * is also what a self-hosted De. needs, and what a dev machine running
48
- * de.workspace on a port other than 22200 needs.
44
+ * Pass `baseUrl` to address a host that differs — what a self-hosted De.
45
+ * needs, and what a dev machine running de.workspace on a port other than
46
+ * 22200 needs.
49
47
  */
50
48
  export const WSP_SERVER_BASEURL = {
51
49
  dev: `http://localhost:${DEV_PORTS.WSP}`,
package/src/core.ts CHANGED
@@ -59,6 +59,7 @@ export type { WorkflowsConfig } from './allend/Workflows'
59
59
  export { default as Utils } from './utils'
60
60
 
61
61
  export { default as APIError } from './error'
62
+ export { statusOf } from './response'
62
63
 
63
64
  // ── Client layer (allend) ─────────────────────────────────────────────────────
64
65
  // DClient ({ Client, Event }), the IoT socket client, and the customer/agent/
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The HTTP status a returned envelope arrived with.
3
+ *
4
+ * Clients return De.'s `{ error, status, message, data }` envelope, which is
5
+ * what makes `status` — the precise refusal code — reachable. It carries no
6
+ * HTTP status, and for most callers that is right: the envelope says more than
7
+ * a number can.
8
+ *
9
+ * A proxy is the exception. It answers its own client with an HTTP status, and
10
+ * mapping every De. refusal onto a generic 400 loses the distinction between
11
+ * "no such order" and "you may not do that" for whoever is downstream.
12
+ *
13
+ * So the status is kept beside the response rather than inside it: the type
14
+ * stays exactly the envelope, and a caller that needs the number asks for it,
15
+ * keyed on the object it was given. Keying on identity rather than on a
16
+ * mutable field is what makes it safe under concurrency — two calls in flight
17
+ * cannot read each other's status.
18
+ *
19
+ * ```ts
20
+ * const answer = await lsp.orders.get( reference )
21
+ * if( answer.error ) throw new HTTPException( statusOf( answer ) ?? 400, ... )
22
+ * ```
23
+ */
24
+ const statuses = new WeakMap<object, number>()
25
+
26
+ /** Record the status for a parsed response body. Called by the transport. */
27
+ export const rememberStatus = ( body: unknown, status: number ): void => {
28
+ if( body && typeof body === 'object' ) statuses.set( body as object, status )
29
+ }
30
+
31
+ /**
32
+ * The HTTP status the given envelope arrived with.
33
+ *
34
+ * `undefined` when the object did not come from this SDK's transport, or when
35
+ * the call threw instead of returning — `APIError` carries its own `status`.
36
+ */
37
+ export const statusOf = ( envelope: unknown ): number | undefined =>
38
+ envelope && typeof envelope === 'object' ? statuses.get( envelope as object ) : undefined
@@ -21,16 +21,35 @@ export type Res<T = any> = { error: boolean, status?: string, message?: string,
21
21
  */
22
22
  export type Data<T> = 'data' extends keyof T ? T['data'] : undefined
23
23
 
24
+ /**
25
+ * Serialize a querystring for a De. route.
26
+ *
27
+ * Array values are appended as repeated keys — `state=A&state=B` — which is
28
+ * what Fastify's query parser turns back into an array. `String( value )` on
29
+ * an array produces `A,B`, a single comma-joined string, and the route answers
30
+ * `querystring/state must be array`: the schema is right, the request never
31
+ * matched it. Every array parameter the SDK sent was malformed this way, the
32
+ * same shape of fault as the coordinates object that once serialized to
33
+ * `[object Object]`.
34
+ *
35
+ * @param params - Query parameters; null and undefined values are dropped
36
+ * @return - `?a=1&b=2`, or an empty string when there is nothing to send
37
+ */
24
38
  export const qs = ( params?: Record<string, any> ): string => {
25
39
  if( !params ) return ''
26
- const q = new URLSearchParams(
27
- Object.fromEntries(
28
- Object.entries( params )
29
- .filter( ( [, v] ) => v != null )
30
- .map( ( [k, v] ) => [k, String( v )] )
31
- )
32
- ).toString()
33
- return q ? `?${q}` : ''
40
+
41
+ const q = new URLSearchParams()
42
+
43
+ for( const [ key, value ] of Object.entries( params ) ){
44
+ if( value === null || value === undefined ) continue
45
+
46
+ Array.isArray( value )
47
+ ? value.forEach( each => each !== null && each !== undefined && q.append( key, String( each ) ) )
48
+ : q.append( key, String( value ) )
49
+ }
50
+
51
+ const query = q.toString()
52
+ return query ? `?${query}` : ''
34
53
  }
35
54
 
36
55
  export default { Stream }