@proveanything/smartlinks 2.0.68 → 2.0.70

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.
@@ -1,4 +1,4 @@
1
- import type { AuthLoginResponse, AppleLoginOptions, RefreshResponse, LogoutResponse, PhoneSendCodeResponse, PhoneVerifyResponse, PasswordResetRequestResponse, VerifyResetTokenResponse, PasswordResetCompleteResponse, EmailVerificationActionResponse, EmailVerifyTokenResponse, AuthKitConfig, AuthKitConfigInput, AuthKitSetIdentityResponse, MagicLinkSendResponse, MagicLinkVerifyResponse, UserProfile, UpdateProfileResponse, ProfileUpdateData, SuccessResponse, SendWhatsAppRequest, SendWhatsAppResponse, ExchangeWhatsAppSessionResponse, VerifyWhatsAppResponse, WhatsAppStatusResponse, SendSmsVerifyRequest, SendSmsVerifyResponse, VerifySmsResponse, UpsertContactRequest, UpsertContactResponse, MfaChallengeSendResponse, MfaFinalizeResponse, MfaEnrollSendResponse, MfaEnrolledResponse, MfaFactorsResponse, TrustedDevice } from "../types/authKit.js";
1
+ import type { AuthLoginResponse, AppleLoginOptions, RefreshResponse, LogoutResponse, PhoneSendCodeResponse, PhoneVerifyResponse, PasswordResetRequestResponse, VerifyResetTokenResponse, PasswordResetCompleteResponse, EmailVerificationActionResponse, EmailVerifyTokenResponse, AuthKitConfig, AuthKitConfigInput, AuthKitSetIdentityResponse, AuthKitInviteRequest, AuthKitInviteResponse, MagicLinkSendResponse, MagicLinkVerifyResponse, UserProfile, UpdateProfileResponse, ProfileUpdateData, SuccessResponse, SendWhatsAppRequest, SendWhatsAppResponse, ExchangeWhatsAppSessionResponse, VerifyWhatsAppResponse, WhatsAppStatusResponse, SendSmsVerifyRequest, SendSmsVerifyResponse, VerifySmsResponse, UpsertContactRequest, UpsertContactResponse, MfaChallengeSendResponse, MfaFinalizeResponse, MfaEnrollSendResponse, MfaTotpEnrollResponse, MfaEnrolledResponse, MfaFactor, MfaFactorsResponse, TrustedDevice } from "../types/authKit.js";
2
2
  /**
3
3
  * Namespace containing helper functions for the new AuthKit API.
4
4
  * Legacy collection-based authKit helpers retained (marked as *Legacy*).
@@ -208,8 +208,11 @@ export declare namespace authKit {
208
208
  function verifyEmailChange(clientId: string, token: string): Promise<SuccessResponse>;
209
209
  function updatePhone(clientId: string, phoneNumber: string, verificationCode: string): Promise<SuccessResponse>;
210
210
  function deleteAccount(clientId: string, password: string, confirmText: string): Promise<SuccessResponse>;
211
- /** Send (or resend) an MFA challenge code to the given factor (public). */
212
- function mfaChallengeSend(clientId: string, mfaSessionToken: string, factor: 'email' | 'sms'): Promise<MfaChallengeSendResponse>;
211
+ /**
212
+ * Send (or resend) an MFA challenge code to the given factor (public). For `totp` nothing is sent —
213
+ * it tells the server the code will come from their authenticator app; then {@link mfaChallengeVerify}.
214
+ */
215
+ function mfaChallengeSend(clientId: string, mfaSessionToken: string, factor: MfaFactor): Promise<MfaChallengeSendResponse>;
213
216
  /**
214
217
  * Verify an MFA challenge code and finalize the login (public). On success this behaves
215
218
  * like {@link login} — the bearer token is adopted and the cache invalidated.
@@ -233,6 +236,15 @@ export declare namespace authKit {
233
236
  function enrollSmsMfa(clientId: string, phoneNumber: string): Promise<MfaEnrollSendResponse>;
234
237
  /** Confirm SMS-factor enrollment with the code sent by {@link enrollSmsMfa} (authenticated). */
235
238
  function confirmSmsMfa(clientId: string, mfaSessionToken: string, code: string): Promise<MfaEnrolledResponse>;
239
+ /**
240
+ * Begin authenticator-app enrollment (authenticated): a new secret for Google Authenticator,
241
+ * Microsoft Authenticator, 1Password and the like. Show `otpauthUri` as a QR code (and `secret` for
242
+ * typing in), then {@link confirmTotpMfa} with the code the app shows. Nothing is kept until then;
243
+ * setting up again replaces the previous app once confirmed.
244
+ */
245
+ function enrollTotpMfa(clientId: string): Promise<MfaTotpEnrollResponse>;
246
+ /** Confirm authenticator-app enrollment with the 6-digit code the app shows (authenticated). */
247
+ function confirmTotpMfa(clientId: string, mfaSessionToken: string, code: string): Promise<MfaEnrolledResponse>;
236
248
  /**
237
249
  * Generate a fresh set of recovery codes, invalidating any previous set (authenticated).
238
250
  * Returned **in plaintext, exactly once** — nothing else in the API ever returns them
@@ -242,7 +254,7 @@ export declare namespace authKit {
242
254
  recoveryCodes: string[];
243
255
  }>;
244
256
  /** Remove an enrolled MFA factor (authenticated). Server route is DELETE with a body. */
