@nauticana/sail 1.1.17 → 1.1.18

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
4
4
 
5
- > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
5
+ > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
6
6
 
7
7
  **Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
8
8
 
@@ -328,6 +328,9 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
328
328
  | `POST /public/otp/resend` | Re-issue OTP for an existing `otpToken` |
329
329
  | `POST /public/2fa/verify` | Login-time TOTP verification (uses `loginToken`) |
330
330
  | `POST /public/2fa/backup-verify` | Login-time backup-code verification |
331
+ | `POST /public/token/refresh` | Rotate `{refreshToken}` → new `{token, refreshToken}`; 401 ends the session |
332
+ | `POST /public/logout` | Revoke `{refreshToken}` (called by `BaseAuthService.logout()`) |
333
+ | `GET /public/ws` | keel realtime hub handshake (`RealtimeService`; JWT via `?token=`) |
331
334
  | `GET /api/config/appdata` | Metadata (menus, permissions, table definitions) |
332
335
  | `POST/GET/DELETE /api/{version}/{table}/list\|get\|post\|delete` | CRUD operations (paginated `list`) |
333
336
  | `POST /api/user/2fa/setup` | Generate TOTP secret, QR URI, and backup codes — **requires re-auth** |
@@ -340,6 +343,7 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
340
343
  | `POST /api/push/register` | Register an FCM / APNs token |
341
344
  | `POST /api/push/revoke` | Revoke an FCM / APNs token |
342
345
  | `POST /api/billing/checkout` | Create provider-hosted checkout session — **JWT-gated, allowlist-validated** |
346
+ | `POST /api/billing/setup-intent` | Provider setup intent for on-device card capture (`BillingService.createSetupIntent()`) |
343
347
  | `GET /public/plans` | Subscription plan catalog (unauthenticated) |
344
348
  | `GET /api/billing/subscription` | Active subscription for the partner |
345
349
  | `POST /api/billing/subscription/cancel` | Cancel auto-renew |
