@fytkit/sdk 3.4.0 → 4.0.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/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ All notable changes to `@fytkit/sdk` are documented in this file.
4
4
  Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).
5
5
  Versioning: [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 4.0.0
8
+
9
+ To be published by hand (release-please, which runs on GitHub Actions, does not cut this release; the manifest records 4.0.0 so the next automated release bumps from it). Create the tag and GitHub release `@fytkit/sdk@4.0.0` when it is published, or release-please proposes 4.0.0 again.
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * `appConfig.get` is removed, with no deprecation window: the `appConfig` namespace is gone from `FytkitClient`, and `GET /api/v1/app-config` and `POST /rpc/appConfig/get` answer 404. Use `app.bootstrap` instead. Its colours become the app theme's curated palette (`primaryColor` → `data.theme.palette.primary`, and so on), `appName` → `data.studio.name`, the links → `data.links`, `realtime` → `data.realtime`; the icon URLs, `buildStatus`, `buildVersion`, the social sign-in ids (`auth`), `id` and `trainerId` are gone. Field-by-field migration: [docs/developer/reference/app-bootstrap.md](https://github.com/cbarlow1993/fytkit-web/blob/main/docs/developer/reference/app-bootstrap.md#migrating-from-appconfigget) ([#535](https://github.com/cbarlow1993/fytkit-web/issues/535))
14
+ * `MessagesRealtimeSource` reads the Realtime settings from `app.bootstrap()` (`{ status: "ok", data: { realtime } }`) instead of `appConfig.get()`. A `FytkitClient` still satisfies it; a hand-written source must rename `appConfig.get` to `app.bootstrap` ([#535](https://github.com/cbarlow1993/fytkit-web/issues/535))
15
+
16
+ ### Features
17
+
18
+ * `app.bootstrap({ etag? })`: the app's launch read. The studio's display name and logo, the effective app theme (template, theme, palette, derived variants and the oldest app release that paints it), every template's minimum app version, feature overrides, the studio's minimum app version, the terms, privacy and help links, the Realtime settings and the payment policy, in one request that needs the client id alone. Resolves to `{ status: "ok", data, etag }`, or to `{ status: "unchanged", etag }` when the `etag` you stored is still current (the server answers 304 with no body). New types `AppBootstrapData`, `AppBootstrapOptions`, `AppBootstrapResult` and `AppNamespace`; `FytkitClient.clientId` names the client id the client was created with, to key per-studio caches by ([#535](https://github.com/cbarlow1993/fytkit-web/issues/535))
19
+ * `app.bootstrap`'s `data.paymentPolicy` and `resolvePaymentMode(policy, { platform, storefront, conditionsMet? })`: whether the app shows "Subscribe" (a link out to the studio's checkout) or "What your plan includes" for the storefront the device's store reports (StoreKit's alpha-3 `Storefront.countryCode` on iOS, Play Billing's alpha-2 country code on Android; case-insensitive). The policy is Fytkit's reviewed list of storefronts where linking out is allowed, with any conditions the app must meet first; every other storefront, and any unmet or unknown condition, is plan info. New types `PaymentMode`, `PaymentPlatform`, `PaymentPolicy` and `ResolvePaymentModeOptions` ([#538](https://github.com/cbarlow1993/fytkit-web/issues/538))
20
+ * `resolveJoinCode(code, { baseUrl?, fetch? })`: resolves a studio's join code (the code on its Fytkit Coach QR code or join link) to the studio's client id, name, logo URL and app theme, with no client id or session. An unknown or replaced code rejects with `{ kind: "not_found", refusal: { reason: "join_code_inactive" } }`; new types `JoinCodeResolution`, `JoinCodeTheme` and `ResolveJoinCodeOptions` ([#529](https://github.com/cbarlow1993/fytkit-web/issues/529))
21
+ * `resolveJoinCode`'s `studio` gains `coaches` (at most 3; today the studio's own public coach profile alone: `name` and the public profile image as `avatarUrl`, null when there is none; never staff names or emails) and `programCount` (published programmes, any access level; drafts and archived ones never count), for a welcome screen's "Coached by" row and programme count. Additive. New type `JoinCodeCoach` ([#600](https://github.com/cbarlow1993/fytkit-web/issues/600))
22
+ * `FytkitRefusalReason` gains `join_code_inactive` ([#529](https://github.com/cbarlow1993/fytkit-web/issues/529))
23
+ * `auth.resendVerification()`: mails a new email-verification link to the signed-in member's own address (a session is required; there is no email input), for a "Send it again" link on a confirm-your-email screen. Resolves to `{ status: "sent" }`, or to `{ status: "already_verified" }` with nothing sent when the address is verified already. Throttled per account (one send a minute, five an hour): over the limit it rejects with `{ kind: "rate_limited" }`. Fytkit Coach members get the app-link URL, as at sign-up. A bearer call, so a 401 refreshes or signs out as usual. Additive. New type `ResendVerificationResult` ([#615](https://github.com/cbarlow1993/fytkit-web/issues/615))
24
+ * `auth.confirmEmailChange({ token })`: applies an email change from the token in the confirmation link, for an app that opens that link itself (Fytkit Coach's app link). Needs no session; idempotent while the address still holds ([#532](https://github.com/cbarlow1993/fytkit-web/issues/532))
25
+ * `sessionLogs.create` and `sessionLogs.addSet` take an optional `clientRequestId` (a UUID) so an offline queue or a retry can replay a write safely: a replay with the same key returns the stored log or set instead of creating a duplicate log or failing the set with `conflict`, and a key reused for another log or set is `conflict`. On a replayed `create`, `sets` holds the log's current sets, so its type is now the set array instead of `never[]`. Calls without a key are unchanged ([#603](https://github.com/cbarlow1993/fytkit-web/issues/603))
26
+ * `bodyMetrics.record` and `nutritionLog.logFood` take an optional `clientRequestId` (a UUID) for the same offline replay: a replay with the same key returns the stored weigh-in (with the warning `update` would give it) or food log entry, with their current values, instead of recording a second one, and writes no second timeline event. A `logFood` key reused for another `loggedFor` day is `conflict`; a weigh-in's values can all be edited, so a `record` replay returns the stored weigh-in whatever it sends. Keys are scoped to the trainee. `nutritionLog.updateFood` and `nutritionLog.setTargets` take no key: they set absolute values, so a replay is already safe. Calls without a key are unchanged (W17, W18)
27
+ * `sessionLogs.delete({ id })`: deletes one of the member's own session logs, in progress or finished, with its sets, personal records and any staff review, comments and reactions; its timeline events go too, and the streak, personal records and program enrollment completion are recomputed. Resolves to `{ data: { id, deleted: true } }`; another member's log or an unknown id is `not_found`, and the call is refused while a member is impersonated ([#602](https://github.com/cbarlow1993/fytkit-web/issues/602))
28
+ * member coaching loop Phase 2: the `bodyMeasurements`, `progressPhotos`, `timeline`, `health` and `habits` namespaces, `progress.getVolume`, `progression.getE1rmTrend`, `bodyMetrics.update`, and the refusal reasons `staff_entered`, `checkin_sourced`, `consent_required`, `consent_outdated`, `too_many_pending`, `photo_limit_reached`, `invalid_uploads` and `poses_differ`, with the `coaching_habits` entitlement refusal and the `first_party_account` error code ([#461](https://github.com/cbarlow1993/fytkit-web/issues/461)) ([#487](https://github.com/cbarlow1993/fytkit-web/issues/487))
29
+
30
+ ## [3.5.0](https://github.com/cbarlow1993/fytkit-web/compare/@fytkit/sdk@3.4.0...@fytkit/sdk@3.5.0) (2026-10-10)
31
+
32
+
33
+ ### Features
34
+
35
+ * member coaching loop ([#461](https://github.com/cbarlow1993/fytkit-web/issues/461)) [integration branch] ([#482](https://github.com/cbarlow1993/fytkit-web/issues/482)) ([75291c1](https://github.com/cbarlow1993/fytkit-web/commit/75291c1ba29b142b96fe3452b61a25e37f016c8b))
36
+
7
37
  ## [3.4.0](https://github.com/cbarlow1993/fytkit-web/compare/@fytkit/sdk@3.3.0...@fytkit/sdk@3.4.0) (2026-10-04)
8
38
 
9
39
 
package/README.md CHANGED
@@ -48,6 +48,42 @@ await fytkit.auth.signOut();
48
48
  off();
49
49
  ```
50
50
 
51
+ ## Joining a studio and launching
52
+
53
+ A Fytkit Coach style app starts from a studio's join code (its QR code or
54
+ join link) and reads the studio's launch settings once per launch:
55
+
56
+ ```ts
57
+ import { createFytkitClient, resolveJoinCode, resolvePaymentMode } from "@fytkit/sdk";
58
+
59
+ // Once, when the member scans or types the code: keep the client id.
60
+ const studio = await resolveJoinCode(scannedCode);
61
+ const fytkit = createFytkitClient({ clientId: studio.clientId });
62
+
63
+ // Every launch: revalidate the stored settings with their ETag.
64
+ const stored = await loadStoredBootstrap(); // { data, etag } | null, your storage
65
+ const result = await fytkit.app.bootstrap({ etag: stored?.etag });
66
+ if (result.status === "ok") await saveStoredBootstrap({ data: result.data, etag: result.etag });
67
+ const settings = result.status === "ok" ? result.data : stored?.data;
68
+ // settings.studio (name, logoUrl), settings.theme (palette, variants),
69
+ // settings.featureOverrides, settings.minAppVersion, settings.links,
70
+ // settings.realtime, settings.paymentPolicy
71
+
72
+ // "Subscribe" (a link out to the studio's checkout) only where the store allows it.
73
+ const { mode } = resolvePaymentMode(settings.paymentPolicy, {
74
+ platform: "ios",
75
+ storefront: storefrontCountryCode, // StoreKit's Storefront.current?.countryCode
76
+ });
77
+ ```
78
+
79
+ `app.bootstrap` resolves to `{ status: "unchanged" }` (a 304, no body) while
80
+ the `etag` you sent is current. It replaced `appConfig.get` in 4.0.0:
81
+ [`app.bootstrap`](../../docs/developer/reference/app-bootstrap.md), with the
82
+ field-by-field migration. [`resolveJoinCode`](../../docs/developer/reference/resolveJoinCode.md)
83
+ needs no client. `resolvePaymentMode` is plan info ("What your plan includes")
84
+ for every storefront the policy doesn't list:
85
+ [Subscribe or what your plan includes](../../docs/developer/reference/app-bootstrap.md#subscribe-or-what-your-plan-includes).
86
+
51
87
  ## Tracking a workout
52
88
 
53
89
  Log a workout ad-hoc (no program enrollment needed), record sets, complete
@@ -117,17 +153,20 @@ await fytkit.auth.regenerateBackupCodes({ password });
117
153
  await fytkit.auth.disableTwoFactor({ password });
118
154
 
119
155
  // Password change (verifies the current password server-side). With
120
- // revokeOtherSessions the SDK adopts the rotated token transparently.
156
+ // revokeOtherSessions every other session of the account ends, in any app,
157
+ // and the SDK adopts the rotated token transparently.
121
158
  await fytkit.auth.changePassword({ currentPassword, newPassword, revokeOtherSessions: true });
122
159
 
123
160
  // Email change — a confirmation link is emailed to the new address;
124
161
  // nothing changes until the trainee opens it.
125
162
  await fytkit.auth.changeEmail({ newEmail });
126
163
 
127
- // Devices: revokeId is an opaque id (never the bearer token).
164
+ // Devices signed in through this app (plus the calling one); the trainee's
165
+ // sessions elsewhere are never listed. revokeId is an opaque id (never the
166
+ // bearer token).
128
167
  const sessions = await fytkit.auth.listSessions();
129
168
  await fytkit.auth.revokeSession({ revokeId: sessions.find((s) => !s.isCurrent)!.revokeId });
130
- await fytkit.auth.revokeOtherSessions(); // "sign out of all other devices"
169
+ await fytkit.auth.revokeOtherSessions(); // "sign out of your other devices" on this app
131
170
 
132
171
  // Permanently close the account (re-verifies email + password). On success the
133
172
  // SDK clears the session and emits signed_out.
@@ -140,9 +179,83 @@ fresh sign-in is `{ kind: "forbidden", code: "reauth_required" }`. None of
140
179
  these sign the trainee out — only a 401 on an authenticated call means the
141
180
  session is gone. A wrong password on `signIn` (for example, signing in again
142
181
  after `reauth_required`) is a plain `auth_required` and leaves the current
143
- session alone. Enrolling twice in a program is `{ kind: "conflict" }` with the
182
+ session alone. An account that also runs or works at a trainer on Fytkit can't
183
+ sign in, sign up or complete two-factor through an app, and an existing app
184
+ session can't change its password or email or close it:
185
+ `{ kind: "forbidden", code: "first_party_account" }`; point it to Fytkit's own
186
+ sign-in. Enrolling twice in a program is `{ kind: "conflict" }` with the
144
187
  existing `enrollmentId`.
145
188
 
189
+ ## Refusals
190
+
191
+ When the server says no for a reason your app can act on (a coaching feature
192
+ the plan lacks, a check-in already sent, a messaging limit, a studio that is
193
+ full), the error carries `refusal: { reason, data }` beside its usual fields.
194
+ Branch on `refusal.reason`, never on message text; `refusalData` reads a
195
+ known reason's data typed:
196
+
197
+ ```ts
198
+ import { refusalData } from "@fytkit/sdk";
199
+
200
+ try {
201
+ await fytkit.messages.send({ body, clientRequestId });
202
+ } catch (err) {
203
+ const plan = refusalData(err, "entitlement_required");
204
+ if (plan) return showUpgrade(plan.entitlement);
205
+ const daily = refusalData(err, "daily_limit");
206
+ if (daily) return keepDraftUntil(daily.retryAt);
207
+ throw err;
208
+ }
209
+ ```
210
+
211
+ Every reason and its data: [`docs/developer/reference/errors.md`](../../docs/developer/reference/errors.md#refusals).
212
+
213
+ ## Live chat
214
+
215
+ `subscribeToMessages` keeps a conversation screen current over Supabase
216
+ Realtime: it fetches a token (`messages.realtimeToken`) and fytkit's public
217
+ connection settings (`app.bootstrap`), joins the trainee's own private
218
+ channel (and joins it afresh after Realtime closes it), renews the token
219
+ before it expires, and reports when to poll instead. Events carry no message content: `onChange` means "read the
220
+ conversation and the unread count again".
221
+
222
+ The SDK has no dependency on supabase-js. Add `@supabase/supabase-js` to
223
+ your app (`npm install @supabase/supabase-js@^2.97.0`) and pass a factory
224
+ that creates a client for the subscription alone. It must be 2.87.1 or
225
+ later: the helper sets the trainee's token on the socket by hand, and
226
+ earlier 2.x clients swap it for the anon key on the next heartbeat, which
227
+ leaves the subscription in `fallback`. Keep the `auth` options below: the
228
+ client carries the trainee's token only, and must not keep or refresh a
229
+ session, or read a sign-in from your page's URL that would replace it.
230
+
231
+ ```ts
232
+ import { createClient } from "@supabase/supabase-js";
233
+ import { MESSAGES_FALLBACK_POLL_MS, subscribeToMessages } from "@fytkit/sdk";
234
+
235
+ const unsubscribe = subscribeToMessages(fytkit, {
236
+ realtime: ({ url, anonKey }) =>
237
+ createClient(url, anonKey, {
238
+ auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
239
+ }),
240
+ onChange: () => refreshConversation(), // messages.list + messages.unreadCount
241
+ onStatus: (status) => {
242
+ // "live": stop polling. "connecting" / "fallback": poll the conversation
243
+ // every MESSAGES_FALLBACK_POLL_MS.conversation (10 s) and the count every
244
+ // MESSAGES_FALLBACK_POLL_MS.unreadCount (60 s). "refused": no trainee to
245
+ // listen for (signed out); the subscription has stopped.
246
+ setPolling(status === "connecting" || status === "fallback");
247
+ },
248
+ });
249
+
250
+ // On leaving the screen or signing out:
251
+ unsubscribe();
252
+ ```
253
+
254
+ Polling spends your app's per-minute request limit, which all its users
255
+ share, so stop as soon as the status is `live`. The full guide:
256
+ [`messages.realtimeToken`](../../docs/developer/reference/data-namespaces.md#messagesrealtimetoken)
257
+ and the recipe [Chat screen with live updates](../../docs/developer/recipes/chat-screen-with-live-updates.md).
258
+
146
259
  ## Notification preferences
147
260
 
148
261
  Per-member toggles stored server-side (defaults apply until first write):
@@ -154,13 +267,18 @@ await fytkit.preferences.updateNotifications({ streakAlerts: false }); // partia
154
267
 
155
268
  ## What's included in this preview
156
269
 
270
+ - `resolveJoinCode` (a studio's join code to its client id, name, logo and app theme, with no client) and `app.bootstrap` (the launch read, revalidated with its ETag), with `resolvePaymentMode` ("Subscribe" link-out or "What your plan includes", per storefront)
157
271
  - `auth.signUp` / `signIn` / `signOut` / `getSession` / `onAuthStateChange`
158
272
  - `auth.requestPasswordReset` / `confirmPasswordReset` / `confirmEmail`
159
273
  - Two-factor: `auth.verifyTotp` / `verifyBackupCode` / `requestEmailOtp` / `verifyEmailOtp` (sign-in), `enableTotp` / `verifyTotpSetup` / `disableTwoFactor` / `regenerateBackupCodes` / `getSecurityStatus` (management)
160
- - Account security: `auth.changePassword` / `changeEmail` / `listSessions` / `revokeSession` / `revokeOtherSessions` / `closeAccount`
161
- - Data namespaces: `workouts`, `programs`, `recipes`, `nutrition`, `nutritionLog`, `profile`, `preferences`, `articles`, `enrollments`, `sessionLogs`, `progression`, `progress`, `bodyMetrics`, `media`, `appConfig`, `subscription`
274
+ - Account security: `auth.changePassword` / `changeEmail` / `confirmEmailChange` / `listSessions` / `revokeSession` / `revokeOtherSessions` / `closeAccount`
275
+ - Data namespaces: `workouts`, `programs`, `recipes`, `nutrition`, `nutritionLog`, `profile`, `preferences`, `articles`, `enrollments`, `sessionLogs`, `progression`, `progress`, `bodyMetrics`, `bodyMeasurements`, `progressPhotos`, `checkIns`, `messages`, `timeline`, `health`, `habits`, `media`, `subscription`
162
276
  - Workout tracking: program session logs and ad-hoc workout logs (`sessionLogs.create` with `workoutId`), set logging, per-workout history (`sessionLogs.list({ workoutId })`)
163
- - Progress: `progress.getSummary` / `getAdherence` / `getStreak` (stored streaks), `progression.getPrSummary` / `listPrHistory` (stored PR history) / `getHistory`, `bodyMetrics.list` / `record` / `delete`
277
+ - Progress: `progress.getSummary` / `getAdherence` / `getStreak` (stored streaks) / `getVolume` (last week's hard sets per muscle group), `progression.getPrSummary` / `listPrHistory` (stored PR history) / `getE1rmTrend` (estimated 1-rep max per session, per lift) / `getHistory`, `bodyMetrics.list` / `record` / `update` / `delete`
278
+ - Coach feedback on logged sessions: `sessionLogs.getFeedback` / `markFeedbackSeen` / `unseenFeedbackCount`
279
+ - Check-ins: `checkIns.current` / `submit` / `update` / `list` / `get` / `markReplySeen` / `requestPhotoUpload` (weekly or fortnightly check-ins a coach reviews; needs a plan with `coaching_checkins`)
280
+ - Messages: `messages.list` / `send` / `requestAttachmentUpload` / `markRead` / `delete` / `unreadCount` / `realtimeToken` (the trainee's conversation with their coaches; starting one needs a plan with `coaching_messages`, answering a coach does not; `requestAttachmentUpload` reserves a photo to send with a message)
281
+ - Live chat: `subscribeToMessages` (joins the trainee's private Realtime channel with `messages.realtimeToken`, renews the token, and says when to poll instead; see [Live chat](#live-chat))
164
282
  - Automatic header injection (`X-Fytkit-Client-Id`, `Authorization: Bearer …`)
165
283
  - Proactive refresh ~60 s before token expiry (retried with backoff when offline) + reactive 401 retry; only a server-rejected refresh signs out
166
284
  - Pluggable token storage (`localStorage` default, in-memory + cookie for SSR)