245
- function removeMfaFactor(clientId: string, factor: 'email' | 'sms', password: string): Promise<SuccessResponse>;
257
+ function removeMfaFactor(clientId: string, factor: MfaFactor, password: string): Promise<SuccessResponse>;
246
258
  /** List devices trusted to skip MFA challenges for the current user (authenticated). */
247
259
  function listTrustedDevices(clientId: string): Promise<{
248
260
  devices: TrustedDevice[];
@@ -281,5 +293,17 @@ export declare namespace authKit {
281
293
  function setIdentity(collectionId: string, authKitId: string, body: {
282
294
  mode: 'own';
283
295
  }): Promise<AuthKitSetIdentityResponse>;
296
+ /**
297
+ * Invite someone to be a member of a site with its own logins (admin). Makes their account if they have
298
+ * none (the only way in when the site is invitation only — `signUp: 'invite'`), finds or creates their
299
+ * contact in this collection with the `customFields` you pass (their level), links the two, and emails
300
+ * them a sign-in link to the site (`redirectUrl`, default the kit's) that works once for 7 days. The page
301
+ * it opens handles it like any magic link: `mode=magicLink&token=…` → {@link verifyMagicLink}.
302
+ * Inviting someone again sends a fresh link.
303
+ *
304
+ * Errors: `OWN_LOGINS_REQUIRED` (409), `INVALID_EMAIL` (400), `INVALID_REDIRECT_URL` (400, not one of
305
+ * this site's addresses), `TOO_MANY_INVITES` (429, 5 an hour per address).
306
+ */
307
+ function invite(collectionId: string, authKitId: string, body: AuthKitInviteRequest): Promise<AuthKitInviteResponse>;
284
308
  function remove(collectionId: string, authKitId: string): Promise<void>;
285
309
  }
@@ -380,7 +380,10 @@ export var authKit;
380
380
  * challenge/send can be called again on the same token (while unexpired/unburned) to
381
381
  * switch factors or resend.
382
382
  * =================================== */
383
- /** Send (or resend) an MFA challenge code to the given factor (public). */
383
+ /**
384
+ * Send (or resend) an MFA challenge code to the given factor (public). For `totp` nothing is sent —
385
+ * it tells the server the code will come from their authenticator app; then {@link mfaChallengeVerify}.
386
+ */
384
387
  async function mfaChallengeSend(clientId, mfaSessionToken, factor) {
385
388
  return post(`/authkit/${encodeURIComponent(clientId)}/mfa/challenge/send`, { mfaSessionToken, factor });
386
389
  }
@@ -442,6 +445,21 @@ export var authKit;
442
445
  return post(`/authkit/${encodeURIComponent(clientId)}/mfa/factors/sms/confirm`, { mfaSessionToken, code });
443
446
  }
444
447
  authKit.confirmSmsMfa = confirmSmsMfa;
