Skip to content

Commit 9d6f31d

Browse files
committed
test(dogfood): pin the connector write refusal and read redaction at the metadata doors; changeset
Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f7dc55 commit 9d6f31d

2 files changed

Lines changed: 293 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: the connector write door refuses an inline secret on every instance auth kind; the metadata read redacts connector credential positions
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: registered connector-instance-auth-inline-secret-refused -->
11+
12+
**BREAKING** accept-set narrowing on a provider-bound connector instance's `auth`, at every authoring door that parses `DeclarativeConnectorEntrySchema`: `defineStack({ connectors })`, `os validate` / `os compile`, and the metadata save door `PUT /api/v1/meta/connector/:name`.
13+
14+
- **Before.** The four static instance-auth kinds (`none`, `bearer`, `api-key`, `basic`) were plain objects: an undeclared key was dropped at parse and the entry still validated. The three ADR-0143 kinds (`oauth2`, `app-credential`, `signed`) already refused one.
15+
- **Now.** All seven kinds refuse an undeclared key with a located error at `auth` (`unrecognized_keys`; at the save door `422 INVALID_METADATA`, the envelope an inline `authentication` already met there). An inline secret — `token` on `bearer`, `key` on `api-key`, `password` on `basic`, and their usual spellings — meets the `credentialRef` prescription; `none` names no credential and says so. The refusal ADR-0143 D3 records for every variant now holds for the static kinds too.
16+
- **Read half (additive).** `connector` joins `datasource` as a built-in of the metadata-type redactor registry (`getMetadataTypeRedactor('connector')`), so every read exit that serves a stored body through that registry — the `/meta` item, list and layered reads and the metadata protocol's reads among them — withholds a stored connector's credential positions: every `auth` key its kind does not declare, every key of a runtime `authentication` block but `type`, and the provider header and environment maps (`providerConfig.headers`, `providerConfig.defaultHeaders`, `providerConfig.transport.headers`, `providerConfig.transport.env`), each map whole. A save of the served form keeps the stored values (the metadata protocol's carry-forward), and the runtime keeps reading the unredacted execution view, so a materialized connector still sends its headers.
17+
- **Unchanged.** A static kind resolves its secret from `credentialRef` alone at materialization, as before; nothing ever read an undeclared key, so no connector's behaviour moves.
18+
19+
## FROM → TO
20+
21+
```ts
22+
// before — parsed green; the inline value was never used
23+
auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN', token: '…' },
24+
25+
// after — refused at `auth` with the credentialRef prescription; keep the reference only
26+
auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN' },
27+
```
28+
29+
Set the secret under the name `credentialRef` gives — an environment variable in the open tier, or Setup → Connector credentials — and delete the inline key. There is deliberately no automatic rewrite: the inline value may be the only copy of the secret its author holds. The upgrade guide entry is `connector-instance-auth-inline-secret-refused`.
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Connector credentials at the metadata doors, booted on the showcase with its
4+
// own connector plugins:
5+
//
6+
// - WRITE: the connector save door refuses an inline secret on an instance's
7+
// `auth` — every static kind (`bearer`, `api-key`, `basic`, `none`) — with
8+
// the same `422 INVALID_METADATA` envelope the publish door gives an inline
9+
// `authentication`, located at `auth`, and stores nothing.
10+
// - CONTROL: an instance that names its secret with `credentialRef` saves,
11+
// materializes, and dispatches upstream with the reference resolved and its
12+
// default headers sent — including after a save of its served form.
13+
// - READ: a stored row holding inline credential positions (seeded through
14+
// the system context, the shape a row stored before the write refusal can
15+
// have) is served with those positions withheld, to a member and to an
16+
// administrator, by name, in the list and in the layered view, and through
17+
// the metadata protocol's reads; every other field is served as stored, and
18+
// the stored row itself is never redacted.
19+
//
20+
// Every credential below is an obviously-fake placeholder, and every assertion
21+
// on one is its absence from what a door served.
22+
23+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
24+
import { createServer, type IncomingHttpHeaders, type Server } from 'node:http';
25+
import type { AddressInfo } from 'node:net';
26+
import { type VerifyStack } from '@objectstack/verify';
27+
import { bootShowcase } from './showcase-boot.js';
28+
29+
const SYSTEM = { isSystem: true, positions: [], permissions: [] };
30+
31+
const PLACEHOLDERS = {
32+
inlineToken: 'TEST_PLACEHOLDER_INLINE_TOKEN',
33+
inlineKey: 'TEST_PLACEHOLDER_INLINE_KEY',
34+
inlinePassword: 'TEST_PLACEHOLDER_INLINE_PASSWORD',
35+
headerValue: 'TEST_PLACEHOLDER_HEADER_VALUE',
36+
defaultHeaderValue: 'TEST_PLACEHOLDER_DEFAULT_HEADER_VALUE',
37+
runtimeToken: 'TEST_PLACEHOLDER_RUNTIME_TOKEN',
38+
resolvedRef: 'TEST_PLACEHOLDER_RESOLVED_REFERENCE',
39+
} as const;
40+
const ALL_PLACEHOLDERS = Object.values(PLACEHOLDERS);
41+
42+
/** The reference the CONTROL instance names; set in this process's environment before it is saved. */
43+
const CONTROL_REF = 'CCH_CONTROL_CREDENTIAL_REF';
44+
const CONTROL = 'cch_control';
45+
const STORED = 'cch_stored';
46+
const REFUSED = 'cch_refused';
47+
48+
const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : Array.isArray(r?.value) ? r.value : []);
49+
const bodyOf = (row: any): Record<string, any> =>
50+
typeof row?.metadata === 'string' ? JSON.parse(row.metadata) : (row?.metadata ?? {});
51+
const itemOf = (json: any): Record<string, any> => (json?.item ?? json?.data?.item ?? json?.data ?? json) as Record<string, any>;
52+
const holdsAPlaceholder = (v: unknown): boolean => {
53+
const text = typeof v === 'string' ? v : JSON.stringify(v ?? null);
54+
return ALL_PLACEHOLDERS.some((p) => text.includes(p));
55+
};
56+
57+
describe('connector metadata doors: inline credentials are refused on write and withheld on read', () => {
58+
let stack: VerifyStack;
59+
let admin: string;
60+
let member: string;
61+
let ql: any;
62+
let protocol: any;
63+
let automation: any;
64+
let upstream: Server;
65+
let upstreamUrl: string;
66+
const received: IncomingHttpHeaders[] = [];
67+
let previousRef: string | undefined;
68+
69+
const stored = async (name: string) =>
70+
rowsOf(await ql.find('sys_metadata', { where: { type: 'connector', name }, context: SYSTEM }));
71+
72+
const instance = (name: string, over: Record<string, unknown> = {}) => ({
73+
name,
74+
label: `CCH ${name}`,
75+
type: 'api',
76+
description: 'A provider-bound instance for the connector credential pins.',
77+
provider: 'rest',
78+
providerConfig: { baseUrl: upstreamUrl },
79+
auth: { type: 'bearer', credentialRef: CONTROL_REF },
80+
...over,
81+
});
82+
83+
/** One door read: 200, names the connector (positive control), and serves no placeholder. */
84+
const expectWithheld = async (token: string, path: string, names: readonly string[]) => {
85+
const res = await stack.apiAs(token, 'GET', path);
86+
const text = await res.text();
87+
expect(res.status, `${path}: ${text.slice(0, 200)}`).toBe(200);
88+
for (const name of names) expect(text.includes(name), `${path} names ${name}`).toBe(true);
89+
expect(holdsAPlaceholder(text), `${path} withholds every credential position`).toBe(false);
90+
return text;
91+
};
92+
93+
const dispatch = async (name: string): Promise<IncomingHttpHeaders> => {
94+
const handler = automation.resolveConnectorAction(name, 'request');
95+
expect(handler, `${name} is materialized with its request action`).toBeTypeOf('function');
96+
const before = received.length;
97+
const out = await handler({ method: 'GET', path: '/probe' }, {});
98+
expect(out?.status, JSON.stringify(out)).toBe(200);
99+
expect(received.length).toBe(before + 1);
100+
return received[received.length - 1]!;
101+
};
102+
103+
const waitReady = async (name: string): Promise<void> => {
104+
const deadline = Date.now() + 15_000;
105+
let last = '';
106+
while (Date.now() < deadline) {
107+
const res = await stack.apiAs(admin, 'GET', '/automation/connectors');
108+
const json = (await res.json()) as any;
109+
const found = (json?.data?.connectors ?? []).find((c: any) => c?.name === name);
110+
last = JSON.stringify(found ?? null);
111+
if (found?.state === 'ready') return;
112+
await new Promise((r) => setTimeout(r, 100));
113+
}
114+
throw new Error(`connector ${name} never reached ready: ${last}`);
115+
};
116+
117+
beforeAll(async () => {
118+
upstream = createServer((req, res) => {
119+
received.push(req.headers);
120+
res.writeHead(200, { 'Content-Type': 'application/json' });
121+
res.end('{"ok":true}');
122+
});
123+
await new Promise<void>((r) => upstream.listen(0, '127.0.0.1', () => r()));
124+
upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
125+
previousRef = process.env[CONTROL_REF];
126+
process.env[CONTROL_REF] = PLACEHOLDERS.resolvedRef;
127+
128+
stack = await bootShowcase({});
129+
ql = await stack.kernel.getServiceAsync<any>('objectql');
130+
protocol = await stack.kernel.getServiceAsync<any>('protocol');
131+
automation = await stack.kernel.getServiceAsync<any>('automation');
132+
admin = await stack.signIn();
133+
member = await stack.signUp('cch-member@connector-credentials.test');
134+
}, 180_000);
135+
136+
afterAll(async () => {
137+
await stack?.stop?.();
138+
await new Promise<void>((r) => (upstream ? upstream.close(() => r()) : r()));
139+
if (previousRef === undefined) delete process.env[CONTROL_REF];
140+
else process.env[CONTROL_REF] = previousRef;
141+
});
142+
143+
describe('WRITE: the save door refuses an inline secret on an instance\'s auth', () => {
144+
it.each([
145+
['bearer', { type: 'bearer', credentialRef: CONTROL_REF, token: PLACEHOLDERS.inlineToken }, 'token'],
146+
['api-key', { type: 'api-key', credentialRef: CONTROL_REF, key: PLACEHOLDERS.inlineKey }, 'key'],
147+
['basic', { type: 'basic', username: 'svc', credentialRef: CONTROL_REF, password: PLACEHOLDERS.inlinePassword }, 'password'],
148+
['none', { type: 'none', token: PLACEHOLDERS.inlineToken }, 'token'],
149+
] as const)('%s: 422 INVALID_METADATA located at `auth`, naming the key, and nothing is stored', async (_kind, auth, key) => {
150+
const res = await stack.apiAs(admin, 'PUT', `/meta/connector/${REFUSED}`, instance(REFUSED, { auth }));
151+
const json = (await res.json()) as any;
152+
expect(res.status, JSON.stringify(json).slice(0, 400)).toBe(422);
153+
expect(json.code, JSON.stringify(json).slice(0, 400)).toBe('INVALID_METADATA');
154+
const issues = (json.issues ?? json.error?.issues ?? json.details?.issues ?? []) as Array<{ path?: unknown; message?: string }>;
155+
const located = issues.find((i) => i.path === 'auth' || (Array.isArray(i.path) && i.path.join('.') === 'auth'));
156+
expect(located, JSON.stringify(issues).slice(0, 400)).toBeDefined();
157+
expect(located!.message).toContain(`\`${key}\``);
158+
expect(holdsAPlaceholder(json), 'the refusal never echoes the value').toBe(false);
159+
expect(await stored(REFUSED)).toHaveLength(0);
160+
});
161+
});
162+
163+
describe('CONTROL: a credentialRef-only instance saves and works', () => {
164+
it('saves, materializes ready, and dispatches with the reference resolved and its default headers sent', async () => {
165+
const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${CONTROL}`, instance(CONTROL, {
166+
providerConfig: { baseUrl: upstreamUrl, defaultHeaders: { 'X-Placeholder-Header': PLACEHOLDERS.defaultHeaderValue } },
167+
}));
168+
expect(put.status, await put.clone().text()).toBe(200);
169+
await waitReady(CONTROL);
170+
const headers = await dispatch(CONTROL);
171+
expect(headers.authorization).toBe(`Bearer ${PLACEHOLDERS.resolvedRef}`);
172+
expect(headers['x-placeholder-header']).toBe(PLACEHOLDERS.defaultHeaderValue);
173+
});
174+
175+
it('its read withholds the header map; a save of the served form keeps it, and dispatch still sends it', async () => {
176+
const read = await stack.apiAs(admin, 'GET', `/meta/connector/${CONTROL}`);
177+
expect(read.status).toBe(200);
178+
const served = itemOf(await read.json());
179+
expect(served.name).toBe(CONTROL);
180+
expect(served.providerConfig).toEqual({ baseUrl: upstreamUrl });
181+
expect(served.auth).toEqual({ type: 'bearer', credentialRef: CONTROL_REF });
182+
183+
const body = Object.fromEntries(Object.entries(served).filter(([k]) => !k.startsWith('_')));
184+
const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${CONTROL}`, { ...body, label: 'CCH control (edited)' });
185+
expect(put.status, await put.clone().text()).toBe(200);
186+
const [row] = await stored(CONTROL);
187+
expect(bodyOf(row).label).toBe('CCH control (edited)');
188+
expect(bodyOf(row).providerConfig.defaultHeaders).toEqual({ 'X-Placeholder-Header': PLACEHOLDERS.defaultHeaderValue });
189+
190+
await waitReady(CONTROL);
191+
expect((await dispatch(CONTROL))['x-placeholder-header']).toBe(PLACEHOLDERS.defaultHeaderValue);
192+
});
193+
});
194+
195+
describe('READ: a stored row\'s inline credential positions are withheld from every reader', () => {
196+
const seeded = () => ({
197+
...instance(STORED, { label: 'CCH stored (seeded)' }),
198+
providerConfig: {
199+
baseUrl: upstreamUrl,
200+
headers: { Authorization: `Bearer ${PLACEHOLDERS.headerValue}` },
201+
defaultHeaders: { Authorization: `Bearer ${PLACEHOLDERS.defaultHeaderValue}` },
202+
},
203+
auth: { type: 'bearer', credentialRef: 'CCH_STORED_REF', token: PLACEHOLDERS.inlineToken },
204+
authentication: { type: 'bearer', token: PLACEHOLDERS.runtimeToken },
205+
});
206+
207+
beforeAll(async () => {
208+
// A clean save makes the row; the system context then writes the body a
209+
// row stored before the write refusal can hold.
210+
const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${STORED}`, instance(STORED, {
211+
auth: { type: 'bearer', credentialRef: 'CCH_STORED_REF' },
212+
}));
213+
expect(put.status, await put.clone().text()).toBe(200);
214+
const [row] = await stored(STORED);
215+
await ql.update('sys_metadata', { id: row.id, metadata: JSON.stringify(seeded()) }, { context: SYSTEM });
216+
});
217+
218+
it('guard the guard: the stored row holds every seeded credential position', async () => {
219+
const [row] = await stored(STORED);
220+
const body = bodyOf(row);
221+
expect(body.label).toBe('CCH stored (seeded)');
222+
expect(body.auth.token).toBe(PLACEHOLDERS.inlineToken);
223+
expect(body.authentication.token).toBe(PLACEHOLDERS.runtimeToken);
224+
expect(body.providerConfig.headers.Authorization).toContain(PLACEHOLDERS.headerValue);
225+
expect(body.providerConfig.defaultHeaders.Authorization).toContain(PLACEHOLDERS.defaultHeaderValue);
226+
});
227+
228+
it('the item read and the layered view withhold every position, for a member and for an administrator', async () => {
229+
for (const token of [member, admin]) {
230+
const text = await expectWithheld(token, `/meta/connector/${STORED}`, [STORED, 'CCH stored (seeded)']);
231+
const served = itemOf(JSON.parse(text));
232+
expect(served.auth).toEqual({ type: 'bearer', credentialRef: 'CCH_STORED_REF' });
233+
expect(served.authentication).toEqual({ type: 'bearer' });
234+
expect(served.providerConfig).toEqual({ baseUrl: upstreamUrl });
235+
expect(served.description).toBe('A provider-bound instance for the connector credential pins.');
236+
expect(served.provider).toBe('rest');
237+
expect(served.type).toBe('api');
238+
await expectWithheld(token, `/meta/connector/${STORED}/layers`, [STORED]);
239+
}
240+
});
241+
242+
it('the list, both spellings, withholds every position, for a member and for an administrator', async () => {
243+
for (const token of [member, admin]) {
244+
await expectWithheld(token, '/meta/connector', [STORED, CONTROL]);
245+
await expectWithheld(token, '/meta/connectors', [STORED, CONTROL]);
246+
}
247+
});
248+
249+
it('the metadata protocol\'s list and item reads withhold every position', async () => {
250+
const listed = await protocol.getMetaItems({ type: 'connector' });
251+
expect(JSON.stringify(listed).includes(STORED)).toBe(true);
252+
expect(holdsAPlaceholder(listed)).toBe(false);
253+
const item = await protocol.getMetaItem({ type: 'connector', name: STORED });
254+
expect(JSON.stringify(item).includes('CCH stored (seeded)')).toBe(true);
255+
expect(holdsAPlaceholder(item)).toBe(false);
256+
});
257+
258+
it('the stored row is never redacted: redaction is a serving act', async () => {
259+
const [row] = await stored(STORED);
260+
expect(bodyOf(row).auth.token).toBe(PLACEHOLDERS.inlineToken);
261+
expect(bodyOf(row).providerConfig.defaultHeaders.Authorization).toContain(PLACEHOLDERS.defaultHeaderValue);
262+
});
263+
});
264+
});

0 commit comments

Comments
 (0)