@@ -382,7 +386,15 @@ If "trust this device" appears to do nothing (user is 2FA-prompted on every logi
382
386
 
383
387
  ### Session token storage
384
388
 
385
- The session JWT and the short-lived `loginToken` are kept in `localStorage` (the trusted-device secret is the only HttpOnly cookie). This is a deliberate trade-off for a token-based SPA, but it means any XSS in the consuming app can read the session token. Harden accordingly: ship a strict `Content-Security-Policy`, keep third-party script surface minimal, and prefer short JWT lifetimes on the keel side.
389
+ The session JWT, the refresh token, and the short-lived `loginToken` are kept in `localStorage` (the trusted-device secret is the only HttpOnly cookie). This is a deliberate trade-off for a token-based SPA, but it means any XSS in the consuming app can read the session token. Harden accordingly: ship a strict `Content-Security-Policy`, keep third-party script surface minimal, and prefer short JWT lifetimes on the keel side — refresh rotation makes short lifetimes free for the user.
390
+
391
+ ### Refresh-token rotation
392
+
393
+ Every keel login response (keel v1.2.57+) carries `refreshToken` beside `token`; `completeLogin` stores both. When an authenticated call answers 401, `authInterceptor` calls `BaseAuthService.refreshSession()` — one shared `POST /public/token/refresh` per burst — stores the rotated pair and replays the request with the new JWT. A 401 from the refresh itself (revoked, expired, already rotated) clears the session and lands on `login?sessionExpired=true`; any other refresh failure keeps the session and lets the 401 count toward the circuit breaker. `logout()` posts the refresh token to `/public/logout` before clearing local state. `acceptToken(token, refreshToken?)` accepts the pair for handoff flows. No wiring beyond registering `authInterceptor`.
394
+
395
+ ### Realtime
396
+
397
+ `RealtimeService` (root-provided) is the client for keel's `realtime.Hub`. `connect()` opens `RestURL.realtimeURL` (`/public/ws`) on the configured backend host with the stored JWT, reconnects with backoff, and re-subscribes every channel after a reconnect. `channel<T>(name)` returns the payloads keel broadcasts on that channel; `userFrames<T>()` returns payloads addressed to the user; `send(frame)` forwards an application frame to the hub's `OnMessage`. A subscription keel's `CanSubscribe` rejects is logged and exposed on `lastError`. Channel names and payload shapes are app-owned.
386
398
 
387
399
  ## Billing
388
400
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.17",
3
+ "version": "1.1.18",
4
4
  "description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -13,8 +13,8 @@ import { UserPaymentMethod } from '../../model/appdata';
13
13
  * + delete actions. Inherits loading/error state from BaseAsync.
14
14
  *
15
15
  * Customise the heading and "add" CTA via inputs; emit (addClicked) so
16
- * the consumer routes to its own SetupIntent flow (sail does not bundle
17
- * a SetupIntent UI today — providers vary too much).
16
+ * the consumer routes to its own SetupIntent flow: BillingService.createSetupIntent()
17
+ * returns the client secret; the provider payment sheet stays app-owned.
18
18
  *
19
19
  * Ships no CSS — the consuming app styles the classes globally.
20
20
  *
package/src/index.ts CHANGED
@@ -72,6 +72,7 @@ export * from './service/oauth_connection.service';
72
72
  export * from './service/user_payment_method.service';
73
73
  export * from './service/script_loader';
74
74
  export * from './service/auth.interceptor';
75
+ export * from './service/realtime.service';
75
76
  export * from './service/api-response.interceptor';
76
77
  export * from './service/rest_service';
77
78
  export * from './service/rest_url';
@@ -140,8 +140,18 @@ export interface RestReport {
140
140
 
141
141
  // 2FA / Trusted device types
142
142
 
143
+ /** POST /public/token/refresh response; every login response carries the same pair. */
144
+ export interface TokenPair {
145
+ token: string;
146
+ refreshToken: string;
147
+ userId?: number;
148
+ partnerId?: number;
149
+ }
150
+
143
151
  export interface LoginResponse2FA {
144
152
  token: string;
153
+ /** keel v1.2.57+: rotated at /public/token/refresh when the JWT expires. */
154
+ refreshToken?: string;
145
155
  twoFactorRequired: boolean;
146
156
  loginToken?: string;
147
157
  userId?: number;
@@ -168,6 +178,7 @@ export interface TwoFactorVerifyRequest {
168
178
  export interface TwoFactorVerifyResponse {
169
179
  valid: boolean;
170
180
  token?: string;
181
+ refreshToken?: string;
171
182
  }
172
183
 
173
184
  export interface TrustedDevice {
@@ -286,6 +297,14 @@ export interface CheckoutRequest {
286
297
  metadata?: { [key: string]: string };
287
298
  }
288
299
 
300
+ /** POST /api/billing/setup-intent — open the provider's payment sheet with these. */
301
+ export interface SetupIntentResponse {
302
+ setupIntentId: string;
303
+ clientSecret: string;
304
+ customerId: string;
305
+ ephemeralKey: string;
306
+ }
307
+
289
308
  export interface CheckoutResponse {
290
309
  checkoutUrl: string;
291
310
  }
package/src/model/auth.ts CHANGED
@@ -93,6 +93,7 @@ export interface OtpVerifyRequest {
93
93
 
94
94
  export interface OtpVerifyResponse {
95
95
  token: string;
96
+ refreshToken?: string;
96
97
  userId?: number;
97
98
  partnerId?: number;
98
99
  }
@@ -137,6 +138,7 @@ export interface SocialLoginRequest {
137
138
 
138
139
  export interface LoginResponseSocial {
139
140
  token: string;
141
+ refreshToken?: string;
140
142
  userId?: number;
141
143
  partnerId?: number;
142
144
  isNewUser: boolean;
@@ -1,9 +1,13 @@
1
- import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/common/http';
1
+ import { HttpErrorResponse, HttpEvent, HttpEventType, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
2
2
  import { inject, Injector } from '@angular/core';
3
- import { catchError, tap, throwError } from 'rxjs';
3
+ import { Observable, catchError, switchMap, tap, throwError } from 'rxjs';
4
4
  import { isKeelApiUrl } from './rest_url';
5
- import { BaseAuthService } from './auth.service';
5
+ import { BaseAuthService, RefreshSupersededError } from './auth.service';
6
6
 
7
+ // A 401 on an authed call first tries one refresh-token rotation (keel
8
+ // /public/token/refresh) and replays the request with the new JWT; only when
9
+ // no refresh token is stored or keel rejects it does the 401 count below.
10
+ //
7
11
  // Auth-loop breaker: repeated 401s on token-bearing API calls (a stale session) can
8
12
  // drive the app to hammer the API/CDN. After AUTH_FAIL_THRESHOLD within
9
13
  // AUTH_FAIL_WINDOW_MS the circuit opens for AUTH_CIRCUIT_COOLDOWN_MS — authed
@@ -25,7 +29,8 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
25
29
  // → interceptors) and so an open circuit clears the dead session even when it
26
30
  // was opened by a previous page load.
27
31
  const injector = inject(Injector);
28
- const expireSession = () => injector.get(BaseAuthService, null, { optional: true })?.sessionExpired();
32
+ const auth = () => injector.get(BaseAuthService, null, { optional: true });
33
+ const expireSession = () => auth()?.sessionExpired();
29
34
 
30
35
  const token = localStorage.getItem('jwt');
31
36
  const authedApi = !!token && isKeelApiUrl(req.url);
@@ -46,6 +51,11 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
46
51
  }));
47
52
  }
48
53
 
54
+ const countFailure = (err: HttpErrorResponse) => {
55
+ if (recordAuthFailure()) expireSession();
56
+ return throwError(() => err);
57
+ };
58
+
49
59
  return next(req).pipe(
50
60
  tap((event) => {
51
61
  if (authedApi && event.type === HttpEventType.Response) {
@@ -53,14 +63,33 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
53
63
  }
54
64
  }),
55
65
  catchError((err: HttpErrorResponse) => {
56
- if (authedApi && err.status === 401 && recordAuthFailure()) {
57
- expireSession();
58
- }
59
- return throwError(() => err);
66
+ if (!authedApi || err.status !== 401) return throwError(() => err);
67
+ const service = auth();
68
+ if (!service || !localStorage.getItem('refreshToken')) return countFailure(err);
69
+ return service.refreshSession().pipe(
70
+ catchError((refreshErr: unknown) => {
71
+ if (refreshErr instanceof RefreshSupersededError) {
72
+ return throwError(() => err); // another session took over; it is not ours to expire
73
+ }
74
+ if (refreshErr instanceof HttpErrorResponse && refreshErr.status !== 401) {
75
+ return countFailure(err); // transient refresh failure: keep the session, count the 401
76
+ }
77
+ expireSession(); // keel rejected the refresh token: the session is dead
78
+ return throwError(() => err);
79
+ }),
80
+ switchMap((token) => replay(req, next, token)), // replay errors surface as-is
81
+ );
60
82
  }),
61
83
  );
62
84
  };
63
85
 
86
+ /** Re-send the request once with the rotated JWT; a second 401 is a real denial. */
87
+ function replay(req: HttpRequest<unknown>, next: HttpHandlerFn, token: string): Observable<HttpEvent<unknown>> {
88
+ return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })).pipe(
89
+ tap((event) => { if (event.type === HttpEventType.Response) clearAuthFailures(); }),
90
+ );
91
+ }
92
+
64
93
  function authCircuitOpen(): boolean {
65
94
  return Date.now() < Number(sessionStorage.getItem(OPEN_KEY) ?? 0);
66
95
  }
@@ -1,8 +1,8 @@
1
1
  import { Injectable, WritableSignal, inject, signal } from '@angular/core';
2
2
  import { HttpErrorResponse, HttpParams } from '@angular/common/http';
3
- import { filter, map, mergeMap, take, tap } from 'rxjs/operators';
3
+ import { filter, finalize, map, mergeMap, shareReplay, take, tap } from 'rxjs/operators';
4
4
  import { PasswordRules } from '../util/password_policy';
5
- import { ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerRegistration, RestReport, TableDefinition, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
5
+ import { ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerRegistration, RestReport, TableDefinition, TokenPair, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
6
6
  import {
7
7
  LoginResponseSocial,
8
8
  OtpRequest, OtpResendRequest, OtpResponse, OtpVerifyRequest, OtpVerifyResponse,
@@ -12,13 +12,19 @@ import {
12
12
  UserProfile,
13
13
  } from '../model/auth';
14
14
  import { RestURL } from './rest_url';
15
- import { BehaviorSubject, Observable, ReplaySubject, catchError, defer, merge, of, throwError } from 'rxjs';
15
+ import { BehaviorSubject, EMPTY, Observable, ReplaySubject, catchError, defer, merge, of, throwError } from 'rxjs';
16
16
  import { ApplicationMenu, ConstantValue } from '../model/common';
17
17
  import { CanActivateFn, CanDeactivateFn, Router, Routes } from '@angular/router';
18
18
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../config';
19
19
  import { BaseRestService } from './base_rest.service';
20
20
  import { resetAuthCircuit } from './auth.interceptor';
21
21
 
22
+ /** Thrown by refreshSession() when the session changed while the rotation was in
23
+ * flight; the result was discarded and the current session is untouched. */
24
+ export class RefreshSupersededError extends Error {
25
+ constructor() { super('session changed during refresh'); this.name = 'RefreshSupersededError'; }
26
+ }
27
+
22
28
  /** Sort dropdown options by their visible label so long lookup lists are scannable. */
23
29
  const byCaption = (a: ConstantValue, b: ConstantValue) =>
24
30
  (a.Caption ?? '').localeCompare(b.Caption ?? '', undefined, { numeric: true, sensitivity: 'base' });
@@ -65,6 +71,8 @@ export abstract class BaseAuthService extends BaseRestService {
65
71
  // before a subclass constructor can call configureRestUrls().
66
72
  protected get appdataUrl() { return this.url(RestURL.appdataURL); }
67
73
  protected get loginUrl() { return this.url(RestURL.loginURL); }
74
+ protected get tokenRefreshUrl() { return this.url(RestURL.tokenRefreshURL); }
75
+ protected get logoutUrl() { return this.url(RestURL.logoutURL); }
68
76
  protected get registerUrl() { return this.url(RestURL.registerURL); }
69
77
  protected get chpassUrl() { return this.url(RestURL.chpassURL); }
70
78
  protected get confirmRegisterUrl() { return this.url(RestURL.confirmRegisterURL); }
@@ -94,6 +102,8 @@ export abstract class BaseAuthService extends BaseRestService {
94
102
 
95
103
  token: string | null = null;
96
104
  readonly isLoggedIn = signal(false);
105
+ /** In-flight rotation shared by every caller so one expired JWT triggers one refresh. */
106
+ private refreshInFlight: Observable<string> | null = null;
97
107
 
98
108
  /** keel password policy for pre-submit validation; undefined until loaded
99
109
  * (or when guiConfig.passwordPolicyUrl is unset). Fed via ensurePasswordPolicy(). */
@@ -176,14 +186,23 @@ export abstract class BaseAuthService extends BaseRestService {
176
186
  /** Adopt an externally-minted JWT (registration/SSO handoff flows) and run the
177
187
  * full post-login sequence — store under the canonical key, load appdata, init
178
188
  * routes. Apps must use this instead of writing localStorage directly. */
179
- acceptToken(token: string) {
180
- this.completeLogin(token);
189
+ acceptToken(token: string, refreshToken?: string) {
190
+ this.completeLogin(token, refreshToken);
181
191
  }
182
192
 
183
- private completeLogin(token: string) {
193
+ /** A pair with no refresh token also drops the stored one: it may belong to a different account. */
194
+ private storeTokens(token: string, refreshToken?: string) {
184
195
  this.token = token;
185
- this.isLoggedIn.set(true);
186
196
  localStorage.setItem('jwt', token);
197
+ if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
198
+ else localStorage.removeItem('refreshToken');
199
+ }
200
+
201
+ private completeLogin(token: string, refreshToken?: string) {
202
+ this.refreshInFlight = null; // a rotation started under the previous session must not land
203
+ this.sessionGeneration++;
204
+ this.storeTokens(token, refreshToken);
205
+ this.isLoggedIn.set(true);
187
206
  resetAuthCircuit(); // a new session must not inherit a prior session's open circuit
188
207
  const ret = this.validatedReturnUrl();
189
208
  if (ret && this.guiConfig.sessionCookieUrl) {
@@ -231,10 +250,34 @@ export abstract class BaseAuthService extends BaseRestService {
231
250
  localStorage.setItem('loginToken', res.loginToken);
232
251
  this.router.navigate(['/login/2fa']);
233
252
  } else {
234
- this.completeLogin(res.token);
253
+ this.completeLogin(res.token, res.refreshToken);
235
254
  }
236
255
  }
237
256
 
257
+ /**
258
+ * Rotate the stored refresh token at keel's /public/token/refresh and emit the
259
+ * new JWT. Concurrent callers share one request. Errors when no refresh token
260
+ * is stored or keel rejects it (401: revoked, expired, or already rotated) —
261
+ * the caller decides whether that ends the session (the auth interceptor does).
262
+ */
263
+ refreshSession(): Observable<string> {
264
+ if (this.refreshInFlight) return this.refreshInFlight;
265
+ const refreshToken = localStorage.getItem('refreshToken');
266
+ if (!refreshToken) return throwError(() => new Error('no refresh token stored'));
267
+ const generation = this.sessionGeneration;
268
+ const inFlight = this.http.post<TokenPair>(this.tokenRefreshUrl, { refreshToken }).pipe(
269
+ map((res) => {
270
+ if (generation !== this.sessionGeneration) throw new RefreshSupersededError();
271
+ this.storeTokens(res.token, res.refreshToken);
272
+ return res.token;
273
+ }),
274
+ finalize(() => { if (this.refreshInFlight === inFlight) this.refreshInFlight = null; }),
275
+ shareReplay(1),
276
+ );
277
+ this.refreshInFlight = inFlight;
278
+ return inFlight;
279
+ }
280
+
238
281
  /**
239
282
  * POST credentials; success completes the login (or routes to 2FA). Callers
240
283
  * subscribe and handle the error channel to surface failures in the UI.
@@ -288,7 +331,7 @@ export abstract class BaseAuthService extends BaseRestService {
288
331
  tap((res) => {
289
332
  if (res.valid && res.token) {
290
333
  localStorage.removeItem('loginToken');
291
- this.completeLogin(res.token);
334
+ this.completeLogin(res.token, res.refreshToken);
292
335
  }
293
336
  }),
294
337
  );
@@ -301,7 +344,7 @@ export abstract class BaseAuthService extends BaseRestService {
301
344
  tap((res) => {
302
345
  if (res.valid && res.token) {
303
346
  localStorage.removeItem('loginToken');
304
- this.completeLogin(res.token);
347
+ this.completeLogin(res.token, res.refreshToken);
305
348
  }
306
349
  }),
307
350
  );
@@ -379,11 +422,22 @@ export abstract class BaseAuthService extends BaseRestService {
379
422
  }
380
423
  }
381
424
 
425
+ /** Revoke the refresh token server-side (best effort: the local session is
426
+ * cleared regardless), then land on the login screen. */
382
427
  logout() {
428
+ this.revokeRefreshToken().subscribe();
383
429
  this.clearSession();
384
430
  this.router.navigate(['/login/local'], { queryParams: { loggedOut: 'true' } });
385
431
  }
386
432
 
433
+ private revokeRefreshToken(): Observable<unknown> {
434
+ const refreshToken = localStorage.getItem('refreshToken');
435
+ if (!refreshToken) return EMPTY;
436
+ return this.http.post(this.logoutUrl, { refreshToken }).pipe(
437
+ catchError((err) => { console.warn('refresh token revocation failed:', err); return EMPTY; }),
438
+ );
439
+ }
440
+
387
441
  register(reg: PartnerRegistration) {
388
442
  return this.http.post<PartnerRegistration>(this.registerUrl, reg);
389
443
  }
@@ -423,7 +477,7 @@ export abstract class BaseAuthService extends BaseRestService {
423
477
  return this.http.post<OtpVerifyResponse>(this.otpVerifyUrl, req).pipe(
424
478
  tap((res) => {
425
479
  if (res?.token) {
426
- this.completeLogin(res.token);
480
+ this.completeLogin(res.token, res.refreshToken);
427
481
  if (role) this.setRole(role);
428
482
  }
429
483
  }),
@@ -441,7 +495,7 @@ export abstract class BaseAuthService extends BaseRestService {
441
495
  ): Observable<LoginResponseSocial> {
442
496
  const payload: SocialLoginRequest = { provider, token: idToken, ...consent };
443
497
  return this.http.post<LoginResponseSocial>(this.loginSocialUrl, payload).pipe(
444
- tap((res) => { if (res?.token) this.completeLogin(res.token); }),
498
+ tap((res) => { if (res?.token) this.completeLogin(res.token, res.refreshToken); }),
445
499
  );
446
500
  }
447
501
 
@@ -503,13 +557,15 @@ export abstract class BaseAuthService extends BaseRestService {
503
557
  this.token = null;
504
558
  this.isLoggedIn.set(false);
505
559
  localStorage.removeItem('jwt');
560
+ localStorage.removeItem('refreshToken');
506
561
  localStorage.removeItem('menu');
507
562
  localStorage.removeItem('loginToken');
508
563
  this.clearRole();
509
564
  this.cache = undefined;
510
565
  this.authIndex.clear();
511
566
  this.appDataError.set(null);
512
- this.sessionGeneration++; // in-flight loads from this session are ignored
567
+ this.refreshInFlight = null;
568
+ this.sessionGeneration++; // in-flight loads and refreshes from this session are ignored
513
569
  this.appData$.next(null); // live subscribers see menus clear; identity stays stable
514
570
  this.appDataFail$.complete();
515
571
  this.appDataFail$ = new ReplaySubject<unknown>(1);
@@ -9,6 +9,7 @@ import {
9
9
  PaymentMethod,
10
10
  PortalResponse,
11
11
  PublicPlan,
12
+ SetupIntentResponse,
12
13
  Subscription,
13
14
  UsageMeter,
14
15
  } from '../model/appdata';
@@ -73,6 +74,15 @@ export class BillingService extends BaseRestService {
73
74
  return this.http.get<PaymentMethod[]>(this.url(RestURL.paymentMethodsURL));
74
75
  }
75
76
 
77
+ /**
78
+ * Create a provider setup intent for on-device card capture (keel v1.2.57
79
+ * AbstractPaymentHandler.CreateSetupIntent). Identity comes from the JWT; the
80
+ * response feeds the provider's payment sheet. Persist customerId on first use.
81
+ */
82
+ createSetupIntent(): Observable<SetupIntentResponse> {
83
+ return this.http.post<SetupIntentResponse>(this.url(RestURL.setupIntentURL), {});
84
+ }
85
+
76
86
  /**
77
87
  * Create a provider customer-portal session; caller redirects to the URL so
78
88
  * the partner can manage their payment method / subscription on the
@@ -0,0 +1,142 @@
1
+ import { DestroyRef, Injectable, inject, signal } from '@angular/core';
2
+ import { Observable, Subject, filter, map } from 'rxjs';
3
+ import { RestURL } from './rest_url';
4
+
5
+ /** Frame published on a channel by keel's realtime.Hub (Broadcast / PublishChannel). */
6
+ export interface RealtimeChannelFrame<T = unknown> {
7
+ channel: string;
8
+ data: T;
9
+ }
10
+
11
+ /** Control frames of the keel realtime wire protocol. */
12
+ interface ControlFrame {
13
+ op: 'subscribed' | 'unsubscribed' | 'error';
14
+ channel?: string;
15
+ reason?: string;
16
+ }
17
+
18
+ const RECONNECT_MIN_MS = 1_000;
19
+ const RECONNECT_MAX_MS = 30_000;
20
+
21
+ /**
22
+ * RealtimeService — client for keel's realtime.Hub (`GET /public/ws`).
23
+ *
24
+ * Owns the socket lifecycle: JWT handshake via `?token=`, reconnect with
25
+ * backoff, and re-subscription of every channel after a reconnect. Channel
26
+ * names and payload schemas are the app's; sail only carries the frames.
27
+ * Relay delivery is fire-and-forget on the server, so keep a REST read path for
28
+ * state a client may have missed while disconnected.
29
+ */
30
+ @Injectable({ providedIn: 'root' })
31
+ export class RealtimeService {
32
+ private readonly destroyRef = inject(DestroyRef);
33
+ private socket: WebSocket | null = null;
34
+ private wanted = false;
35
+ private reconnectDelay = RECONNECT_MIN_MS;
36
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
37
+ private readonly channels = new Set<string>();
38
+ private readonly frames$ = new Subject<unknown>();
39
+
40
+ readonly connected = signal(false);
41
+ /** Last subscription rejected by keel's CanSubscribe, as `{channel, reason}`. */
42
+ readonly lastError = signal<{ channel?: string; reason?: string } | null>(null);
43
+
44
+ constructor() {
45
+ this.destroyRef.onDestroy(() => this.disconnect());
46
+ }
47
+
48
+ /** Open the socket with the stored JWT. Reconnects on drop until disconnect(). */
49
+ connect(): void {
50
+ this.wanted = true;
51
+ if (this.socket) return;
52
+ const token = localStorage.getItem('jwt');
53
+ if (!token) throw new Error('RealtimeService.connect: no session token stored');
54
+ const ws = new WebSocket(`${socketUrl(RestURL.realtimeURL)}?token=${encodeURIComponent(token)}`);
55
+ this.socket = ws;
56
+ ws.onopen = () => {
57
+ if (this.socket !== ws) return;
58
+ this.reconnectDelay = RECONNECT_MIN_MS;
59
+ this.connected.set(true);
60
+ for (const channel of this.channels) this.sendFrame({ op: 'subscribe', channel });
61
+ };
62
+ ws.onmessage = (ev: MessageEvent<string>) => { if (this.socket === ws) this.onFrame(ev.data); };
63
+ ws.onclose = () => {
64
+ if (this.socket !== ws) return; // superseded by disconnect()/connect(); ignore its close
65
+ this.socket = null;
66
+ this.connected.set(false);
67
+ if (this.wanted) this.scheduleReconnect();
68
+ };
69
+ ws.onerror = () => ws.close();
70
+ }
71
+
72
+ disconnect(): void {
73
+ this.wanted = false;
74
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
75
+ this.socket?.close();
76
+ this.socket = null;
77
+ this.connected.set(false);
78
+ }
79
+
80
+ /** Frames published on `channel`; subscribes on first use and survives reconnects. */
81
+ channel<T = unknown>(channel: string): Observable<T> {
82
+ if (!this.channels.has(channel)) {
83
+ this.channels.add(channel);
84
+ if (this.connected()) this.sendFrame({ op: 'subscribe', channel });
85
+ }
86
+ return this.frames$.pipe(
87
+ filter((f): f is RealtimeChannelFrame<T> => isChannelFrame(f) && f.channel === channel),
88
+ map((f) => f.data),
89
+ );
90
+ }
91
+
92
+ unsubscribe(channel: string): void {
93
+ if (this.channels.delete(channel) && this.connected()) this.sendFrame({ op: 'unsubscribe', channel });
94
+ }
95
+
96
+ /** Payloads keel delivers to this user directly (SendToUser / PublishUser). */
97
+ userFrames<T = unknown>(): Observable<T> {
98
+ return this.frames$.pipe(filter((f): f is T => !isChannelFrame(f) && !isControlFrame(f)));
99
+ }
100
+
101
+ /** Send an application frame; keel passes anything but subscribe/unsubscribe to OnMessage. */
102
+ send(frame: object): void {
103
+ if (!this.connected()) throw new Error('RealtimeService.send: socket not connected');
104
+ this.sendFrame(frame);
105
+ }
106
+
107
+ private sendFrame(frame: object): void {
108
+ this.socket?.send(JSON.stringify(frame));
109
+ }
110
+
111
+ private onFrame(raw: string): void {
112
+ let frame: unknown;
113
+ try { frame = JSON.parse(raw); } catch { console.error('realtime: non-JSON frame dropped'); return; }
114
+ if (isControlFrame(frame) && frame.op === 'error') {
115
+ this.lastError.set({ channel: frame.channel, reason: frame.reason });
116
+ console.error(`realtime: subscribe ${frame.channel} rejected: ${frame.reason}`);
117
+ }
118
+ this.frames$.next(frame);
119
+ }
120
+
121
+ private scheduleReconnect(): void {
122
+ this.reconnectTimer = setTimeout(() => {
123
+ this.reconnectTimer = null;
124
+ if (this.wanted) this.connect();
125
+ }, this.reconnectDelay);
126
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
127
+ }
128
+ }
129
+
130
+ function isChannelFrame(f: unknown): f is RealtimeChannelFrame {
131
+ return !!f && typeof f === 'object' && 'channel' in f && 'data' in f && !('op' in f);
132
+ }
133
+
134
+ function isControlFrame(f: unknown): f is ControlFrame {
135
+ return !!f && typeof f === 'object' && 'op' in f;
136
+ }
137
+
138
+ /** ws(s):// form of the configured backend host plus `path`; same-origin when no host is set. */
139
+ function socketUrl(path: string): string {
140
+ const base = RestURL.httpHost || window.location.origin;
141
+ return base.replace(/^http/, 'ws') + path;
142
+ }
@@ -3,6 +3,9 @@ export const RestURL = {
3
3
  api_prefix: '/api/',
4
4
  appdataURL: '/api/config/appdata',
5
5
  loginURL: '/public/login/local',
6
+ tokenRefreshURL: '/public/token/refresh', // rotates {refreshToken} → new {token, refreshToken}
7
+ logoutURL: '/public/logout', // revokes {refreshToken}
8
+ realtimeURL: '/public/ws', // keel realtime.Hub handshake; JWT via ?token=
6
9
  loginGoogleURL: '/public/login/google',
7
10
  registerURL: '/public/register',
8
11
  chpassURL: '/public/login/chpass',
@@ -25,6 +28,7 @@ export const RestURL = {
25
28
  invoicesURL: '/api/billing/invoices',
26
29
  paymentMethodsURL: '/api/billing/payment-methods',
27
30
  portalURL: '/api/billing/portal',
31
+ setupIntentURL: '/api/billing/setup-intent',
28
32
  usageURL: '/api/billing/usage',
29
33
  // OTP / social / push / account-lifecycle endpoints
30
34
  otpSendURL: '/public/otp/send',