diff --git a/apps/console/src/app/(console)/explore/explore-navigation.test.ts b/apps/console/src/app/(console)/explore/explore-navigation.test.ts
index 388a949d0..6be775d53 100644
--- a/apps/console/src/app/(console)/explore/explore-navigation.test.ts
+++ b/apps/console/src/app/(console)/explore/explore-navigation.test.ts
@@ -153,6 +153,30 @@ test("order FLIP is feed-defining: re-pages from page 1 (drops anchor + cursor t
assert.equal(params.get("ucursors"), null, "an order flip resets the upcoming trail too (fresh snapshot)");
});
+test("order FLIP preserves the active query and filters while resetting pagination", () => {
+ const state = accumulatingState({
+ connectionIds: ["cin_ynab"],
+ excludeConnectionIds: ["cin_private"],
+ excludeStreams: ["secrets"],
+ query: "coffee",
+ since: "2026-06-01",
+ streams: ["transactions"],
+ until: "2026-07-01",
+ });
+ const params = paramsOf(buildNavigateHref(EXPLORE, state, { order: "oldest" }));
+
+ assert.equal(params.get("q"), "coffee", "the sort flip must keep the text query");
+ assert.deepEqual(params.getAll("connection"), ["cin_ynab"], "the sort flip must keep included sources");
+ assert.deepEqual(params.getAll("xconnection"), ["cin_private"], "the sort flip must keep excluded sources");
+ assert.deepEqual(params.getAll("stream"), ["transactions"], "the sort flip must keep included streams");
+ assert.deepEqual(params.getAll("xstream"), ["secrets"], "the sort flip must keep excluded streams");
+ assert.equal(params.get("since"), "2026-06-01", "the sort flip must keep the lower time bound");
+ assert.equal(params.get("until"), "2026-07-01", "the sort flip must keep the upper time bound");
+ assert.equal(params.get("order"), "oldest", "the sort flip must select oldest-first");
+ assert.equal(params.get("anchor"), null, "the sort flip must reset the old snapshot anchor");
+ assert.equal(params.get("cursors"), null, "the sort flip must reset the old cursor trail");
+});
+
test("a same-value order (carried forward by a peek) is NOT feed-defining (same feed)", () => {
// A peek that carries the current order forward unchanged (newest == state.order)
// is a pure same-feed move — the accumulated trail + anchor must survive.
diff --git a/apps/console/src/app/(console)/explore/page.invariants.test.ts b/apps/console/src/app/(console)/explore/page.invariants.test.ts
index 31949e417..84fb3c573 100644
--- a/apps/console/src/app/(console)/explore/page.invariants.test.ts
+++ b/apps/console/src/app/(console)/explore/page.invariants.test.ts
@@ -260,6 +260,12 @@ const FEED_PENDING_DIM_RE =
const TYPEAHEAD_VIEWPORT_CLAMP_RE =
/\.rr-x-typeahead \{(?=[\s\S]*?left: 0;)(?=[\s\S]*?right: 0;)(?=[\s\S]*?max-width: 100vw;)(?=[\s\S]*?max-height: min\(280px, 60vh\);)(?=[\s\S]*?overflow-y: auto;)[\s\S]*?\}/;
+// Mobile: the options popover (operator-syntax legend) becomes a full-width sheet,
+// same as the date popover, so it doesn't overflow when the trigger wraps near the
+// left edge. The override turns off the absolute right:0 anchor and spans full width.
+const OPTIONS_VIEWPORT_CLAMP_RE =
+ /\.rr-x-options__body \{(?=[\s\S]*?right: 0;)(?=[\s\S]*?left: 0;)(?=[\s\S]*?width: auto;)(?=[\s\S]*?max-width: none;)[\s\S]*?\}/;
+
// (#7) Motion communicates model state and is reduced-motion gated with a static
// fallback. The shared reveal (Upcoming body / burst expand / day-group mount):
// - base rule = STATIC visible fallback (opacity:1; transform:none) OUTSIDE any
@@ -738,6 +744,14 @@ test("Slice 5 (#6): the operators/typeahead popover stays within the viewport (p
TYPEAHEAD_VIEWPORT_CLAMP_RE,
"the typeahead must be left:0/right:0 anchored to the input AND clamped by max-width:100vw + max-height:min(280px,60vh) + overflow-y:auto so it never runs off-screen"
);
+ // Mobile override: the options popover (operator-syntax legend) also becomes a
+ // full-width sheet to match DateChip's mobile pattern, preventing leftward overflow
+ // when the trigger wraps near the left edge on narrow screens.
+ assert.match(
+ css,
+ OPTIONS_VIEWPORT_CLAMP_RE,
+ "the options popover must have a mobile override with right:0 + left:0 + width:auto + max-width:none to become a full-width sheet and prevent overflow"
+ );
});
test("Slice 5 (#7): expansion/load-more motion is reduced-motion gated with a static fallback and causes no layout shift", async () => {
diff --git a/apps/console/src/app/(console)/grants/page.tsx b/apps/console/src/app/(console)/grants/page.tsx
index 33cbb395e..f01f7f407 100644
--- a/apps/console/src/app/(console)/grants/page.tsx
+++ b/apps/console/src/app/(console)/grants/page.tsx
@@ -134,7 +134,7 @@ export default async function GrantsPage({ searchParams }: { searchParams: Promi
when there is something waiting. */}
{approvals.data.length > 0 ? (
@@ -146,12 +146,11 @@ export default async function GrantsPage({ searchParams }: { searchParams: Promi
))}
- These dashboard shortcut buttons work in open local-dev mode. If placeholder owner auth is enabled, sign in
- at{" "}
+ These approval shortcuts are available only in local approval mode. Otherwise, sign in through{" "}
owner access
{" "}
- and approve there instead.
+ and approve the request there.
) : null}
@@ -162,7 +161,7 @@ export default async function GrantsPage({ searchParams }: { searchParams: Promi
activeFilterChips: activeFilters,
buildListHref: (overrides) => listHref(params, overrides),
description: "Issued authorizations and lifecycle decisions for client access to owner data.",
- emptyHint: "Grant artifacts appear after client/provider-connect consent flows issue or reject grants.",
+ emptyHint: "Grants appear after a connected app requests access and the request is approved, denied, or fails.",
emptyTitle: "No grants yet",
filters: {
query: { defaultValue: params.q ?? "", name: "q", placeholder: "id contains…" },
diff --git a/apps/console/src/app/(console)/lib/connection-catalog.test.ts b/apps/console/src/app/(console)/lib/connection-catalog.test.ts
index 88a96cbcc..e4d2d82fc 100644
--- a/apps/console/src/app/(console)/lib/connection-catalog.test.ts
+++ b/apps/console/src/app/(console)/lib/connection-catalog.test.ts
@@ -19,18 +19,24 @@ import {
browserBoundRunbookEntries,
browserCollectorEntries,
buildConnectorCatalog,
+ buildOwnerConnectorCatalog,
type CatalogManifestLike,
catalogModalityFromManifest,
deploymentBlockedEntries,
+ isOwnerActionableEntry,
localCollectorEntries,
localCollectorUnprovenEntries,
manualUploadConnectEntries,
manualUploadPendingEntries,
+ type OwnerConnectorTemplateLike,
+ providerAuthConnectEntries,
staticSecretConnectEntries,
unsupportedNetworkEntries,
} from "./connection-catalog.ts";
import {
sourceSetupAction,
+ sourceSetupAvailability,
+ sourceSetupContext,
sourceSetupGuidance,
sourceSetupSecondaryAction,
sourceSetupStatus,
@@ -38,8 +44,14 @@ import {
const FIRST_PARTY_REGISTRY_PREFIX = "https://registry.pdpp.org/connectors/";
const TRAILING_SLASH_RE = /\/$/;
-const SECURE_BROWSER_SESSION_RE = /secure browser session/i;
+const SECURE_BROWSER_RE = /secure browser/i;
const SAVE_SIGN_IN_DETAILS_RE = /sign-in details/i;
+const DATA_PORTABILITY_SEPARATE_RE = /separate from Google Maps Timeline Import/;
+const TIMELINE_API_DISTINCTION_RE = /not exposed by Google's documented Data Portability API/i;
+const TIMELINE_NO_SIGN_IN_RE = /no Google account sign-in is used/i;
+const GOOGLE_DEPLOYMENT_BLOCKER_RE = /GOOGLE_DATAPORTABILITY_CLIENT_ID/;
+const PROVIDER_BROWSER_GUIDANCE_RE = /provider's browser/;
+const OWNER_INTENT_URL_RE = /\/v1\/owner\/connections\/intents$/;
function canonicalKeyFromManifestId(connectorId: string): string {
if (connectorId.startsWith(FIRST_PARTY_REGISTRY_PREFIX)) {
@@ -64,6 +76,52 @@ async function loadCommittedManifests(): Promise
{
return parsed.filter((m) => m.connector_id);
}
+function ownerTemplate(
+ args: {
+ actionMethod?: string | null;
+ actionStatus?: string;
+ actionUrl?: string | null;
+ connectorKey?: string;
+ connectorModality?: string;
+ disposition?: string;
+ enrollmentKey?: string | null;
+ listingStatus?: string;
+ nextStepKind?: string;
+ ownerActionable?: boolean;
+ proofGate?: string | null;
+ setupModality?: string;
+ supportState?: string;
+ } = {}
+): OwnerConnectorTemplateLike {
+ const connectorKey = args.connectorKey ?? "test-provider";
+ return {
+ connector_key: connectorKey,
+ connector_modality: args.connectorModality ?? "api_network",
+ display_name: connectorKey,
+ public_listing: { listed: true, status: args.listingStatus ?? "proven" },
+ registration_status: "registered",
+ setup_plan: {
+ catalog_disposition: args.disposition ?? "provider_auth_connect",
+ deployment_readiness: { blockers: [], guidance: null, state: "ready" },
+ enrollment_key: args.enrollmentKey ?? null,
+ next_step_kind: args.nextStepKind ?? "open_provider_auth",
+ owner_actionable: args.ownerActionable ?? true,
+ proof_gate: args.proofGate ?? null,
+ runbook_path: null,
+ setup_modality: args.setupModality ?? "provider_authorization",
+ support_state: args.supportState ?? "supported",
+ },
+ supported_actions: [
+ {
+ family: "initiate_connection",
+ method: args.actionMethod === undefined ? "POST" : args.actionMethod,
+ status: args.actionStatus ?? "supported",
+ url: args.actionUrl === undefined ? "https://reference.test/v1/owner/connections/intents" : args.actionUrl,
+ },
+ ],
+ };
+}
+
test("catalogModalityFromManifest mirrors the filesystem>browser>network precedence", () => {
assert.equal(catalogModalityFromManifest({ connector_id: "x", runtime_requirements: { bindings: {} } }), "unknown");
assert.equal(
@@ -119,7 +177,7 @@ test("only proven-creatable dispositions carry an enrollment deep-link key", asy
}
});
-test("heb is cataloged as one browser-bound account setup with optional stored credentials", async () => {
+test("unproven browser-bound static-secret entries fail closed", async () => {
const catalog = buildConnectorCatalog(await loadCommittedManifests());
const heb = catalog.find((entry) => entry.connectorKey === "heb");
if (!heb) {
@@ -129,16 +187,17 @@ test("heb is cataloged as one browser-bound account setup with optional stored c
assert.equal(heb.setupModality, "static_secret");
assert.equal(heb.disposition, "static_secret_connect");
assert.equal(heb.enrollmentKey, undefined);
- assert.equal(sourceSetupStatus(heb).label, "Connect account");
- assert.equal(sourceSetupAction(heb)?.href, "/connect/browser-session/heb");
- assert.equal(sourceSetupAction(heb)?.label, "Connect account");
+ assert.equal(heb.supportState, "proof_gated");
+ assert.equal(heb.proofGate, "static_secret_live_proof_missing");
+ assert.equal(sourceSetupStatus(heb).label, "Not available here");
+ assert.equal(sourceSetupAction(heb), null);
assert.equal(sourceSetupSecondaryAction(heb), null);
- assert.match(sourceSetupGuidance(heb), SECURE_BROWSER_SESSION_RE);
- assert.match(sourceSetupGuidance(heb), SAVE_SIGN_IN_DETAILS_RE);
+ assert.doesNotMatch(sourceSetupGuidance(heb), SECURE_BROWSER_RE);
+ assert.doesNotMatch(sourceSetupGuidance(heb), SAVE_SIGN_IN_DETAILS_RE);
assert.deepEqual(browserCollectorEntries(catalog), []);
});
-test("browser-bound static-secret-capable connectors get one primary choice generically", () => {
+test("browser-bound static-secret capability is not enough to create an account", () => {
const catalog = buildConnectorCatalog([
{
connector_id: "https://registry.pdpp.org/connectors/browser-sample",
@@ -159,9 +218,9 @@ test("browser-bound static-secret-capable connectors get one primary choice gene
assert.equal(entry.modality, "browser_bound");
assert.equal(entry.setupModality, "static_secret");
assert.equal(entry.disposition, "static_secret_connect");
- assert.equal(sourceSetupAction(entry)?.href, "/connect/browser-session/browser-sample");
+ assert.equal(sourceSetupAction(entry), null);
assert.equal(sourceSetupSecondaryAction(entry), null);
- assert.equal(sourceSetupStatus(entry).label, "Connect account");
+ assert.equal(sourceSetupStatus(entry).label, "Not available here");
});
test("non-browser static-secret connectors keep the existing single capture path", () => {
@@ -190,6 +249,24 @@ test("non-browser static-secret connectors keep the existing single capture path
assert.equal(sourceSetupStatus(entry).label, "Add account");
});
+test("YNAB static-secret entry shows as actionable with draft-create path", async () => {
+ const manifests = await loadCommittedManifests();
+ const catalog = buildConnectorCatalog(manifests);
+ const ynab = catalog.find((entry) => entry.connectorKey === "ynab");
+ assert.ok(ynab, "ynab must be present in the catalog");
+ assert.equal(ynab.modality, "api_network");
+ assert.equal(ynab.setupModality, "static_secret");
+ assert.equal(ynab.disposition, "static_secret_connect");
+ assert.equal(ynab.enrollmentKey, undefined);
+ assert.equal(ynab.supportState, "supported");
+ assert.equal(ynab.proofGate, null);
+ assert.equal(sourceSetupStatus(ynab).label, "Add account");
+ assert.equal(sourceSetupAction(ynab)?.href, "/connect/static-secret/ynab");
+ assert.equal(sourceSetupSecondaryAction(ynab), null);
+ assert.equal(sourceSetupAvailability(ynab), "available_now");
+ assert.ok(sourceSetupGuidance(ynab).includes("protected setup form"));
+});
+
test("no browser-bound or API/network connector is one-click-creatable", async () => {
const catalog = buildConnectorCatalog(await loadCommittedManifests());
for (const entry of catalog) {
@@ -400,6 +477,8 @@ test("provider-authorization deployment blockers are separate from unsupported n
);
assert.deepEqual(deploymentBlockedEntries(catalog), [entry]);
assert.deepEqual(unsupportedNetworkEntries(catalog), []);
+ assert.equal(sourceSetupAction(entry), null);
+ assert.equal(sourceSetupAvailability(entry), "requires_server_setup");
assert.equal(entry.enrollmentKey, undefined);
});
@@ -418,9 +497,198 @@ test("Google Maps Data Portability is the API-backed provider-auth source, not T
entry.deploymentReadiness.blockers.map((blocker) => blocker.key),
["GOOGLE_DATAPORTABILITY_CLIENT_ID", "GOOGLE_DATAPORTABILITY_CLIENT_SECRET", "GOOGLE_DATAPORTABILITY_REDIRECT_URI"]
);
+ assert.equal(sourceSetupAction(entry), null, "provider settings must not link to diagnostics as a setup CTA");
+ assert.match(sourceSetupGuidance(entry), GOOGLE_DEPLOYMENT_BLOCKER_RE);
+ assert.match(sourceSetupContext(entry) ?? "", DATA_PORTABILITY_SEPARATE_RE);
+ assert.ok(entry.externalDocs.length >= 1, "provider-auth manifest documentation should remain available");
assert.equal(entry.enrollmentKey, undefined);
});
+test("Google Maps Timeline keeps its import/API distinction visible in the catalog", async () => {
+ const catalog = buildConnectorCatalog(await loadCommittedManifests());
+ const entry = catalog.find((candidate) => candidate.connectorKey === "google-maps");
+ assert.ok(entry, "google-maps must be in the committed catalog");
+ assert.match(sourceSetupContext(entry) ?? "", TIMELINE_API_DISTINCTION_RE);
+ assert.match(sourceSetupContext(entry) ?? "", TIMELINE_NO_SIGN_IN_RE);
+});
+
+test("configured Google provider readiness exposes the existing owner authorization action", async () => {
+ const catalog = buildConnectorCatalog(await loadCommittedManifests(), ["google-maps-data-portability"]);
+ const entry = catalog.find((candidate) => candidate.connectorKey === "google-maps-data-portability");
+ assert.ok(entry, "google-maps-data-portability must be in the committed catalog");
+ assert.equal(entry.deploymentReadiness.state, "ready");
+ assert.equal(entry.nextStepKind, "open_provider_auth");
+ assert.equal(entry.supportState, "supported");
+ assert.equal(entry.disposition, "provider_auth_connect");
+ assert.equal(sourceSetupStatus(entry).label, "Authorize account");
+ assert.match(sourceSetupGuidance(entry), PROVIDER_BROWSER_GUIDANCE_RE);
+ assert.deepEqual(sourceSetupAction(entry), {
+ href: "/connect/provider-auth/google-maps-data-portability",
+ label: "Authorize account",
+ });
+ assert.equal(sourceSetupAvailability(entry), "available_now");
+ assert.deepEqual(providerAuthConnectEntries(catalog), [entry]);
+});
+
+test("owner catalog fails closed for local-only, listed-unproven, and proof-gated static-secret entries", () => {
+ const staleLocalManifest: CatalogManifestLike = {
+ capabilities: { public_listing: { listed: true, status: "proven" } },
+ connector_id: "stale-local-only",
+ display_name: "Stale local-only",
+ runtime_requirements: { bindings: { network: {} } },
+ };
+ assert.deepEqual(buildOwnerConnectorCatalog([staleLocalManifest], []), [], "local-only entries are not listed");
+
+ const listedUnproven = buildOwnerConnectorCatalog(
+ [],
+ [
+ ownerTemplate({
+ connectorKey: "listed-unproven",
+ connectorModality: "local_collector",
+ disposition: "local_collector_enroll",
+ listingStatus: "unproven",
+ nextStepKind: "enroll_local_collector",
+ ownerActionable: false,
+ setupModality: "local_collector",
+ actionMethod: null,
+ actionStatus: "unsupported",
+ actionUrl: null,
+ }),
+ ]
+ );
+ const [listedUnprovenEntry] = listedUnproven;
+ assert.ok(listedUnprovenEntry, "server listing status controls visibility");
+ assert.equal(listedUnprovenEntry.ownerActionable, false);
+ assert.equal(sourceSetupAction(listedUnprovenEntry), null);
+ assert.equal(sourceSetupAvailability(listedUnprovenEntry), "not_available_here");
+
+ const proofGatedStaticSecret = buildOwnerConnectorCatalog(
+ [],
+ [
+ ownerTemplate({
+ connectorKey: "unproven-static-secret",
+ disposition: "static_secret_connect",
+ nextStepKind: "capture_static_secret",
+ ownerActionable: false,
+ proofGate: "static_secret_live_proof_missing",
+ setupModality: "static_secret",
+ supportState: "proof_gated",
+ actionMethod: null,
+ actionStatus: "unsupported",
+ actionUrl: null,
+ }),
+ ]
+ );
+ const [proofGatedEntry] = proofGatedStaticSecret;
+ assert.ok(proofGatedEntry, "proof-gated static-secret template should remain visible");
+ assert.equal(proofGatedEntry.supportState, "proof_gated");
+ assert.equal(sourceSetupStatus(proofGatedEntry).label, "Not available here");
+ assert.equal(sourceSetupAction(proofGatedEntry), null);
+ assert.equal(sourceSetupAvailability(proofGatedEntry), "not_available_here");
+});
+
+test("owner catalog exposes browser owner-session actions without inventing an owner-agent REST action", () => {
+ const catalog = buildOwnerConnectorCatalog(
+ [],
+ [
+ ownerTemplate({
+ connectorKey: "chatgpt",
+ connectorModality: "browser_bound",
+ disposition: "static_secret_connect",
+ nextStepKind: "capture_static_secret",
+ ownerActionable: true,
+ proofGate: "static_secret_live_proof_missing",
+ setupModality: "static_secret",
+ supportState: "proof_gated",
+ actionMethod: null,
+ actionStatus: "owner_mediated",
+ actionUrl: null,
+ }),
+ ownerTemplate({
+ connectorKey: "chase",
+ connectorModality: "browser_bound",
+ disposition: "browser_collector_manual",
+ enrollmentKey: "chase",
+ nextStepKind: "enroll_browser_collector",
+ ownerActionable: true,
+ proofGate: "browser_collector_live_proof_missing",
+ setupModality: "browser_bound",
+ supportState: "proof_gated",
+ actionMethod: null,
+ actionStatus: "owner_mediated",
+ actionUrl: null,
+ }),
+ ownerTemplate({
+ connectorKey: "doordash",
+ connectorModality: "browser_bound",
+ disposition: "browser_bound_runbook",
+ nextStepKind: "manual_runbook",
+ ownerActionable: false,
+ proofGate: "browser_collector_live_proof_missing",
+ setupModality: "browser_bound",
+ supportState: "proof_gated",
+ actionMethod: null,
+ actionStatus: "unsupported",
+ actionUrl: null,
+ }),
+ ]
+ );
+
+ const chatgpt = catalog.find((entry) => entry.connectorKey === "chatgpt");
+ assert.ok(chatgpt);
+ assert.equal(chatgpt.ownerActionable, true);
+ assert.equal(chatgpt.ownerActionMethod, null);
+ assert.equal(chatgpt.ownerActionUrl, null);
+ assert.equal(sourceSetupAvailability(chatgpt), "available_now");
+ assert.deepEqual(sourceSetupAction(chatgpt), {
+ href: "/connect/browser-session/chatgpt",
+ label: "Connect account",
+ });
+
+ const chase = catalog.find((entry) => entry.connectorKey === "chase");
+ assert.ok(chase);
+ assert.equal(chase.ownerActionable, true);
+ assert.equal(sourceSetupAvailability(chase), "available_now");
+ assert.deepEqual(sourceSetupAction(chase), {
+ href: "/connect/browser-session/chase",
+ label: "Connect account",
+ });
+
+ const doordash = catalog.find((entry) => entry.connectorKey === "doordash");
+ assert.ok(doordash);
+ assert.equal(doordash.ownerActionable, false);
+ assert.equal(sourceSetupAvailability(doordash), "not_available_here");
+ assert.equal(sourceSetupAction(doordash), null);
+});
+
+test("a supported owner action has an invariant actionable provider projection", () => {
+ const catalog = buildOwnerConnectorCatalog(
+ [
+ {
+ connector_id: "test-provider",
+ display_name: "Provider display from manifest",
+ external_docs: [{ label: "Provider docs", url: "https://example.test/docs" }],
+ },
+ ],
+ [ownerTemplate({ connectorKey: "test-provider", listingStatus: "needs_human_auth" })]
+ );
+ const [entry] = catalog;
+ assert.ok(entry);
+ assert.equal(entry.ownerActionable, true);
+ assert.equal(entry.disposition, "provider_auth_connect");
+ assert.equal(entry.supportState, "supported");
+ assert.equal(entry.proofGate, null);
+ assert.equal(entry.ownerActionMethod, "POST");
+ assert.match(entry.ownerActionUrl ?? "", OWNER_INTENT_URL_RE);
+ assert.equal(sourceSetupAvailability(entry), "available_now");
+ assert.deepEqual(sourceSetupAction(entry), {
+ href: "/connect/provider-auth/test-provider",
+ label: "Authorize account",
+ });
+ assert.deepEqual(entry.externalDocs, [{ label: "Provider docs", url: "https://example.test/docs" }]);
+ assert.equal(entry.displayName, "test-provider");
+});
+
test("claude-code manifest slug maps to the claude_code enrollment key", async () => {
// The manifest slug is `claude-code` (hyphen); the proven enrollment path and
// the form's COLLECTOR_RUN_CONNECTORS literal use `claude_code` (underscore).
@@ -452,6 +720,7 @@ test("the grouping helpers partition the catalog without overlap or loss", async
manualUploadConnectEntries(catalog),
manualUploadPendingEntries(catalog),
deploymentBlockedEntries(catalog),
+ providerAuthConnectEntries(catalog),
unsupportedNetworkEntries(catalog),
];
const total = groups.reduce((sum, g) => sum + g.length, 0);
@@ -488,3 +757,138 @@ test("filesystem connectors outside the proven set are local-collector-unproven,
assert.notEqual(entry.modality, "local_collector");
}
});
+
+test("ownerActionable field is the sole authority for live owner catalogs", () => {
+ // Live owner catalogs always compute ownerActionable once in
+ // buildOwnerConnectorCatalog and store it. isOwnerActionableEntry must
+ // trust that field, not re-derive it.
+ const catalog = buildOwnerConnectorCatalog(
+ [],
+ [
+ ownerTemplate({
+ connectorKey: "actionable-api",
+ disposition: "provider_auth_connect",
+ ownerActionable: true,
+ }),
+ ownerTemplate({
+ connectorKey: "blocked-api",
+ disposition: "provider_auth_connect",
+ ownerActionable: false,
+ }),
+ ownerTemplate({
+ connectorKey: "actionable-static-secret",
+ connectorModality: "browser_bound",
+ disposition: "static_secret_connect",
+ setupModality: "static_secret",
+ nextStepKind: "capture_static_secret",
+ ownerActionable: true,
+ actionMethod: null,
+ actionStatus: "owner_mediated",
+ actionUrl: null,
+ }),
+ ownerTemplate({
+ connectorKey: "blocked-static-secret",
+ connectorModality: "browser_bound",
+ disposition: "static_secret_connect",
+ setupModality: "static_secret",
+ nextStepKind: "capture_static_secret",
+ ownerActionable: false,
+ actionMethod: null,
+ actionStatus: "unsupported",
+ actionUrl: null,
+ }),
+ ]
+ );
+
+ for (const entry of catalog) {
+ const actionable = isOwnerActionableEntry(entry);
+ assert.equal(
+ actionable,
+ entry.ownerActionable,
+ `isOwnerActionableEntry(${entry.connectorKey}) must return the ownerActionable field: ${entry.ownerActionable}`
+ );
+ }
+});
+
+test("isOwnerActionableEntry respects demo/test fallback rules when ownerActionable is undefined", async () => {
+ // Pure manifest catalogs (buildConnectorCatalog) have no ownerActionable field.
+ // isOwnerActionableEntry must apply fallback rules for testing. YNAB is proven
+ // to be supported (not proof-gated), so it serves as a good fallback test.
+ const manifests = await loadCommittedManifests();
+ const catalog = buildConnectorCatalog(manifests);
+
+ const ynab = catalog.find((e) => e.connectorKey === "ynab");
+ assert.ok(ynab);
+ assert.equal(ynab.ownerActionable, undefined);
+ assert.equal(ynab.setupModality, "static_secret");
+ assert.equal(ynab.supportState, "supported");
+ assert.equal(ynab.proofGate, null);
+ // Fallback rule: static_secret dispositions are actionable if supported and not proof-gated
+ assert.equal(isOwnerActionableEntry(ynab), true);
+});
+
+test("presentation consistency: helper functions agree with ownerActionable authority", async () => {
+ // Every fixture in the presentation test suite must have presentation functions
+ // that agree with isOwnerActionableEntry. This is the core maintainability check.
+ const manifests = await loadCommittedManifests();
+ const catalog = buildConnectorCatalog(manifests);
+
+ for (const entry of catalog) {
+ const isActionable = isOwnerActionableEntry(entry);
+ const hasAction = sourceSetupAction(entry) !== null;
+
+ // The invariant: if isOwnerActionableEntry returns true, sourceSetupAction
+ // must have a non-null result. Mutations to either would break this.
+ assert.equal(
+ hasAction,
+ isActionable,
+ `${entry.connectorKey}: sourceSetupAction must match isOwnerActionableEntry. ` +
+ `Helper says ${isActionable}, action is ${hasAction ? "set" : "null"}`
+ );
+ }
+});
+
+test("owner catalog: presentation consistency between actionability and availability", () => {
+ // For owner catalogs, ownerActionable gates both sourceSetupAvailability and
+ // sourceSetupAction. They must converge.
+ const catalog = buildOwnerConnectorCatalog(
+ [],
+ [
+ ownerTemplate({
+ connectorKey: "available",
+ disposition: "provider_auth_connect",
+ ownerActionable: true,
+ }),
+ ownerTemplate({
+ connectorKey: "not-available",
+ disposition: "provider_auth_proof_gated",
+ ownerActionable: false,
+ proofGate: "missing_proof",
+ }),
+ ownerTemplate({
+ connectorKey: "deployment-blocked",
+ disposition: "provider_auth_deployment_blocked",
+ ownerActionable: false,
+ }),
+ ]
+ );
+
+ const available = catalog.find((e) => e.connectorKey === "available");
+ assert.ok(available);
+ assert.equal(isOwnerActionableEntry(available), true);
+ assert.equal(sourceSetupAction(available) !== null, true);
+ assert.equal(sourceSetupAvailability(available), "available_now");
+
+ const notAvailable = catalog.find((e) => e.connectorKey === "not-available");
+ assert.ok(notAvailable);
+ assert.equal(isOwnerActionableEntry(notAvailable), false);
+ assert.equal(sourceSetupAction(notAvailable), null);
+ assert.equal(sourceSetupAvailability(notAvailable), "not_available_here");
+
+ const deploymentBlocked = catalog.find((e) => e.connectorKey === "deployment-blocked");
+ assert.ok(deploymentBlocked);
+ assert.equal(isOwnerActionableEntry(deploymentBlocked), false);
+ assert.equal(sourceSetupAction(deploymentBlocked), null);
+ // Special case: deployment_blocked gets "requires_server_setup", not "not_available_here"
+ assert.equal(sourceSetupAvailability(deploymentBlocked), "requires_server_setup");
+});
diff --git a/apps/console/src/app/(console)/lib/connection-catalog.ts b/apps/console/src/app/(console)/lib/connection-catalog.ts
index af65fb757..c206b6b6f 100644
--- a/apps/console/src/app/(console)/lib/connection-catalog.ts
+++ b/apps/console/src/app/(console)/lib/connection-catalog.ts
@@ -4,12 +4,10 @@
/**
* Pure connector-catalog model for the console add-connection surface.
*
- * The add-connection surface is a server component, so it can read every shipped
- * connector manifest cookie-side via `listConnectorManifests()` — each manifest
- * carries `runtime_requirements.bindings`, which is all the binding-derived
- * modality classifier needs. This module turns that manifest list into a catalog
- * the picker renders: every connector, grouped by modality, routed to the honest
- * next step the reference can complete today.
+ * The live add-connection surface consumes the authenticated owner-template
+ * projection. Local manifests are joined only for manifest-authored display,
+ * help, acquisition, and documentation fields; they never supply capability,
+ * listing, registration, proof, readiness, or action truth.
*
* This module introduces NO new classification truth. It projects the shared
* reference setup planner (`pdpp-reference-implementation/connection-setup-plan`)
@@ -29,6 +27,7 @@ import {
enrollmentKeyForCanonicalKey,
manualUploadSetupFromManifest,
type StaticSecretSetupFieldLike,
+ staticSecretCredentialCaptureFromManifest,
} from "pdpp-reference-implementation/connection-setup-plan";
/**
@@ -45,10 +44,18 @@ export interface CatalogManifestLike {
required?: readonly string[] | null;
type?: string | null;
} | null;
+ refresh_policy?: {
+ rationale?: string | null;
+ } | null;
+ public_listing?: {
+ listed?: boolean | null;
+ status?: string | null;
+ } | null;
} | null;
connector_id: string;
connector_key?: string | null;
display_name?: string | null;
+ external_docs?: readonly { label?: string | null; url?: string | null }[] | null;
name?: string | null;
runtime_requirements?: { bindings?: Record | null } | null;
setup?: {
@@ -83,6 +90,45 @@ export interface CatalogManifestLike {
} | null;
}
+/**
+ * Server-owned capability projection returned by the owner-template route.
+ * Local manifests are joined only for display/help/documentation fields; these
+ * fields are the authority for registration, listing, setup capability, proof,
+ * readiness, and owner-facing action. An owner-mediated browser action has no
+ * owner-agent REST method or URL; `supported_actions` remains the authority
+ * for that separate API capability.
+ */
+export interface OwnerConnectorTemplateLike {
+ connector_key?: string | null;
+ connector_modality?: string | null;
+ display_name?: string | null;
+ public_listing?: {
+ listed?: boolean | null;
+ status?: string | null;
+ } | null;
+ registration_status?: string | null;
+ setup_plan?: {
+ catalog_disposition?: string | null;
+ deployment_readiness?: ConnectorSetupDeploymentReadiness | null;
+ enrollment_key?: string | null;
+ next_step_kind?: string | null;
+ /** True when an owner-facing setup path exists, including owner-mediated browser setup. */
+ owner_actionable?: boolean | null;
+ proof_gate?: string | null;
+ runbook_path?: string | null;
+ setup_modality?: string | null;
+ support_state?: string | null;
+ } | null;
+ supported_actions?:
+ | readonly {
+ family?: string | null;
+ method?: string | null;
+ status?: string | null;
+ url?: string | null;
+ }[]
+ | null;
+}
+
/** Binding-derived modality, matching the backend intent route's taxonomy. */
export type CatalogModality = ConnectorIntentModality;
@@ -98,11 +144,9 @@ export type CatalogModality = ConnectorIntentModality;
* path (deep-links to mint a code; the owner finishes the run locally).
* - `browser_bound_runbook` — a browser-bound connector with no generated console
* path yet; visible and pointed at the runbook, but NOT deep-linked.
- * - `static_secret_connect` — a network-class connector whose first connection
- * is created via the owner-session static-secret draft path.
- * A real owner connect route exists; the picker links to that owner-session
- * capture form, not to local-device enrollment, and the connection stays
- * hidden until first ingest accepts records.
+ * - `static_secret_connect` — a network-class connector whose manifest declares
+ * static-secret capture. The live owner catalog supplies the proof/action
+ * gate; a capture form alone never makes this disposition actionable.
* - `manual_upload_connect` — a manifest-declared file/import connector whose
* owner-session upload route is packaged; the picker links to the generic
* file-capture form and the connection stays hidden until first ingest
@@ -125,6 +169,11 @@ export interface ConnectorAcquisitionPath {
posture: string;
}
+export interface ConnectorExternalDoc {
+ readonly label: string;
+ readonly url: string;
+}
+
export interface ConnectorCatalogEntry {
/** Manifest-declared owner acquisition jobs, such as export/upload paths. */
acquisitionPaths: readonly ConnectorAcquisitionPath[];
@@ -143,14 +192,32 @@ export interface ConnectorCatalogEntry {
* never renders an enrollment link the reference cannot complete.
*/
enrollmentKey?: string;
+ /** Manifest-authored external documentation links. */
+ externalDocs: readonly ConnectorExternalDoc[];
/** Binding-derived modality. */
modality: CatalogModality;
/** The next owner step selected by the shared planner. */
nextStepKind: ConnectorSetupNextStepKind;
+ /** Server-authorized owner-facing setup action; live owner catalogs always set it. */
+ ownerActionable?: boolean;
+ /** Projected owner-agent method; null for owner-mediated browser setup. */
+ ownerActionMethod?: string | null;
+ /** Projected owner-agent URL; null for owner-mediated browser setup. */
+ ownerActionUrl?: string | null;
/** Proof gate blocking support, if any. */
proofGate: string | null;
+ /** Server-owned public-listing state. */
+ publicListingStatus?: string | null;
+ /** Existing capability rationale used for owner context where no setup copy exists. */
+ refreshPolicyRationale: string | null;
+ /** Server-owned registration state. */
+ registrationStatus?: string | null;
/** Optional runbook path surfaced in advanced/details copy. */
runbookPath: string | null;
+ /** Manifest-authored setup description, when the setup modality provides one. */
+ setupDescription: string | null;
+ /** Manifest-authored setup help text, when the setup modality provides one. */
+ setupHelpText: string | null;
/** The owner setup modality selected by the shared planner. */
setupModality: ConnectorSetupModality;
/** Support state selected by the shared planner. */
@@ -178,6 +245,33 @@ function displayNameFor(manifest: CatalogManifestLike, connectorKey: string): st
return connectorKey;
}
+function cleanManifestText(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value.trim() : null;
+}
+
+function setupCopyFromManifest(manifest: CatalogManifestLike): {
+ description: string | null;
+ helpText: string | null;
+} {
+ const uploadSetup = manualUploadSetupFromManifest(manifest);
+ const credentialSetup = staticSecretCredentialCaptureFromManifest(manifest);
+ return {
+ description: uploadSetup?.description ?? credentialSetup?.description ?? null,
+ helpText: uploadSetup?.helpText ?? null,
+ };
+}
+
+function externalDocsFromManifest(manifest: CatalogManifestLike): ConnectorExternalDoc[] {
+ if (!Array.isArray(manifest.external_docs)) {
+ return [];
+ }
+ return manifest.external_docs.flatMap((doc) => {
+ const label = cleanManifestText(doc?.label);
+ const url = cleanManifestText(doc?.url);
+ return label && url ? [{ label, url }] : [];
+ });
+}
+
function acquisitionPathsFromManifest(manifest: CatalogManifestLike): ConnectorAcquisitionPath[] {
const uploadSetup = manualUploadSetupFromManifest(manifest);
if (!uploadSetup) {
@@ -193,29 +287,36 @@ function acquisitionPathsFromManifest(manifest: CatalogManifestLike): ConnectorA
}
/**
- * Build the connector catalog from the shipped manifests. One entry per manifest
- * with a `connector_id`, sorted by display name so the picker is stable across
- * renders. Entries only carry an `enrollmentKey` for dispositions the console can
- * actually start, so a caller cannot accidentally deep-link a gated connector.
+ * Build the pure manifest/planner projection used by tests and demo data. The
+ * live Add Source page uses `buildOwnerConnectorCatalog` so local manifests
+ * cannot supply registration, listing, proof, readiness, or action authority.
*/
-export function buildConnectorCatalog(manifests: readonly CatalogManifestLike[]): ConnectorCatalogEntry[] {
+export function buildConnectorCatalog(
+ manifests: readonly CatalogManifestLike[],
+ configuredProviderAuthConnectorKeys: readonly string[] = []
+): ConnectorCatalogEntry[] {
const entries: ConnectorCatalogEntry[] = [];
for (const manifest of manifests) {
if (!manifest.connector_id) {
continue;
}
const connectorKey = canonicalConnectorKey(manifest.connector_id);
- const plan = buildConnectionSetupPlan({ connectorKey, manifest });
+ const plan = buildConnectionSetupPlan({ connectorKey, configuredProviderAuthConnectorKeys, manifest });
+ const setupCopy = setupCopyFromManifest(manifest);
const entry: ConnectorCatalogEntry = {
acquisitionPaths: acquisitionPathsFromManifest(manifest),
connectorKey,
deploymentReadiness: plan.deploymentReadiness,
displayName: displayNameFor(manifest, connectorKey),
disposition: plan.catalogDisposition,
+ externalDocs: externalDocsFromManifest(manifest),
modality: plan.connectorModality,
nextStepKind: plan.nextStepKind,
proofGate: plan.proofGate,
+ refreshPolicyRationale: cleanManifestText(manifest.capabilities?.refresh_policy?.rationale),
runbookPath: plan.runbookPath,
+ setupDescription: setupCopy.description,
+ setupHelpText: setupCopy.helpText,
setupModality: plan.setupModality,
supportState: plan.supportState,
};
@@ -230,6 +331,215 @@ export function buildConnectorCatalog(manifests: readonly CatalogManifestLike[])
return entries;
}
+const OWNER_ACTIONABLE_PUBLIC_LISTING_STATUSES = new Set(["proven", "needs_human_auth"]);
+const CATALOG_INTENT_MODALITIES = new Set([
+ "local_collector",
+ "browser_bound",
+ "api_network",
+ "unknown",
+]);
+const CATALOG_MODALITIES = new Set([
+ "local_collector",
+ "browser_bound",
+ "static_secret",
+ "provider_authorization",
+ "manual_or_upload",
+ "unsupported",
+ "unknown",
+]);
+const CATALOG_NEXT_STEPS = new Set([
+ "enroll_local_collector",
+ "enroll_browser_collector",
+ "capture_static_secret",
+ "open_provider_auth",
+ "needs_deployment_config",
+ "provide_import_file",
+ "manual_runbook",
+ "unsupported",
+]);
+const CATALOG_SUPPORT_STATES = new Set([
+ "supported",
+ "proof_gated",
+ "unsupported",
+ "needs_deployment_config",
+]);
+const CATALOG_READINESS_STATES = new Set([
+ "not_applicable",
+ "ready",
+ "needs_config",
+]);
+const CATALOG_DISPOSITIONS = new Set([
+ "local_collector_enroll",
+ "local_collector_unproven",
+ "browser_collector_manual",
+ "browser_bound_runbook",
+ "static_secret_connect",
+ "manual_upload_connect",
+ "manual_upload_pending",
+ "provider_auth_deployment_blocked",
+ "provider_auth_connect",
+ "provider_auth_proof_gated",
+ "api_network_unsupported",
+ "unknown_unsupported",
+]);
+
+function isCatalogValue(values: ReadonlySet, value: unknown): value is T {
+ return typeof value === "string" && values.has(value as T);
+}
+
+function actionableOwnerActionFromTemplate(
+ template: OwnerConnectorTemplateLike,
+ entry: {
+ connectorModality: ConnectorIntentModality;
+ disposition: CatalogDisposition;
+ enrollmentKey?: string;
+ nextStepKind: ConnectorSetupNextStepKind;
+ proofGate: string | null;
+ setupModality: ConnectorSetupModality;
+ supportState: ConnectorSetupSupportState;
+ }
+): { actionable: boolean; method: string | null; url: string | null } {
+ const action = template.supported_actions?.find((candidate) => candidate.family === "initiate_connection");
+ const method = typeof action?.method === "string" ? action.method : null;
+ const url = typeof action?.url === "string" && action.url.trim() ? action.url : null;
+ const authority =
+ template.registration_status === "registered" &&
+ template.public_listing?.listed === true &&
+ typeof template.public_listing.status === "string" &&
+ OWNER_ACTIONABLE_PUBLIC_LISTING_STATUSES.has(template.public_listing.status) &&
+ template.setup_plan?.owner_actionable === true &&
+ entry.nextStepKind === template.setup_plan?.next_step_kind;
+ const ownerAgentActionable =
+ authority &&
+ entry.supportState === "supported" &&
+ entry.proofGate === null &&
+ action?.status === "supported" &&
+ method !== null &&
+ url !== null &&
+ (entry.disposition !== "local_collector_enroll" || typeof entry.enrollmentKey === "string");
+ const ownerSessionBrowserActionable =
+ authority &&
+ entry.connectorModality === "browser_bound" &&
+ ((entry.disposition === "browser_collector_manual" &&
+ entry.nextStepKind === "enroll_browser_collector" &&
+ typeof entry.enrollmentKey === "string") ||
+ (entry.disposition === "static_secret_connect" && entry.setupModality === "static_secret")) &&
+ action?.status === "owner_mediated" &&
+ method === null &&
+ url === null;
+ return { actionable: ownerAgentActionable || ownerSessionBrowserActionable, method, url };
+}
+
+/**
+ * Build the live catalog from the authenticated server projection. A template
+ * with missing authority fields is dropped rather than reconstructed from a
+ * local manifest. Local data is a display/help/docs join only.
+ */
+export function buildOwnerConnectorCatalog(
+ manifests: readonly CatalogManifestLike[],
+ templates: readonly OwnerConnectorTemplateLike[]
+): ConnectorCatalogEntry[] {
+ const manifestsByKey = new Map();
+ for (const manifest of manifests) {
+ if (manifest.connector_id) {
+ manifestsByKey.set(canonicalConnectorKey(manifest.connector_id), manifest);
+ }
+ }
+
+ const entries: ConnectorCatalogEntry[] = [];
+ for (const template of templates) {
+ const connectorKey = cleanManifestText(template.connector_key);
+ const setupPlan = template.setup_plan;
+ if (!connectorKey || template.registration_status !== "registered" || template.public_listing?.listed !== true) {
+ continue;
+ }
+ const disposition = setupPlan?.catalog_disposition;
+ const connectorModality = template.connector_modality;
+ const setupModality = setupPlan?.setup_modality;
+ const nextStepKind = setupPlan?.next_step_kind;
+ const supportState = setupPlan?.support_state;
+ const deploymentReadiness = setupPlan?.deployment_readiness;
+ if (
+ !(
+ isCatalogValue(CATALOG_DISPOSITIONS, disposition) &&
+ isCatalogValue(CATALOG_INTENT_MODALITIES, connectorModality) &&
+ isCatalogValue(CATALOG_MODALITIES, setupModality) &&
+ isCatalogValue(CATALOG_NEXT_STEPS, nextStepKind) &&
+ isCatalogValue(CATALOG_SUPPORT_STATES, supportState) &&
+ deploymentReadiness &&
+ isCatalogValue(CATALOG_READINESS_STATES, deploymentReadiness.state)
+ )
+ ) {
+ continue;
+ }
+
+ const localManifest = manifestsByKey.get(canonicalConnectorKey(connectorKey));
+ const manifestForCopy = localManifest ?? { connector_id: connectorKey };
+ const proofGate = typeof setupPlan.proof_gate === "string" ? setupPlan.proof_gate : null;
+ const enrollmentKey = cleanManifestText(setupPlan.enrollment_key) ?? undefined;
+ const capability = actionableOwnerActionFromTemplate(template, {
+ connectorModality,
+ disposition,
+ enrollmentKey,
+ nextStepKind,
+ proofGate,
+ setupModality,
+ supportState,
+ });
+ const setupCopy = setupCopyFromManifest(manifestForCopy);
+ const entry: ConnectorCatalogEntry = {
+ acquisitionPaths: acquisitionPathsFromManifest(manifestForCopy),
+ connectorKey: canonicalConnectorKey(connectorKey),
+ deploymentReadiness,
+ displayName: cleanManifestText(template.display_name) ?? displayNameFor(manifestForCopy, connectorKey),
+ disposition,
+ externalDocs: externalDocsFromManifest(manifestForCopy),
+ modality: connectorModality,
+ nextStepKind,
+ ownerActionable: capability.actionable,
+ ownerActionMethod: capability.method,
+ ownerActionUrl: capability.url,
+ proofGate,
+ publicListingStatus: cleanManifestText(template.public_listing?.status),
+ refreshPolicyRationale: cleanManifestText(localManifest?.capabilities?.refresh_policy?.rationale),
+ registrationStatus: template.registration_status,
+ runbookPath: cleanManifestText(setupPlan.runbook_path),
+ setupDescription: setupCopy.description,
+ setupHelpText: setupCopy.helpText,
+ setupModality,
+ supportState,
+ };
+ if (enrollmentKey) {
+ entry.enrollmentKey = enrollmentKey;
+ }
+ entries.push(entry);
+ }
+ entries.sort((a, b) => a.displayName.localeCompare(b.displayName));
+ return entries;
+}
+
+/**
+ * Check if this entry is owner-actionable.
+ *
+ * For live owner-catalog entries, ownerActionable is the authoritative field
+ * computed once during buildOwnerConnectorCatalog. For demo/test entries from
+ * buildConnectorCatalog, fall back to explicit rules since they carry no
+ * owner-session authority.
+ */
+export function isOwnerActionableEntry(entry: ConnectorCatalogEntry): boolean {
+ if (entry.ownerActionable !== undefined) {
+ return entry.ownerActionable;
+ }
+ // `buildConnectorCatalog` remains a pure manifest/planner projection for
+ // tests and demo data. Its static-secret and provider branches still fail
+ // closed on the planner's proof fields; live pages must use the owner
+ // projection above, which also supplies registration and listing authority.
+ if (entry.setupModality === "static_secret" || entry.disposition === "provider_auth_connect") {
+ return entry.supportState === "supported" && entry.proofGate === null;
+ }
+ return entry.supportState === "supported" || entry.disposition === "browser_collector_manual";
+}
+
/** Catalog entries the console can start as a one-click local-collector enroll. */
export function localCollectorEntries(catalog: readonly ConnectorCatalogEntry[]): ConnectorCatalogEntry[] {
return catalog.filter((e) => e.disposition === "local_collector_enroll");
@@ -273,6 +583,11 @@ export function manualUploadPendingEntries(catalog: readonly ConnectorCatalogEnt
return catalog.filter((e) => e.disposition === "manual_upload_pending");
}
+/** Provider-auth entries whose shared plan authorizes an owner action now. */
+export function providerAuthConnectEntries(catalog: readonly ConnectorCatalogEntry[]): ConnectorCatalogEntry[] {
+ return catalog.filter((e) => e.disposition === "provider_auth_connect");
+}
+
/** Provider-authorization entries blocked on instance-level deployment config. */
export function deploymentBlockedEntries(catalog: readonly ConnectorCatalogEntry[]): ConnectorCatalogEntry[] {
return catalog.filter((e) => e.disposition === "provider_auth_deployment_blocked");
diff --git a/apps/console/src/app/(console)/lib/connection-evidence.test.ts b/apps/console/src/app/(console)/lib/connection-evidence.test.ts
index 6ae253fcf..02d5614be 100644
--- a/apps/console/src/app/(console)/lib/connection-evidence.test.ts
+++ b/apps/console/src/app/(console)/lib/connection-evidence.test.ts
@@ -2237,7 +2237,7 @@ test("formatCollectionRateReadout fails closed for partial projection payloads",
ceiling_rate_per_min: 240,
current_interval_ms: 250,
effective_rate_per_min: 240,
- last_backoff: { reason: "throttle" } as never,
+ last_backoff: { reason: "throttle" },
});
assert.ok(partialBackoff);
assert.equal(partialBackoff.backoffLabel, null, "partial back-off → omit optional line");
diff --git a/apps/console/src/app/(console)/lib/connection-evidence.ts b/apps/console/src/app/(console)/lib/connection-evidence.ts
index ff6cadcde..417696981 100644
--- a/apps/console/src/app/(console)/lib/connection-evidence.ts
+++ b/apps/console/src/app/(console)/lib/connection-evidence.ts
@@ -1923,7 +1923,10 @@ export function formatCollectionRateReadout(
}
const backoff = rate.last_backoff;
const backoffLabel =
- backoff && Number.isFinite(backoff.at_interval_ms) && typeof backoff.reason === "string"
+ backoff &&
+ typeof backoff.at_interval_ms === "number" &&
+ Number.isFinite(backoff.at_interval_ms) &&
+ typeof backoff.reason === "string"
? `last backed off to ${backoff.at_interval_ms.toLocaleString()}ms (${backoff.reason})`
: null;
return {
diff --git a/apps/console/src/app/(console)/lib/connector-run-summary-status.test.ts b/apps/console/src/app/(console)/lib/connector-run-summary-status.test.ts
index fb7c693e5..223d55ead 100644
--- a/apps/console/src/app/(console)/lib/connector-run-summary-status.test.ts
+++ b/apps/console/src/app/(console)/lib/connector-run-summary-status.test.ts
@@ -3,7 +3,7 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { isActiveConnectorRunSummaryStatus } from "./connector-run-summary-status.ts";
+import { connectorRunSummaryId, isActiveConnectorRunSummaryStatus } from "./connector-run-summary-status.ts";
test("isActiveConnectorRunSummaryStatus accepts only the current connector-summary active states", () => {
for (const status of ["pending", "started", "in_progress"]) {
@@ -14,3 +14,10 @@ test("isActiveConnectorRunSummaryStatus accepts only the current connector-summa
assert.equal(isActiveConnectorRunSummaryStatus(status), false, `${status} should not be active`);
}
});
+
+test("connectorRunSummaryId rejects scheduler decisions that are not real runs", () => {
+ assert.equal(connectorRunSummaryId("run_123"), "run_123");
+ assert.equal(connectorRunSummaryId(undefined), null);
+ assert.equal(connectorRunSummaryId(null), null);
+ assert.equal(connectorRunSummaryId(" "), null);
+});
diff --git a/apps/console/src/app/(console)/lib/connector-run-summary-status.ts b/apps/console/src/app/(console)/lib/connector-run-summary-status.ts
index 00cbca5d9..2df980a8f 100644
--- a/apps/console/src/app/(console)/lib/connector-run-summary-status.ts
+++ b/apps/console/src/app/(console)/lib/connector-run-summary-status.ts
@@ -7,3 +7,8 @@ const ACTIVE_RUN_SUMMARY_STATUSES = new Set(["pending", "started", "in_progress"
export function isActiveConnectorRunSummaryStatus(status: string): boolean {
return ACTIVE_RUN_SUMMARY_STATUSES.has(status);
}
+
+/** Synthetic scheduler gate decisions have no run id and are not navigable syncs. */
+export function connectorRunSummaryId(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
+}
diff --git a/apps/console/src/app/(console)/lib/operator-runs.test.ts b/apps/console/src/app/(console)/lib/operator-runs.test.ts
index 40142a448..5ab6cc363 100644
--- a/apps/console/src/app/(console)/lib/operator-runs.test.ts
+++ b/apps/console/src/app/(console)/lib/operator-runs.test.ts
@@ -14,6 +14,7 @@ const CONNECTION_CONTROL_PATH_TEMPLATE_RE =
/`\/_ref\/connections\/\$\{encodeURIComponent\(connectionId\)\}\$\{suffix\}`/;
const RUN_CONNECTION_EXPORT_RE =
/export function runConnectionNow\(connectionId: string, options: RunNowOptions = \{\}\)/;
+const RUN_ADMISSION_TYPE_RE = /export type RunAdmission = "browser_enrollment" \| "setup";/;
const RUN_NOW_FORCE_BODY_RE = /forceOption === true \? \{ force: true \}/;
const RUN_NOW_ADMISSION_BODY_RE = /runAdmission \? \{ run_admission: runAdmission \}/;
const RUN_CONNECTOR_OPTIONS_RE =
@@ -33,6 +34,7 @@ test("operator run helpers expose connection-scoped control paths", async () =>
assert.match(src, CONNECTION_CONTROL_PATH_TEMPLATE_RE);
assert.match(src, RUN_CONNECTOR_OPTIONS_RE);
assert.match(src, RUN_CONNECTION_EXPORT_RE);
+ assert.match(src, RUN_ADMISSION_TYPE_RE);
assert.match(src, RUN_NOW_FORCE_BODY_RE);
assert.match(src, RUN_NOW_ADMISSION_BODY_RE);
assert.match(src, SAVE_CONNECTION_SCHEDULE_EXPORT_RE);
diff --git a/apps/console/src/app/(console)/lib/operator-runs.ts b/apps/console/src/app/(console)/lib/operator-runs.ts
index 733770db5..09bfc0cc0 100644
--- a/apps/console/src/app/(console)/lib/operator-runs.ts
+++ b/apps/console/src/app/(console)/lib/operator-runs.ts
@@ -83,9 +83,11 @@ function connectionControlPath(connectionId: string, suffix: string): string {
return `/_ref/connections/${encodeURIComponent(connectionId)}${suffix}`;
}
-interface RunNowOptions {
+export type RunAdmission = "browser_enrollment" | "setup";
+
+export interface RunNowOptions {
force?: boolean;
- runAdmission?: "browser_enrollment";
+ runAdmission?: RunAdmission;
}
async function runNowAt(path: string, options: RunNowOptions = {}) {
diff --git a/apps/console/src/app/(console)/lib/ref-client.ts b/apps/console/src/app/(console)/lib/ref-client.ts
index 2d1b9af1c..345ce5c7e 100644
--- a/apps/console/src/app/(console)/lib/ref-client.ts
+++ b/apps/console/src/app/(console)/lib/ref-client.ts
@@ -93,7 +93,7 @@ export interface RunStatusEnvelope {
} | null;
links: { timeline: string };
object: "run_status";
- run_id: string;
+ run_id?: string | null;
started_at: string | null;
status: RunHandleStatus;
terminal_reason: string | null;
@@ -317,11 +317,16 @@ export interface RefConnectorRunSummary {
first_at: string;
known_gaps?: unknown[];
last_at: string;
+ records_emitted?: number | null;
+ reported_records_emitted?: number | null;
run_id: string;
started_at: string;
status: string;
+ yield_counts_present?: boolean;
}
+export type RefTerminalSetupDisposition = "verified_empty" | "unverified_missing_counts" | "unverified_zero";
+
export interface RefreshPolicy {
assisted_after_owner_auth?: boolean;
background_safe?: boolean;
@@ -676,6 +681,8 @@ export interface RefConnectorSummary {
* the same reason as `manifest_declaration`.
*/
terminal_facts?: RefTerminalFactsState | null;
+ /** Shared connection-scoped terminal setup disposition for a draft. */
+ terminal_setup_disposition?: RefTerminalSetupDisposition | null;
total_records: number;
/**
* Orthogonal state for `total_records` (`reconcile-active-summary-evidence`
@@ -1114,8 +1121,12 @@ export interface RefCollectionRateSnapshot {
current_interval_ms: number;
/** Current effective rate (requests/min). */
effective_rate_per_min: number;
- /** Most recent back-off, or null when none. */
- last_backoff: { at?: string | null; at_interval_ms: number; reason: string } | null;
+ /** Most recent back-off, or null when none; legacy projections may be partial. */
+ last_backoff?: {
+ at?: string | null;
+ at_interval_ms?: number | null;
+ reason?: string | null;
+ } | null;
}
export type RefRemoteSurfaceAxis = "failed" | "idle" | "leased" | "none" | "unknown" | "waiting";
@@ -1304,6 +1315,29 @@ export async function refFetch(
return res.json();
}
+export interface ProviderAuthInitiateResponse {
+ authorization_url: string;
+ connector_id: string;
+ expires_at: string;
+ next_step: {
+ authorization_url: string;
+ expires_at: string;
+ kind: "open_provider_auth";
+ reason: string;
+ redirect_uri: string;
+ };
+ object: "provider_auth_initiate";
+ setup_modality: "provider_authorization";
+}
+
+export async function initiateProviderAuthorization(connectorId: string): Promise {
+ return (await refFetch(`/_ref/connectors/${encodeURIComponent(connectorId)}/provider-auth-initiate`, undefined, {
+ body: "{}",
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ })) as ProviderAuthInitiateResponse;
+}
+
export { RefNotFoundError, RefRequestError };
// Thrown when the owner-session static-secret capture route rejects a credential
@@ -2009,6 +2043,7 @@ export interface StaticSecretDraftConnection {
connector_id: string;
connector_instance_id: string;
credential_kind: string;
+ display_name: string;
next_step: {
kind: "capture_static_secret_credential";
method: "POST";
@@ -2016,7 +2051,7 @@ export interface StaticSecretDraftConnection {
url: string;
};
object: "static_secret_draft_connection";
- status: "draft";
+ status: "active" | "draft";
}
export interface StaticSecretCredentialCapture {
@@ -2040,6 +2075,7 @@ export interface StaticSecretCredentialCapture {
rotated_at: string | null;
status: string | null;
};
+ deduplicated?: boolean;
// Non-secret account identity from a synchronous credential probe ("Connected
// as {identity}"). Null when the connector has no probe (first-sync path).
identity: { account_identity: string; detail: string | null } | null;
@@ -2133,6 +2169,7 @@ export async function captureStaticSecretCredential(input: {
connectionId: string;
credentialKind: string;
secret: string;
+ setupFields?: Record;
}): Promise {
try {
return (await refFetch(
@@ -2142,6 +2179,7 @@ export async function captureStaticSecretCredential(input: {
body: JSON.stringify({
credential_kind: input.credentialKind,
secret: input.secret,
+ ...(input.setupFields ? { setup_fields: input.setupFields } : {}),
}),
headers: { "content-type": "application/json" },
method: "POST",
@@ -2173,6 +2211,10 @@ export type StaticSecretSetupStateValue =
| "first_sync_failed"
| "first_sync_pending"
| "first_sync_running"
+ | "first_sync_unverified_missing_counts"
+ | "first_sync_unverified_zero"
+ | "first_sync_verified_empty"
+ /** @deprecated Kept for references that still emit the pre-disposition state. */
| "first_sync_zero_yield"
| "paused"
| "revoked"
@@ -2242,6 +2284,7 @@ export interface ConnectionSetupStatus {
};
setup_state: StaticSecretSetupStateValue;
status: string;
+ terminal_setup_disposition: RefTerminalSetupDisposition | null;
updated_at: string | null;
}
diff --git a/apps/console/src/app/(console)/lib/rs-client.ts b/apps/console/src/app/(console)/lib/rs-client.ts
index 7f7c998cf..f5c5fb896 100644
--- a/apps/console/src/app/(console)/lib/rs-client.ts
+++ b/apps/console/src/app/(console)/lib/rs-client.ts
@@ -16,6 +16,7 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { findManifestForConnectorId } from "../sources/lib/relationships.ts";
+import type { OwnerConnectorTemplateLike } from "./connection-catalog.ts";
import { isActiveConnectorRunSummaryStatus } from "./connector-run-summary-status.ts";
import {
getOwnerToken,
@@ -142,6 +143,19 @@ export interface StreamMetadata {
}
export interface ConnectorManifest {
+ capabilities?: {
+ auth?: {
+ deployment_config?: readonly string[] | null;
+ kind?: string | null;
+ mode?: string | null;
+ required?: readonly string[] | null;
+ type?: string | null;
+ } | null;
+ public_listing?: {
+ listed?: boolean | null;
+ status?: string | null;
+ } | null;
+ } | null;
connector_id: string;
connector_key?: string;
display_name?: string;
@@ -789,6 +803,14 @@ export async function listConnectorManifests(): Promise {
return manifests;
}
+/** Server-owned catalog projection consumed by the live Add Source surface. */
+export type OwnerConnectorTemplate = OwnerConnectorTemplateLike;
+
+export async function listOwnerConnectorTemplates(): Promise {
+ const body = (await authedFetch("/v1/owner/connector-templates")) as { data?: OwnerConnectorTemplate[] };
+ return Array.isArray(body.data) ? body.data : [];
+}
+
export interface ConnectorOverview {
acquisitionCoverage?: RefAcquisitionCoverageSummary | null;
/**
diff --git a/apps/console/src/app/(console)/lib/run-assistance.test.ts b/apps/console/src/app/(console)/lib/run-assistance.test.ts
index 7bf2cbce3..5d91333b0 100644
--- a/apps/console/src/app/(console)/lib/run-assistance.test.ts
+++ b/apps/console/src/app/(console)/lib/run-assistance.test.ts
@@ -9,6 +9,7 @@ import {
getCurrentRunAssistance,
hasActiveBrowserSurface,
hasAvailableBrowserSurfaceAttachment,
+ hasResolvedBrowserSurfaceAssistance,
requiresBrowserSurfaceAssistance,
} from "./run-assistance.ts";
@@ -177,3 +178,74 @@ test("terminal browser-surface events do not keep stream fallback in the browser
assert.equal(hasActiveBrowserSurface(events), false);
});
+
+// fr-setup-status-lifecycle-0806: an H-E-B-style browser login that already
+// resolved must read as "browser step complete," not the same "nothing has
+// ever happened" signal a fresh run reports before assistance is requested.
+
+test("a never-requested run reports no resolved browser-surface assistance", () => {
+ const events = [event("run.started", { automation_mode: "assisted" })];
+
+ assert.equal(hasResolvedBrowserSurfaceAssistance(events), false);
+});
+
+test("a currently-open browser-surface assistance request is not yet resolved", () => {
+ const events = [
+ event("run.assistance_requested", {
+ assistance_request_id: "assist_1",
+ attachments: [{ kind: "browser_surface", ref: "surface_1", role: "streaming_companion" }],
+ message: "Log in to continue.",
+ owner_action: "operate_attachment",
+ progress_posture: "blocked",
+ response_contract: "response_required",
+ }),
+ ];
+
+ assert.equal(hasResolvedBrowserSurfaceAssistance(events), false);
+});
+
+test("a resolved structured browser-surface assistance request reports handoff-ready", () => {
+ const events = [
+ event("run.assistance_requested", {
+ assistance_request_id: "assist_1",
+ attachments: [{ kind: "browser_surface", ref: "surface_1", role: "streaming_companion" }],
+ message: "Log in to continue.",
+ owner_action: "operate_attachment",
+ progress_posture: "blocked",
+ response_contract: "response_required",
+ }),
+ event("run.assistance_resolved", { assistance_request_id: "assist_1" }),
+ ];
+
+ assert.equal(hasResolvedBrowserSurfaceAssistance(events), true);
+ // The run keeps going — this must not read as fully resolved/ended.
+ assert.equal(getCurrentBrowserSurfaceAssistance(events), null);
+});
+
+test("a resolved legacy manual_action interaction reports handoff-ready", () => {
+ const events = [
+ event("run.interaction_required", {
+ interaction_id: "int_1",
+ kind: "manual_action",
+ message: "Log in to continue.",
+ }),
+ event("run.interaction_completed", { interaction_id: "int_1" }),
+ ];
+
+ assert.equal(hasResolvedBrowserSurfaceAssistance(events), true);
+});
+
+test("a resolved non-browser (app-push) assistance request is not treated as browser handoff", () => {
+ const events = [
+ event("run.assistance_requested", {
+ assistance_request_id: "assist_1",
+ message: "Approve the sign-in in the app.",
+ owner_action: "act_elsewhere",
+ progress_posture: "running",
+ response_contract: "none",
+ }),
+ event("run.assistance_resolved", { assistance_request_id: "assist_1" }),
+ ];
+
+ assert.equal(hasResolvedBrowserSurfaceAssistance(events), false);
+});
diff --git a/apps/console/src/app/(console)/lib/run-assistance.ts b/apps/console/src/app/(console)/lib/run-assistance.ts
index 094f4a55b..043b23d0a 100644
--- a/apps/console/src/app/(console)/lib/run-assistance.ts
+++ b/apps/console/src/app/(console)/lib/run-assistance.ts
@@ -98,6 +98,42 @@ export function hasActiveBrowserSurface(events: SpineEvent[]): boolean {
return false;
}
+/**
+ * True once a browser-surface-shaped assistance request has ever been raised
+ * for this run and is now terminal (resolved/cancelled/escalated/timed out) —
+ * the H-E-B "owner finished login" signal. Distinct from
+ * `hasActiveBrowserSurface`, which answers "is a browser surface open right
+ * now": this answers "did the owner already do the browser step," so the
+ * stream page can hand off to a background-continuing surface instead of
+ * re-showing the same no-action-waiting copy it would show for a run that
+ * never needed browser input at all (fr-setup-status-lifecycle-0806).
+ */
+export function hasResolvedBrowserSurfaceAssistance(events: SpineEvent[]): boolean {
+ const terminalState = getTerminalAssistanceState(events);
+ for (const event of events) {
+ if (event.event_type === "run.assistance_requested") {
+ const id = getEventAssistanceId(event);
+ if (id && terminalState.ids.has(id) && requiresBrowserSurfaceAssistance(assistanceFromEvent(event, id))) {
+ return true;
+ }
+ continue;
+ }
+ if (event.event_type === "run.interaction_completed") {
+ const id = getEventAssistanceId(event);
+ if (!id) {
+ continue;
+ }
+ const requested = events.find(
+ (candidate) => candidate.event_type === "run.interaction_required" && getEventAssistanceId(candidate) === id
+ );
+ if (requested && requiresBrowserSurfaceAssistance(assistanceFromLegacyInteraction(requested, id))) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
function getCompletedLegacyInteractions(events: SpineEvent[]): Set {
return new Set(
events
diff --git a/apps/console/src/app/(console)/lib/same-origin-route.test.ts b/apps/console/src/app/(console)/lib/same-origin-route.test.ts
new file mode 100644
index 000000000..54e10877c
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/same-origin-route.test.ts
@@ -0,0 +1,30 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import test from "node:test";
+import { originMatchesHost, publicOrigin, redirectToPublicPath } from "./same-origin-route.ts";
+
+function requestWith(headers: Record = {}, url = "https://internal.example/submit"): Request {
+ return new Request(url, { headers });
+}
+
+test("same-origin route helper preserves forwarded public origin for redirects", () => {
+ const request = requestWith({
+ host: "internal.example",
+ "x-forwarded-host": "console.example",
+ "x-forwarded-proto": "https",
+ });
+ assert.equal(publicOrigin(request), "https://console.example");
+ const response = redirectToPublicPath(request, "/sources/add?error=failed");
+ assert.equal(response.status, 303);
+ assert.equal(response.headers.get("location"), "https://console.example/sources/add?error=failed");
+});
+
+test("same-origin route helper accepts absent or matching origins and rejects mismatches", () => {
+ assert.equal(originMatchesHost(requestWith({ host: "console.example" })), true);
+ assert.equal(originMatchesHost(requestWith({ host: "console.example", origin: "https://console.example" })), true);
+ assert.equal(originMatchesHost(requestWith({ host: "console.example", origin: "https://attacker.example" })), false);
+ assert.equal(originMatchesHost(requestWith({ host: "console.example", origin: "not a URL" })), false);
+ assert.equal(originMatchesHost(requestWith({ origin: "https://console.example" })), false);
+});
diff --git a/apps/console/src/app/(console)/lib/same-origin-route.ts b/apps/console/src/app/(console)/lib/same-origin-route.ts
new file mode 100644
index 000000000..cb937dbdc
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/same-origin-route.ts
@@ -0,0 +1,35 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { NextResponse } from "next/server";
+
+/** Resolve the browser-facing origin using the same proxy headers as setup routes. */
+export function publicOrigin(request: Request): string {
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? new URL(request.url).host;
+ const proto =
+ request.headers.get("x-forwarded-proto") ??
+ (host.startsWith("localhost") || host.startsWith("127.") ? "http" : "https");
+ return `${proto}://${host}`;
+}
+
+/** Redirect to a local route while preserving the public host/protocol. */
+export function redirectToPublicPath(request: Request, path: string): NextResponse {
+ return NextResponse.redirect(new URL(path, publicOrigin(request)), 303);
+}
+
+/** Accept same-origin form posts and reject cross-origin or malformed Origin headers. */
+export function originMatchesHost(request: Request): boolean {
+ const origin = request.headers.get("origin");
+ if (!origin) {
+ return true;
+ }
+ const host = request.headers.get("host");
+ if (!host) {
+ return false;
+ }
+ try {
+ return new URL(origin).host === host;
+ } catch {
+ return false;
+ }
+}
diff --git a/apps/console/src/app/(console)/lib/schedule-evidence.test.ts b/apps/console/src/app/(console)/lib/schedule-evidence.test.ts
new file mode 100644
index 000000000..9ec511dfb
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/schedule-evidence.test.ts
@@ -0,0 +1,29 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { activeScheduleRunId, scheduleEnabled, scheduleIntervalSeconds } from "./schedule-evidence.ts";
+
+test("schedule interval evidence fails closed for missing or invalid values", () => {
+ for (const value of [undefined, null, Number.NaN, Number.POSITIVE_INFINITY, 0, -1, "3600"]) {
+ assert.equal(scheduleIntervalSeconds(value), null);
+ }
+ assert.equal(scheduleIntervalSeconds(3600), 3600);
+});
+
+test("schedule enabled evidence fails closed when the flag is missing", () => {
+ assert.equal(scheduleEnabled(undefined), null);
+ assert.equal(scheduleEnabled("true"), null);
+ assert.equal(scheduleEnabled(true), true);
+ assert.equal(scheduleEnabled(false), false);
+});
+
+test("active schedule run evidence only accepts a non-empty string id", () => {
+ assert.equal(activeScheduleRunId(null), null);
+ assert.equal(activeScheduleRunId({ active_run_id: undefined }), null);
+ assert.equal(activeScheduleRunId({ active_run_id: 42 }), null);
+ assert.equal(activeScheduleRunId({ active_run_id: "" }), null);
+ assert.equal(activeScheduleRunId({ active_run_id: "run_1" }), "run_1");
+});
diff --git a/apps/console/src/app/(console)/lib/schedule-evidence.ts b/apps/console/src/app/(console)/lib/schedule-evidence.ts
new file mode 100644
index 000000000..6cf9fb8bc
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/schedule-evidence.ts
@@ -0,0 +1,22 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Runtime guards for additive/legacy schedule projections. The wire type is
+ * complete on current references, but old or partially materialized summary
+ * rows can omit fields. Callers should use null as unknown, never format an
+ * omitted value into NaN or treat an arbitrary truthy active-run value as an
+ * href.
+ */
+export function scheduleIntervalSeconds(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
+}
+
+export function scheduleEnabled(value: unknown): boolean | null {
+ return typeof value === "boolean" ? value : null;
+}
+
+export function activeScheduleRunId(schedule: { readonly active_run_id?: unknown } | null | undefined): string | null {
+ const value = schedule?.active_run_id;
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
diff --git a/apps/console/src/app/(console)/lib/source-actionability.test.ts b/apps/console/src/app/(console)/lib/source-actionability.test.ts
index e1b1eb61e..c90c6f530 100644
--- a/apps/console/src/app/(console)/lib/source-actionability.test.ts
+++ b/apps/console/src/app/(console)/lib/source-actionability.test.ts
@@ -20,6 +20,7 @@ import {
SOURCE_WORK_GROUP_COPY,
sourceAttentionHeadline,
sourceWorkFromConnectors,
+ TERMINAL_SETUP_DISPOSITION_COPY,
verdictRequiresOwnerNow,
} from "./source-actionability.ts";
@@ -437,6 +438,25 @@ test("source actionability projects a draft connection as needs-you setup_in_pro
assert.equal(actionability.work?.statusLabel, "needs you");
});
+test("terminal setup dispositions replace generic draft copy while staying inactive setup work", () => {
+ for (const disposition of ["verified_empty", "unverified_zero", "unverified_missing_counts"] as const) {
+ const copy = TERMINAL_SETUP_DISPOSITION_COPY[disposition];
+ const actionability = projectSourceActionability(draftConnector({ terminal_setup_disposition: disposition }));
+
+ assert.equal(isSetupInProgressConnector(draftConnector({ terminal_setup_disposition: disposition })), true);
+ assert.equal(actionability.renderedStatus.label, copy.statusLabel);
+ assert.equal(actionability.renderedStatus.kind, "degraded");
+ assert.equal(actionability.nextAction?.label, copy.actionLabel);
+ assert.equal(actionability.primaryVerdictAction?.cta, copy.actionLabel);
+ assert.equal(actionability.primaryVerdictAction?.terminal, true);
+ assert.equal(actionability.ownerActionCue?.label, copy.actionLabel);
+ assert.equal(actionability.work?.group, "needsOwner");
+ assert.equal(actionability.work?.actionLabel, copy.actionLabel);
+ assert.equal(actionability.work?.statusLabel, copy.statusLabel);
+ assert.equal(actionability.work?.what, copy.what);
+ }
+});
+
test("source actionability: revoked outranks draft — a revoked connection never reads as setup_in_progress", () => {
const actionability = projectSourceActionability(
draftConnector({ revoked_at: "2026-07-10T00:00:00Z", status: "revoked" })
diff --git a/apps/console/src/app/(console)/lib/source-actionability.ts b/apps/console/src/app/(console)/lib/source-actionability.ts
index 1750c12ed..ab3be21f1 100644
--- a/apps/console/src/app/(console)/lib/source-actionability.ts
+++ b/apps/console/src/app/(console)/lib/source-actionability.ts
@@ -8,6 +8,7 @@ import type {
RefConnectorSummary,
RefRenderedVerdict,
RefRequiredAction,
+ RefTerminalSetupDisposition,
RefVerdictTone,
} from "./ref-client.ts";
import {
@@ -121,6 +122,31 @@ export const SOURCE_WORK_GROUP_COPY: Record = {
+ unverified_missing_counts: {
+ actionLabel: "Review setup",
+ statusLabel: "needs review",
+ what: "The first sync completed without durable count evidence. Review the connection before retrying.",
+ },
+ unverified_zero: {
+ actionLabel: "Retry first sync",
+ statusLabel: "needs review",
+ what: "The first sync returned zero records without proving the account was empty. Review the connection and retry.",
+ },
+ verified_empty: {
+ actionLabel: "Review empty result",
+ statusLabel: "verified empty",
+ what: "The first sync verified that this source has no records. Review the setup result before trying again.",
+ },
+};
+
/** The one owner-facing meaning of the headline "needs you" attention number. */
export interface SourceAttentionHeadline {
/** Count of sources genuinely blocked on the owner's action (the needs-you group). */
@@ -231,11 +257,21 @@ function labelWithFreshness(base: string, note: string | null): string {
export function deriveRenderedSourceStatus(
verdict: RefRenderedVerdict | null | undefined,
revoked: boolean,
- pending = false
+ pending = false,
+ terminalSetupDisposition: RefTerminalSetupDisposition | null = null
): SourceStatusFlag {
if (revoked) {
return { dot: "⊘", freshnessNote: null, kind: "revoked", label: "Revoked", tone: "muted" };
}
+ if (terminalSetupDisposition) {
+ return {
+ dot: "◐",
+ freshnessNote: null,
+ kind: "degraded",
+ label: TERMINAL_SETUP_DISPOSITION_COPY[terminalSetupDisposition].statusLabel,
+ tone: "warning",
+ };
+ }
// Setup-in-progress overrides any verdict shape, same priority as revoked:
// a draft has no meaningful health/coverage evidence yet (see
// `isSetupInProgressConnector`), so its verdict tone (if any) must never be
@@ -276,8 +312,18 @@ export const SETUP_IN_PROGRESS_CTA_LABEL = "Continue setup";
*/
export function formatRenderedRequiredAction(
verdict: RefRenderedVerdict | null | undefined,
- pending = false
+ pending = false,
+ terminalSetupDisposition: RefTerminalSetupDisposition | null = null
): FormattedNextAction | null {
+ if (terminalSetupDisposition) {
+ return {
+ actionTarget: "connection_detail",
+ caveat: null,
+ label: TERMINAL_SETUP_DISPOSITION_COPY[terminalSetupDisposition].actionLabel,
+ notificationHint: null,
+ variant: "structured",
+ };
+ }
if (pending) {
return {
actionTarget: "connection_detail",
@@ -320,10 +366,26 @@ function setupInProgressPrimaryVerdictAction(): SourcePrimaryVerdictAction {
};
}
+function terminalSetupPrimaryVerdictAction(disposition: RefTerminalSetupDisposition): SourcePrimaryVerdictAction {
+ return {
+ audience: "owner",
+ channel: "attention",
+ cta: TERMINAL_SETUP_DISPOSITION_COPY[disposition].actionLabel,
+ kind: "reauth",
+ ownerRunnable: true,
+ satisfiedWhenKind: "attention_resolved",
+ terminal: true,
+ };
+}
+
export function formatPrimaryVerdictAction(
verdict: RefRenderedVerdict | null | undefined,
- pending = false
+ pending = false,
+ terminalSetupDisposition: RefTerminalSetupDisposition | null = null
): SourcePrimaryVerdictAction | null {
+ if (terminalSetupDisposition) {
+ return terminalSetupPrimaryVerdictAction(terminalSetupDisposition);
+ }
if (pending) {
return setupInProgressPrimaryVerdictAction();
}
@@ -476,6 +538,16 @@ export function sourceWorkItemFromConnector(connector: RefConnectorSummary): Sou
return null;
}
+ const terminalSetupDisposition = connector.terminal_setup_disposition ?? null;
+ if (isSetupInProgressConnector(connector) && terminalSetupDisposition) {
+ const copy = TERMINAL_SETUP_DISPOSITION_COPY[terminalSetupDisposition];
+ return itemFromConnector(connector, "needsOwner", {
+ actionLabel: copy.actionLabel,
+ statusLabel: copy.statusLabel,
+ what: copy.what,
+ });
+ }
+
// Setup-in-progress outranks the verdict (see `isSetupInProgressConnector`):
// a draft has no health/coverage evidence to derive work from, and the
// owner genuinely has something to finish, so it always surfaces in the
@@ -578,20 +650,25 @@ export function projectSourceActionability(connector: RefConnectorSummary): Sour
const routeId = connectionRouteId(connector);
const label = connectorLabel(connector);
const revoked = isRevokedConnector(connector);
- const pending = !revoked && isSetupInProgressConnector(connector);
+ const terminalSetupDisposition = connector.terminal_setup_disposition ?? null;
+ const pending = !revoked && isSetupInProgressConnector(connector) && terminalSetupDisposition === null;
const primaryAction = pending ? null : primaryRequiredAction(connector.rendered_verdict);
- const primaryVerdictAction = formatPrimaryVerdictAction(connector.rendered_verdict, pending);
+ const primaryVerdictAction = formatPrimaryVerdictAction(
+ connector.rendered_verdict,
+ pending,
+ terminalSetupDisposition
+ );
return {
failureSummary: pending
? null
: deriveFailureSummary(connector.connection_health, connector.rendered_verdict ?? null),
label,
- nextAction: formatRenderedRequiredAction(connector.rendered_verdict, pending),
+ nextAction: formatRenderedRequiredAction(connector.rendered_verdict, pending, terminalSetupDisposition),
ownerActionByStream: pending ? {} : ownerActionAvailabilityByStream(connector.rendered_verdict ?? null),
ownerActionCue: ownerActionCueFromVerdictAction(primaryVerdictAction),
primaryAction,
primaryVerdictAction,
- renderedStatus: deriveRenderedSourceStatus(connector.rendered_verdict, revoked, pending),
+ renderedStatus: deriveRenderedSourceStatus(connector.rendered_verdict, revoked, pending, terminalSetupDisposition),
revoked,
routeId,
work: sourceWorkItemFromConnector(connector),
diff --git a/apps/console/src/app/(console)/lib/source-add-support.test.ts b/apps/console/src/app/(console)/lib/source-add-support.test.ts
index 5be813152..bc5b89100 100644
--- a/apps/console/src/app/(console)/lib/source-add-support.test.ts
+++ b/apps/console/src/app/(console)/lib/source-add-support.test.ts
@@ -20,10 +20,10 @@ import { buildSourceAddSupport, resolveSourceAddSupport } from "./source-add-sup
const ADD_ANOTHER_ACCOUNT_LABEL_RE = /add another account/i;
const CONNECT_BROWSER_SESSION_ROUTE_RE = /\/connect\/browser-session\/amazon/;
-const STATIC_SECRET_ROUTE_RE = /\/connect\/static-secret\/ynab/;
+const STATIC_SECRET_ROUTE_RE = /\/connect\/static-secret\/gmail/;
const DEVICE_EXPORTER_ROUTE_RE = /\/device-exporters\?connector=/;
const MANUAL_UPLOAD_ROUTE_RE = /\/connect\/manual-upload\/google-maps/;
-const PACKAGED_PATH_PENDING_RE = /^Add path not packaged$/;
+const NOT_SELF_SERVICE_RE = /^Adding another account is not available here$/;
const DEMOTION_COPY_RE = /not self-service|not supported|track only|developer proof/i;
const DEV_JARGON_RE = /pnpm --dir|packages\/|monorepo|env var|connector_instance_id|PDPP_/;
@@ -114,9 +114,9 @@ function providerAuthManifest(connectorId: string): CatalogManifestLike {
};
}
-test("static-secret source supports self-service add-another-account with an action", () => {
- const map = buildSourceAddSupport([staticSecretManifest("ynab")]);
- const support = resolveSourceAddSupport(map, "ynab");
+test("proven static-secret source supports self-service add-another-account with an action", () => {
+ const map = buildSourceAddSupport([staticSecretManifest("gmail")]);
+ const support = resolveSourceAddSupport(map, "gmail");
assert.ok(support, "static-secret connector must appear in the support map");
assert.equal(support.support, "self_service");
assert.ok(support.action, "self-service add must carry a next action");
@@ -146,23 +146,23 @@ test("supported browser collector source is self-service and routes to browser-s
assert.doesNotMatch(support.supportLabel, DEMOTION_COPY_RE);
});
-test("browser-bound runbook source stays packaged-path-pending", () => {
+test("proof-gated browser runbook stays unavailable for self-service setup", () => {
const map = buildSourceAddSupport([browserBoundManifest("some_browser_source")]);
const support = resolveSourceAddSupport(map, "some_browser_source");
assert.ok(support);
- assert.equal(support.support, "packaged_path_pending");
+ assert.equal(support.support, "not_self_service");
assert.equal(support.action, null);
- assert.match(support.supportLabel, PACKAGED_PATH_PENDING_RE);
+ assert.match(support.supportLabel, NOT_SELF_SERVICE_RE);
assert.doesNotMatch(support.supportLabel, DEMOTION_COPY_RE);
});
-test("manual/upload pending source stays packaged-path-pending", () => {
+test("unshipped manual upload path stays unavailable for self-service setup", () => {
const map = buildSourceAddSupport([manualUploadPendingManifest("google-maps-pending")]);
const support = resolveSourceAddSupport(map, "google-maps-pending");
assert.ok(support);
- assert.equal(support.support, "packaged_path_pending");
+ assert.equal(support.support, "not_self_service");
assert.equal(support.action, null);
- assert.match(support.supportLabel, PACKAGED_PATH_PENDING_RE);
+ assert.match(support.supportLabel, NOT_SELF_SERVICE_RE);
assert.doesNotMatch(support.supportLabel, DEMOTION_COPY_RE);
});
@@ -187,8 +187,8 @@ test("provider-auth source reports deployment prerequisite, not static-secret se
});
test("a connection's raw registry-prefixed connector_id resolves to the canonical key", () => {
- const map = buildSourceAddSupport([staticSecretManifest("ynab")]);
- const support = resolveSourceAddSupport(map, "https://registry.pdpp.org/connectors/ynab");
+ const map = buildSourceAddSupport([staticSecretManifest("gmail")]);
+ const support = resolveSourceAddSupport(map, "https://registry.pdpp.org/connectors/gmail");
assert.ok(support, "registry-URL connector_id must resolve via canonicalConnectorKey");
assert.equal(support.support, "self_service");
});
diff --git a/apps/console/src/app/(console)/lib/source-add-support.ts b/apps/console/src/app/(console)/lib/source-add-support.ts
index 891fa313d..7fe5c3952 100644
--- a/apps/console/src/app/(console)/lib/source-add-support.ts
+++ b/apps/console/src/app/(console)/lib/source-add-support.ts
@@ -46,8 +46,8 @@ export interface SourceAddSupport {
const SUPPORT_LABELS: Record = {
deployment_prerequisite: "Server setup required to add another account",
- not_self_service: "Add path not available here",
- packaged_path_pending: "Add path not packaged",
+ not_self_service: "Adding another account is not available here",
+ packaged_path_pending: "Adding another account is not available yet",
self_service: "Add another account",
};
diff --git a/apps/console/src/app/(console)/lib/source-copy-negative.test.ts b/apps/console/src/app/(console)/lib/source-copy-negative.test.ts
index c3377ae27..962a58a7a 100644
--- a/apps/console/src/app/(console)/lib/source-copy-negative.test.ts
+++ b/apps/console/src/app/(console)/lib/source-copy-negative.test.ts
@@ -84,10 +84,75 @@ function stripComments(src: string): string {
/** A minimal catalog entry stub for a given disposition. */
function entryForDisposition(disposition: ConnectorCatalogEntry["disposition"]): ConnectorCatalogEntry {
+ const supported = new Set([
+ "local_collector_enroll",
+ "static_secret_connect",
+ "manual_upload_connect",
+ "browser_collector_manual",
+ "provider_auth_connect",
+ ]).has(disposition);
+ const proofGated = new Set([
+ "local_collector_unproven",
+ "provider_auth_proof_gated",
+ "browser_bound_runbook",
+ "manual_upload_pending",
+ ]).has(disposition);
+ const providerDeploymentBlocked = disposition === "provider_auth_deployment_blocked";
+ let setupModality = "unsupported";
+ if (disposition === "static_secret_connect") {
+ setupModality = "static_secret";
+ } else if (disposition === "manual_upload_connect" || disposition === "manual_upload_pending") {
+ setupModality = "manual_or_upload";
+ } else if (
+ disposition === "provider_auth_connect" ||
+ providerDeploymentBlocked ||
+ disposition === "provider_auth_proof_gated"
+ ) {
+ setupModality = "provider_authorization";
+ } else if (disposition === "browser_collector_manual" || disposition === "browser_bound_runbook") {
+ setupModality = "browser_bound";
+ } else if (disposition === "local_collector_enroll" || disposition === "local_collector_unproven") {
+ setupModality = "local_collector";
+ }
+
+ let nextStepKind = "unsupported";
+ if (disposition === "local_collector_enroll") {
+ nextStepKind = "enroll_local_collector";
+ } else if (disposition === "browser_collector_manual") {
+ nextStepKind = "enroll_browser_collector";
+ } else if (disposition === "static_secret_connect") {
+ nextStepKind = "capture_static_secret";
+ } else if (disposition === "provider_auth_connect") {
+ nextStepKind = "open_provider_auth";
+ } else if (providerDeploymentBlocked) {
+ nextStepKind = "needs_deployment_config";
+ } else if (disposition === "manual_upload_connect" || disposition === "manual_upload_pending") {
+ nextStepKind = "provide_import_file";
+ } else if (disposition === "browser_bound_runbook" || disposition === "provider_auth_proof_gated") {
+ nextStepKind = "manual_runbook";
+ }
+ let supportState = "unsupported";
+ if (providerDeploymentBlocked) {
+ supportState = "needs_deployment_config";
+ } else if (supported) {
+ supportState = "supported";
+ } else if (proofGated) {
+ supportState = "proof_gated";
+ }
return {
connectorKey: `stub-${disposition}`,
- deploymentReadiness: { blockers: [], ready: true },
+ deploymentReadiness: {
+ blockers: [],
+ guidance: null,
+ state: providerDeploymentBlocked ? "needs_config" : "ready",
+ },
disposition,
+ nextStepKind,
+ ownerActionMethod: supported ? "POST" : null,
+ ownerActionUrl: supported ? "/v1/owner/connections/intents" : null,
+ proofGate: proofGated ? "setup_proof_missing" : null,
+ setupModality,
+ supportState,
} as unknown as ConnectorCatalogEntry;
}
@@ -99,6 +164,7 @@ const ALL_DISPOSITIONS: readonly ConnectorCatalogEntry["disposition"][] = [
"manual_upload_connect",
"manual_upload_pending",
"provider_auth_deployment_blocked",
+ "provider_auth_connect",
"browser_bound_runbook",
"local_collector_unproven",
"provider_auth_proof_gated",
@@ -127,8 +193,9 @@ test("only self-service and server-setup dispositions expose primary actions", (
"static_secret_connect",
"manual_upload_connect",
"browser_collector_manual",
- "provider_auth_deployment_blocked",
+ "provider_auth_connect",
]);
+ const serverSetupDisposition = "provider_auth_deployment_blocked" as const;
for (const disposition of ALL_DISPOSITIONS) {
const entry = entryForDisposition(disposition);
const action = sourceSetupAction(entry);
@@ -138,6 +205,11 @@ test("only self-service and server-setup dispositions expose primary actions", (
assert.notEqual(availability, "not_available_here");
continue;
}
+ if (disposition === serverSetupDisposition) {
+ assert.equal(action, null, "blocked provider authorization must not render a dead action");
+ assert.equal(availability, "requires_server_setup");
+ continue;
+ }
assert.equal(action, null, `${disposition} must not render a fake primary setup action`);
assert.equal(availability, "not_available_here", `${disposition} must be separated from available setup`);
}
@@ -186,9 +258,9 @@ test("the agreed add-account labels are exactly the realignment-plan vocabulary"
assert.ok(labels.length > 0, "projection must produce at least one label");
const AGREED = new Set([
"Add another account",
- "Add path not packaged",
+ "Adding another account is not available yet",
"Server setup required to add another account",
- "Add path not available here",
+ "Adding another account is not available here",
]);
for (const label of labels) {
assertCleanCopy(label, "addAccountSupport label");
diff --git a/apps/console/src/app/(console)/lib/source-setup-form-contract.test.ts b/apps/console/src/app/(console)/lib/source-setup-form-contract.test.ts
new file mode 100644
index 000000000..d122c1833
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/source-setup-form-contract.test.ts
@@ -0,0 +1,100 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import test from "node:test";
+import type { StaticSecretSetup } from "./ref-client.ts";
+import {
+ browserSessionFormContract,
+ connectionNameFieldContract,
+ optionalCredentialFieldLabel,
+ staticSecretFormContract,
+} from "./source-setup-form-contract.ts";
+
+const INTERACTIVE_SIGN_IN_RE = /Interactive sign-in is valid/;
+const LEAVE_FIELDS_BLANK_RE = /Leave these fields blank/;
+const UNATTENDED_RECONNECTION_RE = /unattended reconnection is not guaranteed/;
+const NO_PROVIDER_CREDENTIALS_RE = /does not collect provider credentials/;
+const NO_UNATTENDED_RECONNECTION_RE = /does not promise unattended reconnection/;
+const AUTOMATIC_LOGIN_RE = /automatic login/i;
+
+const SETUP: StaticSecretSetup = {
+ connector_id: "synthetic-browser-source",
+ credential_capture: {
+ description: "Manifest-authored sign-in details.",
+ fields: [
+ {
+ autocomplete: "username",
+ description: null,
+ help_text: null,
+ help_url: null,
+ identity: false,
+ label: "Username",
+ name: "username",
+ placeholder: null,
+ required: true,
+ secret: true,
+ type: "text",
+ },
+ {
+ autocomplete: "current-password",
+ description: null,
+ help_text: null,
+ help_url: null,
+ identity: false,
+ label: "Password",
+ name: "password",
+ placeholder: null,
+ required: true,
+ secret: true,
+ type: "password",
+ },
+ ],
+ kind: "username_password",
+ label: "Sign-in details",
+ submit_label: "Save details",
+ },
+ credential_kind: "username_password",
+ deployment_readiness: { blockers: [], guidance: null, state: "ready" },
+ display_name: "Synthetic browser source",
+ object: "static_secret_setup",
+ validation: "first_sync",
+};
+
+test("connection-name contract is app-owned and shared across source forms", () => {
+ assert.deepEqual(connectionNameFieldContract("Synthetic source"), {
+ helpText: "Used only when creating a new source. You can rename it later.",
+ label: "Connection name (optional)",
+ maxLength: 200,
+ name: "display_name",
+ placeholder: "Synthetic source personal",
+ });
+});
+
+test("browser credential contract makes interactive sign-in and optional fields explicit", () => {
+ const contract = browserSessionFormContract(SETUP);
+ assert.ok(contract.optionalCredentials);
+ assert.match(contract.setupDescription, INTERACTIVE_SIGN_IN_RE);
+ assert.match(contract.optionalCredentials.description, LEAVE_FIELDS_BLANK_RE);
+ assert.match(contract.optionalCredentials.description, UNATTENDED_RECONNECTION_RE);
+ const usernameField = SETUP.credential_capture.fields.find((field) => field.name === "username");
+ assert.ok(usernameField);
+ assert.equal(optionalCredentialFieldLabel(usernameField), "Username (optional)");
+ assert.deepEqual(contract.optionalCredentials.fields, SETUP.credential_capture.fields);
+});
+
+test("browser-only start has no optional credential section or automatic-login promise", () => {
+ const contract = browserSessionFormContract(null);
+ assert.equal(contract.optionalCredentials, null);
+ assert.match(contract.setupDescription, NO_PROVIDER_CREDENTIALS_RE);
+ assert.match(contract.setupDescription, NO_UNATTENDED_RECONNECTION_RE);
+ assert.doesNotMatch(contract.setupDescription, AUTOMATIC_LOGIN_RE);
+});
+
+test("static-secret form keeps manifest fields and submit label while adding the shared name field", () => {
+ const contract = staticSecretFormContract(SETUP, false);
+ assert.equal(contract.connectionName.name, "display_name");
+ assert.deepEqual(contract.credentialFields, SETUP.credential_capture.fields);
+ assert.equal(contract.primaryActionLabel, "Save details");
+ assert.equal(staticSecretFormContract(SETUP, true).primaryActionLabel, "Reconnect account and run sync");
+});
diff --git a/apps/console/src/app/(console)/lib/source-setup-form-contract.ts b/apps/console/src/app/(console)/lib/source-setup-form-contract.ts
new file mode 100644
index 000000000..f6fe66dc2
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/source-setup-form-contract.ts
@@ -0,0 +1,103 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Shared owner-form presentation for source setup.
+ *
+ * Connector manifests own provider credential fields and their metadata. The
+ * console owns the small amount of workflow state that is common to every
+ * source setup form: the optional connection name and, for browser sessions,
+ * the choice to save optional sign-in details. Keeping those two contracts
+ * here prevents the three capture forms from quietly inventing different
+ * labels or promises.
+ */
+
+import type { StaticSecretSetup, StaticSecretSetupField } from "./ref-client.ts";
+
+export interface ConnectionNameFieldContract {
+ readonly helpText: string;
+ readonly label: "Connection name (optional)";
+ readonly maxLength: 200;
+ readonly name: "display_name";
+ readonly placeholder: string;
+}
+
+export function connectionNameFieldContract(displayName: string): ConnectionNameFieldContract {
+ return {
+ helpText: "Used only when creating a new source. You can rename it later.",
+ label: "Connection name (optional)",
+ maxLength: 200,
+ name: "display_name",
+ placeholder: `${displayName} personal`,
+ };
+}
+
+export interface BrowserOptionalCredentialContract {
+ readonly checkboxLabel: "Save these details to assist initial sign-in or repair.";
+ readonly checkboxName: "remember_sign_in_details";
+ readonly description: string;
+ readonly fields: readonly StaticSecretSetupField[];
+ readonly title: "Optional saved sign-in details";
+}
+
+export interface BrowserSessionFormContract {
+ readonly optionalCredentials: BrowserOptionalCredentialContract | null;
+ readonly repairLoginDescription: string;
+ readonly setupDescription: string;
+}
+
+/**
+ * Project the presence of manifest credential capture into owner-safe browser
+ * copy. A missing setup descriptor is meaningful: the owner signs in in the
+ * browser and this flow makes no automatic-login promise.
+ */
+export function browserSessionFormContract(setup: StaticSecretSetup | null): BrowserSessionFormContract {
+ if (!setup) {
+ return {
+ optionalCredentials: null,
+ repairLoginDescription:
+ "This flow uses the browser session directly; it does not collect provider credentials and does not promise unattended reconnection.",
+ setupDescription:
+ "Create a new account in a secure browser. Sign in interactively; this flow does not collect provider credentials and does not promise unattended reconnection.",
+ };
+ }
+
+ return {
+ optionalCredentials: {
+ checkboxLabel: "Save these details to assist initial sign-in or repair.",
+ checkboxName: "remember_sign_in_details",
+ description:
+ "Interactive sign-in is valid. Leave these fields blank to sign in in the secure browser; save them only if they may help with initial sign-in or repair. CAPTCHA, OTP, passkeys, and other human steps stay in the browser, and unattended reconnection is not guaranteed.",
+ fields: setup.credential_capture.fields,
+ title: "Optional saved sign-in details",
+ },
+ repairLoginDescription:
+ "Optional encrypted sign-in details may assist repair, but they do not replace the secure browser or guarantee unattended reconnection.",
+ setupDescription:
+ "Create a new account in a secure browser. Interactive sign-in is valid; optional saved sign-in details can assist initial sign-in or repair, but they do not guarantee unattended reconnection.",
+ };
+}
+
+export function optionalCredentialFieldLabel(field: StaticSecretSetupField): string {
+ return `${field.label} (optional)`;
+}
+
+export interface StaticSecretFormContract {
+ readonly connectionName: ConnectionNameFieldContract;
+ readonly credentialFields: readonly StaticSecretSetupField[];
+ readonly credentialSectionDescription: string;
+ readonly primaryActionLabel: string;
+}
+
+export function staticSecretFormContract(setup: StaticSecretSetup, isReplaceMode: boolean): StaticSecretFormContract {
+ return {
+ connectionName: connectionNameFieldContract(setup.display_name),
+ credentialFields: setup.credential_capture.fields,
+ credentialSectionDescription:
+ setup.credential_capture.description ??
+ "This form is generated from the connector manifest. Secrets are submitted to the owner-session capture route and are not returned to agents, MCP clients, REST reads, audit payloads, or the dashboard.",
+ primaryActionLabel: isReplaceMode
+ ? "Reconnect account and run sync"
+ : (setup.credential_capture.submit_label ?? "Create connection and start first sync"),
+ };
+}
diff --git a/apps/console/src/app/(console)/lib/source-setup-presentation.ts b/apps/console/src/app/(console)/lib/source-setup-presentation.ts
index 429d65f15..3613f9ada 100644
--- a/apps/console/src/app/(console)/lib/source-setup-presentation.ts
+++ b/apps/console/src/app/(console)/lib/source-setup-presentation.ts
@@ -23,7 +23,7 @@
* `owner-journey-slvp-realignment-plan-2026-06-10.md`.
*/
-import type { ConnectorCatalogEntry } from "./connection-catalog.ts";
+import { type ConnectorCatalogEntry, isOwnerActionableEntry } from "./connection-catalog.ts";
export interface SourceSetupStatus {
/** One short owner-facing status label. */
@@ -37,10 +37,35 @@ export interface SourceSetupAction {
label: string;
}
+/**
+ * Owner context projected from manifest setup/capability metadata. The
+ * fallback is intentionally setup-modality based: it keeps provider auth
+ * distinct from file import without naming a connector or claiming a new
+ * protocol behavior.
+ */
+export function sourceSetupContext(entry: ConnectorCatalogEntry): string | null {
+ if (entry.setupHelpText) {
+ return entry.setupHelpText;
+ }
+ if (entry.setupModality === "provider_authorization") {
+ return (
+ entry.refreshPolicyRationale ??
+ "This source uses provider authorization, not a file import. Provider app settings must be configured on the instance before an owner can authorize an account."
+ );
+ }
+ return entry.setupDescription;
+}
+
function browserBoundWithStoredCredentials(entry: ConnectorCatalogEntry): boolean {
return entry.modality === "browser_bound" && entry.setupModality === "static_secret";
}
+function isUnavailableSetupEntry(entry: ConnectorCatalogEntry): boolean {
+ return (
+ !isOwnerActionableEntry(entry) && entry.disposition !== "provider_auth_deployment_blocked"
+ );
+}
+
/**
* Whether adding a new account for this disposition is self-service today.
*
@@ -66,6 +91,9 @@ export type SourceSetupAvailability = "available_now" | "requires_server_setup"
/** Owner-facing picker order: actionable dispositions first, unsupported last. */
export function sourceSetupRank(entry: ConnectorCatalogEntry): number {
+ if (isUnavailableSetupEntry(entry)) {
+ return 8;
+ }
switch (entry.disposition) {
case "local_collector_enroll":
return 0;
@@ -77,22 +105,27 @@ export function sourceSetupRank(entry: ConnectorCatalogEntry): number {
return 3;
case "manual_upload_pending":
return 4;
- case "provider_auth_deployment_blocked":
+ case "provider_auth_connect":
return 5;
+ case "provider_auth_deployment_blocked":
+ return 6;
case "browser_bound_runbook":
case "local_collector_unproven":
case "provider_auth_proof_gated":
- return 6;
+ return 7;
case "api_network_unsupported":
case "unknown_unsupported":
- return 7;
- default:
return 8;
+ default:
+ return 9;
}
}
/** The owner-facing status label + tone for first-account setup. */
export function sourceSetupStatus(entry: ConnectorCatalogEntry): SourceSetupStatus {
+ if (isUnavailableSetupEntry(entry)) {
+ return { label: "Not available here", tone: "border-border bg-muted/30 text-muted-foreground" };
+ }
if (browserBoundWithStoredCredentials(entry)) {
return {
label: "Connect account",
@@ -117,9 +150,14 @@ export function sourceSetupStatus(entry: ConnectorCatalogEntry): SourceSetupStat
label: "Import file",
tone: "border-[color:var(--success)]/30 bg-status-success-bg text-status-success-fg",
};
+ case "provider_auth_connect":
+ return {
+ label: "Authorize account",
+ tone: "border-[color:var(--success)]/30 bg-status-success-bg text-status-success-fg",
+ };
case "manual_upload_pending":
return {
- label: "Import not packaged",
+ label: "Import not available yet",
tone: "border-[color:var(--warning)]/30 bg-status-warning-bg text-status-warning-fg",
};
case "provider_auth_deployment_blocked":
@@ -129,7 +167,7 @@ export function sourceSetupStatus(entry: ConnectorCatalogEntry): SourceSetupStat
};
case "browser_bound_runbook":
return {
- label: "Browser setup not packaged",
+ label: "Browser setup not available yet",
tone: "border-[color:var(--warning)]/30 bg-status-warning-bg text-status-warning-fg",
};
case "local_collector_unproven":
@@ -146,30 +184,35 @@ export function sourceSetupStatus(entry: ConnectorCatalogEntry): SourceSetupStat
/** One short owner-facing guidance line for first-account setup. */
export function sourceSetupGuidance(entry: ConnectorCatalogEntry): string {
+ if (isUnavailableSetupEntry(entry)) {
+ return "This dashboard cannot add this source yet.";
+ }
if (browserBoundWithStoredCredentials(entry)) {
- return "Connect in a secure browser session. You can optionally remember sign-in details for automatic reconnection; they may help with initial sign-in or repair, but CAPTCHA, OTP, passkeys, and other human steps stay in the secure browser and unattended reconnection is not guaranteed.";
+ return "Sign in in the secure browser. Saving sign-in details is optional and may help with setup or repair, but one-time codes, passkeys, and other human steps still happen in the browser. Automatic reconnection is not guaranteed.";
}
switch (entry.disposition) {
case "local_collector_enroll":
return "Set up the local collector on the machine that has this data. Repeat setup to add another device or account.";
case "browser_collector_manual":
- return "Create a browser-session shell to connect a new account. Add an optional source label so the source is easy to distinguish; if you need to reconnect an existing source, go back to Sources and open that source's reconnect flow.";
+ return "Connect a new account in a secure browser. Add an optional source label to distinguish it later; to reconnect an existing source, go back to Sources and open its reconnect flow.";
case "static_secret_connect":
return "Enter the required provider credential in the protected setup form. Submit again to add another account.";
case "manual_upload_connect":
return "Upload an owner-exported file. Reuse an existing source for another export from the same identity; create a new source only for a different account, profile, device, or source identity.";
+ case "provider_auth_connect":
+ return "Authorize this provider account in the provider's browser. The connection activates after authorization and account inventory succeed.";
case "manual_upload_pending":
- return "This source imports an owner-provided file, but the dashboard upload step is not packaged yet.";
+ return "This source accepts an owner-provided file, but file import is not available in this dashboard yet.";
case "provider_auth_deployment_blocked":
- return `Configure instance-level provider app material first: ${entry.deploymentReadiness.blockers
+ return `Finish the server setup first: ${entry.deploymentReadiness.blockers
.map((blocker) => blocker.label || blocker.key)
.join(", ")}.`;
case "browser_bound_runbook":
- return "This source can collect through a logged-in browser, but this dashboard does not yet package the add-account path safely.";
+ return "This source can collect through a logged-in browser, but this dashboard cannot start a new account from here yet.";
case "local_collector_unproven":
- return "This connector needs a packaged collector path before it can be started from this dashboard.";
+ return "This source needs a local collection setup before it can start from this dashboard.";
case "provider_auth_proof_gated":
- return "Provider authorization is not packaged in this dashboard yet.";
+ return "Provider authorization is not available in this dashboard yet.";
case "api_network_unsupported":
return "This dashboard cannot add this source yet.";
default:
@@ -181,6 +224,9 @@ export function sourceSetupGuidance(entry: ConnectorCatalogEntry): string {
/** The primary next action for first-account setup, or null when none exists. */
export function sourceSetupAction(entry: ConnectorCatalogEntry): SourceSetupAction | null {
+ if (!isOwnerActionableEntry(entry)) {
+ return null;
+ }
// Browser-bound connectors that also declare credential capture still start
// from the one browser-session path. The optional saved-sign-in-details
// fields live inside that page rather than becoming a second picker choice.
@@ -211,8 +257,11 @@ export function sourceSetupAction(entry: ConnectorCatalogEntry): SourceSetupActi
href: `/connect/browser-session/${encodeURIComponent(entry.enrollmentKey ?? entry.connectorKey)}`,
label: "Connect account",
};
- case "provider_auth_deployment_blocked":
- return { href: "/deployment", label: "Open server settings" };
+ case "provider_auth_connect":
+ return {
+ href: `/connect/provider-auth/${encodeURIComponent(entry.connectorKey)}`,
+ label: "Authorize account",
+ };
default:
return null;
}
@@ -226,11 +275,15 @@ export function sourceSetupSecondaryAction(_entry: ConnectorCatalogEntry): Sourc
}
export function sourceSetupAvailability(entry: ConnectorCatalogEntry): SourceSetupAvailability {
+ if (isUnavailableSetupEntry(entry)) {
+ return "not_available_here";
+ }
switch (entry.disposition) {
case "local_collector_enroll":
case "static_secret_connect":
case "manual_upload_connect":
case "browser_collector_manual":
+ case "provider_auth_connect":
return "available_now";
case "provider_auth_deployment_blocked":
return "requires_server_setup";
@@ -246,11 +299,15 @@ export function sourceSetupAvailability(entry: ConnectorCatalogEntry): SourceSet
* self-service add-another-account (the browser-bound dispositions).
*/
export function addAccountSupport(entry: ConnectorCatalogEntry): AddAccountSupport {
+ if (isUnavailableSetupEntry(entry)) {
+ return "not_self_service";
+ }
switch (entry.disposition) {
case "local_collector_enroll":
case "static_secret_connect":
case "manual_upload_connect":
case "browser_collector_manual":
+ case "provider_auth_connect":
return "self_service";
case "browser_bound_runbook":
case "manual_upload_pending":
diff --git a/apps/console/src/app/(console)/schedules/page.tsx b/apps/console/src/app/(console)/schedules/page.tsx
index e95d9a80a..558a93e5b 100644
--- a/apps/console/src/app/(console)/schedules/page.tsx
+++ b/apps/console/src/app/(console)/schedules/page.tsx
@@ -53,7 +53,9 @@ export default async function SchedulesPage({ searchParams }: { searchParams?: P
}
const summaries = page.items;
- const hasActiveRun = summaries.some((s) => s.schedule?.active_run_id !== null);
+ const hasActiveRun = summaries.some(
+ (s) => typeof s.schedule?.active_run_id === "string" && s.schedule.active_run_id.length > 0
+ );
return (
diff --git a/apps/console/src/app/(console)/schedules/schedule-row.tsx b/apps/console/src/app/(console)/schedules/schedule-row.tsx
index 100f8a1bc..37aa89683 100644
--- a/apps/console/src/app/(console)/schedules/schedule-row.tsx
+++ b/apps/console/src/app/(console)/schedules/schedule-row.tsx
@@ -9,6 +9,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useState, useTransition } from "react";
import type { RefConnectorSummary, RefSchedule } from "../lib/ref-client.ts";
+import { activeScheduleRunId, scheduleEnabled, scheduleIntervalSeconds } from "../lib/schedule-evidence.ts";
import { formatTotalRecordsLabel } from "../lib/total-records-label.ts";
import { deleteScheduleAction, pauseScheduleAction, resumeScheduleAction, upsertScheduleAction } from "./actions.ts";
@@ -19,7 +20,10 @@ interface ScheduleRowProps {
type EditState = "idle" | "editing";
-function formatInterval(seconds: number): string {
+function formatInterval(seconds: number | null): string {
+ if (seconds === null) {
+ return "—";
+ }
if (seconds < 60) {
return `${seconds}s`;
}
@@ -32,7 +36,10 @@ function formatInterval(seconds: number): string {
return `${Math.round(seconds / 86_400)}d`;
}
-function formatIntervalForInput(seconds: number): string {
+function formatIntervalForInput(seconds: number | null): string {
+ if (seconds === null) {
+ return "";
+ }
if (seconds < 60) {
return `${seconds}s`;
}
@@ -46,10 +53,11 @@ function formatIntervalForInput(seconds: number): string {
}
function recommendedIntervalLabel(policy: RefConnectorSummary["refresh_policy"]): string | null {
- if (!policy?.recommended_interval_seconds) {
+ const seconds = scheduleIntervalSeconds(policy?.recommended_interval_seconds);
+ if (seconds === null) {
return null;
}
- return formatInterval(policy.recommended_interval_seconds);
+ return formatInterval(seconds);
}
/**
@@ -93,16 +101,37 @@ function remoteScheduleFor(summary: RefConnectorSummary): RefSchedule | null {
return summary.source_kind === "local_device" ? null : summary.schedule;
}
+function ScheduleEditButton({
+ intervalSeconds,
+ isPending,
+ jitterSeconds,
+ onEdit,
+}: {
+ intervalSeconds: number | null;
+ isPending: boolean;
+ jitterSeconds: number | null;
+ onEdit: (every: string, jitter: string) => void;
+}) {
+ return (
+ onEdit(formatIntervalForInput(intervalSeconds), formatIntervalForInput(jitterSeconds))}
+ size="sm"
+ variant="ghost"
+ >
+ Edit
+
+ );
+}
+
export function ScheduleRow({ summary, runsHref }: ScheduleRowProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [editState, setEditState] = useState("idle");
- const [every, setEvery] = useState(() =>
- summary.schedule ? formatIntervalForInput(summary.schedule.interval_seconds) : "1h"
- );
- const [jitter, setJitter] = useState(() =>
- summary.schedule?.jitter_seconds ? formatIntervalForInput(summary.schedule.jitter_seconds) : ""
- );
+ const initialIntervalSeconds = scheduleIntervalSeconds(summary.schedule?.interval_seconds);
+ const initialJitterSeconds = scheduleIntervalSeconds(summary.schedule?.jitter_seconds);
+ const [every, setEvery] = useState(() => formatIntervalForInput(initialIntervalSeconds));
+ const [jitter, setJitter] = useState(() => formatIntervalForInput(initialJitterSeconds));
const [toast, setToast] = useState<{ kind: "error" | "warning"; message: string } | null>(null);
const showToast = useCallback((kind: "error" | "warning", message: string) => {
@@ -194,7 +223,10 @@ export function ScheduleRow({ summary, runsHref }: ScheduleRowProps) {
});
const connectorKey = formatConnectorKeyForDisplay(summary.connector_id);
const recordsHref = recordsHrefForSummary(summary);
- const activeRunId = schedule?.active_run_id;
+ const activeRunId = activeScheduleRunId(schedule);
+ const enabled = scheduleEnabled(schedule?.enabled);
+ const intervalSeconds = scheduleIntervalSeconds(schedule?.interval_seconds);
+ const jitterSeconds = scheduleIntervalSeconds(schedule?.jitter_seconds);
// schedule is RefSchedule | null (remoteScheduleFor can return null); tsc
// confirms schedule.human_attention_needed errors without the guard when
// the fallback is removed. Biome's type-aware pass mis-resolves this one.
@@ -247,9 +279,9 @@ export function ScheduleRow({ summary, runsHref }: ScheduleRowProps) {
running →
)}
- {schedule && (
+ {schedule && enabled !== null && (
<>
- {schedule.enabled ? (
+ {enabled ? (
Pause
@@ -258,18 +290,16 @@ export function ScheduleRow({ summary, runsHref }: ScheduleRowProps) {
Resume
)}
- {
- setEvery(formatIntervalForInput(schedule.interval_seconds));
- setJitter(schedule.jitter_seconds ? formatIntervalForInput(schedule.jitter_seconds) : "");
+ {
+ setEvery(nextEvery);
+ setJitter(nextJitter);
setEditState(editState === "editing" ? "idle" : "editing");
}}
- size="sm"
- variant="ghost"
- >
- Edit
-
+ />
Delete
@@ -294,7 +324,7 @@ export function ScheduleRow({ summary, runsHref }: ScheduleRowProps) {
Automation: {automationModeLabel(schedule.automation_mode)}
- Every: {formatInterval(schedule.interval_seconds)}
+ Every: {formatInterval(intervalSeconds)}
>
) : (
diff --git a/apps/console/src/app/(console)/schedules/schedules-poller.tsx b/apps/console/src/app/(console)/schedules/schedules-poller.tsx
index db472e7b2..c5d7d67b5 100644
--- a/apps/console/src/app/(console)/schedules/schedules-poller.tsx
+++ b/apps/console/src/app/(console)/schedules/schedules-poller.tsx
@@ -3,21 +3,8 @@
// Copyright The PDP-Connect Contributors
// SPDX-License-Identifier: Apache-2.0
-import { useRouter } from "next/navigation";
-import { useEffect } from "react";
-
-const ACTIVE_POLL_MS = 3000;
+import { LivePoller } from "../components/live-poller.tsx";
export function SchedulesPoller({ enabled }: { enabled: boolean }) {
- const router = useRouter();
-
- useEffect(() => {
- if (!enabled) {
- return;
- }
- const id = setInterval(() => router.refresh(), ACTIVE_POLL_MS);
- return () => clearInterval(id);
- }, [enabled, router]);
-
- return null;
+ return ;
}
diff --git a/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx b/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx
index 4bb9ec930..428edf76f 100644
--- a/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx
+++ b/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx
@@ -21,6 +21,7 @@ import { RecordroomShellWithPalette } from "@/app/(console)/components/recordroo
import { ServerUnreachable } from "../../../components/shell.tsx";
import { WarningsBanner } from "../../../components/warnings-banner.tsx";
import { formatStreamCollectionFacts, type StreamCollectionFacts } from "../../../lib/collection-report.ts";
+import { connectorRunSummaryId } from "../../../lib/connector-run-summary-status.ts";
import { ReferenceServerUnreachableError, ResourceServerHttpError } from "../../../lib/owner-token.ts";
import { pickSemanticTimestamp, primaryTimestamp } from "../../../lib/record-timestamps.ts";
import type { RefCollectionReportEntry, RefConnectorRunSummary } from "../../../lib/ref-client.ts";
@@ -664,7 +665,8 @@ function latestStreamRunEvidence(
facts: StreamCollectionFacts | null,
latestRun: RefConnectorRunSummary | null
): { detail: string; href: string | null; value: string } {
- const href = latestRun ? `/syncs/${encodeURIComponent(latestRun.run_id)}` : null;
+ const runId = connectorRunSummaryId(latestRun?.run_id);
+ const href = runId ? `/syncs/${encodeURIComponent(runId)}` : null;
if (!facts) {
return {
detail: latestRun
@@ -675,7 +677,7 @@ function latestStreamRunEvidence(
};
}
const detailParts = [
- latestRun ? `run ${latestRun.run_id}` : null,
+ runId ? `run ${runId}` : null,
`coverage ${facts.coverage.value}`,
facts.disposition ? `next run: ${facts.disposition.label}` : null,
facts.pendingDetailGapsLabel,
diff --git a/apps/console/src/app/(console)/sources/[connector]/actions.ts b/apps/console/src/app/(console)/sources/[connector]/actions.ts
index 0e3b43d2f..7012a9aca 100644
--- a/apps/console/src/app/(console)/sources/[connector]/actions.ts
+++ b/apps/console/src/app/(console)/sources/[connector]/actions.ts
@@ -59,7 +59,7 @@ export async function runConnectorNowAction(formData: FormData) {
try {
const runOptions = { force };
const result = (await (connectionId
- ? runConnectionNow(connectionId, runOptions)
+ ? runConnectionNow(connectionId, { ...runOptions, runAdmission: "setup" })
: runConnectorNow(connectorId, runOptions))) as {
run_id?: string;
trace_id?: string;
diff --git a/apps/console/src/app/(console)/sources/[connector]/page.tsx b/apps/console/src/app/(console)/sources/[connector]/page.tsx
index b9bf0bed0..87f62979d 100644
--- a/apps/console/src/app/(console)/sources/[connector]/page.tsx
+++ b/apps/console/src/app/(console)/sources/[connector]/page.tsx
@@ -2,7 +2,13 @@
// SPDX-License-Identifier: Apache-2.0
import { buttonVariants, IcButton, IcTimestamp } from "@pdpp/brand-react";
-import { formatConnectorKeyForDisplay, formatConnectorNameForDisplay, isFallbackConnectionLabel } from "@pdpp/display";
+import type { StreamManifestEntry } from "@pdpp/display";
+import {
+ formatConnectorKeyForDisplay,
+ formatConnectorNameForDisplay,
+ isFallbackConnectionLabel,
+ streamDisplayLabel,
+} from "@pdpp/display";
import { CopyButton } from "@pdpp/operator-ui/components/copy-button";
import { DataList, PageHeader, Section, StatusBadge } from "@pdpp/operator-ui/components/primitives";
import Link from "next/link";
@@ -31,7 +37,7 @@ import {
syncActionIdleLabel,
} from "../../lib/connection-evidence.ts";
import { isBrowserBoundConnector, isBrowserSessionBoundConnection } from "../../lib/connection-modality.ts";
-import { isActiveConnectorRunSummaryStatus } from "../../lib/connector-run-summary-status.ts";
+import { connectorRunSummaryId, isActiveConnectorRunSummaryStatus } from "../../lib/connector-run-summary-status.ts";
import { getReferencePublicOrigin, ReferenceServerUnreachableError } from "../../lib/owner-token.ts";
import { isRevokedConnection } from "../../lib/records-list-classification.ts";
import {
@@ -158,7 +164,8 @@ export interface ConnectorPageModel {
}
function toConnectorRunRef(summary: RefConnectorRunSummary | null) {
- if (!summary) {
+ const runId = connectorRunSummaryId(summary?.run_id);
+ if (!(summary && runId)) {
return null;
}
return {
@@ -167,7 +174,7 @@ function toConnectorRunRef(summary: RefConnectorRunSummary | null) {
first_at: summary.first_at,
known_gaps: summary.known_gaps ?? [],
last_at: summary.last_at,
- run_id: summary.run_id,
+ run_id: runId,
status: summary.status,
};
}
@@ -181,6 +188,10 @@ function toRunSummaryForConnection(
if (!summary) {
return null;
}
+ const runId = connectorRunSummaryId(summary.run_id);
+ if (!runId) {
+ return null;
+ }
const status = runStatusWithCollectionReportGaps(summary.status, collectionReport);
return {
connection_id: connectionId,
@@ -194,7 +205,7 @@ function toRunSummaryForConnection(
last_at: summary.last_at,
needs_input: false,
object: "run_summary",
- run_id: summary.run_id,
+ run_id: runId,
status,
};
}
@@ -482,13 +493,39 @@ function resolveActiveRunNavigation(input: { overview: ConnectorOverview; schedu
running: boolean;
} {
const activeRunId =
- input.scheduleActiveRunId ?? (input.overview.isRunning ? (input.overview.lastRun?.run_id ?? null) : null);
+ input.scheduleActiveRunId ??
+ (input.overview.isRunning && input.overview.lastRun ? input.overview.lastRun.run_id : null);
return {
activeRunHref: activeRunId ? `/syncs/${encodeURIComponent(activeRunId)}` : null,
running: activeRunId !== null || input.overview.isRunning,
};
}
+function StreamDisplayName({
+ displayLabel,
+ name,
+ unexpected,
+}: {
+ displayLabel: string;
+ name: string;
+ unexpected: boolean;
+}) {
+ return (
+
+ {displayLabel}
+ {unexpected ? (
+
+ (undeclared)
+
+ ) : null}
+
+ );
+}
+
function ConnectorPageView({
model,
dangerMessage,
@@ -579,6 +616,12 @@ function ConnectorPageView({
const syncIdleLabel = syncActionIdleLabel(overview.lastRun?.status);
const streakDots = deriveStreakDots(recentRuns);
const autoPausedBanner = deriveAutoPausedBanner(schedule);
+ const manifestStreams = Array.isArray(manifest.streams) ? manifest.streams : [];
+ const streamLabelsByName = new Map(
+ manifestStreams.map(
+ (stream) => [stream.name, streamDisplayLabel(stream.name, stream as StreamManifestEntry)] as const
+ )
+ );
return (
@@ -658,6 +701,7 @@ function ConnectorPageView({
const ownerActionAvailable = collectionOwnerActionByStream[s.name] ?? true;
const countLabel = streamCountLabel(s);
const unexpected = isUnexpectedStreamDeclaration(s.declaration_state);
+ const displayLabel = streamLabelsByName.get(s.name) ?? s.name;
return (
-
- {s.name}
- {unexpected ? (
-
- (undeclared)
-
- ) : null}
-
+
{
@@ -79,11 +80,11 @@ test("the already_running 409 branch preserves the full run id for linking", asy
assert.match(src, ALREADY_RUNNING_RETURNS_FULL_RUN_ID);
});
-test("run-now action forwards explicit force override to the operator client", async () => {
+test("run-now action forwards explicit force override and runAdmission to the operator client", async () => {
const src = await readFile(ACTIONS_FILE, "utf8");
assert.match(src, FORCE_OPTION_SIGNATURE);
assert.match(src, FORCE_OPTION_BODY);
- assert.match(src, RUN_CONNECTION_WITH_OPTIONS);
+ assert.match(src, RUN_CONNECTION_WITH_SETUP_ADMISSION);
assert.match(src, RUN_CONNECTOR_WITH_OPTIONS);
});
diff --git a/apps/console/src/app/(console)/sources/actions.ts b/apps/console/src/app/(console)/sources/actions.ts
index fff1c07d7..c7719e216 100644
--- a/apps/console/src/app/(console)/sources/actions.ts
+++ b/apps/console/src/app/(console)/sources/actions.ts
@@ -51,7 +51,7 @@ export async function runConnectorNowAction(
try {
const runOptions = { force: options.force === true };
const body = (await (connectionId
- ? runConnectionNow(connectionId, runOptions)
+ ? runConnectionNow(connectionId, { ...runOptions, runAdmission: "setup" })
: runConnectorNow(connectorId, runOptions))) as {
run_id?: string;
trace_id?: string;
diff --git a/apps/console/src/app/(console)/sources/add/add-source-demo-data.ts b/apps/console/src/app/(console)/sources/add/add-source-demo-data.ts
index de4d6b340..4df6f9888 100644
--- a/apps/console/src/app/(console)/sources/add/add-source-demo-data.ts
+++ b/apps/console/src/app/(console)/sources/add/add-source-demo-data.ts
@@ -16,10 +16,14 @@ export function buildAddSourceDemoCatalog(): {
deploymentReadiness: { blockers: [], guidance: null, state: "ready" },
displayName: "ChatGPT",
disposition: "static_secret_connect",
+ externalDocs: [],
modality: "api_network",
nextStepKind: "capture_static_secret",
proofGate: null,
+ refreshPolicyRationale: null,
runbookPath: null,
+ setupDescription: null,
+ setupHelpText: null,
setupModality: "static_secret",
supportState: "supported",
},
@@ -37,10 +41,14 @@ export function buildAddSourceDemoCatalog(): {
deploymentReadiness: { blockers: [], guidance: null, state: "ready" },
displayName: "Amazon",
disposition: "manual_upload_connect",
+ externalDocs: [],
modality: "api_network",
nextStepKind: "provide_import_file",
proofGate: null,
+ refreshPolicyRationale: null,
runbookPath: null,
+ setupDescription: null,
+ setupHelpText: null,
setupModality: "manual_or_upload",
supportState: "supported",
},
@@ -50,11 +58,15 @@ export function buildAddSourceDemoCatalog(): {
deploymentReadiness: { blockers: [], guidance: null, state: "ready" },
displayName: "Claude Code",
disposition: "local_collector_enroll",
+ externalDocs: [],
enrollmentKey: "claude_code",
modality: "local_collector",
nextStepKind: "enroll_local_collector",
proofGate: null,
+ refreshPolicyRationale: null,
runbookPath: null,
+ setupDescription: null,
+ setupHelpText: null,
setupModality: "local_collector",
supportState: "supported",
},
@@ -68,10 +80,14 @@ export function buildAddSourceDemoCatalog(): {
},
displayName: "Calendar Demo",
disposition: "provider_auth_deployment_blocked",
+ externalDocs: [],
modality: "api_network",
nextStepKind: "needs_deployment_config",
proofGate: null,
+ refreshPolicyRationale: null,
runbookPath: null,
+ setupDescription: null,
+ setupHelpText: null,
setupModality: "provider_authorization",
supportState: "needs_deployment_config",
},
@@ -81,10 +97,14 @@ export function buildAddSourceDemoCatalog(): {
deploymentReadiness: { blockers: [], guidance: null, state: "ready" },
displayName: "Browser Archive Demo",
disposition: "browser_bound_runbook",
+ externalDocs: [],
modality: "browser_bound",
nextStepKind: "manual_runbook",
proofGate: "browser_setup_package",
+ refreshPolicyRationale: null,
runbookPath: "docs/connectors/browser-archive.md",
+ setupDescription: null,
+ setupHelpText: null,
setupModality: "browser_bound",
supportState: "proof_gated",
},
diff --git a/apps/console/src/app/(console)/sources/add/page.tsx b/apps/console/src/app/(console)/sources/add/page.tsx
index 67298f6e0..51bcb08c8 100644
--- a/apps/console/src/app/(console)/sources/add/page.tsx
+++ b/apps/console/src/app/(console)/sources/add/page.tsx
@@ -7,9 +7,9 @@ import { RecordroomShellWithPalette } from "@/app/(console)/components/recordroo
import { existingSourcesByConnectorCatalog } from "../../components/existing-sources-by-connector.ts";
import { ServerUnreachable } from "../../components/shell.tsx";
import { type ExistingSourceSetupLink, SourceSetupCatalog } from "../../components/source-setup-catalog.tsx";
-import { buildConnectorCatalog, type ConnectorCatalogEntry } from "../../lib/connection-catalog.ts";
+import { buildOwnerConnectorCatalog, type ConnectorCatalogEntry } from "../../lib/connection-catalog.ts";
import { ReferenceServerUnreachableError } from "../../lib/owner-token.ts";
-import { listConnectorManifests } from "../../lib/rs-client.ts";
+import { listConnectorManifests, listOwnerConnectorTemplates } from "../../lib/rs-client.ts";
export const dynamic = "force-dynamic";
@@ -27,8 +27,8 @@ export default async function AddSourcePage({ searchParams }: { searchParams: Pr
({ catalog, existingSourcesByConnector } = demo.buildAddSourceDemoCatalog());
} else {
try {
- const manifests = await listConnectorManifests();
- catalog = buildConnectorCatalog(manifests);
+ const [manifests, templates] = await Promise.all([listConnectorManifests(), listOwnerConnectorTemplates()]);
+ catalog = buildOwnerConnectorCatalog(manifests, templates);
// EXACT per-connector existing-sources lookup — one `GET
// /_ref/connections?connector_id=` call per catalog entry (bounded by
// the registered connector-type catalog size, a few dozen, never by
@@ -58,7 +58,7 @@ export default async function AddSourcePage({ searchParams }: { searchParams: Pr
{ href: dashboardRoutes.section.records, label: "Sources" },
{ label: "Add source" },
]}
- description="Add source accounts that populate this PDPP instance. App and agent access is configured separately under Connect apps."
+ description="Add sources that populate this PDPP instance. App and local-client access is configured separately under Connect apps."
title="Add source"
/>
-
+
);
}
diff --git a/apps/console/src/app/(console)/sources/page.tsx b/apps/console/src/app/(console)/sources/page.tsx
index c07de7f96..06281ed53 100644
--- a/apps/console/src/app/(console)/sources/page.tsx
+++ b/apps/console/src/app/(console)/sources/page.tsx
@@ -41,6 +41,7 @@ import { getReferencePublicOrigin, ReferenceServerUnreachableError } from "../li
import { listConnectorManifests } from "../lib/rs-client.ts";
import { reactivateConnectionAction, revokeConnectionAction } from "./[connector]/actions.ts";
import { RecordsPagePoller } from "./records-page-poller.tsx";
+import { SOURCE_ACCESS_NOTE } from "./sources-copy.ts";
import { SourcesView } from "./sources-view.tsx";
import {
buildSourcesChurnAdvisory,
@@ -179,7 +180,7 @@ function SourcesHeader({ error, message, notice }: { error?: string; message?: s
margin: 0,
}}
>
- your loading dock · each source pushes into your streams · nothing leaves
+ {SOURCE_ACCESS_NOTE}
{notice ? (
diff --git a/apps/console/src/app/(console)/sources/sources-copy.ts b/apps/console/src/app/(console)/sources/sources-copy.ts
new file mode 100644
index 000000000..3c65ffd3a
--- /dev/null
+++ b/apps/console/src/app/(console)/sources/sources-copy.ts
@@ -0,0 +1,6 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+/** Shared explanation of how source data and connected-app access relate. */
+export const SOURCE_ACCESS_NOTE =
+ "Sources populate streams in this instance. Connected apps read only what a grant allows.";
diff --git a/apps/console/src/app/(console)/sources/sources-view-model.test.ts b/apps/console/src/app/(console)/sources/sources-view-model.test.ts
index e4f467ce4..3c2538a9f 100644
--- a/apps/console/src/app/(console)/sources/sources-view-model.test.ts
+++ b/apps/console/src/app/(console)/sources/sources-view-model.test.ts
@@ -573,6 +573,8 @@ test("formatSchedule is honest about no schedule, paused, and policy-ineligible"
assert.equal(formatSchedule({ ...base, enabled: false }), "paused");
assert.equal(formatSchedule({ ...base, effective_mode: "paused" }), "paused");
assert.equal(formatSchedule({ ...base, ineligibility_reason: "manifest_policy" }), "every 1d · paused by policy");
+ assert.equal(formatSchedule({ ...base, enabled: undefined as never }), "schedule details unavailable");
+ assert.equal(formatSchedule({ ...base, interval_seconds: undefined as never }), "schedule details unavailable");
});
test("exploreHrefFor encodes connection + stream into the Explore deep link", () => {
@@ -823,7 +825,7 @@ test("toSourcesView disambiguates duplicate unnamed connections without exposing
assert.equal(views[0]?.displayName, "Amazon · account 1");
assert.equal(views[1]?.displayName, "Amazon · account 2");
- assert.equal(views[0]?.accountLine, "Unnamed source · 100 records · 2 streams");
+ assert.equal(views[0]?.accountLine, "Amazon source · 100 records · 2 streams");
assert.equal(views[2]?.displayName, "Amazon - Personal");
assert.equal(views[2]?.accountLine, "100 records · 2 streams");
assert.equal(views[2]?.listKind, null);
@@ -1141,3 +1143,68 @@ test("toSourceInstanceView: the passport 'records' row preserves the exact prior
const view = toSourceInstanceView(summary({ total_records: 42, total_records_state: undefined }));
assert.equal(passportField(view, "records"), "42");
});
+
+test("toSourceInstanceView: accountLine uses connector-derived fallback when display_name is a fallback", () => {
+ const view = toSourceInstanceView(
+ summary({
+ connector_display_name: "Gmail",
+ connector_id: "gmail",
+ display_name: "Gmail",
+ })
+ );
+ assert.equal(view.accountLine, "Gmail source · 100 records · 2 streams");
+});
+
+test("toSourceInstanceView: accountLine uses Amazon fallback correctly", () => {
+ const view = toSourceInstanceView(
+ summary({
+ connector_display_name: "Amazon",
+ connector_id: "amazon",
+ display_name: "Amazon",
+ })
+ );
+ assert.equal(view.accountLine, "Amazon source · 100 records · 2 streams");
+});
+
+test("toSourceInstanceView: accountLine preserves owned name (no fallback)", () => {
+ const view = toSourceInstanceView(
+ summary({
+ connector_display_name: "Gmail",
+ connector_id: "gmail",
+ display_name: "Work Gmail",
+ })
+ );
+ assert.equal(view.accountLine, "100 records · 2 streams");
+});
+
+test("toSourceInstanceView: Google Maps timeline_points displays human label, not protocol identifier", () => {
+ const manifest = {
+ connector_id: "google-maps",
+ streams: [
+ {
+ name: "timeline_points",
+ display: { label: "Your Google Maps location points" },
+ },
+ ],
+ };
+ const sum = summary({ connector_id: "google-maps", streams: ["timeline_points"] });
+ const view = toSourceInstanceView(sum, { manifests: [manifest] });
+ assert.equal(view.streams[0]?.displayLabel, "Your Google Maps location points");
+ assert.notEqual(view.streams[0]?.displayLabel, "timeline_points");
+});
+
+test("toSourceInstanceView: stream without manifest display.label falls back to name", () => {
+ const manifest = {
+ connector_id: "gmail",
+ streams: [{ name: "messages" }],
+ };
+ const sum = summary({ connector_id: "gmail", streams: ["messages"] });
+ const view = toSourceInstanceView(sum, { manifests: [manifest] });
+ assert.equal(view.streams[0]?.displayLabel, "messages");
+});
+
+test("toSourceInstanceView: stream with no manifest available falls back to name", () => {
+ const sum = summary({ streams: ["messages"] });
+ const view = toSourceInstanceView(sum, { manifests: undefined });
+ assert.equal(view.streams[0]?.displayLabel, "messages");
+});
diff --git a/apps/console/src/app/(console)/sources/sources-view-model.ts b/apps/console/src/app/(console)/sources/sources-view-model.ts
index a381f0c61..21b7567df 100644
--- a/apps/console/src/app/(console)/sources/sources-view-model.ts
+++ b/apps/console/src/app/(console)/sources/sources-view-model.ts
@@ -27,7 +27,13 @@
* a false zero or green.
*/
-import { formatConnectorNameForDisplay, isFallbackConnectionLabel } from "@pdpp/display";
+import type { StreamManifestEntry } from "@pdpp/display";
+import {
+ deriveSourceDisplayNameFallback,
+ formatConnectorNameForDisplay,
+ isFallbackConnectionLabel,
+ streamDisplayLabel,
+} from "@pdpp/display";
import {
type ConnectorManifestLike,
canonicalConnectorKey,
@@ -44,6 +50,7 @@ import type {
RefRecordVersionStatsRow,
RefSchedule,
} from "../lib/ref-client.ts";
+import { scheduleEnabled, scheduleIntervalSeconds } from "../lib/schedule-evidence.ts";
import {
isRevokedConnector,
isSetupInProgressConnector,
@@ -81,6 +88,8 @@ export interface SourceStreamManifestRow {
collection: SourceStreamCollectionFacts | null;
/** Cursor/checkpoint hint, or null when none is exposed at the index level. */
cursor: string | null;
+ /** Human display label from manifest display.label, or stream name if absent. */
+ displayLabel: string;
/** Deep-link into Explore for this connection + stream. */
exploreHref: string;
name: string;
@@ -194,7 +203,10 @@ export interface SourcesRuntimeAdvisory {
note: string;
}
-type SourceManifestLike = ConnectorManifestLike & { connector_id: string };
+type SourceManifestLike = ConnectorManifestLike & {
+ connector_id: string;
+ streams?: readonly StreamManifestEntry[];
+};
const DUPLICATE_SOURCE_GROUP_MIN_UNNAMED = 3;
@@ -275,16 +287,21 @@ export function formatSchedule(schedule: RefSchedule | null): string {
if (!schedule) {
return "manual — no schedule";
}
- if (schedule.effective_mode === "paused" || !schedule.enabled) {
+ const enabled = scheduleEnabled(schedule.enabled);
+ if (schedule.effective_mode === "paused" || enabled === false) {
return "paused";
}
+ const interval = scheduleIntervalSeconds(schedule.interval_seconds);
+ if (enabled === null || interval === null) {
+ return "schedule details unavailable";
+ }
if (schedule.ineligibility_reason) {
- return `every ${formatInterval(schedule.interval_seconds)} · paused by policy`;
+ return `every ${formatInterval(interval)} · paused by policy`;
}
if (schedule.effective_mode === "automatic") {
- return `every ${formatInterval(schedule.interval_seconds)} · automatic`;
+ return `every ${formatInterval(interval)} · automatic`;
}
- return `every ${formatInterval(schedule.interval_seconds)} · manual`;
+ return `every ${formatInterval(interval)} · manual`;
}
/**
@@ -498,7 +515,12 @@ export function toSourceInstanceView(
const listKind = listKindForDisplayName(displayName, kind);
let accountLine: string;
if (hasFallbackLabel) {
- accountLine = `Unnamed source · ${formatSourceListFacts(summary, sourceStreamNames.length)}`;
+ const fallbackName = deriveSourceDisplayNameFallback({
+ connectorId,
+ displayName: summary.display_name,
+ name: summary.connector_display_name,
+ });
+ accountLine = `${fallbackName} · ${formatSourceListFacts(summary, sourceStreamNames.length)}`;
} else {
accountLine = formatSourceListFacts(summary, sourceStreamNames.length);
}
@@ -507,9 +529,20 @@ export function toSourceInstanceView(
const { ownerActionCue } = actionability;
const status = actionability.renderedStatus;
+ const manifest = options.manifests
+ ? options.manifests.find((candidate) => manifestMatchesConnectorId(candidate, connectorId))
+ : undefined;
+ const streamsByName = new Map();
+ if (manifest?.streams) {
+ for (const stream of manifest.streams) {
+ streamsByName.set(stream.name, stream as StreamManifestEntry);
+ }
+ }
+
const streams: SourceStreamManifestRow[] = sourceStreamNames.map((name) => {
const facts = collectionFactsByStream.get(name) ?? null;
const retained = streamRecordsByStream.get(name) ?? null;
+ const streamDecl = streamsByName.get(name);
return {
collection: facts
? {
@@ -526,10 +559,8 @@ export function toSourceInstanceView(
tone: facts.tone,
}
: null,
- // The index summary exposes no cursor or searchable flag per stream;
- // render them as unknown rather than guessing. Collection-report facts
- // are server-owned and safe to show here without another read.
cursor: null,
+ displayLabel: streamDisplayLabel(name, streamDecl),
exploreHref: exploreHrefFor(routeId, name),
name,
recordCount: retained ? retained.record_count : null,
diff --git a/apps/console/src/app/(console)/sources/sources-view.tsx b/apps/console/src/app/(console)/sources/sources-view.tsx
index a7af387d6..642b0851d 100644
--- a/apps/console/src/app/(console)/sources/sources-view.tsx
+++ b/apps/console/src/app/(console)/sources/sources-view.tsx
@@ -57,6 +57,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useState, useTransition } from "react";
import { type RunNowResult, runConnectorNowAction } from "./actions.ts";
+import { SOURCE_ACCESS_NOTE } from "./sources-copy.ts";
import {
buildDuplicateSourceReview,
collapseDuplicateFallbackSources,
@@ -168,7 +169,7 @@ export function SourcesView({
add a source →
- a source pushes into your streams · nothing leaves
+ {SOURCE_ACCESS_NOTE}
@@ -227,7 +228,7 @@ function DuplicateSourcesAdvisory({ reviews }: { reviews: readonly DuplicateSour
const more = reviews.length > 1 ? ` ${reviews.length - 1} other source type needs the same review.` : "";
return (
- same source type · review labels
+ Several sources need labels
{primary.total.toLocaleString()} {primary.kind} sources are configured; {primary.unnamed.toLocaleString()}{" "}
{primary.unnamed === 1 ? "is" : "are"} unnamed.
@@ -237,7 +238,7 @@ function DuplicateSourcesAdvisory({ reviews }: { reviews: readonly DuplicateSour
and revoke it if it was only a setup attempt.{more}
- Review first unnamed source →
+ Review duplicate source labels →
);
@@ -832,10 +833,13 @@ function StreamManifest({ instance }: { instance: SourceInstanceView }) {
function StreamManifestRow({ stream }: { stream: SourceInstanceView["streams"][number] }) {
const { collection } = stream;
+ const isDisplayLabelDifferent = stream.displayLabel !== stream.name;
return (
- {stream.name}
+
+ {stream.displayLabel}
+
diff --git a/apps/console/src/app/(console)/syncs/[runId]/interaction-form.tsx b/apps/console/src/app/(console)/syncs/[runId]/interaction-form.tsx
index 8f02c7c86..73de0ee1e 100644
--- a/apps/console/src/app/(console)/syncs/[runId]/interaction-form.tsx
+++ b/apps/console/src/app/(console)/syncs/[runId]/interaction-form.tsx
@@ -92,8 +92,7 @@ export function RunInteractionForm({ runId, interactionId, kind, message, fields
) : null}
- Values you submit here satisfy this run only. The reference server does not persist them as durable connector
- credentials, env vars, or timeline payloads.
+ These values are used only for this run and are not saved as connection credentials.
{state.error ? (
diff --git a/apps/console/src/app/(console)/syncs/[runId]/page.tsx b/apps/console/src/app/(console)/syncs/[runId]/page.tsx
index f9b13b2e5..068a7e78c 100644
--- a/apps/console/src/app/(console)/syncs/[runId]/page.tsx
+++ b/apps/console/src/app/(console)/syncs/[runId]/page.tsx
@@ -284,16 +284,16 @@ function getAssistanceTitle(assistance: CurrentRunAssistance, active: boolean):
assistance.ownerAction === "act_elsewhere" &&
assistance.responseContract === "none"
) {
- return "Waiting for external approval";
+ return "Waiting for approval outside this dashboard";
}
if (
assistance.progressPosture === "waiting_retry" &&
assistance.ownerAction === "none" &&
assistance.responseContract === "none"
) {
- return "Waiting before retry";
+ return "Retry scheduled";
}
- return "Waiting on operator input";
+ return "Waiting for your input";
}
function getAssistanceDescription(
@@ -309,14 +309,14 @@ function getAssistanceDescription(
assistance.ownerAction === "act_elsewhere" &&
assistance.responseContract === "none"
) {
- return "The connector is still running and watching for completion. No dashboard response is required.";
+ return "The source is still running and watching for completion. No dashboard response is required.";
}
if (
assistance.progressPosture === "waiting_retry" &&
assistance.ownerAction === "none" &&
assistance.responseContract === "none"
) {
- return "The connector is waiting before retrying. No owner action is required right now.";
+ return "The source will retry automatically. No action is needed right now.";
}
if (supportsStreaming) {
return "This run is blocked until the requested browser-surface action is completed.";
@@ -334,9 +334,9 @@ function formatAssistanceAttachments(assistance: CurrentRunAssistance): string {
return attachment.kind;
}
if (hasAvailableBrowserSurfaceAttachment(assistance)) {
- return "browser_surface available";
+ return "Secure browser ready";
}
- return "browser_surface waiting for stream target";
+ return "Waiting for the secure browser";
})
.join(", ");
}
@@ -625,7 +625,7 @@ function ViolationDiagnosis({ failure }: { failure: SpineEvent | undefined }) {
Failure diagnosis
- runtime-authored
+ Runtime message
subtype
diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx b/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx
index 8a3b6c44f..eca0d114a 100644
--- a/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx
+++ b/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx
@@ -18,6 +18,7 @@ import {
getCurrentBrowserSurfaceAssistance,
getCurrentRunAssistance,
hasActiveBrowserSurface,
+ hasResolvedBrowserSurfaceAssistance,
requiresBrowserSurfaceAssistance,
} from "../../../lib/run-assistance.ts";
import {
@@ -41,13 +42,28 @@ export const viewport: Viewport = {
width: "device-width",
};
-function RunDetailLink({ children, runId }: { children: string; runId: string }) {
+function RunDetailLink({ runId }: { runId: string }) {
return (
- {children}
+ View run details (optional)
+
+ );
+}
+
+function SetupStatusLink({ connectionId, runId }: { connectionId: string | null; runId: string }) {
+ if (!connectionId) {
+ return ;
+ }
+ const query = new URLSearchParams({ run_id: runId });
+ return (
+
+ View setup status
);
}
@@ -112,11 +128,13 @@ function renderNoAssistanceSurface({
connector,
currentAssistance,
envelope,
+ connectorInstanceId,
runId,
runStatus,
}: {
connector: ConnectorContext | null;
currentAssistance: ReturnType;
+ connectorInstanceId: string | null;
envelope: TimelineEnvelope;
runId: string;
runStatus: RunStatusEnvelope | null;
@@ -125,7 +143,7 @@ function renderNoAssistanceSurface({
return ;
}
if (currentAssistance?.ownerAction === "act_elsewhere" && currentAssistance.responseContract === "none") {
- return ;
+ return ;
}
const noAssistanceState = selectNoAssistanceStreamState({
// biome-ignore lint/suspicious/noUnnecessaryConditions: runStatus is nullable; tsc rejects removing this.
@@ -149,9 +167,18 @@ function renderNoAssistanceSurface({
);
}
if (hasActiveBrowserSurface(envelope.events)) {
- return ;
+ return ;
+ }
+ // The owner already completed a browser step (e.g. H-E-B login) for this
+ // run, and no further browser action is currently open. Say so plainly
+ // instead of reusing the generic "No browser action is waiting" copy,
+ // which reads as a dead end right after a login the owner just finished
+ // (fr-setup-status-lifecycle-0806) — the run keeps going in the
+ // background and this page's job here is done.
+ if (hasResolvedBrowserSurfaceAssistance(envelope.events)) {
+ return ;
}
- return ;
+ return ;
}
export default async function RunInteractionStreamPage({
@@ -217,7 +244,14 @@ export default async function RunInteractionStreamPage({
const connector = await resolveConnectorContext(connectorId, connectorInstanceId);
if (!streamableAssistance) {
- return renderNoAssistanceSurface({ connector, currentAssistance, envelope, runId, runStatus });
+ return renderNoAssistanceSurface({
+ connector,
+ connectorInstanceId,
+ currentAssistance,
+ envelope,
+ runId,
+ runStatus,
+ });
}
return (
@@ -246,7 +280,7 @@ function RunEndedSurface({
let statusLabel = "failed";
let title = `${subject} needs a look.`;
let description =
- "The browser step is no longer waiting, but the run did not complete successfully. Open the run timeline for the exact failure and next action.";
+ "The browser step is no longer waiting, but the run did not complete successfully. View run details for the exact failure and next action.";
let sectionClass = "rounded-3xl border border-destructive/30 bg-destructive/5 p-6 shadow-2xl shadow-black/10";
if (terminalStatus === "cancelled") {
statusLabel = "cancelled";
@@ -255,7 +289,7 @@ function RunEndedSurface({
} else if (terminalStatus === "deferred") {
statusLabel = "browser deferred";
title = "Secure browser slot unavailable.";
- description = `${subject} waited for a secure browser slot, but capacity stayed full. No connector work started. Retry when a browser slot is available.`;
+ description = `${subject} waited for a secure browser slot, but capacity stayed full. No connector work started. Try again when a secure browser slot is available.`;
sectionClass = "rounded-3xl border border-border bg-card p-6 shadow-2xl shadow-black/10";
}
return (
@@ -264,15 +298,19 @@ function RunEndedSurface({
run {statusLabel}
{title}
{description}
- Open run timeline
+
);
}
-function RunContinuingSurface({ connector, runId }: { connector: ConnectorContext | null; runId: string }) {
- // biome-ignore lint/suspicious/noUnnecessaryConditions: connector is nullable; tsc rejects removing this.
- const subject = connector?.displayName ?? "This run";
+function RunContinuingSurface({
+ connectionId,
+ runId,
+}: {
+ connectionId: string | null;
+ runId: string;
+}) {
return (
@@ -280,17 +318,38 @@ function RunContinuingSurface({ connector, runId }: { connector: ConnectorContex
run continuing
No browser action is waiting.
- {subject} is still being checked. Open the run timeline to follow the latest status.
+ The run is still in progress. This page updates automatically when browser input is needed.
- Open run timeline
+
);
}
-function PreparingBrowserSurface({ connector, runId }: { connector: ConnectorContext | null; runId: string }) {
- // biome-ignore lint/suspicious/noUnnecessaryConditions: connector is nullable; tsc rejects removing this.
- const subject = connector?.displayName ?? "This run";
+function AssistanceCompleteSurface({ connectionId, runId }: { connectionId: string | null; runId: string }) {
+ return (
+
+
+
+ browser step complete
+ Browser step complete.
+
+ Collection is continuing in the background. You can close this page — it updates automatically if browser
+ input is needed again.
+
+
+
+
+ );
+}
+
+function PreparingBrowserSurface({
+ connectionId,
+ runId,
+}: {
+ connectionId: string | null;
+ runId: string;
+}) {
return (
@@ -298,10 +357,9 @@ function PreparingBrowserSurface({ connector, runId }: { connector: ConnectorCon
secure browser starting
Preparing the secure browser.
- {subject} has started a browser-session repair. This page will open the browser controls as soon as the run
- asks for your input.
+ Keep this page open. Browser controls will appear automatically when the run needs your input.
- Open run timeline
+
);
@@ -309,27 +367,23 @@ function PreparingBrowserSurface({ connector, runId }: { connector: ConnectorCon
function ExternalApprovalSurface({
assistance,
- connector,
runId,
}: {
assistance: NonNullable>;
- connector: ConnectorContext | null;
runId: string;
}) {
- // biome-ignore lint/suspicious/noUnnecessaryConditions: connector is nullable; tsc rejects removing this.
- const subject = connector?.displayName ?? "This run";
return (
approval waiting
- Approve the prompt outside PDPP.
+ Approve the request with the provider.
{assistance.message}
- {subject} will continue automatically after the provider confirms the approval. No browser controls are
- waiting on this page.
+ The run will continue automatically after the provider confirms your approval. No browser controls are waiting
+ here.
- Open run timeline
+
);
@@ -343,11 +397,10 @@ function UnavailableStreamSurface({ connector, runId }: { connector: ConnectorCo
stream unavailable
Waiting for a browser surface
- {connector ? `${connector.displayName} needs browser control, but ` : "This run needs browser control, but "}
- no current stream target is registered for this assistance request. Keep the run open while the runtime
- registers a browser surface, then return to the run detail page.
+ {connector ? `${connector.displayName} needs browser control. ` : "This run needs browser control. "}Keep this
+ page open while the secure browser prepares. The page updates automatically when it is ready.
- Back to run detail
+
);
diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-keyboard-focus.test.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-keyboard-focus.test.ts
index 5267b9fa7..988e2bdd1 100644
--- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-keyboard-focus.test.ts
+++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-keyboard-focus.test.ts
@@ -271,3 +271,62 @@ test("geometry extraction is fail-closed when the SSE element has no complete re
assert.equal(readRemoteEditableRect({ width: 3, x: 1, y: 2 }), null);
assert.equal(readRemoteEditableRect(null), null);
});
+
+test("remote-focus at mount then first tap after realistic delay focuses directly (warm cache covers mount→tap)", () => {
+ let state = createMobileKeyboardFocusState();
+ // Remote page sends focus event at mount (t=0)
+ ({ state } = transition(state, { atMs: 0, rect: editableRect, type: "remote-focus" }));
+ assert.equal(state.editableRectCache?.confirmedAtMs, 0);
+
+ // User taps ~2.5s later (realistic delay: page render + user reaction time)
+ const atRealisticTap = 2500;
+ ({ state } = transition(state, {
+ atMs: atRealisticTap,
+ pointerId: 1,
+ remotePoint: editablePoint,
+ type: "pointerdown",
+ }));
+ const released = transition(state, {
+ atMs: atRealisticTap + 50,
+ pointerId: 1,
+ remotePoint: editablePoint,
+ type: "pointerup",
+ });
+
+ // Should focus directly without needing a second remote-focus (warm cache).
+ assert.equal(released.effect, "focus-text-input", "one-tap path should fire with extended TTL");
+ assert.equal(released.state.gesture, null);
+});
+
+test("negative case: tap at expired stale geometry still requires re-confirmation", () => {
+ let state = createMobileKeyboardFocusState();
+ // Cache confirmed at t=0
+ ({ state } = transition(state, { atMs: 0, rect: editableRect, type: "remote-focus" }));
+
+ // Tap arrives after cache has expired (beyond 3.5s)
+ const atExpiredCache = MOBILE_KEYBOARD_EDITABLE_RECT_CACHE_TTL_MS + 100;
+ ({ state } = transition(state, {
+ atMs: atExpiredCache,
+ pointerId: 2,
+ remotePoint: editablePoint,
+ type: "pointerdown",
+ }));
+ const released = transition(state, {
+ atMs: atExpiredCache + 50,
+ pointerId: 2,
+ remotePoint: editablePoint,
+ type: "pointerup",
+ });
+
+ // Expired cache falls back to awaiting-confirmation pattern, requiring a second remote-focus
+ assert.equal(released.effect, "none");
+ assert.equal(
+ released.state.gesture?.phase,
+ "awaiting-confirmation",
+ "expired cache must re-confirm via second remote-focus"
+ );
+
+ // Re-confirmation arrives and shows affordance
+ const confirmed = transition(released.state, { atMs: atExpiredCache + 51, rect: editableRect, type: "remote-focus" });
+ assert.equal(confirmed.effect, "show-affordance");
+});
diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-keyboard-focus.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-keyboard-focus.ts
index 4d5d58277..dc4898336 100644
--- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-keyboard-focus.ts
+++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-keyboard-focus.ts
@@ -2,10 +2,10 @@
// SPDX-License-Identifier: Apache-2.0
export const MOBILE_KEYBOARD_GESTURE_EXPIRY_MS = 1500;
-// Keep a confirmed rect only across the same short trusted-touch window. This
-// covers the common warm follow-up tap without turning a past remote focus
-// into a durable authority to summon the keyboard.
-export const MOBILE_KEYBOARD_EDITABLE_RECT_CACHE_TTL_MS = 1500;
+// Extend to cover mount → first tap window (~2-3.5s): remote page sends focus event
+// at mount, realistic first tap arrives ~2-3s later after page render/user reaction time.
+// Re-confirm at tap time if geometry has drifted since remote-focus; see test case.
+export const MOBILE_KEYBOARD_EDITABLE_RECT_CACHE_TTL_MS = 3500;
export const MOBILE_KEYBOARD_TAP_SLOP_PX = 12;
export interface RemotePoint {
diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-page-terminal-state.test.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-page-terminal-state.test.ts
index 5b7d20dbe..d394b37a2 100644
--- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-page-terminal-state.test.ts
+++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-page-terminal-state.test.ts
@@ -18,13 +18,24 @@ const ENDED_SURFACE_GATE_RE =
/noAssistanceState === "ended"[\s\S]{0,360} /;
+const SETUP_STATUS_LINK_RE =
+ /function SetupStatusLink[\s\S]{0,520}\/connect\/status\/\$\{encodeURIComponent\(connectionId\)\}/;
+const SETUP_STATUS_CTA_RE = / /g;
+const NO_PENDING_TIMELINE_DETOUR_RE = /still being checked[\s\S]{0,220}Open the run timeline/;
const UNAVAILABLE_STREAM_POLLER_RE =
/function UnavailableStreamSurface[\s\S]{0,520} /;
const PREPARING_BROWSER_SURFACE_GATE_RE =
/hasActiveBrowserSurface\(envelope\.events\)[\s\S]{0,120} /;
+const EXTERNAL_APPROVAL_COPY_RE = /Approve the request with the provider\./;
const EXTERNAL_APPROVAL_WAITING_COPY_RE = /No browser controls are\s+waiting/;
+const OPTIONAL_RUN_DETAILS_COPY_RE = /View run details \(optional\)/;
+const LEGACY_RUN_TIMELINE_CTA_RE = /Open run timeline/;
const POLLER_TIMELINE_PROBE_RE = /fetch\(`\/_ref\/runs\/\$\{encodeURIComponent\(runId\)\}\/timeline`/;
const POLLER_STREAM_READY_RE = /getCurrentBrowserSurfaceAssistance\(timelineEventsFrom\(body\)\) !== null/;
const POLLER_HARD_RELOAD_RE = /window\.location\.reload\(\)/;
@@ -92,6 +103,39 @@ test("stream page does not render resolved copy solely because assistance disapp
assert.match(pageSource, PREPARING_BROWSER_SURFACE_COPY_RE);
assert.match(pageSource, CONTINUING_SURFACE_RE);
assert.match(pageSource, CONTINUING_POLLER_RE);
+ assert.match(pageSource, SETUP_STATUS_LINK_RE);
+ assert.equal([...pageSource.matchAll(SETUP_STATUS_CTA_RE)].length, 3);
+ assert.doesNotMatch(pageSource, NO_PENDING_TIMELINE_DETOUR_RE);
+});
+
+// fr-setup-status-lifecycle-0806: a browser-assistance connector (e.g. H-E-B)
+// that already resolved its login step must not fall back to the generic
+// "No browser action is waiting" copy — that copy is now reserved for a run
+// that genuinely never raised browser assistance at all. Once assistance was
+// requested and resolved, the page must say so plainly and keep polling for
+// any FURTHER assistance request, not just for the run's own terminal state.
+test("stream page hands off with explicit copy once browser assistance resolves, instead of the generic no-action copy", () => {
+ assert.match(pageSource, ASSISTANCE_COMPLETE_GATE_RE);
+ assert.match(pageSource, ASSISTANCE_COMPLETE_COPY_RE);
+ assert.match(pageSource, ASSISTANCE_COMPLETE_POLLER_RE);
+ // The resolved-assistance gate must be checked before the generic
+ // RunContinuingSurface fallback, so a resolved run never regresses to the
+ // ambiguous copy.
+ const assistanceCompleteGateIndex = pageSource.search(ASSISTANCE_COMPLETE_GATE_RE);
+ const continuingSurfaceIndex = pageSource.lastIndexOf("return {
+ assert.match(pageSource, OPTIONAL_RUN_DETAILS_COPY_RE);
+ assert.match(streamViewerSource, OPTIONAL_RUN_DETAILS_COPY_RE);
+ assert.doesNotMatch(pageSource, LEGACY_RUN_TIMELINE_CTA_RE);
+ assert.doesNotMatch(streamViewerSource, LEGACY_RUN_TIMELINE_CTA_RE);
});
test("external provider approval does not render as a browser-session repair", () => {
diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx
index 516c02d16..72de60fbc 100644
--- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx
+++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx
@@ -5979,13 +5979,13 @@ export function ResolvedSurface({ connector, runId }: { connector: ConnectorCont
{subject} is back on it.
- The browser step is complete. You can close this tab with your browser controls, or open the run timeline.
+ The browser step is complete. You can close this tab with your browser controls, or view run details.
- Open run timeline
+ View run details (optional)
This step takes a credential, not a browser.
diff --git a/apps/console/src/app/(console)/syncs/loading.tsx b/apps/console/src/app/(console)/syncs/loading.tsx
index 70999c419..ae2469002 100644
--- a/apps/console/src/app/(console)/syncs/loading.tsx
+++ b/apps/console/src/app/(console)/syncs/loading.tsx
@@ -15,7 +15,7 @@ import { ListLoadingSkeleton } from "../components/route-loading.tsx";
export default function RunsLoading() {
return (
);
}
diff --git a/apps/console/src/app/(console)/syncs/page.tsx b/apps/console/src/app/(console)/syncs/page.tsx
index 80bd01ef2..047913b79 100644
--- a/apps/console/src/app/(console)/syncs/page.tsx
+++ b/apps/console/src/app/(console)/syncs/page.tsx
@@ -5,7 +5,7 @@
* Syncs — the Recordroom reskin of the Runs route.
*
* Health-first: this surface answers "what was recently collected, and what (in
- * plain English) needs my hand?" It fuses three real reference contracts:
+ * needs my attention?" It fuses three real reference contracts:
* - `_ref/runs` → the runs feed, for per-connection Rhythm + last result
* - `_ref/connectors` → per-connection health + schedule + stream list
* via the pure {@link buildSyncsViewModel}, then renders the {@link SyncsView}
diff --git a/apps/console/src/app/(console)/syncs/syncs-model.test.ts b/apps/console/src/app/(console)/syncs/syncs-model.test.ts
index 543d11f51..af098406c 100644
--- a/apps/console/src/app/(console)/syncs/syncs-model.test.ts
+++ b/apps/console/src/app/(console)/syncs/syncs-model.test.ts
@@ -28,6 +28,7 @@ const RECONNECT_PROMPT_RE = /reconnect|log in/i;
const RESUME_FALSE_REASSURANCE_RE = /fills on the next successful run|resumes normally/i;
const RESUME_NORMALLY_RE = /resume normally/i;
const THROTTLING_RE = /throttling/i;
+const UNVERIFIED_ZERO_COPY_RE = /without proving the account was empty/;
const ACTIONABILITY_RENDERED_STATUS_RE = /actionability\.renderedStatus/;
const RAW_VERDICT_TONE_RE = /rendered_verdict\.pill\.tone|verdict\.pill\.tone/;
const SYNCS_PAGE_SOURCE = readFileSync(new URL("./page.tsx", import.meta.url), "utf8");
@@ -551,6 +552,22 @@ test("a draft connection produces a PendingSetupCard, not a SyncGroup or Failure
assert.equal(card.continueHref, "/connect/status/cin_draft");
});
+test("terminal setup disposition keeps a draft out of sync groups and carries shared actionability copy", () => {
+ const model = buildSyncsViewModel({
+ connectors: [draftConnector({ terminal_setup_disposition: "unverified_zero" })],
+ runs: [],
+ });
+
+ assert.equal(model.groups.length, 0);
+ assert.equal(model.failureCards.length, 0);
+ assert.equal(model.pendingSetupCards.length, 1);
+ const [card] = model.pendingSetupCards;
+ assert.ok(card);
+ assert.equal(card.statusLabel, "needs review");
+ assert.equal(card.actionLabel, "Retry first sync");
+ assert.match(card.what, UNVERIFIED_ZERO_COPY_RE);
+});
+
test("a draft connection counts toward needYourHand and inflates onSchedule by zero", () => {
const model = buildSyncsViewModel({
connectors: [draftConnector(), connector({ connection_id: "cin_healthy" })],
diff --git a/apps/console/src/app/(console)/syncs/syncs-model.ts b/apps/console/src/app/(console)/syncs/syncs-model.ts
index 35ab27aa1..f2155e907 100644
--- a/apps/console/src/app/(console)/syncs/syncs-model.ts
+++ b/apps/console/src/app/(console)/syncs/syncs-model.ts
@@ -39,6 +39,7 @@ import {
type SourceStatusFlag,
type SourceWorkItem,
sourceAttentionHeadline,
+ sourceWorkItemFromConnector,
} from "../lib/source-actionability.ts";
// ─── Rhythm tick type (mirrors the kit's RhythmTick) ──────────────────────────
@@ -157,6 +158,8 @@ export interface FailureCard {
* fix-pending-connection-discovery design.
*/
export interface PendingSetupCard {
+ /** Shared owner action translated from the connection-scoped disposition. */
+ actionLabel: string;
/** Durable connection identity. */
connectionId: string;
/** Connector key. */
@@ -165,6 +168,10 @@ export interface PendingSetupCard {
continueHref: string;
/** Connection display name (the card title). */
name: string;
+ /** Shared terminal/setup status label. */
+ statusLabel: string;
+ /** Shared owner-facing explanation. */
+ what: string;
}
/**
@@ -663,11 +670,15 @@ function toPendingSetupCard(connector: RefConnectorSummary): PendingSetupCard {
// recordsHrefForSummary for the same documented pattern).
// biome-ignore lint/suspicious/noUnnecessaryConditions: see comment above.
const routeId = connector.connection_id ?? connector.connector_instance_id ?? connector.connector_id;
+ const work = pendingSetupWorkItem(connector);
return {
+ actionLabel: work.actionLabel ?? SETUP_IN_PROGRESS_CTA_LABEL,
connectionId: connector.connection_id,
connectorId: connector.connector_id,
continueHref: `/connect/status/${encodeURIComponent(routeId)}`,
name: connector.display_name,
+ statusLabel: work.statusLabel,
+ what: work.what,
};
}
@@ -676,6 +687,10 @@ function pendingSetupWorkItem(connector: RefConnectorSummary): SourceWorkItem {
// connection_id is non-optional in the current contract; connector_instance_id
// /connector_id are a real legacy-server fallback (see schedule-row.tsx's
// recordsHrefForSummary for the same documented pattern).
+ const work = sourceWorkItemFromConnector(connector);
+ if (work) {
+ return work;
+ }
// biome-ignore lint/suspicious/noUnnecessaryConditions: see comment above.
const routeId = connector.connection_id ?? connector.connector_instance_id ?? connector.connector_id;
return {
diff --git a/apps/console/src/app/(console)/syncs/syncs-view.tsx b/apps/console/src/app/(console)/syncs/syncs-view.tsx
index d3081c136..4a23addbc 100644
--- a/apps/console/src/app/(console)/syncs/syncs-view.tsx
+++ b/apps/console/src/app/(console)/syncs/syncs-view.tsx
@@ -68,7 +68,7 @@ function HealthBandStrip({ band }: { band: SyncsViewModel["band"] }) {
const reviewValue = band.needYourHand > 0 ? band.needYourHand : band.needsReview;
let reviewLabel = "need attention";
if (band.needYourHand > 0) {
- reviewLabel = "need your hand";
+ reviewLabel = "need your attention";
} else if (band.needsReview > 0) {
reviewLabel = "need review";
}
@@ -79,7 +79,9 @@ function HealthBandStrip({ band }: { band: SyncsViewModel["band"] }) {