448
+ /**
449
+ * Begin authenticator-app enrollment (authenticated): a new secret for Google Authenticator,
450
+ * Microsoft Authenticator, 1Password and the like. Show `otpauthUri` as a QR code (and `secret` for
451
+ * typing in), then {@link confirmTotpMfa} with the code the app shows. Nothing is kept until then;
452
+ * setting up again replaces the previous app once confirmed.
453
+ */
454
+ async function enrollTotpMfa(clientId) {
455
+ return post(`/authkit/${encodeURIComponent(clientId)}/mfa/factors/totp/enroll`, {});
456
+ }
457
+ authKit.enrollTotpMfa = enrollTotpMfa;
458
+ /** Confirm authenticator-app enrollment with the 6-digit code the app shows (authenticated). */
459
+ async function confirmTotpMfa(clientId, mfaSessionToken, code) {
460
+ return post(`/authkit/${encodeURIComponent(clientId)}/mfa/factors/totp/confirm`, { mfaSessionToken, code });
461
+ }
462
+ authKit.confirmTotpMfa = confirmTotpMfa;
445
463
  /**
446
464
  * Generate a fresh set of recovery codes, invalidating any previous set (authenticated).
447
465
  * Returned **in plaintext, exactly once** — nothing else in the API ever returns them
@@ -525,6 +543,22 @@ export var authKit;
525
543
  return post(path, body);
526
544
  }
527
545
  authKit.setIdentity = setIdentity;
546
+ /**
547
+ * Invite someone to be a member of a site with its own logins (admin). Makes their account if they have
548
+ * none (the only way in when the site is invitation only — `signUp: 'invite'`), finds or creates their
549
+ * contact in this collection with the `customFields` you pass (their level), links the two, and emails
550
+ * them a sign-in link to the site (`redirectUrl`, default the kit's) that works once for 7 days. The page
551
+ * it opens handles it like any magic link: `mode=magicLink&token=…` → {@link verifyMagicLink}.
552
+ * Inviting someone again sends a fresh link.
553
+ *
554
+ * Errors: `OWN_LOGINS_REQUIRED` (409), `INVALID_EMAIL` (400), `INVALID_REDIRECT_URL` (400, not one of
555
+ * this site's addresses), `TOO_MANY_INVITES` (429, 5 an hour per address).
556
+ */
557
+ async function invite(collectionId, authKitId, body) {
558
+ const path = `/admin/collection/${encodeURIComponent(collectionId)}/authKit/${encodeURIComponent(authKitId)}/invite`;
559
+ return post(path, body);
560
+ }
561
+ authKit.invite = invite;
528
562
  async function remove(collectionId, authKitId) {
529
563
  const path = `/admin/collection/${encodeURIComponent(collectionId)}/authKit/${encodeURIComponent(authKitId)}`;
530
564
  return del(path);
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.0.68 | Generated: 2026-10-17T10:04:22.637Z
3
+ Version: 2.0.70 | Generated: 2026-10-24T08:16:51.896Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -2808,7 +2808,7 @@ interface ServerFunctionSl {
2808
2808
  tags: { resolve(id: string): Promise<ResolvedTag | null> };
2809
2809
  contacts: {
2810
2810
  upsert(details: ContactUpsertDetails): Promise<{ contactId: string; created: boolean }>;
2811
- forCaller(): Promise<{ contactId: string } | null>;
2811
+ forCaller(): Promise<CallerContact | null>;
2812
2812
  };
2813
2813
  interactions: { record(event: ServerFunctionInteractionEvent): Promise<{ eventId: string }> };
2814
2814
  tools: { run<T = any>(name: string, args?: Record<string, any>): Promise<T> };
@@ -2830,6 +2830,20 @@ interface ServerFunctionCaller {
2830
2830
  }
2831
2831
  ```
2832
2832
 
2833
+ **CallerContact** (interface)
2834
+ ```typescript
2835
+ interface CallerContact {
2836
+ contactId: string;
2837
+ email: string | null;
2838
+ phone: string | null;
2839
+ firstName: string | null;
2840
+ lastName: string | null;
2841
+ displayName: string | null;
2842
+ tags: string[];
2843
+ customFields: Record<string, unknown>;
2844
+ }
2845
+ ```
2846
+
2833
2847
  **ServerFunctionContext** (interface)
2834
2848
  ```typescript
2835
2849
  interface ServerFunctionContext {
@@ -4565,9 +4579,9 @@ interface AppleLoginOptions {
4565
4579
  ```typescript
4566
4580
  interface MfaRequiredDetails {
4567
4581
  mfaSessionToken: string
4568
- availableFactors: Array<'email' | 'sms'>
4569
- preferredFactor: 'email' | 'sms'
4570
- maskedDestinations: { email?: string; sms?: string }
4582
+ availableFactors: MfaFactor[]
4583
+ preferredFactor: MfaFactor
4584
+ maskedDestinations: { email?: string; sms?: string; totp?: string }
4571
4585
  }
4572
4586
  ```
4573
4587
 
@@ -4575,7 +4589,7 @@ interface MfaRequiredDetails {
4575
4589
  ```typescript
4576
4590
  interface MfaChallengeSendResponse {
4577
4591
  success: true
4578
- factor: 'email' | 'sms'
4592
+ factor: MfaFactor
4579
4593
  destination: string
4580
4594
  expiresAt: string
4581
4595
  }
@@ -4590,11 +4604,23 @@ interface MfaEnrollSendResponse {
4590
4604
  }
4591
4605
  ```
4592
4606
 
4607
+ **MfaTotpEnrollResponse** (interface)
4608
+ ```typescript
4609
+ interface MfaTotpEnrollResponse {
4610
+ mfaSessionToken: string
4611
+ secret: string
4612
+ otpauthUri: string
4613
+ issuer: string
4614
+ account: string
4615
+ expiresAt: string
4616
+ }
4617
+ ```
4618
+
4593
4619
  **MfaEnrolledResponse** (interface)
4594
4620
  ```typescript
4595
4621
  interface MfaEnrolledResponse {
4596
4622
  enrolled: true
4597
- factor: 'email' | 'sms'
4623
+ factor: MfaFactor
4598
4624
  }
4599
4625
  ```
4600
4626
 
@@ -4604,6 +4630,7 @@ interface MfaFactorsResponse {
4604
4630
  enrolledFactors: {
4605
4631
  email?: { enrolledAt: string; destination: string }
4606
4632
  sms?: { enrolledAt: string; destination: string }
4633
+ totp?: { enrolledAt: string; destination: string }
4607
4634
  }
4608
4635
  recoveryCodesRemaining: number
4609
4636
  mfaEnabledForClient: boolean
@@ -4866,6 +4893,30 @@ interface AuthKitConfig {
4866
4893
  security?: AuthKitSecurityConfig
4867
4894
  collectionIds?: string[]
4868
4895
  identity?: AuthKitIdentity
4896
+ signUp?: 'open' | 'invite'
4897
+ }
4898
+ ```
4899
+
4900
+ **AuthKitInviteRequest** (interface)
4901
+ ```typescript
4902
+ interface AuthKitInviteRequest {
4903
+ email: string
4904
+ firstName?: string
4905
+ lastName?: string
4906
+ displayName?: string
4907
+ customFields?: Record<string, unknown>
4908
+ redirectUrl?: string
4909
+ }
4910
+ ```
4911
+
4912
+ **AuthKitInviteResponse** (interface)
4913
+ ```typescript
4914
+ interface AuthKitInviteResponse {
4915
+ ok: true
4916
+ userId: string
4917
+ contactId: string
4918
+ created: boolean
4919
+ expiresAt: string
4869
4920
  }
4870
4921
  ```
4871
4922
 
@@ -4934,6 +4985,8 @@ interface AuthKitLockoutPolicy {
4934
4985
 
4935
4986
  **AuthKitErrorCode** = ``
4936
4987
 
4988
+ **MfaFactor** = `'email' | 'sms' | 'totp'`
4989
+
4937
4990
  **MfaErrorCode** = ``
4938
4991
 
4939
4992
  **VerifyStatus** = `'pending' | 'verified' | 'failed' | 'expired' | 'unknown'`
@@ -11291,8 +11344,8 @@ Update the authenticated user's profile and replace the bearer token when refres
11291
11344
  **deleteAccount**(clientId: string, password: string, confirmText: string) → `Promise<SuccessResponse>`
11292
11345
  Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
11293
11346
 
11294
- **mfaChallengeSend**(clientId: string, mfaSessionToken: string, factor: 'email' | 'sms') → `Promise<MfaChallengeSendResponse>`
11295
- Send (or resend) an MFA challenge code to the given factor (public).
11347
+ **mfaChallengeSend**(clientId: string, mfaSessionToken: string, factor: MfaFactor) → `Promise<MfaChallengeSendResponse>`
11348
+ Send (or resend) an MFA challenge code to the given factor (public). For `totp` nothing is sent — it tells the server the code will come from their authenticator app; then {@link mfaChallengeVerify}.
11296
11349
 
11297
11350
  **mfaChallengeVerify**(clientId: string, mfaSessionToken: string, code: string, trustDevice?: boolean, deviceLabel?: string) → `Promise<MfaFinalizeResponse>`
11298
11351
  Verify an MFA challenge code and finalize the login (public). On success this behaves like {@link login} — the bearer token is adopted and the cache invalidated. it and pass it to future {@link login} calls to skip the challenge on this device.
@@ -11315,10 +11368,16 @@ Begin SMS-factor enrollment; sends a code to the given phone number (authenticat
11315
11368
  **confirmSmsMfa**(clientId: string, mfaSessionToken: string, code: string) → `Promise<MfaEnrolledResponse>`
11316
11369
  Confirm SMS-factor enrollment with the code sent by {@link enrollSmsMfa} (authenticated).
11317
11370
 
11371
+ **enrollTotpMfa**(clientId: string) → `Promise<MfaTotpEnrollResponse>`
11372
+ Begin authenticator-app enrollment (authenticated): a new secret for Google Authenticator, Microsoft Authenticator, 1Password and the like. Show `otpauthUri` as a QR code (and `secret` for typing in), then {@link confirmTotpMfa} with the code the app shows. Nothing is kept until then; setting up again replaces the previous app once confirmed.
11373
+
11374
+ **confirmTotpMfa**(clientId: string, mfaSessionToken: string, code: string) → `Promise<MfaEnrolledResponse>`
11375
+ Confirm authenticator-app enrollment with the 6-digit code the app shows (authenticated).
11376
+
11318
11377
  **generateMfaRecoveryCodes**(clientId: string, password: string) → `Promise<`
11319
11378
  Generate a fresh set of recovery codes, invalidating any previous set (authenticated). Returned **in plaintext, exactly once** — nothing else in the API ever returns them again, so the caller must display/export them immediately.
11320
11379
 
11321
- **removeMfaFactor**(clientId: string, factor: 'email' | 'sms', password: string) → `Promise<SuccessResponse>`
11380
+ **removeMfaFactor**(clientId: string, factor: MfaFactor, password: string) → `Promise<SuccessResponse>`
11322
11381
  Remove an enrolled MFA factor (authenticated). Server route is DELETE with a body.
11323
11382
 
11324
11383
  **listTrustedDevices**(clientId: string) → `Promise<`
@@ -11345,8 +11404,11 @@ Update an AuthKit client config (admin). This is how the account **security poli
11345
11404
  **setIdentity**(collectionId: string, authKitId: string, body: { mode: 'own' }) → `Promise<AuthKitSetIdentityResponse>`
11346
11405
  Give this AuthKit its **own logins** (admin): its members become accounts of this site only — the same email can be a separate account, with its own password, on another site — rather than SmartLinks accounts shared across sites. One way: a site with its own members can't go back to the shared pool. Members who signed up before keep their SmartLinks account until moved across. Errors: `MEMBER_POOL_CONFLICT` (409) when another AuthKit already gives one of its collections its own logins (one per collection); `PERMISSION_DENIED` (403) unless you administer every collection it serves.
11347
11406
 
11407
+ **invite**(collectionId: string, authKitId: string, body: AuthKitInviteRequest) → `Promise<AuthKitInviteResponse>`
11408
+ Invite someone to be a member of a site with its own logins (admin). Makes their account if they have none (the only way in when the site is invitation only — `signUp: 'invite'`), finds or creates their contact in this collection with the `customFields` you pass (their level), links the two, and emails them a sign-in link to the site (`redirectUrl`, default the kit's) that works once for 7 days. The page it opens handles it like any magic link: `mode=magicLink&token=…` → {@link verifyMagicLink}. Inviting someone again sends a fresh link. Errors: `OWN_LOGINS_REQUIRED` (409), `INVALID_EMAIL` (400), `INVALID_REDIRECT_URL` (400, not one of this site's addresses), `TOO_MANY_INVITES` (429, 5 an hour per address).
11409
+
11348
11410
  **remove**(collectionId: string, authKitId: string) → `Promise<void>`
11349
- Give this AuthKit its **own logins** (admin): its members become accounts of this site only — the same email can be a separate account, with its own password, on another site — rather than SmartLinks accounts shared across sites. One way: a site with its own members can't go back to the shared pool. Members who signed up before keep their SmartLinks account until moved across. Errors: `MEMBER_POOL_CONFLICT` (409) when another AuthKit already gives one of its collections its own logins (one per collection); `PERMISSION_DENIED` (403) unless you administer every collection it serves.
11411
+ Invite someone to be a member of a site with its own logins (admin). Makes their account if they have none (the only way in when the site is invitation only — `signUp: 'invite'`), finds or creates their contact in this collection with the `customFields` you pass (their level), links the two, and emails them a sign-in link to the site (`redirectUrl`, default the kit's) that works once for 7 days. The page it opens handles it like any magic link: `mode=magicLink&token=…` → {@link verifyMagicLink}. Inviting someone again sends a fresh link. Errors: `OWN_LOGINS_REQUIRED` (409), `INVALID_EMAIL` (400), `INVALID_REDIRECT_URL` (400, not one of this site's addresses), `TOO_MANY_INVITES` (429, 5 an hour per address).
11350
11412
 
11351
11413
  ### batch
11352
11414
 
@@ -305,11 +305,16 @@ clearPersistedTokens();
305
305
 
306
306
  ---
307
307
 
308
- ## Step-up MFA (Phase 1)
308
+ ## Step-up MFA
309
309
 
310
- Backend-only for now — **no admin-console UI and no challenge UI ship with this pass.**
311
- Factors: **email OTP, SMS OTP, recovery codes**. WhatsApp OTP, TOTP, and passkeys as
312
- *factors* are deferred to a later phase; don't build against them yet.
310
+ Factors: **email code, SMS code, authenticator app (`totp`), recovery codes**. The shared login UI
311
+ (`@proveanything/smartlinks-auth-ui`) has the challenge and the Settings → Security screens. WhatsApp
312
+ codes and passkeys are planned; don't build against them yet.
313
+
314
+ **Authenticator apps** (Google Authenticator, Microsoft Authenticator, 1Password, Authy…) are the
315
+ recommended second step: nothing is sent (no SMS cost, nothing to intercept), and they are offered first
316
+ when someone has one. A client can switch any factor off with `mfa.factorsEnabled` (e.g.
317
+ `{ totp: false }`).
313
318
 
314
319
  The step-up gate applies to **every login method that issues a session**:
315
320
 
@@ -408,15 +413,25 @@ await authKit.confirmEmailMfa(clientId, mfaSessionToken, code);
408
413
  const { mfaSessionToken: smsToken } = await authKit.enrollSmsMfa(clientId, '+61400000000');
409
414
  await authKit.confirmSmsMfa(clientId, smsToken, code);
410
415
 
416
+ // Enroll an authenticator app: show otpauthUri as a QR code (and secret for typing in),
417
+ // then confirm with the 6-digit code the app shows
418
+ const { mfaSessionToken: appToken, otpauthUri, secret } = await authKit.enrollTotpMfa(clientId);
419
+ await authKit.confirmTotpMfa(clientId, appToken, codeFromApp);
420
+
411
421
  // Recovery codes — plaintext, shown exactly once; nothing else in the API returns them again
412
422
  const { recoveryCodes } = await authKit.generateMfaRecoveryCodes(clientId, password);
413
423
 
414
424
  // Inspect / remove
415
425
  const factors = await authKit.getMfaFactors(clientId);
416
- // factors: { enrolledFactors: { email?, sms? }, recoveryCodesRemaining, mfaEnabledForClient }
426
+ // factors: { enrolledFactors: { email?, sms?, totp? }, recoveryCodesRemaining, mfaEnabledForClient }
417
427
  await authKit.removeMfaFactor(clientId, 'sms', password);
418
428
  ```
419
429
 
430
+ At sign-in, `MFA_REQUIRED` lists `availableFactors` with `preferredFactor` (`totp`, then `sms`, then
431
+ `email`). For `totp`, `mfaChallengeSend(clientId, token, 'totp')` sends nothing; it just says the code
432
+ comes from the app. Then call `mfaChallengeVerify` with that code, as for the others. Each authenticator
433
+ code works once. The secret is sealed on the server and never returned after `enrollTotpMfa`.
434
+
420
435
  > Enrollment endpoints don't currently check the client's `mfa.mode` — enrolling is possible
421
436
  > even when MFA is configured `'off'` for a client. Those factors simply won't be challenged
422
437
  > at login until an admin turns the mode on (via the existing admin AuthKit config update,
@@ -633,6 +648,31 @@ await authKit.setIdentity(collectionId, authKitId, { mode: 'own' })
633
648
  resets) works the same way; nothing changes in the login UI or your code.
634
649
  - `identity` is read-only on `create` / `update`; it is shown on the config (`authKit.get`).
635
650
 
651
+ ### Invitation only
652
+
653
+ A site with its own logins can let in only the people it invites:
654
+
655
+ ```ts
656
+ await authKit.update(collectionId, authKitId, { signUp: 'invite' }) // 'open' (default) | 'invite'
657
+ await authKit.invite(collectionId, authKitId, { email: 'jo@example.com', firstName: 'Jo', customFields: { level: 'member' } })
658
+ ```
659
+
660
+ - **Enforced on the server, for every route.** Nobody without an account can get one: `register`,
661
+ `verifyMagicLink`, `verifyPhoneCode`, Google, Apple, LinkedIn and WhatsApp answer `SIGN_UP_CLOSED`
662
+ (403). `sendMagicLink` and `sendPhoneCode` answer as if sent, but send nothing to someone who isn't a
663
+ member, so nobody can find out who is.
664
+ - **`invite`** makes the person's account if they have none, finds or creates their contact in that
665
+ collection with the fields you pass, links the two, and emails a sign-in link to the site (`redirectUrl`,
666
+ default the kit's) that works once for 7 days. It lands like any magic link (`mode=magicLink&token=…`).
667
+ Inviting someone again sends a fresh link. The link is never returned to the admin.
668
+ - **Invited members then sign in any way the site offers.** A magic link, "forgot password" to set a
669
+ password, or Google/Apple/LinkedIn with the same verified email: an invited account nobody has signed
670
+ in to yet can be claimed that way.
671
+ - `signUp` is on the public config (`load`): hide "Create an account" when it's `invite`. Setting it
672
+ needs own logins first (`OWN_LOGINS_REQUIRED`, 409).
673
+ - On any site, a member signing in with a verified email or phone is attached to the contact the
674
+ collection already has for it, so fields the staff set beforehand (a level) are theirs.
675
+
636
676
  ## Relationship to other parts of the SDK
637
677
 
638
678
  | Concern | Where it lives |
@@ -646,5 +686,6 @@ await authKit.setIdentity(collectionId, authKitId, { mode: 'own' })
646
686
 
647
687
  ## Further reading
648
688
 
689
+ - [members.md](members.md) — members with a level: clubs, leagues, members-only content
649
690
  - [app-data-storage.md](app-data-storage.md) — storing user-specific data after login
650
691
  - [app-manifest.md](app-manifest.md) — `app.admin.json` setup questions for configuring `clientId`
@@ -94,6 +94,15 @@ Env: `SMARTLINKS_APP_ID`, `SMARTLINKS_DEPLOY_KEY` (dev key — dev-only, safe to
94
94
  optional `SMARTLINKS_API` / `--dir`. It POSTs the files to `POST /apps/{appId}/publish?channel=dev`;
95
95
  the server validates and writes them to `smartlinks.app/apps/{appId}/dev/…` (`no-store`).
96
96
 
97
+ - **Big bundles** go in batches (`/publish/files`), and any file of 4 MB or more goes straight to
98
+ storage through a one-off upload URL (`/publish/uploads`), so no file is too large to publish. The
99
+ final `/publish` call registers the release only once every file has landed.
100
+ - **Source maps (`.map`) are never published.** A release is promoted to beta and stable byte for byte,
101
+ so a map would serve the app's original source to anyone; the server drops any it's sent. Debug in
102
+ Forge's preview, where the dev server has full source. In Forge, **Release to dev** builds readable
103
+ (unminified) code; **Release to beta / stable** builds minified code (the build sees
104
+ `SMARTLINKS_BUILD=debug` or `production`).
105
+
97
106
  **Which to use:** building in Lovable → **A** (ping, no key). Building anywhere you control →
98
107
  **B** (push with a local key). For beta/stable, see the register-a-URL flow below.
99
108
 
@@ -0,0 +1,182 @@
1
+ # Members: signed-in people with a level
2
+
3
+ A club, a league, a wine club, a members' area. SmartLinks has **no separate "membership" object**,
4
+ and you don't need one:
5
+
6
+ | Piece | Where it lives |
7
+ |---|---|
8
+ | A member's login | The **auth kit**: the site's own logins (auth-kit.md → "Own logins") |
9
+ | The member | Their **contact** (one per person, created when they sign in or fill in a form) |
10
+ | What they are (gold, silver, junior, pending) | A **contact custom field**, e.g. `customFields.level` |
11
+ | Who may see or change it | The contact field's **public** setting: hidden, visible or editable |
12
+ | Members-only content | A **server function** that checks the level, then returns the content |
13
+ | Managing members | **Studio → Customers**: find people, set their level, email a segment |
14
+
15
+ **The contract: whoever manages membership writes the member's `level` field.** That can be the owner
16
+ by hand in Studio, a memberships app after a payment, or a connector syncing a club's existing system.
17
+ The site only ever reads the field, so the way people become members can change without rebuilding
18
+ the site.
19
+
20
+ ---
21
+
22
+ ## Questions to settle first
23
+
24
+ Ask these before building, because they change what you build:
25
+
26
+ 1. **Who can join?** Anyone; anyone, but the club confirms them; or **only people the club invites**
27
+ (no sign-up at all)?
28
+ 2. **Who sets the level?** The owner (people sign up, then the owner confirms them), or the person
29
+ themselves? Let people choose only a level that unlocks nothing, like "junior / adult".
30
+ 3. **What does each level get?** Pages, results, discounts, early access, booking.
31
+ 4. **Fees?** If the club takes payment by hand, the owner sets the level once they've been paid. Paying
32
+ on the site is a memberships app's job. Never take card details in a site.
33
+ 5. **Do members see each other?** A members' directory needs consent: a "show me in the directory"
34
+ field the member sets themselves.
35
+
36
+ ---
37
+
38
+ ## 1. Sign-in: the site's own logins
39
+
40
+ - A site's members sign in through its auth kit. The collection's kit is `collection.defaultAuthKitId`
41
+ (`SL.collection.get(collectionId)`).
42
+ - A club should have **its own logins**, so its members are accounts of this site only. In Forge: the
43
+ Site tab → Members → "Set up member sign-in" (or "Use my own members" on a site that has the shared
44
+ login). That creates the kit if needed, makes it the site's, and switches it to its own members.
45
+ - Use the flows in auth-kit.md: register, login, magic link, Google, password reset, email verification.
46
+ Never build a login of your own, and never store passwords.
47
+ - On sign-in, the SDK keeps the member's session (`setBearerToken`). Persist `session.token`
48
+ (respect `rememberMe`), restore it on load with `SL.setBearerToken(token)`, and clear it on sign-out.
49
+
50
+ By default anyone can create a login, and **being a member is the level, not the login**: an approval
51
+ club is just "signed in, no level yet".
52
+
53
+ **Invitation only.** A site with its own logins can turn sign-up off: `SL.authKit.update(collectionId,
54
+ authKitId, { signUp: 'invite' })` (or the owner, in the Customer Login app: Members → Who can join). Then nobody can
55
+ create an account by any route: password, magic link, phone, Google and the rest refuse with
56
+ `SIGN_UP_CLOSED`, and "email me a link" answers the same for strangers without sending anything. The
57
+ site's admins invite people instead:
58
+
59
+ ```ts
60
+ // admin session (the site's admin page, Studio): their account, their contact with a level, and an email
61
+ await SL.authKit.invite(collectionId, authKitId, {
62
+ email: "jo@example.com", firstName: "Jo",
63
+ customFields: { level: "member" },
64
+ redirectUrl: "https://club.example.com/sign-in", // the site's sign-in page (one of its own addresses)
65
+ })
66
+ ```
67
+
68
+ The email's link works once for 7 days and opens the sign-in page with `mode=magicLink&token=…`: handle
69
+ it with `SL.authKit.verifyMagicLink(clientId, token)`, as for any magic link. After that the member
70
+ signs in however the site offers (a magic link to their email, "forgot password" to set one, Google
71
+ with the same email). Build the sign-in page with no "Create an account" option when the kit's
72
+ `signUp` is `invite` (it's on the public config, `SL.authKit.load(authKitId)`), and say "Members only.
73
+ Ask the club for an invitation."
74
+
75
+ **Added by the staff first?** When someone signs in with a verified email or phone, their login is
76
+ attached to the contact the site already has for it: an invite, someone the staff added in Studio, a
77
+ form they filled in. So the level set on that contact is theirs from the first sign-in.
78
+
79
+ ## 2. The level field
80
+
81
+ In a **website**, contact fields are part of its cloud blueprint (`cloud/contacts.json`, applied with
82
+ `forge-cloud`):
83
+
84
+ ```json
85
+ {
86
+ "fields": {
87
+ "level": { "title": "Membership", "type": "string", "enum": ["pending", "member", "gold"], "public": "visible" },
88
+ "memberUntil": { "title": "Member until", "type": "date", "public": "visible" },
89
+ "showInDirectory": { "title": "Show me in the members' list", "type": "boolean", "public": "editable" }
90
+ }
91
+ }
92
+ ```
93
+
94
+ | `public` | Member can see it | Member can change it | Use for |
95
+ |---|---|---|---|
96
+ | `hidden` (default) | no | no | Staff notes, a paid-until the member shouldn't see |
97
+ | `visible` | yes | **no** | **The level**, an expiry date |
98
+ | `editable` | yes | yes | Preferences and consent the member owns |
99
+
100
+ A member's own reads and writes (`SL.contact.publicGetMine`, `SL.contact.publicUpdateMine`) and public
101
+ forms follow these settings on the server, so a `visible` level can't be changed from the browser.
102
+
103
+ Outside a website (a plain app), the same settings live in the collection's `contactCustomFields`
104
+ (field definitions, a SchemaFormConfig) and `contacts` settings groups (`publicVisibleFields`,
105
+ `publicEditableFields`, paths like `customFields.level`). Those lists replace what's stored: read
106
+ them, add to them, and write back the whole list, defaults included. Prefer letting the owner set
107
+ fields up in Studio → Customers.
108
+
109
+ ## 3. Showing members their status
110
+
111
+ ```ts
112
+ const me = await SL.contact.publicGetMine(collectionId) // needs the member's session
113
+ // me.contact?.customFields?.level → "gold"; only fields the member may see are returned
114
+ ```
115
+
116
+ Use it for the "My membership" page and to decide what to **show**. It is not a lock: anything
117
+ members-only must come from a server function.
118
+
119
+ ## 4. Members-only content: check in a server function
120
+
121
+ ```ts
122
+ // visibility: public, authority: collection, elevated: true
123
+ // capabilities: sl:contacts:write (forCaller), sl:records:read
124
+ export async function memberNews(ctx, event) {
125
+ const me = await ctx.sl.contacts.forCaller() // null when signed out
126
+ if (!me) return { ok: false, reason: "sign-in" }
127
+ const level = me.customFields.level
128
+ if (level !== "member" && level !== "gold") return { ok: false, reason: "not-a-member" }
129
+ const until = me.customFields.memberUntil
130
+ if (until && String(until) < new Date().toISOString().slice(0, 10)) return { ok: false, reason: "expired" }
131
+ const news = await ctx.sl.appRecords.query({ recordType: "member-news" })
132
+ return { ok: true, level, news } // never return me.contactId or staff-only fields
133
+ }
134
+ ```
135
+
136
+ - `forCaller()` returns the member's contact with its tags and **all** custom fields, staff-only ones
137
+ included. Decide with them and return only what the member may see.
138
+ - A site's `callFunction` sends the member's session with the call. Without it, the function sees an
139
+ anonymous caller. Check that `src/site/lib/call-function.ts` adds `authorization: Bearer <token>`
140
+ from `sl().getBearerToken()`; older sites may need it added.
141
+ - Do the same check in every function that acts for members: booking a court, entering a result,
142
+ claiming a discount.
143
+
144
+ ## 5. Joining rules
145
+
146
+ | Club | How it works |
147
+ |---|---|
148
+ | **Open** (sign up = member) | Signed in is enough; no level needed, or a `level` the site never gates on |
149
+ | **Approval** ("sign up and I'll add you") | Anyone signs up; the owner sets `level` in Studio → Customers; until then the site says "We'll confirm your membership" |
150
+ | **Invite only** (no sign-up) | `signUp: 'invite'`; the owner invites people with their level (`SL.authKit.invite`): the Customer Login app's Members card, or an "Invite a member" screen on the site's admin page; the sign-in page has no sign-up |
151
+ | **Tiers** (silver, gold) | `level` with an `enum`; each gated function lists the levels it allows |
152
+ | **Paid** | By hand: the owner sets `level` (and `memberUntil`) once paid. Online: a memberships app or connector writes the same fields after payment |
153
+
154
+ Record joining as an interaction (`ctx.sl.interactions.record`, e.g. `member-joined`), and a
155
+ `cloud/flows.json` flow can welcome the member and tell the team someone is waiting for approval.
156
+
157
+ ## 6. The owner's side
158
+
159
+ - **Studio → Customers**: everyone who signed up, their level, and when they joined. Filter by level
160
+ and set it on the contact.
161
+ - **Inviting**: the Customer Login app's Members card (Who can join, Invite a member), or the site's own
162
+ admin screen calling `SL.authKit.invite`; inviting someone again sends a fresh link.
163
+ - **Email a level**: a segment on `customFields.level`, sent from Studio (comms.md).
164
+ - **Tags** suit many-valued labels (`committee`, `coach`); a member has one `level`.
165
+
166
+ ## Don't
167
+
168
+ - Keep members in app records, a table of your own, or the site's code. The contact is the member.
169
+ - Trust the browser's idea of the level. Check it in a server function.
170
+ - Let a member set a level that unlocks anything (`public: "editable"`).
171
+ - Return contact ids, other members' details, or staff-only fields to the browser.
172
+ - Build payments into a site. A memberships app or connector owns fees.
173
+ - Hide the sign-up button and call that invitation only. Set `signUp: 'invite'`, so the server refuses.
174
+
175
+ ## Example: a tennis box league
176
+
177
+ - **Players** are members: own logins, `level` = `pending` or `player`, set by the organiser.
178
+ - **Fixtures, results and standings** are a custom app's records.
179
+ - Only a `player` can enter a result: the function checks `forCaller()`, then that the player is in
180
+ that fixture.
181
+ - **Standings** are public; contact details stay hidden.
182
+ - **New player signs up**: the flow emails the organiser, who sets them to `player` in Studio.