@pdpp/cli 0.1.0-beta.7 → 0.1.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ // Non-secret owner-agent control discovery for the `pdpp owner-agent control`
2
+ // subcommand.
3
+ //
4
+ // A trusted local owner agent (Daisy/Simon-style) needs a typed, route-guess-
5
+ // free way to answer two questions before it operates an owner's instance:
6
+ // 1. "What owner-agent control actions does this build support?" — the
7
+ // bearer-authed capability document `GET /v1/owner/control`.
8
+ // 2. "Which connection instances are configured, and which still need an
9
+ // owner-meaningful label?" — the bearer-authed listing
10
+ // `GET /v1/owner/connections`.
11
+ //
12
+ // Both surfaces are projected server-side from one control catalog, so this
13
+ // module only consumes them; it never invents action families or routes. The
14
+ // owner bearer is read from the stored credential and sent as an Authorization
15
+ // header. It is NEVER printed: this command emits only non-secret capability
16
+ // and connection metadata.
17
+
18
+ import { OwnerAgentError } from './errors.js';
19
+ import { getOwnerAgentAccessToken } from './lifecycle.js';
20
+
21
+ /**
22
+ * Fetch the owner-agent control capability document and the configured
23
+ * connection listing, returning the non-secret subset each surface exposes.
24
+ * The bearer is sent as `Authorization: Bearer` and never returned.
25
+ *
26
+ * @param {object} args
27
+ * @param {typeof fetch} args.fetchFn
28
+ * @param {object} args.record stored owner-agent credential record
29
+ * @returns {Promise<{ control: object, connections: object[] }>}
30
+ */
31
+ export async function discoverOwnerAgentControl({ fetchFn, record }) {
32
+ const token = getOwnerAgentAccessToken(record);
33
+ if (!token) {
34
+ throw new OwnerAgentError('credential_invalid', 'Stored credential is missing an access token.');
35
+ }
36
+ const resource = typeof record?.resource === 'string' ? record.resource.replace(/\/$/, '') : null;
37
+ if (!resource) {
38
+ throw new OwnerAgentError(
39
+ 'credential_invalid',
40
+ 'Stored credential has no resource origin; re-run `pdpp owner-agent onboard`.'
41
+ );
42
+ }
43
+ const headers = { Accept: 'application/json', Authorization: `Bearer ${token}` };
44
+
45
+ const control = await getJson(fetchFn, `${resource}/v1/owner/control`, headers, 'control_failed');
46
+ const connectionsBody = await getJson(
47
+ fetchFn,
48
+ `${resource}/v1/owner/connections`,
49
+ headers,
50
+ 'connections_failed'
51
+ );
52
+ const connections = Array.isArray(connectionsBody?.data) ? connectionsBody.data : [];
53
+ return { control, connections };
54
+ }
55
+
56
+ /**
57
+ * Format the control capability document and connection listing into a
58
+ * non-secret, token-efficient text report. Returns the string the command
59
+ * writes to stdout. Asserts nothing about the bearer; the caller has already
60
+ * ensured it is never included.
61
+ */
62
+ export function formatOwnerAgentControl({ control, connections }) {
63
+ const lines = [];
64
+ lines.push('Owner-agent control capabilities (non-secret):');
65
+ if (control?.entrypoint) {
66
+ lines.push(` entrypoint: ${control.entrypoint}`);
67
+ }
68
+ lines.push(` /mcp owner bearer: rejected (use owner-bearer /v1/* REST, not /mcp)`);
69
+ lines.push('');
70
+ lines.push(' Action families:');
71
+ const actions = Array.isArray(control?.actions) ? control.actions : [];
72
+ for (const action of actions) {
73
+ const family = action?.family ?? 'unknown';
74
+ const status = action?.status ?? 'unknown';
75
+ const route = action?.method && action?.url ? `${action.method} ${action.url}` : '(owner-mediated / not a route)';
76
+ lines.push(` - ${family} [${status}] ${route}`);
77
+ if (action?.reason) {
78
+ lines.push(` ${action.reason}`);
79
+ }
80
+ }
81
+
82
+ lines.push('');
83
+ lines.push(`Configured connections (${connections.length}):`);
84
+ if (connections.length === 0) {
85
+ lines.push(' (none yet — initiate one with the initiate_connection action above)');
86
+ }
87
+ for (const connection of connections) {
88
+ const connectionId = connection?.connection_id ?? '(no connection_id)';
89
+ const connectorId = connection?.connector_id ?? connection?.connector_key ?? '(unknown connector)';
90
+ lines.push(` - ${connectionId} connector=${connectorId}`);
91
+ const label = formatLabel(connection);
92
+ lines.push(` label: ${label}`);
93
+ if (connection?.status) {
94
+ lines.push(` status: ${connection.status}`);
95
+ }
96
+ }
97
+ return `${lines.join('\n')}\n`;
98
+ }
99
+
100
+ // An owner-meaningful label (`owner_set`) is printed as-is. A `fallback` label
101
+ // is the storage-layer placeholder (e.g. a registry URL) — surface it as
102
+ // label-needed, not as a final name, so the agent knows to rename it before
103
+ // relying on it. Never invent a label here.
104
+ function formatLabel(connection) {
105
+ const labelStatus = connection?.label_status;
106
+ const displayName = typeof connection?.display_name === 'string' ? connection.display_name : null;
107
+ if (labelStatus === 'owner_set' && displayName) {
108
+ return `"${displayName}" (owner_set)`;
109
+ }
110
+ if (displayName) {
111
+ return `label-needed (fallback: "${displayName}" — rename with rename_connection)`;
112
+ }
113
+ return 'label-needed (no display_name — rename with rename_connection)';
114
+ }
115
+
116
+ async function getJson(fetchFn, url, headers, errorCode) {
117
+ let response;
118
+ try {
119
+ response = await fetchFn(url, { headers });
120
+ } catch (error) {
121
+ throw new OwnerAgentError(errorCode, `Failed to fetch ${url}: ${error.message}.`);
122
+ }
123
+ if (response.status === 401 || response.status === 403) {
124
+ throw new OwnerAgentError(
125
+ 'control_unauthorized',
126
+ `Owner-agent control is not authorized (HTTP ${response.status}). The credential may be revoked or inactive; run \`pdpp owner-agent status\`.`,
127
+ 4
128
+ );
129
+ }
130
+ if (!response.ok) {
131
+ throw new OwnerAgentError(errorCode, `Failed to fetch ${url}: HTTP ${response.status}.`);
132
+ }
133
+ try {
134
+ return await response.json();
135
+ } catch {
136
+ throw new OwnerAgentError(errorCode, `Response from ${url} was not valid JSON.`);
137
+ }
138
+ }
@@ -0,0 +1,126 @@
1
+ // Local credential target for the trusted owner-agent profile.
2
+ //
3
+ // Owner-agent credentials are owner-level local automation. They are written to
4
+ // a local file with restrictive permissions and are NEVER printed to stdout,
5
+ // stderr, logs, or dashboard status tables.
6
+ //
7
+ // Target resolution:
8
+ // - An explicit `--credential-file <path>` always wins. Daisy's first
9
+ // supported target is `~/applications/daisy/.pi/agent/pdpp-owner-agent.json`;
10
+ // the operator passes it explicitly.
11
+ // - Otherwise a safe default under the user home is used:
12
+ // `~/.pdpp/owner-agents/<host>.json`. This is intentionally rooted in the
13
+ // home directory, not a project-local `.pdpp/`, so an owner-level bearer is
14
+ // never accidentally committed alongside project files.
15
+
16
+ import { chmod, mkdir, writeFile } from 'node:fs/promises';
17
+ import { homedir } from 'node:os';
18
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
19
+
20
+ export const DEFAULT_OWNER_AGENT_DIR = join('.pdpp', 'owner-agents');
21
+
22
+ /**
23
+ * Resolve the absolute credential-file path for an owner-agent credential.
24
+ *
25
+ * @param {object} args
26
+ * @param {string} [args.credentialFile] explicit target (e.g. Daisy's path)
27
+ * @param {string} args.resource normalized resource origin
28
+ * @param {string} [args.home] home dir override (tests)
29
+ * @returns {string} absolute path
30
+ */
31
+ export function resolveCredentialFile({ credentialFile, resource, home } = {}) {
32
+ const base = home ?? homedir();
33
+ if (credentialFile) {
34
+ const expanded = expandHome(credentialFile, base);
35
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
36
+ }
37
+ const host = hostSlug(resource);
38
+ return join(base, DEFAULT_OWNER_AGENT_DIR, `${host}.json`);
39
+ }
40
+
41
+ /**
42
+ * Write owner-agent credential material to the target file with 0600 perms.
43
+ * Returns the absolute path written. The bearer is stored on disk only; it is
44
+ * the caller's responsibility never to print it.
45
+ *
46
+ * @param {string} targetPath absolute path
47
+ * @param {object} payload credential record (must include access_token)
48
+ * @returns {Promise<string>}
49
+ */
50
+ export async function writeOwnerAgentCredential(targetPath, payload) {
51
+ const dir = dirname(targetPath);
52
+ await mkdir(dir, { recursive: true, mode: 0o700 });
53
+ // Best-effort tighten on the directory we own; ignore EPERM on shared parents.
54
+ await chmod(dir, 0o700).catch(() => {});
55
+ await writeFile(targetPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
56
+ // writeFile honors the mode only on creation; enforce 0600 if the file
57
+ // pre-existed with looser perms.
58
+ await chmod(targetPath, 0o600).catch(() => {});
59
+ return targetPath;
60
+ }
61
+
62
+ /**
63
+ * Build the on-disk credential record. Includes the bearer (for the agent to
64
+ * use) plus non-secret metadata for status/introspection/revocation.
65
+ */
66
+ export function buildCredentialRecord({
67
+ resource,
68
+ authorizationServer,
69
+ credential,
70
+ clientId,
71
+ introspectionEndpoint,
72
+ registrationEndpoint,
73
+ registrationClientUri,
74
+ schemaCompactEndpoint,
75
+ schemaEndpoint,
76
+ streamsEndpoint,
77
+ createdAt,
78
+ }) {
79
+ const tokenType = credential.token_type ?? 'Bearer';
80
+ const expiresAt = credential.expires_at ?? null;
81
+ const scope = credential.scope ?? null;
82
+ return {
83
+ profile: 'trusted_owner_agent',
84
+ pdpp_token_kind: 'owner',
85
+ resource,
86
+ authorization_server: authorizationServer ?? null,
87
+ client_id: clientId ?? null,
88
+ introspection_endpoint: introspectionEndpoint ?? null,
89
+ schema_endpoint: schemaEndpoint ?? null,
90
+ schema_compact_endpoint: schemaCompactEndpoint ?? null,
91
+ streams_endpoint: streamsEndpoint ?? null,
92
+ // RFC 7592 client-delete revocation handle, when the credential was bound
93
+ // to a dynamically registered client. The reference implementation gates
94
+ // DELETE with an owner session, not a registration access token.
95
+ registration_client_uri: registrationClientUri ?? credential.registration_client_uri ?? null,
96
+ registration_endpoint: registrationEndpoint ?? null,
97
+ access_token: credential.access_token,
98
+ token_type: tokenType,
99
+ expires_at: expiresAt,
100
+ scope,
101
+ // Backward-compatible nested credential block for callers that adopted the
102
+ // first CLI preview. Daisy and the owner-agent runbook read the top-level
103
+ // access_token.
104
+ credential: {
105
+ access_token: credential.access_token,
106
+ token_type: tokenType,
107
+ expires_at: expiresAt,
108
+ scope,
109
+ },
110
+ created_at: createdAt,
111
+ };
112
+ }
113
+
114
+ function expandHome(p, base) {
115
+ if (p === '~') return base;
116
+ if (p.startsWith('~/')) return join(base, p.slice(2));
117
+ return p;
118
+ }
119
+
120
+ function hostSlug(resource) {
121
+ try {
122
+ return new URL(resource).host.replace(/[^a-zA-Z0-9.-]/g, '_');
123
+ } catch {
124
+ return 'owner-agent';
125
+ }
126
+ }
@@ -0,0 +1,145 @@
1
+ // RFC 8628 device-authorization handling for the trusted owner-agent flow.
2
+ //
3
+ // The owner approves in a browser; the CLI prints only the verification URL,
4
+ // the user code, and non-secret polling status. The bearer returned by the
5
+ // token endpoint is NEVER printed here — it is returned to the caller for
6
+ // non-printing storage.
7
+
8
+ import { OwnerAgentError } from './errors.js';
9
+
10
+ const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
11
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
12
+ const DEFAULT_POLL_TIMEOUT_MS = 5 * 60 * 1000;
13
+
14
+ /**
15
+ * Initiate device authorization. Returns the public RFC 8628 envelope.
16
+ */
17
+ export async function initiateDeviceAuthorization({ fetchFn, endpoint, clientId }) {
18
+ const body = new URLSearchParams();
19
+ if (clientId) {
20
+ body.set('client_id', clientId);
21
+ }
22
+ const result = await postForm(fetchFn, endpoint, body);
23
+ const verificationUri = result.verification_uri_complete ?? result.verification_uri;
24
+ if (!result.device_code || !verificationUri) {
25
+ throw new OwnerAgentError(
26
+ 'device_authorization_invalid',
27
+ 'Device authorization response did not include a device_code and verification URI.'
28
+ );
29
+ }
30
+ return {
31
+ deviceCode: result.device_code,
32
+ userCode: result.user_code ?? null,
33
+ verificationUri,
34
+ verificationUriComplete: result.verification_uri_complete ?? null,
35
+ intervalMs: Number.isFinite(Number(result.interval)) ? Number(result.interval) * 1000 : DEFAULT_POLL_INTERVAL_MS,
36
+ expiresInMs: Number.isFinite(Number(result.expires_in)) ? Number(result.expires_in) * 1000 : DEFAULT_POLL_TIMEOUT_MS,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Poll the token endpoint until the owner approves, denies, or it expires.
42
+ * Honors RFC 8628 `authorization_pending` / `slow_down` / `access_denied` /
43
+ * `expired_token`.
44
+ */
45
+ export async function pollForOwnerAgentToken({
46
+ fetchFn,
47
+ endpoint,
48
+ clientId,
49
+ deviceCode,
50
+ intervalMs = DEFAULT_POLL_INTERVAL_MS,
51
+ timeoutMs = DEFAULT_POLL_TIMEOUT_MS,
52
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
53
+ now = () => Date.now(),
54
+ onPending,
55
+ }) {
56
+ const startedAt = now();
57
+ let currentInterval = intervalMs;
58
+
59
+ while (now() - startedAt <= timeoutMs) {
60
+ const body = new URLSearchParams();
61
+ body.set('grant_type', DEVICE_CODE_GRANT_TYPE);
62
+ body.set('device_code', deviceCode);
63
+ if (clientId) {
64
+ body.set('client_id', clientId);
65
+ }
66
+
67
+ const { status, json } = await postFormRaw(fetchFn, endpoint, body);
68
+ const errorCode = json?.error?.code ?? json?.error ?? json?.code;
69
+
70
+ if (status >= 200 && status < 300 && json?.access_token) {
71
+ return {
72
+ access_token: json.access_token,
73
+ token_type: json.token_type ?? 'Bearer',
74
+ expires_at: expiresAt(json.expires_in, now),
75
+ scope: json.scope ?? null,
76
+ registration_client_uri: json.registration_client_uri ?? null,
77
+ };
78
+ }
79
+
80
+ if (errorCode === 'authorization_pending') {
81
+ onPending?.('pending');
82
+ await sleep(currentInterval);
83
+ continue;
84
+ }
85
+ if (errorCode === 'slow_down') {
86
+ currentInterval += 5000;
87
+ onPending?.('slow_down');
88
+ await sleep(currentInterval);
89
+ continue;
90
+ }
91
+ if (errorCode === 'access_denied') {
92
+ throw new OwnerAgentError('approval_denied', 'Owner denied the trusted owner-agent onboarding request.');
93
+ }
94
+ if (errorCode === 'expired_token') {
95
+ throw new OwnerAgentError('approval_expired', 'Owner-agent approval expired before it was granted. Run onboarding again.');
96
+ }
97
+ if (errorCode === 'invalid_client' || errorCode === 'invalid_grant') {
98
+ throw new OwnerAgentError('token_exchange_failed', `Token endpoint rejected the device-code exchange (${errorCode}).`);
99
+ }
100
+
101
+ throw new OwnerAgentError('token_exchange_failed', `Unexpected token endpoint response (HTTP ${status}).`);
102
+ }
103
+
104
+ throw new OwnerAgentError('approval_expired', 'Timed out waiting for owner approval of the owner-agent credential.');
105
+ }
106
+
107
+ function expiresAt(expiresIn, now) {
108
+ const seconds = Number(expiresIn);
109
+ if (!Number.isFinite(seconds) || seconds <= 0) {
110
+ return null;
111
+ }
112
+ return new Date(now() + seconds * 1000).toISOString();
113
+ }
114
+
115
+ async function postForm(fetchFn, url, body) {
116
+ const { status, json } = await postFormRaw(fetchFn, url, body);
117
+ if (status < 200 || status >= 300) {
118
+ const errorCode = json?.error?.code ?? json?.error ?? json?.code ?? `http_${status}`;
119
+ throw new OwnerAgentError('device_authorization_failed', `Device authorization failed (${errorCode}).`);
120
+ }
121
+ return json ?? {};
122
+ }
123
+
124
+ async function postFormRaw(fetchFn, url, body) {
125
+ let response;
126
+ try {
127
+ response = await fetchFn(url, {
128
+ method: 'POST',
129
+ headers: {
130
+ Accept: 'application/json',
131
+ 'Content-Type': 'application/x-www-form-urlencoded',
132
+ },
133
+ body: body.toString(),
134
+ });
135
+ } catch (error) {
136
+ throw new OwnerAgentError('request_failed', `Request to ${url} failed: ${error.message}.`);
137
+ }
138
+ let json = null;
139
+ try {
140
+ json = await response.json();
141
+ } catch {
142
+ json = null;
143
+ }
144
+ return { status: response.status, json };
145
+ }
@@ -0,0 +1,233 @@
1
+ // Discovery for the trusted owner-agent onboarding profile.
2
+ //
3
+ // A trusted local owner agent (e.g. Daisy) starts from an entrypoint URL and
4
+ // must learn, without route guessing, where to:
5
+ // - initiate browser-mediated owner approval (device authorization),
6
+ // - poll for the issued owner-agent credential (token endpoint),
7
+ // - introspect the credential, and
8
+ // - revoke it (RFC 7592 client delete).
9
+ //
10
+ // Two discovery sources are honored, in priority order:
11
+ // 1. The advisory `pdpp_owner_agent_onboarding` block, when the deployment
12
+ // advertises it in protected-resource metadata or the `GET /` root pointer.
13
+ // This is the explicit, owner-level profile described in the
14
+ // add-trusted-owner-agent-onboarding OpenSpec change.
15
+ // 2. A fallback to the existing RFC 8628 device-authorization shape advertised
16
+ // in authorization-server metadata (`device_authorization_endpoint`,
17
+ // `token_endpoint`, `introspection_endpoint`, `registration_endpoint`).
18
+ // This lets the CLI work against the current reference server before the
19
+ // advisory block is emitted server-side.
20
+ //
21
+ // This module does NOT emit server metadata. It only consumes it.
22
+
23
+ import { OwnerAgentError } from './errors.js';
24
+
25
+ const PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource';
26
+ const AUTHORIZATION_SERVER_METADATA_PATH = '/.well-known/oauth-authorization-server';
27
+
28
+ export function normalizeEntrypointUrl(value) {
29
+ if (typeof value !== 'string' || value.length === 0) {
30
+ return null;
31
+ }
32
+ try {
33
+ const parsed = new URL(value.includes('://') ? value : `https://${value}`);
34
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
35
+ return null;
36
+ }
37
+ parsed.username = '';
38
+ parsed.password = '';
39
+ parsed.hash = '';
40
+ parsed.search = '';
41
+ parsed.pathname = parsed.pathname.length > 1 ? parsed.pathname.replace(/\/+$/, '') : parsed.pathname;
42
+ if (parsed.pathname === '/') {
43
+ parsed.pathname = '';
44
+ }
45
+ return parsed.toString().replace(/\/$/, '');
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Resolve the owner-agent onboarding endpoints starting from an entrypoint URL.
53
+ *
54
+ * @param {string} entrypointUrl
55
+ * @param {object} [options]
56
+ * @param {typeof fetch} [options.fetch]
57
+ * @returns {Promise<OwnerAgentOnboardingProfile>}
58
+ */
59
+ export async function discoverOwnerAgentProfile(entrypointUrl, options = {}) {
60
+ const resource = normalizeEntrypointUrl(entrypointUrl);
61
+ if (!resource) {
62
+ throw new OwnerAgentError('invalid_entrypoint', `Invalid entrypoint URL: ${entrypointUrl}`, 64);
63
+ }
64
+ const fetchFn = options.fetch ?? globalThis.fetch;
65
+ if (typeof fetchFn !== 'function') {
66
+ throw new OwnerAgentError('fetch_unavailable', 'This Node runtime does not provide fetch().');
67
+ }
68
+
69
+ const resourceMetadata = await getJson(
70
+ fetchFn,
71
+ new URL(PROTECTED_RESOURCE_METADATA_PATH, resource).toString(),
72
+ 'metadata_failure'
73
+ );
74
+
75
+ // The root pointer (GET /) may also carry the advisory block. We only fetch
76
+ // it if protected-resource metadata did not already surface onboarding info.
77
+ let onboarding = readOnboardingBlock(resourceMetadata);
78
+ if (!onboarding) {
79
+ const rootMetadata = await getJsonOptional(fetchFn, resource);
80
+ onboarding = rootMetadata ? readOnboardingBlock(rootMetadata) : null;
81
+ }
82
+
83
+ const authorizationServerUrl = selectAuthorizationServer(resourceMetadata, resource);
84
+ const authorizationMetadata = authorizationServerUrl
85
+ ? await getJsonOptional(
86
+ fetchFn,
87
+ new URL(AUTHORIZATION_SERVER_METADATA_PATH, authorizationServerUrl).toString()
88
+ )
89
+ : null;
90
+
91
+ const profile = buildProfile({
92
+ resource,
93
+ authorizationServerUrl,
94
+ onboarding,
95
+ authorizationMetadata,
96
+ });
97
+
98
+ if (!profile.deviceAuthorizationEndpoint || !profile.tokenEndpoint) {
99
+ throw new OwnerAgentError(
100
+ 'onboarding_unavailable',
101
+ 'This deployment does not advertise a trusted owner-agent onboarding flow. ' +
102
+ 'Expected a pdpp_owner_agent_onboarding block or an RFC 8628 device_authorization_endpoint + token_endpoint.'
103
+ );
104
+ }
105
+
106
+ return profile;
107
+ }
108
+
109
+ function readOnboardingBlock(metadata) {
110
+ if (!metadata || typeof metadata !== 'object') {
111
+ return null;
112
+ }
113
+ const block =
114
+ metadata.pdpp_owner_agent_onboarding ??
115
+ metadata.pdpp_agent_discovery?.owner_agent_onboarding ??
116
+ null;
117
+ return block && typeof block === 'object' ? block : null;
118
+ }
119
+
120
+ function buildProfile({ resource, authorizationServerUrl, onboarding, authorizationMetadata }) {
121
+ const issuer = normalizeEntrypointUrl(
122
+ onboarding?.authorization_server ?? authorizationMetadata?.issuer ?? authorizationServerUrl ?? resource
123
+ );
124
+ const base = issuer ?? resource;
125
+
126
+ const deviceAuthorizationEndpoint = resolveEndpoint(
127
+ onboarding?.device_authorization_endpoint ?? authorizationMetadata?.device_authorization_endpoint,
128
+ base
129
+ );
130
+ const tokenEndpoint = resolveEndpoint(
131
+ onboarding?.token_endpoint ?? authorizationMetadata?.token_endpoint,
132
+ base
133
+ );
134
+ const introspectionEndpoint = resolveEndpoint(
135
+ onboarding?.introspection_endpoint ?? authorizationMetadata?.introspection_endpoint,
136
+ base
137
+ );
138
+ const registrationEndpoint = resolveEndpoint(
139
+ onboarding?.registration_endpoint ?? authorizationMetadata?.registration_endpoint,
140
+ base
141
+ );
142
+ const approvalUrl = resolveEndpoint(onboarding?.owner_approval_url ?? onboarding?.approval_url, base);
143
+ const schemaEndpoint = resolveEndpoint(onboarding?.schema_endpoint, resource);
144
+ const schemaCompactEndpoint = resolveEndpoint(
145
+ onboarding?.schema_compact_endpoint ?? (schemaEndpoint ? `${schemaEndpoint}?view=compact` : null),
146
+ resource
147
+ );
148
+ const streamsEndpoint = resolveEndpoint(onboarding?.streams_endpoint, resource);
149
+ const revocationPathTemplate =
150
+ typeof onboarding?.revocation_path_template === 'string' ? onboarding.revocation_path_template : null;
151
+
152
+ return {
153
+ profile: onboarding?.profile ?? 'trusted_owner_agent',
154
+ advisory: Boolean(onboarding),
155
+ resource,
156
+ authorizationServer: issuer,
157
+ deviceAuthorizationEndpoint,
158
+ tokenEndpoint,
159
+ introspectionEndpoint,
160
+ registrationEndpoint,
161
+ revocationPathTemplate,
162
+ approvalUrl,
163
+ schemaEndpoint,
164
+ schemaCompactEndpoint,
165
+ streamsEndpoint,
166
+ mcpRejectsOwnerBearer: onboarding?.mcp_owner_bearer_rejected ?? onboarding?.mcp_rejects_owner_bearer ?? true,
167
+ };
168
+ }
169
+
170
+ function selectAuthorizationServer(resourceMetadata, resource) {
171
+ const servers = resourceMetadata?.authorization_servers;
172
+ const selected = Array.isArray(servers) ? servers[0] : resourceMetadata?.authorization_server;
173
+ return normalizeEntrypointUrl(selected ?? resource);
174
+ }
175
+
176
+ function resolveEndpoint(value, base) {
177
+ if (!value || typeof value !== 'string') {
178
+ return null;
179
+ }
180
+ try {
181
+ return new URL(value, base ? `${base}/` : undefined).toString();
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ async function getJson(fetchFn, url, errorCode) {
188
+ let response;
189
+ try {
190
+ response = await fetchFn(url, { headers: { Accept: 'application/json' } });
191
+ } catch (error) {
192
+ throw new OwnerAgentError(errorCode, `Failed to fetch ${url}: ${error.message}.`);
193
+ }
194
+ if (!response.ok) {
195
+ throw new OwnerAgentError(errorCode, `Failed to fetch ${url}: HTTP ${response.status}.`);
196
+ }
197
+ return response.json();
198
+ }
199
+
200
+ async function getJsonOptional(fetchFn, url) {
201
+ let response;
202
+ try {
203
+ response = await fetchFn(url, { headers: { Accept: 'application/json' } });
204
+ } catch {
205
+ return null;
206
+ }
207
+ if (!response.ok) {
208
+ return null;
209
+ }
210
+ try {
211
+ return await response.json();
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * @typedef {object} OwnerAgentOnboardingProfile
219
+ * @property {string} profile
220
+ * @property {boolean} advisory true when discovered from an advisory block
221
+ * @property {string} resource
222
+ * @property {string|null} authorizationServer
223
+ * @property {string|null} deviceAuthorizationEndpoint
224
+ * @property {string|null} tokenEndpoint
225
+ * @property {string|null} introspectionEndpoint
226
+ * @property {string|null} registrationEndpoint
227
+ * @property {string|null} revocationPathTemplate
228
+ * @property {string|null} approvalUrl
229
+ * @property {string|null} schemaEndpoint
230
+ * @property {string|null} schemaCompactEndpoint
231
+ * @property {string|null} streamsEndpoint
232
+ * @property {boolean} mcpRejectsOwnerBearer
233
+ */
@@ -0,0 +1,13 @@
1
+ // Bounded error type for the trusted owner-agent onboarding flow.
2
+ //
3
+ // Carries a stable machine code and a process exit code so callers can map
4
+ // failures to terminal status without parsing free-form messages. Messages
5
+ // MUST NOT contain bearer material.
6
+ export class OwnerAgentError extends Error {
7
+ constructor(code, message, exitCode = 69) {
8
+ super(message);
9
+ this.name = 'OwnerAgentError';
10
+ this.code = code;
11
+ this.exitCode = exitCode;
12
+ }
13
+ }