@nauticana/sail 1.1.36 → 1.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
4
4
 
5
- > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; 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, the `currency` catalog, OAuth grant routes), sail v1.1.33 adopts keel v1.2.106 (`prompt` on 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 (absolute `return` for 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 the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
5
+ > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.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, the `currency` catalog, OAuth grant routes), sail v1.1.33 adopts keel v1.2.106 (`prompt` on 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 (absolute `return` for 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 the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
6
6
 
7
7
  **Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
8
8
 
@@ -414,7 +414,7 @@ The login flow redirects `twoFactorRequired` responses to `/login/2fa`. With `tw
414
414
 
415
415
  `SessionsComponent` (`<sail-sessions>`) lists the sessions with their `sign_in_method` captions, marks the current one, and signs out any other.
416
416
 
417
- `ApiKeysComponent` (`<sail-api-keys [scopes]>`) lists the partner's keys and uses the backend-provided `generate` and `restrict` table actions; controls appear only for granted actions. Pass the scopes the app offers (`LabelOption[]`) after filtering out scopes 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`).
417
+ `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`).
418
418
 
419
419
  ### Cross-origin cookie requirement
420
420
 
@@ -689,7 +689,7 @@ A complete screen is the pieces above assembled over `BillingService` — no met
689
689
 
690
690
  ## Agent UI (v1.1.20)
691
691
 
692
- 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.14.
692
+ 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.
693
693
 
694
694
  ```ts
695
695
  import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@nauticana/sail/agent';
@@ -702,7 +702,7 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
702
702
  | `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`). |
703
703
  | `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. |
704
704
  | `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. |
705
- | `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 asks once more; reconciling requires a note of up to 500 characters. `[open]` selects an item by key or confirmation id (an emailed link). 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. |
705
+ | `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. |
706
706
  | `studioErrorMessage(err)` | One message for a failed Studio call: the conflict cause, the field errors, or the detail. |
707
707
  | `studioConflictCause(err)` | `'revision'` (reload the draft), `'sealed'` (a layer sits under a sealed one) or `'other'` for a `409`. |
708
708
  | `studioFieldErrors(err)` / `retryAfterSeconds(err)` | Field errors of a `400`; `Retry-After` of a `429`/`503`. |
@@ -713,7 +713,7 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
713
713
  | `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. |
714
714
  | `sail-turn-stream-view` | `TurnStream` + event list + status + cancel button (`cancelRequested`). |
715
715
  | `sail-decision-timeline` | Read-only decision chain of one request, grouped by category. |
716
- | `ConversationService` | Transport for Scout's conversation bridge: `submit`, `stream(requestId, turnStream)`, `cancel`. 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. |
716
+ | `ConversationService` | Transport for Scout's conversation bridge: `submit`, `stream(requestId, turnStream)`, `cancel`, `turns(workRef, before, limit)`, and `agents(agentType)` — the `ConversationAgent`s (`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. |
717
717
  | `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. |
718
718
 
719
719
  `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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.36",
3
+ "version": "1.1.38",
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": {
@@ -19,7 +19,7 @@
19
19
  <li>
20
20
  <button type="button" class="approval-inbox__item" [class.selected]="confirmationKey(c) === selectedKey()"
21
21
  [attr.aria-current]="confirmationKey(c) === selectedKey()" (click)="select(confirmationKey(c))">
22
- <span class="approval-inbox__summary">{{ c.tool }} · {{ c.status | sailCaption:'mcp_confirmation_status' }}</span>
22
+ <span class="approval-inbox__summary">{{ summary()(c) }} · {{ c.status | sailCaption:'mcp_confirmation_status' }}</span>
23
23
  @if (c.status === 'P') {
24
24
  <span class="approval-inbox__deadline">Expires {{ c.expires_at | date: 'medium' }}</span>
25
25
  }
@@ -68,9 +68,9 @@
68
68
  @if (confirming()) {
69
69
  <span>Approve this action? It runs exactly as shown.</span>
70
70
  <button #yes matButton="filled" type="button" [disabled]="loading()" (click)="decide('A')">Yes, approve</button>
71
- <button matButton type="button" [disabled]="loading()" (click)="cancelApprove()">Cancel</button>
71
+ <button matButton type="button" [disabled]="loading()" (click)="cancelAsk()">Cancel</button>
72
72
  } @else {
73
- <button matButton="filled" type="button" [disabled]="loading()" (click)="askApprove()">Approve</button>
73
+ <button matButton="filled" type="button" [disabled]="loading()" (click)="ask('approve')">Approve</button>
74
74
  <button matButton type="button" class="warn" [disabled]="loading()" (click)="decide('X')">Reject</button>
75
75
  <button matButton type="button" [disabled]="loading() || !revisedArguments().trim()" (click)="decide('E')">Edit and run</button>
76
76
  }
@@ -80,7 +80,7 @@
80
80
 
81
81
  @if (selectedConfirmation(); as c) {
82
82
  <section class="approval-inbox__decision" aria-label="Confirmation">
83
- <h3 #heading tabindex="-1">{{ c.tool }}</h3>
83
+ <h3 #heading tabindex="-1">{{ summary()(c) }}</h3>
84
84
  <dl>
85
85
  <dt>Workspace</dt><dd>{{ c.tenant_label || c.tenant_id }}</dd>
86
86
  <dt>Status</dt><dd>{{ c.status | sailCaption:'mcp_confirmation_status' }}@if (c.expired) { (expired) }</dd>
@@ -106,18 +106,22 @@
106
106
  </mat-form-field>
107
107
  }
108
108
  <div class="approval-inbox__actions" role="group" aria-label="Decide">
109
- @if (c.can_decide) {
110
- @if (confirming()) {
111
- <span>Confirm this action? It runs exactly as shown.</span>
112
- <button #yes matButton="filled" type="button" [disabled]="loading()" (click)="confirm(true)">Yes, confirm</button>
113
- <button matButton type="button" [disabled]="loading()" (click)="cancelApprove()">Cancel</button>
114
- } @else {
115
- <button matButton="filled" type="button" [disabled]="loading()" (click)="askApprove()">Confirm</button>
109
+ @if (c.can_decide && confirming() === 'approve') {
110
+ <span>Confirm this action? It runs exactly as shown.</span>
111
+ <button #yes matButton="filled" type="button" [disabled]="loading()" (click)="confirm(true)">Yes, confirm</button>
112
+ <button matButton type="button" [disabled]="loading()" (click)="cancelAsk()">Cancel</button>
113
+ } @else if (c.can_withdraw && confirming() === 'withdraw') {
114
+ <span>Withdraw this confirmation? Nothing will be done.</span>
115
+ <button #yes matButton="filled" type="button" [disabled]="loading()" (click)="withdraw()">Yes, withdraw</button>
116
+ <button matButton type="button" [disabled]="loading()" (click)="cancelAsk()">Cancel</button>
117
+ } @else {
118
+ @if (c.can_decide) {
119
+ <button matButton="filled" type="button" [disabled]="loading()" (click)="ask('approve')">Confirm</button>
116
120
  <button matButton type="button" class="warn" [disabled]="loading()" (click)="confirm(false)">Decline</button>
117
121
  }
118
- }
119
- @if (c.can_withdraw) {
120
- <button matButton type="button" [disabled]="loading()" (click)="withdraw()">Withdraw</button>
122
+ @if (c.can_withdraw) {
123
+ <button matButton type="button" [disabled]="loading()" (click)="ask('withdraw')">Withdraw</button>
124
+ }
121
125
  }
122
126
  @if (c.can_reconcile) {
123
127
  <button matButton type="button" [disabled]="loading() || !reason().trim()" (click)="reconcile('D')">Mark executed</button>
@@ -49,8 +49,8 @@ const actionErrors: Record<number, string> = {
49
49
  /**
50
50
  * The signed-in person's pending approvals and MCP confirmations, grouped as waiting, submitted,
51
51
  * needing reconciliation and recent, with a decision view: approve, reject, or edit a tool call's
52
- * arguments; confirm, decline, withdraw, or reconcile a confirmation. Approving asks once more, and
53
- * reconciling requires a note. A list the backend does not serve (404) stays empty.
52
+ * arguments; confirm, decline, withdraw, or reconcile a confirmation. Approving and withdrawing ask
53
+ * once more, and reconciling requires a note. A list the backend does not serve (404) stays empty.
54
54
  * Selector: <sail-approval-inbox>.
55
55
  */
56
56
  @Component({
@@ -69,6 +69,12 @@ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
69
69
 
70
70
  /** The item to open first: an approval or confirmation key, or a confirmation id from a link. */
71
71
  readonly open = input('');
72
+ /** How a confirmation reads in the list and as its heading. */
73
+ readonly summary = input<(c: Confirmation) => string>((c) => c.tool);
74
+ /** Days back a closed confirmation may have been created and still list; open ones always list. */
75
+ readonly historyDays = input(30);
76
+ /** Most confirmations listed, newest first. */
77
+ readonly limit = input(200);
72
78
 
73
79
  readonly decided = output<ApprovalRequest>();
74
80
  readonly confirmationChanged = output<Confirmation>();
@@ -81,8 +87,8 @@ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
81
87
  readonly selectedKey = signal('');
82
88
  readonly reason = signal('');
83
89
  readonly revisedArguments = signal('');
84
- /** The approve step awaits its second click. */
85
- readonly confirming = signal(false);
90
+ /** The step awaiting its second click. */
91
+ readonly confirming = signal<'' | 'approve' | 'withdraw'>('');
86
92
  readonly selected = computed(() => this.requests().find((r) => this.key(r) === this.selectedKey()));
87
93
  readonly selectedConfirmation = computed(() => this.confirmations().find((c) => this.confirmationKey(c) === this.selectedKey()));
88
94
 
@@ -93,7 +99,8 @@ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
93
99
  load(): void {
94
100
  this.run(forkJoin([
95
101
  this.listed(this.approvals.pending(), this.requests(), 'Failed to load approvals.'),
96
- this.listed(this.approvals.confirmations(), this.confirmations(), 'Failed to load confirmations.'),
102
+ this.listed(this.approvals.confirmations(new Date(Date.now() - this.historyDays() * 86_400_000).toISOString(), this.limit()),
103
+ this.confirmations(), 'Failed to load confirmations.'),
97
104
  ]), ([requests, confirmations]) => {
98
105
  this.requests.set(requests);
99
106
  this.confirmations.set(confirmations);
@@ -121,16 +128,16 @@ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
121
128
  this.selectedKey.set(key);
122
129
  this.reason.set('');
123
130
  this.revisedArguments.set('');
124
- this.confirming.set(false);
131
+ this.confirming.set('');
125
132
  }
126
133
 
127
- askApprove(): void {
128
- this.confirming.set(true);
134
+ ask(step: 'approve' | 'withdraw'): void {
135
+ this.confirming.set(step);
129
136
  this.focus(this.yes);
130
137
  }
131
138
 
132
- cancelApprove(): void {
133
- this.confirming.set(false);
139
+ cancelAsk(): void {
140
+ this.confirming.set('');
134
141
  this.focus(this.heading);
135
142
  }
136
143
 
@@ -174,7 +181,7 @@ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
174
181
 
175
182
  withdraw(): void {
176
183
  const c = this.selectedConfirmation();
177
- if (!c || !window.confirm('Withdraw this confirmation?')) return;
184
+ if (!c) return;
178
185
  this.act(this.approvals.withdrawConfirmation(c), () => {
179
186
  this.load();
180
187
  return 'Withdrawn; nothing will be done.';
@@ -207,7 +214,7 @@ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
207
214
  private act<T>(request: Observable<T>, done: (result: T) => string): void {
208
215
  this.run(request, (result) => {
209
216
  this.reason.set('');
210
- this.confirming.set(false);
217
+ this.confirming.set('');
211
218
  this.successMessage.set(done(result));
212
219
  this.focus(this.heading);
213
220
  }, 'Failed to record the decision.', (err) => {
@@ -1,4 +1,4 @@
1
- /** Agent Studio wire types (Scout v1.2.7). Field names are the backend's JSON names. */
1
+ /** Agent Studio wire types (Scout v1.2.16). Field names are the backend's JSON names. */
2
2
 
3
3
  /** R replace, A append, I intersect (non-prompt settings only). */
4
4
  export type AgentMergeMode = 'R' | 'A' | 'I';
@@ -333,6 +333,8 @@ export interface GuardrailRule {
333
333
  action: string;
334
334
  severity: string;
335
335
  params?: unknown;
336
+ /** Why the rule exists; a blocked answer names it. */
337
+ reason?: string;
336
338
  }
337
339
 
338
340
  /** Rules an agent inherits from one layer: B baseline, P platform, T tenant, Y type. */
@@ -1,6 +1,6 @@
1
1
  import { PrincipalRef } from './decision';
2
2
 
3
- /** Scout approval desk and MCP confirmation inbox wire types (v1.2.14). Codes render through backend captions. */
3
+ /** Scout approval desk and MCP confirmation inbox wire types (v1.2.16). Codes render through backend captions. */
4
4
 
5
5
  /** A approved, X rejected, E edited (tool-call proposals only, with revised arguments). */
6
6
  export type ApprovalVerdict = 'A' | 'X' | 'E';
@@ -17,6 +17,14 @@ export interface ConversationTurnRequest {
17
17
  input: unknown;
18
18
  }
19
19
 
20
+ /** One agent the person may address through an agent type (`agent_name` on a turn request). */
21
+ export interface ConversationAgent {
22
+ agent_name: string;
23
+ display_name: string;
24
+ is_default: boolean;
25
+ release: string;
26
+ }
27
+
20
28
  export interface ConversationTurnAccepted {
21
29
  request_id: string;
22
30
  }
@@ -18,9 +18,9 @@ export class ApprovalService extends BaseRestService {
18
18
  return this.http.post<ApprovalRequest>(this.url(base + 'resolve'), request);
19
19
  }
20
20
 
21
- /** Open confirmations and those created since `since` (RFC 3339), across the person's tenants. */
22
- confirmations(since = ''): Observable<Confirmation[]> {
23
- return this.http.get<Confirmation[]>(this.url(base + 'confirmations'), { params: since ? { since } : {} });
21
+ /** Lists open confirmations plus those since the RFC 3339 time; zero limit takes Scout's batch. */
22
+ confirmations(since = '', limit = 0): Observable<Confirmation[]> {
23
+ return this.http.get<Confirmation[]>(this.url(base + 'confirmations'), { params: { since, limit } });
24
24
  }
25
25
 
26
26
  decideConfirmation(key: ConfirmationKey, approve: boolean, note = ''): Observable<Confirmation> {
@@ -4,6 +4,7 @@ import { BaseAuthService } from '../../service/auth.service';
4
4
  import { BaseRestService } from '../../service/base_rest.service';
5
5
  import {
6
6
  CONVERSATION_BASE_PATH,
7
+ ConversationAgent,
7
8
  ConversationStreamError,
8
9
  ConversationStreamFailure,
9
10
  ConversationTurnAccepted,
@@ -19,7 +20,7 @@ const REPLAY_EXPIRED = 410;
19
20
 
20
21
  /**
21
22
  * Transport for Scout's conversation bridge: `POST {base}turn`,
22
- * `GET {base}turn/stream` as SSE, `POST {base}turn/cancel`. Submit and cancel go through
23
+ * `GET {base}turn/stream` as SSE, `POST {base}turn/cancel`, `GET {base}turns`, `GET {base}agents`. Submit and cancel go through
23
24
  * `HttpClient` (and so through sail's interceptors); the stream cannot, because
24
25
  * `EventSource` sends no `Authorization` header — it is a `fetch` body reader that carries
25
26
  * the bearer itself and rotates it through `BaseAuthService.refreshSession()` once on a 401.
@@ -35,6 +36,11 @@ export class ConversationService extends BaseRestService {
35
36
  return this.http.post<ConversationTurnAccepted>(this.conversationUrl('turn'), request);
36
37
  }
37
38
 
39
+ /** The agents of `agentType` the person may address, the default first. */
40
+ agents(agentType: string): Observable<ConversationAgent[]> {
41
+ return this.http.get<ConversationAgent[]>(this.conversationUrl('agents'), { params: { agent_type: agentType } });
42
+ }
43
+
38
44
  /** The person's turns, newest first; `before` continues from a page's `next_before`. */
39
45
  turns(workRef = '', before = '', limit = 0): Observable<ConversationTurnPage> {
40
46
  const params: Record<string, string> = {};
@@ -22,10 +22,10 @@
22
22
  <mat-label>Expires (optional)</mat-label>
23
23
  <input matInput type="datetime-local" formControlName="expiresAt">
24
24
  </mat-form-field>
25
- @if (scopes().length) {
25
+ @if (offered().length) {
26
26
  <fieldset class="api-keys__scopes">
27
27
  <legend>Scopes</legend>
28
- @for (scope of scopes(); track scope.code) {
28
+ @for (scope of offered(); track scope.code) {
29
29
  <mat-checkbox [checked]="form.controls.scopes.value.includes(scope.code)"
30
30
  (change)="toggleScope(scope.code, $event.checked)">{{ scope.caption }}</mat-checkbox>
31
31
  }
@@ -1,19 +1,20 @@
1
1
  import { DatePipe } from '@angular/common';
2
- import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, inject, input, signal } from '@angular/core';
2
+ import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, computed, inject, input, signal } from '@angular/core';
3
3
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
4
4
  import { MatButtonModule } from '@angular/material/button';
5
5
  import { MatCheckboxModule } from '@angular/material/checkbox';
6
6
  import { MatFormFieldModule } from '@angular/material/form-field';
7
7
  import { MatInputModule } from '@angular/material/input';
8
+ import { forkJoin } from 'rxjs';
8
9
  import { ApiKey, MintedApiKey } from '../../model/access';
9
10
  import { AccessService } from '../../service/access.service';
10
- import { LabelOption } from '../../service/label.service';
11
+ import { LabelOption, LabelService } from '../../service/label.service';
11
12
  import { BaseAsync } from '../abstract/base_async';
12
13
 
13
14
  /**
14
15
  * The partner's API keys: mint one with scopes, allowed networks and an optional expiry (the
15
- * plaintext shows once), restrict its networks later, or revoke it. The app passes the scopes it
16
- * offers; keel's `ScopePolicy` decides and may supply them. Selector: <sail-api-keys>.
16
+ * plaintext shows once), restrict its networks later, or revoke it. The app offers scopes as options
17
+ * or constant domains; keel's `ScopePolicy` decides and may supply them. Selector: <sail-api-keys>.
17
18
  */
18
19
  @Component({
19
20
  selector: 'sail-api-keys',
@@ -24,10 +25,15 @@ import { BaseAsync } from '../abstract/base_async';
24
25
  })
25
26
  export class ApiKeysComponent extends BaseAsync implements OnInit {
26
27
  private readonly access = inject(AccessService);
28
+ private readonly labels = inject(LabelService);
27
29
  private readonly fb = inject(FormBuilder).nonNullable;
28
30
 
29
31
  /** Scopes offered for a new key, as code and caption. */
30
32
  readonly scopes = input<LabelOption[]>([]);
33
+ /** Constant domains whose values are offered as scopes after `scopes`. */
34
+ readonly scopeDomains = input<string[]>([]);
35
+ private readonly domainScopes = signal<LabelOption[]>([]);
36
+ readonly offered = computed(() => [...this.scopes(), ...this.domainScopes()]);
31
37
 
32
38
  readonly canGenerate = this.access.canApiKeyAction('generate');
33
39
  readonly canRestrict = this.access.canApiKeyAction('restrict');
@@ -49,7 +55,11 @@ export class ApiKeysComponent extends BaseAsync implements OnInit {
49
55
  }
50
56
 
51
57
  load(): void {
52
- this.run(this.access.apiKeys(), (rows) => this.keys.set(rows), 'Failed to load API keys.');
58
+ const domains = [...new Set(this.scopeDomains().map((d) => d.trim()))];
59
+ this.run(forkJoin([this.access.apiKeys(), ...domains.map((d) => this.labels.loadLabels(d))]), ([rows]) => {
60
+ this.keys.set(rows);
61
+ this.domainScopes.set(domains.flatMap((d) => this.labels.options(d)));
62
+ }, 'Failed to load API keys.');
53
63
  }
54
64
 
55
65
  toggleScope(code: string, checked: boolean): void {