Skip to content

Commit 5e706e7

Browse files
fix(runtime)!: connector connection door refuses delegated or agent callers on begin and revoke (ADR-0143 D5, ruling A) (#28474)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoRJTSBZchZQBDU9nMzaMGqz)_ Fixes #28468 Clause-②: no (narrowing) Metadata-protocol: none ## 维护者速读 - 改了什么:连接器连接门的「发起连接(含重连)」与「撤销」两步,现在按调用方类型点名拒绝代理调用方(代表某人行事的 AI agent、OAuth 访问令牌、API key),返回 403 `PERMISSION_DENIED`,文案说明规则:连接与撤销是人类管理员本人从登录会话里做的事。 - 为什么改:Tesla 对 batch #435 答「同意」,即裁决 A(#28427 评论 6548764795):连接一个组织的 OAuth 厂商账户决定该组织之后每次运行用谁的授权,ADR-0143 D5 把连接绑定到发起它的会话,只能由人类管理员亲自完成。 - 风险与回滚:这是对代理调用方的收紧(BREAKING,changeset 已标)。人类管理员不受影响:平台管理员、组织管理员权限集、显式授予 `manage_connector_connections` 的人照旧;`isSystem` 照旧放行;读取连接状态与 OAuth 回调两条腿不变。回滚即 revert 本 PR。 - 先看:`packages/runtime/src/domains/automation.ts` 的 `refuseUngrantedConnectorConnectionAccess`(新增按方法的拒绝分支)与 `connectorConnectionDelegatedCaller`;`automation-connector-connection-door.test.ts` 末尾新增的 describe 块。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/automation-connector-connection-door.test.ts`,预期 31 条全绿,其中三类代理调用方在发起与撤销上各得 403。 - 需要你做的:无,席位落地。 ## What changes **The rule (ADR-0143 D5, ruling A, record 6548764795).** The connector connection door's begin leg (`POST /api/v1/automation/connectors/:name/connection`, connect and reconnect) and its revoke leg (`DELETE` on the same path) refuse a delegated or agent caller by name, whatever it holds. Connecting and revoking an organization's connector is a human administrator's act, taken from a signed-in session. **Which callers.** `connectorConnectionDelegatedCaller` names three kinds: - `agent`: an envelope that names a delegator (`principalKind: 'agent'` or `onBehalfOf`), as the shared assembler builds it for an OAuth client acting for a user; - `oauth`: a caller admitted by an OAuth access token (the envelope carries `oauthScopes`); - `api_key`: a caller admitted by an API key (`HttpProtocolContext.admission.credential`). The credential half is read through `apiForwardCredentialKind` (`packages/runtime/src/action-execution.ts`), the api-action forward table's own reading, so the two doors cannot disagree about which credential admitted a caller. A caller with no recorded credential is not named a delegate; the capability gate still judges it, fail-closed. **The refusal.** `403 PERMISSION_DENIED`, the door's existing status and code, with a sentence from `connectorConnectionDelegatedCallerRefusal(kind)` that names the caller's kind and the rule. It runs after the `isSystem` bypass and before the capability read, and ahead of the service probe and the query checks, like the gate it sits in. **Unchanged.** Who holds `manage_connector_connections` (platform administrators, the organization-administrator sets, explicit grants). The system context's bypass. The read leg (`GET …/:name/connection`) and the OAuth callback (`GET …/connectors/oauth/callback`), for every caller. **Docs.** `content/docs/automation/connectors.mdx`, the connect paragraph: connecting and revoking is a human administrator's act from a signed-in session, and an agent or an API key is refused by name. ## Tests New describe block in `packages/runtime/src/domains/automation-connector-connection-door.test.ts`. The envelopes are built by the real `assembleExecutionContext` from `@objectstack/core`, for an organization administrator holding the capability: - the three delegated fixtures are what they say (`connectorConnectionDelegatedCaller` names each, and names nothing for the signed-in holder); - `agent`, `oauth`, `api_key`: refused on begin and on revoke, 403 `PERMISSION_DENIED`, the message equal to `connectorConnectionDelegatedCallerRefusal(kind)` and naming the kind, no service member called; - the same human, signed in: admitted on begin and on revoke, the service handed `tenantId: 'org_a'`; - a system call: admitted on begin and on revoke, even carrying an API-key admission; - `agent`, `oauth`, `api_key`: the read leg and the callback still answer 200 for a holder. File: `Test Files 1 passed (1)`, `Tests 31 passed (31)`. Whole package on `1788eefbb`: runtime `Test Files 494 passed (494)`, `Tests 8048 passed | 19 skipped (8067)`; `pnpm --filter @objectstack/runtime typecheck` exit 0. <details><summary>Ablations (one per refusal branch, plus the whole rule)</summary> Each mutation went through `scripts/ablation-replace.mjs` on `packages/runtime/src/domains/automation.ts`: the anchor landed on disk (count 1 → 0), the file was restored with the blob equal to HEAD and `git diff HEAD` empty. The suite imports its subject from `src`, so no dist was involved. | mutation | red | |:--|:--| | drop the `agent` branch (`principalKind === 'agent' \|\| onBehalfOf`) | 2 / 31: the fixture pin (`expected 'oauth' to be 'agent'`) and the `agent` refusal (the sentence names an OAuth token, not an agent) | | the `oauth` credential no longer named | 2 / 31: the fixture pin and the `oauth` refusal | | the `api_key` credential no longer named | 2 / 31: the fixture pin and the `api_key` refusal | | the refusal never fires (`if (false && delegated)`) | 3 / 31: the three refusal cases | The first attempt at the `oauth` mutation was refused by the tool itself (its replacement text was a substring of the original, so the count could not rise); it was re-run with a distinct replacement, and only that run is reported. </details> ## Acceptance notes - **The agent branch reds on the name, not the status.** The OAuth agent envelope also carries `oauthScopes`, so with the `agent` branch removed it is still refused, named as an OAuth token. The branch is what names an agent envelope that arrives without `oauthScopes`. - **No ADR line owed.** D5 already says a connection is "bound to the initiating session"; this PR enforces that sentence and edits no ADR. - `content/docs/permissions/system-context.mdx` row 57c (the door's `isSystem` bypass) still holds: the bypass reads once in the same function, and the census count is unchanged. - Branch `claude/project-thread-0wkobq` is the card thread's platform-assigned branch; claim 6548795089 names the card thread's branch. ## Verification Local, on `1788eefbb`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 91 families for the 4 changed paths. Each was run with stdin from `/dev/null`, its exit captured by redirect before any pipe: 91 exit 0. `check-plugin-teardown-shape.mjs --self-test` first exited 3 (its pinned fixture commit was not in the shallow clone); after fetching that commit it exited 0. `--ran`: `91 derived famil(ies) accounted for — 91 run, 0 NOT-MEASURED`, exit 0. Scoped lint, the root `lint` script's eslint over `packages/runtime` with `--no-inline-config`: exit 0. `pnpm --filter @objectstack/runtime typecheck`: exit 0. Full dogfood (owed for the narrowing arm), `pnpm --filter @objectstack/dogfood test` under the shared lock on `1788eefbb`: `VERDICT command-exit 0`, `Test Files 362 passed | 3 skipped (365)`, `Tests 2922 passed | 28 skipped (2950)`. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvkQWJban49F52ZjBc)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f79fa74 commit 5e706e7

4 files changed

Lines changed: 202 additions & 4 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime)!: the connector connection door's begin and revoke legs refuse a delegated or agent caller by name; connecting and revoking an organization's connector is a human administrator's act
6+
7+
Clause-②: no (narrowing) — `POST` and `DELETE /api/v1/automation/connectors/:name/connection` refuse a delegated or agent caller by name: one admitted by an API key or an OAuth access token, or carrying an agent envelope (`principalKind: 'agent'`, `onBehalfOf`). Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no metadata an author writes changes meaning: one HTTP door narrows which callers may take two of its steps. The refusal is the door's existing 403 PERMISSION_DENIED with a sentence naming the caller's kind, so there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** caller narrowing at the connector connection door (`@objectstack/runtime`, ADR-0143 D5).
13+
14+
- **The rule.** The begin leg (connect, reconnect) and the revoke leg refuse a delegated or agent caller, whatever it holds, with `403 PERMISSION_DENIED` and a message that names its kind (an agent acting on a person's behalf, an OAuth access token, an API key) and the rule. D5 binds a connection to the session that began it, so connecting and revoking is taken by a person signed in.
15+
- **Unchanged.** Who holds the capability (platform administrators, the organization-administrator sets, explicit grants), the system context's bypass, the read leg (`GET …/:name/connection`) and the OAuth callback.
16+
- **Who sees it.** Any caller on these two steps that is not a person signed in.
17+
18+
**The remedy.** Take the step from a signed-in session (the Console, or a session token), as the administrator who holds the capability.

‎content/docs/automation/connectors.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,10 @@ a rotating refresh token is never spent twice.
535535
organization-scoped `manage_connector_connections` capability. Platform
536536
administrators and organization administrators hold it by default, so an
537537
organization's owners and admins can connect its vendors from the start;
538-
grant it to anyone else through a permission set:
538+
grant it to anyone else through a permission set. Connecting and revoking
539+
is a human administrator's act, taken from a signed-in session: an agent
540+
acting for them (an OAuth client) or an API key is refused by name, whatever
541+
it holds.
539542

540543
```http
541544
POST /api/v1/automation/connectors/:name/connection → { authorizationUrl, expiresAt }

‎packages/runtime/src/domains/automation-connector-connection-door.test.ts‎

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,16 @@
1616
import { describe, it, expect, vi } from 'vitest';
1717

1818
import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security';
19+
import { assembleExecutionContext } from '@objectstack/core';
1920

2021
import { HttpDispatcher } from '../http-dispatcher.js';
2122
import type { HttpProtocolContext } from '../http-dispatcher.js';
22-
import { CONNECTOR_CONNECTION_CAPABILITY, CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS } from './automation.js';
23+
import {
24+
CONNECTOR_CONNECTION_CAPABILITY,
25+
CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS,
26+
connectorConnectionDelegatedCaller,
27+
connectorConnectionDelegatedCallerRefusal,
28+
} from './automation.js';
2329

2430
/** An authorization code as a callback carries it — a dummy. */
2531
const AUTH_CODE = 'dummy-authorization-code';
@@ -255,3 +261,119 @@ describe('ADR-0143 D5 — connector connection door: what it accepts', () => {
255261
}
256262
});
257263
});
264+
265+
// ADR-0143 D5 · ruling A (record 6548764795): connecting, reconnecting and
266+
// revoking an organization's connection is a human administrator's act. The
267+
// begin (`POST`) and revoke (`DELETE`) legs refuse a caller that acts for a
268+
// person instead of being that person signed in, by name, whatever it holds;
269+
// the read leg and the callback are unchanged.
270+
describe('ADR-0143 D5 — connector connection door: begin and revoke refuse a delegated caller by name', () => {
271+
/** The organization administrator's resolved authorization, as core's resolver hands it to the assembler. */
272+
const holderAuthz = {
273+
userId: 'u_orgadmin',
274+
tenantId: 'org_a',
275+
positions: ['organization_admin'],
276+
permissions: [],
277+
directPermissions: [],
278+
systemPermissions: ORG_ADMIN_GRANT,
279+
org_user_ids: ['u_orgadmin'],
280+
accessible_org_ids: ['org_a'],
281+
};
282+
const ENTRY = { localization: undefined, requestLocale: undefined, accessToken: undefined, authGate: undefined } as const;
283+
284+
/** The holder, signed in: a session admitted them. */
285+
const HOLDER_SIGNED_IN = (): HttpProtocolContext => ({
286+
request: {},
287+
admission: { credential: 'session', authGate: undefined },
288+
executionContext: assembleExecutionContext({ authz: holderAuthz, oauth: undefined, ...ENTRY }),
289+
} as HttpProtocolContext);
290+
291+
/**
292+
* An AI agent the holder connected over OAuth with `actions:execute`, as the
293+
* shared assembler builds its envelope: `principalKind: 'agent'`,
294+
* `onBehalfOf` the holder, and the holder's own capability list.
295+
*/
296+
const AGENT_FOR_HOLDER = (): HttpProtocolContext => ({
297+
request: {},
298+
admission: { credential: 'session', authGate: undefined },
299+
executionContext: assembleExecutionContext({
300+
authz: holderAuthz,
301+
oauth: { userId: 'u_orgadmin', scopes: ['openid', 'actions:execute'], clientId: 'client_agent', scopePermissions: [], delegatesActions: true },
302+
...ENTRY,
303+
}),
304+
} as HttpProtocolContext);
305+
306+
/** An OAuth access token that names no client: the principal stays the holder, the credential is still the token. */
307+
const OAUTH_TOKEN_FOR_HOLDER = (): HttpProtocolContext => ({
308+
request: {},
309+
admission: { credential: 'session', authGate: undefined },
310+
executionContext: assembleExecutionContext({
311+
authz: holderAuthz,
312+
oauth: { userId: 'u_orgadmin', scopes: ['openid', 'actions:execute'], scopePermissions: [], delegatesActions: true },
313+
...ENTRY,
314+
}),
315+
} as HttpProtocolContext);
316+
317+
/** An API key the holder minted: it resolves as the holder, admitted by the key. */
318+
const API_KEY_FOR_HOLDER = (): HttpProtocolContext => ({
319+
request: {},
320+
admission: { credential: 'api_key', authGate: undefined },
321+
executionContext: assembleExecutionContext({ authz: holderAuthz, oauth: undefined, ...ENTRY }),
322+
} as HttpProtocolContext);
323+
324+
const DELEGATED = [
325+
['agent', AGENT_FOR_HOLDER, 'an agent acting on a person\'s behalf'],
326+
['oauth', OAUTH_TOKEN_FOR_HOLDER, 'an OAuth access token'],
327+
['api_key', API_KEY_FOR_HOLDER, 'an API key'],
328+
] as const;
329+
330+
it('the fixtures are what they say: each delegated caller holds the capability its human holds', () => {
331+
expect(HOLDER_SIGNED_IN().executionContext).toMatchObject({ principalKind: 'human', systemPermissions: ORG_ADMIN_GRANT });
332+
expect(AGENT_FOR_HOLDER().executionContext).toMatchObject({ principalKind: 'agent', onBehalfOf: { userId: 'u_orgadmin' }, systemPermissions: ORG_ADMIN_GRANT });
333+
expect(OAUTH_TOKEN_FOR_HOLDER().executionContext).toMatchObject({ principalKind: 'human', oauthScopes: ['openid', 'actions:execute'], systemPermissions: ORG_ADMIN_GRANT });
334+
expect(API_KEY_FOR_HOLDER().executionContext).toMatchObject({ principalKind: 'human', systemPermissions: ORG_ADMIN_GRANT });
335+
for (const [kind, ctx] of DELEGATED) expect(connectorConnectionDelegatedCaller(ctx())).toBe(kind);
336+
expect(connectorConnectionDelegatedCaller(HOLDER_SIGNED_IN())).toBeUndefined();
337+
});
338+
339+
it.each(DELEGATED)('%s: refused on begin and on revoke, 403 naming its kind and the rule, and the service is never reached', async (kind, ctx, named) => {
340+
for (const [verb, member] of [['POST', (h: Harness) => h.begin], ['DELETE', (h: Harness) => h.revoke]] as const) {
341+
const h = boot();
342+
const { response } = await connection(h, verb, ctx());
343+
expect(statusOf(response)).toBe(403);
344+
expect(codeOf(response)).toBe('PERMISSION_DENIED');
345+
expect(messageOf(response)).toBe(connectorConnectionDelegatedCallerRefusal(kind));
346+
expect(messageOf(response)).toContain(named);
347+
expect(messageOf(response)).toContain('human administrator');
348+
expect(member(h)).not.toHaveBeenCalled();
349+
for (const m of [h.get, h.begin, h.complete, h.revoke]) expect(m).not.toHaveBeenCalled();
350+
}
351+
});
352+
353+
it('the same human, signed in, is admitted on begin and on revoke', async () => {
354+
const h = boot();
355+
const begun = await connection(h, 'POST', HOLDER_SIGNED_IN());
356+
expect(statusOf(begun.response)).toBe(200);
357+
expect(h.begin).toHaveBeenCalledWith({ connector: 'crm', context: expect.objectContaining({ userId: 'u_orgadmin', tenantId: 'org_a' }) });
358+
const revoked = await connection(h, 'DELETE', HOLDER_SIGNED_IN());
359+
expect(statusOf(revoked.response)).toBe(200);
360+
expect(h.revoke).toHaveBeenCalledTimes(1);
361+
});
362+
363+
it('a system call is admitted on begin and on revoke, even carrying an API-key admission', async () => {
364+
const h = boot();
365+
const sys = { request: {}, admission: { credential: 'api_key', authGate: undefined }, executionContext: { userId: 'system', isSystem: true } } as HttpProtocolContext;
366+
expect(statusOf((await connection(h, 'POST', sys)).response)).toBe(200);
367+
expect(statusOf((await connection(h, 'DELETE', sys)).response)).toBe(200);
368+
expect(h.begin).toHaveBeenCalledTimes(1);
369+
expect(h.revoke).toHaveBeenCalledTimes(1);
370+
});
371+
372+
it.each(DELEGATED)('%s: the read leg and the callback answer as before (admitted for a holder)', async (_kind, ctx) => {
373+
const h = boot();
374+
expect(statusOf((await connection(h, 'GET', ctx())).response)).toBe(200);
375+
expect(statusOf((await callback(h, ctx(), { code: AUTH_CODE, state: 's' })).response)).toBe(200);
376+
expect(h.get).toHaveBeenCalledTimes(1);
377+
expect(h.complete).toHaveBeenCalledTimes(1);
378+
});
379+
});

‎packages/runtime/src/domains/automation.ts‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ import {
5959
// Who may START a flow by name — the one rule the trigger door, the action door
6060
// and the declared endpoint share (the maintainer's ruling, letters B and A).
6161
import { flowStartRefusal } from '../flow-start-admission.js';
62+
// ADR-0143 D5 · ruling A (record 6548764795) — which credential admitted the
63+
// caller, read the way the api-action forward table reads it.
64+
import { apiForwardCredentialKind } from '../action-execution.js';
6265
// [#12156] ADR-0126 §7.1 clone — the whole-definition copy, the keys it must
6366
// not carry forward, the same-name refusal and the references notice.
6467
import {
@@ -943,18 +946,69 @@ export const CONNECTOR_CONNECTION_CAPABILITY = 'manage_connector_connections';
943946
const CONNECTOR_CONNECTION_DENY_MESSAGE =
944947
`Connecting, reconnecting or revoking a connector instance, or reading its connection, requires the \`${CONNECTOR_CONNECTION_CAPABILITY}\` capability.`;
945948

949+
/**
950+
* ADR-0143 D5 — a caller that acts for a person instead of being that person
951+
* signed in, by kind: `agent` (an envelope that names a delegator,
952+
* `principalKind: 'agent'` or `onBehalfOf`), `oauth` (admitted by an OAuth
953+
* access token) or `api_key` (admitted by an API key). The credential half is
954+
* the forward table's own reading ({@link apiForwardCredentialKind}), so the
955+
* two doors cannot disagree about which credential admitted a caller.
956+
*/
957+
export type ConnectorConnectionDelegatedCaller = 'agent' | 'oauth' | 'api_key';
958+
959+
/** The delegated kind of this request's caller, or `undefined` for a person signed in (or no recorded credential). */
960+
export function connectorConnectionDelegatedCaller(context: HttpProtocolContext | undefined): ConnectorConnectionDelegatedCaller | undefined {
961+
const ec: any = context?.executionContext;
962+
if (ec?.principalKind === 'agent' || ec?.onBehalfOf) return 'agent';
963+
const credential = apiForwardCredentialKind(context);
964+
return credential === 'oauth' || credential === 'api_key' ? credential : undefined;
965+
}
966+
967+
const CONNECTOR_CONNECTION_DELEGATED_CALLER_NAMES: Readonly<Record<ConnectorConnectionDelegatedCaller, string>> = {
968+
agent: 'an agent acting on a person\'s behalf',
969+
oauth: 'an OAuth access token, which a client holds to act for a person',
970+
api_key: 'an API key, which acts for its owner without them present',
971+
};
972+
973+
/**
974+
* The refusal sentence for a delegated caller on the begin or revoke leg,
975+
* naming its kind and the rule.
976+
*/
977+
export function connectorConnectionDelegatedCallerRefusal(kind: ConnectorConnectionDelegatedCaller): string {
978+
return (
979+
'Connecting, reconnecting or revoking a connector instance is a human administrator\'s act, taken from a '
980+
+ `signed-in session, and this request comes from ${CONNECTOR_CONNECTION_DELEGATED_CALLER_NAMES[kind]}. `
981+
+ 'Nothing was connected or revoked. Sign in and take this step yourself.'
982+
);
983+
}
984+
946985
/**
947986
* ADR-0143 D5 — the connection door's capability gate:
948987
* {@link CONNECTOR_CONNECTION_CAPABILITY}, fail-closed, only `isSystem`
949988
* bypassing. The callback is gated too: the browser that lands on it is the
950989
* administrator's, carrying the session that began the connect.
990+
*
991+
* Ruling A (record 6548764795): the begin leg (`POST`: connect, reconnect) and
992+
* the revoke leg (`DELETE`) refuse a delegated caller by name
993+
* ({@link connectorConnectionDelegatedCaller}), whatever it holds. D5 binds a
994+
* connection to the session that began it, so connecting and revoking an
995+
* organization's vendor account is a person's act, never an agent's acting for
996+
* them. The read leg and the callback are unchanged.
951997
*/
952998
function refuseUngrantedConnectorConnectionAccess(
953999
deps: DomainHandlerDeps,
9541000
context: HttpProtocolContext,
1001+
method: string,
9551002
): HttpDispatcherResult | undefined {
9561003
const ec: any = context?.executionContext;
9571004
if (ec?.isSystem) return undefined;
1005+
const delegated = method === 'POST' || method === 'DELETE' ? connectorConnectionDelegatedCaller(context) : undefined;
1006+
if (delegated) {
1007+
return {
1008+
handled: true,
1009+
response: deps.error(connectorConnectionDelegatedCallerRefusal(delegated), FLOW_WRITE_DENY_STATUS, { code: FLOW_WRITE_DENY_CODE }),
1010+
};
1011+
}
9581012
if (new Set<string>(ec?.systemPermissions ?? []).has(CONNECTOR_CONNECTION_CAPABILITY)) return undefined;
9591013
return {
9601014
handled: true,
@@ -3096,9 +3150,10 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
30963150
// the organization-scoped capability, ahead of the service probe and of
30973151
// any query check, as above. No walled refusal: a
30983152
// connection belongs to its organization (Q1 = A), so it is served under
3099-
// every tenancy posture.
3153+
// every tenancy posture. Begin and revoke refuse a delegated caller by
3154+
// name (ruling A, record 6548764795).
31003155
if (isConnectorConnectionDoor(parts, m)) {
3101-
const refusal = refuseUngrantedConnectorConnectionAccess(deps, context);
3156+
const refusal = refuseUngrantedConnectorConnectionAccess(deps, context, m);
31023157
if (refusal) return refusal;
31033158
}
31043159

0 commit comments

Comments
 (0)