@nauticana/sail
v1.1.51
Published
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.
Downloads
2,621
Maintainers
Readme
@nauticana/sail
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 Go backend.
Compatibility: sail and keel are versioned in lock-step. The current line is sail v1.1.x ↔ keel v1.2.x; sail v1.1.47 adopts keel v1.2.131 (money columns declare their currency), sail v1.1.45 adopts keel v1.2.126 (a plan change takes the offer), sail v1.1.43 adopts keel v1.2.125 (plan-change overages), sail v1.1.42 adopts keel v1.2.118 (RFC 3339 timestamps,
X-Time-Zone), sail v1.1.41 adopts keel v1.2.117 (scheduled plan changes), sail v1.1.39 and v1.1.40 adopt keel v1.2.115 (usage captions, provider-managed subscriptions, the connect disconnect route), sail v1.1.37 adopts keel v1.2.114 (scopes validated against a constant list), sail v1.1.36 adopts keel v1.2.113 (API key expiry, caller-aware scope policy), sail v1.1.35 adopts keel v1.2.112 (minor-unit money, thecurrencycatalog, OAuth grant routes), sail v1.1.33 adopts keel v1.2.106 (prompton the login page, new-device step-up, sign-in sessions), sail v1.1.32 adopts keel v1.2.101 (the OAuth connect callback ticket), sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolutereturnfor the OAuth hand-off), 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 thetable_actionframework (per-table custom buttons surfaced inTableList/TableSearch/TableEdit/TableDetail); see the Migrating to v0.7.0 §5 — TableAction section for the seed shape (basistable_action+authorization_object+authorization_object_actionrows) and the keel/README Table Actions section for backend wiring viahandler.WrapTableAction.
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.
v1.1.10 is a breaking release. The base classes migrated to signals (
records(),editableRecord(),isNew()…),login()/loginWithGoogle()now return Observables, CRUD errors are typedSailApiError,BaseViewsplit into read-onlyBaseView+AbstractEditableView, the decorators are renamedIsSailString/IsSailNumeric, andconfigureRestUrls()is finally safe to call from a subclass constructor. The complete old→new mapping is machine-readable inmigration_guide.json. Consumers must fully migrate before pushing to test or prod.
What it provides
| Category | Exports |
|----------|---------|
| Table components | TableList, TableSearch, TableEdit, TableDetail, TableLookup, TableReport |
| Form components | DynamicField, RecordForm, TableForm |
| Navigation | Navigation (sidenav + toolbar with menu, responsive) |
| Login | LoginComponent, RegisterComponent, ChpassComponent, ConfirmRegisterComponent, ConfirmChpassComponent |
| Security | TwoFactorSetupComponent, TwoFactorVerifyComponent, TrustedDevicesComponent, SessionsComponent, ApiKeysComponent, ConnectedAppsComponent, AccountDeletionComponent |
| Account | MyAccountComponent (self-service hub), ProfileEditorComponent (name/locale immediate; email/phone verify-before-apply) |
| Auth | ConsentGateComponent, OtpInputComponent, HumanChallengeComponent, SocialLoginComponent, SsoLoginComponent, SsoReturnComponent |
| Billing | PlanSelectorComponent, PriceSelectorComponent, CheckoutButtonComponent, PaymentMethodsComponent, PortalButtonComponent, UsageMeterComponent, StatusChipComponent, TrialBannerComponent, ScheduledChangeBannerComponent, SeatSelectorComponent, DunningBannerComponent |
| Dashboard | DashboardShellComponent, DataCardComponent, StatTileComponent, LockedOverlayComponent, ActionCenterComponent, EntitySelectorComponent, VerificationFlowComponent |
| Payout | PayoutProviderOnboardingComponent, PayoutBankInfoFormComponent |
| Agency | AgencyClientsComponent, AgencyStatusBannerComponent, AgencyAcceptComponent, AgencyEarningsSummaryComponent, CommissionLedgerComponent, AgencyPayoutHistoryComponent, AgencyPayoutProfileComponent |
| Notifications | NotificationCenterComponent, NotificationInboxService |
| Documents | DocumentUploadComponent, PartnerDocumentsComponent, DocumentReviewQueueComponent, PartnerDocumentService |
| Payments / SCA | UserPaymentMethodsComponent, ScaConfirmComponent, ScaConfirmer (port), SCA_CONFIRMER (token) |
| Services | BaseAuthService (OTP / social / push / deleteAccount / logoutEverywhere / profile: getProfile, updateProfile, request+confirmEmailChange, request+confirmPhoneChange), BillingService, AgencyService, BackendService, PayoutService, UserPaymentMethodService, PartnerDocumentService, NotificationInboxService, loadScript(), authInterceptor, apiResponseInterceptor |
| Abstracts | BaseTable, BaseForm, BaseView, AbstractEditableView, BaseAsync, BaseRestService |
| Config | SAIL_GUI_CONFIG, SailGuiConfig, configureRestUrls() |
| Models | ApplicationData, TableDefinition, SiudAction, ApplicationMenu, ConstantValue, UserAccount, RestReport, ReportParam, TrustedDevice, PublicPlan, PlanPrice, PaymentMethod, Subscription, PlanChangeResult, PlanOverage, Invoice, CheckoutRequest/CheckoutResponse, PortalResponse, UsageMeter, ChargeResult, UserPaymentMethod, TableAction, TableActionParameter, ReusableAccount, PayoutOnboardingSession, BankInfoFormValue, Currency, Country, OtpRequest/OtpResponse, SignupConsent, ConsentState, ConsentOption, SocialProvider, PushPlatform, InboxMessage/InboxPage, PartnerDocument/DocumentUploadRequest, 2FA types, etc. |
| Decorators | @IsSailString(), @IsSailNumeric() (class-validator based) |
| Utils | titleCase(), fromPriceLabel(), formatMinor(), parseMinor(), minorToDecimal(), CurrencyService, MoneyColumnService, sailMoney pipe |
Quick start
1. Install
sail is published on the public npm registry — no .npmrc or registry override needed:
npm install @nauticana/sail class-validatorThis adds the following to your package.json:
"dependencies": {
"@nauticana/sail": "^1.0.0",
"class-validator": "^0.15.1"
}2. Configure tsconfig paths
Since sail ships raw TypeScript source, you need a path mapping so the Angular compiler can resolve and compile it. Add to your tsconfig.json:
"paths": {
"@nauticana/sail": ["./node_modules/@nauticana/sail/src/index"]
}Apps that use the agent entry point (see Agent UI) add a second mapping, "@nauticana/sail/agent": ["./node_modules/@nauticana/sail/src/agent/index"]. The package exposes only these two entry points; deep imports such as @nauticana/sail/service/... are not supported.
Note: If your tsconfig has
"baseUrl": "src", use"../node_modules/@nauticana/sail/src/index"instead (paths resolve relative tobaseUrl).
Suppress class-validator CommonJS warnings in angular.json:
"build": {
"options": {
"allowedCommonJsDependencies": ["class-validator"]
}
}3. Create your AuthService
// src/service/auth.service.ts
import { Injectable } from '@angular/core';
import { BaseAuthService, configureRestUrls } from '@nauticana/sail';
import { environment } from '../environment/environment';
@Injectable({ providedIn: 'root' })
export class AuthService extends BaseAuthService {
constructor() {
super();
configureRestUrls(environment.httphost);
}
// Add project-specific auth methods here (e.g., loginWithGoogle override, OTP, etc.)
}4. Bootstrap your app
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { SAIL_GUI_CONFIG, BaseAuthService, authInterceptor, apiResponseInterceptor, TwoFactorVerifyComponent } from '@nauticana/sail';
import { AuthService } from './service/auth.service';
import { DashUser } from './component/dashboard/dash_user';
import { App } from './app/app';
bootstrapApplication(App, {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(withInterceptors([apiResponseInterceptor, authInterceptor])),
provideRouter([]),
{ provide: BaseAuthService, useExisting: AuthService },
{
provide: SAIL_GUI_CONFIG,
useValue: {
opField: 'op_code',
hiddenFields: ['op_code', 'PartnerId'],
appTitle: 'My App',
appLogoUrl: 'logo.png',
dashboardComponent: DashUser,
publicRoutes: [
{ path: 'login/local', loadComponent: () => import('@nauticana/sail').then(m => m.LoginComponent) },
{ path: 'login/register', loadComponent: () => import('@nauticana/sail').then(m => m.RegisterComponent) },
{ path: 'login/chpass', loadComponent: () => import('@nauticana/sail').then(m => m.ChpassComponent) },
{ path: 'login/2fa', component: TwoFactorVerifyComponent },
{ path: 'confirm/register', loadComponent: () => import('@nauticana/sail').then(m => m.ConfirmRegisterComponent) },
{ path: 'confirm/password', loadComponent: () => import('@nauticana/sail').then(m => m.ConfirmChpassComponent) },
],
publicRouteLinks: [
{ label: 'Login', routerLink: '/login/local' },
{ label: 'Register', routerLink: '/login/register' },
],
// Shown in the toolbar before Logout when logged in. Mount the matching
// authenticated route to MyAccountComponent (its links expect
// /account/2fa, /account/devices, /account/delete, /login/chpass).
accountLink: { label: 'My Account', routerLink: '/account' },
loginFooterLinks: [
{ label: 'Sign in with Google', routerLink: '/login/google' },
],
// Map backend menu items to custom components
menuItemRouteOverrides: {
// 'external_connection': SocialConnectionComponent,
// 'analytic/*': TableReport,
},
},
},
],
});Auth-loop breaker.
authInterceptorcarries a built-in circuit breaker: after 5401s on token-bearing keel API calls within 10s it opens for 30s and refuses those requests locally (never sent), so a stale/rejected session can't drive the app to hammer the API and the edge/CDN. Only401counts — a403is a legitimate permission denial, not a session failure. A later success or accepting a new token (login) closes it, so a freshly authenticated session is never blocked. No wiring needed beyond registeringauthInterceptor.
Time zone.
authInterceptoralso sendsX-Time-Zone(the browser's IANA zone, read once per page load) on every keel/api/and/public/request, signed in or not, so keel renders emails, notifications and exports in the user's zone (keel v1.2.118). Timestamps in responses are RFC 3339 UTC; render them with thedatepipe, without a zone argument.
Phone-number change (opt-in).
ProfileEditorComponentandMyAccountComponentgate phone change behind aphoneChangeEnabledinput (defaultfalse, since it needs an SMS provider — keel returns 503 withoutNotify). Enable it once your backend has SMS wired:<sail-my-account [phoneChangeEnabled]="true" />or<sail-profile-editor [phoneChangeEnabled]="true" />.
5. Create the root component
// src/app/app.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { Navigation } from '@nauticana/sail';
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Navigation],
template: `<sail-navigation><span toolbar-title>My App</span></sail-navigation>`,
})
export class App {}Place the application logo in the consumer's public/ directory (for example,
public/logo.png) and set the same public URL in appLogoUrl. Sail displays it
in the sidenav toolbar as a link to /. When appLogoUrl is omitted, the link
falls back to appTitle, then to a home icon, so home navigation remains
available without requiring a library-owned brand asset. The consuming app may
also reuse that asset as its favicon:
<link rel="icon" type="image/png" href="logo.png">6. Provide global styles
All sail components use ViewEncapsulation.None — they ship no CSS. Your project must provide a global stylesheet covering sail selectors.
Required structural styles (without these, the sidenav collapses and gets cut off). Add to your src/styles.css:
.sidenav-container { height: 100vh; }
.sidenav { width: 250px; }Key CSS classes used by components:
/* Layout */
.auth-container, .auth-card, .auth-header, .auth-title, .auth-form,
.auth-actions, .auth-footer, .auth-app-title, .auth-checkbox,
.auth-section-label, .auth-instructions, .form-row, .register-card,
.sail-navigation-home, .sail-navigation-logo, .sail-navigation-home-title
/* Feedback */
.auth-error, .auth-success, .geocode-status, .geocode-loading,
.geocode-success, .geocode-error
/* Tables */
.edit-container, .actions-bar, .tab-content, .empty-state, .empty-state__filters,
.detail-actions, .search-actions, .select-btn, .form-container
/* Navigation: hide .sail-skip-link until focused; .sail-main takes focus after navigation */
.sidenav-container, .sidenav, .toolbar-spacer, .sail-skip-link, .sail-main
/* Fields */
.field-editable, .field-readonly
/* State classes */
.deleted-record, .updated-record, .new-record
/* Buttons (replace deprecated Material color attributes) */
.primary, .accent, .warn, .current-device-badge
/* Security */
.twofactor-qr, .twofactor-backup, .backup-code-list,
.trusted-devices-table, .sessions-tableHow it works
sail follows a metadata-driven architecture. On login, the backend returns ApplicationData containing:
- MainMenu — menu structure with pages and permissions
- Permissions — role-based access control entries
- TableDefinitions — column metadata, types, validation rules, foreign keys
- Apis — REST endpoint mappings per table
- ConstantCache / TableCache — dropdown/lookup values
BaseAuthService.initRoutes() dynamically builds Angular routes from this metadata. Each menu item automatically gets a TableSearch or TableList route with the correct API endpoint and table metadata. Custom components can override specific menu items via menuItemRouteOverrides in the config.
Application controls can call BaseAuthService.hasPermission(object, action, value) for any backend grant. Table actions continue to use canExecute().
List pagination
keel REST list responses are paginated:
{ "items": [...], "limit": 100, "offset": 0, "total": 12345 }BackendService.list<T>() continues to return Observable<T[]> — sail unwraps items for you. Both list methods accept a typed PageRequest after the filter map. To access the metadata, use listPaginated<T>():
this.backend.listPaginated<MyRow>('orders', { Status: 'open' }, {
limit: 50,
offset: 0,
orderBy: 'created_at DESC',
})
.subscribe((page) => {
this.rows.set(page.items);
this.total.set(page.total); // total rows matching the filter
});Default page size is 100, capped at 1000 server-side. Sail maps limit, offset, and orderBy to keel's _limit, _offset, and _order query controls. Existing callers that put those string controls in the filter map remain compatible; explicit PageRequest values take precedence.
Generic screens: lists, edits, confirmations
- List URL state.
TableListreads_order(ColumnorColumn DESC, a visible column),_limit(one of the page sizes) and_offsetas controls, never as filters, ignores invalid values, and writes them back on sort and page changes; sorting is server-side across all pages and resets the offset. Every other query parameter is a filter. - Loading and empty states. While a request runs the list shows a
mat-progress-barand setsaria-busy; only the latest request may replace rows. With filters active the empty state reads "No {caption} match your search", lists the filters and offers Clear search (keeps sort and page size); otherwise it shows[emptyMessage]or "No {caption} yet". - Unsaved changes.
BaseForm.hasUnsavedChanges()is the dirty-state contract;TableEditreports changed scalars and child rows marked insert, update or delete. Cancel and the dialog close button confirm before discarding.TableListdelegates the contract to its open dialog, which stays open across navigation untilBaseAuthService.canDeactivatedecides; a custom dialog without the method keeps its behavior. - Confirmations and notices.
PromptService.confirm({ title, message?, action, destructive? })returnsObservable<boolean>from a Material dialog;ask(options, then)runsthenonly on the action;notify(message)shows a snackbar. Deletes, revocations, discards,TableAction.confirmMessage, and the route guard use it; an ordinary save is not confirmed. Table views report failures in theirrole="alert"region throughreportError(). - Invalid submit. Primary actions stay enabled while a form is invalid;
submittable(control, scope)marks it touched and focuses the first invalid field inscope.<mat-error sailFieldError></mat-error>(FieldError) shows the field's first validation error. - Read-only fields.
DynamicFieldrenders read-only values with nativereadonly, so they stay focusable and copyable; a read-only boolean shows "Yes" or "No". The lookup button stays disabled. - Page shell.
sail-navigationrenders a "Skip to content" link and wraps the outlet in<main id="sail-main" tabindex="-1">, focused after a path change (not on first load, a query-only change, or while the handset menu is open). Generated routes take the menu caption as their title, suffixed withappTitle.
Money
keel stores money as integer minor units and serves the currency table (code, caption, exponent) as the only source of exponents. formatMinor(minor, currency, exponent) renders an amount; Intl supplies the symbol and grouping only. parseMinor(text, exponent) is the inverse for typed input: exact, . as the decimal point, null for malformed text, unsafe integers or more fraction digits than the exponent. Billing payloads (PlanPrice, Subscription, Invoice) carry exponent beside their …Minor fields, so they render without a catalog: {{ invoice.totalMinor | sailMoney:invoice.currency:invoice.exponent }}. Amounts without an exponent, such as the agency ledger, use {{ minor | sailMoney:currency }}: the pipe loads the catalog once through CurrencyService (generic REST currency, so the role needs TABLE SELECT currency), renders '' until it arrives, and throws for a code the catalog lacks rather than guessing decimals.
Metadata-driven screens need no pipe. A column keel marks with TableColumn.CurrencyPath (seeded in column_display_attribute.currency_path, keel v1.2.131) is money: TableList, TableDetail, TableLookup, TableReport and the read-only form show it at the exponent of the row's currency, and the edit field takes a decimal (12.50, . as the point) with the currency code as suffix, storing minor units. MoneyColumnService follows the path: a sibling column (currency) reads the row, a dotted path (invoice.currency) reads the parent row a child grid is nested under, or fetches each ancestor once through its generic REST api and re-renders when it arrives. A cell is empty until the currency and the catalog are known, so the role needs TABLE SELECT on the ancestor tables and on currency. TableReport matches its rows' SQL column names against [tableName]'s columns. minorToDecimal(minor, exponent) is parseMinor's inverse.
Backend-driven constant captions
LabelService loads one caption map from the backend-provided ApplicationData: a keel constant_value domain from ConstantCache by constant id, or a catalog table registered in foreign_key_lookup from TableCache by table name. sailCaption is the pipe form: {{ code | sailCaption:'constant_id' }}. The domain is the backend constant_id (for example ride_type), not a locale: keel's constant catalogue does not contain a language column. It does not call generic constant_value CRUD, which is normally restricted to administrative roles.
this.labels.loadLabels('ride_type').subscribe(() => {
this.rideTypeCaption.set(this.labels.getLabel('ride_type', ride.type));
});Concurrent loads share one application-data subscription, completed domains stay cached, and application-data failures can be retried. getLabel falls back to the raw key; requireLabel throws instead, for values that must not render as a raw code. options(domain) returns every {code, caption} of a loaded domain, sorted by caption, for picklists.
Status-filtered API errors
Pass an optional status allowlist when backend detail is safe to show only for selected responses. Other HTTP statuses return the caller's generic fallback:
errorDetail(error, 'Could not save.', [400, 409]);Configuration reference
interface SailGuiConfig {
opField: string; // Operation field name (default: 'op_code')
hiddenFields: string[]; // Fields hidden from forms (default: ['op_code', 'PartnerId'])
appTitle?: string; // Shown on login pages
appLogoUrl?: string; // Public logo URL used by the navigation home link
googleMapsApiKey?: string; // For RegisterComponent geocoding
humanVerifier?: { provider, siteKey }; // Challenge widget for registration (see Human verification)
dashboardComponent?: Type<any>; // Component for /dashboard route
publicRoutes?: Routes; // Routes available before login
publicRouteLinks?: RouteLink[]; // Links shown in toolbar when logged out
loginFooterLinks?: RouteLink[]; // Extra links below login form
extraRoutes?: (data) => Routes; // Dynamic routes from ApplicationData
menuItemRouteOverrides?: { // Map RestUri to custom component
[restUriPattern: string]: Type<unknown>; // Supports 'exact' and 'prefix/*'
};
// Social / consent / account-deletion config
googleClientId?: string; // Google Identity Services client ID
appleServiceId?: string; // Apple Services ID
appleRedirectUri?: string; // Apple Sign-In redirect URI
privacyPolicyUrl?: string; // Linked from ConsentGateComponent and the login footer
termsUrl?: string; // Linked from the SMS-consent disclosure and the login footer
defaultPolicyVersion?: string; // Content hash of the deployed policy
defaultPolicyLanguage?: string; // ISO 639-1 fallback language
accountDeletedRoute?: string; // Route after account deletion (default '/login/local')
passwordPolicyUrl?: string; // Public route serving keel's password policy (see below)
oauthServerUrl?: string; // Base URL of keel's OAuth authorization server (see below)
allowedReturnHosts?: string[]; // Hosts a post-login ?return= may name
ssoLogin?: boolean; // Shows organization sign-in on <sail-login> (see Tenant single sign-on)
ssoErrorMessages?: Partial<Record<SsoErrorCode, string>>; // Rewords single sign-on errors
}Password-policy validation
OAuth sign-in hand-off (v1.1.28, keel v1.2.93). When the login page is opened with
?return=<absolute authorize URL> and both oauthServerUrl and allowedReturnHosts are set,
sail posts {"return": <raw value>} with the bearer JWT to
{oauthServerUrl}/oauth/session/handoff and then leaves the SPA with a full navigation to the
redirect keel answers. This runs after a sign-in, and from loadStoredSession() when a user
who is already signed in opens a /login route; a 401 rotates the refresh token once and retries. keel sets the authorization server's own HttpOnly cookie while
redeeming that URL; sail does not opt into cross-origin cookies or send a CSRF token. A
return whose host is not in allowedReturnHosts, or a redirect outside oauthServerUrl's
origin, is not followed. Failures set BaseAuthService.sessionHandoffError (rendered by
<sail-login>) and never redirect: 400 means keel rejected the return URL, 401 means the user
signs in again to retry. The authorization server must allow the SPA origin by CORS.
With prompt=login or prompt=select_account, <sail-login> holds back the stored session and
offers sign-out (isLoggedIn stays false); account selection also offers continueSession(). A
fresh sign-in, including its 2FA step, keeps the hand-off query. Any sign-in revokes the refresh
token it replaces.
Set passwordPolicyUrl to the app's public route for keel's PublicHandler.GetPasswordPolicy
(e.g. '/public/v1/auth/password/policy'). BaseAuthService.ensurePasswordPolicy() fetches it
once into the passwordRules signal, and passwordPolicyValidator(() => auth.passwordRules())
validates new-password inputs against the real policy before submit (passwordPolicyHint(rules)
builds the matching hint text). Built into chpass / confirm-chpass; reuse in app signup forms.
Omit the URL to skip client-side policy checks. The server stays the authoritative gate.
Backend endpoints (keel v1.0)
| Endpoint | Purpose |
|----------|---------|
| POST /public/login/local | Username/password login with 2FA + trusted-device support |
| POST /public/login/google | Gmail OAuth-code login (legacy; prefer /public/login/social) |
| POST /public/login/social | ID-token social login (Google, Apple) |
| POST /public/otp/send | Send OTP code to phone or email; returns opaque otpToken |
| POST /public/otp/verify | Verify OTP code with otpToken, returns JWT |
| POST /public/otp/resend | Re-issue OTP for an existing otpToken |
| POST /public/2fa/verify | Login-time TOTP or emailed step-up code verification (uses loginToken) |
| POST /public/2fa/backup-verify | Login-time backup-code verification |
| POST /public/register | Start a signup from a PartnerRegistration; emails the confirmation code |
| POST /public/register/confirm | Confirm ?email=&code=; answers session tokens and the partner result (confirmRegister) |
| POST /public/register/exchange | Trade a single-use hand-off {code} for a session (exchangeHandoff) |
| POST /api/register/partner | Create the signed-in user's partner from a PartnerSetup (createPartner) |
| POST /public/password/change | Change {username, old_password, new_password} (chpass); keel revokes every refresh token |
| POST /public/password/forgot | Email a reset code for {username} (forgotPassword) |
| POST /public/password/reset | Set the new password from {username, code, new_password} (confirmChpass); revokes every refresh token |
| POST /public/token/refresh | Rotate {refreshToken} → new {token, refreshToken}; 401 ends the session |
| POST /public/logout | Revoke {refreshToken} (called by BaseAuthService.logout()) |
| GET /public/ws | keel realtime hub handshake (RealtimeService; JWT via ?token=) |
| GET /api/config/appdata | Metadata (menus, permissions, table definitions) |
| POST/GET/DELETE /api/{version}/{table}/list\|get\|post\|delete | CRUD operations (paginated list) |
| POST /api/user/2fa/setup | Generate TOTP secret, QR URI, and backup codes — requires re-auth |
| POST /api/user/2fa/verify | Confirm 2FA setup by verifying TOTP code |
| POST /api/user/2fa/disable | Disable 2FA — requires password + current TOTP code |
| GET /api/user/trusted-device/list | List trusted devices |
| POST /api/user/trusted-device/revoke | Revoke a trusted device |
| GET /api/user/sessions | The caller's live sign-in sessions (getSessions) |
| POST /api/user/sessions/revoke | Sign out one session {id} (revokeSession); 404 session_not_found |
| POST /api/user/logout-everywhere | Sign out of all devices — requires re-auth |
| DELETE /api/user/account | Soft-delete the caller's account — requires re-auth |
| GET /api/notifications | Notification inbox page — ?before=<id>&limit=<n>, newest first (keel v1.2.71) |
| POST /api/notifications/mark_read | Mark one message read — {"id": "<id>"} |
| POST /api/notifications/mark_all_read | Mark the caller's whole inbox read |
| POST /api/push/register | Register an FCM / APNs token |
| POST /api/push/revoke | Revoke an FCM / APNs token |
| POST /api/billing/checkout | Create provider-hosted checkout session — JWT-gated, allowlist-validated |
| POST /api/billing/setup-intent | Provider setup intent for on-device card capture (BillingService.createSetupIntent()) |
| GET /public/plans | Subscription plan catalog (unauthenticated) |
| GET /api/billing/subscription | Active subscription for the partner |
| POST /api/billing/subscription/cancel | Cancel auto-renew |
| GET /api/billing/invoices | Invoice history |
| GET /api/billing/payment-methods | Saved payment methods |
Device registration happens within /public/2fa/verify when trustDevice=true. Since keel v0.9 / sail v0.9.0, the trusted-device credential is a server-minted keel_td cookie (HttpOnly + Secure + SameSite=Strict) — not a client-supplied fingerprint. sail sets withCredentials: true on every session-minting call (password, Google, social, OTP, 2FA and backup verify, registration confirm and exchange) so this cookie and keel's keel_device cookie (keel v1.2.106: a recognized device gets no new-device notice or step-up) round-trip; see Enabling 2FA → Cross-origin cookie requirement below.
Enabling 2FA in your project
Add the 2FA verification route to your publicRoutes config:
import { SessionsComponent, TrustedDevicesComponent, TwoFactorSetupComponent, TwoFactorVerifyComponent } from '@nauticana/sail';
// In publicRoutes:
{ path: 'login/2fa', component: TwoFactorVerifyComponent }
// In extraRoutes or authenticated routes (optional — for user self-service):
{ path: 'security/2fa', component: TwoFactorSetupComponent }
{ path: 'security/devices', component: TrustedDevicesComponent }
{ path: 'security/sessions', component: SessionsComponent }The login flow redirects twoFactorRequired responses to /login/2fa. With twoFactorMethod: 'email', the page explains the new-device check and hides device trust and backup codes. Sign-in refusals signin_network and stepup_unavailable have user-facing messages.
SessionsComponent (<sail-sessions>) lists the sessions with their sign_in_method captions, marks the current one, and signs out any other.
ApiKeysComponent (<sail-api-keys [scopes] [scopeDomains]>) lists the partner's keys and uses the backend-provided generate and restrict table actions; controls appear only for granted actions. Offer scopes as LabelOption[] or as constant domains whose values the component loads, leaving out those the caller may not mint; with none, keel's ScopePolicy supplies them. A key may carry an expiry, shown in its own column. The role also needs generic SELECT and DELETE grants to list and revoke keys. ConnectedAppsComponent (<sail-connected-apps>) lists and disconnects OAuth clients through keel's GrantHandler routes (v1.2.112, RestURL.oauthGrantsURL and oauthGrantRevokeURL).
Cross-origin cookie requirement
The "trust this device" feature (keel v0.9+) is backed by a server-set HttpOnly cookie named keel_td. The lifecycle is entirely server-driven:
- On
POST /public/2fa/verifywithtrustDevice: true, keel mints a 32-byte secret, stores its SHA-256, and returns the raw secret to the browser only as aSet-Cookie: keel_td=…header (never in the JSON body). - On the next
POST /public/login/local(or/public/login/gmail), the browser sends thekeel_tdcookie back, keel hashes it, finds the matching row, and skips the 2FA prompt.
For the browser to store and resend that cookie, the requests must be credentialed. sail already sets withCredentials: true on these calls — you do not add this in your app code. But the keel backend must permit credentialed CORS, or the browser drops the cookie and every login re-prompts for 2FA:
- Same-origin deployments (SPA served from the same host as the API) work with no extra config — credentialed same-origin requests are always allowed.
- Cross-origin deployments (SPA on a different host/port than the API) require the keel
HttpBackendto set:AllowCredentials: true→ emitsAccess-Control-Allow-Credentials: trueOrigin= the exact SPA origin (e.g.https://app.example.com). A wildcard*is rejected by the browser for credentialed responses — keel's CORS layer enforces this.
If "trust this device" appears to do nothing (user is 2FA-prompted on every login despite checking the box), the cause is almost always a missing AllowCredentials / wildcard-origin on the backend — open devtools and confirm the keel_td cookie is actually set after a successful verify.
Session token storage
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.
Refresh-token rotation
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.
A successful chpass() or confirmChpass() clears any current session and lands on login?passwordChanged=true, where <sail-login> asks the user to sign in with the new password.
Realtime
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, including keel's {op: "notification"} frames; 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.
Billing
sail ships a shared BillingService and ten billing components backed by keel's payment endpoints (see keel/SHARED_PAYMENT.md for the provider contract — Stripe by default, pluggable).
Service
import { BillingService } from '@nauticana/sail';
@Component({ /* ... */ })
export class PricingPage {
private billing = inject(BillingService);
readonly plans = toSignal(this.billing.listPlans(), { initialValue: [] });
readonly sub = toSignal(this.billing.getSubscription());
}| Method | Endpoint | Returns |
|--------|----------|---------|
| listPlans() | GET /public/plans | Observable<PublicPlan[]> |
| createCheckout(req) | POST /api/billing/checkout | Observable<CheckoutResponse> |
| getSubscription() | GET /api/billing/subscription | Observable<Subscription> |
| cancelSubscription() | POST /api/billing/subscription/cancel | Observable<void> |
| changePlan(planId, offer?) | POST /api/billing/subscription/change | Observable<PlanChangeResult> |
| checkChange(planId) | POST /api/billing/subscription/change/check | Observable<{ overages: PlanOverage[] }> |
| setSeats(seats) | POST /api/billing/subscription/seats | Observable<void> |
| listInvoices() | GET /api/billing/invoices | Observable<Invoice[]> |
| listPaymentMethods() | GET /api/billing/payment-methods | Observable<PaymentMethod[]> |
| createPortalSession() | POST /api/billing/portal | Observable<PortalResponse> |
| listUsage() | GET /api/billing/usage | Observable<UsageMeter[]> |
cancelSubscription(), changePlan() and createPortalSession() answer 403 (keel v1.2.89)
unless the user holds PARTNER_PLAN_SUBSCRIPTION CANCEL / CHANGE / PORTAL, seeded for
PARTNER_ADMIN. Test a failure with isPermissionDenied(err) and report it as not permitted;
<sail-portal-button> does.
Components
Plan picker — pure presentational, no API calls:
<sail-plan-selector
[plans]="plans()"
[selected]="selectedPlan()"
[features]="{ PRO: ['Unlimited users', '24/7 support'] }"
(selectionChange)="selectedPlan.set($event)">
</sail-plan-selector>Offer / price picker (v1.0.5) — a plan no longer has a single price. keel v1.0.5 sells per-plan offers in the subscription_plan_price table (billing cycle × commitment term, each with its own provider_price_id), surfaced as PublicPlan.prices: PlanPrice[]. The customer picks an offer at checkout; its priceId is what flows to <sail-checkout-button>:
<sail-price-selector
[prices]="selectedPlan().prices ?? []"
[selected]="offer()?.priceId"
(selectionChange)="offer.set($event)">
</sail-price-selector><sail-price-selector> is presentational (a radiogroup of offers), formats each amountMinor at the offer's exponent, and maps PERIOD_TYPE codes to copy via [cycleLabels]. It emits the full PlanPrice so you keep the chosen terms, not just the id.
Checkout button — calls createCheckout() and redirects to the provider-hosted URL. Feed it the chosen offer's priceId:
<sail-checkout-button
[priceId]="offer()!.priceId!"
[mode]="'subscription'"
[successUrl]="'https://app.example.com/billing/done'"
[cancelUrl]="'https://app.example.com/billing'"
[email]="userEmail">
</sail-checkout-button>For one-off charges use [mode]="'payment'". For "save a card without charging" (Stripe SetupIntent), use [mode]="'setup'" and omit [priceId] — keel rejects a non-empty priceId in setup mode with 400.
keel allowlists —
priceId,successUrl, andcancelUrlmust each match the server-sideAllowedPriceIDs/AllowedRedirectHostsallowlists; otherwise the request is rejected with 400. Configure these on your keel deployment.
AllowedRedirectHostsmatching is hostname-only by default — entries without a colon (e.g.app.example.com) tolerate any port. Add an explicit port (e.g.app.example.com:8443) only when port-strict matching is intentional.
Metadata stringification. CheckoutRequest.metadata keys and values are typed as strings because that's what providers store. keel stringifies numeric and boolean metadata server-side, so a partner_id: 42 written in your domain handler arrives back in webhook payloads as "42". Stringify on the way in:
metadata: {
partner_id: String(partnerId),
source: 'pricing-page',
}Payment methods — lists saved methods with loading and empty states:
<sail-payment-methods></sail-payment-methods>Registration → checkout flow
BaseAuthService.confirmRegister(email, code) signs the user in from the tokens keel answers (keel v1.2.94), so no separate login follows. ConfirmRegisterComponent then sends the user to resp.paymentUrl when the backend returns paymentRequired: true with one; otherwise the post-login navigation applies.
A two-step signup registers the account alone (a PartnerRegistration without partner fields), then calls createPartner(setup) once signed in. It posts keel's PartnerSetup — application fields ride in extra — rotates the refresh token so the session carries the new partner, reloads appdata and routes, and emits PartnerCreated. When paymentRequired comes without a paymentUrl, the application's billing page opens the checkout. exchangeHandoff(code) completes a sign-in that arrived by redirect with keel's single-use code, including the 2FA step.
BaseAsync
Both CheckoutButtonComponent and PaymentMethodsComponent extend BaseAsync — an abstract class that bundles loading(), errorMessage(), successMessage() signals and a run(obs, onSuccess, fallbackError) helper. Reuse it in your own async components:
import { BaseAsync, BillingService } from '@nauticana/sail';
@Component({ /* ... */ })
export class MyWidget extends BaseAsync {
private billing = inject(BillingService);
cancel() {
this.run(
this.billing.cancelSubscription(),
() => this.successMessage.set('Cancelled.'),
'Could not cancel.',
);
}
}Template:
<button (click)="cancel()" [disabled]="loading()">Cancel plan</button>
@if (errorMessage()) { <div class="auth-error">{{ errorMessage() }}</div> }
@if (successMessage()) { <div class="auth-success">{{ successMessage() }}</div> }Suggested styles for billing components
Add to src/styles.css to match the rest of the library:
/* Plan selector */
.plan-selector { display: flex; gap: 1rem; flex-wrap: wrap; }
.plan-card { flex: 1 1 220px; padding: 1rem; border: 1px solid #ccc; border-radius: 8px; }
.plan-selected { border-color: #1976d2; box-shadow: 0 0 0 2px #1976d2; }
.plan-caption { margin: 0 0 .5rem; }
.plan-price .plan-amount { font-size: 1.5rem; font-weight: 600; }
.plan-features { padding-left: 1.2rem; }
/* Payment methods */
.payment-methods-list { list-style: none; padding: 0; }
.payment-method { display: flex; justify-content: space-between; padding: .5rem 0; }
.payment-default-badge { font-size: .75rem; background: #e0e0e0; padding: 2px 8px; border-radius: 12px; }
/* Checkout button */
.checkout-button { display: flex; flex-direction: column; gap: .5rem; }Customer portal (v1.0.0)
createPortalSession() + <sail-portal-button> mirror checkout — POST /api/billing/portal, then redirect to the provider-hosted customer portal where the partner manages their payment method / subscription:
<sail-portal-button [label]="'Manage billing'" [providerManaged]="sub()?.providerManaged ?? true" />Subscription.providerManaged is false for a subscription the provider does not hold (no provider subscription or customer); the button then hides. Gate an app's cancel and plan-change actions on it too.
Usage meters (v1.0.0)
listUsage() returns UsageMeter[] ({ resource, caption, used, limit }, matching keel's UsageItem); <sail-usage-meter> renders one progress bar per resource labelled by caption (a limit < 0 shows "Unlimited"):
readonly meters = toSignal(this.billing.listUsage(), { initialValue: [] });<sail-usage-meter [meters]="meters()"></sail-usage-meter>Redirect-action (v1.0.0)
A TableAction with kind: 'R' (redirect — the constant_value code) makes BaseTable.executeAction POST and then follow the returned { url } instead of refreshing — so checkout / portal / any provider-hosted handoff can be declared as a basis table_action row with no bespoke component. The backend action handler returns { "url": "https://…" }.
Response-shape contract. The generic redirect-action path reads a bare
urlbecause it's polymorphic — one dispatch for checkout / portal / export / etc., where the action's identity lives in the metadata, not the payload. keel'sWrapTableActionenforces no shape; your inner handler must return{ url }. The typed billing endpoints keep specific names instead (createCheckout()→checkoutUrl,createPortalSession()→portalUrl). So the same portal concept has two response shapes depending on the path: wire it as<sail-portal-button>(typed →portalUrl) or as atable_actionredirect (generic →url), not both.
Action parameters (v1.1.25)
An action with parameters (keel v1.2.75 table_action_parameter) opens a dialog before posting: one field per parameter, typed by dataType (boolean checkbox, numeric, date, timestamp, file, secret or password as a masked input posted as a string, otherwise text), or a dropdown when lookupTable names a table in the client cache (a foreign_key_lookup dropdown table); a numeric dataType still posts the chosen key as a number. The dialog shows confirmMessage in place of the native prompt and posts {...primaryKeyValues(record), ...values}; empty optional values are left out. A file parameter turns the post into multipart/form-data, so a table-level upload action whose parameters are file, document_type (lookup document_type), title, … posts straight to keel's DocumentHandler.Upload. An action without parameters behaves as before.
Status chip (v1.0.1)
<sail-status-chip> resolves a code to its caption from the client-cache constant_value dictionary — no per-app @switch. It defaults to the subscription_status domain (so T trialing / X past-due render with no app code) and works for any constant domain via [domain]. The code is exposed as [attr.data-status] so the app can colour the chip:
<sail-status-chip [value]="sub()?.status"></sail-status-chip>
<sail-status-chip [value]="client.status" domain="agency_client_status"></sail-status-chip>Replaces a downstream app's hardcoded status
@switch(e.g. seo's): captions are backend-authoritative — when a dictionary row is absent the raw code shows, never a fabricated label.
Trial banner (v1.0.1)
<sail-trial-banner> shows "Trial ends in N days" from Subscription.trialEnd; it renders nothing when the value is empty, unparseable, or already past, so it can sit unconditionally in the template:
<sail-trial-banner [trialEnd]="sub()?.trialEnd"></sail-trial-banner>Seat selector (v1.0.1)
<sail-seat-selector> is a presentational quantity stepper seeded from Subscription.seats. Feed the result into checkout ([quantity] / metadata) for new subs, or BillingService.setSeats() for an existing one:
<sail-seat-selector [seats]="sub()?.seats ?? 1" [min]="1" (seatsChange)="seats.set($event)"></sail-seat-selector>Plan change vs first-time checkout (v1.0.1)
First-time checkout uses <sail-checkout-button> (provider-hosted). For an existing subscription, upgrade/downgrade is a distinct path — BillingService.changePlan(planId, offer?) routes to keel's SubscriptionLifecycle.ChangePlan via an endpoint the app exposes and answers { status: 'changed' | 'scheduled' }: a scheduled change sets Subscription.scheduledAt and scheduledPlanId, and re-selecting the current plan cancels it. A paid target the partner exceeds (more live-counted resources than it allows) is refused with 409; call checkChange(planId) first to list each PlanOverage to remove. Wire the picker's selection to whichever path applies:
onPlanPicked(planId: string) {
this.sub() ? this.billing.changePlan(planId).subscribe() : this.startCheckout(planId);
}offer (keel v1.2.126) is one of the target plan's prices — a PlanPrice fits — and moves the subscription to that billing cycle and term, on the same plan or another; omitted, keel keeps the current terms. For a paid subscriber whose plan sells more than one offer, show <sail-price-selector> and pass the chosen offer; Subscription.billingCycle marks the current one:
@if ((currentPlan()?.prices?.length ?? 0) > 1) {
<sail-price-selector [prices]="currentPlan()!.prices!" [selected]="offer()?.priceId"
(selectionChange)="billing.changePlan(sub()!.planId, $event).subscribe()" />
}<sail-plan-selector> varies its CTA copy by PublicPlan.activationMode ("Start trial" for T, "Subscribe" for P, "Activate" for F); override or extend via [ctaLabels]="{ S: 'Add seats' }".
Dunning banner (v1.0.1)
<sail-dunning-banner> shows only when Subscription.status === 'X' (past-due / keel SetDunningState), prompting the partner to update their card and deep-linking to the provider portal via an embedded <sail-portal-button>:
<sail-dunning-banner [status]="sub()?.status" [providerManaged]="sub()?.providerManaged ?? true"></sail-dunning-banner>Scheduled change banner (v1.1.41)
<sail-scheduled-change-banner> shows while Subscription.scheduledAt is set: the ending plan, its end date and the plan that follows (captioned from [plans]), with a Keep my plan button that re-selects the current plan and emits kept:
<sail-scheduled-change-banner [subscription]="sub()" [plans]="plans()" (kept)="reload()" />Compose a full Billing screen
A complete screen is the pieces above assembled over BillingService — no metadata CRUD (billing data is partner-scoped, read-mostly, money-formatted, with an external-redirect action, which TableList/TableEdit don't model):
<sail-dunning-banner [status]="sub()?.status"></sail-dunning-banner>
<sail-trial-banner [trialEnd]="sub()?.trialEnd"></sail-trial-banner>
<sail-scheduled-change-banner [subscription]="sub()" [plans]="plans()" (kept)="reload()" />
<sail-status-chip [value]="sub()?.status"></sail-status-chip>
<sail-plan-selector [plans]="plans()" [selected]="sub()?.planId" (selectionChange)="onPlanPicked($event)"></sail-plan-selector>
<sail-price-selector [prices]="selectedPlan()?.prices ?? []" [selected]="offer()?.priceId" (selectionChange)="offer.set($event)"></sail-price-selector>
<sail-seat-selector [seats]="sub()?.seats ?? 1" (seatsChange)="seats.set($event)"></sail-seat-selector>
<sail-checkout-button [priceId]="offer()!.priceId!" [quantity]="seats()" [successUrl]="okUrl" [cancelUrl]="backUrl"></sail-checkout-button>
<sail-usage-meter [meters]="usage()"></sail-usage-meter>
<sail-payment-methods></sail-payment-methods>
<sail-portal-button [providerManaged]="sub()?.providerManaged ?? true" />
<!-- invoices: render listInvoices() rows directly -->Agent UI (v1.1.20)
Agent Studio and agent-conversation UI live in a secondary entry point, @nauticana/sail/agent. Nothing in it is exported from @nauticana/sail, so an app that never imports it bundles none of it. It needs no additional peer dependency. The wire types match Scout v1.2.16.
import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@nauticana/sail/agent';| Export | Purpose |
|---|---|
| AgentStudioService | Client for Scout's core Studio routes under /api/agent-studio/ (fixed, unversioned paths): the features manifest, list, draft, settings, create, rename, archive, enable, test, publish, restore, reset, default, history, audit, release sections, models. Models mirror the backend's JSON names. |
| sail-studio-page | Embeddable Studio: an agent rail grouped by [groupBy] (agent type by default; pass a function for workflow or position), and Overview, Prompts, Settings (scoped bindings and what they resolve to), Test, and History (diff, restore, audit) tabs. Product tabs are <ng-template sailStudioTab="id" label="Label" let-agent>. A control shows only when the agents feature lists its action and the caller holds the AGENT_STUDIO grant at agent:<name> (agent_type:<type> for create); hiding is never the authorization. hasUnsavedChanges() serves a canDeactivate guard. |
| sail-studio-page Guardrails tab | Shown when the features manifest lists guardrails: inherited layers read-only, the agent's own rules as an editable JSON array saved against expected_agent_revision, and its safety events paged by before. Edit needs the feature's EDIT action. The rail captions Scout's fixed AgentReadiness values (AGENT_READINESS_CAPTIONS). |
| sail-assistant-activity | The tenant's decision records from Scout's decisions feature (GET /api/agent-studio/decisions, AGENT_AUDIT VIEW), newest first and paged by next_before: when, action, resource, outcome caption, who, app, and whom it acted for, named by the product's Scout Labeler (kind and id, or the client ref, without one). [category] and [outcome] filter. |
| sail-turn-thread | The signed-in person's turns from GET {base}turns (ConversationService.turns), newest first, paged by next_before and optionally narrowed to [workRef]: turn_status caption, settled cost in minor units with its exponent, agent and release version. Each task_kind renders its result through <ng-template sailTurnResult="kind" let-turn> ("*" for any other kind; let-turn is a ConversationTurn); a failed turn without error text shows [failedText]; the first page reloads every [pollMs] while a turn is nonterminal. |
| sail-approval-inbox / ApprovalService | The signed-in person's pending approvals (GET /api/approval/pending) and MCP confirmations (GET /api/approval/confirmations) grouped as waiting, submitted, needing reconciliation and recent; each list loads independently and a 404 leaves it empty. Approvals are approved, rejected, or edited (POST /api/approval/resolve); confirmations are confirmed, declined, withdrawn, or reconciled as executed or failed through /api/approval/confirmation/*, each control shown only when Scout says the person may. Approving and withdrawing ask once more; reconciling requires a note of up to 500 characters. [open] selects an item by key or confirmation id (an emailed link); [summary] names a confirmation in the list and heading (default its tool); [historyDays] (30) and [limit] (200) bound the closed ones listed by creation time and count. People, workspaces and apps show their Scout Labeler captions. A tool's preview renders through <ng-template sailApprovalPreview="tool" let-c> ("*" for any other tool), otherwise as JSON. A 404 or 409 reloads the list; (decided) and (confirmationChanged) emit the result. |
| studioErrorMessage(err) | One message for a failed Studio call: the conflict cause, the field errors, or the detail. |
| studioConflictCause(err) | 'revision' (reload the draft), 'sealed' (a layer sits under a sealed one) or 'other' for a 409. |
| studioFieldErrors(err) / retryAfterSeconds(err) | Field errors of a 400; Retry-After of a 429/503. |
| sail-prompt-language-editor | One language's prompt sections. Emits the whole language on every edit — a save must send every layer back, because an editable layer missing from the request ends its binding. Emits resetRequested with the scope, section, and language to reset. |
| sail-prompt-section-editor | One section's layers in server order. Read-only layers render as text; editable ones edit instruction, output, merge_mode, sealed. A sealed layer locks every layer below it. Effective text is shown as received, never merged client-side; after a save, replace the whole draft with the response. |
| sail-release-sections | Frozen sections of a release with the layer (source) that decided each. |
| sail-turn-event-list | Renders typed turn events in frame order; proposal, result, effect, and approval of one call meet by call_id. Effect status unknown is its own state. Unknown kinds are ignored; extension events render only through [extensionTemplate]. |
| TurnStream | Per-turn stream state: accept(frame) accepts Scout's zero-based frames and drops frames already rendered, cursor() is the next sequence wanted for inclusive replay, a suspension frame (approval_pending, even when marked final) sets awaiting_approval without advancing the cursor so the resumed frame at the same sequence is accepted, markReplayExpired() + acceptStoredResult(frame) handle an expired replay window, markCancelRequested() holds cancelling until the final frame. |
| sail-turn-stream-view | TurnStream + event list + status + cancel button (cancelRequested). |
| sail-decision-timeline | Read-only decision chain of one request, grouped by category. |
| ConversationService | Transport for Scout's conversation bridge: submit, stream(requestId, turnStream), cancel, turns(workRef, before, limit), and agents(agentType) — the ConversationAgents (agent_name, display_name, is_default, release) of that type the person may address, default first, whose agent_name goes on a turn request. Mount point injected through CONVERSATION_BASE_PATH; the service is a root singleton, so provide the token at bootstrap, or both together on a component. |
| SseParser | Incremental text/event-stream parser: push(chunk) returns whole events out of chunks that split anywhere, including between the CR and LF of a CRLF; finish() flushes what the closing body left pending. |
DecisionRecord is Scout's api.DecisionRecord (outcome A, D, or P, captioned from audit_decision_outcome); the timeline takes the records of one request, the activity table pages the tenant's. Style the .prompt-*, .release-*, .turn-*, .decision-*, .studio__*, and .approval-inbox__* class hooks. Scout's short codes render through sailCaption, a pipe over the backend's constant captions.
Conversation transport (v1.1.22)
Scout v0.7.1 fixed the wire contract, so the transport is no longer app-specific.
ConversationService speaks it and feeds a TurnStream; the app supplies the mount
point, the turn input payload, and its own renderers.
providers: [{ provide: CONVERSATION_BASE_PATH, useValue: '/api/wingmate/' }]const stream = new TurnStream(requestId);
this.conversation.submit({ request_id: requestId, conversation_id: conversationId, agent_type: 'wingmate', input })
.pipe(switchMap(() => this.conversation.stream(requestId, stream)), takeUntilDestroyed())
.subscribe({ error: (err) => this.onStreamError(err) });| Method | Wire |
|---|---|
| submit(request) | POST {base}turn → 202 {request_id}. Goes through HttpClient, so the interceptors apply. |
| stream(requestId, turnStream) | GET {base}turn/stream?request_id=&cursor= as SSE. Reads from turnStream.cursor(), feeds every event: turn frame in, and emits the ones the stream accepted. Completes when the server closes the delivery; unsubscribing aborts the request. |
| cancel(requestId, reason?) | POST {base}turn/cancel → 202. Cancel is a request — call turnStream.markCancelRequested() and wait for the final frame. |
Notes that decide whether an adoption is correct:
- The stream carries its own bearer.
EventSourcecannot send anAuthorizationheader, so this is afetchbody reader. It reads the samejwtkey the interceptor reads and, on a401, rotates once throughBaseAuthService.refreshSession()— the shared, de-duplicated rotation — then retries. The interceptor's auth-loop circuit breaker does not cover the stream. - Reconnect is the same call.
stream()re-read fromstream.cursor()resumes exactly where the last accepted frame left off. A suspension frame (approval_pending) does not advance the cursor, so reconnecting after the approval is decided picks up the resumed frame at the same sequence. 410is an expired replay window under a still-running turn. No stored result exists yet, which is why it is an error and not a frame. The service callsmarkReplayExpired()and errors with aConversationStreamFailurecarrying status410; callingstream()again later self-heals, because once the turn is terminal the backend answers the same expired cursor with the stored final frame — delivered as an ordinaryevent: turn, carrying the cursor it was asked with, soaccept()takes it.event: errorcarriesConversationStreamError(status,detail,request_id) — a failure raised after the stream's200was committed. It surfaces as the sameConversationStreamFailure.- Unknown
TurnEventkinds are dropped at the transport, and a frame missingrequest_id,sequenceoreventsis ignored rather than fed in.
Agency / reseller UI (v1.1.14)
sail v1.1.14 supplies composable, unstyled agency components over keel v1.2.41. The app owns routes, route guards, brand copy, global CSS, and any columns that refer to an app-specific managed entity.
AgencyService covers profile enrollment, invitation staging/acceptance,
delegation/revocation, per-client referral/wholesale selection, earnings, and
payout-profile selection. Its DTOs use the upstream names
agency_client_invitation, agency_client_delegation, and
agency_client_rate; there is no client-side rule-schedule model.
The enrollment method is enroll(); downstream enrollReferral() calls must
be renamed when adopting this release.
Compose an agency page from the pieces you need:
<sail-agency-clients
[extraColumns]="clientColumns"
[showBillingModelControls]="true" />
<sail-agency-earnings-summary [earnings]="earnings()" [activeClients]="activeClients()" />
<sail-commission-ledger [entries]="earnings()?.entries ?? []" />
<sail-agency-payout-history [payouts]="earnings()?.payouts ?? []" />
<sail-agency-payout-profile />AgencyClientsComponent renders status values through the shared
StatusChipComponent and backend constant catalogues. It accepts app-specific
columns rather than assuming the managed entity. Accepted clients begin in
referral mode; wholesale controls appear only when the profile permits them and
the app opts in with showBillingModelControls.
Load keel's v1.2.40 basis seed so status chips receive these canonical domains:
| Domain | Values |
|---|---|
| agency_client_status | S Staged, I Invited, V Client, X Cancelled |
| agency_billing_model | R Referral, W Wholesale |
| agency_commission_status | H Held, P Payable, R Reserved, D Paid, O Offset |
| agency_payout_status | C Created, P Processing, D Paid, F Failed, R Returned, V Reversed, M Manual review |
Do not substitute app-local domain names such as billing_model or
commission_status; a missing canonical domain deliberately renders the raw
code so configuration drift remains visible.
AgencyAcceptComponent keeps consent explicit and persists an invitation token
when the authenticated user still needs to create a business partner. Consumers
handle its navigation outputs and route back after business setup. Other 409
conflicts, such as an existing delegation or self-delegation, remain on the
invitation screen as errors instead of entering the business-setup flow. The
branch uses keel's stable RFC 7807 code: "agency_no_partner" (keel v1.2.41),
not error prose.
The payout-profile component composes the existing provider-onboarding surface and only allows a fully onboarded destination returned by keel. Mount it only for an approved, non-suspended agency; keel intentionally rejects its GET for a pending profile. The ledger and payout-history components show default empty mess
