@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 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, create accounts, or
7
- grant access. This is a local package foundation; registry publication is a
8
- separate step.
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: 'http://localhost:5173', // Use your verified Vuoma HTTPS origin in production.
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
- No key is needed for these public reads. Schedule timestamps are normalized to
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: 'http://localhost:5173',
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. Never put them in a website bundle, native binary, URL or SDK
85
- token adapter. They do not sign a customer in.
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, CORS policy or backend permission. A third-party
96
- website still needs its backend adapter. Independent app authorization and
97
- app-scoped member grants are separate platform work; a public app ID alone must
98
- never authorize private account data.
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
- Supported routes are public `/api/v1/o/{slug}/member/{bootstrap,schedule,catalog}`;
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
- ## Verification and package artifact
265
+ From the Vuoma source checkout:
106
266
 
107
267
  ```sh
108
- npm test
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
- an installable npm archive under `artifacts/`. It checks the archive's public
114
- file list and loads the packed module in an isolated temporary directory.
115
- Nothing is published and no registry authentication is attempted. The inherited
116
- license remains `UNLICENSED`; `private: false` prepares packaging without granting
117
- open-source licensing rights.
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.1.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": "UNLICENSED",
8
- "files": ["src", "README.md"],
7
+ "license": "SEE LICENSE IN LICENSE",
8
+ "files": [
9
+ "src",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
9
13
  "exports": {
10
- ".": { "types": "./src/index.d.ts", "import": "./src/index.js" }
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": { "access": "public" },
20
- "engines": { "node": ">=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?: typeof globalThis.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 initial = await readSession()
186
- const value = await request(`${memberRoot}${path}`, { ...requestOptions, accessToken: initial.session.access_token, privateResponse: true })
187
- const current = await readSession()
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.id)
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(`${memberRoot}/auth/sign-in`, { method: "POST", body: { email, password }, signal, privateResponse: true }), organizationSlug, organizationId)
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("/api/v1/member/auth/refresh", { method: "POST", body: { refresh_token: refreshToken(token) }, signal, privateResponse: true }), organizationSlug, organizationId)
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("/api/v1/member/auth/sign-out", { method: "POST", body: { refresh_token: refreshToken(token) }, signal, privateResponse: true, empty: true })
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
- getProfile({ signal } = {}) { return accountRequest("/profile", { signal }, profile) },
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]) =>