@nauticana/sail 1.1.21 → 1.1.22

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
@@ -649,8 +649,56 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
649
649
  | `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. |
650
650
  | `sail-turn-stream-view` | `TurnStream` + event list + status + cancel button (`cancelRequested`). |
651
651
  | `sail-decision-timeline` | Read-only decision chain of one request, grouped by category. |
652
+ | `ConversationService` | Transport for Scout's conversation bridge: `submit`, `stream(requestId, turnStream)`, `cancel`. Mount point injected through `CONVERSATION_BASE_PATH`. |
653
+ | `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. |
652
654
 
653
- Transport is app-owned: map your reply frames onto `TurnReplyFrame` and your audit rows onto `DecisionRecord`, then feed them in. Style the `.prompt-*`, `.release-*`, `.turn-*`, and `.decision-*` class hooks.
655
+ Map your audit rows onto `DecisionRecord` to feed the timeline. Style the `.prompt-*`, `.release-*`, `.turn-*`, and `.decision-*` class hooks.
656
+
657
+ ### Conversation transport (v1.1.22)
658
+
659
+ Scout `v0.7.1` fixed the wire contract, so the transport is no longer app-specific.
660
+ `ConversationService` speaks it and feeds a `TurnStream`; the app supplies the mount
661
+ point, the turn `input` payload, and its own renderers.
662
+
663
+ ```ts
664
+ providers: [{ provide: CONVERSATION_BASE_PATH, useValue: '/api/wingmate/' }]
665
+ ```
666
+
667
+ ```ts
668
+ const stream = new TurnStream(requestId);
669
+ this.conversation.submit({ request_id: requestId, conversation_id: conversationId, agent_id: 'wingmate', input })
670
+ .pipe(switchMap(() => this.conversation.stream(requestId, stream)), takeUntilDestroyed())
671
+ .subscribe({ error: (err) => this.onStreamError(err) });
672
+ ```
673
+
674
+ | Method | Wire |
675
+ |---|---|
676
+ | `submit(request)` | `POST {base}turn` → `202 {request_id}`. Goes through `HttpClient`, so the interceptors apply. |
677
+ | `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. |
678
+ | `cancel(requestId, reason?)` | `POST {base}turn/cancel` → `202`. Cancel is a request — call `turnStream.markCancelRequested()` and wait for the final frame. |
679
+
680
+ Notes that decide whether an adoption is correct:
681
+
682
+ - **The stream carries its own bearer.** `EventSource` cannot send an `Authorization`
683
+ header, so this is a `fetch` body reader. It reads the same `jwt` key the interceptor
684
+ reads and, on a `401`, rotates once through `BaseAuthService.refreshSession()` — the
685
+ shared, de-duplicated rotation — then retries. The interceptor's auth-loop circuit
686
+ breaker does **not** cover the stream.
687
+ - **Reconnect is the same call.** `stream()` re-read from `stream.cursor()` resumes
688
+ exactly where the last accepted frame left off. A suspension frame
689
+ (`approval_pending`) does not advance the cursor, so reconnecting after the approval is
690
+ decided picks up the resumed frame at the same sequence.
691
+ - **`410` is an expired replay window under a still-running turn.** No stored result
692
+ exists yet, which is why it is an error and not a frame. The service calls
693
+ `markReplayExpired()` and errors with a `ConversationStreamFailure` carrying status
694
+ `410`; calling `stream()` again later self-heals, because once the turn is terminal the
695
+ backend answers the same expired cursor with the stored final frame — delivered as an
696
+ ordinary `event: turn`, carrying the cursor it was asked with, so `accept()` takes it.
697
+ - **`event: error`** carries `ConversationStreamError` (`status`, `detail`, `request_id`) —
698
+ a failure raised after the stream's `200` was committed. It surfaces as the same
699
+ `ConversationStreamFailure`.
700
+ - Unknown `TurnEvent` kinds are dropped at the transport, and a frame missing
701
+ `request_id`, `sequence` or `events` is ignored rather than fed in.
654
702
 
655
703
  ## Agency / reseller UI (v1.1.14)
656
704
 
@@ -906,7 +954,7 @@ Horizontal building blocks for an entitlement-aware dashboard. All are presentat
906
954
  - **`NoSourceOverlayComponent`** (`sail-no-source-overlay`) — source-readiness gate. Takes `[sourceName]` and emits `connect`; while `[unavailable]`, its protected `<ng-template>` is never instantiated. Use this for included features whose provider is not connected or whose collector is unavailable—never show an upgrade prompt for this state.
907
955
  - **`ActionCenterComponent`** (`sail-action-center`) — prioritized next-best-action list from `[actions]: ActionItem[]` (sorted by `priority`, done items show a check). Emits `act`.
908
956
  - **`EntitySelectorComponent`** (`sail-entity-selector`) — scope dropdown over `[entities]: EntityOption[]`; resolves `[selected]` or the first active entity and emits `selectionChange`.
909
- - **`VerificationFlowComponent`** (`sail-verification-flow`) — self-service request → enter-code → verified UI (a 2FA sibling), backend-agnostic: emits `requestVerification` / `confirmCode`, driven by `[step]` + `[errorMessage]` the host feeds back.
957
+ - **`VerificationFlowComponent`** (`sail-verification-flow`) — self-service request → enter-code → verified UI (a 2FA sibling), backend-agnostic: emits `requestVerification` / `confirmCode`, driven by `[step]` + `[errorMessage]` the host feeds back. `[resendAfterSeconds]` holds `Resend code` for a cooldown and `[codeExpiresInSeconds]` blocks `Confirm` once the issued code expires; both default to `0`, which keeps the button always enabled and shows no expiry. Both countdowns restart when the component emits `requestVerification`. `[disabled]` blocks all actions while the host is processing one. `[errorMessage]` also renders in the `idle` step, so a failed send is reported where it happened.
910
958
 
911
959
  ```html
