@vuoma/sdk 0.1.0 → 0.2.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/LICENSE +45 -0
- package/README.md +188 -24
- package/package.json +25 -6
- package/src/index.d.ts +64 -1
- package/src/index.js +87 -9
- package/src/member-contracts.js +98 -0
- package/src/organization-contracts.generated.d.ts +31 -0
- package/src/organization-contracts.generated.js +1343 -0
- package/src/organization.d.ts +23 -0
- package/src/organization.js +95 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Vuoma Connected Application License
|
|
2
|
+
Version 1.0 — October 19, 2026
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 UPPP LLC, doing business as Vuoma.
|
|
5
|
+
|
|
6
|
+
Permission is granted, without a separate software license fee, to use, copy,
|
|
7
|
+
modify and distribute this package and its documentation (the Software) solely
|
|
8
|
+
to develop, test, operate and distribute applications that integrate with the
|
|
9
|
+
Vuoma service through authorized Vuoma APIs. This includes commercial,
|
|
10
|
+
studio-branded applications, websites, and development or test versions using
|
|
11
|
+
mocked Vuoma responses. You may share the Software with your developers and
|
|
12
|
+
contractors for these purposes and include it in source or compiled application
|
|
13
|
+
distributions, subject to this license.
|
|
14
|
+
|
|
15
|
+
You must retain this license and the copyright notice in copies or substantial
|
|
16
|
+
portions of the Software, including an application's third-party notices. Your
|
|
17
|
+
modifications must remain subject to these permitted-use limits. You may not
|
|
18
|
+
use or distribute the Software as a general-purpose library for applications
|
|
19
|
+
unconnected to Vuoma, or to implement or operate a replacement for the Vuoma
|
|
20
|
+
service, without separate written permission from UPPP LLC.
|
|
21
|
+
|
|
22
|
+
This license applies only to the Software included in this package. It does
|
|
23
|
+
not license Vuoma's backend, databases, customer data, studio branding,
|
|
24
|
+
third-party assets or trademarks. Separately identified third-party software
|
|
25
|
+
remains subject to its own license. You retain any rights you otherwise hold
|
|
26
|
+
in your application code and content.
|
|
27
|
+
|
|
28
|
+
This license does not grant API credentials, access to any account or data,
|
|
29
|
+
hosting, support, or a right to continued service. Access to Vuoma remains
|
|
30
|
+
subject to applicable service agreements, permissions and charges. Ending
|
|
31
|
+
service does not transfer the backend or authorize continued API access. You
|
|
32
|
+
may retain archival copies and use the Software in development intended for an
|
|
33
|
+
authorized Vuoma integration; operating it for an unrelated replacement service
|
|
34
|
+
requires separate permission.
|
|
35
|
+
|
|
36
|
+
All rights not expressly granted are reserved. Use outside these terms is not
|
|
37
|
+
licensed. This is a source-available, restricted-use license, not an open-source
|
|
38
|
+
license. Separate written agreements with UPPP LLC may grant additional rights.
|
|
39
|
+
|
|
40
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
41
|
+
IMPLIED, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
42
|
+
NONINFRINGEMENT. TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER
|
|
43
|
+
IS NOT LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM THE
|
|
44
|
+
SOFTWARE OR ITS USE. Nothing in this license limits liability or rights that
|
|
45
|
+
cannot lawfully be limited.
|
package/README.md
CHANGED
|
@@ -3,15 +3,29 @@
|
|
|
3
3
|
Framework-neutral JavaScript and TypeScript client for the existing Vuoma v1
|
|
4
4
|
public organization and member account APIs. Works with a host-provided Fetch
|
|
5
5
|
implementation; it imports no React, Svelte, Expo or Supabase runtime. The package
|
|
6
|
-
does not store credentials, read environment variables,
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
does not store credentials, read environment variables, or grant access. Account
|
|
7
|
+
setup and booking use the same reviewed organization-member API methods as the
|
|
8
|
+
Vuoma native apps. Server authorization and studio policies remain authoritative.
|
|
9
|
+
Install the public package in your own application; access to Vuoma's private
|
|
10
|
+
repository is not required:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @vuoma/sdk@0.2.0
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use SvelteKit and SvelteKit UI with `@vuoma/web` for website account components
|
|
17
|
+
and same-origin server sessions. Use `@vuoma/native-ui` for React Native account and booking UI and this SDK as
|
|
18
|
+
its framework-neutral API transport. The complete `@vuoma/react-native` studio
|
|
19
|
+
app core remains a private workspace; independent apps do not depend on it.
|
|
20
|
+
The public setup guide is at `/developers` and `/o/<organization_slug>/developers`
|
|
21
|
+
on the same combined Vuoma application and landing-page deployment. The current
|
|
22
|
+
verified API origin is `https://vuoma.com`.
|
|
9
23
|
|
|
10
24
|
```js
|
|
11
25
|
import { createVuomaClient } from '@vuoma/sdk'
|
|
12
26
|
|
|
13
27
|
const vuoma = createVuomaClient({
|
|
14
|
-
apiOrigin: '
|
|
28
|
+
apiOrigin: 'https://vuoma.com', // Use your verified Vuoma origin.
|
|
15
29
|
organizationSlug: 'your-studio',
|
|
16
30
|
fetch: globalThis.fetch,
|
|
17
31
|
})
|
|
@@ -24,7 +38,11 @@ const schedule = await vuoma.public.getSchedule({
|
|
|
24
38
|
})
|
|
25
39
|
```
|
|
26
40
|
|
|
27
|
-
|
|
41
|
+
The organization slug is public routing configuration, built into each studio app.
|
|
42
|
+
Sign-in authenticates a person for that studio. Private calls require their bearer
|
|
43
|
+
token; the server verifies the live session, studio membership and permissions.
|
|
44
|
+
Public schedule and catalog reads need no registration or app key.
|
|
45
|
+
Schedule timestamps are normalized to
|
|
28
46
|
UTC, and ranges must be positive and at most 63 days. The response preserves
|
|
29
47
|
`schedule.has_more`; when it is true, split the requested range and fetch each
|
|
30
48
|
part rather than displaying the first response as a complete schedule.
|
|
@@ -39,7 +57,7 @@ server or native host; it does not establish a browser cookie session.
|
|
|
39
57
|
```js
|
|
40
58
|
let session = null // Example in-memory host state; no SDK persistence.
|
|
41
59
|
const vuoma = createVuomaClient({
|
|
42
|
-
apiOrigin: '
|
|
60
|
+
apiOrigin: 'https://vuoma.com',
|
|
43
61
|
organizationSlug: 'your-studio',
|
|
44
62
|
tokenAdapter: { getSession: () => session },
|
|
45
63
|
})
|
|
@@ -63,6 +81,9 @@ Native, keep refresh tokens in Keychain/Keystore through a secure-storage adapte
|
|
|
63
81
|
keep access tokens in memory, and refresh through the documented member route.
|
|
64
82
|
Refresh scheduling, retries and persistence remain host responsibilities.
|
|
65
83
|
|
|
84
|
+
Existing signed-in sessions remain valid while the native app-key system is removed.
|
|
85
|
+
Authorization failures do not trigger SDK retries or change the host session.
|
|
86
|
+
|
|
66
87
|
The SDK uses `credentials: 'omit'`, prevents redirects, and sends member access
|
|
67
88
|
tokens only to its exact configured origin and organization. It rejects missing,
|
|
68
89
|
expired, cross-organization, platform-account, organization API, identity-only
|
|
@@ -78,11 +99,108 @@ business/financial validation, and do not replace the server's authorization or
|
|
|
78
99
|
canonical schemas. The configured response byte limit is checked after reading
|
|
79
100
|
the response text and against a supplied Content-Length header.
|
|
80
101
|
|
|
102
|
+
## Signup, activation and password reset
|
|
103
|
+
|
|
104
|
+
Email confirmation and password creation stay within the host UI; the person
|
|
105
|
+
reads the six-digit code in their email app. No access token, cookie or API key
|
|
106
|
+
is sent by these methods. For new or automatically resolved accounts, collect a
|
|
107
|
+
display name. Choose `auto` to let verified email ownership resolve a new profile,
|
|
108
|
+
an imported profile or an existing studio account; `activate` performs the same
|
|
109
|
+
verified resolution without collecting a new name. `new` and `migrate` retain
|
|
110
|
+
the dedicated signup confirmation path. Recovery uses its own code path.
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
await vuoma.auth.requestAccountCode({
|
|
114
|
+
mode: 'signup', signupIntent: 'auto', email, displayName,
|
|
115
|
+
})
|
|
116
|
+
const capability = await vuoma.auth.confirmAccountCode({
|
|
117
|
+
mode: 'signup', signupIntent: 'auto', email, code,
|
|
118
|
+
})
|
|
119
|
+
// The resolution contains only the verified display name and masked contacts.
|
|
120
|
+
const resolution = capability.account_resolution
|
|
121
|
+
await vuoma.auth.completePassword({ capability, password })
|
|
122
|
+
session = await vuoma.auth.signIn({ email, password })
|
|
123
|
+
|
|
124
|
+
// Password reset uses the same scoped completion step.
|
|
125
|
+
await vuoma.auth.requestAccountCode({ mode: 'recovery', email })
|
|
126
|
+
const reset = await vuoma.auth.confirmAccountCode({ mode: 'recovery', email, code })
|
|
127
|
+
await vuoma.auth.completePassword({ capability: reset, password: newPassword })
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Keep password capabilities in memory and scoped to their original studio. They
|
|
131
|
+
are not login tokens. A `VuomaApiError` with status `400` and code
|
|
132
|
+
`weak_password_retry` lets the person choose a stronger password using the same
|
|
133
|
+
capability. Other failures retain their HTTP status and safe machine code; raw
|
|
134
|
+
backend messages and credentials are never reflected in SDK error messages.
|
|
135
|
+
|
|
136
|
+
## Class and appointment booking
|
|
137
|
+
|
|
138
|
+
Every mutation executes **once**. Create a `source_request_id` before submission
|
|
139
|
+
and retain it with the exact payload until the outcome is confirmed. A timeout,
|
|
140
|
+
abort or invalid response may follow a committed mutation. Retrying the same
|
|
141
|
+
class payload with the same ID uses the server's existing idempotency boundary;
|
|
142
|
+
do not generate a new ID to recover an uncertain result. Availability, credits,
|
|
143
|
+
prices, cancellation policies and current authorization are rechecked by Vuoma.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const bookingRequest = {
|
|
147
|
+
service_session_id: selectedSession.id,
|
|
148
|
+
source_request_id: crypto.randomUUID(),
|
|
149
|
+
// entitlement_id is optional; the server can select an eligible benefit.
|
|
150
|
+
}
|
|
151
|
+
const receipt = await vuoma.member.bookClass(bookingRequest)
|
|
152
|
+
const terms = await vuoma.member.getCancellationQuote(receipt.booking.id)
|
|
153
|
+
// Present terms and collect confirmation before this call.
|
|
154
|
+
await vuoma.member.cancelBooking(receipt.booking.id, {
|
|
155
|
+
expectedFeeAmount: terms.quote.fee_amount,
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
When required by the studio, class requests also supply the complete existing
|
|
160
|
+
policy acceptance: `policy_version`, `policy_consent: true`,
|
|
161
|
+
`payment_authorized: true` and `payment_authorization_id`. Never invent consent
|
|
162
|
+
or a card authorization; payment/card setup is outside these methods. A
|
|
163
|
+
`booking_cancellation_quote_changed` response requires fetching and presenting
|
|
164
|
+
fresh terms before another cancellation attempt. `cancelBooking` also cancels an
|
|
165
|
+
individual appointment booking through the same reviewed API.
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
const bookingCatalog = await vuoma.public.getBookingCatalog()
|
|
169
|
+
const availability = await vuoma.public.getAppointmentAvailability({ from, to })
|
|
170
|
+
// If has_more is true, split the range and load both halves before presenting it
|
|
171
|
+
// as complete. Availability accepts half-open ranges spanning at most 14 days.
|
|
172
|
+
const visitRequest = {
|
|
173
|
+
source_request_id: crypto.randomUUID(),
|
|
174
|
+
appointments: selectedSlots.map(({ service_offering_id, starts_at, ends_at, credit_cost }) =>
|
|
175
|
+
({ service_offering_id, starts_at, ends_at, credit_cost })),
|
|
176
|
+
}
|
|
177
|
+
const visit = await vuoma.member.bookAppointmentVisit(visitRequest)
|
|
178
|
+
// After an uncertain result, read the original receipt before resubmitting.
|
|
179
|
+
const recovered = await vuoma.member.getAppointmentVisit(visitRequest)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Visits contain 1–10 ordered, non-overlapping appointments. Submission and
|
|
183
|
+
reconciliation validate the request ID, each selected offering/time/credit cost,
|
|
184
|
+
receipt totals and unique booking/session IDs. A status `404` alone does not
|
|
185
|
+
prove an earlier in-flight submission cannot finish; retain the saved payload
|
|
186
|
+
and recover/resubmit only with that same ID. The SDK does not persist pending
|
|
187
|
+
requests, retry mutations or decide when to clear recovery state.
|
|
188
|
+
|
|
189
|
+
Native hosts that keep an access token separately from customer profile data may
|
|
190
|
+
supply `bookingTokenAdapter: { getCredential: () => ({ organization_slug,
|
|
191
|
+
access_token }) }` instead of `tokenAdapter`. Return the **current** credential or
|
|
192
|
+
`null`, so completion after sign-out/account change is rejected. This adapter
|
|
193
|
+
supports only class/visit/quote/cancellation methods, rejects platform-account
|
|
194
|
+
and integration credentials, and does not authorize profile or commerce reads.
|
|
195
|
+
The complete member session adapter is preferred when available. Existing
|
|
196
|
+
Vuoma platform-account flows remain a separate private authority path.
|
|
197
|
+
|
|
81
198
|
## Keys and client IDs
|
|
82
199
|
|
|
83
200
|
- **Organization integration/delegated API keys** are confidential server
|
|
84
|
-
credentials.
|
|
85
|
-
|
|
201
|
+
credentials. Keep them on an integration server and outside website bundles,
|
|
202
|
+
native binaries, URLs and the member client's session adapter. Use the separate
|
|
203
|
+
organization client below for supported scoped API reads.
|
|
86
204
|
- **Registered OAuth client IDs** are public app identifiers. Existing shared
|
|
87
205
|
sign-in grants only `openid`, `profile` and `email`; those identity tokens do
|
|
88
206
|
not authorize this SDK's member account methods.
|
|
@@ -92,26 +210,72 @@ the response text and against a supplied Content-Length header.
|
|
|
92
210
|
Platform-account sessions are a separate boundary and are intentionally not
|
|
93
211
|
accepted by this member client.
|
|
94
212
|
|
|
95
|
-
This package adds no OAuth grant
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
213
|
+
This package adds no OAuth grant or backend permission. Vuoma's member routes
|
|
214
|
+
allow cross-origin requests with explicit bearer credentials and no cookies.
|
|
215
|
+
Browser cookie sessions use the website's same-origin backend adapter. Live user
|
|
216
|
+
sessions and studio permissions are enforced by the API; a studio slug never
|
|
217
|
+
authorizes private account data.
|
|
218
|
+
|
|
219
|
+
Supported routes include public `/api/v1/o/{slug}/member/{bootstrap,schedule,catalog,booking-catalog,availability}`;
|
|
220
|
+
organization Auth `/api/v1/o/{slug}/auth/{sign-in,refresh,sign-out,sign-up,recovery,password}`
|
|
221
|
+
and the reviewed `sign-up`, `account` and `recovery` `confirm-code` endpoints;
|
|
222
|
+
member profile GET (`/me/profile?view=basic`), PATCH `/me/profile`, GET `/me/commerce`,
|
|
223
|
+
POST `/me/commerce/bookings`, booking `cancellation-quote`/`cancel`, and
|
|
224
|
+
POST/GET `/me/commerce/visits` with receipt lookup by request ID.
|
|
225
|
+
Payments, uploads, staff administration and platform-account authentication
|
|
226
|
+
remain outside this member client.
|
|
227
|
+
|
|
228
|
+
## Contract-generated organization operations
|
|
229
|
+
|
|
230
|
+
The server-side `@vuoma/sdk/organization` entry point accepts a confidential
|
|
231
|
+
scoped API token. It shares generated request and response schemas with the
|
|
232
|
+
operation registry, OpenAPI document and organization MCP server. The seven
|
|
233
|
+
reviewed reads cover organization details, locations, services, schedule,
|
|
234
|
+
the user directory, task lists and inventory. Route inventory is not a reviewed
|
|
235
|
+
payload contract and does not automatically become an SDK method.
|
|
236
|
+
|
|
237
|
+
```js
|
|
238
|
+
import { createOrganizationApiClient } from '@vuoma/sdk/organization'
|
|
239
|
+
|
|
240
|
+
const api = createOrganizationApiClient({
|
|
241
|
+
apiOrigin: 'https://vuoma.com',
|
|
242
|
+
organizationSlug: 'your-studio',
|
|
243
|
+
getApiToken: () => integrationServerToken,
|
|
244
|
+
})
|
|
245
|
+
const result = await api.operations['users.directory.read']({ limit: 25 })
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The host supplies the current token for every request; the client does not store
|
|
249
|
+
it. Queries and responses are validated against the generated contracts, and
|
|
250
|
+
redirects and oversized responses are rejected. The server rechecks the exact
|
|
251
|
+
scope, current organization permissions and tenant access on every call. There
|
|
252
|
+
are no automatic retries. Three explicitly reviewed mutations are available in
|
|
253
|
+
`api.writeOperations`: `messages.send`, `messages.read_state.update` and
|
|
254
|
+
`messages.mute.update`. Supply `{ body: { channel_id, body } }` to send, or
|
|
255
|
+
`{ path: { channel_id }, body: { message_id } }` / `{ path: { channel_id }, body: { is_muted } }`
|
|
256
|
+
to advance read state / mute a channel. The client validates path, payload and response
|
|
257
|
+
contracts and enforces the request byte limit. Each call executes once; reconcile
|
|
258
|
+
an uncertain send by reading the channel before repeating it. Generated MCP tools
|
|
259
|
+
remain reviewed reads only. Run
|
|
260
|
+
`npm run api:generate` from the repository root after changing a reviewed
|
|
261
|
+
contract; `npm run api:check` verifies that generated files are current.
|
|
99
262
|
|
|
100
|
-
|
|
101
|
-
member sign-in, profile GET/PATCH and commerce GET; plus the existing global
|
|
102
|
-
member refresh/sign-out routes. Signup, payments, booking mutations, uploads,
|
|
103
|
-
staff administration and platform-account sessions are outside this initial SDK.
|
|
263
|
+
## Maintainer verification and package artifact
|
|
104
264
|
|
|
105
|
-
|
|
265
|
+
From the Vuoma source checkout:
|
|
106
266
|
|
|
107
267
|
```sh
|
|
108
|
-
npm
|
|
109
|
-
npm run verify
|
|
268
|
+
npm run verify --workspace @vuoma/sdk
|
|
110
269
|
```
|
|
111
270
|
|
|
112
271
|
Verification tests the actual wire route/header/session boundaries and creates
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
272
|
+
`packages/sdk/artifacts/vuoma-sdk-0.2.0.tgz`. For pre-publication testing, a
|
|
273
|
+
maintainer can provide that reviewed archive to a consumer, which can install it
|
|
274
|
+
with `npm install ./vendor/vuoma-sdk-reviewed.tgz` and retain it with its lockfile. It checks the archive's public
|
|
275
|
+
file list, installs its declared dependencies in an isolated temporary directory,
|
|
276
|
+
and loads both packed entry points.
|
|
277
|
+
The verification command does not publish or attempt registry authentication. The [Vuoma Connected Application License](./LICENSE) permits use, modification
|
|
278
|
+
and redistribution for authorized Vuoma-connected apps, including commercial
|
|
279
|
+
studio apps and mocked development. It does not license an unrelated replacement
|
|
280
|
+
backend or grant API access. It is a restricted-use license, not an open-source
|
|
281
|
+
license. Include the license in your application's third-party notices.
|
package/package.json
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vuoma/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Framework-neutral client for Vuoma public organization and member account APIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
7
|
-
"license": "
|
|
8
|
-
"files": [
|
|
7
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
9
13
|
"exports": {
|
|
10
|
-
".": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./src/index.d.ts",
|
|
16
|
+
"import": "./src/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./organization": {
|
|
19
|
+
"types": "./src/organization.d.ts",
|
|
20
|
+
"import": "./src/organization.js"
|
|
21
|
+
}
|
|
11
22
|
},
|
|
12
23
|
"types": "./src/index.d.ts",
|
|
13
24
|
"sideEffects": false,
|
|
@@ -16,6 +27,14 @@
|
|
|
16
27
|
"verify": "npm test && node scripts/verify-package.mjs",
|
|
17
28
|
"prepack": "npm test"
|
|
18
29
|
},
|
|
19
|
-
"publishConfig": {
|
|
20
|
-
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"ajv": "^8.20.0",
|
|
38
|
+
"ajv-formats": "^3.0.1"
|
|
39
|
+
}
|
|
21
40
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -16,14 +16,22 @@ export interface MemberTokenAdapter {
|
|
|
16
16
|
getSession(): MemberAuthSession | null | Promise<MemberAuthSession | null>
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** For native booking wrappers that hold an access token separately from profile data.
|
|
20
|
+
* Accepted only by booking/visit methods; never accepts platform-account credentials. */
|
|
21
|
+
export interface MemberBookingTokenAdapter {
|
|
22
|
+
getCredential(): { organization_slug: string; access_token: string } | null | Promise<{ organization_slug: string; access_token: string } | null>
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
export interface VuomaClientOptions {
|
|
20
26
|
/** Origin only. HTTPS required except HTTP loopback during local development. */
|
|
21
27
|
apiOrigin: string
|
|
22
28
|
organizationSlug: string
|
|
23
29
|
/** Optional stronger client-side tenant check against the member JWT claim. */
|
|
24
30
|
organizationId?: string
|
|
25
|
-
fetch?:
|
|
31
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>
|
|
26
32
|
tokenAdapter?: MemberTokenAdapter
|
|
33
|
+
/** Mutually exclusive with tokenAdapter. Profile/commerce reads still require a full session. */
|
|
34
|
+
bookingTokenAdapter?: MemberBookingTokenAdapter
|
|
27
35
|
/** Default 2 MiB; accepted range 1 KiB–16 MiB. */
|
|
28
36
|
maximumResponseBytes?: number
|
|
29
37
|
}
|
|
@@ -104,14 +112,61 @@ export interface MemberCommerceSnapshot {
|
|
|
104
112
|
orders: ApiRecord[]
|
|
105
113
|
}
|
|
106
114
|
|
|
115
|
+
export type MemberSignupIntent = 'new' | 'migrate' | 'auto' | 'activate'
|
|
116
|
+
export type MemberAccountCodeRequest = RequestOptions & { email: string } & (
|
|
117
|
+
{ mode: 'recovery'; signupIntent?: never; displayName?: never } |
|
|
118
|
+
{ mode: 'signup'; signupIntent: 'new' | 'auto'; displayName: string } |
|
|
119
|
+
{ mode: 'signup'; signupIntent: 'migrate' | 'activate'; displayName?: never }
|
|
120
|
+
)
|
|
121
|
+
export type MemberAccountCodeConfirmation = RequestOptions & { email: string; code: string } & (
|
|
122
|
+
{ mode: 'recovery'; signupIntent?: never } | { mode: 'signup'; signupIntent: MemberSignupIntent }
|
|
123
|
+
)
|
|
124
|
+
export interface MemberAccountResolution { kind: 'existing_account' | 'existing_profile' | 'new_profile'; display_name: string; masked_email: string; masked_phone: string | null }
|
|
125
|
+
export interface MemberPasswordCapability { organization_slug: string; value: string; account_resolution?: MemberAccountResolution }
|
|
126
|
+
export interface MemberBookingPolicyAcceptance { policy_version: string; policy_consent: true; payment_authorized: true; payment_authorization_id: string }
|
|
127
|
+
export type MemberClassBookingInput = { service_session_id: string; entitlement_id?: string | null; source_request_id: string } & (MemberBookingPolicyAcceptance | { policy_version?: never; policy_consent?: never; payment_authorized?: never; payment_authorization_id?: never })
|
|
128
|
+
export interface MemberClassBookingResult { api_version: 1; booking: { id: string; status: 'confirmed' | 'waitlisted' | 'cancelled' | 'attended' | 'no_show' }; redemption: { id: string; status: 'waitlisted' | 'reserved' | 'consumed' | 'released' | 'reversed' } }
|
|
129
|
+
export interface MemberCancellationQuote {
|
|
130
|
+
policy_version: string | null; can_cancel: boolean; late: boolean; late_cancellation_deadline: string | null;
|
|
131
|
+
grace_until: string | null; grace_applies: boolean; demand_observed: boolean; fee_amount: number; currency: string;
|
|
132
|
+
reason: 'no_policy' | 'fees_disabled' | 'legacy_booking' | 'on_time' | 'waitlisted' | 'booking_grace' | 'quiet_class' | 'late_fee_if_unfilled' | 'seat_refilled' | 'teacher_assignment' | 'started';
|
|
133
|
+
waive_if_filled: boolean; credit_return: boolean; quoted_at: string; policy_context?: ApiRecord | null
|
|
134
|
+
}
|
|
135
|
+
export interface MemberCancellationQuoteResult { api_version: 1; booking_id: string; quote: MemberCancellationQuote }
|
|
136
|
+
export interface MemberBookingCancellation { api_version: 1; booking_id: string; status: 'cancelled'; canceled: true; cancellation_quote: MemberCancellationQuote | null }
|
|
137
|
+
export interface MemberAppointmentSelection { service_offering_id: string; starts_at: string; ends_at: string; credit_cost: number }
|
|
138
|
+
export interface MemberAppointmentVisitRequest { source_request_id: string; appointments: MemberAppointmentSelection[] }
|
|
139
|
+
export interface MemberAppointmentVisitResult {
|
|
140
|
+
api_version: 1; visit_id: string; source_request_id: string; credit_cost: number;
|
|
141
|
+
bookings: Array<MemberAppointmentSelection & { booking_id: string; service_session_id: string; status: 'confirmed' | 'cancelled' | 'attended' | 'no_show' }>
|
|
142
|
+
}
|
|
143
|
+
export interface MemberLocation { public_id: string; name: string; timezone: string; city: string | null; region: string | null }
|
|
144
|
+
export interface MemberAppointmentCandidate extends MemberAppointmentSelection {
|
|
145
|
+
candidate_id: string; service_id: string; duration_minutes: number; location: MemberLocation;
|
|
146
|
+
service: { id: string; name: string; description: string | null }
|
|
147
|
+
}
|
|
148
|
+
export interface MemberPublicAvailability { api_version: 1; generated_at: string; range: { from: string; to: string }; slots: MemberAppointmentCandidate[]; has_more: boolean }
|
|
149
|
+
export interface MemberBookingCatalog {
|
|
150
|
+
api_version: 1; generated_at: string; cancellation_notice_minutes?: number;
|
|
151
|
+
experience: { classes: boolean; appointments: boolean; default_view: 'classes' | 'appointments' };
|
|
152
|
+
services: Array<{ id: string; name: string; description: string | null; offerings: Array<{ id: string; duration_minutes: number; location_public_id: string; credit_cost: number | null }> }>;
|
|
153
|
+
locations: MemberLocation[]
|
|
154
|
+
}
|
|
155
|
+
|
|
107
156
|
export interface VuomaClient {
|
|
108
157
|
readonly public: {
|
|
109
158
|
getBootstrap(options?: RequestOptions & { includeEvents?: boolean }): Promise<MemberBootstrap>
|
|
110
159
|
/** Returns one server range. If has_more is true, split the range and request again. */
|
|
111
160
|
getSchedule(options: RequestOptions & { from: string; to: string; includeEvents?: boolean; fresh?: boolean }): Promise<MemberPublicSchedule>
|
|
161
|
+
getBookingCatalog(options?: RequestOptions): Promise<MemberBookingCatalog>
|
|
162
|
+
/** One half-open range, up to 14 days. Split when has_more is true. */
|
|
163
|
+
getAppointmentAvailability(options: RequestOptions & { from: string; to: string }): Promise<MemberPublicAvailability>
|
|
112
164
|
getCatalog(options?: RequestOptions & { includeCreditPurchases?: boolean }): Promise<MemberPublicCatalog>
|
|
113
165
|
}
|
|
114
166
|
readonly auth: {
|
|
167
|
+
requestAccountCode(options: MemberAccountCodeRequest): Promise<{ ok: true; mode: 'signup' | 'recovery'; requires_email_code: true }>
|
|
168
|
+
confirmAccountCode(options: MemberAccountCodeConfirmation): Promise<MemberPasswordCapability>
|
|
169
|
+
completePassword(options: RequestOptions & { capability: MemberPasswordCapability; password: string }): Promise<void>
|
|
115
170
|
/** Returns credentials to the host; the SDK neither stores nor installs them. */
|
|
116
171
|
signIn(options: RequestOptions & { email: string; password: string }): Promise<MemberAuthSession>
|
|
117
172
|
refresh(options: RequestOptions & { refreshToken: string }): Promise<MemberAuthSession>
|
|
@@ -119,6 +174,14 @@ export interface VuomaClient {
|
|
|
119
174
|
signOut(options: RequestOptions & { refreshToken: string }): Promise<void>
|
|
120
175
|
}
|
|
121
176
|
readonly member: {
|
|
177
|
+
/** Executes once. Retain source_request_id and the exact payload for uncertain-result recovery. */
|
|
178
|
+
bookClass(input: MemberClassBookingInput, options?: RequestOptions): Promise<MemberClassBookingResult>
|
|
179
|
+
getCancellationQuote(bookingId: string, options?: RequestOptions): Promise<MemberCancellationQuoteResult>
|
|
180
|
+
/** Applies to individual class and appointment bookings. Recheck a changed quote before retrying. */
|
|
181
|
+
cancelBooking(bookingId: string, options?: RequestOptions & { expectedFeeAmount?: number }): Promise<MemberBookingCancellation>
|
|
182
|
+
bookAppointmentVisit(input: MemberAppointmentVisitRequest, options?: RequestOptions): Promise<MemberAppointmentVisitResult>
|
|
183
|
+
/** Read the original receipt using its saved request; validates every returned selection. */
|
|
184
|
+
getAppointmentVisit(input: MemberAppointmentVisitRequest, options?: RequestOptions): Promise<MemberAppointmentVisitResult>
|
|
122
185
|
getProfile(options?: RequestOptions): Promise<MemberProfile>
|
|
123
186
|
updateProfile(changes: MemberProfileChanges, options?: RequestOptions): Promise<MemberProfile>
|
|
124
187
|
getCommerce(options?: RequestOptions): Promise<MemberCommerceSnapshot>
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { bookingInput, visitInput, bookingId, feeAmount, validBookingResult, validQuote, validVisitResult, availabilityRange, validAvailability, validBookingCatalog, accountInput, displayName, emailCode, validCapability, accountResolution } from "./member-contracts.js"
|
|
2
|
+
|
|
1
3
|
// Public package: no environment reads, provider clients, persistence or secrets.
|
|
2
4
|
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u
|
|
3
5
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
|
|
@@ -138,16 +140,38 @@ export function createVuomaClient(options) {
|
|
|
138
140
|
if (typeof requestFetch !== "function") throw new TypeError("A fetch implementation is required.")
|
|
139
141
|
const tokenAdapter = options.tokenAdapter
|
|
140
142
|
if (tokenAdapter !== undefined && (!object(tokenAdapter) || typeof tokenAdapter.getSession !== "function")) throw new TypeError("Provide a member session adapter.")
|
|
143
|
+
const bookingTokenAdapter = options.bookingTokenAdapter
|
|
144
|
+
if (bookingTokenAdapter !== undefined && (!object(bookingTokenAdapter) || typeof bookingTokenAdapter.getCredential !== "function" || tokenAdapter)) throw new TypeError("Provide either a member session adapter or a booking credential adapter.")
|
|
141
145
|
const maximumBytes = options.maximumResponseBytes ?? 2 * 1024 * 1024
|
|
142
146
|
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1024 || maximumBytes > 16 * 1024 * 1024) throw new TypeError("Use a response limit between 1 KiB and 16 MiB.")
|
|
143
147
|
const memberRoot = `/api/v1/o/${organizationSlug}/member`
|
|
148
|
+
const selfRoot = `/api/v1/o/${organizationSlug}/me`
|
|
149
|
+
const authRoot = `/api/v1/o/${organizationSlug}/auth`
|
|
144
150
|
|
|
145
151
|
async function readSession() {
|
|
146
152
|
if (!tokenAdapter) throw invalidSession()
|
|
147
153
|
return verifySession(await tokenAdapter.getSession(), organizationSlug, organizationId)
|
|
148
154
|
}
|
|
149
155
|
|
|
156
|
+
async function readBookingSession() {
|
|
157
|
+
if (!bookingTokenAdapter) return readSession()
|
|
158
|
+
const credential = await bookingTokenAdapter.getCredential()
|
|
159
|
+
if (!object(credential) || credential.organization_slug !== organizationSlug) throw invalidSession()
|
|
160
|
+
const claims = memberClaims(credential.access_token)
|
|
161
|
+
if (organizationId && claims.app_metadata.organization_id !== organizationId) throw invalidSession()
|
|
162
|
+
return { session: credential, identity: `${organizationSlug}:${claims.sub}:${claims.session_id}:${claims.app_metadata.organization_id}` }
|
|
163
|
+
}
|
|
164
|
+
function checkSignal(signal) {
|
|
165
|
+
if (signal?.aborted) { const error = new Error("The request was cancelled."); error.name = "AbortError"; throw error }
|
|
166
|
+
}
|
|
167
|
+
function checked(value, valid) { if (!valid(value)) throw invalidResponse(); return value }
|
|
168
|
+
function setupResult(value) {
|
|
169
|
+
if (value.ok !== true || value.organization_slug != null && value.organization_slug !== organizationSlug) throw invalidResponse()
|
|
170
|
+
return value
|
|
171
|
+
}
|
|
172
|
+
|
|
150
173
|
async function request(path, { method = "GET", body, accessToken, signal, query, privateResponse = false, empty = false } = {}) {
|
|
174
|
+
checkSignal(signal)
|
|
151
175
|
const url = new URL(path, origin)
|
|
152
176
|
if (query) for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value)
|
|
153
177
|
const headers = { accept: "application/json" }
|
|
@@ -158,11 +182,13 @@ export function createVuomaClient(options) {
|
|
|
158
182
|
cache: privateResponse ? "no-store" : "default",
|
|
159
183
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
160
184
|
})
|
|
185
|
+
checkSignal(signal)
|
|
161
186
|
// Custom fetch adapters must also honor the no-redirect destination boundary.
|
|
162
187
|
if (response.redirected || (response.url && response.url !== url.href)) throw invalidResponse()
|
|
163
188
|
const declared = Number(response.headers.get("content-length"))
|
|
164
189
|
if (Number.isFinite(declared) && declared > maximumBytes) throw invalidResponse()
|
|
165
190
|
const text = await response.text()
|
|
191
|
+
checkSignal(signal)
|
|
166
192
|
if (utf8Length(text) > maximumBytes) throw invalidResponse()
|
|
167
193
|
let value
|
|
168
194
|
try { value = text ? JSON.parse(text) : null } catch { if (response.ok) throw invalidResponse() }
|
|
@@ -181,12 +207,14 @@ export function createVuomaClient(options) {
|
|
|
181
207
|
return value
|
|
182
208
|
}
|
|
183
209
|
|
|
184
|
-
async function accountRequest(path, requestOptions = {}, parse = value => value) {
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
const
|
|
210
|
+
async function accountRequest(path, requestOptions = {}, parse = value => value, booking = false) {
|
|
211
|
+
const read = booking ? readBookingSession : readSession
|
|
212
|
+
const initial = await read()
|
|
213
|
+
const value = await request(`${selfRoot}${path}`, { ...requestOptions, accessToken: initial.session.access_token, privateResponse: true })
|
|
214
|
+
const current = await read()
|
|
215
|
+
checkSignal(requestOptions.signal)
|
|
188
216
|
if (current.identity !== initial.identity) throw new VuomaApiError(401, "member_session_changed", "Your signed-in account changed. Try again.")
|
|
189
|
-
return parse(value, initial.session.customer
|
|
217
|
+
return parse(value, initial.session.customer?.id)
|
|
190
218
|
}
|
|
191
219
|
|
|
192
220
|
function profile(value, customerId) {
|
|
@@ -221,24 +249,74 @@ export function createVuomaClient(options) {
|
|
|
221
249
|
if (value.schedule.sessions.length > 250) throw invalidResponse()
|
|
222
250
|
return value
|
|
223
251
|
},
|
|
252
|
+
async getBookingCatalog({ signal } = {}) {
|
|
253
|
+
return checked(await request(`${memberRoot}/booking-catalog`, { signal }), validBookingCatalog)
|
|
254
|
+
},
|
|
255
|
+
async getAppointmentAvailability({ from, to, signal }) {
|
|
256
|
+
const range = availabilityRange({ from, to })
|
|
257
|
+
return checked(await request(`${memberRoot}/availability`, { query: range, signal }), value => validAvailability(value, range))
|
|
258
|
+
},
|
|
224
259
|
async getCatalog({ includeCreditPurchases = false, signal } = {}) {
|
|
225
260
|
return catalog(await request(`${memberRoot}/catalog`, { signal, query: includeCreditPurchases ? { include_credit_purchases: "true" } : undefined }))
|
|
226
261
|
},
|
|
227
262
|
}),
|
|
228
263
|
auth: Object.freeze({
|
|
264
|
+
async requestAccountCode({ mode, email, signupIntent, displayName: name, signal }) {
|
|
265
|
+
accountInput({ mode, email, signupIntent })
|
|
266
|
+
const body = mode === "signup" ? { email, signup_intent: signupIntent, ...(["new", "auto"].includes(signupIntent) ? { display_name: displayName(name) } : {}) } : { email }
|
|
267
|
+
const value = setupResult(await request(`${authRoot}/${mode === "signup" ? "sign-up" : "recovery"}`, { method: "POST", body, signal, privateResponse: true }))
|
|
268
|
+
if (value.mode !== mode || value.requires_email_code !== true) throw invalidResponse()
|
|
269
|
+
return { ok: true, mode, requires_email_code: true }
|
|
270
|
+
},
|
|
271
|
+
async confirmAccountCode({ mode, email, signupIntent, code, signal }) {
|
|
272
|
+
accountInput({ mode, email, signupIntent })
|
|
273
|
+
const resolve = mode === "signup" && ["auto", "activate"].includes(signupIntent)
|
|
274
|
+
const path = mode === "signup" ? resolve ? "account" : "sign-up" : "recovery"
|
|
275
|
+
const value = setupResult(await request(`${authRoot}/${path}/confirm-code`, { method: "POST", body: { email, code: emailCode(code) }, signal, privateResponse: true }))
|
|
276
|
+
if (!validCapability(value.capability)) throw invalidResponse()
|
|
277
|
+
let resolution
|
|
278
|
+
try { if (resolve) resolution = accountResolution(value.account_resolution) } catch { throw invalidResponse() }
|
|
279
|
+
return { organization_slug: organizationSlug, value: value.capability, ...(resolution ? { account_resolution: resolution } : {}) }
|
|
280
|
+
},
|
|
281
|
+
async completePassword({ capability, password, signal }) {
|
|
282
|
+
if (!object(capability) || capability.organization_slug !== organizationSlug || !validCapability(capability.value)) throw invalidSession()
|
|
283
|
+
if (typeof password !== "string" || !password || password.length > 1024) throw new TypeError("Provide a password.")
|
|
284
|
+
setupResult(await request(`${authRoot}/password`, { method: "POST", body: { capability: capability.value, password, password_confirmation: password }, signal, privateResponse: true }))
|
|
285
|
+
},
|
|
229
286
|
async signIn({ email, password, signal }) {
|
|
230
287
|
if (typeof email !== "string" || !email.trim() || email.length > 320 || typeof password !== "string" || !password || password.length > 1024) throw new TypeError("Provide an email address and password.")
|
|
231
|
-
return sessionResponse(await request(`${
|
|
288
|
+
return sessionResponse(await request(`${authRoot}/sign-in`, { method: "POST", body: { email, password }, signal, privateResponse: true }), organizationSlug, organizationId)
|
|
232
289
|
},
|
|
233
290
|
async refresh({ refreshToken: token, signal }) {
|
|
234
|
-
return sessionResponse(await request(
|
|
291
|
+
return sessionResponse(await request(`${authRoot}/refresh`, { method: "POST", body: { refresh_token: refreshToken(token) }, signal, privateResponse: true }), organizationSlug, organizationId)
|
|
235
292
|
},
|
|
236
293
|
async signOut({ refreshToken: token, signal }) {
|
|
237
|
-
await request(
|
|
294
|
+
await request(`${authRoot}/sign-out`, { method: "POST", body: { refresh_token: refreshToken(token) }, signal, privateResponse: true, empty: true })
|
|
238
295
|
},
|
|
239
296
|
}),
|
|
240
297
|
member: Object.freeze({
|
|
241
|
-
|
|
298
|
+
bookClass(input, { signal } = {}) {
|
|
299
|
+
const body = bookingInput(input)
|
|
300
|
+
return accountRequest("/commerce/bookings", { method: "POST", body, signal }, value => checked(value, validBookingResult), true)
|
|
301
|
+
},
|
|
302
|
+
getCancellationQuote(id, { signal } = {}) {
|
|
303
|
+
bookingId(id)
|
|
304
|
+
return accountRequest(`/commerce/bookings/${id}/cancellation-quote`, { signal, query: { include_booking_policy_context: "true" } }, value => checked(value, result => result.api_version === 1 && result.booking_id === id && validQuote(result.quote)), true)
|
|
305
|
+
},
|
|
306
|
+
cancelBooking(id, { expectedFeeAmount, signal } = {}) {
|
|
307
|
+
bookingId(id)
|
|
308
|
+
const body = expectedFeeAmount === undefined ? {} : { expected_fee_amount: feeAmount(expectedFeeAmount) }
|
|
309
|
+
return accountRequest(`/commerce/bookings/${id}/cancel`, { method: "POST", body, signal }, value => checked(value, result => result.api_version === 1 && result.booking_id === id && result.status === "cancelled" && result.canceled === true && (result.cancellation_quote === null || validQuote(result.cancellation_quote))), true)
|
|
310
|
+
},
|
|
311
|
+
bookAppointmentVisit(input, { signal } = {}) {
|
|
312
|
+
const body = visitInput(input)
|
|
313
|
+
return accountRequest("/commerce/visits", { method: "POST", body, signal }, value => checked(value, result => validVisitResult(result, body)), true)
|
|
314
|
+
},
|
|
315
|
+
getAppointmentVisit(input, { signal } = {}) {
|
|
316
|
+
const body = visitInput(input)
|
|
317
|
+
return accountRequest(`/commerce/visits/${body.source_request_id}`, { signal }, value => checked(value, result => validVisitResult(result, body)), true)
|
|
318
|
+
},
|
|
319
|
+
getProfile({ signal } = {}) { return accountRequest("/profile", { signal, query: { view: "basic" } }, profile) },
|
|
242
320
|
updateProfile(changes, { signal } = {}) {
|
|
243
321
|
const allowed = new Set(["first_name", "last_name", "phone", "birth_date"])
|
|
244
322
|
if (!object(changes) || !Object.keys(changes).length || Object.entries(changes).some(([key, value]) =>
|