912
960
  <sail-data-card heading="Trends" [state]="state()" description="Weekly visibility trend" (upgrade)="goUpgrade()">
@@ -927,6 +975,51 @@ unless the backend sends
927
975
  `source.status` is `null` and a missing source is indistinguishable from an
928
976
  empty result.
929
977
 
978
+ ## Domain verification (v1.1.22)
979
+
980
+ `DomainVerificationComponent` (`sail-domain-verification`) is the send-code → confirm
981
+ flow for proving ownership of a domain: the domain field, the two steps, the resend
982
+ cooldown, the expiry state and the errors. Every app that verifies a domain renders
983
+ the same thing, so only the endpoints stay downstream.
984
+
985
+ The endpoints reach it through the `DOMAIN_VERIFIER` port — sail ships no
986
+ implementation and knows none of the paths:
987
+
988
+ ```ts
989
+ export interface DomainVerifier {
990
+ requestVerification(domain: string): Observable<unknown>;
991
+ confirm(domain: string, code: string): Observable<unknown>;
992
+ }
993
+ ```
994
+
995
+ ```ts
996
+ // app config — any service with those two methods satisfies the port
997
+ providers: [{ provide: DOMAIN_VERIFIER, useExisting: RegistrationService }]
998
+ ```
999
+
1000
+ ```html
1001
+ <sail-domain-verification
1002
+ [domain]="domainUrl()"
1003
+ [resendAfterSeconds]="60"
1004
+ [codeExpiresInSeconds]="900"
1005
+ (domainChange)="form.controls.domainUrl.setValue($event)"
1006
+ (verified)="domainVerified.set(true)" />
1007
+ ```
1008
+
1009
+ | Input / output | Meaning |
1010
+ |---|---|
1011
+ | `[domain]` | Initial value; the component keeps its own editable copy. |
1012
+ | `[label]` | Field label, default `Domain`. |
1013
+ | `[resendAfterSeconds]` | Resend cooldown, default `60`. |
1014
+ | `[codeExpiresInSeconds]` | Code lifetime; `0` (default) shows no expiry. Pass the backend's TTL when it publishes one. |
1015
+ | `(domainChange)` | Every edit, so the host form stays in sync. |
1016
+ | `(verified)` | Fires once with the proven domain. |
1017
+
1018
+ Editing the domain returns the flow to `idle`: a code is bound to the domain it was
1019
+ issued for. The component is unstyled — style `.domain-verification`,
1020
+ `.domain-verification-field`, `.domain-verification-verified` and the
1021
+ `.verification-*` hooks of the step UI it composes.
1022
+
930
1023
  ## Grouped review actions and diffs (v1.1.19)
931
1024
 
932
1025
  `GroupedBulkActionComponent` (`sail-grouped-bulk-action`) groups eligible rows
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.21",
3
+ "version": "1.1.22",
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
  "exports": {
@@ -7,9 +7,9 @@ import { TurnStream } from '../service/turn_stream';
7
7
  import { TurnEventListComponent } from './turn_event_list';
8
8
 
9
9
  /**
10
- * One turn's stream: its events, its status, and a cancel request. The app owns the transport
11
- * and the cancel call; on `cancelRequested` it sends the request and calls
12
- * `stream.markCancelRequested()`. Selector: <sail-turn-stream-view>.
10
+ * One turn's stream: its events, its status, and a cancel request. The host owns the cancel
11
+ * call; on `cancelRequested` it sends it — through `ConversationService.cancel` or its own
12
+ * transport — and calls `stream.markCancelRequested()`. Selector: <sail-turn-stream-view>.
13
13
  */
14
14
  @Component({
15
15
  selector: 'sail-turn-stream-view',
@@ -2,10 +2,13 @@ export * from './model/agent_studio';
2
2
  export * from './model/turn_event';
3
3
  export * from './model/reply_frame';
4
4
  export * from './model/decision';
5
+ export * from './model/conversation';
5
6
  export * from './service/agent_studio.service';
6
7
  export * from './service/turn_stream';
8
+ export * from './service/conversation.service';
7
9
  export * from './util/studio_errors';
8
10
  export * from './util/turn_timeline';
11
+ export * from './util/sse';
9
12
  export * from './component/prompt_section_editor';
10
13
  export * from './component/prompt_language_editor';
11
14
  export * from './component/release_sections';
@@ -0,0 +1,35 @@
1
+ import { InjectionToken } from '@angular/core';
2
+
3
+ /** One asynchronous turn, as Scout's conversation bridge takes it. */
4
+ export interface ConversationTurnRequest {
5
+ request_id: string;
6
+ conversation_id: string;
7
+ agent_id: string;
8
+ /** Opaque product payload; the backend forwards it to the agent unread. */
9
+ input: unknown;
10
+ }
11
+
12
+ export interface ConversationTurnAccepted {
13
+ request_id: string;
14
+ }
15
+
16
+ /** Data of an SSE `error` event: a failure raised after the stream's 200 was committed. */
17
+ export interface ConversationStreamError {
18
+ status: number;
19
+ detail: string;
20
+ request_id?: string;
21
+ }
22
+
23
+ /** Error thrown into `ConversationService.stream()`; status 410 is an expired replay window. */
24
+ export class ConversationStreamFailure extends Error {
25
+ constructor(readonly status: number, readonly detail: string, readonly requestId?: string) {
26
+ super(detail);
27
+ this.name = 'ConversationStreamFailure';
28
+ }
29
+ }
30
+
31
+ /** Where the app mounts Scout's ConversationHandler, e.g. '/api/wingmate/'. */
32
+ export const CONVERSATION_BASE_PATH = new InjectionToken<string>('SailConversationBasePath', {
33
+ providedIn: 'root',
34
+ factory: () => '/api/conversation/',
35
+ });
@@ -3,8 +3,8 @@ import { TurnEvent } from './turn_event';
3
3
  export const TURN_ERROR_CANCELED = 'canceled';
4
4
 
5
5
  /**
6
- * One reply frame as sail consumes it. The backend's reply type fixes no JSON names, so the
7
- * app's transport adapter maps its own wire shape onto this one.
6
+ * One reply frame as sail consumes it. `ConversationService` validates Scout's standard wire
7
+ * shape; app-specific transports can map their own reply shape onto this one.
8
8
  */
9
9
  export interface TurnReplyFrame {
10
10
  request_id: string;
@@ -0,0 +1,180 @@
1
+ import { Injectable, inject } from '@angular/core';
2
+ import { Observable, Subscriber, firstValueFrom } from 'rxjs';
3
+ import { BaseAuthService } from '../../service/auth.service';
4
+ import { BaseRestService } from '../../service/base_rest.service';
5
+ import {
6
+ CONVERSATION_BASE_PATH,
7
+ ConversationStreamError,
8
+ ConversationStreamFailure,
9
+ ConversationTurnAccepted,
10
+ ConversationTurnRequest,
11
+ } from '../model/conversation';
12
+ import { TurnReplyFrame } from '../model/reply_frame';
13
+ import { isKnownTurnEvent } from '../model/turn_event';
14
+ import { SseParser, ServerSentEvent } from '../util/sse';
15
+ import { TurnStream } from './turn_stream';
16
+
17
+ const REPLAY_EXPIRED = 410;
18
+
19
+ /**
20
+ * Transport for Scout's conversation bridge: `POST {base}turn`,
21
+ * `GET {base}turn/stream` as SSE, `POST {base}turn/cancel`. Submit and cancel go through
22
+ * `HttpClient` (and so through sail's interceptors); the stream cannot, because
23
+ * `EventSource` sends no `Authorization` header — it is a `fetch` body reader that carries
24
+ * the bearer itself and rotates it through `BaseAuthService.refreshSession()` once on a 401.
25
+ *
26
+ * The interceptor's auth-loop circuit breaker does not cover the stream.
27
+ */
28
+ @Injectable({ providedIn: 'root' })
29
+ export class ConversationService extends BaseRestService {
30
+ private readonly auth = inject(BaseAuthService, { optional: true });
31
+ private readonly basePath = normalizeBasePath(inject(CONVERSATION_BASE_PATH));
32
+
33
+ submit(request: ConversationTurnRequest): Observable<ConversationTurnAccepted> {
34
+ return this.http.post<ConversationTurnAccepted>(this.conversationUrl('turn'), request);
35
+ }
36
+
37
+ /** Cancel is a request: the turn stays open until its final frame arrives. */
38
+ cancel(requestId: string, reason?: string): Observable<ConversationTurnAccepted> {
39
+ return this.http.post<ConversationTurnAccepted>(this.conversationUrl('turn/cancel'), {
40
+ request_id: requestId,
41
+ reason,
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Read the reply stream from `stream.cursor()` into `stream`, emitting every accepted frame
47
+ * and completing when the server closes the delivery. Unsubscribing aborts the request; a
48
+ * reconnect — after a drop or a resolved approval — calls this again unchanged.
49
+ */
50
+ stream(requestId: string, stream: TurnStream): Observable<TurnReplyFrame> {
51
+ return new Observable<TurnReplyFrame>((subscriber) => {
52
+ const controller = new AbortController();
53
+ this.read(requestId, stream, controller.signal, subscriber).then(
54
+ () => subscriber.complete(),
55
+ (err: unknown) => { if (!controller.signal.aborted) subscriber.error(err); },
56
+ );
57
+ return () => controller.abort();
58
+ });
59
+ }
60
+
61
+ private async read(
62
+ requestId: string,
63
+ stream: TurnStream,
64
+ signal: AbortSignal,
65
+ subscriber: Subscriber<TurnReplyFrame>,
66
+ ): Promise<void> {
67
+ let response = await this.open(requestId, stream.cursor(), signal, localStorage.getItem('jwt'));
68
+ if (response.status === 401 && this.auth) {
69
+ const token = await firstValueFrom(this.auth.refreshSession());
70
+ response = await this.open(requestId, stream.cursor(), signal, token);
71
+ }
72
+ if (response.status === REPLAY_EXPIRED) {
73
+ // The window is gone and the turn has not finished, so no stored result exists yet;
74
+ // streaming again once it is terminal delivers that result instead.
75
+ stream.markReplayExpired();
76
+ throw new ConversationStreamFailure(REPLAY_EXPIRED, await failureDetail(response), requestId);
77
+ }
78
+ if (!response.ok || !response.body) {
79
+ throw new ConversationStreamFailure(response.status, await failureDetail(response), requestId);
80
+ }
81
+ // A 200 that is not the event stream (a proxy's error page, a login redirect) would
82
+ // otherwise parse to nothing and complete the turn silently.
83
+ if (!response.headers.get('Content-Type')?.includes('text/event-stream')) {
84
+ throw new ConversationStreamFailure(response.status, 'conversation stream did not return text/event-stream', requestId);
85
+ }
86
+
87
+ const reader = response.body.getReader();
88
+ const decoder = new TextDecoder();
89
+ const parser = new SseParser();
90
+ for (;;) {
91
+ const { done, value } = await reader.read();
92
+ if (done) {
93
+ for (const event of parser.push(decoder.decode())) {
94
+ this.dispatch(event, requestId, stream, subscriber);
95
+ }
96
+ for (const event of parser.finish()) {
97
+ this.dispatch(event, requestId, stream, subscriber);
98
+ }
99
+ return;
100
+ }
101
+ for (const event of parser.push(decoder.decode(value, { stream: true }))) {
102
+ this.dispatch(event, requestId, stream, subscriber);
103
+ }
104
+ }
105
+ }
106
+
107
+ private dispatch(
108
+ event: ServerSentEvent,
109
+ requestId: string,
110
+ stream: TurnStream,
111
+ subscriber: Subscriber<TurnReplyFrame>,
112
+ ): void {
113
+ if (event.event === 'error') {
114
+ const failure = parseJson<ConversationStreamError>(event.data);
115
+ const status = failure?.status ?? 0;
116
+ if (status === REPLAY_EXPIRED) stream.markReplayExpired();
117
+ throw new ConversationStreamFailure(
118
+ status,
119
+ failure?.detail ?? 'conversation stream failed',
120
+ failure?.request_id ?? requestId,
121
+ );
122
+ }
123
+ if (event.event !== 'turn') return;
124
+ const frame = toFrame(parseJson(event.data));
125
+ // A stored result answering an expired cursor arrives on this channel too, carrying the
126
+ // sequence it was asked with, so accept() takes it like any other frame.
127
+ if (frame && stream.accept(frame)) subscriber.next(frame);
128
+ }
129
+
130
+ private open(requestId: string, cursor: number, signal: AbortSignal, token: string | null): Promise<Response> {
131
+ const query = new URLSearchParams({ request_id: requestId, cursor: String(cursor) });
132
+ return fetch(`${this.conversationUrl('turn/stream')}?${query}`, {
133
+ signal,
134
+ headers: {
135
+ Accept: 'text/event-stream',
136
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
137
+ },
138
+ });
139
+ }
140
+
141
+ private conversationUrl(path: string): string {
142
+ return this.url(this.basePath + path);
143
+ }
144
+ }
145
+
146
+ function normalizeBasePath(base: string): string {
147
+ return '/' + base.trim().replace(/^\/+|\/+$/g, '') + '/';
148
+ }
149
+
150
+ function parseJson<T>(raw: string): T | undefined {
151
+ try {
152
+ return JSON.parse(raw) as T;
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ }
157
+
158
+ /** Unknown event kinds are dropped here so `TurnStream.events()` stays typed as it reads. */
159
+ function toFrame(value: unknown): TurnReplyFrame | undefined {
160
+ if (!value || typeof value !== 'object') return undefined;
161
+ const frame = value as Partial<TurnReplyFrame>;
162
+ if (typeof frame.request_id !== 'string' || typeof frame.sequence !== 'number' || !Array.isArray(frame.events)) {
163
+ return undefined;
164
+ }
165
+ return {
166
+ ...frame,
167
+ request_id: frame.request_id,
168
+ conversation_id: typeof frame.conversation_id === 'string' ? frame.conversation_id : '',
169
+ sequence: frame.sequence,
170
+ events: frame.events.filter(isKnownTurnEvent),
171
+ final: frame.final === true,
172
+ };
173
+ }
174
+
175
+ async function failureDetail(response: Response): Promise<string> {
176
+ const body = await response.text().catch(() => '');
177
+ const problem = parseJson<{ detail?: string; title?: string; error?: string }>(body);
178
+ const detail = problem?.detail ?? problem?.title ?? problem?.error ?? body;
179
+ return detail || `HTTP ${response.status}`;
180
+ }
@@ -5,9 +5,9 @@ import { TurnEvent } from '../model/turn_event';
5
5
  const terminalStatuses: ReadonlySet<TurnStreamStatus> = new Set(['completed', 'canceled', 'failed']);
6
6
 
7
7
  /**
8
- * Client-side state of one turn's reply stream. Transport stays the app's: it feeds frames to
9
- * `accept`, reconnects from `cursor()`, and reports an expired replay window. One instance per
10
- * turn; not injectable.
8
+ * Client-side state of one turn's reply stream. `ConversationService` or an app-specific
9
+ * transport feeds frames to `accept`, reconnects from `cursor()`, and reports an expired replay
10
+ * window. One instance per turn; not injectable.
11
11
  */
12
12
  export class TurnStream {
13
13
  private readonly frames = signal<TurnReplyFrame[]>([]);
@@ -0,0 +1,63 @@
1
+ export interface ServerSentEvent {
2
+ event: string;
3
+ data: string;
4
+ }
5
+
6
+ /**
7
+ * Incremental `text/event-stream` parser. A fetch body reader hands it decoded chunks —
8
+ * which split anywhere — and it returns only whole events.
9
+ */
10
+ export class SseParser {
11
+ private buffer = '';
12
+ private pendingCarriageReturn = false;
13
+
14
+ push(chunk: string): ServerSentEvent[] {
15
+ // An empty chunk must not resolve a pending CR: the LF completing it may still be coming.
16
+ if (!chunk) return [];
17
+ if (this.pendingCarriageReturn) {
18
+ this.buffer += '\n';
19
+ this.pendingCarriageReturn = false;
20
+ if (chunk.startsWith('\n')) chunk = chunk.slice(1);
21
+ }
22
+ if (chunk.endsWith('\r')) {
23
+ this.pendingCarriageReturn = true;
24
+ chunk = chunk.slice(0, -1);
25
+ }
26
+ this.buffer += chunk.replace(/\r\n?/g, '\n');
27
+ return this.drain();
28
+ }
29
+
30
+ /** Resolve a final standalone carriage return when the response body closes. */
31
+ finish(): ServerSentEvent[] {
32
+ if (this.pendingCarriageReturn) {
33
+ this.buffer += '\n';
34
+ this.pendingCarriageReturn = false;
35
+ }
36
+ return this.drain();
37
+ }
38
+
39
+ private drain(): ServerSentEvent[] {
40
+ const events: ServerSentEvent[] = [];
41
+ for (let end = this.buffer.indexOf('\n\n'); end !== -1; end = this.buffer.indexOf('\n\n')) {
42
+ const event = parseBlock(this.buffer.slice(0, end));
43
+ this.buffer = this.buffer.slice(end + 2);
44
+ if (event) events.push(event);
45
+ }
46
+ return events;
47
+ }
48
+ }
49
+
50
+ function parseBlock(block: string): ServerSentEvent | undefined {
51
+ let event = 'message';
52
+ const data: string[] = [];
53
+ for (const line of block.split(/\r\n|\r|\n/)) {
54
+ if (!line || line.startsWith(':')) continue;
55
+ const colon = line.indexOf(':');
56
+ const field = colon === -1 ? line : line.slice(0, colon);
57
+ const raw = colon === -1 ? '' : line.slice(colon + 1);
58
+ const value = raw.startsWith(' ') ? raw.slice(1) : raw;
59
+ if (field === 'event') event = value;
60
+ else if (field === 'data') data.push(value);
61
+ }
62
+ return data.length ? { event, data: data.join('\n') } : undefined;
63
+ }
@@ -7,7 +7,9 @@
7
7
  <p class="verification-intro">
8
8
  Verify ownership@if (target()) { of {{ target() }} } to unlock trusted features.
9
9
  </p>
10
- <button matButton="elevated" type="button" class="primary" (click)="requestVerification.emit()">
10
+ @if (errorMessage()) { <p class="verification-error" role="alert">{{ errorMessage() }}</p> }
11
+ <button matButton="elevated" type="button" class="primary"
12
+ [disabled]="disabled()" (click)="request()">
11
13
  Start verification
12
14
  </button>
13
15
  }
@@ -17,15 +19,26 @@
17
19
  <mat-label>Verification code</mat-label>
18
20
  <input matInput #codeInput name="code" autocomplete="one-time-code"
19
21
  [value]="code()" (input)="code.set(codeInput.value)"
20
- [disabled]="step() === 'verifying'">
22
+ [disabled]="disabled() || step() === 'verifying'">
21
23
  </mat-form-field>
22
24
  @if (errorMessage()) { <p class="verification-error" role="alert">{{ errorMessage() }}</p> }
25
+ @if (expired()) {
26
+ <p class="verification-expiry verification-expiry--expired" role="status">
27
+ That code expired. Request a new one.
28
+ </p>
29
+ } @else if (expiresIn() > 0) {
30
+ <p class="verification-expiry" aria-live="polite">Code expires in {{ expiresIn() }}s.</p>
31
+ }
23
32
  <div class="verification-actions">
24
33
  <button matButton="elevated" type="button" class="primary"
25
- [disabled]="step() === 'verifying' || !code().trim()" (click)="submit()">
34
+ [disabled]="disabled() || step() === 'verifying' || !code().trim() || expired()" (click)="submit()">
26
35
  @if (step() === 'verifying') { Verifying… } @else { Confirm }
27
36
  </button>
28
- <button matButton type="button" (click)="requestVerification.emit()">Resend code</button>
37
+ @if (resendIn() > 0) {
38
+ <span class="verification-resend" aria-live="polite">Resend code in {{ resendIn() }}s</span>
39
+ } @else {
40
+ <button matButton type="button" [disabled]="disabled()" (click)="request()">Resend code</button>
41
+ }
29
42
  </div>
30
43
  }
31
44
  }
@@ -1,4 +1,7 @@
1
- import { ChangeDetectionStrategy, Component, ViewEncapsulation, input, output, signal } from '@angular/core';
1
+ import {
2
+ ChangeDetectionStrategy, Component, DestroyRef, ViewEncapsulation,
3
+ inject, input, output, signal,
4
+ } from '@angular/core';
2
5
  import { MatButtonModule } from '@angular/material/button';
3
6
  import { MatFormFieldModule } from '@angular/material/form-field';
4
7
  import { MatInputModule } from '@angular/material/input';
@@ -18,19 +21,71 @@ import { VerificationStep } from '../../model/dashboard';
18
21
  imports: [MatButtonModule, MatFormFieldModule, MatInputModule],
19
22
  })
20
23
  export class VerificationFlowComponent {
24
+ private readonly destroyRef = inject(DestroyRef);
25
+
21
26
  readonly step = input<VerificationStep>('idle');
22
27
  readonly errorMessage = input<string>('');
23
28
  /** The email/phone/identifier being verified, shown in the copy. */
24
29
  readonly target = input<string>('');
30
+ /** Seconds before `Resend code` re-enables; 0 leaves it always enabled. */
31
+ readonly resendAfterSeconds = input(0);
32
+ /** Seconds the issued code stays valid; 0 shows no expiry and never blocks confirm. */
33
+ readonly codeExpiresInSeconds = input(0);
34
+ /** Prevent interaction while the host is processing a request. */
35
+ readonly disabled = input(false);
36
+
25
37
  readonly requestVerification = output<void>();
26
38
  readonly confirmCode = output<string>();
27
39
 
28
40
  readonly code = signal('');
41
+ readonly resendIn = signal(0);
42
+ readonly expiresIn = signal(0);
43
+ readonly expired = signal(false);
44
+
45
+ private timer: ReturnType<typeof setInterval> | null = null;
46
+
47
+ constructor() {
48
+ this.destroyRef.onDestroy(() => this.clearTimer());
49
+ }
29
50
 
30
51
  submit(): void {
31
52
  const c = this.code().trim();
32
- if (c) {
53
+ if (c && !this.expired() && !this.disabled()) {
33
54
  this.confirmCode.emit(c);
34
55
  }
35
56
  }
57
+
58
+ /** Both the first request and a resend restart the countdowns, as the OTP input does. */
59
+ request(): void {
60
+ if (this.disabled()) return;
61
+ this.requestVerification.emit();
62
+ this.startCountdowns();
63
+ }
64
+
65
+ private startCountdowns(): void {
66
+ this.clearTimer();
67
+ this.code.set('');
68
+ this.expired.set(false);
69
+ this.resendIn.set(this.resendAfterSeconds());
70
+ this.expiresIn.set(this.codeExpiresInSeconds());
71
+ if (this.resendIn() > 0 || this.expiresIn() > 0) {
72
+ this.timer = setInterval(() => this.tick(), 1000);
73
+ }
74
+ }
75
+
76
+ private tick(): void {
77
+ if (this.resendIn() > 0) this.resendIn.update((s) => s - 1);
78
+ if (this.expiresIn() > 0) {
79
+ this.expiresIn.update((s) => s - 1);
80
+ if (this.expiresIn() === 0) this.expired.set(true);
81
+ }
82
+ if (this.resendIn() === 0 && this.expiresIn() === 0) this.clearTimer();
83
+ }
84
+
85
+ private clearTimer(): void {
86
+ if (this.timer) {
87
+ clearInterval(this.timer);
88
+ this.timer = null;
89
+ }
90
+ }
36
91
  }
@@ -0,0 +1,23 @@
1
+ <div class="domain-verification" [attr.data-step]="step()">
2
+ <div class="domain-verification-field">
3
+ <mat-form-field appearance="outline">
4
+ <mat-label>{{ label() }}</mat-label>
5
+ <input matInput #domainField type="text" name="domain" inputmode="url" autocomplete="url"
6
+ [value]="domain()" (input)="onDomainInput(domainField.value)"
7
+ [disabled]="isVerified() || loading()">
8
+ </mat-form-field>
9
+ @if (isVerified()) {
10
+ <mat-icon class="domain-verification-verified" aria-hidden="true">verified</mat-icon>
11
+ }
12
+ </div>
13
+
14
+ <sail-verification-flow
15
+ [step]="step()"
16
+ [target]="domain()"
17
+ [errorMessage]="errorMessage()"
18
+ [disabled]="loading() || !domain().trim()"
19
+ [resendAfterSeconds]="resendAfterSeconds()"
20
+ [codeExpiresInSeconds]="codeExpiresInSeconds()"
21
+ (requestVerification)="request()"
22
+ (confirmCode)="confirm($event)" />
23
+ </div>
@@ -0,0 +1,85 @@
1
+ import {
2
+ ChangeDetectionStrategy, Component, ViewEncapsulation,
3
+ computed, effect, inject, input, output, signal, untracked,
4
+ } from '@angular/core';
5
+ import { MatFormFieldModule } from '@angular/material/form-field';
6
+ import { MatIconModule } from '@angular/material/icon';
7
+ import { MatInputModule } from '@angular/material/input';
8
+ import { VerificationStep } from '../../model/dashboard';
9
+ import { DOMAIN_VERIFIER } from '../../service/domain_verifier';
10
+ import { BaseAsync } from '../abstract/base_async';
11
+ import { VerificationFlowComponent } from '../dashboard/verification_flow';
12
+
13
+ /**
14
+ * Domain-ownership verification: the domain field plus the send-code → confirm steps,
15
+ * driven against the injected `DOMAIN_VERIFIER`. The app owns the endpoints, the page
16
+ * shell and the styling; `verified` fires once with the domain that was proven.
17
+ *
18
+ * Selector: <sail-domain-verification>
19
+ */
20
+ @Component({
21
+ selector: 'sail-domain-verification',
22
+ templateUrl: './domain_verification.html',
23
+ changeDetection: ChangeDetectionStrategy.OnPush,
24
+ encapsulation: ViewEncapsulation.None,
25
+ imports: [MatFormFieldModule, MatIconModule, MatInputModule, VerificationFlowComponent],
26
+ })
27
+ export class DomainVerificationComponent extends BaseAsync {
28
+ private readonly verifier = inject(DOMAIN_VERIFIER);
29
+
30
+ readonly domainInput = input('', { alias: 'domain' });
31
+ readonly label = input('Domain');
32
+ readonly resendAfterSeconds = input(60);
33
+ readonly codeExpiresInSeconds = input(0);
34
+
35
+ readonly domainChange = output<string>();
36
+ readonly verified = output<string>();
37
+
38
+ readonly domain = signal('');
39
+ readonly step = signal<VerificationStep>('idle');
40
+ readonly isVerified = computed(() => this.step() === 'verified');
41
+
42
+ constructor() {
43
+ super();
44
+ effect(() => {
45
+ const value = this.domainInput();
46
+ if (value === untracked(this.domain)) return;
47
+ this.domain.set(value);
48
+ this.step.set('idle');
49
+ this.clearMessages();
50
+ });
51
+ }
52
+
53
+ protected onDomainInput(value: string): void {
54
+ this.domain.set(value);
55
+ this.step.set('idle'); // a code is bound to the domain it was issued for
56
+ this.clearMessages();
57
+ this.domainChange.emit(value);
58
+ }
59
+
60
+ protected request(): void {
61
+ const domain = this.domain().trim();
62
+ if (!domain || this.isVerified() || this.loading()) return;
63
+ this.run(
64
+ this.verifier.requestVerification(domain),
65
+ () => this.step.set('requested'),
66
+ 'Could not send the verification code.',
67
+ () => this.step.set('idle'),
68
+ );
69
+ }
70
+
71
+ protected confirm(code: string): void {
72
+ const domain = this.domain().trim();
73
+ if (!domain || this.isVerified() || this.loading()) return;
74
+ this.step.set('verifying');
75
+ this.run(
76
+ this.verifier.confirm(domain, code),
77
+ () => {
78
+ this.step.set('verified');
79
+ this.verified.emit(domain);
80
+ },
81
+ 'Verification failed.',
82
+ () => this.step.set('error'),
83
+ );
84
+ }
85
+ }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from './component/dashboard/no_source_overlay';
14
14
  export * from './component/dashboard/action_center';
15
15
  export * from './component/dashboard/entity_selector';
16
16
  export * from './component/dashboard/verification_flow';
17
+ export * from './component/verification/domain_verification';
17
18
  export * from './component/oauth_connection/oauth_connection_panel';
18
19
  export * from './component/abstract/base_form';
19
20
  export * from './component/abstract/base_view';
@@ -68,6 +69,7 @@ export * from './component/payment/user_payment_methods';
68
69
  export * from './component/payment/sca_confirm';
69
70
  export * from './service/auth.service';
70
71
  export * from './service/sca_confirmer';
72
+ export * from './service/domain_verifier';
71
73
  export * from './service/billing.service';
72
74
  export * from './service/payout.service';
73
75
  export * from './service/agency.service';
@@ -0,0 +1,20 @@
1
+ import { InjectionToken } from '@angular/core';
2
+ import { Observable } from 'rxjs';
3
+
4
+ /**
5
+ * Port for proving ownership of a domain: issue a code, then confirm it.
6
+ *
7
+ * sail ships no implementation — the endpoints, their paths and their payloads are
8
+ * the app's. Register one at composition time:
9
+ *
10
+ * { provide: DOMAIN_VERIFIER, useExisting: RegistrationService }
11
+ *
12
+ * Both methods may emit any body; `DomainVerificationComponent` reads only success
13
+ * or failure, and reports the failure through `errorDetail()`.
14
+ */
15
+ export interface DomainVerifier {
16
+ requestVerification(domain: string): Observable<unknown>;
17
+ confirm(domain: string, code: string): Observable<unknown>;
18
+ }
19
+
20
+ export const DOMAIN_VERIFIER = new InjectionToken<DomainVerifier>('SailDomainVerifier');